init
This commit is contained in:
commit
ec1112e162
22 changed files with 837 additions and 0 deletions
5
.containerignore
Normal file
5
.containerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.session
|
||||||
|
__pycache__
|
||||||
|
.git
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
BOT_TOKEN=
|
||||||
|
TELEGRAM_API_ID=
|
||||||
|
TELEGRAM_API_HASH=
|
||||||
|
TELEGRAM_PHONE=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
CLAUDE_MODEL=claude-opus-4-6
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.session
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.mypy_cache/
|
||||||
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a new, empty project. Update this file as the codebase takes shape.
|
||||||
12
Containerfile
Normal file
12
Containerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY bot/ bot/
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
CMD ["python", "-m", "bot"]
|
||||||
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
67
bot/__main__.py
Normal file
67
bot/__main__.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from telethon import TelegramClient
|
||||||
|
|
||||||
|
from bot.config import settings
|
||||||
|
from bot.handlers import analyze, start
|
||||||
|
|
||||||
|
SESSION_PATH = "/app/data/analyzer_session"
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def login() -> None:
|
||||||
|
"""Interactive one-time login to create the Telethon session file."""
|
||||||
|
client = TelegramClient(
|
||||||
|
SESSION_PATH,
|
||||||
|
settings.telegram_api_id,
|
||||||
|
settings.telegram_api_hash,
|
||||||
|
)
|
||||||
|
await client.start(phone=settings.telegram_phone)
|
||||||
|
log.info("Session created at %s.session", SESSION_PATH)
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
telethon_client = TelegramClient(
|
||||||
|
SESSION_PATH,
|
||||||
|
settings.telegram_api_id,
|
||||||
|
settings.telegram_api_hash,
|
||||||
|
)
|
||||||
|
await telethon_client.connect()
|
||||||
|
if not await telethon_client.is_user_authorized():
|
||||||
|
log.error(
|
||||||
|
"No valid session. Run with --login first: "
|
||||||
|
"podman run -it --env-file .env -v ./data:/app/data tg-analyzer python -m bot --login"
|
||||||
|
)
|
||||||
|
await telethon_client.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
log.info("Telethon client started")
|
||||||
|
|
||||||
|
bot = Bot(token=settings.bot_token)
|
||||||
|
# Attach telethon client to bot instance for handler access
|
||||||
|
bot._telethon_client = telethon_client # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
dp = Dispatcher()
|
||||||
|
dp.include_router(start.router)
|
||||||
|
dp.include_router(analyze.router)
|
||||||
|
|
||||||
|
log.info("Starting bot polling...")
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
await telethon_client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--login" in sys.argv:
|
||||||
|
asyncio.run(login())
|
||||||
|
else:
|
||||||
|
asyncio.run(main())
|
||||||
15
bot/config.py
Normal file
15
bot/config.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
bot_token: str
|
||||||
|
telegram_api_id: int
|
||||||
|
telegram_api_hash: str
|
||||||
|
telegram_phone: str
|
||||||
|
anthropic_api_key: str
|
||||||
|
claude_model: str = "claude-opus-4-6"
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
0
bot/handlers/__init__.py
Normal file
0
bot/handlers/__init__.py
Normal file
167
bot/handlers/analyze.py
Normal file
167
bot/handlers/analyze.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.errors import (
|
||||||
|
ChannelInvalidError,
|
||||||
|
ChannelPrivateError,
|
||||||
|
FloodWaitError,
|
||||||
|
UsernameInvalidError,
|
||||||
|
UsernameNotOccupiedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.models import ReportType
|
||||||
|
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
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
# channel_username -> store temporarily per user for callback
|
||||||
|
_pending: dict[int, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("analyze"))
|
||||||
|
async def cmd_analyze(message: Message) -> 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",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = _extract_channel(args[1])
|
||||||
|
if not channel:
|
||||||
|
await message.answer("Could not parse channel name. Use @username or t.me/username.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_pending[message.from_user.id] = channel
|
||||||
|
await message.answer(
|
||||||
|
f"Channel: <b>@{channel}</b>\n\nChoose report type:",
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=_report_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("report:"))
|
||||||
|
async def on_report_type(callback: CallbackQuery) -> 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.")
|
||||||
|
return
|
||||||
|
|
||||||
|
report_value = callback.data.split(":", 1)[1]
|
||||||
|
report_type = ReportType(report_value)
|
||||||
|
|
||||||
|
telethon_client: TelegramClient = callback.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.")
|
||||||
|
return
|
||||||
|
|
||||||
|
status_msg = await callback.message.answer(
|
||||||
|
f"Starting <b>{report_type.label}</b> for @{channel}...\n\n"
|
||||||
|
"Fetching messages...",
|
||||||
|
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}",
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages, stats = await fetch_channel_messages(telethon_client, channel)
|
||||||
|
except (ChannelPrivateError, ChannelInvalidError):
|
||||||
|
await status_msg.edit_text("Channel is private or does not exist.")
|
||||||
|
return
|
||||||
|
except (UsernameInvalidError, UsernameNotOccupiedError):
|
||||||
|
await status_msg.edit_text("Channel username not found.")
|
||||||
|
return
|
||||||
|
except FloodWaitError as e:
|
||||||
|
await status_msg.edit_text(f"Rate limited by Telegram. Retry in {e.seconds}s.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Failed to fetch channel %s", channel)
|
||||||
|
await status_msg.edit_text(f"Failed to fetch channel: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
await status_msg.edit_text("No text messages found in this channel.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update_status(f"Fetched {len(messages)} messages. Chunking...")
|
||||||
|
|
||||||
|
chunks = chunk_messages(messages)
|
||||||
|
await update_status(f"{len(messages)} messages in {len(chunks)} chunks. Analyzing...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = await analyze_channel(
|
||||||
|
report_type=report_type,
|
||||||
|
chunks=chunks,
|
||||||
|
channel_title=stats["title"],
|
||||||
|
channel_username=stats.get("username"),
|
||||||
|
subscribers=stats.get("subscribers"),
|
||||||
|
msg_count=len(messages),
|
||||||
|
on_progress=update_status,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Analysis failed for %s", channel)
|
||||||
|
await status_msg.edit_text(f"Analysis failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update_status("Sending report...")
|
||||||
|
|
||||||
|
parts = split_report(report)
|
||||||
|
for part in parts:
|
||||||
|
try:
|
||||||
|
await callback.message.answer(part, parse_mode="HTML")
|
||||||
|
except Exception:
|
||||||
|
# Fallback: send without formatting
|
||||||
|
await callback.message.answer(part)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await status_msg.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
22
bot/handlers/start.py
Normal file
22
bot/handlers/start.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
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.message(Command("start", "help"))
|
||||||
|
async def cmd_start(message: Message) -> None:
|
||||||
|
await message.answer(HELP_TEXT, parse_mode="HTML")
|
||||||
35
bot/models.py
Normal file
35
bot/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(Enum):
|
||||||
|
CONTENT = "content"
|
||||||
|
CONTENT_STATS = "content_stats"
|
||||||
|
FULL_AUDIT = "full_audit"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return {
|
||||||
|
ReportType.CONTENT: "Content Analysis",
|
||||||
|
ReportType.CONTENT_STATS: "Content + Stats",
|
||||||
|
ReportType.FULL_AUDIT: "Full Audit",
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return {
|
||||||
|
ReportType.CONTENT: "Topics, tone, themes, content strategy",
|
||||||
|
ReportType.CONTENT_STATS: "Above + posting frequency, engagement patterns",
|
||||||
|
ReportType.FULL_AUDIT: "All above + sentiment, audience insights, recommendations",
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelMessage:
|
||||||
|
id: int
|
||||||
|
date: datetime
|
||||||
|
text: str
|
||||||
|
views: int | None = None
|
||||||
|
forwards: int | None = None
|
||||||
|
replies: int | None = None
|
||||||
0
bot/prompts/__init__.py
Normal file
0
bot/prompts/__init__.py
Normal file
70
bot/prompts/chunk_summary.py
Normal file
70
bot/prompts/chunk_summary.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from bot.models import ReportType
|
||||||
|
|
||||||
|
_BASE = """\
|
||||||
|
You are analyzing a batch of Telegram channel posts. Extract structured insights from this chunk.
|
||||||
|
|
||||||
|
Channel: {title}
|
||||||
|
Chunk {chunk_idx} of {total_chunks}
|
||||||
|
|
||||||
|
Posts:
|
||||||
|
{chunk_text}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONTENT = """\
|
||||||
|
Focus on:
|
||||||
|
- Main topics and themes discussed
|
||||||
|
- Tone and communication style
|
||||||
|
- Content formats (long-form, short updates, lists, etc.)
|
||||||
|
- Key narratives or recurring ideas
|
||||||
|
- Notable quotes or standout posts
|
||||||
|
|
||||||
|
Provide a concise structured summary."""
|
||||||
|
|
||||||
|
_CONTENT_STATS = """\
|
||||||
|
Focus on:
|
||||||
|
- Main topics and themes discussed
|
||||||
|
- Tone and communication style
|
||||||
|
- Content formats used
|
||||||
|
- Key narratives or recurring ideas
|
||||||
|
- Posting frequency patterns in this batch
|
||||||
|
- Engagement patterns (which topics get more views/forwards/replies)
|
||||||
|
- Any notable spikes or drops in engagement
|
||||||
|
|
||||||
|
Provide a concise structured summary with both qualitative and quantitative observations."""
|
||||||
|
|
||||||
|
_FULL_AUDIT = """\
|
||||||
|
Focus on:
|
||||||
|
- Main topics and themes discussed
|
||||||
|
- Tone and communication style
|
||||||
|
- Content formats used
|
||||||
|
- Key narratives or recurring ideas
|
||||||
|
- Posting frequency patterns
|
||||||
|
- Engagement patterns with specific numbers
|
||||||
|
- Sentiment analysis (positive/negative/neutral distribution)
|
||||||
|
- Audience interaction patterns
|
||||||
|
- Content strengths and weaknesses
|
||||||
|
- Missed opportunities
|
||||||
|
|
||||||
|
Provide a detailed structured summary covering all dimensions."""
|
||||||
|
|
||||||
|
CHUNK_PROMPTS = {
|
||||||
|
ReportType.CONTENT: _CONTENT,
|
||||||
|
ReportType.CONTENT_STATS: _CONTENT_STATS,
|
||||||
|
ReportType.FULL_AUDIT: _FULL_AUDIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_chunk_prompt(
|
||||||
|
report_type: ReportType,
|
||||||
|
title: str,
|
||||||
|
chunk_text: str,
|
||||||
|
chunk_idx: int,
|
||||||
|
total_chunks: int,
|
||||||
|
) -> str:
|
||||||
|
base = _BASE.format(
|
||||||
|
title=title,
|
||||||
|
chunk_idx=chunk_idx,
|
||||||
|
total_chunks=total_chunks,
|
||||||
|
chunk_text=chunk_text,
|
||||||
|
)
|
||||||
|
return base + "\n" + CHUNK_PROMPTS[report_type]
|
||||||
130
bot/prompts/synthesis.py
Normal file
130
bot/prompts/synthesis.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from bot.models import ReportType
|
||||||
|
|
||||||
|
_BASE = """\
|
||||||
|
You are producing a final report for a Telegram channel analysis.
|
||||||
|
|
||||||
|
Channel: {title} (@{username})
|
||||||
|
Subscribers: {subscribers}
|
||||||
|
Total messages analyzed: {msg_count}
|
||||||
|
Analysis chunks: {chunk_count}
|
||||||
|
|
||||||
|
Below are the summaries from each chunk of the channel's history:
|
||||||
|
|
||||||
|
{chunk_summaries}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONTENT = """\
|
||||||
|
Synthesize the chunk summaries into a comprehensive **Content Analysis Report** with these sections:
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Brief channel description and positioning.
|
||||||
|
|
||||||
|
## Key Topics & Themes
|
||||||
|
Main subject areas with examples.
|
||||||
|
|
||||||
|
## Tone & Communication Style
|
||||||
|
How the channel communicates with its audience.
|
||||||
|
|
||||||
|
## Content Strategy
|
||||||
|
Formats used, posting patterns, narrative arcs.
|
||||||
|
|
||||||
|
## Notable Content
|
||||||
|
Standout posts or recurring motifs.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Key takeaways in 3-5 bullet points.
|
||||||
|
|
||||||
|
Use Telegram-friendly formatting (bold, bullet points). Be specific — reference actual content patterns you observed."""
|
||||||
|
|
||||||
|
_CONTENT_STATS = """\
|
||||||
|
Synthesize the chunk summaries into a comprehensive **Content & Stats Report** with these sections:
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Brief channel description, subscriber count, and overall activity level.
|
||||||
|
|
||||||
|
## Key Topics & Themes
|
||||||
|
Main subject areas ranked by frequency and engagement.
|
||||||
|
|
||||||
|
## Tone & Communication Style
|
||||||
|
How the channel communicates with its audience.
|
||||||
|
|
||||||
|
## Content Strategy
|
||||||
|
Formats used, posting patterns, narrative arcs.
|
||||||
|
|
||||||
|
## Engagement Analysis
|
||||||
|
- Average engagement patterns
|
||||||
|
- Top-performing content types
|
||||||
|
- Engagement trends over time
|
||||||
|
|
||||||
|
## Posting Patterns
|
||||||
|
Frequency, schedule consistency, any notable gaps or bursts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Key takeaways in 5-7 bullet points mixing qualitative and quantitative insights.
|
||||||
|
|
||||||
|
Use Telegram-friendly formatting. Include specific numbers where available."""
|
||||||
|
|
||||||
|
_FULL_AUDIT = """\
|
||||||
|
Synthesize the chunk summaries into a comprehensive **Full Channel Audit** with these sections:
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
Channel positioning, key metrics, and overall assessment.
|
||||||
|
|
||||||
|
## Key Topics & Themes
|
||||||
|
Main subject areas ranked by frequency and engagement, with trend analysis.
|
||||||
|
|
||||||
|
## Tone & Communication Style
|
||||||
|
Detailed analysis of voice, register, and audience relationship.
|
||||||
|
|
||||||
|
## Content Strategy Assessment
|
||||||
|
Formats, patterns, narrative arcs — what works and what doesn't.
|
||||||
|
|
||||||
|
## Engagement Deep Dive
|
||||||
|
- Engagement metrics and benchmarks
|
||||||
|
- Top-performing vs underperforming content
|
||||||
|
- Engagement drivers and detractors
|
||||||
|
|
||||||
|
## Sentiment Analysis
|
||||||
|
Overall sentiment distribution, sentiment by topic, shifts over time.
|
||||||
|
|
||||||
|
## Audience Insights
|
||||||
|
Inferred audience profile, interaction patterns, community dynamics.
|
||||||
|
|
||||||
|
## Strengths
|
||||||
|
What the channel does well (3-5 points with evidence).
|
||||||
|
|
||||||
|
## Areas for Improvement
|
||||||
|
Actionable recommendations (3-5 points with specific suggestions).
|
||||||
|
|
||||||
|
## Strategic Recommendations
|
||||||
|
Forward-looking advice for channel growth and content optimization.
|
||||||
|
|
||||||
|
Use Telegram-friendly formatting. Be specific — back every claim with observed patterns or data."""
|
||||||
|
|
||||||
|
SYNTHESIS_PROMPTS = {
|
||||||
|
ReportType.CONTENT: _CONTENT,
|
||||||
|
ReportType.CONTENT_STATS: _CONTENT_STATS,
|
||||||
|
ReportType.FULL_AUDIT: _FULL_AUDIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_synthesis_prompt(
|
||||||
|
report_type: ReportType,
|
||||||
|
title: str,
|
||||||
|
username: str | None,
|
||||||
|
subscribers: int | None,
|
||||||
|
msg_count: int,
|
||||||
|
chunk_summaries: list[str],
|
||||||
|
) -> str:
|
||||||
|
numbered = "\n\n".join(
|
||||||
|
f"### Chunk {i+1}\n{s}" for i, s in enumerate(chunk_summaries)
|
||||||
|
)
|
||||||
|
base = _BASE.format(
|
||||||
|
title=title,
|
||||||
|
username=username or "N/A",
|
||||||
|
subscribers=subscribers or "N/A",
|
||||||
|
msg_count=msg_count,
|
||||||
|
chunk_count=len(chunk_summaries),
|
||||||
|
chunk_summaries=numbered,
|
||||||
|
)
|
||||||
|
return base + "\n" + SYNTHESIS_PROMPTS[report_type]
|
||||||
0
bot/services/__init__.py
Normal file
0
bot/services/__init__.py
Normal file
124
bot/services/analyzer.py
Normal file
124
bot/services/analyzer.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
from bot.config import settings
|
||||||
|
from bot.models import ReportType
|
||||||
|
from bot.prompts.chunk_summary import build_chunk_prompt
|
||||||
|
from bot.prompts.synthesis import build_synthesis_prompt
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Only 1 concurrent request to stay within rate limits
|
||||||
|
_semaphore = asyncio.Semaphore(1)
|
||||||
|
|
||||||
|
MAX_RETRIES = 5
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(
|
||||||
|
client: anthropic.AsyncAnthropic,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
max_tokens: int,
|
||||||
|
on_progress: "ProgressCallback | None" = None,
|
||||||
|
) -> str:
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
async with _semaphore:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": settings.claude_model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{"role": "user", "content": user}],
|
||||||
|
}
|
||||||
|
# Adaptive thinking only works on Opus 4.6
|
||||||
|
if "opus-4-6" in settings.claude_model:
|
||||||
|
kwargs["thinking"] = {"type": "adaptive"}
|
||||||
|
|
||||||
|
async with client.messages.stream(**kwargs) as stream:
|
||||||
|
response = await stream.get_final_message()
|
||||||
|
|
||||||
|
return "".join(
|
||||||
|
block.text for block in response.content if block.type == "text"
|
||||||
|
)
|
||||||
|
except anthropic.RateLimitError as e:
|
||||||
|
wait = getattr(e, "retry_after", None) or 60
|
||||||
|
log.warning("Rate limited, waiting %ds (attempt %d/%d)", wait, attempt + 1, MAX_RETRIES)
|
||||||
|
if on_progress:
|
||||||
|
await on_progress(f"Rate limited, waiting {wait}s...")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
except anthropic.APIStatusError as e:
|
||||||
|
if e.status_code >= 500:
|
||||||
|
wait = 10 * (attempt + 1)
|
||||||
|
log.warning("Server error %d, retrying in %ds", e.status_code, wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise RuntimeError("Max retries exceeded due to rate limiting")
|
||||||
|
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[str], Coroutine[Any, Any, None]]
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_channel(
|
||||||
|
report_type: ReportType,
|
||||||
|
chunks: list[str],
|
||||||
|
channel_title: str,
|
||||||
|
channel_username: str | None,
|
||||||
|
subscribers: int | None,
|
||||||
|
msg_count: int,
|
||||||
|
on_progress: ProgressCallback | None = None,
|
||||||
|
) -> str:
|
||||||
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||||
|
|
||||||
|
total = len(chunks)
|
||||||
|
summaries: list[str] = []
|
||||||
|
|
||||||
|
for i, chunk_text in enumerate(chunks, 1):
|
||||||
|
if on_progress:
|
||||||
|
await on_progress(f"Analyzing chunk {i}/{total}...")
|
||||||
|
|
||||||
|
prompt = build_chunk_prompt(
|
||||||
|
report_type, channel_title, chunk_text, i, total
|
||||||
|
)
|
||||||
|
summary = await _call_claude(
|
||||||
|
client,
|
||||||
|
system="You are an expert Telegram channel analyst.",
|
||||||
|
user=prompt,
|
||||||
|
max_tokens=4096,
|
||||||
|
on_progress=on_progress,
|
||||||
|
)
|
||||||
|
summaries.append(summary)
|
||||||
|
log.info("Chunk %d/%d summarized (%d chars)", i, total, len(summary))
|
||||||
|
|
||||||
|
# Wait 60s between chunks — rate limit is 30K input tokens/min
|
||||||
|
if i < total:
|
||||||
|
if on_progress:
|
||||||
|
await on_progress(f"Chunk {i}/{total} done. Cooling down 60s for rate limit...")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
if on_progress:
|
||||||
|
await on_progress("Generating final report...")
|
||||||
|
|
||||||
|
synthesis_prompt = build_synthesis_prompt(
|
||||||
|
report_type,
|
||||||
|
channel_title,
|
||||||
|
channel_username,
|
||||||
|
subscribers,
|
||||||
|
msg_count,
|
||||||
|
summaries,
|
||||||
|
)
|
||||||
|
report = await _call_claude(
|
||||||
|
client,
|
||||||
|
system="You are an expert Telegram channel analyst producing a final report.",
|
||||||
|
user=synthesis_prompt,
|
||||||
|
max_tokens=16000,
|
||||||
|
on_progress=on_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("Final report generated (%d chars)", len(report))
|
||||||
|
return report
|
||||||
45
bot/services/chunker.py
Normal file
45
bot/services/chunker.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
from bot.models import ChannelMessage
|
||||||
|
|
||||||
|
MAX_TOKENS_PER_CHUNK = 25_000
|
||||||
|
CHARS_PER_TOKEN = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _format_message(msg: ChannelMessage) -> str:
|
||||||
|
parts = [f"[{msg.date:%Y-%m-%d %H:%M}]"]
|
||||||
|
stats = []
|
||||||
|
if msg.views is not None:
|
||||||
|
stats.append(f"{msg.views} views")
|
||||||
|
if msg.forwards is not None:
|
||||||
|
stats.append(f"{msg.forwards} fwd")
|
||||||
|
if msg.replies is not None:
|
||||||
|
stats.append(f"{msg.replies} replies")
|
||||||
|
if stats:
|
||||||
|
parts.append(f"({', '.join(stats)})")
|
||||||
|
parts.append(f"\n{msg.text}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_messages(messages: list[ChannelMessage]) -> list[str]:
|
||||||
|
"""Split messages into token-bounded chunks."""
|
||||||
|
max_chars = MAX_TOKENS_PER_CHUNK * CHARS_PER_TOKEN
|
||||||
|
chunks: list[str] = []
|
||||||
|
current_lines: list[str] = []
|
||||||
|
current_len = 0
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
formatted = _format_message(msg)
|
||||||
|
entry = formatted + "\n---\n"
|
||||||
|
entry_len = len(entry)
|
||||||
|
|
||||||
|
if current_len + entry_len > max_chars and current_lines:
|
||||||
|
chunks.append("".join(current_lines))
|
||||||
|
current_lines = []
|
||||||
|
current_len = 0
|
||||||
|
|
||||||
|
current_lines.append(entry)
|
||||||
|
current_len += entry_len
|
||||||
|
|
||||||
|
if current_lines:
|
||||||
|
chunks.append("".join(current_lines))
|
||||||
|
|
||||||
|
return chunks
|
||||||
43
bot/services/fetcher.py
Normal file
43
bot/services/fetcher.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.tl.functions.channels import GetFullChannelRequest
|
||||||
|
|
||||||
|
from bot.models import ChannelMessage
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_channel_messages(
|
||||||
|
client: TelegramClient,
|
||||||
|
channel: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> tuple[list[ChannelMessage], dict]:
|
||||||
|
"""Fetch messages and basic stats from a public channel."""
|
||||||
|
entity = await client.get_entity(channel)
|
||||||
|
full = await client(GetFullChannelRequest(entity))
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"title": entity.title,
|
||||||
|
"username": getattr(entity, "username", None),
|
||||||
|
"subscribers": full.full_chat.participants_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
messages: list[ChannelMessage] = []
|
||||||
|
async for msg in client.iter_messages(entity, limit=limit):
|
||||||
|
if not msg.text:
|
||||||
|
continue
|
||||||
|
messages.append(
|
||||||
|
ChannelMessage(
|
||||||
|
id=msg.id,
|
||||||
|
date=msg.date,
|
||||||
|
text=msg.text,
|
||||||
|
views=msg.views,
|
||||||
|
forwards=msg.forwards,
|
||||||
|
replies=msg.replies.replies if msg.replies else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.reverse()
|
||||||
|
log.info("Fetched %d text messages from %s", len(messages), channel)
|
||||||
|
return messages, stats
|
||||||
66
bot/services/formatter.py
Normal file
66
bot/services/formatter.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
MAX_MSG_LEN = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_md2(text: str) -> str:
|
||||||
|
"""Escape special chars for MarkdownV2, preserving bold/headers/bullets."""
|
||||||
|
# We use HTML instead — MarkdownV2 escaping is too fragile
|
||||||
|
raise NotImplementedError("Use HTML mode")
|
||||||
|
|
||||||
|
|
||||||
|
def _md_to_html(text: str) -> str:
|
||||||
|
"""Minimal Markdown-to-HTML for Telegram's supported subset."""
|
||||||
|
# Bold: **text** or __text__
|
||||||
|
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
|
||||||
|
text = re.sub(r"__(.+?)__", r"<b>\1</b>", text)
|
||||||
|
# Italic: *text* (but not inside bold tags)
|
||||||
|
text = re.sub(r"(?<!</b>)\*(.+?)\*(?!<)", r"<i>\1</i>", text)
|
||||||
|
# Inline code
|
||||||
|
text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
|
||||||
|
# Headers: ## Title -> bold
|
||||||
|
text = re.sub(r"^#{1,3}\s+(.+)$", r"<b>\1</b>", text, flags=re.MULTILINE)
|
||||||
|
# Escape HTML entities in remaining text (but preserve our tags)
|
||||||
|
# Already handled — Telegram is lenient with unescaped text in HTML mode
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def split_report(report: str) -> list[str]:
|
||||||
|
"""Split a report into Telegram-safe HTML messages."""
|
||||||
|
html = _md_to_html(report)
|
||||||
|
|
||||||
|
if len(html) <= MAX_MSG_LEN:
|
||||||
|
return [html]
|
||||||
|
|
||||||
|
# Split on section boundaries (bold headers on their own line)
|
||||||
|
sections = re.split(r"(?=\n<b>[^<]+</b>\n)", html)
|
||||||
|
|
||||||
|
messages: list[str] = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
for section in sections:
|
||||||
|
if not section.strip():
|
||||||
|
continue
|
||||||
|
if len(current) + len(section) > MAX_MSG_LEN:
|
||||||
|
if current.strip():
|
||||||
|
messages.append(current.strip())
|
||||||
|
# If a single section is too long, split on paragraphs
|
||||||
|
if len(section) > MAX_MSG_LEN:
|
||||||
|
paragraphs = section.split("\n\n")
|
||||||
|
current = ""
|
||||||
|
for para in paragraphs:
|
||||||
|
if len(current) + len(para) + 2 > MAX_MSG_LEN:
|
||||||
|
if current.strip():
|
||||||
|
messages.append(current.strip())
|
||||||
|
current = para + "\n\n"
|
||||||
|
else:
|
||||||
|
current += para + "\n\n"
|
||||||
|
else:
|
||||||
|
current = section
|
||||||
|
else:
|
||||||
|
current += section
|
||||||
|
|
||||||
|
if current.strip():
|
||||||
|
messages.append(current.strip())
|
||||||
|
|
||||||
|
return messages if messages else [html[:MAX_MSG_LEN]]
|
||||||
7
compose.yml
Normal file
7
compose.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
10
pyproject.toml
Normal file
10
pyproject.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[project]
|
||||||
|
name = "tg-channel-analyzer"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"aiogram>=3.24,<4",
|
||||||
|
"telethon>=1.42,<2",
|
||||||
|
"anthropic>=0.80,<1",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue