Introduce a shared ConversationService (steward/bot/core.py) that owns the LLM call, history, knowledge-base search, and thread-memory keying behind a normalized ThreadKey, so both Telegram and Matrix drive the same pipeline. - Add steward/bot/matrix.py: a mautrix-python appservice bot that receives Synapse transactions and replies via the client-server API. - Refactor telegram.py handlers into thin wrappers over ConversationService. - Generalize ThreadMemoryStore/ThreadSummary to platform-scoped keys with legacy chat_id:thread_id migration. - Add a matrix config section (homeserver, tokens, room/user allowlists). - Rewrite main.py as async, starting Telegram and/or Matrix on one event loop. - Add mautrix>=0.21.0 dependency. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
558 lines
20 KiB
Python
558 lines
20 KiB
Python
"""Telegram bot interface for Steward."""
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from telegram import Update
|
|
from telegram.constants import ParseMode
|
|
from telegram.ext import (
|
|
Application,
|
|
CommandHandler,
|
|
ContextTypes,
|
|
MessageHandler,
|
|
filters,
|
|
)
|
|
|
|
from steward.bot.core import ConversationService
|
|
from steward.bot.thread_key import ThreadKey
|
|
from steward.config import Settings
|
|
from steward.proposals.generator import Proposal, ProposalGenerator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FLUSH_SYSTEM_PROMPT = (
|
|
"You are Steward. The following is a complete Telegram message thread conversation. "
|
|
"Produce a concise but comprehensive summary that captures:\n"
|
|
"- The main topics discussed\n"
|
|
"- Key decisions or conclusions reached\n"
|
|
"- Any outstanding actions or open questions\n"
|
|
"- Important context that would help recall this conversation later\n\n"
|
|
"Be precise. Omit pleasantries."
|
|
)
|
|
|
|
_TAGS_SYSTEM_PROMPT = (
|
|
"You are a keyword tagger for a knowledge base. "
|
|
"Extract 5\u20138 short, lowercase keyword tags from the following conversation summary. "
|
|
"Tags should represent the main topics, entities, and concepts discussed. "
|
|
"Return ONLY a comma-separated list of tags with no other text or punctuation. "
|
|
"Example output: api design, authentication, database schema, user roles, caching"
|
|
)
|
|
|
|
_CONVERSATION_SYSTEM_APPENDIX = """
|
|
Telegram conversation guidance:
|
|
- Reply like a thoughtful software engineer in a chat, not like a one-shot FAQ bot.
|
|
- Ask a brief follow-up question when the requested outcome, constraints, or preferred
|
|
option is unclear.
|
|
- You may send multiple short Telegram messages when that makes the conversation easier to follow.
|
|
- To send multiple messages, separate each message with a line containing only [MESSAGE].
|
|
- When the user needs to choose from 2-10 concise options, use Telegram's native poll format:
|
|
[POLL]
|
|
question: The decision to make?
|
|
- First option
|
|
- Second option
|
|
[/POLL]
|
|
- Use polls only for discrete choices. Use a normal clarifying question for open-ended input.
|
|
""".strip()
|
|
|
|
_MESSAGE_MARKER_RE = re.compile(r"^\s*\[MESSAGE\]\s*$", re.IGNORECASE)
|
|
_POLL_START_RE = re.compile(r"^\s*\[POLL\]\s*$", re.IGNORECASE)
|
|
_POLL_END_RE = re.compile(r"^\s*\[/POLL\]\s*$", re.IGNORECASE)
|
|
_OPTION_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)]|option\s*:?)\s*(.+?)\s*$", re.IGNORECASE)
|
|
_QUESTION_RE = re.compile(r"^\s*question\s*:\s*(.+?)\s*$", re.IGNORECASE)
|
|
_MAX_POLL_OPTIONS = 10
|
|
_MAX_POLL_QUESTION_LENGTH = 300
|
|
_MAX_POLL_OPTION_LENGTH = 100
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PollRequest:
|
|
"""A Telegram poll requested by the assistant response."""
|
|
|
|
question: str
|
|
options: list[str]
|
|
|
|
|
|
TelegramResponseAction = str | PollRequest
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TelegramResponsePlan:
|
|
"""Telegram actions derived from a single assistant response."""
|
|
|
|
actions: list[TelegramResponseAction] = field(default_factory=list)
|
|
|
|
@property
|
|
def messages(self) -> list[str]:
|
|
"""Text messages in this response plan."""
|
|
return [action for action in self.actions if isinstance(action, str)]
|
|
|
|
@property
|
|
def polls(self) -> list[PollRequest]:
|
|
"""Polls in this response plan."""
|
|
return [action for action in self.actions if isinstance(action, PollRequest)]
|
|
|
|
|
|
def _thread_key(update: Update) -> ThreadKey | None:
|
|
"""Return the ThreadKey if the message is part of a Telegram thread, else None."""
|
|
msg = update.message
|
|
chat = update.effective_chat
|
|
if msg is None or chat is None:
|
|
return None
|
|
thread_id = msg.message_thread_id
|
|
if thread_id is None:
|
|
return None
|
|
return ThreadKey(platform="telegram", scope=str(chat.id), thread=str(thread_id))
|
|
|
|
|
|
def _is_allowed(user_id: int, settings: Settings) -> bool:
|
|
"""Return True if the user is in the allow-list (or no list is configured)."""
|
|
if not settings.telegram_allowed_user_ids:
|
|
return True
|
|
allowed = user_id in settings.telegram_allowed_user_ids
|
|
if not allowed:
|
|
logger.info("Ignoring update from unauthorized user %s", user_id)
|
|
return allowed
|
|
|
|
|
|
def _is_group_enabled(chat_id: int, settings: Settings) -> bool:
|
|
"""Return True if the group/channel is in the configured list (or no list is configured)."""
|
|
if not settings.telegram_group_ids:
|
|
return True
|
|
return chat_id in settings.telegram_group_ids
|
|
|
|
|
|
def _is_chat_enabled(chat: Any, settings: Settings) -> bool:
|
|
"""Return True if the chat is allowed.
|
|
|
|
Private chats are governed only by the user allow-list. Group/channel allow-listing
|
|
applies only to non-private chats.
|
|
"""
|
|
if getattr(chat, "type", None) == "private":
|
|
return True
|
|
|
|
allowed = _is_group_enabled(chat.id, settings)
|
|
if not allowed:
|
|
logger.info(
|
|
"Ignoring update in unauthorized chat %s (type=%s); configured group IDs: %s",
|
|
chat.id,
|
|
getattr(chat, "type", None),
|
|
settings.telegram_group_ids,
|
|
)
|
|
return allowed
|
|
|
|
|
|
def _telegram_system_prompt(settings: Settings) -> str:
|
|
"""Return the conversational Telegram system prompt for normal chat turns."""
|
|
base_prompt = settings.openai_system_prompt.strip()
|
|
if not base_prompt:
|
|
return _CONVERSATION_SYSTEM_APPENDIX
|
|
return f"{base_prompt}\n\n{_CONVERSATION_SYSTEM_APPENDIX}"
|
|
|
|
|
|
def _append_message_action(actions: list[TelegramResponseAction], lines: list[str]) -> None:
|
|
message = "\n".join(lines).strip()
|
|
if message:
|
|
actions.append(message)
|
|
lines.clear()
|
|
|
|
|
|
def _compact_telegram_text(text: str, max_length: int) -> str:
|
|
return re.sub(r"\s+", " ", text).strip()[:max_length]
|
|
|
|
|
|
def _parse_poll(lines: list[str]) -> PollRequest | None:
|
|
question = ""
|
|
options: list[str] = []
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
|
|
question_match = _QUESTION_RE.match(stripped)
|
|
if question_match and not question:
|
|
question = question_match.group(1)
|
|
continue
|
|
|
|
option_match = _OPTION_RE.match(stripped)
|
|
if option_match:
|
|
options.append(option_match.group(1))
|
|
continue
|
|
|
|
if not question:
|
|
question = stripped
|
|
|
|
question = _compact_telegram_text(question, _MAX_POLL_QUESTION_LENGTH)
|
|
unique_options: list[str] = []
|
|
for option in options:
|
|
compact_option = _compact_telegram_text(option, _MAX_POLL_OPTION_LENGTH)
|
|
if compact_option and compact_option not in unique_options:
|
|
unique_options.append(compact_option)
|
|
|
|
unique_options = unique_options[:_MAX_POLL_OPTIONS]
|
|
if not question or len(unique_options) < 2:
|
|
return None
|
|
|
|
return PollRequest(question=question, options=unique_options)
|
|
|
|
|
|
def _parse_telegram_response(reply: str) -> TelegramResponsePlan:
|
|
"""Parse assistant response directives into ordered Telegram actions."""
|
|
actions: list[TelegramResponseAction] = []
|
|
message_lines: list[str] = []
|
|
poll_lines: list[str] = []
|
|
in_poll = False
|
|
|
|
for line in reply.splitlines():
|
|
if in_poll:
|
|
if _POLL_END_RE.match(line):
|
|
poll = _parse_poll(poll_lines)
|
|
if poll is None:
|
|
message_lines.extend(["[POLL]", *poll_lines, "[/POLL]"])
|
|
else:
|
|
actions.append(poll)
|
|
poll_lines.clear()
|
|
in_poll = False
|
|
else:
|
|
poll_lines.append(line)
|
|
continue
|
|
|
|
if _MESSAGE_MARKER_RE.match(line):
|
|
_append_message_action(actions, message_lines)
|
|
continue
|
|
|
|
if _POLL_START_RE.match(line):
|
|
_append_message_action(actions, message_lines)
|
|
in_poll = True
|
|
continue
|
|
|
|
message_lines.append(line)
|
|
|
|
if in_poll:
|
|
message_lines.extend(["[POLL]", *poll_lines])
|
|
_append_message_action(actions, message_lines)
|
|
|
|
if not actions and reply.strip():
|
|
actions.append(reply.strip())
|
|
|
|
return TelegramResponsePlan(actions=actions)
|
|
|
|
|
|
def _format_response_for_history(plan: TelegramResponsePlan) -> str:
|
|
"""Render Telegram actions back into natural assistant history text."""
|
|
history_parts: list[str] = []
|
|
for action in plan.actions:
|
|
if isinstance(action, str):
|
|
history_parts.append(action)
|
|
else:
|
|
options = "; ".join(action.options)
|
|
history_parts.append(f"Poll: {action.question} ({options})")
|
|
return "\n\n".join(history_parts)
|
|
|
|
|
|
async def _send_telegram_response(update: Update, plan: TelegramResponsePlan) -> None:
|
|
"""Send all Telegram actions for a parsed assistant response."""
|
|
for action in plan.actions:
|
|
if isinstance(action, str):
|
|
await _send_long(update, action)
|
|
else:
|
|
await update.message.reply_poll( # type: ignore[union-attr]
|
|
question=action.question,
|
|
options=action.options,
|
|
is_anonymous=False,
|
|
)
|
|
|
|
|
|
async def _send_long(update: Update, text: str) -> None:
|
|
"""Send text, splitting across messages if it exceeds Telegram's 4096-char limit."""
|
|
limit = 4096
|
|
for i in range(0, len(text), limit):
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
text[i : i + limit],
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
|
|
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /start."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
user = update.effective_user
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"Hello, I'm *Steward* \U0001f916\n\n"
|
|
"I'm your AI-assisted personal operations platform.\n"
|
|
"Talk to me naturally, or use:\n"
|
|
"/help \u2013 show available commands\n"
|
|
"/clear \u2013 reset conversation history\n"
|
|
"/flush \u2013 summarise and archive this thread's memory\n"
|
|
"/recall [query] \u2013 retrieve archived thread summaries\n"
|
|
"/analyse \u2013 run a manual API analysis right now",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
|
|
async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /help."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
user = update.effective_user
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"*Steward commands*\n\n"
|
|
"/start \u2013 greeting\n"
|
|
"/help \u2013 this message\n"
|
|
"/clear \u2013 reset conversation history for this context\n"
|
|
"/flush \u2013 summarise the current thread, store the summary, and compress memory\n"
|
|
" _(only available inside a message thread)_\n"
|
|
"/recall [query] \u2013 show this thread's summary, list all summaries, or search\n"
|
|
" by keyword\n"
|
|
"/analyse \u2013 trigger an immediate API analysis and proposal",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
|
|
async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /clear \u2013 wipe conversation history for this context.
|
|
|
|
Inside a message thread: clears the thread's unbounded history.
|
|
Outside a thread: clears the per-user capped history.
|
|
"""
|
|
settings: Settings = context.bot_data["settings"]
|
|
service: ConversationService = context.bot_data["service"]
|
|
user = update.effective_user
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
key = _thread_key(update)
|
|
if key is not None:
|
|
service.clear_history(key)
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"Thread conversation history cleared."
|
|
)
|
|
else:
|
|
service.clear_history(ThreadKey(platform="telegram", scope=str(user.id)))
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"Conversation history cleared."
|
|
)
|
|
|
|
|
|
async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /flush \u2013 summarise thread memory, persist it, and compress in-memory history."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
service: ConversationService = context.bot_data["service"]
|
|
user = update.effective_user
|
|
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
key = _thread_key(update)
|
|
if key is None:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"\u26a0\ufe0f /flush can only be used inside a message thread."
|
|
)
|
|
return
|
|
|
|
if not service.has_history(key):
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"This thread has no conversation history to flush."
|
|
)
|
|
return
|
|
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"\U0001f4be Summarising thread memory\u2026"
|
|
)
|
|
|
|
thread_summary = await service.flush_history(key)
|
|
if thread_summary is None:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"This thread has no conversation history to flush."
|
|
)
|
|
return
|
|
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
f"\u2705 Thread memory flushed and stored "
|
|
f"(thread `{thread_summary.thread_id}`, "
|
|
f"{thread_summary.message_count} messages summarised).",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
|
|
|
|
async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /recall [query] \u2013 retrieve stored thread summaries."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
service: ConversationService = context.bot_data["service"]
|
|
user = update.effective_user
|
|
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
args: list[str] = context.args or []
|
|
if args:
|
|
query = " ".join(args).strip()
|
|
results = service.search_summaries(query)
|
|
if not results:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
f"No memories found matching *{query}*. "
|
|
"Try a different keyword or use /flush to add more summaries.",
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
return
|
|
lines = [f"\U0001f50d *Knowledge base search: {query}*\n"]
|
|
for s in results:
|
|
date = s.flushed_at[:10]
|
|
first_line = s.summary.split("\n")[0][:80]
|
|
lines.append(f"\u2022 Thread `{s.thread_id}` ({date}): {first_line}\u2026")
|
|
if s.tags:
|
|
lines.append(f" \U0001f3f7 {', '.join(s.tags)}")
|
|
await _send_long(update, "\n".join(lines))
|
|
return
|
|
|
|
key = _thread_key(update)
|
|
if key is not None:
|
|
stored = service.get_summary(key)
|
|
if stored is None:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"No stored summary for this thread yet. Use /flush to create one."
|
|
)
|
|
else:
|
|
await _send_long(update, stored.format_for_telegram())
|
|
return
|
|
|
|
all_summaries = service.all_summaries()
|
|
if not all_summaries:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"No thread summaries stored yet. Use /flush inside a message thread."
|
|
)
|
|
return
|
|
|
|
lines = ["\U0001f4da *Stored thread summaries*\n"]
|
|
for s in all_summaries:
|
|
date = s.flushed_at[:10]
|
|
first_line = s.summary.split("\n")[0][:80]
|
|
lines.append(f"\u2022 Thread `{s.thread_id}` ({date}): {first_line}\u2026")
|
|
if s.tags:
|
|
lines.append(f" \U0001f3f7 {', '.join(s.tags)}")
|
|
await _send_long(update, "\n".join(lines))
|
|
|
|
|
|
async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle /analyse \u2013 run the proposal generator on demand."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
user = update.effective_user
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
|
|
chat = update.effective_chat
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
await update.message.reply_text("Running analysis, please wait...") # type: ignore[union-attr]
|
|
generator = ProposalGenerator(settings, context.bot_data["llm"])
|
|
proposal = await generator.run()
|
|
if proposal is None:
|
|
await update.message.reply_text( # type: ignore[union-attr]
|
|
"Analysis could not be completed. Check that `ANALYSIS_TARGET_URL` is configured."
|
|
)
|
|
return
|
|
|
|
await _send_long(update, proposal.format_for_telegram())
|
|
|
|
|
|
async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle plain text messages \u2013 forward to the shared pipeline and reply."""
|
|
settings: Settings = context.bot_data["settings"]
|
|
service: ConversationService = context.bot_data["service"]
|
|
user = update.effective_user
|
|
chat = update.effective_chat
|
|
if user is None or not _is_allowed(user.id, settings):
|
|
return
|
|
if chat is None or not _is_chat_enabled(chat, settings):
|
|
return
|
|
|
|
text = update.message.text # type: ignore[union-attr]
|
|
if not text:
|
|
return
|
|
|
|
system_prompt = _telegram_system_prompt(settings)
|
|
key = _thread_key(update)
|
|
if key is None:
|
|
key = ThreadKey(platform="telegram", scope=str(user.id))
|
|
history_cap = 40
|
|
else:
|
|
history_cap = None
|
|
|
|
reply = await service.process_message(
|
|
key,
|
|
str(user.id),
|
|
text,
|
|
system_prompt,
|
|
history_cap=history_cap,
|
|
history_formatter=lambda raw: _format_response_for_history(_parse_telegram_response(raw)),
|
|
)
|
|
response_plan = _parse_telegram_response(reply)
|
|
await _send_telegram_response(update, response_plan)
|
|
|
|
|
|
def build_application(
|
|
settings: Settings,
|
|
service: ConversationService,
|
|
generator: ProposalGenerator | None = None,
|
|
) -> Application: # type: ignore[type-arg]
|
|
"""Build and return the Telegram Application."""
|
|
app = Application.builder().token(settings.telegram_bot_token).build()
|
|
app.bot_data["settings"] = settings
|
|
app.bot_data["service"] = service
|
|
app.bot_data["llm"] = service.llm
|
|
app.bot_data["generator"] = generator
|
|
logger.info("Configured allowed users: %s", settings.telegram_allowed_user_ids)
|
|
logger.info("Configured group IDs: %s", settings.telegram_group_ids)
|
|
|
|
app.add_handler(CommandHandler("start", start_handler))
|
|
app.add_handler(CommandHandler("help", help_handler))
|
|
app.add_handler(CommandHandler("clear", clear_handler))
|
|
app.add_handler(CommandHandler("flush", flush_handler))
|
|
app.add_handler(CommandHandler("recall", recall_handler))
|
|
app.add_handler(CommandHandler("analyse", analyse_handler))
|
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, message_handler))
|
|
|
|
return app
|
|
|
|
|
|
async def send_proposal(app: Application, proposal: Proposal, user_ids: list[int]) -> None: # type: ignore[type-arg]
|
|
"""Send a proposal message to all configured user IDs."""
|
|
text = proposal.format_for_telegram()
|
|
for uid in user_ids:
|
|
try:
|
|
await app.bot.send_message(
|
|
chat_id=uid,
|
|
text=text,
|
|
parse_mode=ParseMode.MARKDOWN,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to send proposal to user %d", uid)
|