1
0
Fork 0
mirror of https://github.com/EDeev/vkrepost_to_tg.git synced 2026-10-07 20:49:50 +03:00

Compare commits

...

2 commits
v2.0.0 ... main

Author SHA1 Message Date
0939585083 Образы на git.deev.su вместо dcr.deev.su: реестр zot выведен из работы, копию из GHCR делает синхронизатор 2026-10-06 17:04:15 +03:00
688ce4fcf1 Хранилище — PostgreSQL вместо двух файлов SQLite
code/sql.py на psycopg 3 с пулом соединений; таблицы users, pages, subscribers, page_state
(user и group в PostgreSQL зарезервированы), внутренние номера сохраняются. Счётчики подписок
меняются одним запросом. scripts/migrate_sqlite.py переносит старые базы со сверкой (проверено
на копии рабочих: 7 пользователей, 10 страниц). Тесты на PostgreSQL в CI; compose — с базой.
2026-10-06 11:49:28 +00:00
14 changed files with 286 additions and 154 deletions

View file

@ -2,3 +2,5 @@
BOT_TOKEN=123456:your-token BOT_TOKEN=123456:your-token
# Сервисный ключ приложения VK (vk.com/apps?act=manage → приложение → Настройки) # Сервисный ключ приложения VK (vk.com/apps?act=manage → приложение → Настройки)
VK_SERVICE_TOKEN=your-vk-service-key VK_SERVICE_TOKEN=your-vk-service-key
# База PostgreSQL (в compose.yaml задаётся автоматически)
DATABASE_URL=postgresql://vkport:vkport@localhost:5432/vkport

View file

@ -8,16 +8,28 @@ on:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: vkport_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/vkport_test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install -r requirements-dev.txt - run: pip install -r requirements-dev.txt
- run: ruff check --select E9,F code tests - run: ruff check --select E9,F code tests scripts
- run: pytest -q - run: pytest -q
- name: Бот импортируется - name: Бот импортируется
working-directory: code working-directory: code
env: env:
BOT_TOKEN: "123456:TEST" BOT_TOKEN: "123456:TEST"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/vkport_test
run: python -c "import bot" run: python -c "import bot"

View file

@ -19,17 +19,11 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta - id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
ghcr.io/edeev/vkrepost_to_tg ghcr.io/edeev/vkrepost_to_tg
dcr.deev.su/edeev/vkrepost_to_tg
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}

1
.gitignore vendored
View file

