This commit is contained in:
Sergei Poljanski 2026-02-22 21:33:23 +02:00
commit ec1112e162
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
22 changed files with 837 additions and 0 deletions

5
.containerignore Normal file
View file

@ -0,0 +1,5 @@
.env
data/
*.session
__pycache__
.git

6
.env.example Normal file
View 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
View file

@ -0,0 +1,6 @@
.env
data/
*.session
__pycache__/
*.pyc
.mypy_cache/

7
CLAUDE.md Normal file
View 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
View 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
View file

67
bot/__main__.py Normal file
View 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
View 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
View file

167
bot/handlers/analyze.py Normal file
View 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
View 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
View 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
View file

View 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
View 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
View file

124
bot/services/analyzer.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
services:
bot:
build: .
env_file: .env
volumes:
- ./data:/app/data
restart: unless-stopped

10
pyproject.toml Normal file
View 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",
]