Initial commit for RedAirsoft VK to TG and MAX poster

This commit is contained in:
2026-08-14 19:10:27 +05:00
commit 41722c0ffc
17 changed files with 2356 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# ==========================================
# VKontakte Settings
# ==========================================
# VK User/Service access token
VK_ACCESS_TOKEN=vk1.a.your_vk_token_here
# Target VK source: screen name, URL or owner_id (e.g., "redairsoft", "club123456", "-123456", "https://vk.com/redairsoft")
VK_SOURCE=redairsoft
# VK API Version
VK_API_VERSION=5.199
# Number of wall posts to fetch each cycle (default 10)
VK_CHECK_COUNT=10
# ==========================================
# Telegram Settings
# ==========================================
# Telegram Bot Token (from @BotFather)
TG_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
# Destination Chat/Channel ID (e.g., "-1001234567890" or with topic thread "-1001234567890:42")
TG_CHAT_ID=-1001234567890
# Optional TG Media Storage Channel (e.g. for generating permanent file_ids)
TG_MEDIA_CHANNEL_ID=
# Admin Telegram ID(s) for notifications & reports (single ID or comma-separated "123456,789012")
TG_ADMIN_IDS=123456789
# ==========================================
# Local Telegram Bot API (Optional)
# If TELEGRAM_API_ID & TELEGRAM_API_HASH are provided, local API server runs inside container automatically
# ==========================================
LOCAL_BOT_API_URL=
TELEGRAM_API_ID=
TELEGRAM_API_HASH=
# ==========================================
# MAX Messenger Settings
# ==========================================
# MAX Bot Token
MAX_BOT_TOKEN=your_max_bot_token_here
# MAX Destination Chat ID
MAX_CHAT_ID=123456
# MAX API Base URL
MAX_API_BASE_URL=https://platform-api2.max.ru
# MAX Auto Reaction emoji on published post
MAX_AUTO_REACTION=👍
MAX_AUTO_REACTION_ENABLED=true
# ==========================================
# Polling, Reporting & Maintenance
# ==========================================
# Check interval in minutes (default: 15)
CHECK_INTERVAL_MINUTES=15
# Initial start behavior on fresh DB:
# "skip_existing" (default) - ignores old history, posts only new posts published after start
# "publish_latest_one" - publishes only the 1 latest post and skips older ones
# "publish_all" - publishes all wall posts fetched
BOOTSTRAP_MODE=skip_existing
# Send report to admin even if 0 new posts were found
REPORT_EMPTY_RUNS=false
# Send report to admin if an error occurred during checking/posting
REPORT_ON_ERROR=true
# Clean stale temp files older than X minutes (default: 30)
CACHE_MAX_AGE_MINUTES=30
# ==========================================
# Media & Video Limits
# ==========================================
# Max video size for Cloud TG API in MB (limit: 49-50 MB)
VIDEO_MAX_SIZE_MB_CLOUD=49
# Max video size when using Local TG API in MB (supports up to 2000 MB)
VIDEO_MAX_SIZE_MB_LOCAL=500
# Max video duration in seconds (default: 600s / 10 min)
VIDEO_MAX_DURATION_SEC=600
# Max video resolution height (default: 720p)
VIDEO_MAX_HEIGHT=720
# ==========================================
# Post Formatting & Customization
# ==========================================
# Text to prepend before every post
HEADER_TEXT=
# Text to append after every post
FOOTER_TEXT=
# Common hashtags to attach (e.g., "#redairsoft #страйкбол")
COMMON_TAGS=#redairsoft #страйкбол
# Make the first line of the post bold
FORMAT_FIRST_LINE_BOLD=true
# Logging Level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
+11
View File
@@ -0,0 +1,11 @@
.env
data/
cache/
__pycache__/
*.py[cod]
*$py.class
.venv/
env/
venv/
*.log
.DS_Store
+49
View File
@@ -0,0 +1,49 @@
FROM aiogram/telegram-bot-api:latest AS telegram-bot-api
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies & ffmpeg
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Russian Trusted CA certificates
RUN mkdir -p /usr/local/share/ca-certificates/russian-trusted \
&& curl -fsSLk https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt \
-o /usr/local/share/ca-certificates/russian-trusted/russian_trusted_root_ca_pem.crt \
&& curl -fsSLk https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt \
-o /usr/local/share/ca-certificates/russian-trusted/russian_trusted_sub_ca_pem.crt \
&& update-ca-certificates
# Copy local Telegram Bot API binary and dependencies
COPY --from=telegram-bot-api /usr/local/bin/telegram-bot-api /usr/local/bin/telegram-bot-api
COPY --from=telegram-bot-api /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
COPY --from=telegram-bot-api /usr/lib/libssl.so.3 /usr/lib/libssl.so.3
COPY --from=telegram-bot-api /usr/lib/libcrypto.so.3 /usr/lib/libcrypto.so.3
COPY --from=telegram-bot-api /usr/lib/libz.so.1 /usr/lib/libz.so.1
COPY --from=telegram-bot-api /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
COPY --from=telegram-bot-api /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
# Install Python requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code and entrypoint
COPY src/ src/
COPY docker-entrypoint.sh /usr/local/bin/poster-entrypoint
RUN chmod +x /usr/local/bin/poster-entrypoint \
&& mkdir -p /app/data /tmp/poster_cache /var/lib/telegram-bot-api /tmp/telegram-bot-api
ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1
ENV TELEGRAM_WORK_DIR=/var/lib/telegram-bot-api
ENV TELEGRAM_TEMP_DIR=/tmp/telegram-bot-api
ENV TELEGRAM_HTTP_PORT=8081
ENTRYPOINT ["poster-entrypoint"]
CMD ["python", "src/main.py"]
+83
View File
@@ -0,0 +1,83 @@
# RedAirsoft VK to Telegram & MAX Messenger Poster
Автоматизированный Docker-сервис для периодического парсинга новых постов из сообщества ВКонтакте и их одновременной публикации в **Telegram** и мессенджер **МАКС** (MAX Messenger) с отправкой отчётов администраторам в личные сообщения.
---
## ⚡ Особенности и возможности
1. **Telegram Rich Messages (современный формат) & Legacy fallback**:
- Автоматическая сборка Rich Messages с `<tg-collage>`, инлайн-фото/видео и форматированным текстом.
- Поддержка облачного Telegram Bot API и локального `telegram-bot-api` (для видео больше 50 МБ).
- При недоступности формата Rich Message — автоматический переход на классические медиагруппы (`sendMediaGroup` / `sendPhoto` / `sendVideo`).
2. **Надёжная интеграция с Мессенджером МАКС**:
- Загрузка изображений и видео через `/uploads?type=...`.
- Полный цикл поллинга готовности видео (`/videos/{token}`) перед отправкой сообщения.
- Обработка `attachment.not.ready` и рейтов (429 Retry-After).
- Автоматическая простановка реакций (например, 👍) на первый опубликованный пост.
- Чанкинг длинных текстов (до 4000 символов).
3. **Безопасная очистка кэша и загрузок**:
- Немедленное удаление временных файлов после обработки каждого поста.
- Фоновый процесс очистки старых «хвостов» (`cleaner.py`), который **никогда** не удаляет файлы, находящиеся в процессе скачивания или отправки (благодаря реестру активных локов).
4. **Отчёты администраторам в ЛС Telegram**:
- Поддержка одного или списка Telegram ID через запятую (`TG_ADMIN_IDS=123456,789012`).
- Отправка ссылок на оригинал в ВК, опубликованный пост в Telegram и МАКС.
5. **Сохранение состояния (SQLite)**:
- База данных в папке `data/poster.db` (монтируется в Docker volume).
- Исключает повторную публикацию уже обработанных постов при перезапуске контейнера.
---
## 🛠 Быстрый старт
### 1. Клонирование и настройка окружения
Скопируйте пример файла конфигурации:
```bash
cp .env.example .env
```
Заполните переменные в файле `.env`:
- `VK_ACCESS_TOKEN`: ваш токен ВКонтакте.
- `VK_SOURCE`: ссылка, короткое имя или ID группы ВК (например, `redairsoft` или `https://vk.com/redairsoft`).
- `TG_BOT_TOKEN`: токен бота Telegram (от `@BotFather`).
- `TG_CHAT_ID`: ID канала/группы Telegram (например, `-1001234567890`).
- `TG_ADMIN_IDS`: ID администраторов через запятую для отчётов.
- `MAX_BOT_TOKEN`: токен бота в мессенджере МАКС.
- `MAX_CHAT_ID`: ID чата в МАКС.
### 2. Запуск через Docker Compose
Запустите контейнер в фоновом режиме:
```bash
docker compose up -d --build
```
Просмотр логов:
```bash
docker compose logs -f
```
---
## 📁 Структура проекта
```
├── src/
│ ├── config.py # Загрузка и валидация настроек из .env
│ ├── database.py # SQLite хранилище опубликованных постов
│ ├── vk_client.py # Клиент VK API (wall.get, resolve, media extraction)
│ ├── media_processor.py # Загрузчик фото/видео (yt-dlp, ffmpeg) с защитой активных файлов
│ ├── text_formatter.py # Форматирование текста (HTML, заголовки, хештеги)
│ ├── tg_poster.py # Публикация в Telegram (Rich Message + Legacy)
│ ├── max_poster.py # Публикация в MAX Messenger (Uploads + Messages + Reactions)
│ ├── cleaner.py # Фоновая очистка зависшего кэша
│ ├── admin_notifier.py # Отправка отчетов администраторам
│ └── main.py # Главный цикл оркестрации
├── Dockerfile # Мультистейдж образ с ffmpeg и сертификатами
├── docker-compose.yml # Конфигурация Docker Compose
├── docker-entrypoint.sh # Скрипт запуска и опционального поднятия local bot api
├── requirements.txt # Зависимости Python
├── .env.example # Шаблон переменных окружения
└── README.md
```
+15
View File
@@ -0,0 +1,15 @@
services:
vk-poster:
build:
context: .
dockerfile: Dockerfile
container_name: redairsoft-vk-poster
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
- ./cache:/tmp/poster_cache
# Port 8081 can be exposed if external access to local Telegram Bot API is desired
# ports:
# - "8081:8081"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env sh
set -eu
tg_pid=""
stop_children() {
if [ -n "$tg_pid" ] && kill -0 "$tg_pid" 2>/dev/null; then
echo "Stopping local telegram-bot-api (PID $tg_pid)..."
kill "$tg_pid"
wait "$tg_pid" 2>/dev/null || true
fi
}
trap stop_children INT TERM EXIT
if [ -n "${TELEGRAM_API_ID:-}" ] && [ -n "${TELEGRAM_API_HASH:-}" ]; then
mkdir -p "${TELEGRAM_WORK_DIR:-/var/lib/telegram-bot-api}" "${TELEGRAM_TEMP_DIR:-/tmp/telegram-bot-api}"
telegram-bot-api \
--api-id="${TELEGRAM_API_ID}" \
--api-hash="${TELEGRAM_API_HASH}" \
--dir="${TELEGRAM_WORK_DIR:-/var/lib/telegram-bot-api}" \
--temp-dir="${TELEGRAM_TEMP_DIR:-/tmp/telegram-bot-api}" \
--http-port="${TELEGRAM_HTTP_PORT:-8081}" \
--local &
tg_pid="$!"
echo "Started local Telegram Bot API server on 127.0.0.1:${TELEGRAM_HTTP_PORT:-8081}"
# Auto configure local bot api url if not explicitly defined
if [ -z "${LOCAL_BOT_API_URL:-}" ]; then
export LOCAL_BOT_API_URL="http://127.0.0.1:${TELEGRAM_HTTP_PORT:-8081}"
fi
else
echo "TELEGRAM_API_ID / TELEGRAM_API_HASH are not set; local Telegram Bot API will not be started."
fi
exec "$@"
+7
View File
@@ -0,0 +1,7 @@
aiogram==3.21.0
aiohttp==3.12.13
aiosqlite==0.21.0
loguru==0.7.3
pydantic-settings==2.10.1
Pillow==11.3.0
yt-dlp>=2026.1.1
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
from loguru import logger
from .config import settings
class AdminNotifier:
def __init__(self, bot: Bot) -> None:
self.bot = bot
self.admin_ids = settings.admin_id_list
async def send_to_all(self, text: str) -> None:
if not self.admin_ids:
return
for admin_id in self.admin_ids:
try:
await self.bot.send_message(
chat_id=admin_id,
text=text,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Failed to send report to admin {}: {}", admin_id, exc)
async def notify_cycle_result(
self,
group_name: str,
vk_url: str,
found_posts: list[dict[str, Any]],
error: Optional[str] = None,
) -> None:
if not self.admin_ids:
return
# If 0 posts found and no error, check settings
if not found_posts and not error:
if not settings.report_empty_runs:
return
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if error:
text = (
f"⚠️ <b>[VK Poster Alert] Ошибка при проверке группы</b>\n\n"
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)\n"
f"⏱ <b>Время:</b> <code>{now_str}</code>\n"
f"❌ <b>Ошибка:</b> <code>{error}</code>"
)
await self.send_to_all(text)
return
if not found_posts:
text = (
f"ℹ️ <b>[VK Poster] Проверка завершена</b>\n\n"
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)\n"
f"⏱ <b>Время:</b> <code>{now_str}</code>\n"
f"📥 Новых постов не обнаружено."
)
await self.send_to_all(text)
return
# We have published posts
lines = [
f"🚀 <b>[VK Poster] Опубликованы новые посты!</b>\n",
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)",
f"⏱ <b>Время:</b> <code>{now_str}</code>",
f"📊 <b>Количество:</b> {len(found_posts)}\n",
"<b>Список публикаций:</b>",
]
for idx, p in enumerate(found_posts, 1):
post_id = p.get("vk_post_id")
vk_post_url = p.get("vk_post_url")
tg_url = p.get("tg_url")
max_url = p.get("max_url")
tg_status = p.get("tg_status")
max_status = p.get("max_status")
tg_err = p.get("tg_error")
max_err = p.get("max_error")
lines.append(f"\n<b>{idx}. Пост #{post_id}</b>")
lines.append(f"• <a href=\"{vk_post_url}\">Оригинал в VK</a>")
if tg_status == "published":
tg_link = f"<a href=\"{tg_url}\">Ссылка</a>" if tg_url else "Опубликовано"
lines.append(f"• Telegram: ✅ {tg_link}")
elif tg_err:
lines.append(f"• Telegram: ❌ Ошибка: <code>{tg_err[:100]}</code>")
if max_status == "published":
max_link = f"<a href=\"{max_url}\">Ссылка</a>" if max_url else "Опубликовано"
lines.append(f"• MAX: ✅ {max_link}")
elif max_err:
lines.append(f"• MAX: ❌ Ошибка: <code>{max_err[:100]}</code>")
await self.send_to_all("\n".join(lines))
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import asyncio
import os
import time
from pathlib import Path
from loguru import logger
from .config import settings
from .media_processor import is_path_active
async def cleanup_stale_cache(max_age_minutes: Optional[int] = None) -> int:
"""
Deletes files from the cache directory that are older than max_age_minutes,
strictly skipping any files that are currently active in downloads/processing.
"""
if max_age_minutes is None:
max_age_minutes = settings.cache_max_age_minutes
cache_dir = settings.cache_path
if not cache_dir.exists():
return 0
threshold = time.time() - (max_age_minutes * 60)
removed_count = 0
for entry in cache_dir.iterdir():
if not entry.is_file():
continue
try:
# Strictly check if file is currently registered as actively used
if await is_path_active(entry):
continue
stat = entry.stat()
if stat.st_mtime < threshold:
entry.unlink(missing_ok=True)
removed_count += 1
logger.debug("Removed stale cache file: {}", entry.name)
except Exception as exc:
logger.warning("Error during cache file inspection/cleanup for {}: {}", entry.name, exc)
if removed_count > 0:
logger.info("Cleaned up {} stale cache files older than {}m", removed_count, max_age_minutes)
return removed_count
async def run_cleaner_loop(interval_minutes: int = 15) -> None:
"""Background task running periodic cache cleanup."""
logger.info("Cache cleanup worker started (interval: {}m)", interval_minutes)
while True:
try:
await cleanup_stale_cache()
except Exception as exc:
logger.warning("Error in periodic cache cleaner: {}", exc)
await asyncio.sleep(interval_minutes * 60)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# VK Settings
vk_access_token: str = ""
vk_source: str = "" # URL, screen name or owner_id (e.g., "redairsoft", "club12345", "-12345")
vk_api_version: str = "5.199"
vk_check_count: int = 10
vk_rate_limit_rps: int = 3
# Telegram Poster Settings
tg_bot_token: str = ""
tg_chat_id: str = "" # Destination chat/channel, e.g. "-1001234567890" or "-1001234567890:42"
tg_media_channel_id: str = "" # Optional storage channel
tg_admin_ids: str = "" # Comma-separated admin IDs for reports, e.g. "123456,789012"
local_bot_api_url: str = "" # e.g., "http://127.0.0.1:8081"
telegram_api_id: str = ""
telegram_api_hash: str = ""
# MAX Messenger Settings
max_bot_token: str = ""
max_chat_id: str = "" # Destination chat ID in MAX
max_api_base_url: str = "https://platform-api2.max.ru"
max_auto_reaction: str = "👍"
max_auto_reaction_enabled: bool = True
max_reaction_path_template: str = "/messages/{message_id}/reactions"
max_video_ready_attempts: int = 6
max_video_ready_delay_sec: float = 8.0
# Polling, Worker & Bootstrap Settings
check_interval_minutes: int = 15
report_empty_runs: bool = False
report_on_error: bool = True
# bootstrap_mode: "skip_existing" (default: ignores old historical posts on first start, only tracks new posts),
# "publish_latest_one" (publishes only the single latest post and skips older ones),
# "publish_all" (publishes all found posts)
bootstrap_mode: str = "skip_existing"
# Database & Storage
database_path: str = "data/poster.db"
cache_dir: str = "/tmp/poster_cache"
cache_max_age_minutes: int = 30
# Media & Video Limits
video_max_size_mb_cloud: int = 49
video_max_size_mb_local: int = 500
video_max_duration_sec: int = 600
video_max_height: int = 720
media_download_timeout_sec: int = 60
yt_dlp_timeout_sec: int = 300
# Text Styling & Decoration
header_text: str = ""
footer_text: str = ""
common_tags: str = ""
format_first_line_bold: bool = True
# Logging
log_level: str = "INFO"
@property
def admin_id_list(self) -> list[int]:
ids: list[int] = []
for raw in (self.tg_admin_ids or "").split(","):
raw = raw.strip()
if raw.lstrip("-").isdigit():
ids.append(int(raw))
return ids
@property
def cache_path(self) -> Path:
p = Path(self.cache_dir).resolve()
p.mkdir(parents=True, exist_ok=True)
return p
@property
def db_path(self) -> Path:
p = Path(self.database_path).resolve()
p.parent.mkdir(parents=True, exist_ok=True)
return p
settings = Settings()
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Optional
import aiosqlite
from loguru import logger
from .config import settings
class Database:
def __init__(self, db_path: Optional[str] = None) -> None:
self.db_path = str(settings.db_path if db_path is None else db_path)
async def init(self) -> None:
logger.info("Initializing database at {}", self.db_path)
async with aiosqlite.connect(self.db_path) as db:
await db.execute("PRAGMA journal_mode=WAL;")
await db.execute(
"""
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vk_post_id INTEGER NOT NULL,
vk_owner_id INTEGER NOT NULL,
posted_at INTEGER,
text TEXT,
raw_json TEXT,
tg_status TEXT DEFAULT 'pending',
tg_message_ids TEXT,
tg_url TEXT,
tg_error TEXT,
max_status TEXT DEFAULT 'pending',
max_message_ids TEXT,
max_url TEXT,
max_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP,
UNIQUE(vk_owner_id, vk_post_id)
);
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS publication_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
found_count INTEGER DEFAULT 0,
published_tg_count INTEGER DEFAULT 0,
published_max_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'ok',
error TEXT
);
"""
)
await db.commit()
async def has_any_posts(self, owner_id: int) -> bool:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT 1 FROM posts WHERE vk_owner_id = ? LIMIT 1",
(owner_id,),
)
row = await cursor.fetchone()
return bool(row)
async def mark_post_skipped(
self,
owner_id: int,
post_id: int,
posted_at: int,
text: str,
raw_data: dict[str, Any],
reason: str = "bootstrap_initial_skip",
) -> None:
raw_json = json.dumps(raw_data, ensure_ascii=False)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT INTO posts (vk_owner_id, vk_post_id, posted_at, text, raw_json, tg_status, max_status, tg_error, max_error)
VALUES (?, ?, ?, ?, ?, 'skipped', 'skipped', ?, ?)
ON CONFLICT(vk_owner_id, vk_post_id) DO UPDATE SET
tg_status = 'skipped',
max_status = 'skipped';
""",
(owner_id, post_id, posted_at, text, raw_json, reason, reason),
)
await db.commit()
async def is_post_processed(self, owner_id: int, post_id: int) -> bool:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id, tg_status, max_status FROM posts WHERE vk_owner_id = ? AND vk_post_id = ?",
(owner_id, post_id),
)
row = await cursor.fetchone()
if not row:
return False
# If already published on both or marked skipped, it's processed
return bool(
row["tg_status"] in ("published", "skipped")
and row["max_status"] in ("published", "skipped")
)
async def get_post(self, owner_id: int, post_id: int) -> Optional[dict[str, Any]]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM posts WHERE vk_owner_id = ? AND vk_post_id = ?",
(owner_id, post_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def save_or_update_post(
self,
owner_id: int,
post_id: int,
posted_at: int,
text: str,
raw_data: dict[str, Any],
) -> int:
raw_json = json.dumps(raw_data, ensure_ascii=False)
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
INSERT INTO posts (vk_owner_id, vk_post_id, posted_at, text, raw_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(vk_owner_id, vk_post_id) DO UPDATE SET
text = excluded.text,
raw_json = excluded.raw_json
RETURNING id;
""",
(owner_id, post_id, posted_at, text, raw_json),
)
row = await cursor.fetchone()
await db.commit()
return int(row[0]) if row else 0
async def update_tg_result(
self,
post_db_id: int,
status: str,
message_ids: Optional[list[int]] = None,
url: Optional[str] = None,
error: Optional[str] = None,
) -> None:
msg_str = ",".join(str(m) for m in message_ids) if message_ids else None
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
UPDATE posts
SET tg_status = ?,
tg_message_ids = COALESCE(?, tg_message_ids),
tg_url = COALESCE(?, tg_url),
tg_error = ?,
published_at = CASE WHEN ? = 'published' THEN CURRENT_TIMESTAMP ELSE published_at END
WHERE id = ?;
""",
(status, msg_str, url, error, status, post_db_id),
)
await db.commit()
async def update_max_result(
self,
post_db_id: int,
status: str,
message_ids: Optional[list[str]] = None,
url: Optional[str] = None,
error: Optional[str] = None,
) -> None:
msg_str = ",".join(message_ids) if message_ids else None
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
UPDATE posts
SET max_status = ?,
max_message_ids = COALESCE(?, max_message_ids),
max_url = COALESCE(?, max_url),
max_error = ?,
published_at = CASE WHEN ? = 'published' THEN CURRENT_TIMESTAMP ELSE published_at END
WHERE id = ?;
""",
(status, msg_str, url, error, status, post_db_id),
)
await db.commit()
async def record_run(
self,
found_count: int,
tg_count: int,
max_count: int,
status: str = "ok",
error: Optional[str] = None,
) -> None:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT INTO publication_runs (found_count, published_tg_count, published_max_count, status, error)
VALUES (?, ?, ?, ?, ?);
""",
(found_count, tg_count, max_count, status, error),
)
await db.commit()
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
import asyncio
import os
import signal
import sys
from typing import Any, Optional
from loguru import logger
from .admin_notifier import AdminNotifier
from .cleaner import cleanup_stale_cache, run_cleaner_loop
from .config import settings
from .database import Database
from .max_poster import MAXPoster
from .media_processor import MediaProcessor
from .tg_poster import TelegramPoster
from .vk_client import VKClient, VKPost
class ServiceApp:
def __init__(self) -> None:
self.db = Database()
self.tg_poster = TelegramPoster()
self.max_poster = MAXPoster()
self.admin_notifier: Optional[AdminNotifier] = None
self.vk_group_owner_id: Optional[int] = None
self.vk_group_name: str = ""
self.vk_group_url: str = ""
self.running = False
self.cleaner_task: Optional[asyncio.Task] = None
async def init(self) -> None:
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
)
logger.info("Initializing VK to TG & MAX Poster Service...")
# Initialize SQLite DB
await self.db.init()
# Initialize Telegram Poster
await self.tg_poster.init()
if self.tg_poster.bot:
self.admin_notifier = AdminNotifier(self.tg_poster.bot)
# Resolve VK Group
if not settings.vk_source:
raise ValueError("VK_SOURCE is not set in configuration")
async with VKClient() as vk:
screen_name, owner_id, name = await vk.resolve_group(settings.vk_source)
self.vk_group_owner_id = owner_id
self.vk_group_name = name
self.vk_group_url = f"https://vk.com/{screen_name}"
logger.info("Resolved VK Group: '{}' (owner_id: {}, url: {})", name, owner_id, self.vk_group_url)
# Start periodic background cache cleaner
self.cleaner_task = asyncio.create_task(run_cleaner_loop(interval_minutes=15))
async def process_new_post(self, post: VKPost) -> dict[str, Any]:
vk_url = f"https://vk.com/wall{post.owner_id}_{post.post_id}"
logger.info("Processing post #{} from {}", post.post_id, vk_url)
# Save to database
post_db_id = await self.db.save_or_update_post(
owner_id=post.owner_id,
post_id=post.post_id,
posted_at=post.date,
text=post.text,
raw_data=post.raw,
)
media_processor = MediaProcessor(is_local_tg_api=self.tg_poster.is_local_api)
processed_media = []
result_summary: dict[str, Any] = {
"vk_post_id": post.post_id,
"vk_post_url": vk_url,
"tg_status": "pending",
"tg_url": None,
"tg_error": None,
"max_status": "pending",
"max_url": None,
"max_error": None,
}
try:
# 1. Download/extract media
if post.media:
logger.info("Downloading {} media items for post #{}...", len(post.media), post.post_id)
processed_media = await media_processor.process_media_items(post.media)
# 2. Publish to Telegram
try:
tg_mids, tg_url = await self.tg_poster.post_to_telegram(
raw_text=post.text,
media_items=processed_media,
vk_url=vk_url,
)
await self.db.update_tg_result(
post_db_id=post_db_id,
status="published",
message_ids=tg_mids,
url=tg_url,
)
result_summary["tg_status"] = "published"
result_summary["tg_url"] = tg_url
except Exception as exc:
err = str(exc)
logger.exception("Telegram post error for #{}: {}", post.post_id, exc)
await self.db.update_tg_result(post_db_id=post_db_id, status="failed", error=err)
result_summary["tg_status"] = "failed"
result_summary["tg_error"] = err
# 3. Publish to MAX Messenger
if settings.max_bot_token and settings.max_chat_id:
try:
max_mids, max_url = await self.max_poster.post_to_max(
raw_text=post.text,
media_items=processed_media,
vk_url=vk_url,
)
await self.db.update_max_result(
post_db_id=post_db_id,
status="published",
message_ids=max_mids,
url=max_url,
)
result_summary["max_status"] = "published"
result_summary["max_url"] = max_url
except Exception as exc:
err = str(exc)
logger.exception("MAX post error for #{}: {}", post.post_id, exc)
await self.db.update_max_result(post_db_id=post_db_id, status="failed", error=err)
result_summary["max_status"] = "failed"
result_summary["max_error"] = err
else:
await self.db.update_max_result(post_db_id=post_db_id, status="skipped")
result_summary["max_status"] = "skipped"
finally:
# Immediate cleanup of temporary media files
if processed_media:
await media_processor.cleanup(processed_media)
return result_summary
async def run_cycle(self) -> None:
if self.vk_group_owner_id is None:
return
logger.info("Checking VK group '{}' for new posts...", self.vk_group_name)
posts_to_process: list[VKPost] = []
cycle_error: Optional[str] = None
published_reports: list[dict[str, Any]] = []
try:
async with VKClient() as vk:
latest_posts = await vk.get_latest_posts(
owner_id=self.vk_group_owner_id,
count=settings.vk_check_count,
)
# Check if this is the very first run on an empty database
is_initial_start = not await self.db.has_any_posts(self.vk_group_owner_id)
if is_initial_start and latest_posts:
logger.info(
"Initial start detected on fresh database. Applying bootstrap mode: '{}'",
settings.bootstrap_mode,
)
if settings.bootstrap_mode == "skip_existing":
for p in latest_posts:
await self.db.mark_post_skipped(
owner_id=p.owner_id,
post_id=p.post_id,
posted_at=p.date,
text=p.text,
raw_data=p.raw,
reason="initial_bootstrap_skip",
)
logger.info("Marked {} existing posts as already known. Only new future posts will be published.", len(latest_posts))
latest_posts = []
elif settings.bootstrap_mode == "publish_latest_one":
# Mark all except the single latest post as skipped
newest = max(latest_posts, key=lambda p: p.date)
for p in latest_posts:
if p.post_id != newest.post_id:
await self.db.mark_post_skipped(
owner_id=p.owner_id,
post_id=p.post_id,
posted_at=p.date,
text=p.text,
raw_data=p.raw,
reason="initial_bootstrap_skip",
)
latest_posts = [newest]
logger.info("Bootstrap mode: keeping only the single newest post #{}", newest.post_id)
# Filter out processed posts and sort oldest -> newest
for p in latest_posts:
if p.is_repost:
logger.debug("Skipping repost #{}", p.post_id)
continue
is_done = await self.db.is_post_processed(p.owner_id, p.post_id)
if not is_done:
posts_to_process.append(p)
# Sort chronological (oldest to newest)
posts_to_process.sort(key=lambda p: p.date)
if posts_to_process:
logger.info("Found {} new posts to publish", len(posts_to_process))
for post in posts_to_process:
report = await self.process_new_post(post)
published_reports.append(report)
await asyncio.sleep(2.0) # Pause between posts
else:
logger.info("No new posts found.")
tg_success = sum(1 for r in published_reports if r.get("tg_status") == "published")
max_success = sum(1 for r in published_reports if r.get("max_status") == "published")
await self.db.record_run(
found_count=len(posts_to_process),
tg_count=tg_success,
max_count=max_success,
status="ok",
)
except Exception as exc:
cycle_error = str(exc)
logger.exception("Error during parse cycle: {}", exc)
await self.db.record_run(
found_count=0,
tg_count=0,
max_count=0,
status="error",
error=cycle_error,
)
# Notify admins
if self.admin_notifier:
await self.admin_notifier.notify_cycle_result(
group_name=self.vk_group_name,
vk_url=self.vk_group_url,
found_posts=published_reports,
error=cycle_error,
)
async def run(self) -> None:
await self.init()
self.running = True
logger.info("Service started. Checking every {} minutes.", settings.check_interval_minutes)
while self.running:
try:
await self.run_cycle()
except Exception as exc:
logger.exception("Unexpected error in main loop: {}", exc)
# Sleep between cycles
sleep_seconds = settings.check_interval_minutes * 60
logger.debug("Sleeping for {} seconds until next check...", sleep_seconds)
for _ in range(sleep_seconds):
if not self.running:
break
await asyncio.sleep(1.0)
async def close(self) -> None:
logger.info("Stopping service...")
self.running = False
if self.cleaner_task:
self.cleaner_task.cancel()
await self.tg_poster.close()
# Clean any remaining stale files on shutdown
await cleanup_stale_cache(max_age_minutes=0)
logger.info("Service stopped cleanly.")
async def main() -> None:
app = ServiceApp()
loop = asyncio.get_running_loop()
def handle_signal():
logger.info("Signal received, stopping...")
asyncio.create_task(app.close())
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, handle_signal)
except NotImplementedError:
# Signal handlers not implemented on Windows event loop for non-main threads
pass
try:
await app.run()
finally:
await app.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logger.info("Application exited.")
+302
View File
@@ -0,0 +1,302 @@
from __future__ import annotations
import asyncio
import json
import mimetypes
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
import aiohttp
from loguru import logger
from .config import settings
from .media_processor import ProcessedMedia
from .text_formatter import format_post_text, split_message_chunks
MAX_MESSAGE_LIMIT = 4000
MAX_MEDIA_ITEMS = 10
class MAXAPIError(RuntimeError):
def __init__(self, message: str, status: Optional[int] = None, code: str = "") -> None:
super().__init__(message)
self.status = status
self.code = code
class MAXAPIClient:
def __init__(
self,
token: str,
base_url: str = "https://platform-api2.max.ru",
timeout_sec: int = 120,
max_attempts: int = 4,
retry_backoff_max_sec: int = 30,
) -> None:
self.token = token
self.base_url = base_url.rstrip("/")
self.timeout_sec = timeout_sec
self.max_attempts = max_attempts
self.retry_backoff_max_sec = retry_backoff_max_sec
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self) -> "MAXAPIClient":
timeout = aiohttp.ClientTimeout(total=self.timeout_sec)
self.session = aiohttp.ClientSession(
timeout=timeout, headers={"Authorization": self.token}
)
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if self.session:
await self.session.close()
async def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
if not self.session:
raise RuntimeError("MAX session is not initialized")
url = f"{self.base_url}{path}"
last_error = ""
for attempt in range(1, self.max_attempts + 1):
try:
async with self.session.request(method, url, **kwargs) as resp:
text = await resp.text()
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError:
data = {"raw": text}
if 200 <= resp.status < 300:
return data
retry_after = resp.headers.get("Retry-After")
message = data.get("message") if isinstance(data, dict) else text
code = data.get("code") if isinstance(data, dict) else ""
last_error = f"MAX API {resp.status} {code}: {message or text}"
if resp.status == 429 and retry_after:
await asyncio.sleep(float(retry_after) + 0.5)
continue
if code == "attachment.not.ready" and attempt < self.max_attempts:
await asyncio.sleep(min(2 ** attempt, self.retry_backoff_max_sec))
continue
if resp.status < 500:
raise MAXAPIError(last_error, resp.status, str(code or ""))
except Exception as exc:
last_error = str(exc)
if attempt >= self.max_attempts:
raise
await asyncio.sleep(min(2 ** attempt, self.retry_backoff_max_sec))
raise RuntimeError(last_error or "MAX API request failed")
async def send_message(
self,
chat_id: str,
text: str,
attachments: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"text": text, "notify": True, "format": "html"}
if attachments:
payload["attachments"] = attachments
return await self.request("POST", f"/messages?chat_id={quote(str(chat_id))}", json=payload)
async def get_message(self, message_id: str) -> dict[str, Any]:
return await self.request("GET", f"/messages/{quote(message_id, safe='')}")
async def get_video_info(self, token: str) -> dict[str, Any]:
return await self.request("GET", f"/videos/{quote(token, safe='')}")
async def get_upload_url(self, media_type: str) -> dict[str, Any]:
upload_type = "video" if media_type == "video" else "image"
return await self.request("POST", f"/uploads?type={upload_type}")
async def upload_file(self, upload_url: str, path: Path) -> dict[str, Any]:
if not self.session:
raise RuntimeError("MAX session is not initialized")
form = aiohttp.FormData()
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
with path.open("rb") as fh:
form.add_field("data", fh, filename=path.name, content_type=content_type)
async with self.session.post(upload_url, data=form) as resp:
text = await resp.text()
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError:
data = {"raw": text}
if resp.status < 200 or resp.status >= 300:
raise RuntimeError(f"MAX upload {resp.status}: {data}")
return data
async def react(self, path_template: str, message_id: str, reaction: str) -> None:
path = path_template.format(message_id=message_id)
payload = {"reaction": reaction}
await self.request("POST", path, json=payload)
class MAXPoster:
def __init__(self) -> None:
self.token = settings.max_bot_token
self.chat_id = settings.max_chat_id
self.api_base_url = settings.max_api_base_url
self.message_limit = MAX_MESSAGE_LIMIT
self.video_ready_attempts = settings.max_video_ready_attempts
self.video_ready_delay_sec = settings.max_video_ready_delay_sec
def message_id_from_response(self, data: dict[str, Any]) -> str:
candidates = [
data.get("id"),
data.get("mid"),
(data.get("message") or {}).get("id") if isinstance(data.get("message"), dict) else None,
(data.get("message") or {}).get("mid") if isinstance(data.get("message"), dict) else None,
]
message = data.get("message") if isinstance(data.get("message"), dict) else {}
body = message.get("body") if isinstance(message.get("body"), dict) else {}
candidates.extend([body.get("id"), body.get("mid"), body.get("message_id")])
return next((str(v) for v in candidates if v), "")
def message_url_from_response(self, data: dict[str, Any]) -> Optional[str]:
candidates = [data.get("url")]
message = data.get("message") if isinstance(data.get("message"), dict) else {}
body = message.get("body") if isinstance(message.get("body"), dict) else {}
candidates.extend([message.get("url"), body.get("url")])
return next((str(v).strip() for v in candidates if str(v or "").strip()), None)
def video_tokens(self, attachments: list[dict[str, Any]]) -> list[str]:
tokens: list[str] = []
for item in attachments:
if item.get("type") != "video":
continue
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
token = str(payload.get("token") or "").strip()
if token:
tokens.append(token)
return tokens
async def wait_for_videos(self, client: MAXAPIClient, attachments: list[dict[str, Any]]) -> None:
tokens = self.video_tokens(attachments)
if not tokens:
return
delay = self.video_ready_delay_sec
for attempt in range(1, self.video_ready_attempts + 1):
pending: list[str] = []
for token in tokens:
info = await client.get_video_info(token)
if not info.get("urls"):
pending.append(token)
if not pending:
return
if attempt >= self.video_ready_attempts:
logger.warning("MAX video still processing after {} attempts, proceeding...", attempt)
return
logger.info("MAX video processing... waiting {}s (attempt {}/{})", delay, attempt, self.video_ready_attempts)
await asyncio.sleep(delay)
delay = min(delay * 1.5, 30.0)
async def send_message_waiting_for_media(
self, client: MAXAPIClient, text: str, attachments: list[dict[str, Any]]
) -> dict[str, Any]:
has_video = any(item.get("type") == "video" for item in attachments)
attempts = self.video_ready_attempts if has_video else 1
delay = self.video_ready_delay_sec
for attempt in range(1, attempts + 1):
try:
return await client.send_message(self.chat_id, text, attachments=attachments or None)
except MAXAPIError as exc:
if exc.code != "attachment.not.ready" or attempt >= attempts:
raise
logger.warning("MAX attachment not ready, retrying send in {}s ({}/{})", delay, attempt + 1, attempts)
await asyncio.sleep(delay)
delay = min(delay * 1.5, 30.0)
raise RuntimeError("MAX attachment readiness retry exhausted")
async def upload_media_item(
self, client: MAXAPIClient, item: ProcessedMedia
) -> Optional[dict[str, Any]]:
if not item.local_path or not item.local_path.exists():
return None
media_type = "video" if item.media_type == "video" else "image"
upload_info = await client.get_upload_url(media_type)
upload_url = str(upload_info.get("url") or "").strip()
if not upload_url:
raise RuntimeError(f"MAX upload URL is empty: {upload_info}")
payload = await client.upload_file(upload_url, item.local_path)
if media_type == "video" and upload_info.get("token") and not payload.get("token"):
payload["token"] = upload_info["token"]
has_image = bool(payload.get("photos")) if isinstance(payload.get("photos"), dict) else False
if not payload.get("token") and not has_image:
raise RuntimeError(f"MAX upload did not return valid token: {payload}")
return {"type": media_type, "payload": payload}
async def post_to_max(
self,
raw_text: str,
media_items: list[ProcessedMedia],
vk_url: Optional[str] = None,
) -> tuple[list[str], Optional[str]]:
if not self.token or not self.chat_id:
logger.warning("MAX bot token or chat ID is not set; skipping MAX post.")
return [], None
link_only = [m for m in media_items if m.is_link_only]
link_text = ""
if link_only:
lines = [f"- {m.media_type}: {m.original_url}" for m in link_only]
link_text = "\n\nМедиа по ссылке:\n" + "\n".join(lines)
full_raw = (raw_text + link_text).strip()
formatted_text = format_post_text(
full_raw,
parse_mode="html",
bold_first_line=settings.format_first_line_bold,
vk_url=vk_url,
)
chunks = split_message_chunks(formatted_text, self.message_limit)
valid_media = [m for m in media_items if not m.is_link_only and m.local_path][:MAX_MEDIA_ITEMS]
message_ids: list[str] = []
first_url: Optional[str] = None
async with MAXAPIClient(self.token, self.api_base_url) as client:
attachments: list[dict[str, Any]] = []
for item in valid_media:
try:
att = await self.upload_media_item(client, item)
if att:
attachments.append(att)
except Exception as exc:
logger.warning("MAX media upload failed for {}: {}", item.attachment_id, exc)
if attachments:
await self.wait_for_videos(client, attachments)
first_text = chunks[0] if chunks else ""
res = await self.send_message_waiting_for_media(client, first_text, attachments)
first_mid = self.message_id_from_response(res)
if first_mid:
message_ids.append(first_mid)
msg_obj = res.get("message") if isinstance(res.get("message"), dict) else res
first_url = self.message_url_from_response(msg_obj)
# Try auto reaction if enabled
if settings.max_auto_reaction_enabled and settings.max_auto_reaction:
try:
await client.react(
settings.max_reaction_path_template,
first_mid,
settings.max_auto_reaction,
)
except Exception as exc:
logger.warning("MAX auto reaction failed: {}", exc)
for chunk in chunks[1:]:
await asyncio.sleep(1.0)
sub_res = await client.send_message(self.chat_id, chunk)
sub_mid = self.message_id_from_response(sub_res)
if sub_mid:
message_ids.append(sub_mid)
logger.info("Sent MAX Message: {}", message_ids)
return message_ids, first_url
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Set
import aiohttp
from loguru import logger
from .config import settings
# Global registry of paths currently being written / downloaded / processed
_ACTIVE_LOCK = asyncio.Lock()
_ACTIVE_PATHS: Set[str] = set()
async def register_active_path(path: Path) -> None:
async with _ACTIVE_LOCK:
_ACTIVE_PATHS.add(str(path.resolve()))
async def unregister_active_path(path: Path) -> None:
async with _ACTIVE_LOCK:
_ACTIVE_PATHS.discard(str(path.resolve()))
async def is_path_active(path: Path) -> bool:
async with _ACTIVE_LOCK:
return str(path.resolve()) in _ACTIVE_PATHS
@dataclass
class ProcessedMedia:
media_type: str # "photo" or "video"
local_path: Optional[Path]
original_url: str
attachment_id: str
size_bytes: int = 0
duration_sec: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
error: Optional[str] = None
is_link_only: bool = False
class MediaProcessor:
def __init__(self, is_local_tg_api: bool = False) -> None:
self.is_local_tg_api = is_local_tg_api
self.cache_dir = settings.cache_path
@property
def max_video_bytes(self) -> int:
max_mb = (
settings.video_max_size_mb_local
if self.is_local_tg_api
else settings.video_max_size_mb_cloud
)
return max_mb * 1024 * 1024
async def download_photo(
self, session: aiohttp.ClientSession, url: str, prefix: str = "photo_"
) -> Optional[Path]:
if not url.startswith(("http://", "https://")):
return None
suffix = Path(url.split("?", 1)[0]).suffix[:6] or ".jpg"
tmp = tempfile.NamedTemporaryFile(
dir=self.cache_dir, prefix=prefix, suffix=suffix, delete=False
)
tmp_path = Path(tmp.name)
tmp.close()
await register_active_path(tmp_path)
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=settings.media_download_timeout_sec)
) as resp:
if resp.status != 200:
logger.warning("Photo download failed with status {}: {}", resp.status, url)
tmp_path.unlink(missing_ok=True)
await unregister_active_path(tmp_path)
return None
with open(tmp_path, "wb") as f:
async for chunk in resp.content.iter_chunked(64 * 1024):
f.write(chunk)
return tmp_path
except Exception as exc:
logger.warning("Error downloading photo {}: {}", url, exc)
tmp_path.unlink(missing_ok=True)
await unregister_active_path(tmp_path)
return None
async def download_video_ytdlp(
self, url: str, prefix: str = "video_"
) -> tuple[Optional[Path], Optional[str], bool]:
"""Returns (path, error_message, is_permanent_error)"""
tmp = tempfile.NamedTemporaryFile(
dir=self.cache_dir, prefix=prefix, suffix=".mp4", delete=False
)
output_path = Path(tmp.name)
tmp.close()
await register_active_path(output_path)
max_size = self.max_video_bytes
netrc_path = None
cmd = [sys.executable, "-m", "yt_dlp"]
if "vk.com" in url and settings.vk_access_token:
netrc_path = f"{output_path}.netrc"
try:
with open(netrc_path, "w", encoding="utf-8") as fh:
fh.write(f"machine vk.com login vk_token password {settings.vk_access_token}\n")
cmd.extend(["--netrc-location", netrc_path])
except Exception as exc:
logger.warning("Failed to write netrc for yt-dlp: {}", exc)
cmd.extend([
url,
"-o", str(output_path),
"--no-playlist",
"--match-filter", f"duration <= {settings.video_max_duration_sec}",
"--merge-output-format", "mp4",
"-f", (
f"best[height<={settings.video_max_height}][filesize<{max_size}]"
f"/best[height<={settings.video_max_height}]"
f"/bestvideo[height<={settings.video_max_height}][filesize<{max_size}]+bestaudio/best"
f"/best[filesize<{max_size}]"
f"/best"
),
"--quiet", "--no-warnings",
])
proc = None
stderr = b""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(
proc.communicate(), timeout=settings.yt_dlp_timeout_sec
)
except asyncio.TimeoutError:
if proc:
try:
proc.kill()
await proc.communicate()
except Exception:
pass
output_path.unlink(missing_ok=True)
await unregister_active_path(output_path)
return None, "video download timeout", False
finally:
if netrc_path:
Path(netrc_path).unlink(missing_ok=True)
if proc.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
err_msg = (stderr or b"").decode("utf-8", errors="ignore").lower()
is_permanent = any(
m in err_msg for m in (
"removed", "unavailable", "private", "access denied", "does not pass filter", "sign in"
)
)
output_path.unlink(missing_ok=True)
await unregister_active_path(output_path)
return None, f"yt-dlp failed: {err_msg[:200]}", is_permanent
size = output_path.stat().st_size
if size > max_size:
logger.warning(
"Downloaded video size {} MB exceeds limit {} MB",
round(size / (1024 * 1024), 2),
round(max_size / (1024 * 1024), 2),
)
output_path.unlink(missing_ok=True)
await unregister_active_path(output_path)
return None, "video exceeds size limit", True
return output_path, None, False
async def process_media_items(
self, items: list[Any]
) -> list[ProcessedMedia]:
results: list[ProcessedMedia] = []
async with aiohttp.ClientSession() as session:
for item in items:
media_type = item.media_type
url = item.url
att_id = item.attachment_id
if media_type == "photo":
p = await self.download_photo(session, url, prefix=f"p_{att_id}_")
if p:
results.append(
ProcessedMedia(
media_type="photo",
local_path=p,
original_url=url,
attachment_id=att_id,
size_bytes=p.stat().st_size,
width=item.width,
height=item.height,
)
)
else:
results.append(
ProcessedMedia(
media_type="photo",
local_path=None,
original_url=url,
attachment_id=att_id,
error="failed to download photo",
is_link_only=True,
)
)
elif media_type == "video":
# Check duration
if item.duration_sec and item.duration_sec > settings.video_max_duration_sec:
results.append(
ProcessedMedia(
media_type="video",
local_path=None,
original_url=url,
attachment_id=att_id,
duration_sec=item.duration_sec,
error="video duration exceeds maximum",
is_link_only=True,
)
)
continue
p, err, perm = await self.download_video_ytdlp(url, prefix=f"v_{att_id}_")
if p:
results.append(
ProcessedMedia(
media_type="video",
local_path=p,
original_url=url,
attachment_id=att_id,
size_bytes=p.stat().st_size,
duration_sec=item.duration_sec,
width=item.width,
height=item.height,
)
)
else:
results.append(
ProcessedMedia(
media_type="video",
local_path=None,
original_url=url,
attachment_id=att_id,
duration_sec=item.duration_sec,
error=err or "failed to download video",
is_link_only=True,
)
)
return results
async def cleanup(self, items: list[ProcessedMedia]) -> None:
"""Immediately unregisters and removes downloaded local files."""
for item in items:
if item.local_path:
try:
await unregister_active_path(item.local_path)
item.local_path.unlink(missing_ok=True)
except Exception as exc:
logger.warning("Error cleaning up {}: {}", item.local_path, exc)
item.local_path = None
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import html
import re
from typing import Optional
from .config import settings
def split_message_chunks(text: str, limit: int = 4000) -> list[str]:
text = str(text or "").strip()
if not text:
return []
chunks: list[str] = []
while len(text) > limit:
split_at = text.rfind("\n", 0, limit)
if split_at < limit // 2:
split_at = text.rfind(" ", 0, limit)
if split_at < limit // 2:
split_at = limit
chunks.append(text[:split_at].strip())
text = text[split_at:].strip()
if text:
chunks.append(text)
return chunks
def clean_dividers(lines: list[str]) -> list[str]:
sep_pattern = re.compile(r"^\s*[━—─\-=\*\#_]{2,}\s*$")
return ["" if sep_pattern.match(line) else line for line in lines]
def strip_trailing_hashtags(text: str) -> str:
lines = text.rstrip().splitlines()
while lines and not lines[-1].strip():
lines.pop()
if not lines:
return ""
parts = lines[-1].split()
if parts and all(part.startswith("#") for part in parts):
lines.pop()
return "\n".join(lines).rstrip()
def format_post_text(
raw_text: str,
*,
parse_mode: str = "html", # "html" or "plain"
header: Optional[str] = None,
footer: Optional[str] = None,
tags: Optional[str] = None,
bold_first_line: bool = True,
vk_url: Optional[str] = None,
) -> str:
header = (settings.header_text if header is None else header).strip()
footer = (settings.footer_text if footer is None else footer).strip()
tags = (settings.common_tags if tags is None else tags).strip()
body = strip_trailing_hashtags(raw_text)
lines = clean_dividers(body.splitlines())
title_idx: Optional[int] = None
for idx, line in enumerate(lines):
if line.strip():
title_idx = idx
break
formatted_lines: list[str] = []
for idx, line in enumerate(lines):
if idx == title_idx and bold_first_line and line.strip():
if parse_mode == "html":
escaped = html.escape(line.strip())
formatted_lines.append(f"<b>{escaped}</b>")
else:
formatted_lines.append(f"**{line.strip()}**")
# Blank line after header if next line is not empty
if idx + 1 < len(lines) and lines[idx + 1].strip() != "":
formatted_lines.append("")
else:
if parse_mode == "html":
formatted_lines.append(html.escape(line))
else:
formatted_lines.append(line)
normalized_body = re.sub(r"\n{3,}", "\n\n", "\n".join(formatted_lines)).strip()
parts: list[str] = []
if header:
parts.append(html.escape(header) if parse_mode == "html" else header)
if normalized_body:
parts.append(normalized_body)
if footer:
parts.append(html.escape(footer) if parse_mode == "html" else footer)
if tags:
parts.append(html.escape(tags) if parse_mode == "html" else tags)
return "\n\n".join(part for part in parts if part).strip()
+407
View File
@@ -0,0 +1,407 @@
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from typing import Any, Optional
import aiohttp
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.exceptions import TelegramRetryAfter
from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
from loguru import logger
from .config import settings
from .media_processor import ProcessedMedia
from .text_formatter import format_post_text, split_message_chunks
MAX_MEDIA_GROUP = 10
MAX_RICH_MEDIA = 50
MAX_RICH_TEXT = 32768
def parse_topic(value: str) -> tuple[int, Optional[int]]:
raw = str(value or "").strip()
if not raw:
raise ValueError("Telegram chat ID is empty")
if ":" in raw:
chat_id, thread_id = raw.split(":", 1)
return int(chat_id), int(thread_id)
return int(raw), None
def tg_message_url(chat_id: int, message_id: int) -> str:
chat = str(abs(int(chat_id)))
if chat.startswith("100"):
chat = chat[3:]
return f"https://t.me/c/{chat}/{message_id}"
class RichMessageUnavailable(RuntimeError):
pass
class TelegramPoster:
def __init__(self) -> None:
self.bot: Optional[Bot] = None
self.chat_id: int = 0
self.thread_id: Optional[int] = None
self.storage_chat_id: Optional[int] = None
self.storage_thread_id: Optional[int] = None
self.is_local_api: bool = False
self.caption_limit: int = 1024
self.message_limit: int = 4096
async def init(self) -> None:
if not settings.tg_bot_token:
raise ValueError("TG_BOT_TOKEN is not configured")
if not settings.tg_chat_id:
raise ValueError("TG_CHAT_ID is not configured")
self.chat_id, self.thread_id = parse_topic(settings.tg_chat_id)
if settings.tg_media_channel_id:
try:
self.storage_chat_id, self.storage_thread_id = parse_topic(settings.tg_media_channel_id)
except Exception as exc:
logger.warning("Could not parse TG_MEDIA_CHANNEL_ID: {}", exc)
local_url = (settings.local_bot_api_url or "").strip().rstrip("/")
if local_url:
try:
session = AiohttpSession(
api=TelegramAPIServer.from_base(local_url, is_local=True)
)
test_bot = Bot(token=settings.tg_bot_token, session=session)
me = await test_bot.get_me()
self.bot = test_bot
self.is_local_api = True
logger.info("Connected to local Telegram Bot API at {}: @{}", local_url, me.username)
except Exception as exc:
logger.warning(
"Local Telegram Bot API unavailable at {}: {}. Falling back to standard API.",
local_url,
exc,
)
self.bot = Bot(token=settings.tg_bot_token)
self.is_local_api = False
else:
self.bot = Bot(token=settings.tg_bot_token)
self.is_local_api = False
async def close(self) -> None:
if self.bot and self.bot.session:
await self.bot.session.close()
def chat_kwargs(self, is_storage: bool = False) -> dict[str, Any]:
c_id = self.storage_chat_id if is_storage and self.storage_chat_id else self.chat_id
t_id = self.storage_thread_id if is_storage and self.storage_chat_id else self.thread_id
kwargs: dict[str, Any] = {"chat_id": int(c_id)}
if t_id:
kwargs["message_thread_id"] = int(t_id)
return kwargs
async def tg_retry(self, fn):
for attempt in range(1, 4):
try:
return await fn()
except TelegramRetryAfter as exc:
delay = float(exc.retry_after) + 0.5
logger.warning("Telegram flood control: retry after {}s", delay)
await asyncio.sleep(delay)
except Exception as exc:
if attempt >= 3:
raise
logger.warning("Telegram request error attempt {}: {}", attempt, exc)
await asyncio.sleep(2 ** attempt)
raise RuntimeError("Telegram retries exhausted")
async def upload_media_for_file_ids(
self, media_items: list[ProcessedMedia]
) -> dict[str, str]:
"""Uploads files to destination/storage to obtain tg_file_ids"""
file_ids: dict[str, str] = {}
valid_items = [m for m in media_items if m.local_path and m.local_path.exists()]
if not valid_items or not self.bot:
return file_ids
# If storage channel is configured, we send there; otherwise we send directly
use_storage = bool(self.storage_chat_id)
group = []
for item in valid_items[:MAX_MEDIA_GROUP]:
fs = FSInputFile(str(item.local_path))
if item.media_type == "photo":
group.append(InputMediaPhoto(media=fs))
else:
group.append(InputMediaVideo(media=fs))
if not group:
return file_ids
try:
if len(group) == 1:
item = valid_items[0]
fs = FSInputFile(str(item.local_path))
if item.media_type == "photo":
msg = await self.tg_retry(
lambda: self.bot.send_photo(photo=fs, **self.chat_kwargs(use_storage))
)
if msg.photo:
file_ids[item.attachment_id] = msg.photo[-1].file_id
else:
msg = await self.tg_retry(
lambda: self.bot.send_video(video=fs, **self.chat_kwargs(use_storage))
)
if msg.video:
file_ids[item.attachment_id] = msg.video.file_id
else:
msgs = await self.tg_retry(
lambda: self.bot.send_media_group(media=group, **self.chat_kwargs(use_storage))
)
for item, msg in zip(valid_items, msgs):
if item.media_type == "photo" and msg.photo:
file_ids[item.attachment_id] = msg.photo[-1].file_id
elif item.media_type == "video" and msg.video:
file_ids[item.attachment_id] = msg.video.file_id
except Exception as exc:
logger.warning("Error uploading media items for file_ids: {}", exc)
return file_ids
def build_rich_text_html(self, text: str) -> str:
paragraphs = []
for p in text.strip().split("\n\n"):
body = "<br/>".join(line for line in p.splitlines() if line.strip())
if body:
paragraphs.append(f"<p>{body}</p>")
return "\n".join(paragraphs)
def build_rich_message(
self, text: str, media_items: list[ProcessedMedia], file_ids: dict[str, str]
) -> Optional[dict[str, Any]]:
rich_media = []
media_tags = []
valid_items = [
m for m in media_items if m.attachment_id in file_ids
][:MAX_RICH_MEDIA]
if not valid_items:
return None
for idx, item in enumerate(valid_items):
f_id = file_ids[item.attachment_id]
media_id = f"m{idx}"
m_type = "photo" if item.media_type == "photo" else "video"
rich_media.append({"id": media_id, "media": {"type": m_type, "media": f_id}})
if item.media_type == "photo":
media_tags.append(f'<img src="tg://photo?id={media_id}"/>')
else:
media_tags.append(f'<video src="tg://video?id={media_id}"></video>')
rich_text = self.build_rich_text_html(text)
if len(rich_text) > MAX_RICH_TEXT:
return None
media_html = (
media_tags[0] if len(media_tags) == 1 else f"<tg-collage>{''.join(media_tags)}</tg-collage>"
)
return {
"html": f"{media_html}\n{rich_text}" if rich_text else media_html,
"media": rich_media,
}
async def send_rich_message(self, rich_message: dict[str, Any]) -> list[int]:
data: dict[str, Any] = {
"chat_id": int(self.chat_id),
"rich_message": rich_message,
}
if self.thread_id:
data["message_thread_id"] = int(self.thread_id)
base_url = (settings.local_bot_api_url or "").strip().rstrip("/") or "https://api.telegram.org"
url = f"{base_url}/bot{settings.tg_bot_token}/sendRichMessage"
timeout = aiohttp.ClientTimeout(total=90)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=data) as resp:
payload = await resp.json(content_type=None)
if payload.get("ok"):
res = payload.get("result", {})
mid = res.get("message_id")
if mid:
return [int(mid)]
raise RichMessageUnavailable("sendRichMessage ok but no message_id")
desc = str(payload.get("description") or f"HTTP {resp.status}")
raise RichMessageUnavailable(desc)
async def send_legacy_media_post(
self, text: str, media_items: list[ProcessedMedia], file_ids: dict[str, str]
) -> list[int]:
if not self.bot:
raise RuntimeError("Bot not initialized")
input_media = []
valid_items = [
m for m in media_items if m.local_path or m.attachment_id in file_ids
]
for item in valid_items:
fid = file_ids.get(item.attachment_id)
val = fid if fid else (FSInputFile(str(item.local_path)) if item.local_path else None)
if not val:
continue
if item.media_type == "photo":
input_media.append({"type": "photo", "media": val})
else:
input_media.append({"type": "video", "media": val})
if not input_media:
# Text only post
return await self.send_text_post(text)
# Handle caption vs overflow
text_chunks: list[str] = []
if len(text) <= self.caption_limit:
first_caption = text
else:
first_caption = ""
text_chunks = split_message_chunks(text, self.message_limit)
first_group = input_media[:MAX_MEDIA_GROUP]
message_ids: list[int] = []
if len(first_group) == 1:
item = first_group[0]
if item["type"] == "photo":
msg = await self.tg_retry(
lambda: self.bot.send_photo(
photo=item["media"],
caption=first_caption or None,
parse_mode="HTML",
**self.chat_kwargs(),
)
)
else:
msg = await self.tg_retry(
lambda: self.bot.send_video(
video=item["media"],
caption=first_caption or None,
parse_mode="HTML",
**self.chat_kwargs(),
)
)
message_ids.append(int(msg.message_id))
else:
group = []
for idx, item in enumerate(first_group):
cap = first_caption if idx == 0 and first_caption else None
if item["type"] == "photo":
group.append(InputMediaPhoto(media=item["media"], caption=cap, parse_mode="HTML"))
else:
group.append(InputMediaVideo(media=item["media"], caption=cap, parse_mode="HTML"))
msgs = await self.tg_retry(
lambda: self.bot.send_media_group(media=group, **self.chat_kwargs())
)
message_ids.extend(int(m.message_id) for m in msgs)
# Remaining media chunks if more than 10
rest = input_media[MAX_MEDIA_GROUP:]
for start in range(0, len(rest), MAX_MEDIA_GROUP):
chunk = rest[start : start + MAX_MEDIA_GROUP]
g = [
InputMediaPhoto(media=item["media"])
if item["type"] == "photo"
else InputMediaVideo(media=item["media"])
for item in chunk
]
msgs = await self.tg_retry(
lambda: self.bot.send_media_group(media=g, **self.chat_kwargs())
)
message_ids.extend(int(m.message_id) for m in msgs)
# Overflow text chunks if caption exceeded 1024 chars
for chunk in text_chunks:
msg = await self.tg_retry(
lambda c=chunk: self.bot.send_message(
text=c,
parse_mode="HTML",
disable_web_page_preview=True,
**self.chat_kwargs(),
)
)
message_ids.append(int(msg.message_id))
return message_ids
async def send_text_post(self, text: str) -> list[int]:
if not self.bot:
raise RuntimeError("Bot not initialized")
chunks = split_message_chunks(text, self.message_limit)
mids: list[int] = []
for chunk in chunks:
msg = await self.tg_retry(
lambda c=chunk: self.bot.send_message(
text=c,
parse_mode="HTML",
disable_web_page_preview=True,
**self.chat_kwargs(),
)
)
mids.append(int(msg.message_id))
return mids
async def post_to_telegram(
self,
raw_text: str,
media_items: list[ProcessedMedia],
vk_url: Optional[str] = None,
) -> tuple[list[int], Optional[str]]:
"""
Main Telegram posting routine:
1. Formats text for HTML parse mode.
2. Uploads media (or storage channel if configured) to get file_ids.
3. Tries sendRichMessage first.
4. If unavailable, falls back to legacy media groups / single media / text.
"""
# Append link-only media notice if any videos/photos couldn't be downloaded
link_only = [m for m in media_items if m.is_link_only]
link_text = ""
if link_only:
lines = [f"- {m.media_type}: <a href=\"{m.original_url}\">ссылка</a>" for m in link_only]
link_text = "\n\n<i>Медиа по ссылке:</i>\n" + "\n".join(lines)
full_raw = (raw_text + link_text).strip()
formatted_text = format_post_text(
full_raw,
parse_mode="html",
bold_first_line=settings.format_first_line_bold,
vk_url=vk_url,
)
valid_media = [m for m in media_items if not m.is_link_only and m.local_path]
# 1. Obtain file_ids if we have storage channel or if we want rich message
file_ids: dict[str, str] = {}
if valid_media and self.storage_chat_id:
file_ids = await self.upload_media_for_file_ids(valid_media)
# 2. Try Rich Message if file_ids are available
if file_ids:
rich_msg = self.build_rich_message(formatted_text, valid_media, file_ids)
if rich_msg:
try:
mids = await self.send_rich_message(rich_msg)
url = tg_message_url(self.chat_id, mids[0]) if mids else None
logger.info("Sent Telegram Rich Message: {}", mids)
return mids, url
except RichMessageUnavailable as exc:
logger.warning("Telegram sendRichMessage failed: {}. Falling back to legacy.", exc)
# 3. Fallback to legacy media group / text
mids = await self.send_legacy_media_post(formatted_text, valid_media, file_ids)
url = tg_message_url(self.chat_id, mids[0]) if mids else None
logger.info("Sent Telegram Legacy Message: {}", mids)
return mids, url
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
import asyncio
import re
import time
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp
from loguru import logger
from .config import settings
class VKAPIError(RuntimeError):
def __init__(self, code: Optional[int], message: str) -> None:
self.code = code
super().__init__(f"VK API error {code}: {message}")
class VKRateLimiter:
def __init__(self, rps: int = 3) -> None:
self.rps = max(1, int(rps))
self.interval = 1.0 / self.rps
self._last = 0.0
self._lock = asyncio.Lock()
async def acquire(self) -> None:
async with self._lock:
now = time.monotonic()
wait = self.interval - (now - self._last)
if wait > 0:
await asyncio.sleep(wait)
self._last = time.monotonic()
@dataclass
class VKMediaItem:
media_type: str # "photo" or "video"
url: str
attachment_id: str
width: Optional[int] = None
height: Optional[int] = None
duration_sec: Optional[int] = None
title: Optional[str] = None
@dataclass
class VKPost:
post_id: int
owner_id: int
date: int
text: str
media: list[VKMediaItem]
raw: dict[str, Any]
is_pinned: bool = False
is_repost: bool = False
def normalize_vk_source(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
for host in ("vk.com", "vk.ru", "m.vk.com"):
if host in raw:
raw = raw.split(host, 1)[1]
break
raw = raw.lstrip("/")
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
raw = re.sub(r"^(club|public)", "", raw, flags=re.IGNORECASE)
return raw.lower()
class VKClient:
base_url = "https://api.vk.com/method"
def __init__(
self,
token: Optional[str] = None,
version: Optional[str] = None,
rps: int = 3,
) -> None:
self.token = token or settings.vk_access_token
self.version = version or settings.vk_api_version
self.limiter = VKRateLimiter(rps)
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self) -> "VKClient":
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=45, connect=10)
)
return self
async def __aexit__(self, *args) -> None:
if self.session:
await self.session.close()
async def call(self, method: str, **params: Any) -> Any:
if not self.session:
raise RuntimeError("VKClient session not initialized")
if not self.token:
raise RuntimeError("VK_ACCESS_TOKEN is empty")
payload = dict(params)
payload["access_token"] = self.token
payload["v"] = self.version
for attempt in range(1, 4):
try:
await self.limiter.acquire()
async with self.session.post(f"{self.base_url}/{method}", data=payload) as resp:
resp.raise_for_status()
data = await resp.json(content_type=None)
if "error" not in data:
return data.get("response", {})
err = data["error"]
code = err.get("error_code")
msg = err.get("error_msg", "unknown")
if code == 6 and attempt < 3:
logger.warning("VK rate limit reached, sleeping 1.5s")
await asyncio.sleep(1.5)
continue
raise VKAPIError(code, msg)
except VKAPIError:
raise
except Exception as exc:
if attempt >= 3:
raise
logger.warning("VK call {} error attempt {}: {}", method, attempt, exc)
await asyncio.sleep(1.5 * attempt)
raise VKAPIError(None, "VK retries exhausted")
async def resolve_group(self, input_value: str) -> tuple[str, int, str]:
"""Resolves screen name/link to (screen_name, owner_id (negative), title)"""
norm = normalize_vk_source(input_value)
if not norm:
raise ValueError(f"Invalid VK source identifier: {input_value}")
if norm.lstrip("-").isdigit():
group_id = abs(int(norm))
res = await self.call("groups.getById", group_id=str(group_id))
else:
res = await self.call("groups.getById", group_id=norm)
items = res if isinstance(res, list) else res.get("groups", [])
if not items:
raise VKAPIError(None, f"Cannot resolve VK group for: {input_value}")
g = items[0]
gid = int(g["id"])
screen_name = str(g.get("screen_name") or norm)
name = str(g.get("name") or screen_name)
return screen_name, -gid, name
def extract_media(self, post_raw: dict[str, Any]) -> list[VKMediaItem]:
items: list[VKMediaItem] = []
for att in post_raw.get("attachments", []) or []:
att_type = att.get("type")
if att_type == "photo":
photo = att.get("photo") or {}
owner_id = photo.get("owner_id")
photo_id = photo.get("id")
sizes = sorted(
photo.get("sizes", []) or [],
key=lambda s: int(s.get("width") or 0) * int(s.get("height") or 0),
reverse=True,
)
if sizes and owner_id is not None and photo_id is not None:
best = sizes[0]
items.append(
VKMediaItem(
media_type="photo",
url=str(best.get("url") or ""),
attachment_id=f"photo{owner_id}_{photo_id}",
width=best.get("width"),
height=best.get("height"),
)
)
elif att_type == "video":
video = att.get("video") or {}
owner_id = video.get("owner_id")
video_id = video.get("id")
access_key = video.get("access_key")
if owner_id is not None and video_id is not None:
video_url = f"https://vk.com/video{owner_id}_{video_id}"
if access_key:
video_url += f"_{access_key}"
items.append(
VKMediaItem(
media_type="video",
url=video_url,
attachment_id=f"video{owner_id}_{video_id}",
width=video.get("width"),
height=video.get("height"),
duration_sec=video.get("duration"),
title=video.get("title"),
)
)
return items
async def get_latest_posts(self, owner_id: int, count: int = 10) -> list[VKPost]:
res = await self.call("wall.get", owner_id=owner_id, count=count, filter="owner")
items = res.get("items", []) if isinstance(res, dict) else []
posts: list[VKPost] = []
for raw in items:
if raw.get("is_deleted") or not raw.get("id") or not raw.get("date"):
continue
is_pinned = bool(raw.get("is_pinned"))
is_repost = bool(raw.get("copy_history"))
media = self.extract_media(raw)
posts.append(
VKPost(
post_id=int(raw["id"]),
owner_id=int(raw.get("owner_id") or owner_id),
date=int(raw["date"]),
text=str(raw.get("text") or ""),
media=media,
raw=raw,
is_pinned=is_pinned,
is_repost=is_repost,
)
)
return posts