From ec1112e162938a3ea774384cc4cf8891ad95d639 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sun, 22 Feb 2026 21:33:23 +0200 Subject: [PATCH] init --- .containerignore | 5 ++ .env.example | 6 ++ .gitignore | 6 ++ CLAUDE.md | 7 ++ Containerfile | 12 +++ bot/__init__.py | 0 bot/__main__.py | 67 ++++++++++++++ bot/config.py | 15 ++++ bot/handlers/__init__.py | 0 bot/handlers/analyze.py | 167 +++++++++++++++++++++++++++++++++++ bot/handlers/start.py | 22 +++++ bot/models.py | 35 ++++++++ bot/prompts/__init__.py | 0 bot/prompts/chunk_summary.py | 70 +++++++++++++++ bot/prompts/synthesis.py | 130 +++++++++++++++++++++++++++ bot/services/__init__.py | 0 bot/services/analyzer.py | 124 ++++++++++++++++++++++++++ bot/services/chunker.py | 45 ++++++++++ bot/services/fetcher.py | 43 +++++++++ bot/services/formatter.py | 66 ++++++++++++++ compose.yml | 7 ++ pyproject.toml | 10 +++ 22 files changed, 837 insertions(+) create mode 100644 .containerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Containerfile create mode 100644 bot/__init__.py create mode 100644 bot/__main__.py create mode 100644 bot/config.py create mode 100644 bot/handlers/__init__.py create mode 100644 bot/handlers/analyze.py create mode 100644 bot/handlers/start.py create mode 100644 bot/models.py create mode 100644 bot/prompts/__init__.py create mode 100644 bot/prompts/chunk_summary.py create mode 100644 bot/prompts/synthesis.py create mode 100644 bot/services/__init__.py create mode 100644 bot/services/analyzer.py create mode 100644 bot/services/chunker.py create mode 100644 bot/services/fetcher.py create mode 100644 bot/services/formatter.py create mode 100644 compose.yml create mode 100644 pyproject.toml diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..a572c24 --- /dev/null +++ b/.containerignore @@ -0,0 +1,5 @@ +.env +data/ +*.session +__pycache__ +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8750ca --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +BOT_TOKEN= +TELEGRAM_API_ID= +TELEGRAM_API_HASH= +TELEGRAM_PHONE= +ANTHROPIC_API_KEY= +CLAUDE_MODEL=claude-opus-4-6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c14db91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +data/ +*.session +__pycache__/ +*.pyc +.mypy_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..99c2b7f --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..d59bd32 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/__main__.py b/bot/__main__.py new file mode 100644 index 0000000..e4345cb --- /dev/null +++ b/bot/__main__.py @@ -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()) diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..5e0d528 --- /dev/null +++ b/bot/config.py @@ -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() diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/analyze.py b/bot/handlers/analyze.py new file mode 100644 index 0000000..c51985c --- /dev/null +++ b/bot/handlers/analyze.py @@ -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: /analyze @channel", + 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: @{channel}\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 {report_type.label} for @{channel}...\n\n" + "Fetching messages...", + parse_mode="HTML", + ) + + async def update_status(text: str) -> None: + try: + await status_msg.edit_text( + f"{report_type.label} 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 diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..1ab704b --- /dev/null +++ b/bot/handlers/start.py @@ -0,0 +1,22 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +router = Router() + +HELP_TEXT = ( + "Telegram Channel Analyzer\n\n" + "Analyze any public Telegram channel using AI.\n\n" + "Usage:\n" + "/analyze @channel — Start analysis\n" + "/analyze https://t.me/channel — Also works\n\n" + "You'll choose a report type:\n" + "• Content Analysis — topics, tone, themes\n" + "• Content + Stats — above + engagement data\n" + "• Full Audit — comprehensive review with recommendations" +) + + +@router.message(Command("start", "help")) +async def cmd_start(message: Message) -> None: + await message.answer(HELP_TEXT, parse_mode="HTML") diff --git a/bot/models.py b/bot/models.py new file mode 100644 index 0000000..85b503b --- /dev/null +++ b/bot/models.py @@ -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 diff --git a/bot/prompts/__init__.py b/bot/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/prompts/chunk_summary.py b/bot/prompts/chunk_summary.py new file mode 100644 index 0000000..efd3156 --- /dev/null +++ b/bot/prompts/chunk_summary.py @@ -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] diff --git a/bot/prompts/synthesis.py b/bot/prompts/synthesis.py new file mode 100644 index 0000000..46c95dd --- /dev/null +++ b/bot/prompts/synthesis.py @@ -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] diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/services/analyzer.py b/bot/services/analyzer.py new file mode 100644 index 0000000..4172dc7 --- /dev/null +++ b/bot/services/analyzer.py @@ -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 diff --git a/bot/services/chunker.py b/bot/services/chunker.py new file mode 100644 index 0000000..9eb60f6 --- /dev/null +++ b/bot/services/chunker.py @@ -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 diff --git a/bot/services/fetcher.py b/bot/services/fetcher.py new file mode 100644 index 0000000..66b6c61 --- /dev/null +++ b/bot/services/fetcher.py @@ -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 diff --git a/bot/services/formatter.py b/bot/services/formatter.py new file mode 100644 index 0000000..07d1ecb --- /dev/null +++ b/bot/services/formatter.py @@ -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"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + # Italic: *text* (but not inside bold tags) + text = re.sub(r"(?)\*(.+?)\*(?!<)", r"\1", text) + # Inline code + text = re.sub(r"`(.+?)`", r"\1", text) + # Headers: ## Title -> bold + text = re.sub(r"^#{1,3}\s+(.+)$", r"\1", 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[^<]+\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]] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..d2b90f0 --- /dev/null +++ b/compose.yml @@ -0,0 +1,7 @@ +services: + bot: + build: . + env_file: .env + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..218b36f --- /dev/null +++ b/pyproject.toml @@ -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", +]