1
0
Fork 0
mirror of https://github.com/EDeev/vkrepost_to_tg.git synced 2026-10-08 04:59:45 +03:00

Compare commits

..

3 commits

Author SHA1 Message Date
b7a1f6ea22 Зависимости, тесты, CI, Docker, лицензия MIT и README на русском и английском
requirements.txt раньше не устанавливался (sqlite3 и бэкпорт asyncio с PyPI).
2026-10-05 19:52:45 +00:00
a38819dee7 Исправлены ошибки пересылки постов
- фоновая рассылка падала на каждом новом посте: NameError: name 'types' is not defined
  (видно в логе сервера) — бот не пересылал ничего;
- пост с одной картинкой не отправлялся: send_media_group принимает от 2 до 10 элементов;
  больше 10 фото — тоже ошибка; теперь одиночное фото — send_photo, альбом обрезается до 10;
- подпись длиннее 1024 символов ломала отправку — длинный текст уходит отдельными
  сообщениями (с делением по строкам, чтобы не рвать HTML);
- если первым вложением была ссылка или опрос, текст поста терялся (подпись ставилась
  только на вложение с индексом 0);
- текст и названия из ВК подставлялись в HTML без экранирования; ссылки оформлялись
  Markdown-синтаксисом ([..](..)) при разметке HTML;
- post_message[0] у одиночного сообщения, logging.danger, /notif без подписок — падения;
- у видео без photo_800 разбор поста падал; «Лайков» у видео на самом деле были просмотры;
- синхронные вызовы vk_api и time.sleep блокировали бота — теперь в отдельном потоке и
  asyncio.sleep; ошибка у одного подписчика не мешает остальным.

Новое: /logout — удалить сохранённый токен VK; сообщение со ссылкой-токеном удаляется из
переписки. Токены бота и VK — из переменных окружения, таблицы SQLite создаются при первом
запуске (пустые базы убраны из репозитория), ссылка «АВТОР» — на EDeev.
2026-10-05 19:52:45 +00:00
490ea8b5d6 Версия на aiogram 3 — та, что работала на сервере
В репозитории оставался код на aiogram 2. На сервере с апреля–сентября 2024 работала
переписанная версия: aiogram 3, разметка HTML вместо Markdown, код разделён на bot.py /
handlers.py / init.py. Переводы строк приведены к LF.
2026-10-05 19:47:59 +00:00
24 changed files with 1040 additions and 599 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
# Токен бота от @BotFather
BOT_TOKEN=123456:your-token
# Сервисный ключ приложения VK (vk.com/apps?act=manage → приложение → Настройки)
VK_SERVICE_TOKEN=your-vk-service-key

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.db binary

23
.github/workflows/ci.yml vendored Normal file
View file

@ -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"

42
.github/workflows/docker.yml vendored Normal file
View file

@ -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 }}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
.pytest_cache/
db/*.db
*.log

19
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

86
README.en.md Normal file
View file

@ -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 <short name>` 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)
---
<div align="center">
<sub>⭐ If you find this project useful, give it a star on GitHub!</sub>
<p><sub>Made with ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

188
README.md
View file

@ -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) **Русский** · [English](README.en.md)
[![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)
**Автоматизированная система репостинга контента из 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)
### Ключевые возможности **Стек:** Python 3.12 · aiogram 3 · vk_api · SQLite · Docker
- **Автоматический мониторинг** публикаций из VK страниц и сообществ
- **Персонализированные подписки** до 10 источников на пользователя
- **Интеграция с VK API** через пользовательские токены
- **Интерактивное взаимодействие** с лайками постов через Telegram
- **Многоформатная поддержка** медиа-контента (фото, видео, аудио, документы)
### Архитектурные особенности ## Возможности
- Асинхронная обработка запросов с использованием `asyncio`
- Двухуровневая система баз данных (пользователи/группы)
- Токен-ротация для обхода ограничений API
- Централизованное управление подписками
## 🚀 Быстрый старт - До 10 подписок на пользователя: личные страницы, группы и паблики
- Пост переводится в формат Telegram:
- фото, обложки видео и картинки-документы — альбомом;
- аудио — отдельным сообщением;
- опросы, ссылки и документы — в тексте;
- упоминания `[id1|Имя]` — ссылками;
- у репостов — оба автора и комментарий.
- Ограничения Telegram учтены: одиночное фото, альбомы до 10 элементов, длинный текст отдельными
сообщениями
- `/last_post` — последний пост любой страницы, `/update` — прислать пост заново с актуальными данными
- Свой токен VK (необязательно) даёт посты закрытых страниц, на которые вы подписаны, и лайки из Telegram
(`/like` ответом на пост)
> [!IMPORTANT]
> Если вы присылаете боту свой токен VK, он хранится на сервере бота. Удалить его можно командой
> `/logout`, полностью отозвать доступ — в настройках VK, раздел «Приложения и сайты».
## Запуск
### Предварительные требования
```bash ```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
``` ```
### Конфигурация Готовый образ: `docker pull ghcr.io/edeev/vkrepost_to_tg` или `docker pull dcr.deev.su/edeev/vkrepost_to_tg`.
1. Создайте Telegram-бота через [@BotFather](https://t.me/botfather) Базы SQLite создаются при первом запуске.
2. Получите служебный токен VK API
3. Настройте файл `config.py`:
```python Без Docker: Python 3.12, `pip install -r requirements.txt`, затем
# TOKENS `cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`.
botToken = 'YOUR_TELEGRAM_BOT_TOKEN'
serviceToken = "YOUR_VK_SERVICE_TOKEN"
# 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 ```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 # Основная база данных
```
### Технологический стек **Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
- **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)
--- ---
<div align="center"> <div align="center">
<sub>Проект носит некоммерческий характер и предназначен для образовательных целей и личного использования.</sub> <sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p> <p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div> </div>

View file

@ -1,306 +1,66 @@
import logging, config, asyncio, time, sql, random import asyncio
import logging
from aiogram import Bot, Dispatcher, executor, types import random
from emoji import emojize
import scripts
from vk_scripts import VkParser import vk_scripts
from scripts import login, pars_post from init import bot, dp, du, db
from config import serviceToken
# log level from handlers import router
logging.basicConfig(level=logging.INFO)
# bot init async def check_group(group_id, activ_user, sub_user):
bot = Bot(token=config.botToken) tokens = [x[0] for x in activ_user if group_id in x[1]]
dp = Dispatcher(bot) users = [x[0] for x in sub_user if group_id in x[1]]
# connection db # токен случайного подписчика даёт доступ к закрытым страницам, иначе — сервисный ключ
du = sql.Users('../db/users.db') if tokens:
db = sql.Base('../db/base.db') vk = vk_scripts.VkParser(db.get_token(random.choice(tokens)))
else: vk = vk_scripts.VkParser(serviceToken)
# events # vk_api синхронный — в отдельном потоке, чтобы не замирал весь бот
@dp.message_handler(commands=["start", "help"]) output = await asyncio.to_thread(vk.last_post, owner_id=du.get_vk_id(group_id))
async def start(message: types.Message): last_post = db.get_postGroup(group_id)
login(message.chat.id, du, db)
if output[3] > last_post:
buttons = [types.InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"), text, audio, media = scripts.pars_post(output)
types.InlineKeyboardButton(text="АВТОР", callback_data="auth")] db.update_postGroup(group_id, output[3])
keyboard = types.InlineKeyboardMarkup(row_width=2) for sub in users:
keyboard.add(*buttons) try:
await scripts.send_post(bot, du.get_tg_id(sub), text, audio, media)
await message.answer(text=f'*Portal in VK* - это бот для перепоста постов со страниц в социальной сети ВКонтакте. ' except Exception:
f'Для начала работы вам нужно всего лишь вызвать команду */add* и добавить к ней короткое ' # подписчик мог заблокировать бота — остальным пост всё равно уходит
f'имя личной или публичной страницы. После этого, бот начнёт присылать каждый новый пост ' logging.warning("Не удалось отправить пост подписчику %s", sub, exc_info=True)
f'с этой страницы форматируя данные с неё под сообщение в Telegram. С полным функционалом '
f'бота, вы можете ознакомиться по кнопке *КОМАНДЫ*\n\n'
async def timer(wait_for) -> None:
f'Если вы хотите ставить лайки или получать уведомления от закрытых страниц на которые ' while True:
f'подписаны в социальной сети, вам необходимо скинуть ссылку из поисковой строки в чат, ' await asyncio.sleep(wait_for)
f'после подтверждения на [сайте]({config.loginUrl})',
parse_mode=types.ParseMode.MARKDOWN, reply_markup=keyboard) notif_group = [x[0] for x in db.all_notifGroup()]
activ_user = [[x[0], list(map(int, x[1].split(';')))] for x in db.all_activUser() if x[1]]
sub_user = [[x[0], list(map(int, x[1].split(';')))] for x in db.all_subUser() if x[1]]
# ИЛАЙН КЛАВИАТУРА HELP
@dp.callback_query_handler(text="com") for group_id in notif_group:
async def function(call: types.CallbackQuery): await asyncio.sleep(1) # пауза между страницами — лимиты VK API
await call.message.answer(text='*| КОМАНДЫ |*\n\n'
f'*/like* - лайк на пост, который вы отметили\n' try:
f'*/list* - список страниц от, которых вы получаете уведомления\n' await check_group(group_id, activ_user, sub_user)
f'*/notif* - отписка или подписка от всех уведомлений\n' except Exception:
f'*/last_post* - получение последнего поста по короткому имени страницы\n' logging.error("Ошибка обновления поста:", exc_info=True)
f'*/add* и */del* - добавление и удаление странички из списка подписок\n',
parse_mode=types.ParseMode.MARKDOWN)
async def main() -> None:
dp.include_router(router)
@dp.callback_query_handler(text="auth") await bot.delete_webhook(drop_pending_updates=True)
async def author(call: types.CallbackQuery): await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
await call.message.answer(text='*| АВТОР |*\n\n*>>* Этот бот не коммерческий проект, для получения постов из '
'социальной сети ВКонтакте. Бот работает на сервисном токене VK API, пока вы не '
'предоставите собственный. Ваш токен нужен для получения постов со страниц, ' async def process() -> None:
'доступ к которым есть исключительно у вас. Также это даст вам возможность ставить ' await asyncio.gather(main(), timer(60))
'через Telegram лайки на посты в самой социальной сети!'
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать ' if __name__ == "__main__":
'больше на моём [GitHub](https://github.com/IGlek).', try: asyncio.run(process())
parse_mode=types.ParseMode.MARKDOWN) except KeyboardInterrupt: pass
# КОМАНДЫ
@dp.message_handler(commands=["notif"])
async def notification(message: types.Message):
user_id = login(message.chat.id, du, db)
status = db.get_status(user_id)
groups = list(map(int, db.get_user_groups(user_id).split(";")))
for group in groups: db.update_countGroup(group, -1 if status else 1)
if status: await message.answer("Получение постов из ВК выключено!")
else: await message.answer("Получение постов из ВК включено!")
db.update_status(user_id)
@dp.message_handler(commands=["add", "del"])
async def add_del(message: types.Message):
user_id = login(message.chat.id, du, db)
domain = message.get_args()
if domain:
try:
token = db.get_token(user_id)
if token: vk = VkParser(token)
else: vk = VkParser(config.serviceToken)
group_id, typ, last_post = vk.login(domain)
if not du.group_exists(group_id):
du.add_group(group_id)
group_id = du.get_group_id(group_id)
if not db.infoGroup_exists(group_id):
db.add_infoGroup(group_id, typ, last_post)
groups = db.get_user_groups(user_id)
user_groups = groups.split(";") if groups else []
if str(group_id) not in user_groups:
if message.get_command() == "/del":
await message.answer("Вы и так не подписаны на эту группу!")
else:
if db.get_countUser(user_id) < 10:
user_groups.append(str(group_id))
db.update_user_groups(user_id, ";".join(user_groups))
db.update_countUser(user_id, 1)
if db.get_status(user_id): db.update_countGroup(group_id, 1)
await message.answer("Группа успешно добавлена в список уведомлений!")
else:
await message.answer("У вас добавлено максимально количество групп!")
else:
if message.get_command() == "/add":
await message.answer("Вы уже подписаны на эту страницу!")
else:
user_groups.remove(str(group_id))
db.update_user_groups(user_id, ";".join(user_groups))
db.update_countUser(user_id, -1)
if db.get_status(user_id): db.update_countGroup(group_id, -1)
await message.answer("Группа успешно удалена из списка уведомлений!")
except Exception as e:
await message.answer("Произошла ошибка! Проверьте правильность написания короткого адреса страницы "
"и имеете ли вы доступ к этой странице!")
else:
if message.get_command() == "/add":
await message.answer("Команда введена некорректно, такой страницы не существует или она закрыта для "
"просмотра!\n\nКорректный ввод команд: /add <i>example</i>", types.ParseMode.HTML)
elif message.get_command() == "/del":
await message.answer("Команда введена некорректно, такой страницы не существует или она закрыта для "
"просмотра!\n\nКорректный ввод команд: /del <i>example</i>", types.ParseMode.HTML)
@dp.message_handler(commands=["list"])
async def lst(message: types.Message):
user_id = login(message.chat.id, du, db)
groups = db.get_user_groups(user_id)
user_groups = list(map(int, groups.split(";") if groups else []))
if user_groups:
group_ids = [du.get_vk_id(id) for id in user_groups]
vk = VkParser(config.serviceToken)
peoples, groups = vk.info(group_ids)
text = "<b><i>* Список страниц, от которых вы получаете уведомления!</i></b>\n"
if peoples:
text += "\n<b>Личные страницы пользователей</b>\n"
for i, people in enumerate(peoples):
text += f'{emojize(":green_circle:") if people[2] else emojize(":red_circle:")} ' \
f'{i + 1}. {people[1]} <a href="https://vk.com/{people[0]}">@{people[0]}</a>\n'
if groups:
text += "\n<b>Группы / сообщества / паблики</b>\n"
for i, group in enumerate(groups):
text += f'{i + 1}. {group[1]} <a href="https://vk.com/{group[0]}">@{group[0]}</a>\n'
await message.answer(text, types.ParseMode.HTML)
else:
await message.answer("Вы не добавили ни одной страницы для отслеживания публикаций!")
@dp.message_handler(commands=["like"])
async def like(message: types.Message):
user_id = login(message.chat.id, du, db)
token = db.get_token(user_id)
if token and 'reply_to_message' in message:
url = ''
if 'entities' in message.reply_to_message:
for i in message.reply_to_message.entities:
if i['type'] == 'text_link': url = i['url']; break
elif 'caption_entities' in message.reply_to_message:
for i in message.reply_to_message.caption_entities:
if i['type'] == 'text_link': url = i['url']; break
if url:
try:
[group_id, last_post] = list(map(int, url.split('wall')[-1].split('_')))
vk = VkParser(token)
vk.like(group_id, last_post)
await message.answer(text='Вы поставили лайк 😉')
except Exception as e:
await message.answer(text='По неизвестной причине не удалось поставить лайк!')
else:
await message.answer(text='Вы ответили не на тот пост, для отправки лайка!')
else:
await message.answer(text='Вы не отметили пост который хотите лайкнуть или не прислали свой токен для '
'возможности лайкать посты!')
# ВЗАИМОДЕЙСТВИЕ С ДАННЫМИ ВК ПОСТОВ
@dp.message_handler(commands=["last_post"])
async def last_post(message: types.Message):
user_id = message.chat.id
short_id = login(user_id, du, db)
domain = message.get_args()
if domain:
try:
token = db.get_token(short_id)
if token: vk = VkParser(token)
else: vk = VkParser(config.serviceToken)
output = vk.last_post(domain=domain); vk.login(domain)
text, audio, media = pars_post(types, output)
if audio and media:
post_message = await bot.send_media_group(chat_id=user_id, media=media)
await bot.send_media_group(chat_id=user_id, media=audio, reply_to_message_id=post_message[0].message_id)
elif audio and media == []:
post_message = await bot.send_message(chat_id=user_id, text=text, parse_mode=types.ParseMode.MARKDOWN)
await bot.send_media_group(chat_id=user_id, media=audio, reply_to_message_id=(post_message[0].message_id))
elif audio == [] and media:
await bot.send_media_group(chat_id=user_id, media=media)
else:
await bot.send_message(chat_id=user_id, text=text, parse_mode=types.ParseMode.MARKDOWN)
except Exception as e:
await message.answer("Короткое имя страницы неверное или у вас нет доступа к этой странице!")
else:
await message.answer("Команда введена некорректно! Корректный ввод: /last_post example")
# ПОЛУЧЕНИЕ ТОКЕНА ПОЛЬЗОВАТЕЛЯ
@dp.message_handler(content_types="text")
async def url(message: types.Message):
user_id = login(message.chat.id, du, db)
if config.checkUrl in message.text:
try:
token = message.text.split("&")[0].split("=")[-1]
info = VkParser(token).check()
db.update_token(user_id, token)
await message.answer("Токен успешно сохранён!")
except Exception as e:
await message.answer("Ваша ссылка не корректна, попробуйте повторить копирование!")
else:
await message.answer("Ваше сообщение не корректно, необходима ссылка, что отобразилась в строке поиске после"
" подтверждения доступа!")
# ПРОВЕРКА СТРАНИЦ НА НОВЫЕ ПОСТЫ
async def timer(wait_for):
while True:
await asyncio.sleep(wait_for)
notif_group = list(map(lambda x: x[0], db.all_notifGroup()))
activ_user = list(map(lambda x: [x[0], list(map(int, x[1].split(';')))], db.all_activUser()))
sub_user = list(map(lambda x: [x[0], list(map(int, x[1].split(';')))], db.all_subUser()))
for group_id in notif_group:
time.sleep(1)
tokens = [x[0] for x in activ_user if group_id in x[1]]
users = [x[0] for x in sub_user if group_id in x[1]]
if tokens:
user_id = tokens[random.randrange(len(tokens))]
vk = VkParser(db.get_token(user_id))
else: vk = VkParser(config.serviceToken)
try:
output = vk.last_post(owner_id=du.get_vk_id(group_id))
last_post = db.get_postGroup(group_id)
if output[3] > last_post:
text, audio, media = pars_post(types, output)
db.update_postGroup(group_id, output[3])
for sub in users:
tg_id = du.get_tg_id(sub)
if audio and media:
post_message = await bot.send_media_group(chat_id=tg_id, media=media)
await bot.send_media_group(chat_id=tg_id, media=audio, reply_to_message_id=post_message[0].message_id)
elif audio and media == []:
post_message = await bot.send_message(chat_id=tg_id, text=text, parse_mode=types.ParseMode.MARKDOWN)
await bot.send_media_group(chat_id=tg_id, media=audio, reply_to_message_id=post_message[0].message_id)
elif audio == [] and media:
await bot.send_media_group(chat_id=tg_id, media=media)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode=types.ParseMode.MARKDOWN)
except Exception as e:
print(repr(e))
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.create_task(timer(60)) # ПРОВЕРКА КАЖДУЮ МИНУТУ
executor.start_polling(dp, skip_updates=True)

View file

@ -1,14 +1,19 @@
# TOKENS import os
botToken = '**********'
serviceToken = "**********" # TOKENS
botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @vkportalbot
# URL serviceToken = os.getenv("VK_SERVICE_TOKEN", "") # сервисный ключ приложения VK
loginUrl = "************"
checkUrl = "https://oauth.vk.com/blank.html#access_token=" # URL
loginUrl = os.getenv("VK_LOGIN_URL", "https://oauth.vk.com/authorize?client_id=6121396&scope=215985366"
# VARIABILITY "&redirect_uri=https://oauth.vk.com/blank.html&display=page"
unvote = {True: 'без возможности переголосовать', False: 'с возможностью переголосовать'} "&response_type=token&revoke=1")
anonymous = {True: 'анонимный опрос', False: 'не анонимный опрос'} checkUrl = "https://oauth.vk.com/blank.html#access_token="
# DATA # VARIABILITY
data = {0: 'Б', 1: 'КБ', 2: 'МБ', 3: 'ГБ', 4: 'ТБ'} unvote = {True: 'без возможности переголосовать', False: 'с возможностью переголосовать'}
anonymous = {True: 'анонимный опрос', False: 'не анонимный опрос'}
# DATA
data = {0: 'Б', 1: 'КБ', 2: 'МБ', 3: 'ГБ', 4: 'ТБ'}
dura = {0: 'с.', 1: 'мин.'}

