mirror of
https://github.com/EDeev/vkrepost_to_tg.git
synced 2026-10-07 20:49:50 +03:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0939585083 | |||
| 688ce4fcf1 |
14 changed files with 286 additions and 154 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -2,5 +2,4 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
db/*.db
|
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
14
README.en.md
14
README.en.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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`.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
217
code/sql.py
217
code/sql.py
|
|
@ -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()
|
|
||||||
|
|
|
||||||
21
compose.yaml
21
compose.yaml
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
65
scripts/migrate_sqlite.py
Normal 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
71
tests/test_sql.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue