stage-12: Telegram bot adapter

- New src/adapters/telegram_bot.py: full Telegram adapter with python-telegram-bot v22
  - Commands: /start, /help, /clear, /status, /model, /register
  - Inline keyboards for model selection
  - Message routing through existing router.py
  - Private chat: admin-only access
  - Group chat: responds to @mentions and replies to bot
  - Security logging for unauthorized access attempts
  - Message splitting for 4096 char limit
- Updated main.py: runs Discord + Telegram bots concurrently
  - Telegram is optional (gracefully skipped if no telegram_token)
- Updated requirements.txt: added python-telegram-bot>=21.0
- Updated config.json: added telegram_channels section
- Updated cli.py doctor: telegram token check (optional)
- 37 new tests (410 total, zero failures)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 20:55:04 +00:00
parent d1bb67abc1
commit 2d8e56d44c
6 changed files with 849 additions and 6 deletions

View File

@@ -0,0 +1,368 @@
"""Telegram bot adapter — commands and message handlers."""
import asyncio
import logging
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction, ChatType
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from src.config import Config
from src.claude_session import (
clear_session,
get_active_session,
set_session_model,
VALID_MODELS,
)
from src.router import route_message
logger = logging.getLogger("echo-core.telegram")
_security_log = logging.getLogger("echo-core.security")
# Module-level config reference, set by create_telegram_bot()
_config: Config | None = None
def _get_config() -> Config:
"""Return the module-level config, raising if not initialized."""
if _config is None:
raise RuntimeError("Bot not initialized — call create_telegram_bot() first")
return _config
# --- Authorization helpers ---
def is_owner(user_id: int) -> bool:
"""Check if user_id matches config bot.owner."""
owner = _get_config().get("bot.owner")
return str(user_id) == str(owner)
def is_admin(user_id: int) -> bool:
"""Check if user_id is owner or in admins list."""
if is_owner(user_id):
return True
admins = _get_config().get("bot.admins", [])
return str(user_id) in admins
def is_registered_chat(chat_id: int) -> bool:
"""Check if Telegram chat_id is in any registered channel entry."""
channels = _get_config().get("telegram_channels", {})
return any(ch.get("id") == str(chat_id) for ch in channels.values())
def _channel_alias_for_chat(chat_id: int) -> str | None:
"""Resolve a Telegram chat ID to its config alias."""
channels = _get_config().get("telegram_channels", {})
for alias, info in channels.items():
if info.get("id") == str(chat_id):
return alias
return None
# --- Message splitting helper ---
def split_message(text: str, limit: int = 4096) -> list[str]:
"""Split text into chunks that fit Telegram's message limit."""
if len(text) <= limit:
return [text]
chunks = []
while text:
if len(text) <= limit:
chunks.append(text)
break
split_at = text.rfind("\n", 0, limit)
if split_at == -1:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
# --- Command handlers ---
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start — welcome message."""
await update.message.reply_text(
"Echo Core — Telegram adapter.\n"
"Send a message to chat with Claude.\n"
"Use /help for available commands."
)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help — list commands."""
lines = [
"*Echo Commands*",
"/start — Welcome message",
"/help — Show this help",
"/clear — Clear the session for this chat",
"/status — Show session status",
"/model — View/change AI model",
"/register <alias> — Register this chat (owner only)",
]
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
async def cmd_clear(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /clear — clear session for this chat."""
chat_id = str(update.effective_chat.id)
default_model = _get_config().get("bot.default_model", "sonnet")
removed = clear_session(chat_id)
if removed:
await update.message.reply_text(
f"Session cleared. Model reset to {default_model}."
)
else:
await update.message.reply_text("No active session for this chat.")
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /status — show session status."""
chat_id = str(update.effective_chat.id)
session = get_active_session(chat_id)
if not session:
await update.message.reply_text("No active session.")
return
model = session.get("model", "?")
sid = session.get("session_id", "?")[:8]
count = session.get("message_count", 0)
in_tok = session.get("total_input_tokens", 0)
out_tok = session.get("total_output_tokens", 0)
await update.message.reply_text(
f"Model: {model}\n"
f"Session: {sid}\n"
f"Messages: {count}\n"
f"Tokens: {in_tok} in / {out_tok} out"
)
async def cmd_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /model — view or change model with inline keyboard."""
chat_id = str(update.effective_chat.id)
args = context.args
if args:
# /model opus — change model directly
choice = args[0].lower()
if choice not in VALID_MODELS:
await update.message.reply_text(
f"Invalid model '{choice}'. Choose from: {', '.join(sorted(VALID_MODELS))}"
)
return
session = get_active_session(chat_id)
if session:
set_session_model(chat_id, choice)
else:
from src.claude_session import _load_sessions, _save_sessions
from datetime import datetime, timezone
sessions = _load_sessions()
sessions[chat_id] = {
"session_id": "",
"model": choice,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_message_at": datetime.now(timezone.utc).isoformat(),
"message_count": 0,
}
_save_sessions(sessions)
await update.message.reply_text(f"Model changed to *{choice}*.", parse_mode="Markdown")
return
# No args — show current model + inline keyboard
session = get_active_session(chat_id)
if session:
current = session.get("model", "?")
else:
current = _get_config().get("bot.default_model", "sonnet")
keyboard = [
[
InlineKeyboardButton(
f"{'> ' if m == current else ''}{m}",
callback_data=f"model:{m}",
)
for m in sorted(VALID_MODELS)
]
]
await update.message.reply_text(
f"Current model: *{current}*\nSelect a model:",
reply_markup=InlineKeyboardMarkup(keyboard),
parse_mode="Markdown",
)
async def callback_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle inline keyboard callback for model selection."""
query = update.callback_query
await query.answer()
choice = query.data.replace("model:", "")
if choice not in VALID_MODELS:
return
chat_id = str(query.message.chat_id)
session = get_active_session(chat_id)
if session:
set_session_model(chat_id, choice)
else:
from src.claude_session import _load_sessions, _save_sessions
from datetime import datetime, timezone
sessions = _load_sessions()
sessions[chat_id] = {
"session_id": "",
"model": choice,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_message_at": datetime.now(timezone.utc).isoformat(),
"message_count": 0,
}
_save_sessions(sessions)
await query.edit_message_text(f"Model changed to *{choice}*.", parse_mode="Markdown")
async def cmd_register(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /register <alias> — register current chat (owner only)."""
user_id = update.effective_user.id
if not is_owner(user_id):
_security_log.warning(
"Unauthorized owner command /register by user=%s (%s)",
user_id, update.effective_user.username,
)
await update.message.reply_text("Owner only.")
return
if not context.args:
await update.message.reply_text("Usage: /register <alias>")
return
alias = context.args[0].lower()
chat_id = str(update.effective_chat.id)
config = _get_config()
channels = config.get("telegram_channels", {})
if alias in channels:
await update.message.reply_text(f"Alias '{alias}' already registered.")
return
channels[alias] = {"id": chat_id, "default_model": "sonnet"}
config.set("telegram_channels", channels)
config.save()
await update.message.reply_text(
f"Chat registered as '{alias}' (ID: {chat_id})."
)
# --- Message handler ---
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Process incoming text messages — route to Claude."""
message = update.message
if not message or not message.text:
return
user_id = update.effective_user.id
chat_id = update.effective_chat.id
chat_type = update.effective_chat.type
# Private chat: only admins
if chat_type == ChatType.PRIVATE:
if not is_admin(user_id):
_security_log.warning(
"Unauthorized Telegram DM from user=%s (%s): %s",
user_id, update.effective_user.username,
message.text[:100],
)
return
# Group chat: only registered chats, and bot must be mentioned or replied to
elif chat_type in (ChatType.GROUP, ChatType.SUPERGROUP):
if not is_registered_chat(chat_id):
return
# In groups, only respond when mentioned or replied to
bot_username = context.bot.username
is_reply_to_bot = (
message.reply_to_message
and message.reply_to_message.from_user
and message.reply_to_message.from_user.id == context.bot.id
)
is_mention = bot_username and f"@{bot_username}" in message.text
if not is_reply_to_bot and not is_mention:
return
else:
return
text = message.text
# Remove bot mention from text if present
bot_username = context.bot.username
if bot_username:
text = text.replace(f"@{bot_username}", "").strip()
if not text:
return
logger.info(
"Telegram message from %s (%s) in chat %s: %s",
user_id, update.effective_user.username,
chat_id, text[:100],
)
# Show typing indicator
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
try:
response, _is_cmd = await asyncio.to_thread(
route_message, str(chat_id), str(user_id), text
)
chunks = split_message(response)
for chunk in chunks:
await message.reply_text(chunk)
except Exception:
logger.exception("Error processing Telegram message from %s", user_id)
await message.reply_text("Sorry, something went wrong processing your message.")
# --- Factory ---
def create_telegram_bot(config: Config, token: str) -> Application:
"""Create and configure the Telegram bot with all handlers."""
global _config
_config = config
app = Application.builder().token(token).build()
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("clear", cmd_clear))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("model", cmd_model))
app.add_handler(CommandHandler("register", cmd_register))
app.add_handler(CallbackQueryHandler(callback_model, pattern="^model:"))
app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)
)
return app

