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