From c30b7e4675329faa2fc8182d91b67bef5198e90a Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Mon, 23 Feb 2026 01:18:12 +0200 Subject: [PATCH] Add multi-user features: i18n, payments, multi-model AI, focus areas Replace single report type with composable analysis: depth levels (basic/standard/full) + focus area multi-select (psychology, business, marketing, content, audience, sentiment). Multi-step inline keyboard flow guides users through selection. - i18n: English + Russian, auto-detect from Telegram, /lang override - AI providers: Anthropic + OpenRouter via AIClient abstraction - Telegram Stars payments with per-depth pricing and free trial - SQLite (aiosqlite) for users, analyses, payments tracking - User middleware for auto-registration and language detection - Report persistence: save .md locally, offer file download - New commands: /features, /prices, /lang - Composable prompt system: depth modifiers + focus area fragments --- .env.example | 8 + CLAUDE.md | 35 ++-- bot/__main__.py | 19 +- bot/config.py | 15 ++ bot/db/__init__.py | 0 bot/db/engine.py | 54 ++++++ bot/db/usage_repo.py | 46 +++++ bot/db/user_repo.py | 52 ++++++ bot/handlers/analyze.py | 285 ++++++++++++++++++++++++------ bot/handlers/features.py | 16 ++ bot/handlers/lang.py | 20 +++ bot/handlers/payment.py | 9 + bot/handlers/prices.py | 19 ++ bot/handlers/start.py | 18 +- bot/i18n/__init__.py | 30 ++++ bot/i18n/strings.py | 255 ++++++++++++++++++++++++++ bot/middleware/__init__.py | 0 bot/middleware/user_middleware.py | 41 +++++ bot/models.py | 44 +++-- bot/prompts/chunk_summary.py | 63 ++----- bot/prompts/depth.py | 19 ++ bot/prompts/focus_areas.py | 74 ++++++++ bot/prompts/synthesis.py | 118 +++---------- bot/services/ai_client.py | 106 +++++++++++ bot/services/analyzer.py | 70 +++----- bot/services/report_saver.py | 18 ++ pyproject.toml | 3 +- 27 files changed, 1156 insertions(+), 281 deletions(-) create mode 100644 bot/db/__init__.py create mode 100644 bot/db/engine.py create mode 100644 bot/db/usage_repo.py create mode 100644 bot/db/user_repo.py create mode 100644 bot/handlers/features.py create mode 100644 bot/handlers/lang.py create mode 100644 bot/handlers/payment.py create mode 100644 bot/handlers/prices.py create mode 100644 bot/i18n/__init__.py create mode 100644 bot/i18n/strings.py create mode 100644 bot/middleware/__init__.py create mode 100644 bot/middleware/user_middleware.py create mode 100644 bot/prompts/depth.py create mode 100644 bot/prompts/focus_areas.py create mode 100644 bot/services/ai_client.py create mode 100644 bot/services/report_saver.py diff --git a/.env.example b/.env.example index c8750ca..7292ce3 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,11 @@ TELEGRAM_API_HASH= TELEGRAM_PHONE= ANTHROPIC_API_KEY= CLAUDE_MODEL=claude-opus-4-6 +OPENROUTER_API_KEY= +AVAILABLE_MODELS=claude-haiku-4-5,anthropic/claude-3.5-sonnet +PRICE_BASIC=50 +PRICE_STANDARD=100 +PRICE_FULL=200 +FREE_ANALYSES=1 +DB_PATH=/app/data/bot.db +REPORTS_DIR=/app/data/reports diff --git a/CLAUDE.md b/CLAUDE.md index b583997..7295f22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,40 +4,55 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -Telegram Channel Analyzer Bot — fetches public channel history via Telethon, analyzes with Claude AI (chunked summarization pipeline), delivers reports via aiogram bot. +Telegram Channel Analyzer Bot — multi-user public bot with payments, multi-language (EN/RU), multi-provider AI (Anthropic + OpenRouter). Fetches public channel history via Telethon, analyzes with configurable AI models (chunked summarization pipeline), delivers HTML reports via aiogram bot. ## Stack - **Python 3.12**, async throughout -- **aiogram 3** — Telegram bot interface (commands, inline keyboards, progress messages) +- **aiogram 3** — Telegram bot interface (commands, inline keyboards, payments, progress messages) - **Telethon** — userbot client for reading public channel history - **anthropic** (AsyncAnthropic) — Claude API with streaming +- **httpx** — OpenRouter API calls (transitive dep of anthropic) +- **aiosqlite** — SQLite database for users, usage, payments - **pydantic-settings** — config from environment variables ## Architecture - Both aiogram and Telethon share one asyncio loop (no threads) - Telethon client is attached to the bot instance in `__main__.py` -- Analysis pipeline: Fetch → Chunk (token-bounded) → Summarize each chunk → Synthesize final report +- Analysis flow: Channel → Depth selection → Focus areas (multi-select) → Model → Payment check → Pipeline +- Pipeline: Fetch → Chunk (token-bounded) → Summarize each chunk → Synthesize final report → Save .md → Send - Rate limit handling: semaphore(1), 60s cooldown between chunks, retry with backoff on 429 - Output: Markdown→HTML conversion, split on section boundaries at 4000 chars +- User middleware auto-creates DB user, detects language, injects `lang`/`db_user` into handler data +- In-memory `_sessions` dict tracks multi-step analysis flow per user ## Key Files -- `bot/config.py` — all settings from env vars, `CLAUDE_MODEL` selects the model -- `bot/services/analyzer.py` — Claude API calls, adaptive thinking only for opus-4-6 -- `bot/services/chunker.py` — `MAX_TOKENS_PER_CHUNK` and `CHARS_PER_TOKEN` control chunking -- `bot/prompts/` — prompt templates per report type (chunk_summary.py, synthesis.py) +- `bot/config.py` — all settings from env vars (Anthropic, OpenRouter, pricing, paths) +- `bot/models.py` — `Depth`, `FocusArea` enums, `AnalysisSession` dataclass +- `bot/i18n/` — `Lang` enum, `t()` lookup, all UI strings in `strings.py` +- `bot/db/` — aiosqlite engine, `user_repo`, `usage_repo` +- `bot/middleware/user_middleware.py` — auto-create user, detect lang +- `bot/services/ai_client.py` — `AIClient` ABC, `AnthropicClient`, `OpenRouterClient`, `get_ai_client()` +- `bot/services/analyzer.py` — orchestrates chunk analysis + synthesis with retry +- `bot/services/report_saver.py` — saves .md to `data/reports/` +- `bot/prompts/` — composable prompts: `depth.py`, `focus_areas.py`, `chunk_summary.py`, `synthesis.py` +- `bot/handlers/analyze.py` — multi-step flow (depth→focus→model→pay→run) +- `bot/handlers/payment.py` — pre_checkout handler ## Running - Container-based: `Containerfile` + `compose.yml` - `--login` flag for interactive Telethon session creation -- Session persists in `data/` volume +- Session persists in `data/` volume, DB at `data/bot.db`, reports at `data/reports/` - `.env` file must not have inline comments (Podman/Docker limitation) ## Common Tasks - To change chunk size: edit `MAX_TOKENS_PER_CHUNK` in `bot/services/chunker.py` -- To add a report type: add to `ReportType` enum, add prompts in both `prompts/` files -- To change model: set `CLAUDE_MODEL` env var; thinking params auto-adapt in `analyzer.py` +- To add a focus area: add to `FocusArea` enum, add prompt fragments in `prompts/focus_areas.py`, add i18n strings +- To add an AI model: add to `AVAILABLE_MODELS` env var (use `org/model` format for OpenRouter) +- To change pricing: set `PRICE_BASIC`/`PRICE_STANDARD`/`PRICE_FULL` env vars +- To change free trial count: set `FREE_ANALYSES` env var +- To add a language: add to `Lang` enum, add translations in `i18n/strings.py` diff --git a/bot/__main__.py b/bot/__main__.py index e4345cb..4bc63e5 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,7 +6,9 @@ from aiogram import Bot, Dispatcher from telethon import TelegramClient from bot.config import settings -from bot.handlers import analyze, start +from bot.db.engine import get_db, close_db +from bot.handlers import analyze, features, lang, payment, prices, start +from bot.middleware.user_middleware import UserMiddleware SESSION_PATH = "/app/data/analyzer_session" @@ -30,6 +32,10 @@ async def login() -> None: async def main() -> None: + # Init database + await get_db() + log.info("Database initialized") + telethon_client = TelegramClient( SESSION_PATH, settings.telegram_api_id, @@ -46,11 +52,19 @@ async def main() -> None: log.info("Telethon client started") bot = Bot(token=settings.bot_token) - # Attach telethon client to bot instance for handler access bot._telethon_client = telethon_client # type: ignore[attr-defined] dp = Dispatcher() + + # Register middleware + dp.update.middleware(UserMiddleware()) + + # Register routers — payment.pre_checkout must come before analyze dp.include_router(start.router) + dp.include_router(lang.router) + dp.include_router(features.router) + dp.include_router(prices.router) + dp.include_router(payment.router) dp.include_router(analyze.router) log.info("Starting bot polling...") @@ -58,6 +72,7 @@ async def main() -> None: await dp.start_polling(bot) finally: await telethon_client.disconnect() + await close_db() if __name__ == "__main__": diff --git a/bot/config.py b/bot/config.py index 5e0d528..eebab08 100644 --- a/bot/config.py +++ b/bot/config.py @@ -9,7 +9,22 @@ class Settings(BaseSettings): anthropic_api_key: str claude_model: str = "claude-opus-4-6" + openrouter_api_key: str = "" + available_models: str = "claude-haiku-4-5" + + price_basic: int = 50 + price_standard: int = 100 + price_full: int = 200 + free_analyses: int = 1 + + db_path: str = "/app/data/bot.db" + reports_dir: str = "/app/data/reports" + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} + @property + def models_list(self) -> list[str]: + return [m.strip() for m in self.available_models.split(",") if m.strip()] + settings = Settings() diff --git a/bot/db/__init__.py b/bot/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/db/engine.py b/bot/db/engine.py new file mode 100644 index 0000000..799f254 --- /dev/null +++ b/bot/db/engine.py @@ -0,0 +1,54 @@ +import aiosqlite + +from bot.config import settings + +_db: aiosqlite.Connection | None = None + +SCHEMA = """\ +CREATE TABLE IF NOT EXISTS users ( + telegram_id INTEGER PRIMARY KEY, + username TEXT, + first_name TEXT, + lang TEXT NOT NULL DEFAULT 'en', + free_used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL REFERENCES users(telegram_id), + channel TEXT NOT NULL, + depth TEXT NOT NULL, + focus_areas TEXT NOT NULL, + model_id TEXT NOT NULL, + stars_paid INTEGER NOT NULL DEFAULT 0, + report_path TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL REFERENCES users(telegram_id), + telegram_payment_id TEXT NOT NULL UNIQUE, + stars_amount INTEGER NOT NULL, + analysis_id INTEGER REFERENCES analyses(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +""" + + +async def get_db() -> aiosqlite.Connection: + global _db + if _db is None: + _db = await aiosqlite.connect(settings.db_path) + _db.row_factory = aiosqlite.Row + await _db.executescript(SCHEMA) + await _db.commit() + return _db + + +async def close_db() -> None: + global _db + if _db is not None: + await _db.close() + _db = None diff --git a/bot/db/usage_repo.py b/bot/db/usage_repo.py new file mode 100644 index 0000000..5d7800d --- /dev/null +++ b/bot/db/usage_repo.py @@ -0,0 +1,46 @@ +import json + +from bot.db.engine import get_db + + +async def record_analysis( + telegram_id: int, + channel: str, + depth: str, + focus_areas: list[str], + model_id: str, + stars_paid: int = 0, + report_path: str | None = None, +) -> int: + db = await get_db() + cursor = await db.execute( + "INSERT INTO analyses (telegram_id, channel, depth, focus_areas, model_id, stars_paid, report_path) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (telegram_id, channel, depth, json.dumps(focus_areas), model_id, stars_paid, report_path), + ) + await db.commit() + return cursor.lastrowid + + +async def update_report_path(analysis_id: int, report_path: str) -> None: + db = await get_db() + await db.execute( + "UPDATE analyses SET report_path = ? WHERE id = ?", (report_path, analysis_id) + ) + await db.commit() + + +async def record_payment( + telegram_id: int, + telegram_payment_id: str, + stars_amount: int, + analysis_id: int | None = None, +) -> int: + db = await get_db() + cursor = await db.execute( + "INSERT INTO payments (telegram_id, telegram_payment_id, stars_amount, analysis_id) " + "VALUES (?, ?, ?, ?)", + (telegram_id, telegram_payment_id, stars_amount, analysis_id), + ) + await db.commit() + return cursor.lastrowid diff --git a/bot/db/user_repo.py b/bot/db/user_repo.py new file mode 100644 index 0000000..3820704 --- /dev/null +++ b/bot/db/user_repo.py @@ -0,0 +1,52 @@ +from bot.db.engine import get_db + + +async def get_or_create( + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + lang: str = "en", +) -> dict: + db = await get_db() + row = await db.execute_fetchall( + "SELECT * FROM users WHERE telegram_id = ?", (telegram_id,) + ) + if row: + return dict(row[0]) + + await db.execute( + "INSERT INTO users (telegram_id, username, first_name, lang) VALUES (?, ?, ?, ?)", + (telegram_id, username, first_name, lang), + ) + await db.commit() + row = await db.execute_fetchall( + "SELECT * FROM users WHERE telegram_id = ?", (telegram_id,) + ) + return dict(row[0]) + + +async def update_lang(telegram_id: int, lang: str) -> None: + db = await get_db() + await db.execute("UPDATE users SET lang = ? WHERE telegram_id = ?", (lang, telegram_id)) + await db.commit() + + +async def increment_free(telegram_id: int) -> int: + db = await get_db() + await db.execute( + "UPDATE users SET free_used = free_used + 1 WHERE telegram_id = ?", + (telegram_id,), + ) + await db.commit() + row = await db.execute_fetchall( + "SELECT free_used FROM users WHERE telegram_id = ?", (telegram_id,) + ) + return row[0][0] + + +async def get_free_used(telegram_id: int) -> int: + db = await get_db() + row = await db.execute_fetchall( + "SELECT free_used FROM users WHERE telegram_id = ?", (telegram_id,) + ) + return row[0][0] if row else 0 diff --git a/bot/handlers/analyze.py b/bot/handlers/analyze.py index c51985c..a9c91ca 100644 --- a/bot/handlers/analyze.py +++ b/bot/handlers/analyze.py @@ -3,7 +3,14 @@ import re from aiogram import F, Router from aiogram.filters import Command -from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message +from aiogram.types import ( + CallbackQuery, + FSInputFile, + InlineKeyboardButton, + InlineKeyboardMarkup, + LabeledPrice, + Message, +) from telethon import TelegramClient from telethon.errors import ( ChannelInvalidError, @@ -13,132 +20,273 @@ from telethon.errors import ( UsernameNotOccupiedError, ) -from bot.models import ReportType +from bot.config import settings +from bot.db import usage_repo, user_repo +from bot.i18n import Lang, t +from bot.models import AnalysisSession, Depth, FocusArea from bot.services.analyzer import analyze_channel from bot.services.chunker import chunk_messages from bot.services.fetcher import fetch_channel_messages from bot.services.formatter import split_report +from bot.services.report_saver import save_report log = logging.getLogger(__name__) router = Router() -# channel_username -> store temporarily per user for callback -_pending: dict[int, str] = {} +_sessions: dict[int, AnalysisSession] = {} def _extract_channel(text: str) -> str | None: text = text.strip() - # @username m = re.match(r"@(\w+)", text) if m: return m.group(1) - # https://t.me/username m = re.match(r"https?://t\.me/(\w+)", text) if m: return m.group(1) - # bare username if re.match(r"^\w+$", text): return text return None -def _report_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=rt.label, callback_data=f"report:{rt.value}")] - for rt in ReportType - ] - ) +def _depth_keyboard(lang: Lang) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t("depth_basic", lang), callback_data="depth:basic")], + [InlineKeyboardButton(text=t("depth_standard", lang), callback_data="depth:standard")], + [InlineKeyboardButton(text=t("depth_full", lang), callback_data="depth:full")], + ]) +def _focus_keyboard(lang: Lang, selected: set[str]) -> InlineKeyboardMarkup: + rows = [] + for area in FocusArea: + check = "✅" if area.value in selected else "☐" + rows.append([InlineKeyboardButton( + text=f"{check} {t(f'focus_{area.value}', lang)}", + callback_data=f"focus:{area.value}", + )]) + rows.append([InlineKeyboardButton(text=f"✅ {t('done', lang)}", callback_data="focus:done")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def _model_keyboard() -> InlineKeyboardMarkup: + rows = [] + for model_id in settings.models_list: + label = model_id.split("/")[-1] if "/" in model_id else model_id + rows.append([InlineKeyboardButton(text=label, callback_data=f"model:{model_id}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +# Step 0: /analyze @channel @router.message(Command("analyze")) -async def cmd_analyze(message: Message) -> None: +async def cmd_analyze(message: Message, lang: Lang = Lang.EN, **_: object) -> None: args = (message.text or "").split(maxsplit=1) if len(args) < 2: - await message.answer( - "Please provide a channel: /analyze @channel", - parse_mode="HTML", - ) + await message.answer(t("provide_channel", lang), parse_mode="HTML") return channel = _extract_channel(args[1]) if not channel: - await message.answer("Could not parse channel name. Use @username or t.me/username.") + await message.answer(t("bad_channel", lang), parse_mode="HTML") return - _pending[message.from_user.id] = channel + _sessions[message.from_user.id] = AnalysisSession(channel=channel) await message.answer( - f"Channel: @{channel}\n\nChoose report type:", + t("choose_depth", lang, channel=channel), parse_mode="HTML", - reply_markup=_report_keyboard(), + reply_markup=_depth_keyboard(lang), ) -@router.callback_query(F.data.startswith("report:")) -async def on_report_type(callback: CallbackQuery) -> None: +# Step 1: Depth selected +@router.callback_query(F.data.startswith("depth:")) +async def on_depth(callback: CallbackQuery, lang: Lang = Lang.EN, **_: object) -> None: await callback.answer() - user_id = callback.from_user.id - channel = _pending.pop(user_id, None) - if not channel: - await callback.message.answer("Session expired. Please run /analyze again.") + session = _sessions.get(user_id) + if not session: + await callback.message.answer(t("session_expired", lang)) return - report_value = callback.data.split(":", 1)[1] - report_type = ReportType(report_value) + depth_val = callback.data.split(":", 1)[1] + session.depth = Depth(depth_val) - telethon_client: TelegramClient = callback.message.bot.__dict__.get("_telethon_client") + await callback.message.edit_text( + t("choose_focus", lang), + parse_mode="HTML", + reply_markup=_focus_keyboard(lang, set()), + ) + + +# Step 2: Focus area toggle +@router.callback_query(F.data.startswith("focus:")) +async def on_focus(callback: CallbackQuery, lang: Lang = Lang.EN, **_: object) -> None: + user_id = callback.from_user.id + session = _sessions.get(user_id) + if not session: + await callback.answer() + await callback.message.answer(t("session_expired", lang)) + return + + value = callback.data.split(":", 1)[1] + + if value == "done": + if not session.focus_areas: + await callback.answer(t("no_focus_selected", lang), show_alert=True) + return + await callback.answer() + # Show model selection + if len(settings.models_list) == 1: + # Skip model selection if only one available + session.model_id = settings.models_list[0] + await _check_payment_and_run(callback.message, user_id, lang) + else: + await callback.message.edit_text( + t("choose_model", lang), + parse_mode="HTML", + reply_markup=_model_keyboard(), + ) + return + + await callback.answer() + area = FocusArea(value) + if area in session.focus_areas: + session.focus_areas.remove(area) + else: + session.focus_areas.append(area) + + selected = {a.value for a in session.focus_areas} + await callback.message.edit_reply_markup( + reply_markup=_focus_keyboard(lang, selected), + ) + + +# Step 3: Model selected +@router.callback_query(F.data.startswith("model:")) +async def on_model(callback: CallbackQuery, lang: Lang = Lang.EN, **_: object) -> None: + await callback.answer() + user_id = callback.from_user.id + session = _sessions.get(user_id) + if not session: + await callback.message.answer(t("session_expired", lang)) + return + + session.model_id = callback.data.split(":", 1)[1] + await _check_payment_and_run(callback.message, user_id, lang) + + +# Step 4: Payment check + run +async def _check_payment_and_run(message: Message, user_id: int, lang: Lang) -> None: + session = _sessions.get(user_id) + if not session: + return + + free_used = await user_repo.get_free_used(user_id) + price_map = { + Depth.BASIC: settings.price_basic, + Depth.STANDARD: settings.price_standard, + Depth.FULL: settings.price_full, + } + price = price_map[session.depth] + + if free_used < settings.free_analyses: + await user_repo.increment_free(user_id) + await message.edit_text( + t("free_analysis", lang, used=free_used + 1, max=settings.free_analyses), + parse_mode="HTML", + ) + await _run_analysis(message, user_id, lang, stars_paid=0) + else: + # Record analysis first to get ID for payload + analysis_id = await usage_repo.record_analysis( + telegram_id=user_id, + channel=session.channel, + depth=session.depth.value, + focus_areas=[a.value for a in session.focus_areas], + model_id=session.model_id, + stars_paid=price, + ) + await message.answer_invoice( + title=t("invoice_title", lang, depth=session.depth.label), + description=t("invoice_description", lang, channel=session.channel, depth=session.depth.label), + payload=str(analysis_id), + currency="XTR", + prices=[LabeledPrice(label="Analysis", amount=price)], + ) + + +# Payment callback triggers analysis +@router.message(lambda m: m.successful_payment is not None) +async def on_payment_run(message: Message, lang: Lang = Lang.EN, **_: object) -> None: + user_id = message.from_user.id + session = _sessions.get(user_id) + if not session: + return + payment = message.successful_payment + await usage_repo.record_payment( + telegram_id=user_id, + telegram_payment_id=payment.telegram_payment_charge_id, + stars_amount=payment.total_amount, + analysis_id=int(payment.invoice_payload) if payment.invoice_payload.isdigit() else None, + ) + await _run_analysis(message, user_id, lang, stars_paid=payment.total_amount) + + +# Step 5: Run pipeline +async def _run_analysis(message: Message, user_id: int, lang: Lang, stars_paid: int) -> None: + session = _sessions.pop(user_id, None) + if not session: + return + + telethon_client: TelegramClient | None = message.bot.__dict__.get("_telethon_client") if not telethon_client: - # Fallback: try dispatcher data - from aiogram import Dispatcher - # Access via bot's dispatcher isn't directly available in callback, - # so we store it on the bot instance in __main__.py - await callback.message.answer("Internal error: Telethon client not configured.") + await message.answer(t("internal_error", lang)) return - status_msg = await callback.message.answer( - f"Starting {report_type.label} for @{channel}...\n\n" - "Fetching messages...", + status_msg = await message.answer( + t("analysis_starting", lang, channel=session.channel, status=t("fetching", lang)), parse_mode="HTML", ) async def update_status(text: str) -> None: try: await status_msg.edit_text( - f"{report_type.label} for @{channel}\n\n{text}", + t("analysis_starting", lang, channel=session.channel, status=text), parse_mode="HTML", ) except Exception: pass try: - messages, stats = await fetch_channel_messages(telethon_client, channel) + messages, stats = await fetch_channel_messages(telethon_client, session.channel) except (ChannelPrivateError, ChannelInvalidError): - await status_msg.edit_text("Channel is private or does not exist.") + await status_msg.edit_text(t("channel_private", lang)) return except (UsernameInvalidError, UsernameNotOccupiedError): - await status_msg.edit_text("Channel username not found.") + await status_msg.edit_text(t("channel_not_found", lang)) return except FloodWaitError as e: - await status_msg.edit_text(f"Rate limited by Telegram. Retry in {e.seconds}s.") + await status_msg.edit_text(t("flood_wait", lang, s=e.seconds)) return except Exception as e: - log.exception("Failed to fetch channel %s", channel) - await status_msg.edit_text(f"Failed to fetch channel: {e}") + log.exception("Failed to fetch channel %s", session.channel) + await status_msg.edit_text(t("fetch_failed", lang, e=e)) return if not messages: - await status_msg.edit_text("No text messages found in this channel.") + await status_msg.edit_text(t("no_messages", lang)) return - await update_status(f"Fetched {len(messages)} messages. Chunking...") - + await update_status(t("fetched_n", lang, n=len(messages))) chunks = chunk_messages(messages) - await update_status(f"{len(messages)} messages in {len(chunks)} chunks. Analyzing...") + await update_status(t("chunked", lang, n=len(messages), c=len(chunks))) try: report = await analyze_channel( - report_type=report_type, + depth=session.depth, + focus_areas=session.focus_areas, + lang=lang.value, + model_id=session.model_id, chunks=chunks, channel_title=stats["title"], channel_username=stats.get("username"), @@ -147,21 +295,42 @@ async def on_report_type(callback: CallbackQuery) -> None: on_progress=update_status, ) except Exception as e: - log.exception("Analysis failed for %s", channel) - await status_msg.edit_text(f"Analysis failed: {e}") + log.exception("Analysis failed for %s", session.channel) + await status_msg.edit_text(t("analysis_failed", lang, e=e)) return - await update_status("Sending report...") + # Save report + report_path = await save_report(user_id, session.channel, report) + + # Record in DB + await usage_repo.record_analysis( + telegram_id=user_id, + channel=session.channel, + depth=session.depth.value, + focus_areas=[a.value for a in session.focus_areas], + model_id=session.model_id, + stars_paid=stars_paid, + report_path=report_path, + ) + + await update_status(t("sending_report", lang)) parts = split_report(report) for part in parts: try: - await callback.message.answer(part, parse_mode="HTML") + await message.answer(part, parse_mode="HTML") except Exception: - # Fallback: send without formatting - await callback.message.answer(part) + await message.answer(part) + + # Offer file download + await message.answer_document( + FSInputFile(report_path, filename=f"{session.channel}_report.md"), + caption=t("download_report", lang), + ) try: await status_msg.delete() except Exception: pass + + diff --git a/bot/handlers/features.py b/bot/handlers/features.py new file mode 100644 index 0000000..13340d5 --- /dev/null +++ b/bot/handlers/features.py @@ -0,0 +1,16 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from bot.i18n import Lang, t +from bot.models import FocusArea + +router = Router() + + +@router.message(Command("features")) +async def cmd_features(message: Message, lang: Lang = Lang.EN, **_: object) -> None: + lines = [t("features_title", lang)] + for area in FocusArea: + lines.append(f"• {t(f'features_{area.value}', lang)}") + await message.answer("\n".join(lines), parse_mode="HTML") diff --git a/bot/handlers/lang.py b/bot/handlers/lang.py new file mode 100644 index 0000000..be04aa4 --- /dev/null +++ b/bot/handlers/lang.py @@ -0,0 +1,20 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from bot.db import user_repo +from bot.i18n import Lang, t + +router = Router() + + +@router.message(Command("lang")) +async def cmd_lang(message: Message, lang: Lang = Lang.EN, **_: object) -> None: + args = (message.text or "").split(maxsplit=1) + if len(args) < 2 or args[1].strip().lower() not in ("en", "ru"): + await message.answer(t("lang_usage", lang), parse_mode="HTML") + return + + new_lang = args[1].strip().lower() + await user_repo.update_lang(message.from_user.id, new_lang) + await message.answer(t("lang_set", new_lang), parse_mode="HTML") diff --git a/bot/handlers/payment.py b/bot/handlers/payment.py new file mode 100644 index 0000000..8d898b4 --- /dev/null +++ b/bot/handlers/payment.py @@ -0,0 +1,9 @@ +from aiogram import Router +from aiogram.types import PreCheckoutQuery + +router = Router() + + +@router.pre_checkout_query() +async def on_pre_checkout(query: PreCheckoutQuery, **_: object) -> None: + await query.answer(ok=True) diff --git a/bot/handlers/prices.py b/bot/handlers/prices.py new file mode 100644 index 0000000..b92a372 --- /dev/null +++ b/bot/handlers/prices.py @@ -0,0 +1,19 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from bot.config import settings +from bot.i18n import Lang, t + +router = Router() + + +@router.message(Command("prices")) +async def cmd_prices(message: Message, lang: Lang = Lang.EN, **_: object) -> None: + text = t( + "prices", lang, + basic=settings.price_basic, + standard=settings.price_standard, + full=settings.price_full, + ) + await message.answer(text, parse_mode="HTML") diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 1ab704b..c4f5697 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -2,21 +2,11 @@ from aiogram import Router from aiogram.filters import Command from aiogram.types import Message -router = Router() +from bot.i18n import Lang, t -HELP_TEXT = ( - "Telegram Channel Analyzer\n\n" - "Analyze any public Telegram channel using AI.\n\n" - "Usage:\n" - "/analyze @channel — Start analysis\n" - "/analyze https://t.me/channel — Also works\n\n" - "You'll choose a report type:\n" - "• Content Analysis — topics, tone, themes\n" - "• Content + Stats — above + engagement data\n" - "• Full Audit — comprehensive review with recommendations" -) +router = Router() @router.message(Command("start", "help")) -async def cmd_start(message: Message) -> None: - await message.answer(HELP_TEXT, parse_mode="HTML") +async def cmd_start(message: Message, lang: Lang = Lang.EN, **_: object) -> None: + await message.answer(t("welcome", lang), parse_mode="HTML") diff --git a/bot/i18n/__init__.py b/bot/i18n/__init__.py new file mode 100644 index 0000000..664a058 --- /dev/null +++ b/bot/i18n/__init__.py @@ -0,0 +1,30 @@ +from enum import Enum + +from bot.i18n.strings import STRINGS + + +class Lang(Enum): + EN = "en" + RU = "ru" + + +def t(key: str, lang: Lang | str = Lang.EN, **kwargs: object) -> str: + if isinstance(lang, Lang): + lang_str = lang.value + elif lang in ("en", "ru"): + lang_str = lang + else: + lang_str = "en" + entry = STRINGS.get(key) + if not entry: + return key + text = entry.get(lang_str, entry.get("en", key)) + if kwargs: + text = text.format(**kwargs) + return text + + +def detect_lang(language_code: str | None) -> Lang: + if language_code and language_code.startswith("ru"): + return Lang.RU + return Lang.EN diff --git a/bot/i18n/strings.py b/bot/i18n/strings.py new file mode 100644 index 0000000..399534e --- /dev/null +++ b/bot/i18n/strings.py @@ -0,0 +1,255 @@ +STRINGS: dict[str, dict[str, str]] = { + # /start, /help + "welcome": { + "en": ( + "Telegram Channel Analyzer\n\n" + "Analyze any public Telegram channel using AI.\n\n" + "Commands:\n" + "/analyze @channel — Start analysis\n" + "/lang en|ru — Change language\n" + "/features — Available focus areas\n" + "/prices — Pricing info\n" + ), + "ru": ( + "Анализатор Telegram-каналов\n\n" + "Анализ любого публичного канала с помощью ИИ.\n\n" + "Команды:\n" + "/analyze @channel — Начать анализ\n" + "/lang en|ru — Сменить язык\n" + "/features — Доступные области анализа\n" + "/prices — Информация о ценах\n" + ), + }, + + # /analyze + "provide_channel": { + "en": "Please provide a channel: /analyze @channel", + "ru": "Укажите канал: /analyze @канал", + }, + "bad_channel": { + "en": "Could not parse channel name. Use @username or t.me/username.", + "ru": "Не удалось разобрать имя канала. Используйте @username или t.me/username.", + }, + "choose_depth": { + "en": "Channel: @{channel}\n\nChoose analysis depth:", + "ru": "Канал: @{channel}\n\nВыберите глубину анализа:", + }, + "choose_focus": { + "en": "Select focus areas (tap to toggle, then Done):", + "ru": "Выберите области анализа (нажмите для выбора, затем Готово):", + }, + "choose_model": { + "en": "Choose AI model:", + "ru": "Выберите модель ИИ:", + }, + "session_expired": { + "en": "Session expired. Please run /analyze again.", + "ru": "Сессия истекла. Запустите /analyze снова.", + }, + "no_focus_selected": { + "en": "Please select at least one focus area.", + "ru": "Выберите хотя бы одну область анализа.", + }, + + # Depth labels + "depth_basic": { + "en": "Basic (fast)", + "ru": "Базовый (быстрый)", + }, + "depth_standard": { + "en": "Standard", + "ru": "Стандартный", + }, + "depth_full": { + "en": "Full (detailed)", + "ru": "Полный (детальный)", + }, + + # Focus area labels + "focus_psychology": { + "en": "Psychology & Influence", + "ru": "Психология и влияние", + }, + "focus_business": { + "en": "Business & Monetization", + "ru": "Бизнес и монетизация", + }, + "focus_marketing": { + "en": "Marketing & Growth", + "ru": "Маркетинг и рост", + }, + "focus_content": { + "en": "Content Strategy", + "ru": "Контент-стратегия", + }, + "focus_audience": { + "en": "Audience & Engagement", + "ru": "Аудитория и вовлечённость", + }, + "focus_sentiment": { + "en": "Sentiment Analysis", + "ru": "Анализ тональности", + }, + + # Analysis progress + "fetching": { + "en": "Fetching messages...", + "ru": "Загрузка сообщений...", + }, + "fetched_n": { + "en": "Fetched {n} messages. Chunking...", + "ru": "Загружено {n} сообщений. Разбиение...", + }, + "chunked": { + "en": "{n} messages in {c} chunks. Analyzing...", + "ru": "{n} сообщений в {c} частях. Анализ...", + }, + "analyzing_chunk": { + "en": "Analyzing chunk {i}/{total}...", + "ru": "Анализ части {i}/{total}...", + }, + "chunk_done_cooldown": { + "en": "Chunk {i}/{total} done. Cooling down 60s...", + "ru": "Часть {i}/{total} готова. Пауза 60с...", + }, + "generating_report": { + "en": "Generating final report...", + "ru": "Генерация финального отчёта...", + }, + "sending_report": { + "en": "Sending report...", + "ru": "Отправка отчёта...", + }, + "analysis_starting": { + "en": "Starting analysis for @{channel}...\n\n{status}", + "ru": "Начинаю анализ @{channel}...\n\n{status}", + }, + + # Errors + "channel_private": { + "en": "Channel is private or does not exist.", + "ru": "Канал приватный или не существует.", + }, + "channel_not_found": { + "en": "Channel username not found.", + "ru": "Имя канала не найдено.", + }, + "flood_wait": { + "en": "Rate limited by Telegram. Retry in {s}s.", + "ru": "Ограничение Telegram. Повторите через {s}с.", + }, + "fetch_failed": { + "en": "Failed to fetch channel: {e}", + "ru": "Ошибка получения канала: {e}", + }, + "no_messages": { + "en": "No text messages found in this channel.", + "ru": "В канале не найдено текстовых сообщений.", + }, + "analysis_failed": { + "en": "Analysis failed: {e}", + "ru": "Ошибка анализа: {e}", + }, + "internal_error": { + "en": "Internal error: Telethon client not configured.", + "ru": "Внутренняя ошибка: клиент Telethon не настроен.", + }, + "rate_limited": { + "en": "Rate limited, waiting {s}s...", + "ru": "Лимит запросов, ожидание {s}с...", + }, + + # Payment + "free_analysis": { + "en": "This analysis is free ({used}/{max} free trial).", + "ru": "Этот анализ бесплатный ({used}/{max} пробный).", + }, + "payment_required": { + "en": "This analysis costs {price} Stars.", + "ru": "Этот анализ стоит {price} Stars.", + }, + "invoice_title": { + "en": "Channel Analysis — {depth}", + "ru": "Анализ канала — {depth}", + }, + "invoice_description": { + "en": "Analysis of @{channel} ({depth} depth)", + "ru": "Анализ @{channel} ({depth})", + }, + "payment_success": { + "en": "Payment received! Starting analysis...", + "ru": "Оплата получена! Начинаю анализ...", + }, + + # /lang + "lang_set": { + "en": "Language set to English.", + "ru": "Язык установлен: Русский.", + }, + "lang_usage": { + "en": "Usage: /lang en or /lang ru", + "ru": "Использование: /lang en или /lang ru", + }, + + # /features + "features_title": { + "en": "Available Focus Areas\n", + "ru": "Доступные области анализа\n", + }, + "features_psychology": { + "en": "Psychology & Influence — Persuasion techniques, cognitive biases, emotional triggers", + "ru": "Психология и влияние — Техники убеждения, когнитивные искажения, эмоциональные триггеры", + }, + "features_business": { + "en": "Business & Monetization — Revenue models, product placement, conversion patterns", + "ru": "Бизнес и монетизация — Модели доходов, продвижение продуктов, воронки конверсии", + }, + "features_marketing": { + "en": "Marketing & Growth — Growth tactics, viral mechanics, audience acquisition", + "ru": "Маркетинг и рост — Тактики роста, вирусные механики, привлечение аудитории", + }, + "features_content": { + "en": "Content Strategy — Topics, formats, posting patterns, narrative arcs", + "ru": "Контент-стратегия — Темы, форматы, паттерны публикаций, нарративы", + }, + "features_audience": { + "en": "Audience & Engagement — Interaction patterns, community dynamics, engagement drivers", + "ru": "Аудитория и вовлечённость — Паттерны взаимодействия, динамика сообщества", + }, + "features_sentiment": { + "en": "Sentiment Analysis — Tone distribution, emotional shifts, sentiment by topic", + "ru": "Анализ тональности — Распределение тона, эмоциональные сдвиги, тональность по темам", + }, + + # /prices + "prices": { + "en": ( + "Pricing\n\n" + "First analysis is free!\n\n" + "• Basic — {basic} Stars\n" + "• Standard — {standard} Stars\n" + "• Full — {full} Stars\n\n" + "Payment via Telegram Stars." + ), + "ru": ( + "Цены\n\n" + "Первый анализ бесплатно!\n\n" + "• Базовый — {basic} Stars\n" + "• Стандартный — {standard} Stars\n" + "• Полный — {full} Stars\n\n" + "Оплата через Telegram Stars." + ), + }, + + # Report download + "download_report": { + "en": "Download report", + "ru": "Скачать отчёт", + }, + + # Done button + "done": { + "en": "Done", + "ru": "Готово", + }, +} diff --git a/bot/middleware/__init__.py b/bot/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/middleware/user_middleware.py b/bot/middleware/user_middleware.py new file mode 100644 index 0000000..4f239b5 --- /dev/null +++ b/bot/middleware/user_middleware.py @@ -0,0 +1,41 @@ +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update + +from bot.db import user_repo +from bot.i18n import Lang, detect_lang + + +class UserMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + user = None + if isinstance(event, Update): + if event.message and event.message.from_user: + user = event.message.from_user + elif event.callback_query and event.callback_query.from_user: + user = event.callback_query.from_user + elif event.pre_checkout_query and event.pre_checkout_query.from_user: + user = event.pre_checkout_query.from_user + elif hasattr(event, "from_user") and event.from_user: + user = event.from_user + + if user: + db_user = await user_repo.get_or_create( + telegram_id=user.id, + username=user.username, + first_name=user.first_name, + lang=detect_lang(user.language_code).value, + ) + data["lang"] = Lang(db_user["lang"]) + data["db_user"] = db_user + else: + data["lang"] = Lang.EN + data["db_user"] = None + + return await handler(event, data) diff --git a/bot/models.py b/bot/models.py index 85b503b..fb32ea2 100644 --- a/bot/models.py +++ b/bot/models.py @@ -1,27 +1,39 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum -class ReportType(Enum): - CONTENT = "content" - CONTENT_STATS = "content_stats" - FULL_AUDIT = "full_audit" +class Depth(Enum): + BASIC = "basic" + STANDARD = "standard" + FULL = "full" @property def label(self) -> str: return { - ReportType.CONTENT: "Content Analysis", - ReportType.CONTENT_STATS: "Content + Stats", - ReportType.FULL_AUDIT: "Full Audit", + Depth.BASIC: "Basic", + Depth.STANDARD: "Standard", + Depth.FULL: "Full", }[self] + +class FocusArea(Enum): + PSYCHOLOGY = "psychology" + BUSINESS = "business" + MARKETING = "marketing" + CONTENT = "content" + AUDIENCE = "audience" + SENTIMENT = "sentiment" + @property - def description(self) -> str: + def label(self) -> str: return { - ReportType.CONTENT: "Topics, tone, themes, content strategy", - ReportType.CONTENT_STATS: "Above + posting frequency, engagement patterns", - ReportType.FULL_AUDIT: "All above + sentiment, audience insights, recommendations", + FocusArea.PSYCHOLOGY: "Psychology & Influence", + FocusArea.BUSINESS: "Business & Monetization", + FocusArea.MARKETING: "Marketing & Growth", + FocusArea.CONTENT: "Content Strategy", + FocusArea.AUDIENCE: "Audience & Engagement", + FocusArea.SENTIMENT: "Sentiment Analysis", }[self] @@ -33,3 +45,11 @@ class ChannelMessage: views: int | None = None forwards: int | None = None replies: int | None = None + + +@dataclass +class AnalysisSession: + channel: str + depth: Depth | None = None + focus_areas: list[FocusArea] = field(default_factory=list) + model_id: str | None = None diff --git a/bot/prompts/chunk_summary.py b/bot/prompts/chunk_summary.py index efd3156..9f0e3c4 100644 --- a/bot/prompts/chunk_summary.py +++ b/bot/prompts/chunk_summary.py @@ -1,4 +1,6 @@ -from bot.models import ReportType +from bot.models import Depth, FocusArea +from bot.prompts.depth import DEPTH_CHUNK_MODIFIERS +from bot.prompts.focus_areas import FOCUS_CHUNK_BULLETS _BASE = """\ You are analyzing a batch of Telegram channel posts. Extract structured insights from this chunk. @@ -10,52 +12,11 @@ Posts: {chunk_text} """ -_CONTENT = """\ -Focus on: -- Main topics and themes discussed -- Tone and communication style -- Content formats (long-form, short updates, lists, etc.) -- Key narratives or recurring ideas -- Notable quotes or standout posts - -Provide a concise structured summary.""" - -_CONTENT_STATS = """\ -Focus on: -- Main topics and themes discussed -- Tone and communication style -- Content formats used -- Key narratives or recurring ideas -- Posting frequency patterns in this batch -- Engagement patterns (which topics get more views/forwards/replies) -- Any notable spikes or drops in engagement - -Provide a concise structured summary with both qualitative and quantitative observations.""" - -_FULL_AUDIT = """\ -Focus on: -- Main topics and themes discussed -- Tone and communication style -- Content formats used -- Key narratives or recurring ideas -- Posting frequency patterns -- Engagement patterns with specific numbers -- Sentiment analysis (positive/negative/neutral distribution) -- Audience interaction patterns -- Content strengths and weaknesses -- Missed opportunities - -Provide a detailed structured summary covering all dimensions.""" - -CHUNK_PROMPTS = { - ReportType.CONTENT: _CONTENT, - ReportType.CONTENT_STATS: _CONTENT_STATS, - ReportType.FULL_AUDIT: _FULL_AUDIT, -} - def build_chunk_prompt( - report_type: ReportType, + depth: Depth, + focus_areas: list[FocusArea], + lang: str, title: str, chunk_text: str, chunk_idx: int, @@ -67,4 +28,14 @@ def build_chunk_prompt( total_chunks=total_chunks, chunk_text=chunk_text, ) - return base + "\n" + CHUNK_PROMPTS[report_type] + parts = [base] + + parts.append(f"Depth: {DEPTH_CHUNK_MODIFIERS[depth]}\n") + + parts.append("Focus on the following areas:") + for area in focus_areas: + parts.append(FOCUS_CHUNK_BULLETS[area]) + + parts.append(f"\nProvide the analysis in {'Russian' if lang == 'ru' else 'English'}.") + + return "\n".join(parts) diff --git a/bot/prompts/depth.py b/bot/prompts/depth.py new file mode 100644 index 0000000..a065e7d --- /dev/null +++ b/bot/prompts/depth.py @@ -0,0 +1,19 @@ +from bot.models import Depth + +DEPTH_CHUNK_MODIFIERS: dict[Depth, str] = { + Depth.BASIC: "Provide a brief, high-level summary. Focus on the most important points only. Be concise.", + Depth.STANDARD: "Provide a structured summary with moderate detail. Cover key patterns and notable examples.", + Depth.FULL: ( + "Provide an exhaustive, detailed analysis. Include specific examples, quotes, " + "numerical data, and subtle patterns. Leave nothing significant out." + ), +} + +DEPTH_SYNTHESIS_MODIFIERS: dict[Depth, str] = { + Depth.BASIC: "Keep the report concise and actionable. Use short sections with bullet points.", + Depth.STANDARD: "Provide a well-structured report with moderate depth. Balance brevity and detail.", + Depth.FULL: ( + "Produce a comprehensive, in-depth report. Include detailed analysis, specific evidence, " + "data-backed observations, and strategic recommendations. Be thorough." + ), +} diff --git a/bot/prompts/focus_areas.py b/bot/prompts/focus_areas.py new file mode 100644 index 0000000..6d9db36 --- /dev/null +++ b/bot/prompts/focus_areas.py @@ -0,0 +1,74 @@ +from bot.models import FocusArea + +FOCUS_CHUNK_BULLETS: dict[FocusArea, str] = { + FocusArea.PSYCHOLOGY: ( + "- Persuasion and influence techniques used\n" + "- Cognitive biases leveraged (scarcity, social proof, authority, etc.)\n" + "- Emotional triggers and manipulation patterns\n" + "- Framing and narrative control techniques" + ), + FocusArea.BUSINESS: ( + "- Revenue models and monetization strategies\n" + "- Product/service placement and promotion patterns\n" + "- Conversion funnels and calls-to-action\n" + "- Pricing psychology and offer structuring" + ), + FocusArea.MARKETING: ( + "- Growth tactics and audience acquisition strategies\n" + "- Viral mechanics and shareability factors\n" + "- Cross-promotion and collaboration patterns\n" + "- Brand positioning and differentiation" + ), + FocusArea.CONTENT: ( + "- Main topics and themes discussed\n" + "- Content formats (long-form, short updates, lists, media, etc.)\n" + "- Posting frequency and schedule patterns\n" + "- Narrative arcs and series/recurring segments\n" + "- Content quality and originality assessment" + ), + FocusArea.AUDIENCE: ( + "- Engagement patterns (views, forwards, replies per content type)\n" + "- Audience interaction and community dynamics\n" + "- Top-performing vs underperforming content\n" + "- Engagement drivers and detractors" + ), + FocusArea.SENTIMENT: ( + "- Overall sentiment distribution (positive/negative/neutral)\n" + "- Sentiment breakdown by topic\n" + "- Emotional tone shifts over time\n" + "- Controversial or polarizing content identification" + ), +} + +FOCUS_SYNTHESIS_SECTIONS: dict[FocusArea, str] = { + FocusArea.PSYCHOLOGY: ( + "## Psychology & Influence\n" + "Analyze persuasion techniques, cognitive biases, emotional triggers, " + "and manipulation patterns found across the channel's content." + ), + FocusArea.BUSINESS: ( + "## Business & Monetization\n" + "Detail revenue models, monetization strategies, product placements, " + "conversion patterns, and business-related content." + ), + FocusArea.MARKETING: ( + "## Marketing & Growth\n" + "Assess growth tactics, viral mechanics, cross-promotion strategies, " + "and brand positioning approaches." + ), + FocusArea.CONTENT: ( + "## Content Strategy\n" + "Analyze topics, formats, posting patterns, narrative arcs, " + "content quality, and overall editorial strategy." + ), + FocusArea.AUDIENCE: ( + "## Audience & Engagement\n" + "Deep dive into engagement metrics, audience interaction patterns, " + "community dynamics, and what drives or kills engagement." + ), + FocusArea.SENTIMENT: ( + "## Sentiment Analysis\n" + "Present sentiment distribution, emotional tone analysis, " + "sentiment by topic, and shifts over time." + ), +} diff --git a/bot/prompts/synthesis.py b/bot/prompts/synthesis.py index 46c95dd..55c54c8 100644 --- a/bot/prompts/synthesis.py +++ b/bot/prompts/synthesis.py @@ -1,4 +1,6 @@ -from bot.models import ReportType +from bot.models import Depth, FocusArea +from bot.prompts.depth import DEPTH_SYNTHESIS_MODIFIERS +from bot.prompts.focus_areas import FOCUS_SYNTHESIS_SECTIONS _BASE = """\ You are producing a final report for a Telegram channel analysis. @@ -13,103 +15,11 @@ Below are the summaries from each chunk of the channel's history: {chunk_summaries} """ -_CONTENT = """\ -Synthesize the chunk summaries into a comprehensive **Content Analysis Report** with these sections: - -## Overview -Brief channel description and positioning. - -## Key Topics & Themes -Main subject areas with examples. - -## Tone & Communication Style -How the channel communicates with its audience. - -## Content Strategy -Formats used, posting patterns, narrative arcs. - -## Notable Content -Standout posts or recurring motifs. - -## Summary -Key takeaways in 3-5 bullet points. - -Use Telegram-friendly formatting (bold, bullet points). Be specific — reference actual content patterns you observed.""" - -_CONTENT_STATS = """\ -Synthesize the chunk summaries into a comprehensive **Content & Stats Report** with these sections: - -## Overview -Brief channel description, subscriber count, and overall activity level. - -## Key Topics & Themes -Main subject areas ranked by frequency and engagement. - -## Tone & Communication Style -How the channel communicates with its audience. - -## Content Strategy -Formats used, posting patterns, narrative arcs. - -## Engagement Analysis -- Average engagement patterns -- Top-performing content types -- Engagement trends over time - -## Posting Patterns -Frequency, schedule consistency, any notable gaps or bursts. - -## Summary -Key takeaways in 5-7 bullet points mixing qualitative and quantitative insights. - -Use Telegram-friendly formatting. Include specific numbers where available.""" - -_FULL_AUDIT = """\ -Synthesize the chunk summaries into a comprehensive **Full Channel Audit** with these sections: - -## Executive Summary -Channel positioning, key metrics, and overall assessment. - -## Key Topics & Themes -Main subject areas ranked by frequency and engagement, with trend analysis. - -## Tone & Communication Style -Detailed analysis of voice, register, and audience relationship. - -## Content Strategy Assessment -Formats, patterns, narrative arcs — what works and what doesn't. - -## Engagement Deep Dive -- Engagement metrics and benchmarks -- Top-performing vs underperforming content -- Engagement drivers and detractors - -## Sentiment Analysis -Overall sentiment distribution, sentiment by topic, shifts over time. - -## Audience Insights -Inferred audience profile, interaction patterns, community dynamics. - -## Strengths -What the channel does well (3-5 points with evidence). - -## Areas for Improvement -Actionable recommendations (3-5 points with specific suggestions). - -## Strategic Recommendations -Forward-looking advice for channel growth and content optimization. - -Use Telegram-friendly formatting. Be specific — back every claim with observed patterns or data.""" - -SYNTHESIS_PROMPTS = { - ReportType.CONTENT: _CONTENT, - ReportType.CONTENT_STATS: _CONTENT_STATS, - ReportType.FULL_AUDIT: _FULL_AUDIT, -} - def build_synthesis_prompt( - report_type: ReportType, + depth: Depth, + focus_areas: list[FocusArea], + lang: str, title: str, username: str | None, subscribers: int | None, @@ -127,4 +37,18 @@ def build_synthesis_prompt( chunk_count=len(chunk_summaries), chunk_summaries=numbered, ) - return base + "\n" + SYNTHESIS_PROMPTS[report_type] + parts = [base] + + parts.append("Synthesize the chunk summaries into a comprehensive report with these sections:\n") + parts.append("## Overview\nBrief channel description and positioning.\n") + + for area in focus_areas: + parts.append(FOCUS_SYNTHESIS_SECTIONS[area] + "\n") + + parts.append("## Key Takeaways\nSummarize the most important findings.\n") + + parts.append(DEPTH_SYNTHESIS_MODIFIERS[depth]) + parts.append(f"\nWrite the entire report in {'Russian' if lang == 'ru' else 'English'}.") + parts.append("Use Telegram-friendly formatting (bold, bullet points). Be specific — reference actual content patterns.") + + return "\n".join(parts) diff --git a/bot/services/ai_client.py b/bot/services/ai_client.py new file mode 100644 index 0000000..f96fa56 --- /dev/null +++ b/bot/services/ai_client.py @@ -0,0 +1,106 @@ +import json +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Coroutine +from typing import Any + +import anthropic +import httpx + +from bot.config import settings + +log = logging.getLogger(__name__) + +ProgressCallback = Callable[[str], Coroutine[Any, Any, None]] + + +class AIClient(ABC): + @abstractmethod + async def complete( + self, + system: str, + user: str, + max_tokens: int, + on_progress: ProgressCallback | None = None, + ) -> str: ... + + +class AnthropicClient(AIClient): + def __init__(self, model_id: str) -> None: + self.model_id = model_id + self.client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + + async def complete( + self, + system: str, + user: str, + max_tokens: int, + on_progress: ProgressCallback | None = None, + ) -> str: + kwargs: dict[str, Any] = { + "model": self.model_id, + "max_tokens": max_tokens, + "system": system, + "messages": [{"role": "user", "content": user}], + } + if "opus-4-6" in self.model_id: + kwargs["thinking"] = {"type": "adaptive"} + + async with self.client.messages.stream(**kwargs) as stream: + response = await stream.get_final_message() + + return "".join( + block.text for block in response.content if block.type == "text" + ) + + +class OpenRouterClient(AIClient): + BASE_URL = "https://openrouter.ai/api/v1/chat/completions" + + def __init__(self, model_id: str) -> None: + self.model_id = model_id + + async def complete( + self, + system: str, + user: str, + max_tokens: int, + on_progress: ProgressCallback | None = None, + ) -> str: + headers = { + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + } + payload = { + "model": self.model_id, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "stream": True, + } + + collected = [] + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream("POST", self.BASE_URL, headers=headers, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + chunk = json.loads(data) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + collected.append(content) + + return "".join(collected) + + +def get_ai_client(model_id: str) -> AIClient: + if "/" in model_id: + return OpenRouterClient(model_id) + return AnthropicClient(model_id) diff --git a/bot/services/analyzer.py b/bot/services/analyzer.py index 4172dc7..dd8f0da 100644 --- a/bot/services/analyzer.py +++ b/bot/services/analyzer.py @@ -4,46 +4,32 @@ from collections.abc import Callable, Coroutine from typing import Any import anthropic +import httpx -from bot.config import settings -from bot.models import ReportType +from bot.models import Depth, FocusArea from bot.prompts.chunk_summary import build_chunk_prompt from bot.prompts.synthesis import build_synthesis_prompt +from bot.services.ai_client import AIClient, get_ai_client log = logging.getLogger(__name__) -# Only 1 concurrent request to stay within rate limits _semaphore = asyncio.Semaphore(1) - MAX_RETRIES = 5 +ProgressCallback = Callable[[str], Coroutine[Any, Any, None]] -async def _call_claude( - client: anthropic.AsyncAnthropic, + +async def _call_with_retry( + client: AIClient, system: str, user: str, max_tokens: int, - on_progress: "ProgressCallback | None" = None, + on_progress: ProgressCallback | None = None, ) -> str: for attempt in range(MAX_RETRIES): try: async with _semaphore: - kwargs: dict[str, Any] = { - "model": settings.claude_model, - "max_tokens": max_tokens, - "system": system, - "messages": [{"role": "user", "content": user}], - } - # Adaptive thinking only works on Opus 4.6 - if "opus-4-6" in settings.claude_model: - kwargs["thinking"] = {"type": "adaptive"} - - async with client.messages.stream(**kwargs) as stream: - response = await stream.get_final_message() - - return "".join( - block.text for block in response.content if block.type == "text" - ) + return await client.complete(system, user, max_tokens, on_progress) except anthropic.RateLimitError as e: wait = getattr(e, "retry_after", None) or 60 log.warning("Rate limited, waiting %ds (attempt %d/%d)", wait, attempt + 1, MAX_RETRIES) @@ -57,15 +43,22 @@ async def _call_claude( await asyncio.sleep(wait) else: raise + except httpx.HTTPStatusError as e: + if e.response.status_code == 429 or e.response.status_code >= 500: + wait = 10 * (attempt + 1) + log.warning("HTTP %d, retrying in %ds", e.response.status_code, wait) + await asyncio.sleep(wait) + else: + raise - raise RuntimeError("Max retries exceeded due to rate limiting") - - -ProgressCallback = Callable[[str], Coroutine[Any, Any, None]] + raise RuntimeError("Max retries exceeded") async def analyze_channel( - report_type: ReportType, + depth: Depth, + focus_areas: list[FocusArea], + lang: str, + model_id: str, chunks: list[str], channel_title: str, channel_username: str | None, @@ -73,8 +66,7 @@ async def analyze_channel( msg_count: int, on_progress: ProgressCallback | None = None, ) -> str: - client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) - + client = get_ai_client(model_id) total = len(chunks) summaries: list[str] = [] @@ -83,9 +75,9 @@ async def analyze_channel( await on_progress(f"Analyzing chunk {i}/{total}...") prompt = build_chunk_prompt( - report_type, channel_title, chunk_text, i, total + depth, focus_areas, lang, channel_title, chunk_text, i, total ) - summary = await _call_claude( + summary = await _call_with_retry( client, system="You are an expert Telegram channel analyst.", user=prompt, @@ -95,24 +87,20 @@ async def analyze_channel( summaries.append(summary) log.info("Chunk %d/%d summarized (%d chars)", i, total, len(summary)) - # Wait 60s between chunks — rate limit is 30K input tokens/min if i < total: if on_progress: - await on_progress(f"Chunk {i}/{total} done. Cooling down 60s for rate limit...") + await on_progress(f"Chunk {i}/{total} done. Cooling down 60s...") await asyncio.sleep(60) if on_progress: await on_progress("Generating final report...") synthesis_prompt = build_synthesis_prompt( - report_type, - channel_title, - channel_username, - subscribers, - msg_count, - summaries, + depth, focus_areas, lang, + channel_title, channel_username, subscribers, + msg_count, summaries, ) - report = await _call_claude( + report = await _call_with_retry( client, system="You are an expert Telegram channel analyst producing a final report.", user=synthesis_prompt, diff --git a/bot/services/report_saver.py b/bot/services/report_saver.py new file mode 100644 index 0000000..595ebab --- /dev/null +++ b/bot/services/report_saver.py @@ -0,0 +1,18 @@ +import os +from datetime import datetime, timezone + +from bot.config import settings + + +async def save_report( + telegram_id: int, + channel: str, + report_md: str, +) -> str: + os.makedirs(settings.reports_dir, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename = f"{telegram_id}_{channel}_{ts}.md" + path = os.path.join(settings.reports_dir, filename) + with open(path, "w", encoding="utf-8") as f: + f.write(report_md) + return path diff --git a/pyproject.toml b/pyproject.toml index 218b36f..d3ee41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "tg-channel-analyzer" -version = "0.1.0" +version = "0.2.0" requires-python = ">=3.11" dependencies = [ "aiogram>=3.24,<4", "telethon>=1.42,<2", "anthropic>=0.80,<1", "pydantic-settings>=2.0", + "aiosqlite>=0.20", ]