diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5d0f55 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Токен бота от @BotFather +BOT_TOKEN=123456:your-token +# Сервисный ключ приложения VK (vk.com/apps?act=manage → приложение → Настройки) +VK_SERVICE_TOKEN=your-vk-service-key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4fb84e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements-dev.txt + - run: ruff check --select E9,F code tests + - run: pytest -q + - name: Бот импортируется + working-directory: code + env: + BOT_TOKEN: "123456:TEST" + run: python -c "import bot" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..2e2f2b2 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,42 @@ +name: Docker + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/login-action@v3 + with: + registry: dcr.deev.su + username: ${{ secrets.ZOT_USERNAME }} + password: ${{ secrets.ZOT_PASSWORD }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/edeev/vkrepost_to_tg + dcr.deev.su/edeev/vkrepost_to_tg + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05721bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +db/*.db +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ad98ae0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY code/ code/ +RUN useradd --create-home --uid 1000 app \ + && mkdir -p db \ + && chown -R app:app /app +USER app +VOLUME ["/app/db"] + +# пути к базам в коде — относительно папки code/ (../db) +WORKDIR /app/code +CMD ["python", "bot.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1051c59 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Egor Deev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..d5e5220 --- /dev/null +++ b/README.en.md @@ -0,0 +1,86 @@ +# Portal in VK + +[Русский](README.md) · **English** + +[![CI](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/ci.yml) +[![Docker](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/docker.yml) +[![License](https://img.shields.io/github/license/EDeev/vkrepost_to_tg)](LICENSE) + +A Telegram bot that forwards new posts from VK (VKontakte) pages and communities to Telegram: subscribe +with `/add ` and every new post arrives as a message, with photos, video covers, documents, +audio, polls and reposts. The bot speaks Russian. + +**Status:** personal project, completed · bot [@vkportalbot](https://t.me/vkportalbot) + +**Stack:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker + +## Features + +- Up to 10 subscriptions per user: personal pages, groups and public pages +- A post is converted to Telegram format: + - photos, video covers and image documents — as an album; + - audio — as a separate message; + - polls, links and documents — in the text; + - `[id1|Name]` mentions — as links; + - reposts show both authors and the comment. +- Telegram limits are handled: a single photo, albums up to 10 items, long text as separate messages +- `/last_post` — the latest post of any page; `/update` — resend a post with fresh data +- Your own VK token (optional) gives posts from closed pages you follow and likes from Telegram (`/like` + as a reply to a post) + +> [!IMPORTANT] +> If you send the bot your VK token, it is stored on the bot's server. Delete it with `/logout`; revoke +> access completely in VK settings, "Apps and websites". + +## Running + +```bash +git clone https://github.com/EDeev/vkrepost_to_tg.git && cd vkrepost_to_tg +cp .env.example .env # BOT_TOKEN and the VK app service key +docker compose up -d +``` + +Prebuilt image: `docker pull ghcr.io/edeev/vkrepost_to_tg` or `docker pull dcr.deev.su/edeev/vkrepost_to_tg`. +The SQLite databases are created on first start. + +Without Docker: Python 3.12, `pip install -r requirements.txt`, then +`cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`. + +## How it works + +``` +code/bot.py entry point and background polling of subscriptions every minute +code/handlers.py commands: subscriptions, latest post, likes, VK token +code/vk_scripts.py VK API requests and post parsing +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) +``` + +For a closed page the bot uses the token of one of its subscribers; for open pages, the service key. VK +requests run in a separate thread so the bot never freezes. Text from VK is escaped. + +## Development + +```bash +pip install -r requirements-dev.txt +ruff check --select E9,F code tests && pytest +``` + +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 +`dcr.deev.su`. + +## License + +MIT — see [LICENSE](LICENSE). + +## Author + +**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space) + +--- + +
+ ⭐ If you find this project useful, give it a star on GitHub! +

Made with ❤️ — deev.space

+
diff --git a/README.md b/README.md index bcf222b..f8fa7e3 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,87 @@ -# Repost from Vk to Tg 🔄 +# Portal in VK -[![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://python.org) -[![Aiogram](https://img.shields.io/badge/Aiogram-2.x-green.svg)](https://aiogram.dev) -[![VK API](https://img.shields.io/badge/VK%20API-5.131-orange.svg)](https://dev.vk.com) -[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +**Русский** · [English](README.en.md) -**Автоматизированная система репостинга контента из VKontakte в Telegram** +[![CI](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/ci.yml) +[![Docker](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/vkrepost_to_tg/actions/workflows/docker.yml) +[![License](https://img.shields.io/github/license/EDeev/vkrepost_to_tg)](LICENSE) -Telegram-бот для автоматического мониторинга и репостинга публикаций из социальной сети ВКонтакте с поддержкой персонализированных подписок и расширенным функционалом взаимодействия. +Telegram-бот, который пересылает новые посты со страниц и сообществ ВКонтакте в Telegram: подписываешься +командой `/add <короткое имя>`, и каждый новый пост приходит сообщением — с фото, обложками видео, +документами, аудио, опросами и репостами. -## 🎯 Основная функциональность +**Статус:** личный проект, завершён · бот [@vkportalbot](https://t.me/vkportalbot) -### Ключевые возможности -- **Автоматический мониторинг** публикаций из VK страниц и сообществ -- **Персонализированные подписки** до 10 источников на пользователя -- **Интеграция с VK API** через пользовательские токены -- **Интерактивное взаимодействие** с лайками постов через Telegram -- **Многоформатная поддержка** медиа-контента (фото, видео, аудио, документы) +**Стек:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker -### Архитектурные особенности -- Асинхронная обработка запросов с использованием `asyncio` -- Двухуровневая система баз данных (пользователи/группы) -- Токен-ротация для обхода ограничений API -- Централизованное управление подписками +## Возможности -## 🚀 Быстрый старт +- До 10 подписок на пользователя: личные страницы, группы и паблики +- Пост переводится в формат Telegram: + - фото, обложки видео и картинки-документы — альбомом; + - аудио — отдельным сообщением; + - опросы, ссылки и документы — в тексте; + - упоминания `[id1|Имя]` — ссылками; + - у репостов — оба автора и комментарий. +- Ограничения Telegram учтены: одиночное фото, альбомы до 10 элементов, длинный текст отдельными + сообщениями +- `/last_post` — последний пост любой страницы, `/update` — прислать пост заново с актуальными данными +- Свой токен VK (необязательно) даёт посты закрытых страниц, на которые вы подписаны, и лайки из Telegram + (`/like` ответом на пост) + +> [!IMPORTANT] +> Если вы присылаете боту свой токен VK, он хранится на сервере бота. Удалить его можно командой +> `/logout`, полностью отозвать доступ — в настройках VK, раздел «Приложения и сайты». + +## Запуск -### Предварительные требования ```bash -pip install -r requirements.txt +git clone https://github.com/EDeev/vkrepost_to_tg.git && cd vkrepost_to_tg +cp .env.example .env # BOT_TOKEN и сервисный ключ приложения VK +docker compose up -d ``` -### Конфигурация -1. Создайте Telegram-бота через [@BotFather](https://t.me/botfather) -2. Получите служебный токен VK API -3. Настройте файл `config.py`: +Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull dcr.deev.su/edeev/vkrepost_to_tg`. +Базы SQLite создаются при первом запуске. -```python -# TOKENS -botToken = 'YOUR_TELEGRAM_BOT_TOKEN' -serviceToken = "YOUR_VK_SERVICE_TOKEN" +Без Docker: Python 3.12, `pip install -r requirements.txt`, затем +`cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`. -# URL -loginUrl = "https://oauth.vk.com/authorize?client_id=YOUR_APP_ID&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=wall,likes&response_type=token&v=5.131" +## Как устроено + +``` +code/bot.py запуск и фоновый опрос подписок раз в минуту +code/handlers.py команды: подписки, последний пост, лайки, токен VK +code/vk_scripts.py запросы к VK API и разбор поста +code/scripts.py пост → текст в HTML и медиа Telegram, отправка с учётом ограничений +code/sql.py пользователи, подписки и последний пост каждой страницы (SQLite) ``` -### Запуск системы +Для закрытой страницы бот берёт токен одного из её подписчиков, для открытых — сервисный ключ. Запросы +к VK идут в отдельном потоке, чтобы бот не замирал. Текст из ВК экранируется. + +## Разработка + ```bash -python bot.py +pip install -r requirements-dev.txt +ruff check --select E9,F code tests && pytest ``` -## 📋 Структура команд +Тесты проверяют разбор постов (экранирование, упоминания, ссылки, репосты, лимит альбома) и отправку +(одиночное фото, длинный текст, аудио). Docker-образ собирается по тегу `v*` и публикуется в GitHub +Packages и `dcr.deev.su`. -| Команда | Описание | Синтаксис | -|---------|----------|-----------| -| `/start` | Инициализация пользователя | `/start` | -| `/add` | Добавление подписки | `/add domain_name` | -| `/del` | Удаление подписки | `/del domain_name` | -| `/list` | Просмотр активных подписок | `/list` | -| `/like` | Лайк поста (ответ на сообщение) | `/like` | -| `/notif` | Переключение уведомлений | `/notif` | -| `/last_post` | Получение последней публикации | `/last_post domain_name` | +## Лицензия -## 🏗️ Архитектура системы +MIT — см. [LICENSE](LICENSE). -### Компоненты системы -``` -├── bot.py # Основной модуль бота -├── vk_scripts.py # VK API интеграция -├── sql.py # Управление базами данных -├── scripts.py # Вспомогательные функции -├── config.py # Конфигурация проекта -└── db/ - ├── users.db # База пользователей - └── base.db # Основная база данных -``` +## Автор -### Технологический стек -- **Backend**: Python 3.8+ -- **Telegram Framework**: Aiogram 2.x -- **VK Integration**: vk_api -- **Database**: SQLite -- **Async Processing**: asyncio -- **Media Processing**: Built-in handlers - -### Схема базы данных - -#### Таблица `user` (users.db) -```sql -user_id INTEGER PRIMARY KEY -- Telegram ID пользователя -id INTEGER AUTOINCREMENT -- Внутренний ID -``` - -#### Таблица `user` (base.db) -```sql -user_id INTEGER -- Ссылка на users.db -status BOOLEAN -- Статус уведомлений -groups TEXT -- Список подписок (разделитель ;) -token TEXT -- VK access token -count INTEGER -- Количество подписок -``` - -## ⚡ Алгоритм работы - -### Цикл мониторинга -1. **Сканирование источников** (интервал: 60 секунд) -2. **Проверка новых публикаций** через VK API -3. **Форматирование контента** под Telegram -4. **Массовая рассылка** подписчикам -5. **Обновление метаданных** в базе данных - -### Обработка медиа-контента -- **Фотографии**: Группировка в медиа-альбомы -- **Видео**: Информационные заглушки с ссылками -- **Аудио**: Отдельные медиа-сообщения -- **Документы**: Прямые ссылки с метаданными -- **Опросы**: Текстовое представление с результатами - -## 🔧 Расширенные возможности - -### Система токенов -- **Служебный токен**: Базовый доступ к публичному контенту -- **Пользовательские токены**: Доступ к закрытым страницам и функции лайков -- **Автоматическая ротация**: Распределение нагрузки между токенами - -### Обработка ошибок -- Graceful handling VK API лимитов -- Автоматический фallback на служебный токен -- Логирование критических ошибок - -## 📊 Метрики производительности - -- **Пропускная способность**: До 1000 пользователей -- **Частота обновлений**: 60 секунд -- **Лимит подписок**: 10 на пользователя -- **Поддерживаемые форматы**: 6 типов медиа - -## 📄 Лицензия - -Проект распространяется под лицензией MIT. - -## 👨‍💻 Автор - -**Деев Егор Викторович** -- GitHub: [@EDeev](https://github.com/EDeev) -- Email: egor@deev.space -- Telegram: [@Egor_Deev](https://t.me/Egor_Deev) +**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space) ---
- Проект носит некоммерческий характер и предназначен для образовательных целей и личного использования. -

