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

@ -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: <code>/analyze @channel</code>",
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: <b>@{channel}</b>\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 <b>{report_type.label}</b> 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"<b>{report_type.label}</b> 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

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.types import Message
router = Router()
from bot.i18n import Lang, t
HELP_TEXT = (
"<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 = 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")