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

..

No commits in common. "b7a1f6ea220c3abe9ee289c9bf714a17d50a64e8" and "c93ffe7c57abd3b23394369b11898e49a1d68d65" have entirely different histories.

24 changed files with 599 additions and 1040 deletions

View file

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

2
.gitattributes vendored
View file

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

View file

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

View file

@ -1,42 +0,0 @@
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
View file

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

View file

@ -1,19 +0,0 @@
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
View file

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

View file

@ -1,86 +0,0 @@
# 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,87 +1,155 @@
# Portal in VK # Repost from Vk to Tg 🔄
**Русский** · [English](README.en.md) [![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)
[![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) **Автоматизированная система репостинга контента из VKontakte в Telegram**
[![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
git clone https://github.com/EDeev/vkrepost_to_tg.git && cd vkrepost_to_tg pip install -r requirements.txt
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`. ### Конфигурация
Базы SQLite создаются при первом запуске. 1. Создайте Telegram-бота через [@BotFather](https://t.me/botfather)
2. Получите служебный токен VK API
3. Настройте файл `config.py`:
Без Docker: Python 3.12, `pip install -r requirements.txt`, затем ```python
`cd code && BOT_TOKEN=… VK_SERVICE_TOKEN=… python bot.py`. # TOKENS
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
pip install -r requirements-dev.txt python bot.py
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>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub> <sub>Проект носит некоммерческий характер и предназначен для образовательных целей и личного использования.</sub>
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p> <p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
</div> </div>

View file

@ -1,66 +1,306 @@
import asyncio import logging, config, asyncio, time, sql, random
import logging
import random
import scripts from aiogram import Bot, Dispatcher, executor, types
import vk_scripts from emoji import emojize
from init import bot, dp, du, db
from config import serviceToken from vk_scripts import VkParser
from handlers import router from scripts import login, pars_post
# log level
logging.basicConfig(level=logging.INFO)
# bot init
bot = Bot(token=config.botToken)
dp = Dispatcher(bot)
# connection db
du = sql.Users('../db/users.db')
db = sql.Base('../db/base.db')
async def check_group(group_id, activ_user, sub_user): # events
tokens = [x[0] for x in activ_user if group_id in x[1]] @dp.message_handler(commands=["start", "help"])
users = [x[0] for x in sub_user if group_id in x[1]] async def start(message: types.Message):
login(message.chat.id, du, db)
# токен случайного подписчика даёт доступ к закрытым страницам, иначе — сервисный ключ buttons = [types.InlineKeyboardButton(text="КОМАНДЫ", callback_data="com"),
if tokens: types.InlineKeyboardButton(text="АВТОР", callback_data="auth")]
vk = vk_scripts.VkParser(db.get_token(random.choice(tokens)))
else: vk = vk_scripts.VkParser(serviceToken)
# vk_api синхронный — в отдельном потоке, чтобы не замирал весь бот keyboard = types.InlineKeyboardMarkup(row_width=2)
output = await asyncio.to_thread(vk.last_post, owner_id=du.get_vk_id(group_id)) keyboard.add(*buttons)
last_post = db.get_postGroup(group_id)
if output[3] > last_post: await message.answer(text=f'*Portal in VK* - это бот для перепоста постов со страниц в социальной сети ВКонтакте. '
text, audio, media = scripts.pars_post(output) f'Для начала работы вам нужно всего лишь вызвать команду */add* и добавить к ней короткое '
db.update_postGroup(group_id, output[3]) f'имя личной или публичной страницы. После этого, бот начнёт присылать каждый новый пост '
f'с этой страницы форматируя данные с неё под сообщение в Telegram. С полным функционалом '
f'бота, вы можете ознакомиться по кнопке *КОМАНДЫ*\n\n'
for sub in users: f'Если вы хотите ставить лайки или получать уведомления от закрытых страниц на которые '
f'подписаны в социальной сети, вам необходимо скинуть ссылку из поисковой строки в чат, '
f'после подтверждения на [сайте]({config.loginUrl})',
parse_mode=types.ParseMode.MARKDOWN, reply_markup=keyboard)
# ИЛАЙН КЛАВИАТУРА HELP
@dp.callback_query_handler(text="com")
async def function(call: types.CallbackQuery):
await call.message.answer(text='*| КОМАНДЫ |*\n\n'
f'*/like* - лайк на пост, который вы отметили\n'
f'*/list* - список страниц от, которых вы получаете уведомления\n'
f'*/notif* - отписка или подписка от всех уведомлений\n'
f'*/last_post* - получение последнего поста по короткому имени страницы\n'
f'*/add* и */del* - добавление и удаление странички из списка подписок\n',
parse_mode=types.ParseMode.MARKDOWN)
@dp.callback_query_handler(text="auth")
async def author(call: types.CallbackQuery):
await call.message.answer(text='*| АВТОР |*\n\n*>>* Этот бот не коммерческий проект, для получения постов из '
'социальной сети ВКонтакте. Бот работает на сервисном токене VK API, пока вы не '
'предоставите собственный. Ваш токен нужен для получения постов со страниц, '
'доступ к которым есть исключительно у вас. Также это даст вам возможность ставить '
'через Telegram лайки на посты в самой социальной сети!'
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать '
'больше на моём [GitHub](https://github.com/IGlek).',
parse_mode=types.ParseMode.MARKDOWN)
# КОМАНДЫ
@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: try:
await scripts.send_post(bot, du.get_tg_id(sub), text, audio, media) token = db.get_token(user_id)
except Exception: if token: vk = VkParser(token)
# подписчик мог заблокировать бота — остальным пост всё равно уходит else: vk = VkParser(config.serviceToken)
logging.warning("Не удалось отправить пост подписчику %s", sub, exc_info=True)
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)
async def timer(wait_for) -> None: @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: while True:
await asyncio.sleep(wait_for) await asyncio.sleep(wait_for)
notif_group = [x[0] for x in db.all_notifGroup()] notif_group = list(map(lambda x: x[0], db.all_notifGroup()))
activ_user = [[x[0], list(map(int, x[1].split(';')))] for x in db.all_activUser() if x[1]] activ_user = list(map(lambda x: [x[0], list(map(int, x[1].split(';')))], db.all_activUser()))
sub_user = [[x[0], list(map(int, x[1].split(';')))] for x in db.all_subUser() if x[1]] sub_user = list(map(lambda x: [x[0], list(map(int, x[1].split(';')))], db.all_subUser()))
for group_id in notif_group: for group_id in notif_group:
await asyncio.sleep(1) # пауза между страницами — лимиты VK API 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: try:
await check_group(group_id, activ_user, sub_user) output = vk.last_post(owner_id=du.get_vk_id(group_id))
except Exception: last_post = db.get_postGroup(group_id)
logging.error("Ошибка обновления поста:", exc_info=True)
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))
async def main() -> None: if __name__ == '__main__':
dp.include_router(router) loop = asyncio.get_event_loop()
await bot.delete_webhook(drop_pending_updates=True) loop.create_task(timer(60)) # ПРОВЕРКА КАЖДУЮ МИНУТУ
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) executor.start_polling(dp, skip_updates=True)
async def process() -> None:
await asyncio.gather(main(), timer(60))
if __name__ == "__main__":
try: asyncio.run(process())
except KeyboardInterrupt: pass