View File

@@ -108,6 +108,16 @@ def main():
"Heartbeat registered (every %d min)", interval_min
)
# Telegram bot (optional — only if telegram_token exists)
telegram_token = get_secret("telegram_token")
telegram_app = None
if telegram_token:
from src.adapters.telegram_bot import create_telegram_bot
telegram_app = create_telegram_bot(config, telegram_token)
logger.info("Telegram bot configured")
else:
logger.info("No telegram_token — Telegram bot disabled")
# PID file
PID_FILE.write_text(str(os.getpid()))
@@ -122,8 +132,27 @@ def main():
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
async def _run_all():
"""Run Discord + Telegram bots concurrently."""
tasks = [asyncio.create_task(client.start(token))]
if telegram_app:
async def _run_telegram():
await telegram_app.initialize()
await telegram_app.start()
await telegram_app.updater.start_polling()
logger.info("Telegram bot started polling")
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
await telegram_app.updater.stop()
await telegram_app.stop()
await telegram_app.shutdown()
tasks.append(asyncio.create_task(_run_telegram()))
await asyncio.gather(*tasks)
try:
loop.run_until_complete(client.start(token))
loop.run_until_complete(_run_all())
except KeyboardInterrupt:
loop.run_until_complete(scheduler.stop())
loop.run_until_complete(client.close())