Создано с ❤️ от вашего дорогого - deev.space ©

+ ⭐ Если проект оказался полезным, поставьте звёздочку на GitHub! +

Сделано с ❤️ — deev.space

diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9b68e0d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,11 @@ +services: + bot: + build: . + image: ghcr.io/edeev/vkrepost_to_tg:latest + env_file: .env + volumes: + - db:/app/db + restart: unless-stopped + +volumes: + db: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b065da0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +ruff==0.14.0 diff --git a/requirements.txt b/requirements.txt index aca2fed..f7633c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,3 @@ -# Telegram Bot Framework -aiogram==2.25.1 - -# VK API -vk-api==11.9.9 - -# Database -sqlite3 - -# Async support -asyncio - -# Emoji support -emoji==2.8.0 - -# Additional dependencies -requests>=2.28.0 -aiohttp>=3.8.0 +aiogram==3.23.0 +vk_api==11.10.1 +emoji==2.16.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2f77833 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code")) diff --git a/tests/test_post.py b/tests/test_post.py new file mode 100644 index 0000000..7ce9c21 --- /dev/null +++ b/tests/test_post.py @@ -0,0 +1,96 @@ +import asyncio +from types import SimpleNamespace + +import scripts +import vk_scripts + + +def wall(text, attachments, repost=None): + post = {"id": 7, "owner_id": -100, "text": text, "attachments": attachments} + if repost: + post["copy_history"] = [repost] + return {"items": [post], "groups": [{"id": 100, "name": "Паблик <&>", "screen_name": "pub"}], + "profiles": [{"id": 5, "first_name": "Иван", "last_name": "Петров", "screen_name": "ivan"}]} + + +def parse(response): + class Api: + class wall: + @staticmethod + def get(**_): return response + parser = vk_scripts.VkParser.__new__(vk_scripts.VkParser) + parser.vk = Api + return parser.last_post(owner_id=-100) + + +def photo(url): + return {"type": "photo", "photo": {"sizes": [{"url": url + "-small"}, {"url": url}]}} + + +def test_text_is_escaped_and_mentions_become_links(): + out = parse(wall("1 < 2 & [id5|Иван] пишет ", [])) + text, audio, media = scripts.pars_post(out) + assert "1 < 2 & Иван пишет <b>" in text + assert "Паблик <&>" in text + assert audio == [] and media == [] + + +def test_attachments_links_are_html_and_media_limited(): + atts = [{"type": "link", "link": {"title": "Сайт", "url": "https://example.com/?a=1&b=2"}}] + atts += [photo(f"https://img/{i}") for i in range(12)] + text, audio, media = scripts.pars_post(parse(wall("пост", atts))) + assert "Сайт" in text + assert "](" not in text # раньше ссылки оформлялись Markdown-синтаксисом + assert len(media) == 10 and media[0].media == "https://img/0" + + +def test_repost_has_both_authors(): + orig = {"id": 3, "owner_id": 5, "text": "оригинал", "attachments": []} + text, _, _ = scripts.pars_post(parse(wall("мой комментарий", [], repost=orig))) + assert "Автор репоста" in text and "Иван Петров" in text and "
мой комментарий
" in text + + +def test_split_text_keeps_lines(): + text = "\n".join(f"строка {i}" for i in range(1000)) + parts = scripts.split_text(text, 4096) + assert all(len(p) <= 4096 for p in parts) + assert "".join(parts).count("") == 1000 and all(p.count("") == p.count("") for p in parts) + + +class FakeBot: + def __init__(self): + self.calls = [] + + def __getattr__(self, name): + async def method(**kwargs): + self.calls.append((name, kwargs)) + msg = SimpleNamespace(message_id=len(self.calls)) + return [msg] if name == "send_media_group" else msg + return method + + +def send(text, audio, media): + bot = FakeBot() + asyncio.run(scripts.send_post(bot, 1, text, audio, media)) + return bot.calls + + +def test_single_photo_is_sent_as_photo_not_album(): + _, _, media = scripts.pars_post(parse(wall("пост", [photo("https://img/1")]))) + calls = send("короткий текст", [], media) + assert [c[0] for c in calls] == ["send_photo"] and calls[0][1]["caption"] == "короткий текст" + + +def test_long_text_with_album_goes_as_separate_messages(): + _, _, media = scripts.pars_post(parse(wall("пост", [photo("a"), photo("b")]))) + calls = send("x" * 3000, [], media) + assert calls[0][0] == "send_media_group" and calls[0][1]["media"][0].caption is None + assert calls[1][0] == "send_message" and calls[1][1]["reply_to_message_id"] == 1 + + +def test_text_with_single_audio(): + atts = [{"type": "audio", "audio": {"title": "Песня", "url": "https://a/1.mp3", "artist": "Автор"}}] + text, audio, media = scripts.pars_post(parse(wall("пост", atts))) + calls = send(text, audio, media) + assert [c[0] for c in calls] == ["send_message", "send_audio"] + assert calls[1][1]["reply_to_message_id"] == 1