View file

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

View file

@ -1,306 +0,0 @@
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>, необходима ссылка, что отобразилась в строке "
"поиске после подтверждения доступа!")

View file

@ -1,21 +0,0 @@
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,12 +1,5 @@
from html import escape
from aiogram import types
import config import config
CAPTION_LIMIT = 1024 # подпись к фото в Telegram
TEXT_LIMIT = 4096 # обычное сообщение
def login(user_id, du, db): def login(user_id, du, db):
user_id = int(user_id) user_id = int(user_id)
@ -18,109 +11,47 @@ def login(user_id, du, db):
return user_id return user_id
def pars_post(output): def pars_post(types, output):
"""Пост из vk_scripts.get_output → текст в HTML, аудио и медиа для Telegram. media, ttl, url, audio, poll, link = [], [], [], [], [], []
Всё, что пришло из ВК, экранируется: «<» или «&» в посте иначе ломают отправку"""
media, video, url, audio, poll, link = [], [], [], [], [], []
for group in output[0]: for group in output[0]:
if group[0] == "poll": poll = group[1] if group[0] == "poll": poll = group[1]
elif group[0] == "link": link.append(group[1]) elif group[0] == "link": link.append(group[1])
elif group[0] == "video": video.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]]) elif group[0] == "doc" and group[1][2] != 4: url.append([group[1][0], group[1][1], group[1][3]])
elif group[0] == "audio": audio.append(types.InputMediaAudio( elif group[0] == "audio": audio.append(types.InputMediaAudio(media=group[1][1], parse_mode=types.ParseMode.MARKDOWN,
media=group[1][1], caption=f"<b>Название: {escape(group[1][0])} / Автор: {escape(group[1][2])}</b>")) caption=f"*Название: {group[1][0]} / Автор: {group[1][2]}*"))
if output[1] != "": if output[1] != "": text = f"{output[2]}\n\n*{output[1]}*\n"
# упоминания ВК вида [id1|Имя] превращаются в ссылки
post = [elem_2 for elem_1 in escape(output[1], quote=False).split("[") for elem_2 in elem_1.split("]")]
for i, elem in enumerate(post):
if "|" in elem and i != 0:
elem = elem.split("|", 1)
post[i] = f"<a href='https://vk.com/{escape(elem[0])}'>{elem[1]}</a>"
text = f"{output[2]}\n\n{''.join(post)}\n"
else: text = f"{output[2]}\n" else: text = f"{output[2]}\n"
if poll: if poll:
text += (f"\n<b>> Проводится</b> {config.anonymous[poll[3]]} {config.unvote[poll[2]]}! " text += f"\n*> Проводиться {config.anonymous[poll[3]]} {config.unvote[poll[2]]}!"
f"<b>Вопрос:</b> {escape(poll[0])} <b>[{poll[1]}]</b>\n") text += f"*\n*---------------------*\n*- Вопрос: {poll[0]} [{poll[1]}]*\n"
text += ''.join([f'<b>{i + 1}. Ответ:</b> {escape(ans[0])} <b>[{ans[1]}]</b>\n' text += ''.join([f'*{i}. Ответ: {ans[0]} [{ans[1]}]*\n' for i, ans in enumerate(poll[4])])
for i, ans in enumerate(poll[4])]) + "\n" text += "*---------------------*\n"
if video: if ttl: text += f"\n*Видео -* находятся в оригинальном посте в количестве {len(ttl)} записей\n"
for frame in video:
text += f"\n<b>Видео</b> <i>« {escape(frame[0])} »</i>\n"
k, dura = 0, frame[1]
if dura > 60: dura, k = dura // 60, 1
text += f"<b>Длительность:</b> {dura} {config.dura[k]}; <b>Просмотров:</b> {frame[2]};\n"
if url: if url:
for data in url: for data in url:
k, dt = 0, data[2] k, dt = 0, data[2]
while dt > 1024 and k < len(config.data) - 1: while True:
dt, k = dt // 1024, k + 1 if dt > 1024: dt = dt // 1024; k += 1
text += (f"\n<b>Документ - [</b> <a href='{escape(data[1])}'>{escape(data[0])}</a> " else: break
f"({dt} {config.data[k]}) <b>]</b>") text += f"\n*Документ - [* [{data[0]}]({data[1]}) ({dt} {config.data[k]}) *]*"
text += "\n" text += "\n"
if link: if link: text += ''.join([f"\n*Ссылка - [* [{s[0]}]({s[1]}) *]*" for s in link]) + "\n"
text += ''.join([f"\n<b>Ссылка - [</b> <a href='{escape(s[1])}'>{escape(s[0])}</a> <b>]</b>" if audio: text += '\n*« Также присутствуют аудио файлы »*'
for s in link]) + "\n"
if audio: text += '\n<b>« Также присутствуют аудио файлы »</b>'
for group in output[0]: for i, group in enumerate(output[0]):
if group[0] == "photo": if group[0] == "photo":
media.append(types.InputMediaPhoto(media=group[1])) if i: media.append(types.InputMediaPhoto(media=group[1]))
elif group[0] == "video" and group[1][3]: # у видео может не быть обложки else: media.append(types.InputMediaPhoto(media=group[1], caption=text, parse_mode=types.ParseMode.MARKDOWN))
media.append(types.InputMediaPhoto(media=group[1][3])) elif group[0] == "doc":
elif group[0] == "doc" and group[1][2] == 4: if group[1][2] == 4:
media.append(types.InputMediaPhoto(media=group[1][1])) 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))
return text, audio[:10], media[:10] # в одном альбоме Telegram не больше 10 элементов return text, audio, media
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,11 +7,6 @@ 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):
"""Проверяем, есть ли уже пользователь в базе""" """Проверяем, есть ли уже пользователь в базе"""
@ -68,12 +63,6 @@ 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,5 +1,3 @@
from html import escape
import vk_api import vk_api
@ -40,52 +38,26 @@ class VkParser:
return peoples, groups return peoples, groups
def last_post(self, owner_id=None, domain=None, post=None): def last_post(self, owner_id=None, domain=None):
if post: wall = self.vk.wall.getById(posts=post, extended=1) wall = self.vk.wall.get(owner_id=owner_id, domain=domain, count=2, extended=1)
else: 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"]]) post = max([[int(pt["id"]), pt] for pt in wall["items"]])
owner_id = post[1]['owner_id']
if owner_id > 0:
[name, domain] = [[per['first_name'] + ' ' + per['last_name'], per['screen_name']] for per in wall["profiles"] if per["id"] == owner_id][0]
else:
[name, domain] = [[gro['name'], gro['screen_name']] for gro in wall["groups"] if gro["id"] == int(str(owner_id)[1:])][0]
if 'copy_history' in post[1]:
comment, orig_id = post[1]['text'], post[0]
post = [post[1]['copy_history'][0]['id'], post[1]['copy_history'][0]]
owner_id_r = post[1]['owner_id']
if owner_id_r > 0:
[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]
else:
[name_r, domain_r] = [[gro['name'], gro['screen_name']] for gro in wall["groups"] if gro["id"] == int(str(owner_id_r)[1:])][0]
return get_output(post, name_r, domain_r, [orig_id, owner_id, name, domain, comment])
else:
return get_output(post, name, domain)
def get_output(post, name, domain, repost=None):
[post_id, post] = post [post_id, post] = post
owner_id, output = post['owner_id'], [] owner_id, output = post['owner_id'], []
attachments = post.get('attachments', []) if owner_id > 0: [name, domain] = [[per['first_name'] + ' ' + per['last_name'], per['screen_name']]
for per in wall["profiles"] if per["id"] == owner_id][0]
else: [name, domain] = [[gro['name'], gro['screen_name']]
for gro in wall["groups"] if gro["id"] == int(str(owner_id)[1:])][0]
attachments = post['attachments']
types = [[typ['type'], typ] for typ in attachments] types = [[typ['type'], typ] for typ in attachments]
for typ in types: for typ in types:
if typ[0] == 'photo': if typ[0] == 'photo':
output.append([typ[0], typ[1]['photo']['sizes'][-1]['url']]) output.append([typ[0], typ[1]['photo']['sizes'][-1]['url']])
elif typ[0] == 'video': elif typ[0] == 'video':
video = typ[1]['video'] output.append([typ[0], typ[1]['video']['title']])
# обложка: самая крупная из доступных
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": elif typ[0] == "doc":
output.append([typ[0], [typ[1]['doc']['title'], typ[1]['doc']['url'], output.append([typ[0], [typ[1]['doc']['title'], typ[1]['doc']['url'],
typ[1]['doc']['type'], typ[1]['doc']['size']]]) typ[1]['doc']['type'], typ[1]['doc']['size']]])
@ -98,14 +70,7 @@ def get_output(post, name, domain, repost=None):
elif typ[0] == "link": elif typ[0] == "link":
output.append([typ[0], [typ[1]['link']['title'], typ[1]['link']['url']]]) output.append([typ[0], [typ[1]['link']['title'], typ[1]['link']['url']]])
if repost: return [output, post['text'],
[author_id, author_owner_id, author_name, author_domain, author_comment] = repost f"*Автор поста -* [{name}](https://vk.com/{domain}?w=wall{owner_id}_{post_id})", post_id]
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]

View file

@ -1,11 +0,0 @@
services:
bot:
build: .
image: ghcr.io/edeev/vkrepost_to_tg:latest
env_file: .env
volumes:
- db:/app/db
restart: unless-stopped
volumes:
db:

View file

BIN
db/base.db Normal file

Binary file not shown.

BIN
db/users.db Normal file

Binary file not shown.

View file

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

View file

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

View file

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

View file

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