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
This commit is contained in:
Sergei Poljanski 2026-02-23 01:18:12 +02:00
commit c30b7e4675
27 changed files with 1155 additions and 280 deletions

View file

@ -4,3 +4,11 @@ TELEGRAM_API_HASH=
TELEGRAM_PHONE= TELEGRAM_PHONE=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
CLAUDE_MODEL=claude-opus-4-6 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

View file

@ -4,40 +4,55 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview ## 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 ## Stack
- **Python 3.12**, async throughout - **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 - **Telethon** — userbot client for reading public channel history
- **anthropic** (AsyncAnthropic) — Claude API with streaming - **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 - **pydantic-settings** — config from environment variables
## Architecture ## Architecture
- Both aiogram and Telethon share one asyncio loop (no threads) - Both aiogram and Telethon share one asyncio loop (no threads)
- Telethon client is attached to the bot instance in `__main__.py` - 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 - 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 - 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 ## Key Files
- `bot/config.py` — all settings from env vars, `CLAUDE_MODEL` selects the model - `bot/config.py` — all settings from env vars (Anthropic, OpenRouter, pricing, paths)
- `bot/services/analyzer.py` — Claude API calls, adaptive thinking only for opus-4-6 - `bot/models.py``Depth`, `FocusArea` enums, `AnalysisSession` dataclass
- `bot/services/chunker.py``MAX_TOKENS_PER_CHUNK` and `CHARS_PER_TOKEN` control chunking - `bot/i18n/``Lang` enum, `t()` lookup, all UI strings in `strings.py`
- `bot/prompts/` — prompt templates per report type (chunk_summary.py, synthesis.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 ## Running
- Container-based: `Containerfile` + `compose.yml` - Container-based: `Containerfile` + `compose.yml`
- `--login` flag for interactive Telethon session creation - `--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) - `.env` file must not have inline comments (Podman/Docker limitation)
## Common Tasks ## Common Tasks
- To change chunk size: edit `MAX_TOKENS_PER_CHUNK` in `bot/services/chunker.py` - 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 add a focus area: add to `FocusArea` enum, add prompt fragments in `prompts/focus_areas.py`, add i18n strings
- To change model: set `CLAUDE_MODEL` env var; thinking params auto-adapt in `analyzer.py` - 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`

View file

@ -6,7 +6,9 @@ from aiogram import Bot, Dispatcher
from telethon import TelegramClient from telethon import TelegramClient
from bot.config import settings 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" SESSION_PATH = "/app/data/analyzer_session"
@ -30,6 +32,10 @@ async def login() -> None:
async def main() -> None: async def main() -> None:
# Init database
await get_db()
log.info("Database initialized")
telethon_client = TelegramClient( telethon_client = TelegramClient(
SESSION_PATH, SESSION_PATH,
settings.telegram_api_id, settings.telegram_api_id,
@ -46,11 +52,19 @@ async def main() -> None:
log.info("Telethon client started") log.info("Telethon client started")
bot = Bot(token=settings.bot_token) bot = Bot(token=settings.bot_token)
# Attach telethon client to bot instance for handler access
bot._telethon_client = telethon_client # type: ignore[attr-defined] bot._telethon_client = telethon_client # type: ignore[attr-defined]
dp = Dispatcher() 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(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) dp.include_router(analyze.router)
log.info("Starting bot polling...") log.info("Starting bot polling...")
@ -58,6 +72,7 @@ async def main() -> None:
await dp.start_polling(bot) await dp.start_polling(bot)
finally: finally:
await telethon_client.disconnect() await telethon_client.disconnect()
await close_db()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,7 +9,22 @@ class Settings(BaseSettings):
anthropic_api_key: str anthropic_api_key: str
claude_model: str = "claude-opus-4-6" 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"} 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() settings = Settings()

0
bot/db/__init__.py Normal file
View file

54
bot/db/engine.py Normal file
View file

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

46
bot/db/usage_repo.py Normal file
View file

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

52
bot/db/user_repo.py Normal file
View file

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

View file

@ -3,7 +3,14 @@ import re
from aiogram import F, Router from aiogram import F, Router
from aiogram.filters import Command 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 import TelegramClient
from telethon.errors import ( from telethon.errors import (
ChannelInvalidError, ChannelInvalidError,
@ -13,132 +20,273 @@ from telethon.errors import (
UsernameNotOccupiedError, 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.analyzer import analyze_channel
from bot.services.chunker import chunk_messages from bot.services.chunker import chunk_messages
from bot.services.fetcher import fetch_channel_messages from bot.services.fetcher import fetch_channel_messages
from bot.services.formatter import split_report from bot.services.formatter import split_report
from bot.services.report_saver import save_report
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = Router() router = Router()
# channel_username -> store temporarily per user for callback _sessions: dict[int, AnalysisSession] = {}
_pending: dict[int, str] = {}
def _extract_channel(text: str) -> str | None: def _extract_channel(text: str) -> str | None:
text = text.strip() text = text.strip()
# @username
m = re.match(r"@(\w+)", text) m = re.match(r"@(\w+)", text)
if m: if m:
return m.group(1) return m.group(1)
# https://t.me/username
m = re.match(r"https?://t\.me/(\w+)", text) m = re.match(r"https?://t\.me/(\w+)", text)
if m: if m:
return m.group(1) return m.group(1)
# bare username
if re.match(r"^\w+$", text): if re.match(r"^\w+$", text):
return text return text
return None return None
def _report_keyboard() -> InlineKeyboardMarkup: def _depth_keyboard(lang: Lang) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup( return InlineKeyboardMarkup(inline_keyboard=[
inline_keyboard=[ [InlineKeyboardButton(text=t("depth_basic", lang), callback_data="depth:basic")],
[InlineKeyboardButton(text=rt.label, callback_data=f"report:{rt.value}")] [InlineKeyboardButton(text=t("depth_standard", lang), callback_data="depth:standard")],
for rt in ReportType [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")) @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) args = (message.text or "").split(maxsplit=1)
if len(args) < 2: if len(args) < 2:
await message.answer( await message.answer(t("provide_channel", lang), parse_mode="HTML")
"Please provide a channel: <code>/analyze @channel</code>",
parse_mode="HTML",
)
return return
channel = _extract_channel(args[1]) channel = _extract_channel(args[1])
if not channel: 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 return
_pending[message.from_user.id] = channel _sessions[message.from_user.id] = AnalysisSession(channel=channel)
await message.answer( await message.answer(
f"Channel: <b>@{channel}</b>\n\nChoose report type:", t("choose_depth", lang, channel=channel),
parse_mode="HTML", parse_mode="HTML",
reply_markup=_report_keyboard(), reply_markup=_depth_keyboard(lang),
) )
@router.callback_query(F.data.startswith("report:")) # Step 1: Depth selected
async def on_report_type(callback: CallbackQuery) -> None: @router.callback_query(F.data.startswith("depth:"))
async def on_depth(callback: CallbackQuery, lang: Lang = Lang.EN, **_: object) -> None:
await callback.answer() await callback.answer()
user_id = callback.from_user.id user_id = callback.from_user.id
channel = _pending.pop(user_id, None) session = _sessions.get(user_id)
if not channel: if not session:
await callback.message.answer("Session expired. Please run /analyze again.") await callback.message.answer(t("session_expired", lang))
return return
report_value = callback.data.split(":", 1)[1] depth_val = callback.data.split(":", 1)[1]
report_type = ReportType(report_value) 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: if not telethon_client:
# Fallback: try dispatcher data await message.answer(t("internal_error", lang))
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.")
return return
status_msg = await callback.message.answer( status_msg = await message.answer(
f"Starting <b>{report_type.label}</b> for @{channel}...\n\n" t("analysis_starting", lang, channel=session.channel, status=t("fetching", lang)),
"Fetching messages...",
parse_mode="HTML", parse_mode="HTML",
) )
async def update_status(text: str) -> None: async def update_status(text: str) -> None:
try: try:
await status_msg.edit_text( await status_msg.edit_text(
f"<b>{report_type.label}</b> for @{channel}\n\n{text}", t("analysis_starting", lang, channel=session.channel, status=text),
parse_mode="HTML", parse_mode="HTML",
) )
except Exception: except Exception:
pass pass
try: try:
messages, stats = await fetch_channel_messages(telethon_client, channel) messages, stats = await fetch_channel_messages(telethon_client, session.channel)
except (ChannelPrivateError, ChannelInvalidError): 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 return
except (UsernameInvalidError, UsernameNotOccupiedError): except (UsernameInvalidError, UsernameNotOccupiedError):
await status_msg.edit_text("Channel username not found.") await status_msg.edit_text(t("channel_not_found", lang))
return return
except FloodWaitError as e: 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 return
except Exception as e: except Exception as e:
log.exception("Failed to fetch channel %s", channel) log.exception("Failed to fetch channel %s", session.channel)
await status_msg.edit_text(f"Failed to fetch channel: {e}") await status_msg.edit_text(t("fetch_failed", lang, e=e))
return return
if not messages: 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 return
await update_status(f"Fetched {len(messages)} messages. Chunking...") await update_status(t("fetched_n", lang, n=len(messages)))
chunks = chunk_messages(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: try:
report = await analyze_channel( 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, chunks=chunks,
channel_title=stats["title"], channel_title=stats["title"],
channel_username=stats.get("username"), channel_username=stats.get("username"),
@ -147,21 +295,42 @@ async def on_report_type(callback: CallbackQuery) -> None:
on_progress=update_status, on_progress=update_status,
) )
except Exception as e: except Exception as e:
log.exception("Analysis failed for %s", channel) log.exception("Analysis failed for %s", session.channel)
await status_msg.edit_text(f"Analysis failed: {e}") await status_msg.edit_text(t("analysis_failed", lang, e=e))
return 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) parts = split_report(report)
for part in parts: for part in parts:
try: try:
await callback.message.answer(part, parse_mode="HTML") await message.answer(part, parse_mode="HTML")
except Exception: except Exception:
# Fallback: send without formatting await message.answer(part)
await callback.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: try:
await status_msg.delete() await status_msg.delete()
except Exception: except Exception:
pass pass

16
bot/handlers/features.py Normal file
View file

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

20
bot/handlers/lang.py Normal file
View file

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

9
bot/handlers/payment.py Normal file
View file

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

19
bot/handlers/prices.py Normal file
View file

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

View file

@ -2,21 +2,11 @@ from aiogram import Router
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message from aiogram.types import Message
router = Router() from bot.i18n import Lang, t
HELP_TEXT = ( router = Router()
"<b>Telegram Channel Analyzer</b>\n\n"
"Analyze any public Telegram channel using AI.\n\n"
"<b>Usage:</b>\n"
"<code>/analyze @channel</code> — Start analysis\n"
"<code>/analyze https://t.me/channel</code> — Also works\n\n"
"You'll choose a report type:\n"
"• <b>Content Analysis</b> — topics, tone, themes\n"
"• <b>Content + Stats</b> — above + engagement data\n"
"• <b>Full Audit</b> — comprehensive review with recommendations"
)
@router.message(Command("start", "help")) @router.message(Command("start", "help"))
async def cmd_start(message: Message) -> None: async def cmd_start(message: Message, lang: Lang = Lang.EN, **_: object) -> None:
await message.answer(HELP_TEXT, parse_mode="HTML") await message.answer(t("welcome", lang), parse_mode="HTML")

30
bot/i18n/__init__.py Normal file
View file

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

255
bot/i18n/strings.py Normal file
View file

@ -0,0 +1,255 @@
STRINGS: dict[str, dict[str, str]] = {
# /start, /help
"welcome": {
"en": (
"<b>Telegram Channel Analyzer</b>\n\n"
"Analyze any public Telegram channel using AI.\n\n"
"<b>Commands:</b>\n"
"<code>/analyze @channel</code> — Start analysis\n"
"<code>/lang en|ru</code> — Change language\n"
"<code>/features</code> — Available focus areas\n"
"<code>/prices</code> — Pricing info\n"
),
"ru": (
"<b>Анализатор Telegram-каналов</b>\n\n"
"Анализ любого публичного канала с помощью ИИ.\n\n"
"<b>Команды:</b>\n"
"<code>/analyze @channel</code> — Начать анализ\n"
"<code>/lang en|ru</code> — Сменить язык\n"
"<code>/features</code> — Доступные области анализа\n"
"<code>/prices</code> — Информация о ценах\n"
),
},
# /analyze
"provide_channel": {
"en": "Please provide a channel: <code>/analyze @channel</code>",
"ru": "Укажите канал: <code>/analyze @канал</code>",
},
"bad_channel": {
"en": "Could not parse channel name. Use @username or t.me/username.",
"ru": "Не удалось разобрать имя канала. Используйте @username или t.me/username.",
},
"choose_depth": {
"en": "Channel: <b>@{channel}</b>\n\nChoose analysis depth:",
"ru": "Канал: <b>@{channel}</b>\n\nВыберите глубину анализа:",
},
"choose_focus": {
"en": "Select focus areas (tap to toggle, then <b>Done</b>):",
"ru": "Выберите области анализа (нажмите для выбора, затем <b>Готово</b>):",
},
"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: <code>/lang en</code> or <code>/lang ru</code>",
"ru": "Использование: <code>/lang en</code> или <code>/lang ru</code>",
},
# /features
"features_title": {
"en": "<b>Available Focus Areas</b>\n",
"ru": "<b>Доступные области анализа</b>\n",
},
"features_psychology": {
"en": "<b>Psychology & Influence</b> — Persuasion techniques, cognitive biases, emotional triggers",
"ru": "<b>Психология и влияние</b> — Техники убеждения, когнитивные искажения, эмоциональные триггеры",
},
"features_business": {
"en": "<b>Business & Monetization</b> — Revenue models, product placement, conversion patterns",
"ru": "<b>Бизнес и монетизация</b> — Модели доходов, продвижение продуктов, воронки конверсии",
},
"features_marketing": {
"en": "<b>Marketing & Growth</b> — Growth tactics, viral mechanics, audience acquisition",
"ru": "<b>Маркетинг и рост</b> — Тактики роста, вирусные механики, привлечение аудитории",
},
"features_content": {
"en": "<b>Content Strategy</b> — Topics, formats, posting patterns, narrative arcs",
"ru": "<b>Контент-стратегия</b> — Темы, форматы, паттерны публикаций, нарративы",
},
"features_audience": {
"en": "<b>Audience & Engagement</b> — Interaction patterns, community dynamics, engagement drivers",
"ru": "<b>Аудитория и вовлечённость</b> — Паттерны взаимодействия, динамика сообщества",
},
"features_sentiment": {
"en": "<b>Sentiment Analysis</b> — Tone distribution, emotional shifts, sentiment by topic",
"ru": "<b>Анализ тональности</b> — Распределение тона, эмоциональные сдвиги, тональность по темам",
},
# /prices
"prices": {
"en": (
"<b>Pricing</b>\n\n"
"First analysis is free!\n\n"
"• <b>Basic</b> — {basic} Stars\n"
"• <b>Standard</b> — {standard} Stars\n"
"• <b>Full</b> — {full} Stars\n\n"
"Payment via Telegram Stars."
),
"ru": (
"<b>Цены</b>\n\n"
"Первый анализ бесплатно!\n\n"
"• <b>Базовый</b> — {basic} Stars\n"
"• <b>Стандартный</b> — {standard} Stars\n"
"• <b>Полный</b> — {full} Stars\n\n"
"Оплата через Telegram Stars."
),
},
# Report download
"download_report": {
"en": "Download report",
"ru": "Скачать отчёт",
},
# Done button
"done": {
"en": "Done",
"ru": "Готово",
},
}

View file

View file

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

View file

@ -1,27 +1,39 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
class ReportType(Enum): class Depth(Enum):
CONTENT = "content" BASIC = "basic"
CONTENT_STATS = "content_stats" STANDARD = "standard"
FULL_AUDIT = "full_audit" FULL = "full"
@property @property
def label(self) -> str: def label(self) -> str:
return { return {
ReportType.CONTENT: "Content Analysis", Depth.BASIC: "Basic",
ReportType.CONTENT_STATS: "Content + Stats", Depth.STANDARD: "Standard",
ReportType.FULL_AUDIT: "Full Audit", Depth.FULL: "Full",
}[self] }[self]
class FocusArea(Enum):
PSYCHOLOGY = "psychology"
BUSINESS = "business"
MARKETING = "marketing"
CONTENT = "content"
AUDIENCE = "audience"
SENTIMENT = "sentiment"
@property @property
def description(self) -> str: def label(self) -> str:
return { return {
ReportType.CONTENT: "Topics, tone, themes, content strategy", FocusArea.PSYCHOLOGY: "Psychology & Influence",
ReportType.CONTENT_STATS: "Above + posting frequency, engagement patterns", FocusArea.BUSINESS: "Business & Monetization",
ReportType.FULL_AUDIT: "All above + sentiment, audience insights, recommendations", FocusArea.MARKETING: "Marketing & Growth",
FocusArea.CONTENT: "Content Strategy",
FocusArea.AUDIENCE: "Audience & Engagement",
FocusArea.SENTIMENT: "Sentiment Analysis",
}[self] }[self]
@ -33,3 +45,11 @@ class ChannelMessage:
views: int | None = None views: int | None = None
forwards: int | None = None forwards: int | None = None
replies: 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

View file

@ -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 = """\ _BASE = """\
You are analyzing a batch of Telegram channel posts. Extract structured insights from this chunk. You are analyzing a batch of Telegram channel posts. Extract structured insights from this chunk.
@ -10,52 +12,11 @@ Posts:
{chunk_text} {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( def build_chunk_prompt(
report_type: ReportType, depth: Depth,
focus_areas: list[FocusArea],
lang: str,
title: str, title: str,
chunk_text: str, chunk_text: str,
chunk_idx: int, chunk_idx: int,
@ -67,4 +28,14 @@ def build_chunk_prompt(
total_chunks=total_chunks, total_chunks=total_chunks,
chunk_text=chunk_text, 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)

19
bot/prompts/depth.py Normal file
View file

@ -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."
),
}

View file

@ -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."
),
}

View file

@ -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 = """\ _BASE = """\
You are producing a final report for a Telegram channel analysis. 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} {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( def build_synthesis_prompt(
report_type: ReportType, depth: Depth,
focus_areas: list[FocusArea],
lang: str,
title: str, title: str,
username: str | None, username: str | None,
subscribers: int | None, subscribers: int | None,
@ -127,4 +37,18 @@ def build_synthesis_prompt(
chunk_count=len(chunk_summaries), chunk_count=len(chunk_summaries),
chunk_summaries=numbered, 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)

106
bot/services/ai_client.py Normal file
View file

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

View file

@ -4,46 +4,32 @@ from collections.abc import Callable, Coroutine
from typing import Any from typing import Any
import anthropic import anthropic
import httpx
from bot.config import settings from bot.models import Depth, FocusArea
from bot.models import ReportType
from bot.prompts.chunk_summary import build_chunk_prompt from bot.prompts.chunk_summary import build_chunk_prompt
from bot.prompts.synthesis import build_synthesis_prompt from bot.prompts.synthesis import build_synthesis_prompt
from bot.services.ai_client import AIClient, get_ai_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Only 1 concurrent request to stay within rate limits
_semaphore = asyncio.Semaphore(1) _semaphore = asyncio.Semaphore(1)
MAX_RETRIES = 5 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, system: str,
user: str, user: str,
max_tokens: int, max_tokens: int,
on_progress: "ProgressCallback | None" = None, on_progress: ProgressCallback | None = None,
) -> str: ) -> str:
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
try: try:
async with _semaphore: async with _semaphore:
kwargs: dict[str, Any] = { return await client.complete(system, user, max_tokens, on_progress)
"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"
)
except anthropic.RateLimitError as e: except anthropic.RateLimitError as e:
wait = getattr(e, "retry_after", None) or 60 wait = getattr(e, "retry_after", None) or 60
log.warning("Rate limited, waiting %ds (attempt %d/%d)", wait, attempt + 1, MAX_RETRIES) 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) await asyncio.sleep(wait)
else: else:
raise 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") raise RuntimeError("Max retries exceeded")
ProgressCallback = Callable[[str], Coroutine[Any, Any, None]]
async def analyze_channel( async def analyze_channel(
report_type: ReportType, depth: Depth,
focus_areas: list[FocusArea],
lang: str,
model_id: str,
chunks: list[str], chunks: list[str],
channel_title: str, channel_title: str,
channel_username: str | None, channel_username: str | None,
@ -73,8 +66,7 @@ async def analyze_channel(
msg_count: int, msg_count: int,
on_progress: ProgressCallback | None = None, on_progress: ProgressCallback | None = None,
) -> str: ) -> str:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) client = get_ai_client(model_id)
total = len(chunks) total = len(chunks)
summaries: list[str] = [] summaries: list[str] = []
@ -83,9 +75,9 @@ async def analyze_channel(
await on_progress(f"Analyzing chunk {i}/{total}...") await on_progress(f"Analyzing chunk {i}/{total}...")
prompt = build_chunk_prompt( 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, client,
system="You are an expert Telegram channel analyst.", system="You are an expert Telegram channel analyst.",
user=prompt, user=prompt,
@ -95,24 +87,20 @@ async def analyze_channel(
summaries.append(summary) summaries.append(summary)
log.info("Chunk %d/%d summarized (%d chars)", i, total, len(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 i < total:
if on_progress: 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) await asyncio.sleep(60)
if on_progress: if on_progress:
await on_progress("Generating final report...") await on_progress("Generating final report...")
synthesis_prompt = build_synthesis_prompt( synthesis_prompt = build_synthesis_prompt(
report_type, depth, focus_areas, lang,
channel_title, channel_title, channel_username, subscribers,
channel_username, msg_count, summaries,
subscribers,
msg_count,
summaries,
) )
report = await _call_claude( report = await _call_with_retry(
client, client,
system="You are an expert Telegram channel analyst producing a final report.", system="You are an expert Telegram channel analyst producing a final report.",
user=synthesis_prompt, user=synthesis_prompt,

View file

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

View file

@ -1,10 +1,11 @@
[project] [project]
name = "tg-channel-analyzer" name = "tg-channel-analyzer"
version = "0.1.0" version = "0.2.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"aiogram>=3.24,<4", "aiogram>=3.24,<4",
"telethon>=1.42,<2", "telethon>=1.42,<2",
"anthropic>=0.80,<1", "anthropic>=0.80,<1",
"pydantic-settings>=2.0", "pydantic-settings>=2.0",
"aiosqlite>=0.20",
] ]