@ -2,5 +2,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
db/*.db
*.log *.log

View file

@ -8,12 +8,8 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY code/ code/ COPY code/ code/
RUN useradd --create-home --uid 1000 app \ RUN useradd --create-home --uid 1000 app && chown -R app:app /app
&& mkdir -p db \
&& chown -R app:app /app
USER app USER app
VOLUME ["/app/db"]
# пути к базам в коде — относительно папки code/ (../db)
WORKDIR /app/code WORKDIR /app/code
CMD ["python", "bot.py"] CMD ["python", "bot.py"]

View file

@ -12,7 +12,7 @@ audio, polls and reposts. The bot speaks Russian.
**Status:** personal project, completed · bot [@vkportalbot](https://t.me/vkportalbot) **Status:** personal project, completed · bot [@vkportalbot](https://t.me/vkportalbot)
**Stack:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker **Stack:** Python 3.12 · aiogram 3 · vk_api · PostgreSQL · Docker
## Features ## Features
@ -40,11 +40,12 @@ cp .env.example .env # BOT_TOKEN and the VK app service key
docker compose up -d docker compose up -d
``` ```
Prebuilt image: `docker pull ghcr.io/edeev/vkrepost_to_tg` or `docker pull dcr.deev.su/edeev/vkrepost_to_tg`. Prebuilt image: `docker pull ghcr.io/edeev/vkrepost_to_tg` or `docker pull git.deev.su/edeev/vkrepost_to_tg`.
The SQLite databases are created on first start. PostgreSQL tables are created on first start. Data from the old version (SQLite `users.db` and `base.db`)
is moved by `python scripts/migrate_sqlite.py --sqlite-dir path/to/db --dsn postgresql://…`.
Without Docker: Python 3.12, `pip install -r requirements.txt`, then Without Docker: Python 3.12, `pip install -r requirements.txt`, then
`cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`. `cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… DATABASE_URL=postgresql://… python bot.py` (needs PostgreSQL).
## How it works ## How it works
@ -53,7 +54,8 @@ code/bot.py entry point and background polling of subscriptions every m
code/handlers.py commands: subscriptions, latest post, likes, VK token code/handlers.py commands: subscriptions, latest post, likes, VK token
code/vk_scripts.py VK API requests and post parsing code/vk_scripts.py VK API requests and post parsing
code/scripts.py post → HTML text and Telegram media, sending within Telegram limits code/scripts.py post → HTML text and Telegram media, sending within Telegram limits
code/sql.py users, subscriptions and each page's latest post (SQLite) code/sql.py users, subscriptions and each page's latest post (PostgreSQL)
scripts/ migration from SQLite
``` ```
For a closed page the bot uses the token of one of its subscribers; for open pages, the service key. VK For a closed page the bot uses the token of one of its subscribers; for open pages, the service key. VK
@ -68,7 +70,7 @@ ruff check --select E9,F code tests && pytest
The tests cover post parsing (escaping, mentions, links, reposts, album limit) and sending (single photo, The tests cover post parsing (escaping, mentions, links, reposts, album limit) and sending (single photo,
long text, audio). The Docker image is built on `v*` tags and published to GitHub Packages and long text, audio). The Docker image is built on `v*` tags and published to GitHub Packages and
`dcr.deev.su`. `git.deev.su`.
## License ## License

View file

@ -12,7 +12,7 @@ Telegram-бот, который пересылает новые посты со
**Статус:** личный проект, завершён · бот [@vkportalbot](https://t.me/vkportalbot) **Статус:** личный проект, завершён · бот [@vkportalbot](https://t.me/vkportalbot)
**Стек:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker **Стек:** Python 3.12 · aiogram 3 · vk_api · PostgreSQL · Docker
## Возможности ## Возможности
@ -41,11 +41,12 @@ cp .env.example .env # BOT_TOKEN и сервисный ключ прило
docker compose up -d docker compose up -d
``` ```
Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull dcr.deev.su/edeev/vkrepost_to_tg`. Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull git.deev.su/edeev/vkrepost_to_tg`.
Базы SQLite создаются при первом запуске. Таблицы в PostgreSQL создаются при первом запуске. Данные старой версии (SQLite `users.db` и `base.db`)
переносит `python scripts/migrate_sqlite.py --sqlite-dir путь/к/db --dsn postgresql://…`.
Без Docker: Python 3.12, `pip install -r requirements.txt`, затем Без Docker: Python 3.12, `pip install -r requirements.txt`, затем
`cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`. `cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… DATABASE_URL=postgresql://… python bot.py` (нужен PostgreSQL).
## Как устроено ## Как устроено
@ -54,7 +55,8 @@ code/bot.py запуск и фоновый опрос подписок
code/handlers.py команды: подписки, последний пост, лайки, токен VK code/handlers.py команды: подписки, последний пост, лайки, токен VK
code/vk_scripts.py запросы к VK API и разбор поста code/vk_scripts.py запросы к VK API и разбор поста
code/scripts.py пост → текст в HTML и медиа Telegram, отправка с учётом ограничений code/scripts.py пост → текст в HTML и медиа Telegram, отправка с учётом ограничений
code/sql.py пользователи, подписки и последний пост каждой страницы (SQLite) code/sql.py пользователи, подписки и последний пост каждой страницы (PostgreSQL)
scripts/ перенос данных из SQLite
``` ```
Для закрытой страницы бот берёт токен одного из её подписчиков, для открытых — сервисный ключ. Запросы Для закрытой страницы бот берёт токен одного из её подписчиков, для открытых — сервисный ключ. Запросы
@ -69,7 +71,7 @@ ruff check --select E9,F code tests && pytest
Тесты проверяют разбор постов (экранирование, упоминания, ссылки, репосты, лимит альбома) и отправку Тесты проверяют разбор постов (экранирование, упоминания, ссылки, репосты, лимит альбома) и отправку
(одиночное фото, длинный текст, аудио). Docker-образ собирается по тегу `v*` и публикуется в GitHub (одиночное фото, длинный текст, аудио). Docker-образ собирается по тегу `v*` и публикуется в GitHub
Packages и `dcr.deev.su`. Packages и `git.deev.su`.
## Лицензия ## Лицензия

View file

@ -7,14 +7,14 @@ from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties from aiogram.client.bot import DefaultBotProperties
from config import botToken from config import botToken
from sql import Users, Base from sql import Users, Base, connect
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
os.makedirs("../db", exist_ok=True) pool = connect(os.getenv("DATABASE_URL", "postgresql://vkport:vkport@localhost:5432/vkport"))
du = Users('../db/users.db') du = Users(pool)
db = Base('../db/base.db') db = Base(pool)
# лог в файл, если задан LOG_FILE (так бот работал на сервере), иначе — в консоль # лог в файл, если задан LOG_FILE (так бот работал на сервере), иначе — в консоль
logging.basicConfig(level=logging.INFO, filename=os.getenv("LOG_FILE") or None, logging.basicConfig(level=logging.INFO, filename=os.getenv("LOG_FILE") or None,

View file

@ -1,179 +1,150 @@
import sqlite3 """Хранилище бота — PostgreSQL (до 2026-10 — два файла SQLite users.db и base.db).
users и pages хранят короткие внутренние номера пользователей Telegram и страниц ВК; subscribers и
page_state ссылаются на эти номера, как раньше таблицы user и group в base.db (в PostgreSQL имена
user и group зарезервированы)."""
from psycopg_pool import ConnectionPool
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS pages (
id SERIAL PRIMARY KEY,
vk_id BIGINT NOT NULL UNIQUE -- у сообществ отрицательный
);
CREATE TABLE IF NOT EXISTS subscribers (
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
status BOOLEAN NOT NULL DEFAULT TRUE, -- получает ли посты
count INTEGER NOT NULL DEFAULT 0, -- число подписок
groups TEXT, -- номера страниц через «;»
token TEXT -- токен VK пользователя (необязательный)
);
CREATE TABLE IF NOT EXISTS page_state (
page_id INTEGER PRIMARY KEY REFERENCES pages (id) ON DELETE CASCADE,
type BOOLEAN NOT NULL, -- TRUE — сообщество, FALSE — личная страница
count INTEGER NOT NULL DEFAULT 0, -- сколько подписчиков ждут посты
last_post BIGINT NOT NULL
);
"""
class Users: def connect(dsn):
def __init__(self, database): """Пул соединений сам переподключается, если PostgreSQL перезапускали"""
"""Подключаемся к БД и сохраняем курсор соединения""" pool = ConnectionPool(dsn, min_size=1, max_size=4, kwargs={"autocommit": True}, open=True)
self.connection = sqlite3.connect(database) with pool.connection() as conn:
self.cursor = self.connection.cursor() conn.execute(SCHEMA)
return pool
# на новой установке (например, в Docker с пустым томом) таблиц ещё нет
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS \"group\" (id INTEGER PRIMARY KEY, group_id INTEGER NOT NULL)")
# КОМАНДЫ USER class _Base:
def __init__(self, pool):
self.pool = pool
def _one(self, query, args=()):
with self.pool.connection() as conn:
row = conn.execute(query, args).fetchone()
return row[0] if row else None
def _all(self, query, args=()):
with self.pool.connection() as conn:
return conn.execute(query, args).fetchall()
def _run(self, query, args=()):
with self.pool.connection() as conn:
conn.execute(query, args)
class Users(_Base):
# ПОЛЬЗОВАТЕЛИ TELEGRAM
def user_exists(self, user_id): def user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе""" return bool(self._one("SELECT 1 FROM users WHERE tg_id = %s", (user_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def add_user(self, user_id): def add_user(self, user_id):
"""Добавляем нового пользователя""" self._run("INSERT INTO users (tg_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,))
with self.connection:
return self.cursor.execute("INSERT INTO `user` (`user_id`) VALUES(?)", (user_id,))
def get_user_id(self, user_id): def get_user_id(self, user_id):
"""Получаем короткое айди юзера""" """Внутренний номер по id Telegram"""
with self.connection: return self._one("SELECT id FROM users WHERE tg_id = %s", (user_id,))
return self.cursor.execute('SELECT `id` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_tg_id(self, user_id): def get_tg_id(self, user_id):
"""Получаем длинное айди юзера""" """id Telegram по внутреннему номеру"""
with self.connection: return self._one("SELECT tg_id FROM users WHERE id = %s", (user_id,))
return self.cursor.execute('SELECT `user_id` FROM `user` WHERE `id` = ?', (user_id,)).fetchone()[0]
# КОМАНДЫ GROUP # СТРАНИЦЫ ВК
def group_exists(self, group_id): def group_exists(self, group_id):
"""Проверяем, есть ли уже группа в базе""" return bool(self._one("SELECT 1 FROM pages WHERE vk_id = %s", (group_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `group` WHERE `group_id` = ?', (group_id,)).fetchall()
return bool(len(result))
def add_group(self, group_id): def add_group(self, group_id):
"""Добавляем новую группу""" self._run("INSERT INTO pages (vk_id) VALUES (%s) ON CONFLICT DO NOTHING", (group_id,))
with self.connection:
return self.cursor.execute("INSERT INTO `group` (`group_id`) VALUES(?)", (group_id,))
def get_group_id(self, group_id): def get_group_id(self, group_id):
"""Получаем короткое айди группы""" """Внутренний номер страницы по id ВК"""
with self.connection: return self._one("SELECT id FROM pages WHERE vk_id = %s", (group_id,))
return self.cursor.execute('SELECT `id` FROM `group` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
def get_vk_id(self, group_id): def get_vk_id(self, group_id):
"""Получаем длинное айди группы""" """id ВК по внутреннему номеру"""
with self.connection: return self._one("SELECT vk_id FROM pages WHERE id = %s", (group_id,))
return self.cursor.execute('SELECT `group_id` FROM `group` WHERE `id` = ?', (group_id,)).fetchone()[0]
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class Base: class Base(_Base):
def __init__(self, database): # ПОДПИСЧИКИ
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS user (user_id INTEGER NOT NULL, status BOOLEAN NOT NULL "
"DEFAULT (True), count INTEGER NOT NULL DEFAULT (0), groups TEXT, token TEXT)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS \"group\" (group_id INTEGER NOT NULL, type BOOLEAN NOT NULL, "
"count INTEGER NOT NULL DEFAULT (0), last_post INTEGER NOT NULL)")
# КОМАНДЫ USER
def infoUser_exists(self, user_id): def infoUser_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" return bool(self._one("SELECT 1 FROM subscribers WHERE user_id = %s", (user_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def add_infoUser(self, user_id): def add_infoUser(self, user_id):
"""Добавляем информацию о пользователе""" self._run("INSERT INTO subscribers (user_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,))
with self.connection:
return self.cursor.execute("INSERT INTO `user` (`user_id`) VALUES(?)", (user_id, ))
def all_activUser(self): def all_activUser(self):
"""Список активных пользователей""" """Подписчики со своим токеном VK, которые получают посты"""
with self.connection: return self._all("SELECT user_id, groups FROM subscribers WHERE status AND count > 0 AND token IS NOT NULL")
return self.cursor.execute('SELECT `user_id`, `groups` FROM `user` WHERE `status` = 1 AND `count` > 0 '
'AND `token` NOT NULL').fetchall()
def all_subUser(self): def all_subUser(self):
"""Список пользователей подписанных на группу""" """Все подписчики, которые получают посты"""
with self.connection: return self._all("SELECT user_id, groups FROM subscribers WHERE status AND count > 0")
return self.cursor.execute('SELECT `user_id`, `groups` FROM `user` WHERE `status` = 1 AND `count` > 0').fetchall()
def get_status(self, user_id): def get_status(self, user_id):
"""Получаем статус рассылки уведомлений""" return self._one("SELECT status FROM subscribers WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `status` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def update_status(self, user_id): def update_status(self, user_id):
"""Обновляем статус рассылки уведомлений""" self._run("UPDATE subscribers SET status = NOT status WHERE user_id = %s", (user_id,))
with self.connection:
status = self.cursor.execute('SELECT `status` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `user` SET `status` = ? WHERE `user_id` = ?", (not status, user_id))
def get_user_groups(self, user_id): def get_user_groups(self, user_id):
"""Получаем группы на которые подписан пользователь""" return self._one("SELECT groups FROM subscribers WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `groups` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def update_user_groups(self, user_id, groups): def update_user_groups(self, user_id, groups):
"""Обновляем группы на которые подписан пользователь""" self._run("UPDATE subscribers SET groups = %s WHERE user_id = %s", (groups, user_id))
with self.connection:
return self.cursor.execute("UPDATE `user` SET `groups` = ? WHERE `user_id` = ?", (groups, user_id))
def get_token(self, user_id): def get_token(self, user_id):
"""Получаем access token пользователя""" return self._one("SELECT token FROM subscribers WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `token` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def update_token(self, user_id, token): def update_token(self, user_id, token):
"""Обновляем access token пользователя""" self._run("UPDATE subscribers SET token = %s WHERE user_id = %s", (token, user_id))
with self.connection:
return self.cursor.execute("UPDATE `user` SET `token` = ? WHERE `user_id` = ?", (token, user_id))
def get_countUser(self, user_id): def get_countUser(self, user_id):
"""Получаем количество подписок пользователя""" return self._one("SELECT count FROM subscribers WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `count` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def update_countUser(self, user_id, num): def update_countUser(self, user_id, num):
"""Обновляем количество подписок пользователя""" self._run("UPDATE subscribers SET count = count + %s WHERE user_id = %s", (num, user_id))
with self.connection:
count = self.cursor.execute('SELECT `count` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `user` SET `count` = ? WHERE `user_id` = ?", (count + num, user_id))
# КОМАНДЫ GROUP # СТРАНИЦЫ
def infoGroup_exists(self, group_id): def infoGroup_exists(self, group_id):
"""Проверяем, есть ли данные уже в базе""" return bool(self._one("SELECT 1 FROM page_state WHERE page_id = %s", (group_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `group` WHERE `group_id` = ?', (group_id,)).fetchall()
return bool(len(result))
def add_infoGroup(self, group_id, tp, last_post): def add_infoGroup(self, group_id, tp, last_post):
"""Добавляем информацию о группе""" self._run("INSERT INTO page_state (page_id, type, last_post) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
with self.connection: (group_id, bool(tp), last_post))
return self.cursor.execute("INSERT INTO `group` (`group_id`, `type`, `last_post`) VALUES(?, ?, ?)",
(group_id, tp, last_post))
def all_notifGroup(self): def all_notifGroup(self):
"""Список отслеживаемых групп""" """Страницы, на которые кто-то подписан"""
with self.connection: return self._all("SELECT page_id FROM page_state WHERE count > 0")
return self.cursor.execute('SELECT `group_id` FROM `group` WHERE `count` > 0').fetchall()
def get_postGroup(self, group_id): def get_postGroup(self, group_id):
"""Получаем номер последнего поста""" return self._one("SELECT last_post FROM page_state WHERE page_id = %s", (group_id,))
with self.connection:
return self.cursor.execute('SELECT `last_post` FROM `group` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
def update_postGroup(self, group_id, last_post): def update_postGroup(self, group_id, last_post):
"""Обновляем номер последнего поста""" self._run("UPDATE page_state SET last_post = %s WHERE page_id = %s", (last_post, group_id))
with self.connection:
return self.cursor.execute("UPDATE `group` SET `last_post` = ? WHERE `group_id` = ?", (last_post, group_id))
def update_countGroup(self, group_id, num): def update_countGroup(self, group_id, num):
"""Обновляем количество подписок на группу""" self._run("UPDATE page_state SET count = count + %s WHERE page_id = %s", (num, group_id))
with self.connection:
count = self.cursor.execute('SELECT `count` FROM `group` WHERE `group_id` = ?', (group_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `group` SET `count` = ? WHERE `group_id` = ?", (count + num, group_id))
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()

View file

@ -3,9 +3,26 @@ services:
build: . build: .
image: ghcr.io/edeev/vkrepost_to_tg:latest image: ghcr.io/edeev/vkrepost_to_tg:latest
env_file: .env env_file: .env
environment:
DATABASE_URL: postgresql://vkport:vkport@db:5432/vkport
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: vkport
POSTGRES_PASSWORD: vkport
POSTGRES_DB: vkport
volumes: volumes:
- db:/app/db - pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vkport"]
interval: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
volumes: volumes:
db: pgdata:

View file

View file

@ -1,3 +1,4 @@
aiogram==3.23.0 aiogram==3.23.0
vk_api==11.10.1 vk_api==11.10.1
emoji==2.16.0 emoji==2.16.0
psycopg[binary,pool]==3.3.6

65
scripts/migrate_sqlite.py Normal file
View file

@ -0,0 +1,65 @@
"""Перенос данных Portal in VK из SQLite (users.db, base.db) в PostgreSQL.
python scripts/migrate_sqlite.py --sqlite-dir /path/to/db --dsn postgresql://…
Внутренние номера пользователей и страниц сохраняются: на них ссылаются списки подписок.
"""
import argparse
import os
import sqlite3
import sys
import psycopg
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
from sql import SCHEMA # noqa: E402
def migrate(sqlite_dir, dsn, force=False):
users_db = sqlite3.connect(os.path.join(sqlite_dir, "users.db"))
base_db = sqlite3.connect(os.path.join(sqlite_dir, "base.db"))
users = users_db.execute("SELECT id, user_id FROM user ORDER BY id").fetchall()
pages = users_db.execute('SELECT id, group_id FROM "group" ORDER BY id').fetchall()
subs = base_db.execute("SELECT user_id, status, count, groups, token FROM user").fetchall()
states = base_db.execute('SELECT group_id, type, count, last_post FROM "group"').fetchall()
known_users, known_pages = {u[0] for u in users}, {p[0] for p in pages}
with psycopg.connect(dsn) as conn:
conn.execute(SCHEMA)
if conn.execute("SELECT count(*) FROM users").fetchone()[0]:
if not force:
raise SystemExit("В PostgreSQL уже есть данные — перенос остановлен (--force очистит таблицы)")
conn.execute("TRUNCATE users, pages, subscribers, page_state RESTART IDENTITY CASCADE")
with conn.cursor() as cur:
cur.executemany("INSERT INTO users (id, tg_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", users)
cur.executemany("INSERT INTO pages (id, vk_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", pages)
cur.executemany("INSERT INTO subscribers (user_id, status, count, groups, token) VALUES (%s, %s, %s, %s, %s) "
"ON CONFLICT DO NOTHING",
[(u, bool(s), c or 0, g, t) for u, s, c, g, t in subs if u in known_users])
cur.executemany("INSERT INTO page_state (page_id, type, count, last_post) VALUES (%s, %s, %s, %s) "
"ON CONFLICT DO NOTHING",
[(p, bool(tp), c or 0, lp) for p, tp, c, lp in states if p in known_pages])
for table in ("users", "pages"):
cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
f"GREATEST((SELECT max(id) FROM {table}), 1))")
report = {name: (src, conn.execute(f"SELECT count(*) FROM {name}").fetchone()[0])
for name, src in (("users", len(users)), ("pages", len(pages)),
("subscribers", len(subs)), ("page_state", len(states)))}
for name, (src, dst) in report.items():
print(f"{name:11} SQLite {src:>5} PostgreSQL {dst:>5} {'ok' if src == dst else 'MISMATCH'}")
return all(a == b for a, b in report.values())
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--sqlite-dir", required=True)
parser.add_argument("--dsn", default=os.getenv("DATABASE_URL"))
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
sys.exit(0 if migrate(args.sqlite_dir, args.dsn, args.force) else 1)
if __name__ == "__main__":
main()

71
tests/test_sql.py Normal file
View file

@ -0,0 +1,71 @@
import os
import sqlite3
import sys
import psycopg
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
DSN = os.environ.get("TEST_DATABASE_URL")
pytestmark = pytest.mark.skipif(not DSN, reason="нужен TEST_DATABASE_URL (PostgreSQL)")
@pytest.fixture
def pool():
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
import sql
p = sql.connect(DSN)
yield p
p.close()
def test_subscriptions(pool):
from sql import Base, Users
du, db = Users(pool), Base(pool)
du.add_user(111)
uid = du.get_user_id(111)
db.add_infoUser(uid)
du.add_group(-500)
gid = du.get_group_id(-500)
db.add_infoGroup(gid, 1, 42)
db.update_user_groups(uid, str(gid))
db.update_countUser(uid, 1)
db.update_countGroup(gid, 1)
assert db.all_subUser() == [(uid, str(gid))] and db.all_activUser() == []
db.update_token(uid, "tok")
assert db.all_activUser() == [(uid, str(gid))]
assert db.all_notifGroup() == [(gid,)] and db.get_postGroup(gid) == 42 and du.get_vk_id(gid) == -500
db.update_status(uid)
assert db.get_status(uid) is False and db.all_subUser() == []
def test_migration(pool, tmp_path):
import migrate_sqlite
users = sqlite3.connect(tmp_path / "users.db")
users.executescript("""
CREATE TABLE user (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL);
CREATE TABLE "group" (id INTEGER PRIMARY KEY, group_id INTEGER NOT NULL);
INSERT INTO user VALUES (3, 333);
INSERT INTO "group" VALUES (7, -700);
""")
users.commit()
base = sqlite3.connect(tmp_path / "base.db")
base.executescript("""
CREATE TABLE user (user_id INTEGER NOT NULL, status BOOLEAN NOT NULL DEFAULT (True), count INTEGER NOT NULL DEFAULT (0),
groups TEXT, token TEXT);
CREATE TABLE "group" (group_id INTEGER NOT NULL, type BOOLEAN NOT NULL, count INTEGER NOT NULL DEFAULT (0),
last_post INTEGER NOT NULL);
INSERT INTO user VALUES (3, 1, 1, '7', NULL);
INSERT INTO "group" VALUES (7, 1, 1, 99);
""")
base.commit()
assert migrate_sqlite.migrate(str(tmp_path), DSN, force=True)
from sql import Base, Users
du, db = Users(pool), Base(pool)
assert du.get_user_id(333) == 3 and du.get_group_id(-700) == 7
assert db.get_user_groups(3) == "7" and db.get_postGroup(7) == 99
du.add_group(-800)
assert du.get_group_id(-800) == 8