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

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

14 changed files with 154 additions and 286 deletions

View file

@ -2,5 +2,3 @@
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,28 +8,16 @@ 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 scripts - run: ruff check --select E9,F code tests
- 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,11 +19,17 @@ 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,4 +2,5 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
db/*.db
*.log *.log

View file

@ -8,8 +8,12 @@ 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 && chown -R app:app /app RUN useradd --create-home --uid 1000 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 · PostgreSQL · Docker **Stack:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker
## Features ## Features
@ -40,12 +40,11 @@ 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 git.deev.su/edeev/vkrepost_to_tg`. Prebuilt image: `docker pull ghcr.io/edeev/vkrepost_to_tg` or `docker pull dcr.deev.su/edeev/vkrepost_to_tg`.
PostgreSQL tables are created on first start. Data from the old version (SQLite `users.db` and `base.db`) The SQLite databases are created on first start.
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=… DATABASE_URL=postgresql://… python bot.py` (needs PostgreSQL). `cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`.
## How it works ## How it works
@ -54,8 +53,7 @@ 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 (PostgreSQL) code/sql.py users, subscriptions and each page's latest post (SQLite)
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
@ -70,7 +68,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
`git.deev.su`. `dcr.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 · PostgreSQL · Docker **Стек:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker
## Возможности ## Возможности
@ -41,12 +41,11 @@ cp .env.example .env # BOT_TOKEN и сервисный ключ прило
docker compose up -d docker compose up -d
``` ```
Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull git.deev.su/edeev/vkrepost_to_tg`. Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull dcr.deev.su/edeev/vkrepost_to_tg`.
Таблицы в PostgreSQL создаются при первом запуске. Данные старой версии (SQLite `users.db` и `base.db`) Базы SQLite создаются при первом запуске.
переносит `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=… DATABASE_URL=postgresql://… python bot.py` (нужен PostgreSQL). `cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`.
## Как устроено ## Как устроено
@ -55,8 +54,7 @@ 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 пользователи, подписки и последний пост каждой страницы (PostgreSQL) code/sql.py пользователи, подписки и последний пост каждой страницы (SQLite)
scripts/ перенос данных из SQLite
``` ```
Для закрытой страницы бот берёт токен одного из её подписчиков, для открытых — сервисный ключ. Запросы Для закрытой страницы бот берёт токен одного из её подписчиков, для открытых — сервисный ключ. Запросы
@ -71,7 +69,7 @@ ruff check --select E9,F code tests && pytest
Тесты проверяют разбор постов (экранирование, упоминания, ссылки, репосты, лимит альбома) и отправку Тесты проверяют разбор постов (экранирование, упоминания, ссылки, репосты, лимит альбома) и отправку
(одиночное фото, длинный текст, аудио). Docker-образ собирается по тегу `v*` и публикуется в GitHub (одиночное фото, длинный текст, аудио). Docker-образ собирается по тегу `v*` и публикуется в GitHub
Packages и `git.deev.su`. Packages и `dcr.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, connect from sql import Users, Base
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())
pool = connect(os.getenv("DATABASE_URL", "postgresql://vkport:vkport@localhost:5432/vkport")) os.makedirs("../db", exist_ok=True)
du = Users(pool) du = Users('../db/users.db')
db = Base(pool) db = Base('../db/base.db')
# лог в файл, если задан 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,150 +1,179 @@
"""Хранилище бота — PostgreSQL (до 2026-10 — два файла SQLite users.db и base.db). import sqlite3
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
);
"""
def connect(dsn): class Users:
"""Пул соединений сам переподключается, если PostgreSQL перезапускали""" def __init__(self, database):
pool = ConnectionPool(dsn, min_size=1, max_size=4, kwargs={"autocommit": True}, open=True) """Подключаемся к БД и сохраняем курсор соединения"""
with pool.connection() as conn: self.connection = sqlite3.connect(database)
conn.execute(SCHEMA) self.cursor = self.connection.cursor()
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)")
class _Base: # КОМАНДЫ USER
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""" """Получаем короткое айди юзера"""
return self._one("SELECT id FROM users WHERE tg_id = %s", (user_id,)) with self.connection:
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 по внутреннему номеру""" """Получаем длинное айди юзера"""
return self._one("SELECT tg_id FROM users WHERE id = %s", (user_id,)) with self.connection:
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 ВК""" """Получаем короткое айди группы"""
return self._one("SELECT id FROM pages WHERE vk_id = %s", (group_id,)) with self.connection:
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 ВК по внутреннему номеру""" """Получаем длинное айди группы"""
return self._one("SELECT vk_id FROM pages WHERE id = %s", (group_id,)) with self.connection:
return self.cursor.execute('SELECT `group_id` FROM `group` WHERE `id` = ?', (group_id,)).fetchone()[0]
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class Base(_Base): class 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, которые получают посты""" """Список активных пользователей"""
return self._all("SELECT user_id, groups FROM subscribers WHERE status AND count > 0 AND token IS NOT NULL") with self.connection:
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):
"""Все подписчики, которые получают посты""" """Список пользователей подписанных на группу"""
return self._all("SELECT user_id, groups FROM subscribers WHERE status AND count > 0") with self.connection:
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):
return self._one("SELECT token FROM subscribers WHERE user_id = %s", (user_id,)) """Получаем access token пользователя"""
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):
self._run("UPDATE subscribers SET token = %s WHERE user_id = %s", (token, user_id)) """Обновляем access token пользователя"""
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", """Добавляем информацию о группе"""
(group_id, bool(tp), last_post)) with self.connection:
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):
"""Страницы, на которые кто-то подписан""" """Список отслеживаемых групп"""
return self._all("SELECT page_id FROM page_state WHERE count > 0") with self.connection:
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,26 +3,9 @@ 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:
- pgdata:/var/lib/postgresql/data - db:/app/db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vkport"]
interval: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pgdata: db:

0
db/.gitkeep Normal file
View file

View file

@ -1,4 +1,3 @@
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

View file

@ -1,65 +0,0 @@
"""Перенос данных 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()

View file

@ -1,71 +0,0 @@
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