feat(matrix): land QA follow-ups and refresh docs
- 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
This commit is contained in:
parent
7fce4c9b3e
commit
6ced154124
35 changed files with 8380 additions and 67 deletions
511
bot-examples/telegram_bot_topics.py
Normal file
511
bot-examples/telegram_bot_topics.py
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
"""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]"
|
||||
Loading…
Add table
Add a link
Reference in a new issue