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:
parent
8a84145e15
commit
c30b7e4675
27 changed files with 1155 additions and 280 deletions
|
|
@ -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
16
bot/handlers/features.py
Normal 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
20
bot/handlers/lang.py
Normal 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
9
bot/handlers/payment.py
Normal 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
19
bot/handlers/prices.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue