feat(05-04): implement @mention message handler

Add mention_handler for @claude, @gpt, @gemini direct messages:
- Parse model name from @mention prefix
- Get active discussion for context (if exists)
- Query model via query_model_direct with full context
- Persist response with is_direct=True flag
- Register MessageHandler with regex filter after CommandHandlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-01-16 19:48:39 +00:00
parent 5934d21256
commit 3296874408
2 changed files with 89 additions and 2 deletions

View file

@ -4,12 +4,13 @@ This module contains handlers for Telegram bot commands including
project management, discussion commands, and export functionality. project management, discussion commands, and export functionality.
""" """
from telegram.ext import Application, CommandHandler from telegram.ext import Application, CommandHandler, MessageHandler, filters
from moai.bot.handlers.commands import help_command, start_command from moai.bot.handlers.commands import help_command, start_command
from moai.bot.handlers.discussion import ( from moai.bot.handlers.discussion import (
ask_command, ask_command,
discuss_command, discuss_command,
mention_handler,
next_command, next_command,
open_command, open_command,
stop_command, stop_command,
@ -41,3 +42,7 @@ def register_handlers(app: Application) -> None:
app.add_handler(CommandHandler("discuss", discuss_command)) app.add_handler(CommandHandler("discuss", discuss_command))
app.add_handler(CommandHandler("next", next_command)) app.add_handler(CommandHandler("next", next_command))
app.add_handler(CommandHandler("stop", stop_command)) app.add_handler(CommandHandler("stop", stop_command))
# @mention handler - MessageHandler registered AFTER CommandHandlers
# Matches messages starting with @claude, @gpt, or @gemini followed by content
app.add_handler(MessageHandler(filters.Regex(r"^@(claude|gpt|gemini)\s"), mention_handler))

View file

@ -6,13 +6,14 @@ from telegram.ext import ContextTypes
from moai.bot.handlers.projects import get_selected_project from moai.bot.handlers.projects import get_selected_project
from moai.core.ai_client import MODEL_MAP, get_ai_client from moai.core.ai_client import MODEL_MAP, get_ai_client
from moai.core.models import DiscussionType, RoundType from moai.core.models import DiscussionType, RoundType
from moai.core.orchestrator import query_models_parallel, run_discussion_round from moai.core.orchestrator import query_model_direct, query_models_parallel, run_discussion_round
from moai.core.services.discussion import ( from moai.core.services.discussion import (
complete_discussion, complete_discussion,
create_discussion, create_discussion,
create_message, create_message,
create_round, create_round,
get_active_discussion, get_active_discussion,
get_current_round,
get_discussion, get_discussion,
) )
@ -343,3 +344,84 @@ async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
except Exception as e: except Exception as e:
await update.message.reply_text(f"Error: {e}") await update.message.reply_text(f"Error: {e}")
async def mention_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle @model mention messages.
Messages starting with @claude, @gpt, or @gemini are routed to that
specific model. If a discussion is active, includes full context.
Examples:
@claude What do you think about this?
@gpt Can you elaborate on your previous point?
@gemini Do you agree with Claude?
"""
message_text = update.message.text
if not message_text:
return
# Parse model name from first word (e.g., "@claude" -> "claude")
parts = message_text.split(maxsplit=1)
if not parts:
return
model_tag = parts[0].lower()
# Strip the @ prefix
model_name = model_tag.lstrip("@")
# Validate model
if model_name not in MODEL_MAP:
return # Not a valid model mention, ignore
# Get the rest of the message as content
content = parts[1] if len(parts) > 1 else ""
if not content.strip():
await update.message.reply_text(
f"Usage: @{model_name} <message>\n\nExample: @{model_name} What do you think?"
)
return
# Get project context if available
project = await get_selected_project(context)
if project is None:
await update.message.reply_text("No project selected. Use /project select <name> first.")
return
project_name = project.name
# Check for active discussion (for context)
discussion = await get_active_discussion(project.id)
# Show typing indicator
await update.message.chat.send_action("typing")
try:
# Query the model directly with optional discussion context
response = await query_model_direct(
model=model_name,
message=content,
discussion=discussion,
project_name=project_name,
)
# If there's an active discussion, persist the message
if discussion is not None:
# Get or create a round for this direct message
current_round = await get_current_round(discussion.id)
if current_round is not None:
await create_message(
round_id=current_round.id,
model=model_name,
content=response,
is_direct=True,
)
# Format response
await update.message.reply_text(
f"*@{model_name.title()} (direct):*\n{response}",
parse_mode="Markdown",
)
except Exception as e:
await update.message.reply_text(f"Error: {e}")