feat: add Matrix appservice bot and platform-agnostic conversation core

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>
This commit is contained in:
2026-08-18 21:38:01 +10:00
co-authored by Sisyphus
parent 260720dd10
commit 76777c98eb
11 changed files with 749 additions and 348 deletions
+179
View File
@@ -0,0 +1,179 @@
"""Platform-agnostic conversation core for Steward.
This module owns the shared message pipeline (LLM call, history management,
knowledge-base search, and thread-memory keying) so that both the Telegram
and Matrix adapters can drive the same behaviour without duplicating logic.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from steward.bot.thread_key import ThreadKey
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
from steward.tools.client import ToolClient
logger = logging.getLogger(__name__)
_MAX_HISTORY = 20
_FLUSH_SYSTEM_PROMPT = (
"You are Steward. The following is a complete conversation thread. "
"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-8 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"
)
_KB_CONTEXT_HEADER = (
"The following are relevant past conversation summaries from your knowledge base. "
"Use them as background context if they relate to the current question, "
"but do not repeat their contents unless directly asked."
)
class ConversationService:
"""Owns the shared message pipeline used by all platform adapters."""
def __init__(
self,
settings: Settings,
llm: LLMClient,
thread_store: ThreadMemoryStore,
tool_client: ToolClient | None = None,
) -> None:
self._settings = settings
self._llm = llm
self._store = thread_store
self._tool_client = tool_client
self._histories: dict[ThreadKey, list[dict[str, Any]]] = {}
def _history_for(self, key: ThreadKey) -> list[dict[str, Any]]:
return self._histories.setdefault(key, [])
@property
def llm(self) -> LLMClient:
"""The shared LLM client (used by platform adapters for ad-hoc calls)."""
return self._llm
def _with_kb_context(
self,
history: list[dict[str, Any]],
relevant: list[ThreadSummary],
) -> list[dict[str, Any]]:
if not relevant:
return history
snippets = [f"[Thread {s.thread_id}] {s.summary[:400]}" for s in relevant[:3]]
kb_msg = _KB_CONTEXT_HEADER + "\n\n" + "\n\n---\n\n".join(snippets)
return [{"role": "system", "content": kb_msg}, *history]
async def process_message(
self,
key: ThreadKey,
user_id: str,
text: str,
system_prompt: str,
history_cap: int | None = None,
history_formatter: Callable[[str], str] | None = None,
) -> str:
"""Process a user message and return the raw assistant reply text.
The reply is the raw LLM output; platform adapters are responsible for
parsing and rendering it (e.g. Telegram polls/multi-message markers).
``history_formatter`` transforms the raw reply into the text stored in
conversation history (defaults to the raw reply).
"""
history = self._history_for(key)
call_history = self._with_kb_context(history, self._store.search(text))
if self._tool_client is not None:
reply = await self._llm.chat_with_tools(
text,
self._tool_client,
history=call_history,
system_prompt=system_prompt,
)
else:
reply = await self._llm.chat(text, history=call_history, system_prompt=system_prompt)
history.append({"role": "user", "content": text})
stored_reply = history_formatter(reply) if history_formatter else reply
history.append({"role": "assistant", "content": stored_reply})
if history_cap is not None and len(history) > history_cap:
self._histories[key] = history[-history_cap:]
return reply
def clear_history(self, key: ThreadKey) -> None:
"""Wipe the in-memory conversation history for a scope."""
self._histories.pop(key, None)
def has_history(self, key: ThreadKey) -> bool:
"""Return True if the scope has any in-memory conversation history."""
return bool(self._histories.get(key))
async def flush_history(self, key: ThreadKey) -> ThreadSummary | None:
"""Summarise the scope's history, persist it, and compress in-memory history.
Returns the persisted :class:`ThreadSummary`, or ``None`` if there was no
history to flush.
"""
history = self._history_for(key)
if not history:
return None
transcript_lines = []
for msg in history:
role_label = "User" if msg["role"] == "user" else "Steward"
transcript_lines.append(f"{role_label}: {msg['content']}")
transcript = "\n".join(transcript_lines)
summary_text = await self._llm.chat(
f"Thread transcript:\n\n{transcript}",
system_prompt=_FLUSH_SYSTEM_PROMPT,
)
tags_raw = await self._llm.chat(
f"Summary to tag:\n\n{summary_text}",
system_prompt=_TAGS_SYSTEM_PROMPT,
)
tags = [t.strip().lower() for t in tags_raw.split(",") if t.strip()][:8]
message_count = sum(1 for m in history if m["role"] == "user")
thread_summary = ThreadSummary(
platform=key.platform,
scope=key.scope,
thread=key.thread,
summary=summary_text,
message_count=message_count,
tags=tags,
)
self._store.save(thread_summary)
self._histories[key] = [
{"role": "system", "content": f"Summary of earlier conversation:\n{summary_text}"}
]
return thread_summary
def get_summary(self, key: ThreadKey) -> ThreadSummary | None:
"""Return the stored summary for a scope, or None if not found."""
return self._store.get(key)
def search_summaries(self, query: str) -> list[ThreadSummary]:
"""Search the knowledge base for summaries whose tags overlap the query."""
return self._store.search(query)
def all_summaries(self) -> list[ThreadSummary]:
"""Return all stored summaries, newest first."""
return self._store.all()
+126
View File
@@ -0,0 +1,126 @@
"""Matrix appservice bot interface for Steward.
Registers as a Synapse application service. Synapse pushes room events to the
appservice HTTP server (``/_matrix/app/v1/transactions/{txnId}``); the bot
replies through the client-server API using the appservice's ``as_token``.
"""
from __future__ import annotations
import asyncio
import logging
from mautrix.appservice import AppService
from mautrix.types import (
Event,
EventType,
MessageEvent,
MessageType,
TextMessageEventContent,
)
from steward.bot.core import ConversationService
from steward.bot.thread_key import ThreadKey
from steward.config import Settings
logger = logging.getLogger(__name__)
_MATRIX_SYSTEM_APPENDIX = """
Matrix 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.
- Keep replies to a single message unless splitting genuinely helps readability.
""".strip()
class StewardMatrixBot:
"""Matrix appservice bot that drives the shared conversation pipeline."""
def __init__(self, settings: Settings, service: ConversationService) -> None:
self._settings = settings
self._service = service
self._az = AppService(
server=settings.matrix.homeserver_url,
domain=settings.matrix.homeserver_domain,
as_token=settings.matrix.as_token,
hs_token=settings.matrix.hs_token,
bot_localpart=settings.matrix.bot_localpart,
id=settings.matrix.appservice_id,
)
self._az.matrix_event_handler(self._on_event)
@property
def bot_mxid(self) -> str:
return self._az.bot_mxid
def _is_allowed_user(self, sender: str) -> bool:
allowed = self._settings.matrix.allowed_user_ids
if not allowed:
return True
return sender in allowed
def _is_allowed_room(self, room_id: str) -> bool:
allowed = self._settings.matrix.allowed_room_ids
if not allowed:
return True
return room_id in allowed
def _matrix_system_prompt(self) -> str:
base_prompt = self._settings.openai_system_prompt.strip()
if not base_prompt:
return _MATRIX_SYSTEM_APPENDIX
return f"{base_prompt}\n\n{_MATRIX_SYSTEM_APPENDIX}"
async def _on_event(self, evt: Event) -> None:
if not isinstance(evt, MessageEvent):
return
if evt.sender == self.bot_mxid:
return
if evt.type != EventType.ROOM_MESSAGE:
return
if not isinstance(evt.content, TextMessageEventContent):
return
if not self._is_allowed_user(evt.sender):
logger.info("Ignoring message from unauthorized user %s", evt.sender)
return
if not self._is_allowed_room(evt.room_id):
logger.info("Ignoring message in unauthorized room %s", evt.room_id)
return
body = (evt.content.body or "").strip()
if not body:
return
key = ThreadKey(platform="matrix", scope=evt.room_id)
reply = await self._service.process_message(
key,
evt.sender,
body,
self._matrix_system_prompt(),
history_cap=40,
)
if not reply.strip():
return
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=reply)
await self._az.intent.send_message(evt.room_id, content)
async def run(self) -> None:
"""Start the appservice HTTP server and keep the event loop alive."""
await self._az.start(
host=self._settings.matrix.listen_host,
port=self._settings.matrix.listen_port,
)
logger.info(
"Matrix appservice listening on %s:%s (bot %s)",
self._settings.matrix.listen_host,
self._settings.matrix.listen_port,
self.bot_mxid,
)
await self._az.intent.set_displayname("Steward")
while True:
await asyncio.sleep(3600)
async def stop(self) -> None:
await self._az.stop()
+61 -184
View File
@@ -2,7 +2,6 @@
import logging
import re
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
@@ -16,23 +15,13 @@ from telegram.ext import (
filters,
)
from steward.bot.core import ConversationService
from steward.bot.thread_key import ThreadKey
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
from steward.proposals.generator import Proposal, ProposalGenerator
from steward.tools.client import ToolClient
logger = logging.getLogger(__name__)
# Per-user conversation history for non-threaded messages (capped at _MAX_HISTORY turns).
_history: dict[int, list[dict[str, Any]]] = defaultdict(list)
_MAX_HISTORY = 20
# Per-thread conversation history: (chat_id, thread_id) → full history (unbounded).
# Messages belonging to a Telegram message thread are kept in their entirety here
# until explicitly flushed by the /flush command.
_thread_history: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
_FLUSH_SYSTEM_PROMPT = (
"You are Steward. The following is a complete Telegram message thread conversation. "
"Produce a concise but comprehensive summary that captures:\n"
@@ -45,18 +34,12 @@ _FLUSH_SYSTEM_PROMPT = (
_TAGS_SYSTEM_PROMPT = (
"You are a keyword tagger for a knowledge base. "
"Extract 5–8 short, lowercase keyword tags from the following conversation summary. "
"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"
)
_KB_CONTEXT_HEADER = (
"The following are relevant past conversation summaries from your knowledge base. "
"Use them as background context if they relate to the current question, "
"but do not repeat their contents unless directly asked."
)
_CONVERSATION_SYSTEM_APPENDIX = """
Telegram conversation guidance:
- Reply like a thoughtful software engineer in a chat, not like a one-shot FAQ bot.
@@ -111,8 +94,8 @@ class TelegramResponsePlan:
return [action for action in self.actions if isinstance(action, PollRequest)]
def _thread_key(update: Update) -> tuple[int, int] | None:
"""Return the (chat_id, thread_id) key if the message is part of a thread, else None."""
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:
@@ -120,7 +103,7 @@ def _thread_key(update: Update) -> tuple[int, int] | None:
thread_id = msg.message_thread_id
if thread_id is None:
return None
return (chat.id, thread_id)
return ThreadKey(platform="telegram", scope=str(chat.id), thread=str(thread_id))
def _is_allowed(user_id: int, settings: Settings) -> bool:
@@ -307,11 +290,11 @@ async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
"Hello, I'm *Steward* \U0001f916\n\n"
"I'm your AI-assisted personal operations platform.\n"
"Talk to me naturally, or use:\n"
"/help – show available commands\n"
"/clear – reset conversation history\n"
"/flush – summarise and archive this thread's memory\n"
"/recall [query] – retrieve archived thread summaries\n"
"/analyse – run a manual API analysis right now",
"/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,
)
@@ -329,24 +312,26 @@ async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
await update.message.reply_text( # type: ignore[union-attr]
"*Steward commands*\n\n"
"/start – greeting\n"
"/help – this message\n"
"/clear – reset conversation history for this context\n"
"/flush – summarise the current thread, store the summary, and compress memory\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] – show this thread's summary, list all summaries, or search by keyword\n"
"/analyse – trigger an immediate API analysis and proposal",
"/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 – wipe conversation history for this context.
"""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
@@ -357,30 +342,21 @@ async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
key = _thread_key(update)
if key is not None:
_thread_history[key].clear()
service.clear_history(key)
await update.message.reply_text( # type: ignore[union-attr]
"Thread conversation history cleared."
)
else:
_history[user.id].clear()
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 – summarise thread memory, persist it, and compress in-memory history.
Steps:
1. Verify the command is issued inside a message thread.
2. Summarise the full thread history via the LLM.
3. Persist the summary in the ThreadMemoryStore (keyed by chat_id + thread_id).
4. Replace the in-memory thread history with a single compressed context message
so conversation can continue with the summary as background.
"""
"""Handle /flush \u2013 summarise thread memory, persist it, and compress in-memory history."""
settings: Settings = context.bot_data["settings"]
llm: LLMClient = context.bot_data["llm"]
store: ThreadMemoryStore = context.bot_data["thread_store"]
service: ConversationService = context.bot_data["service"]
user = update.effective_user
if user is None or not _is_allowed(user.id, settings):
@@ -397,10 +373,7 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
)
return
chat_id, thread_id = key
history = _thread_history[key]
if not history:
if not service.has_history(key):
await update.message.reply_text( # type: ignore[union-attr]
"This thread has no conversation history to flush."
)
@@ -410,60 +383,25 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
"\U0001f4be Summarising thread memory\u2026"
)
# Build a readable transcript for the LLM to summarise
transcript_lines = []
for msg in history:
role_label = "User" if msg["role"] == "user" else "Steward"
transcript_lines.append(f"{role_label}: {msg['content']}")
transcript = "\n".join(transcript_lines)
summary_text = await llm.chat(
f"Thread transcript:\n\n{transcript}",
system_prompt=_FLUSH_SYSTEM_PROMPT,
)
# Extract keyword tags for knowledge-base indexing (second LLM call, lightweight)
tags_raw = await llm.chat(
f"Summary to tag:\n\n{summary_text}",
system_prompt=_TAGS_SYSTEM_PROMPT,
)
tags = [t.strip().lower() for t in tags_raw.split(",") if t.strip()][:8]
message_count = sum(1 for m in history if m["role"] == "user")
thread_summary = ThreadSummary(
chat_id=chat_id,
thread_id=thread_id,
summary=summary_text,
message_count=message_count,
tags=tags,
)
store.save(thread_summary)
# Compress: replace history with a single system-context entry so the thread
# can continue with the summary as background knowledge.
_thread_history[key] = [
{"role": "system", "content": f"Summary of earlier conversation:\n{summary_text}"}
]
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_id}`, {message_count} messages summarised).",
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] – retrieve stored thread summaries.
With a query argument (e.g. ``/recall api design``): searches all stored
summaries whose tags overlap with the query keywords and returns matches.
Without a query:
- Inside a thread: shows the stored summary for this thread (if any).
- Outside a thread: lists all stored summaries (newest first).
"""
"""Handle /recall [query] \u2013 retrieve stored thread summaries."""
settings: Settings = context.bot_data["settings"]
store: ThreadMemoryStore = context.bot_data["thread_store"]
service: ConversationService = context.bot_data["service"]
user = update.effective_user
if user is None or not _is_allowed(user.id, settings):
@@ -473,11 +411,10 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
if chat is None or not _is_chat_enabled(chat, settings):
return
# If the user supplied a keyword query, search the knowledge base
args: list[str] = context.args or [] # type: ignore[assignment]
args: list[str] = context.args or []
if args:
query = " ".join(args).strip()
results = store.search(query)
results = service.search_summaries(query)
if not results:
await update.message.reply_text( # type: ignore[union-attr]
f"No memories found matching *{query}*. "
@@ -497,8 +434,7 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
key = _thread_key(update)
if key is not None:
chat_id, thread_id = key
stored = store.get(chat_id, thread_id)
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."
@@ -507,8 +443,7 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await _send_long(update, stored.format_for_telegram())
return
# Outside a thread: list all stored summaries
all_summaries = store.all()
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."
@@ -526,9 +461,8 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /analyse – run the proposal generator on demand."""
"""Handle /analyse \u2013 run the proposal generator on demand."""
settings: Settings = context.bot_data["settings"]
llm: LLMClient = context.bot_data["llm"]
user = update.effective_user
if user is None or not _is_allowed(user.id, settings):
return
@@ -538,7 +472,7 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return
await update.message.reply_text("Running analysis, please wait...") # type: ignore[union-attr]
generator = ProposalGenerator(settings, llm)
generator = ProposalGenerator(settings, context.bot_data["llm"])
proposal = await generator.run()
if proposal is None:
await update.message.reply_text( # type: ignore[union-attr]
@@ -549,42 +483,10 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await _send_long(update, proposal.format_for_telegram())
def _with_kb_context(
history: list[dict[str, Any]],
relevant: list[ThreadSummary],
) -> list[dict[str, Any]]:
"""Prepend relevant knowledge-base summaries as a transient system context message.
The returned list is a *new* list — the original *history* is not mutated.
The injected message is never appended to the stored history, so it does not
permanently consume the context window.
"""
if not relevant:
return history
snippets = [f"[Thread {s.thread_id}] {s.summary[:400]}" for s in relevant[:3]]
kb_msg = _KB_CONTEXT_HEADER + "\n\n" + "\n\n---\n\n".join(snippets)
return [{"role": "system", "content": kb_msg}, *history]
async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle plain text messages – forward to LLM and reply.
Thread messages: history is stored unbounded under the (chat_id, thread_id) key.
Non-thread messages: history is capped at _MAX_HISTORY turns per user.
Before each LLM call the knowledge base is searched for summaries whose tags
overlap with keywords in the current message. Any matches are injected as
transient context — they are NOT stored in the rolling history, so they do
not permanently consume the context window.
If a :class:`~steward.tools.client.ToolClient` is registered in
``context.bot_data``, the LLM is invoked with tool calling support so it
can take actions on the configured MCP/OpenAPI tool server.
"""
"""Handle plain text messages \u2013 forward to the shared pipeline and reply."""
settings: Settings = context.bot_data["settings"]
llm: LLMClient = context.bot_data["llm"]
store: ThreadMemoryStore = context.bot_data["thread_store"]
tool_client: ToolClient | None = context.bot_data.get("tool_client")
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):
@@ -598,60 +500,35 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
system_prompt = _telegram_system_prompt(settings)
key = _thread_key(update)
if key is not None:
# Thread message: unbounded history
history: list[dict[str, Any]] = _thread_history[key]
call_history = _with_kb_context(history, store.search(text))
if tool_client is not None:
reply = await llm.chat_with_tools(
text,
tool_client,
history=call_history,
system_prompt=system_prompt,
)
else:
reply = await llm.chat(text, history=call_history, system_prompt=system_prompt)
response_plan = _parse_telegram_response(reply)
history.append({"role": "user", "content": text})
history.append(
{"role": "assistant", "content": _format_response_for_history(response_plan)}
)
if key is None:
key = ThreadKey(platform="telegram", scope=str(user.id))
history_cap = 40
else:
# Non-thread message: capped history per user
user_history: list[dict[str, Any]] = _history[user.id]
call_history = _with_kb_context(user_history, store.search(text))
if tool_client is not None:
reply = await llm.chat_with_tools(
text,
tool_client,
history=call_history,
system_prompt=system_prompt,
)
else:
reply = await llm.chat(text, history=call_history, system_prompt=system_prompt)
response_plan = _parse_telegram_response(reply)
user_history.append({"role": "user", "content": text})
user_history.append(
{"role": "assistant", "content": _format_response_for_history(response_plan)}
)
if len(user_history) > _MAX_HISTORY * 2:
_history[user.id] = user_history[-(_MAX_HISTORY * 2) :]
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,
llm: LLMClient,
thread_store: ThreadMemoryStore | None = None,
tool_client: ToolClient | None = None,
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["llm"] = llm
app.bot_data["thread_store"] = thread_store or ThreadMemoryStore(settings.thread_memory_path)
app.bot_data["tool_client"] = tool_client # None when tools are not configured
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)
+24
View File
@@ -0,0 +1,24 @@
"""Normalised conversation identity shared across platforms and storage."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ThreadKey:
"""Normalised identity of a conversation scope across platforms.
``platform`` is ``"telegram"`` or ``"matrix"``. ``scope`` is the chat/room/user
identifier as a string. ``thread`` is an optional sub-thread identifier.
"""
platform: str
scope: str
thread: str | None = None
def __str__(self) -> str:
parts = [self.platform, self.scope]
if self.thread:
parts.append(self.thread)
return ":".join(parts)