306
code/handlers.py Normal file
View file

@ -0,0 +1,306 @@
import asyncio
import logging
from aiogram import types, F, Router
from aiogram.types import Message, CallbackQuery, ContentType
from aiogram.filters import Command
from emoji import emojize
from init import bot, du, db
from config import checkUrl, loginUrl, serviceToken
from vk_scripts import VkParser
from scripts import login, pars_post, send_post
router = Router()
@router.message(Command("start", "help"))
async def start(msg: Message) -> None:
login(msg.chat.id, du, db)
buttons = [[types.InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
types.InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons)
await msg.answer(text=f'<b>Portal in VK</b> - это бот для перепоста постов со страниц в социальной сети ВКонтакте. '
f'Для начала работы вам нужно всего лишь вызвать команду <b>/add</b> и добавить к ней '
f'короткое имя личной или публичной страницы. После этого, бот начнёт присылать каждый новый '
f'пост с этой страницы форматируя данные с неё под сообщение в Telegram. С полным '
f'функционалом бота, вы можете ознакомиться по кнопке <b>КОМАНДЫ</b>\n\n'
f'Если вы хотите ставить лайки или получать уведомления от закрытых страниц на которые '
f'подписаны в социальной сети, вам необходимо скинуть ссылку из поисковой строки в чат, '
f'после подтверждения на <a href="{loginUrl}">сайте</a>', reply_markup=keyboard)
# ИЛАЙН КЛАВИАТУРА HELP
@router.callback_query(F.data == "com")
async def function(call: CallbackQuery) -> None:
await call.message.answer(text='<b>| КОМАНДЫ |</b>\n\n'
'<b>/like</b> - лайк на пост, который вы отметили\n'
'<b>/list</b> - список страниц от, которых вы получаете уведомления\n'
'<b>/notif</b> - отписка или подписка от всех уведомлений\n'
'<b>/last_post</b> - получение последнего поста по короткому имени страницы\n'
'<b>/add</b> и <b>/del</b> - добавление и удаление странички из списка подписок\n'
'<b>/update</b> - ответом на пост: прислать его заново с актуальными данными\n'
'<b>/logout</b> - удалить сохранённый токен VK\n')
@router.callback_query(F.data == "auth")
async def author(call: CallbackQuery) -> None:
await call.message.answer(text='<b>| АВТОР |</b>\n\n<b>>></b> Этот бот не коммерческий проект, для получения '
'постов из социальной сети ВКонтакте. Бот работает на сервисном токене VK API, '
'пока вы не предоставите собственный. Ваш токен нужен для получения постов со '
'страниц, доступ к которым есть исключительно у вас. Также это даст вам '
'возможность ставить через Telegram лайки на посты в самой социальной сети!'
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать '
'больше на моём <a href="https://github.com/EDeev">GitHub</a>.')
# КОМАНДЫ
@router.message(Command("notif"))
async def notification(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
status = db.get_status(user_id)
# у пользователя может ещё не быть ни одной подписки
groups = db.get_user_groups(user_id)
for group in map(int, groups.split(";") if groups else []):
db.update_countGroup(group, -1 if status else 1)
if status: await msg.answer("Получение постов из ВК выключено!")
else: await msg.answer("Получение постов из ВК включено!")
db.update_status(user_id)
@router.message(Command("add", "del"))
async def add_del(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
domain = msg.text.split()[-1]
command = msg.text.split()[0]
if "/" not in domain:
try:
token = db.get_token(user_id)
if token: vk = VkParser(token)
else: vk = VkParser(serviceToken)
group_id, typ, last_post = await asyncio.to_thread(vk.login, domain)
if not du.group_exists(group_id):
du.add_group(group_id)
group_id = du.get_group_id(group_id)
if not db.infoGroup_exists(group_id):
db.add_infoGroup(group_id, typ, last_post)
groups = db.get_user_groups(user_id)
user_groups = groups.split(";") if groups else []
if str(group_id) not in user_groups:
if command == "/del":
await msg.answer("Вы и так не подписаны на эту группу!")
else:
if db.get_countUser(user_id) < 10:
user_groups.append(str(group_id))
db.update_user_groups(user_id, ";".join(user_groups))
db.update_countUser(user_id, 1)
if db.get_status(user_id): db.update_countGroup(group_id, 1)
await msg.answer("Группа успешно добавлена в список уведомлений!")
else:
await msg.answer("У вас добавлено максимально количество групп!")
else:
if command == "/add":
await msg.answer("Вы уже подписаны на эту страницу!")
else:
user_groups.remove(str(group_id))
db.update_user_groups(user_id, ";".join(user_groups))
db.update_countUser(user_id, -1)
if db.get_status(user_id): db.update_countGroup(group_id, -1)
await msg.answer("Группа успешно удалена из списка уведомлений!")
except Exception:
logging.info("Нет доступа к страницы:", exc_info=True)
await msg.answer("<b>Произошла ошибка!</b> Проверьте правильность написания <b>короткого адреса</b> "
"страницы, <b>имеете ли вы доступ</b> к этой странице и есть ли на ней <b>хотя бы "
"один пост</b>!")
else:
if command == "/add":
await msg.answer("Команда введена некорректно, такой страницы не существует или она закрыта для "
"просмотра!\n\nКорректный ввод команд: /add <i>example</i>")
elif command == "/del":
await msg.answer("Команда введена некорректно, такой страницы не существует или она закрыта для "
"просмотра!\n\nКорректный ввод команд: /del <i>example</i>")
@router.message(Command("list"))
async def lst(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
groups = db.get_user_groups(user_id)
user_groups = list(map(int, groups.split(";") if groups else []))
if user_groups:
group_ids = [du.get_vk_id(id) for id in user_groups]
vk = VkParser(serviceToken)
peoples, groups = await asyncio.to_thread(vk.info, group_ids)
text = "<b><i>* Список страниц, от которых вы получаете уведомления!</i></b>\n"
if peoples:
text += "\n<b>Личные страницы пользователей</b>\n"
for i, people in enumerate(peoples):
text += f'{emojize(":green_circle:") if people[2] else emojize(":red_circle:")} ' \
f'{i + 1}. {people[1]} <a href="https://vk.com/{people[0]}">@{people[0]}</a>\n'
if groups:
text += "\n<b>Группы / сообщества / паблики</b>\n"
for i, group in enumerate(groups):
text += f'{i + 1}. {group[1]} <a href="https://vk.com/{group[0]}">@{group[0]}</a>\n'
await msg.answer(text, disable_web_page_preview=True)
else:
await msg.answer("Вы <b>не добавили ни одной страницы</b> для отслеживания публикаций!")
@router.message(Command("like"))
async def like(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
token = db.get_token(user_id)
if token and (msg.reply_to_message is not None):
url = ''
if msg.reply_to_message.entities is not None:
for i in list(msg.reply_to_message.entities):
i = dict(i)
if i['type'] == 'text_link':
url = i['url']; break
elif msg.reply_to_message.caption_entities is not None:
for i in list(msg.reply_to_message.caption_entities):
i = dict(i)
if i['type'] == 'text_link':
url = i['url']; break
if url:
try:
[group_id, last_post] = list(map(int, url.split('wall')[-1].split('_')))
vk = VkParser(token)
await asyncio.to_thread(vk.like, group_id, last_post)
await msg.answer('Вы поставили лайк 😉')
except Exception:
logging.error("Не удалось поставить лайк:", exc_info=True)
await msg.answer('По <b>неизвестной причине</b> не удалось поставить лайк!')
else:
await msg.answer('Вы <b>ответили не на тот пост</b>, для отправки лайка!')
else:
await msg.answer('Вы <b>не отметили пост</b> который хотите лайкнуть или <b>не прислали свой токен</b> для '
'возможности лайкать посты!')
@router.message(Command("update"))
async def update(msg: Message) -> None:
user_id = msg.chat.id
short_id = login(msg.chat.id, du, db)
if msg.reply_to_message is not None:
url = ''
if msg.reply_to_message.entities is not None:
for i in list(msg.reply_to_message.entities):
i = dict(i)
if i['type'] == 'text_link':
url = i['url']; break
elif msg.reply_to_message.caption_entities is not None:
for i in list(msg.reply_to_message.caption_entities):
i = dict(i)
if i['type'] == 'text_link':
url = i['url']; break
if url:
try:
[group_id, last_post] = list(map(int, url.split('wall')[-1].split('_')))
token = db.get_token(short_id)
if token: vk = VkParser(token)
else: vk = VkParser(serviceToken)
output = await asyncio.to_thread(vk.last_post, post=f"{group_id}_{last_post}")
text, audio, media = pars_post(output)
await bot.delete_message(chat_id=user_id, message_id=msg.reply_to_message.message_id)
await bot.delete_message(chat_id=user_id, message_id=msg.message_id)
await send_post(bot, user_id, text, audio, media)
except Exception:
logging.error("Ошибка обновления поста:", exc_info=True)
await msg.answer(text='По неизвестной причине не удалось обновить пост!')
else: await msg.answer(text='Вы ответили не на тот пост!')
else: await msg.answer(text='Вы не отметили пост который хотите обновить!')
# ВЗАИМОДЕЙСТВИЕ С ДАННЫМИ ВК ПОСТОВ
@router.message(Command("last_post"))
async def last_post(msg: Message) -> None:
user_id = msg.chat.id
short_id = login(user_id, du, db)
domain = msg.text.split()[-1]
if "/" not in domain:
try:
token = db.get_token(short_id)
if token: vk = VkParser(token)
else: vk = VkParser(serviceToken)
output = await asyncio.to_thread(vk.last_post, domain=domain)
text, audio, media = pars_post(output)
await send_post(bot, user_id, text, audio, media)
except Exception:
logging.info("Некорректное/недоступное короткое имя:", exc_info=True)
await msg.answer("Короткое <b>имя страницы неверное</b> или у вас <b>нет доступа</b> к этой странице!")
else: await msg.answer("Команда введена некорректно! Корректный ввод: /last_post <b>example</b>")
@router.message(Command("logout"))
async def logout(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
if db.get_token(user_id):
db.update_token(user_id, None)
await msg.answer("Токен VK удалён, бот снова работает на сервисном ключе. Полностью отозвать доступ "
"можно в настройках VK: <b>Приложения и сайты</b>.")
else:
await msg.answer("Сохранённого токена нет.")
# ПОЛУЧЕНИЕ ТОКЕНА ПОЛЬЗОВАТЕЛЯ
@router.message(F.content_type == ContentType.TEXT)
async def url(msg: Message) -> None:
user_id = login(msg.chat.id, du, db)
if checkUrl in msg.text:
try:
token = msg.text.split("&")[0].split("=")[-1]
await asyncio.to_thread(VkParser(token).check)
db.update_token(user_id, token)
# ссылка с токеном не должна оставаться в переписке
try: await msg.delete()
except Exception: pass
await msg.answer("Токен успешно сохранён! Удалить его можно командой /logout")
except Exception:
logging.info("Некорректная ссылка:", exc_info=True)
await msg.answer("Ваша <b>ссылка не корректна</b>, попробуйте повторить копирование!")
else: await msg.answer("Ваше <b>сообщение не корректно</b>, необходима ссылка, что отобразилась в строке "
"поиске после подтверждения доступа!")

21
code/init.py Normal file
View file

@ -0,0 +1,21 @@
import logging
import os
from aiogram import Bot, Dispatcher
from aiogram.enums.parse_mode import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties
from config import botToken
from sql import Users, Base
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage())
os.makedirs("../db", exist_ok=True)
du = Users('../db/users.db')
db = Base('../db/base.db')
# лог в файл, если задан LOG_FILE (так бот работал на сервере), иначе — в консоль
logging.basicConfig(level=logging.INFO, filename=os.getenv("LOG_FILE") or None,
format="%(asctime)s %(levelname)s %(message)s")

View file

@ -1,57 +1,126 @@
import config from html import escape
from aiogram import types
def login(user_id, du, db):
user_id = int(user_id) import config
if not du.user_exists(user_id): du.add_user(user_id)
CAPTION_LIMIT = 1024 # подпись к фото в Telegram
user_id = du.get_user_id(user_id) TEXT_LIMIT = 4096 # обычное сообщение
if not db.infoUser_exists(user_id): db.add_infoUser(user_id)
return user_id def login(user_id, du, db):
user_id = int(user_id)
if not du.user_exists(user_id): du.add_user(user_id)
def pars_post(types, output):
media, ttl, url, audio, poll, link = [], [], [], [], [], [] user_id = du.get_user_id(user_id)
if not db.infoUser_exists(user_id): db.add_infoUser(user_id)
for group in output[0]:
if group[0] == "poll": poll = group[1] return user_id
elif group[0] == "link": link.append(group[1])
elif group[0] == "video": ttl.append(group[1])
elif group[0] == "doc" and group[1][2] != 4: url.append([group[1][0], group[1][1], group[1][3]]) def pars_post(output):
elif group[0] == "audio": audio.append(types.InputMediaAudio(media=group[1][1], parse_mode=types.ParseMode.MARKDOWN, """Пост из vk_scripts.get_output → текст в HTML, аудио и медиа для Telegram.
caption=f"*Название: {group[1][0]} / Автор: {group[1][2]}*")) Всё, что пришло из ВК, экранируется: «<» или «&» в посте иначе ломают отправку"""
media, video, url, audio, poll, link = [], [], [], [], [], []
if output[1] != "": text = f"{output[2]}\n\n*{output[1]}*\n"
else: text = f"{output[2]}\n" for group in output[0]:
if group[0] == "poll": poll = group[1]
if poll: elif group[0] == "link": link.append(group[1])
text += f"\n*> Проводиться {config.anonymous[poll[3]]} {config.unvote[poll[2]]}!" elif group[0] == "video": video.append(group[1])
text += f"*\n*---------------------*\n*- Вопрос: {poll[0]} [{poll[1]}]*\n" elif group[0] == "doc" and group[1][2] != 4: url.append([group[1][0], group[1][1], group[1][3]])
text += ''.join([f'*{i}. Ответ: {ans[0]} [{ans[1]}]*\n' for i, ans in enumerate(poll[4])]) elif group[0] == "audio": audio.append(types.InputMediaAudio(
text += "*---------------------*\n" media=group[1][1], caption=f"<b>Название: {escape(group[1][0])} / Автор: {escape(group[1][2])}</b>"))
if ttl: text += f"\n*Видео -* находятся в оригинальном посте в количестве {len(ttl)} записей\n" if output[1] != "":
# упоминания ВК вида [id1|Имя] превращаются в ссылки
if url: post = [elem_2 for elem_1 in escape(output[1], quote=False).split("[") for elem_2 in elem_1.split("]")]
for data in url: for i, elem in enumerate(post):
k, dt = 0, data[2] if "|" in elem and i != 0:
while True: elem = elem.split("|", 1)
if dt > 1024: dt = dt // 1024; k += 1 post[i] = f"<a href='https://vk.com/{escape(elem[0])}'>{elem[1]}</a>"
else: break
text += f"\n*Документ - [* [{data[0]}]({data[1]}) ({dt} {config.data[k]}) *]*" text = f"{output[2]}\n\n{''.join(post)}\n"
text += "\n" else: text = f"{output[2]}\n"
if link: text += ''.join([f"\n*Ссылка - [* [{s[0]}]({s[1]}) *]*" for s in link]) + "\n" if poll:
if audio: text += '\n*« Также присутствуют аудио файлы »*' text += (f"\n<b>> Проводится</b> {config.anonymous[poll[3]]} {config.unvote[poll[2]]}! "
f"<b>Вопрос:</b> {escape(poll[0])} <b>[{poll[1]}]</b>\n")
for i, group in enumerate(output[0]): text += ''.join([f'<b>{i + 1}. Ответ:</b> {escape(ans[0])} <b>[{ans[1]}]</b>\n'
if group[0] == "photo": for i, ans in enumerate(poll[4])]) + "\n"
if i: media.append(types.InputMediaPhoto(media=group[1]))
else: media.append(types.InputMediaPhoto(media=group[1], caption=text, parse_mode=types.ParseMode.MARKDOWN)) if video:
elif group[0] == "doc": for frame in video:
if group[1][2] == 4: text += f"\n<b>Видео</b> <i>« {escape(frame[0])} »</i>\n"
if i: media.append(types.InputMediaPhoto(media=group[1][1]))
else: media.append(types.InputMediaPhoto(media=group[1][1], caption=text, parse_mode=types.ParseMode.MARKDOWN)) k, dura = 0, frame[1]
if dura > 60: dura, k = dura // 60, 1
return text, audio, media
text += f"<b>Длительность:</b> {dura} {config.dura[k]}; <b>Просмотров:</b> {frame[2]};\n"
if url:
for data in url:
k, dt = 0, data[2]
while dt > 1024 and k < len(config.data) - 1:
dt, k = dt // 1024, k + 1
text += (f"\n<b>Документ - [</b> <a href='{escape(data[1])}'>{escape(data[0])}</a> "
f"({dt} {config.data[k]}) <b>]</b>")
text += "\n"
if link:
text += ''.join([f"\n<b>Ссылка - [</b> <a href='{escape(s[1])}'>{escape(s[0])}</a> <b>]</b>"
for s in link]) + "\n"
if audio: text += '\n<b>« Также присутствуют аудио файлы »</b>'
for group in output[0]:
if group[0] == "photo":
media.append(types.InputMediaPhoto(media=group[1]))
elif group[0] == "video" and group[1][3]: # у видео может не быть обложки
media.append(types.InputMediaPhoto(media=group[1][3]))
elif group[0] == "doc" and group[1][2] == 4:
media.append(types.InputMediaPhoto(media=group[1][1]))
return text, audio[:10], media[:10] # в одном альбоме Telegram не больше 10 элементов
def split_text(text, limit=TEXT_LIMIT):
"""Делит длинный текст по строкам, чтобы не разрывать HTML-теги посередине"""
parts, current = [], ""
for line in text.split("\n"):
while len(line) > limit: # одна строка длиннее лимита — режем как есть
if current: parts.append(current); current = ""
parts.append(line[:limit]); line = line[limit:]
if len(current) + len(line) + 1 > limit:
parts.append(current); current = ""
current += line + "\n"
if current.strip(): parts.append(current)
return parts
async def send_post(bot, chat_id, text, audio, media):
"""Отправляет пост с учётом ограничений Telegram: альбом — от 2 до 10 элементов, подпись —
до 1024 символов, сообщение — до 4096"""
first = None
if media:
caption = text if len(text) <= CAPTION_LIMIT else None
if len(media) == 1:
first = await bot.send_photo(chat_id=chat_id, photo=media[0].media, caption=caption)
else:
media[0].caption = caption
first = (await bot.send_media_group(chat_id=chat_id, media=media))[0]
if caption is None:
for part in split_text(text):
await bot.send_message(chat_id=chat_id, text=part, disable_web_page_preview=True,
reply_to_message_id=first.message_id)
else:
for part in split_text(text):
sent = await bot.send_message(chat_id=chat_id, text=part, disable_web_page_preview=True)
first = first or sent
if audio:
reply_to = first.message_id if first else None
if len(audio) == 1:
await bot.send_audio(chat_id=chat_id, audio=audio[0].media, caption=audio[0].caption,
reply_to_message_id=reply_to)
else:
await bot.send_media_group(chat_id=chat_id, media=audio, reply_to_message_id=reply_to)

View file

@ -7,6 +7,11 @@ class Users:
self.connection = sqlite3.connect(database) self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor() self.cursor = self.connection.cursor()
# на новой установке (например, в 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 # КОМАНДЫ USER
def user_exists(self, user_id): def user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе""" """Проверяем, есть ли уже пользователь в базе"""
@ -63,6 +68,12 @@ class Base:
self.connection = sqlite3.connect(database) self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor() 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 # КОМАНДЫ USER
def infoUser_exists(self, user_id): def infoUser_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" """Проверяем, есть ли данные уже в базе"""

View file

@ -1,76 +1,111 @@
import vk_api from html import escape
import vk_api
class VkParser:
def __init__(self, token):
"""Подключаемся к API VK""" class VkParser:
self.session = vk_api.VkApi(token=token) def __init__(self, token):
self.vk = self.session.get_api() """Подключаемся к API VK"""
self.session = vk_api.VkApi(token=token)
def check(self): self.vk = self.session.get_api()
return self.vk.account.getInfo(fields="lang")
def check(self):
def like(self, owner_id, post_id): return self.vk.account.getInfo(fields="lang")
return self.vk.likes.add(type="post", owner_id=owner_id, item_id=post_id)
def like(self, owner_id, post_id):
def login(self, domain): return self.vk.likes.add(type="post", owner_id=owner_id, item_id=post_id)
posts = self.vk.wall.get(domain=domain, count=3)
post = max([[int(pt["id"]), int(pt["owner_id"])] for pt in posts["items"]]) def login(self, domain):
posts = self.vk.wall.get(domain=domain, count=3)
[last_post, group_id] = post post = max([[int(pt["id"]), int(pt["owner_id"])] for pt in posts["items"]])
typ = 0 if group_id > 0 else 1
return group_id, typ, last_post [last_post, group_id] = post
typ = 0 if group_id > 0 else 1
def info(self, group_ids): return group_id, typ, last_post
groups, peoples = [], []
def info(self, group_ids):
for id in group_ids: groups, peoples = [], []
if id > 0: peoples.append(id)
else: groups.append(id) for id in group_ids:
if id > 0: peoples.append(id)
if groups: else: groups.append(id)
data = self.vk.groups.getById(group_ids=", ".join(list(map(lambda x: str(x)[1:], groups))))
groups = [[elem['screen_name'], elem['name']] for elem in data] if groups:
data = self.vk.groups.getById(group_ids=", ".join(list(map(lambda x: str(x)[1:], groups))))
if peoples: groups = [[elem['screen_name'], elem['name']] for elem in data]
data = self.vk.users.get(user_ids=", ".join(list(map(str, peoples))), fields="domain, online")
peoples = [[elem['domain'], elem['first_name'] + ' ' + elem['last_name'], elem['online']] for elem in data] if peoples:
data = self.vk.users.get(user_ids=", ".join(list(map(str, peoples))), fields="domain, online")
return peoples, groups peoples = [[elem['domain'], elem['first_name'] + ' ' + elem['last_name'], elem['online']] for elem in data]
def last_post(self, owner_id=None, domain=None): return peoples, groups
wall = self.vk.wall.get(owner_id=owner_id, domain=domain, count=2, extended=1)
post = max([[int(pt["id"]), pt] for pt in wall["items"]]) def last_post(self, owner_id=None, domain=None, post=None):
if post: wall = self.vk.wall.getById(posts=post, extended=1)
[post_id, post] = post else: wall = self.vk.wall.get(owner_id=owner_id, domain=domain, count=2, extended=1)
owner_id, output = post['owner_id'], []
post = max([[int(pt["id"]), pt] for pt in wall["items"]])
if owner_id > 0: [name, domain] = [[per['first_name'] + ' ' + per['last_name'], per['screen_name']] owner_id = post[1]['owner_id']
for per in wall["profiles"] if per["id"] == owner_id][0]
else: [name, domain] = [[gro['name'], gro['screen_name']] if owner_id > 0:
for gro in wall["groups"] if gro["id"] == int(str(owner_id)[1:])][0] [name, domain] = [[per['first_name'] + ' ' + per['last_name'], per['screen_name']] for per in wall["profiles"] if per["id"] == owner_id][0]
else:
attachments = post['attachments'] [name, domain] = [[gro['name'], gro['screen_name']] for gro in wall["groups"] if gro["id"] == int(str(owner_id)[1:])][0]
types = [[typ['type'], typ] for typ in attachments]
if 'copy_history' in post[1]:
for typ in types: comment, orig_id = post[1]['text'], post[0]
if typ[0] == 'photo':
output.append([typ[0], typ[1]['photo']['sizes'][-1]['url']]) post = [post[1]['copy_history'][0]['id'], post[1]['copy_history'][0]]
elif typ[0] == 'video': owner_id_r = post[1]['owner_id']
output.append([typ[0], typ[1]['video']['title']])
elif typ[0] == "doc": if owner_id_r > 0:
output.append([typ[0], [typ[1]['doc']['title'], typ[1]['doc']['url'], [name_r, domain_r] = [[per['first_name'] + ' ' + per['last_name'], per['screen_name']] for per in wall["profiles"] if per["id"] == owner_id_r][0]
typ[1]['doc']['type'], typ[1]['doc']['size']]]) else:
elif typ[0] == "audio": [name_r, domain_r] = [[gro['name'], gro['screen_name']] for gro in wall["groups"] if gro["id"] == int(str(owner_id_r)[1:])][0]
output.append([typ[0], [typ[1]['audio']['title'], typ[1]['audio']['url'], typ[1]['audio']['artist']]])
elif typ[0] == "poll": return get_output(post, name_r, domain_r, [orig_id, owner_id, name, domain, comment])
answer = [[que['text'], que['votes']] for que in typ[1]['poll']['answers']] else:
output.append([typ[0], [typ[1]['poll']['question'], typ[1]['poll']['votes'], return get_output(post, name, domain)
typ[1]['poll']['disable_unvote'], typ[1]['poll']['anonymous'], answer]])
elif typ[0] == "link":
output.append([typ[0], [typ[1]['link']['title'], typ[1]['link']['url']]]) def get_output(post, name, domain, repost=None):
[post_id, post] = post
return [output, post['text'], owner_id, output = post['owner_id'], []
f"*Автор поста -* [{name}](https://vk.com/{domain}?w=wall{owner_id}_{post_id})", post_id]
attachments = post.get('attachments', [])
types = [[typ['type'], typ] for typ in attachments]
for typ in types:
if typ[0] == 'photo':
output.append([typ[0], typ[1]['photo']['sizes'][-1]['url']])
elif typ[0] == 'video':
video = typ[1]['video']
# обложка: самая крупная из доступных
frame = video.get('photo_1280') or video.get('photo_800') or video.get('photo_320')
if frame is None and video.get('image'):
frame = video['image'][-1]['url']
output.append([typ[0], [video['title'], video.get('duration', 0), video.get('views', 0), frame]])
elif typ[0] == "doc":
output.append([typ[0], [typ[1]['doc']['title'], typ[1]['doc']['url'],
typ[1]['doc']['type'], typ[1]['doc']['size']]])
elif typ[0] == "audio":
output.append([typ[0], [typ[1]['audio']['title'], typ[1]['audio']['url'], typ[1]['audio']['artist']]])
elif typ[0] == "poll":
answer = [[que['text'], que['votes']] for que in typ[1]['poll']['answers']]
output.append([typ[0], [typ[1]['poll']['question'], typ[1]['poll']['votes'],
typ[1]['poll']['disable_unvote'], typ[1]['poll']['anonymous'], answer]])
elif typ[0] == "link":
output.append([typ[0], [typ[1]['link']['title'], typ[1]['link']['url']]])
if repost:
[author_id, author_owner_id, author_name, author_domain, author_comment] = repost
comment = f"<b>Автор репоста -</b> <a href='https://vk.com/{author_domain}?w=wall{author_owner_id}_{author_id}'>{escape(author_name)}</a>"
if author_comment != "": comment += f"\n<blockquote>{escape(author_comment)}</blockquote>\n"
comment += f"\n<b>Автор поста -</b> <a href='https://vk.com/{domain}?w=wall{owner_id}_{post_id}'>{escape(name)}</a>"
return [output, f"{post['text']}", comment, author_id]
else:
return [output, f"{post['text']}",
f"<b>Автор поста -</b> <a href='https://vk.com/{domain}?w=wall{owner_id}_{post_id}'>{escape(name)}</a>", post_id]

11
compose.yaml Normal file
View file

@ -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:

0
db/.gitkeep Normal file
View file

Binary file not shown.

Binary file not shown.

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
ruff==0.14.0

View file

@ -1,18 +1,3 @@
# Telegram Bot Framework aiogram==3.23.0
aiogram==2.25.1 vk_api==11.10.1
emoji==2.16.0
# 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

4
tests/conftest.py Normal file
View file

@ -0,0 +1,4 @@
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))

96
tests/test_post.py Normal file
View file

@ -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|Иван] пишет <b>", []))
text, audio, media = scripts.pars_post(out)
assert "1 &lt; 2 &amp; <a href='https://vk.com/id5'>Иван</a> пишет &lt;b&gt;" in text
assert "Паблик &lt;&amp;&gt;" 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 "<a href='https://example.com/?a=1&amp;b=2'>Сайт</a>" 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 "<blockquote>мой комментарий</blockquote>" in text
def test_split_text_keeps_lines():
text = "\n".join(f"<b>строка {i}</b>" for i in range(1000))
parts = scripts.split_text(text, 4096)
assert all(len(p) <= 4096 for p in parts)
assert "".join(parts).count("<b>") == 1000 and all(p.count("<b>") == p.count("</b>") 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