- harden Matrix onboarding/chat lifecycle after manual QA - refresh README and Matrix docs to match current behavior - add local ignores for runtime artifacts and include current planning/report docs Closes #7 Closes #9 Closes #14
511 lines
18 KiB
Python
511 lines
18 KiB
Python
"""Telegram bot engine.
|
|
|
|
Handles messages (text, photo, voice), topic management, and Claude CLI integration.
|
|
Uses RetryHTTPXRequest for proxy resilience, progressive message editing for streaming.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from telegram import BotCommand, Update
|
|
from telegram.constants import ChatAction, ParseMode
|
|
from telegram.error import BadRequest, NetworkError
|
|
from telegram.ext import (
|
|
Application,
|
|
CommandHandler,
|
|
ContextTypes,
|
|
MessageHandler,
|
|
filters,
|
|
)
|
|
from telegram.request import HTTPXRequest
|
|
|
|
from core.asr import transcribe
|
|
from core.claude_session import send_message as claude_send
|
|
from core.config import Config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Streaming edit parameters
|
|
EDIT_INTERVAL = 1.5 # seconds between message edits
|
|
EDIT_MIN_DELTA = 150 # minimum new chars before editing
|
|
|
|
|
|
class RetryHTTPXRequest(HTTPXRequest):
|
|
"""HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""
|
|
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY = 2
|
|
|
|
async def do_request(self, *args, **kwargs):
|
|
last_exc = None
|
|
for attempt in range(self.MAX_RETRIES):
|
|
try:
|
|
return await super().do_request(*args, **kwargs)
|
|
except NetworkError as e:
|
|
if "ConnectError" in str(e):
|
|
last_exc = e
|
|
if attempt < self.MAX_RETRIES - 1:
|
|
logger.warning(
|
|
"Telegram ConnectError (attempt %d/%d), retrying in %ds...",
|
|
attempt + 1, self.MAX_RETRIES, self.RETRY_DELAY,
|
|
)
|
|
await asyncio.sleep(self.RETRY_DELAY)
|
|
else:
|
|
raise
|
|
raise last_exc
|
|
|
|
|
|
def build_app(config: Config) -> Application:
|
|
"""Build and configure the Telegram Application."""
|
|
builder = Application.builder().token(config.bot_token)
|
|
|
|
# Configure HTTP client with proxy and timeouts
|
|
request_kwargs = {
|
|
"connect_timeout": 30.0,
|
|
"read_timeout": 60.0,
|
|
"write_timeout": 60.0,
|
|
"pool_timeout": 10.0,
|
|
}
|
|
if config.proxy:
|
|
request_kwargs["proxy"] = config.proxy
|
|
|
|
request = RetryHTTPXRequest(**request_kwargs)
|
|
builder = builder.request(request)
|
|
builder = builder.concurrent_updates(True)
|
|
|
|
app = builder.build()
|
|
|
|
# Store config in bot_data for handler access
|
|
app.bot_data["config"] = config
|
|
|
|
# Register handlers (order matters — more specific first)
|
|
app.add_handler(CommandHandler("start", handle_start))
|
|
app.add_handler(CommandHandler("newtopic", handle_new_topic))
|
|
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
|
|
app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
|
|
app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
|
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
|
|
|
# Post-init: set bot commands
|
|
app.post_init = _post_init
|
|
|
|
return app
|
|
|
|
|
|
async def _post_init(application: Application) -> None:
|
|
"""Set bot commands menu after initialization."""
|
|
commands = [
|
|
BotCommand("newtopic", "Create a new topic"),
|
|
BotCommand("start", "Start / help"),
|
|
]
|
|
await application.bot.set_my_commands(commands)
|
|
logger.info("Bot initialized: @%s", application.bot.username)
|
|
|
|
|
|
def _get_config(context: ContextTypes.DEFAULT_TYPE) -> Config:
|
|
return context.bot_data["config"]
|
|
|
|
|
|
def _is_owner(update: Update, config: Config) -> bool:
|
|
return update.effective_user and update.effective_user.id == config.owner_id
|
|
|
|
|
|
def _topic_id(update: Update) -> str:
|
|
"""Get topic ID from message, or 'general' for the default topic."""
|
|
thread_id = update.effective_message.message_thread_id
|
|
return str(thread_id) if thread_id else "general"
|
|
|
|
|
|
def _topic_dir(config: Config, topic_id: str) -> Path:
|
|
"""Get data directory for a topic."""
|
|
d = config.data_dir / "topics" / topic_id
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _log_interaction(config: Config, topic_id: str, user_msg: str, bot_msg: str) -> None:
|
|
"""Append interaction to topic log."""
|
|
log_file = _topic_dir(config, topic_id) / "log.jsonl"
|
|
entry = {
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"user": user_msg[:1000],
|
|
"bot": bot_msg[:2000],
|
|
}
|
|
with open(log_file, "a") as f:
|
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _md_to_html(text: str) -> str:
|
|
"""Convert common Markdown to Telegram HTML."""
|
|
import re
|
|
# Escape HTML entities first (but preserve our conversions)
|
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
# Code blocks: ```lang\n...\n```
|
|
text = re.sub(
|
|
r"```\w*\n(.*?)```",
|
|
lambda m: f"<pre>{m.group(1)}</pre>",
|
|
text, flags=re.DOTALL,
|
|
)
|
|
# Inline code: `...`
|
|
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
# Bold: **...**
|
|
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
|
|
# Italic: *...*
|
|
text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
|
|
# Headers: ## ... → bold line
|
|
text = re.sub(r"^#{1,6}\s+(.+)$", r"<b>\1</b>", text, flags=re.MULTILINE)
|
|
# Bullet lists: - item → bullet
|
|
text = re.sub(r"^- ", "• ", text, flags=re.MULTILINE)
|
|
|
|
return text
|
|
|
|
|
|
async def _edit_text_md(message, text: str) -> None:
|
|
"""Edit message with HTML formatting, falling back to plain text."""
|
|
try:
|
|
html = _md_to_html(text)
|
|
await message.edit_text(html, parse_mode=ParseMode.HTML)
|
|
except BadRequest:
|
|
try:
|
|
await message.edit_text(text)
|
|
except BadRequest:
|
|
pass
|
|
|
|
|
|
# Cache of topic labels we've already applied: {topic_id: label}
|
|
_applied_labels: dict[str, str] = {}
|
|
|
|
# Pending questions from Claude: {topic_id: asyncio.Future}
|
|
_pending_questions: dict[str, asyncio.Future] = {}
|
|
|
|
|
|
async def _sync_topic_name(update: Update, config: Config, topic_id: str) -> None:
|
|
"""Rename Telegram topic if topic-map.yml has a new/changed label."""
|
|
if topic_id == "general":
|
|
return
|
|
topic_map_path = config.data_dir / "topic-map.yml"
|
|
if not topic_map_path.exists():
|
|
return
|
|
try:
|
|
with open(topic_map_path) as f:
|
|
topic_map = yaml.safe_load(f) or {}
|
|
entry = topic_map.get(topic_id) or topic_map.get(int(topic_id))
|
|
if not entry or not isinstance(entry, dict):
|
|
return
|
|
label = entry.get("label")
|
|
if not label or _applied_labels.get(topic_id) == label:
|
|
return
|
|
await update.get_bot().edit_forum_topic(
|
|
chat_id=update.effective_chat.id,
|
|
message_thread_id=int(topic_id),
|
|
name=label[:128],
|
|
)
|
|
_applied_labels[topic_id] = label
|
|
logger.info("Renamed topic %s to: %s", topic_id, label)
|
|
except BadRequest as e:
|
|
if "not modified" not in str(e).lower():
|
|
logger.warning("Failed to rename topic %s: %s", topic_id, e)
|
|
_applied_labels[topic_id] = label # don't retry
|
|
except Exception as e:
|
|
logger.warning("Error reading topic-map.yml: %s", e)
|
|
|
|
|
|
async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /start command."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
await update.effective_message.reply_text(
|
|
"Ready. Send me a message or use /newtopic to create a topic."
|
|
)
|
|
|
|
|
|
async def handle_new_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /newtopic <name> — create a forum topic."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
|
|
name = " ".join(context.args) if context.args else None
|
|
if not name:
|
|
await update.effective_message.reply_text("Usage: /newtopic Topic Name")
|
|
return
|
|
|
|
try:
|
|
topic = await context.bot.create_forum_topic(
|
|
chat_id=update.effective_chat.id,
|
|
name=name,
|
|
)
|
|
tid = str(topic.message_thread_id)
|
|
_topic_dir(config, tid)
|
|
await context.bot.send_message(
|
|
chat_id=update.effective_chat.id,
|
|
message_thread_id=topic.message_thread_id,
|
|
text=f"Topic created. Send me anything here.",
|
|
)
|
|
logger.info("Created topic: %s (id=%s)", name, tid)
|
|
except BadRequest as e:
|
|
logger.error("Failed to create topic: %s", e)
|
|
await update.effective_message.reply_text(f"Failed to create topic: {e}")
|
|
|
|
|
|
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle text messages — send to Claude CLI."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
|
|
tid = _topic_id(update)
|
|
user_text = update.effective_message.text
|
|
|
|
# If Claude is waiting for an answer in this topic, deliver it
|
|
if tid in _pending_questions:
|
|
future = _pending_questions.pop(tid)
|
|
if not future.done():
|
|
future.set_result(user_text)
|
|
return
|
|
|
|
# Send typing indicator and placeholder
|
|
await context.bot.send_chat_action(
|
|
chat_id=update.effective_chat.id,
|
|
action=ChatAction.TYPING,
|
|
message_thread_id=update.effective_message.message_thread_id,
|
|
)
|
|
placeholder = await update.effective_message.reply_text("thinking...")
|
|
|
|
# Streaming state
|
|
last_edit_time = 0.0
|
|
last_edit_len = 0
|
|
|
|
async def on_chunk(text_so_far: str):
|
|
nonlocal last_edit_time, last_edit_len
|
|
now = time.monotonic()
|
|
delta = len(text_so_far) - last_edit_len
|
|
|
|
if delta >= EDIT_MIN_DELTA and (now - last_edit_time) >= EDIT_INTERVAL:
|
|
try:
|
|
display = _truncate_for_telegram(text_so_far)
|
|
await placeholder.edit_text(display)
|
|
last_edit_time = now
|
|
last_edit_len = len(text_so_far)
|
|
except BadRequest:
|
|
pass # message not modified or too long
|
|
|
|
async def on_question(question: str) -> str:
|
|
"""Claude asks user a question — send it and wait for reply."""
|
|
await update.effective_message.reply_text(f"❓ {question}")
|
|
loop = asyncio.get_event_loop()
|
|
future = loop.create_future()
|
|
_pending_questions[tid] = future
|
|
return await future
|
|
|
|
topic_dir = _topic_dir(config, tid)
|
|
|
|
try:
|
|
response = await claude_send(
|
|
config, tid, user_text, on_chunk=on_chunk, on_question=on_question,
|
|
)
|
|
display = _truncate_for_telegram(response)
|
|
await _edit_text_md(placeholder, display)
|
|
except RuntimeError as e:
|
|
logger.error("Claude error for topic %s: %s", tid, e)
|
|
await placeholder.edit_text(f"Error: {e}")
|
|
response = f"[error] {e}"
|
|
finally:
|
|
_pending_questions.pop(tid, None)
|
|
|
|
await _send_outbox(update, topic_dir)
|
|
_log_interaction(config, tid, user_text, response)
|
|
await _sync_topic_name(update, config, tid)
|
|
|
|
|
|
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle photo messages — save image, send path to Claude."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
|
|
tid = _topic_id(update)
|
|
images_dir = _topic_dir(config, tid) / "images"
|
|
images_dir.mkdir(exist_ok=True)
|
|
|
|
# Download the largest photo
|
|
photo = update.effective_message.photo[-1]
|
|
file = await context.bot.get_file(photo.file_id)
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{ts}_{photo.file_unique_id}.jpg"
|
|
filepath = images_dir / filename
|
|
await file.download_to_drive(str(filepath))
|
|
|
|
caption = update.effective_message.caption or ""
|
|
message = f"User sent an image: {filepath}"
|
|
if caption:
|
|
message += f"\nCaption: {caption}"
|
|
|
|
# Send typing and placeholder
|
|
placeholder = await update.effective_message.reply_text("looking at image...")
|
|
|
|
try:
|
|
response = await claude_send(config, tid, message)
|
|
display = _truncate_for_telegram(response)
|
|
await _edit_text_md(placeholder, display)
|
|
except RuntimeError as e:
|
|
logger.error("Claude error for photo in topic %s: %s", tid, e)
|
|
await placeholder.edit_text(f"Error: {e}")
|
|
response = f"[error] {e}"
|
|
|
|
_log_interaction(config, tid, f"[photo] {caption}", response)
|
|
await _sync_topic_name(update, config, tid)
|
|
|
|
|
|
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle document messages — save file, send path to Claude."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
|
|
tid = _topic_id(update)
|
|
docs_dir = _topic_dir(config, tid) / "documents"
|
|
docs_dir.mkdir(exist_ok=True)
|
|
|
|
doc = update.effective_message.document
|
|
file = await context.bot.get_file(doc.file_id)
|
|
# Use original filename if available, otherwise generate one
|
|
orig_name = doc.file_name or f"{doc.file_unique_id}"
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{ts}_{orig_name}"
|
|
filepath = docs_dir / filename
|
|
await file.download_to_drive(str(filepath))
|
|
|
|
caption = update.effective_message.caption or ""
|
|
message = f"User sent a document: {filepath} (name: {orig_name}, size: {doc.file_size} bytes)"
|
|
if caption:
|
|
message += f"\nCaption: {caption}"
|
|
|
|
topic_dir = _topic_dir(config, tid)
|
|
placeholder = await update.effective_message.reply_text("reading document...")
|
|
|
|
try:
|
|
response = await claude_send(config, tid, message)
|
|
display = _truncate_for_telegram(response)
|
|
await _edit_text_md(placeholder, display)
|
|
except RuntimeError as e:
|
|
logger.error("Claude error for document in topic %s: %s", tid, e)
|
|
await placeholder.edit_text(f"Error: {e}")
|
|
response = f"[error] {e}"
|
|
|
|
await _send_outbox(update, topic_dir)
|
|
_log_interaction(config, tid, f"[document: {orig_name}] {caption}", response)
|
|
await _sync_topic_name(update, config, tid)
|
|
|
|
|
|
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle voice/audio messages — save file, send path to Claude."""
|
|
config = _get_config(context)
|
|
if not _is_owner(update, config):
|
|
return
|
|
|
|
tid = _topic_id(update)
|
|
voice_dir = _topic_dir(config, tid) / "voice"
|
|
voice_dir.mkdir(exist_ok=True)
|
|
|
|
# Download voice file
|
|
voice = update.effective_message.voice or update.effective_message.audio
|
|
file = await context.bot.get_file(voice.file_id)
|
|
ext = "ogg" if update.effective_message.voice else "mp3"
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{ts}_{voice.file_unique_id}.{ext}"
|
|
filepath = voice_dir / filename
|
|
await file.download_to_drive(str(filepath))
|
|
|
|
topic_dir = _topic_dir(config, tid)
|
|
|
|
# Transcribe via Whisper if available, otherwise send file path
|
|
if config.whisper_url:
|
|
placeholder = await update.effective_message.reply_text("transcribing voice...")
|
|
try:
|
|
text = await transcribe(str(filepath), config.whisper_url)
|
|
message = f"[voice message transcription]: {text}"
|
|
logger.info("Transcribed voice in topic %s: %d chars", tid, len(text))
|
|
# Show transcription to user, then send to Claude
|
|
try:
|
|
await placeholder.edit_text(f"🎤 {text}")
|
|
except BadRequest:
|
|
pass
|
|
placeholder = await update.effective_message.reply_text("thinking...")
|
|
except RuntimeError as e:
|
|
logger.error("ASR failed for topic %s: %s", tid, e)
|
|
message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)\n(transcription failed: {e})"
|
|
else:
|
|
message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)"
|
|
placeholder = await update.effective_message.reply_text("processing voice...")
|
|
|
|
try:
|
|
response = await claude_send(config, tid, message)
|
|
display = _truncate_for_telegram(response)
|
|
await _edit_text_md(placeholder, display)
|
|
except RuntimeError as e:
|
|
logger.error("Claude error for voice in topic %s: %s", tid, e)
|
|
await placeholder.edit_text(f"Error: {e}")
|
|
response = f"[error] {e}"
|
|
|
|
await _send_outbox(update, topic_dir)
|
|
_log_interaction(config, tid, message, response)
|
|
await _sync_topic_name(update, config, tid)
|
|
|
|
|
|
async def _send_outbox(update: Update, topic_dir: Path) -> None:
|
|
"""Send files queued in outbox.jsonl by Claude via send-to-user tool."""
|
|
outbox = topic_dir / "outbox.jsonl"
|
|
if not outbox.exists():
|
|
return
|
|
|
|
entries = []
|
|
try:
|
|
with open(outbox) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
entries.append(json.loads(line))
|
|
# Clear outbox
|
|
outbox.unlink()
|
|
except Exception as e:
|
|
logger.error("Failed to read outbox: %s", e)
|
|
return
|
|
|
|
for entry in entries:
|
|
fpath = Path(entry.get("path", ""))
|
|
ftype = entry.get("type", "document")
|
|
caption = entry.get("caption", "") or fpath.name
|
|
|
|
if not fpath.is_file():
|
|
logger.warning("Outbox file not found: %s", fpath)
|
|
continue
|
|
|
|
try:
|
|
with open(fpath, "rb") as f:
|
|
if ftype == "image":
|
|
await update.effective_message.reply_photo(photo=f, caption=caption)
|
|
elif ftype == "video":
|
|
await update.effective_message.reply_video(video=f, caption=caption)
|
|
elif ftype == "audio":
|
|
await update.effective_message.reply_voice(voice=f, caption=caption)
|
|
else:
|
|
await update.effective_message.reply_document(document=f, caption=caption)
|
|
logger.info("Sent %s: %s", ftype, fpath.name)
|
|
except Exception as e:
|
|
logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
|
|
|
|
|
|
def _truncate_for_telegram(text: str, max_len: int = 4096) -> str:
|
|
"""Truncate text to Telegram message limit."""
|
|
if len(text) <= max_len:
|
|
return text
|
|
return text[: max_len - 20] + "\n\n[truncated]"
|