mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix: improve telegram topic mode setup
This commit is contained in:
parent
d6615d8ec7
commit
25065283b3
7 changed files with 458 additions and 38 deletions
|
|
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
|||
# so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain)
|
||||
# become visible instead of piling up as NULL session titles.
|
||||
FailureCallback = Callable[[str, BaseException], None]
|
||||
TitleCallback = Callable[[str], None]
|
||||
|
||||
_TITLE_PROMPT = (
|
||||
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
|
||||
|
|
@ -90,6 +91,7 @@ def auto_title_session(
|
|||
assistant_response: str,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
) -> None:
|
||||
"""Generate and set a session title if one doesn't already exist.
|
||||
|
||||
|
|
@ -119,6 +121,11 @@ def auto_title_session(
|
|||
try:
|
||||
session_db.set_session_title(session_id, title)
|
||||
logger.debug("Auto-generated session title: %s", title)
|
||||
if title_callback is not None:
|
||||
try:
|
||||
title_callback(title)
|
||||
except Exception:
|
||||
logger.debug("Auto-title callback failed", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to set auto-generated title: %s", e)
|
||||
|
||||
|
|
@ -131,6 +138,7 @@ def maybe_auto_title(
|
|||
conversation_history: list,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget title generation after the first exchange.
|
||||
|
||||
|
|
@ -152,7 +160,11 @@ def maybe_auto_title(
|
|||
thread = threading.Thread(
|
||||
target=auto_title_session,
|
||||
args=(session_db, session_id, user_message, assistant_response),
|
||||
kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime},
|
||||
kwargs={
|
||||
"failure_callback": failure_callback,
|
||||
"main_runtime": main_runtime,
|
||||
"title_callback": title_callback,
|
||||
},
|
||||
daemon=True,
|
||||
name="auto-title",
|
||||
)
|
||||
|
|
|
|||
BIN
gateway/assets/telegram-botfather-threads-settings.jpg
Normal file
BIN
gateway/assets/telegram-botfather-threads-settings.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
|
|
@ -688,6 +688,29 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return None
|
||||
|
||||
async def rename_dm_topic(
|
||||
self,
|
||||
chat_id: int,
|
||||
thread_id: int,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Rename a forum topic in a private (DM) chat."""
|
||||
if not self._bot:
|
||||
return
|
||||
try:
|
||||
chat_id_arg = int(chat_id)
|
||||
except (TypeError, ValueError):
|
||||
chat_id_arg = chat_id
|
||||
await self._bot.edit_forum_topic(
|
||||
chat_id=chat_id_arg,
|
||||
message_thread_id=int(thread_id),
|
||||
name=name,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'",
|
||||
self.name, chat_id, thread_id, name,
|
||||
)
|
||||
|
||||
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
|
||||
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
|
||||
try:
|
||||
|
|
|
|||
288
gateway/run.py
288
gateway/run.py
|
|
@ -1050,6 +1050,7 @@ class GatewayRunner:
|
|||
)
|
||||
self.delivery_router = DeliveryRouter(self.config)
|
||||
self._running = False
|
||||
self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._exit_cleanly = False
|
||||
self._exit_with_failure = False
|
||||
|
|
@ -1493,17 +1494,19 @@ class GatewayRunner:
|
|||
def _telegram_topic_root_lobby_message(self) -> str:
|
||||
return (
|
||||
"This main chat is reserved for system commands.\n\n"
|
||||
"To chat with Hermes, create a new topic using the + button in "
|
||||
"this bot interface. Each topic works as an independent Hermes "
|
||||
"session."
|
||||
"To start a new Hermes chat, open the All Messages topic at the top "
|
||||
"of this bot interface and send any message there. Telegram will "
|
||||
"create a new topic for that message; each topic works as an "
|
||||
"independent Hermes session."
|
||||
)
|
||||
|
||||
def _telegram_topic_root_new_message(self) -> str:
|
||||
return (
|
||||
"To start a new parallel Hermes chat, create a new topic with the "
|
||||
"+ button in this bot interface.\n\n"
|
||||
"Each topic is an independent Hermes session. Use /new inside a "
|
||||
"topic only if you want to replace that topic's current session."
|
||||
"To start a new parallel Hermes chat, open the All Messages topic "
|
||||
"at the top of this bot interface and send any message there. "
|
||||
"Telegram will create a new topic for it.\n\n"
|
||||
"Each topic is an independent Hermes session. Use /new inside an "
|
||||
"existing topic only if you want to replace that topic's current session."
|
||||
)
|
||||
|
||||
def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]:
|
||||
|
|
@ -1511,9 +1514,9 @@ class GatewayRunner:
|
|||
return None
|
||||
return (
|
||||
"Started a new Hermes session in this topic.\n\n"
|
||||
"Tip: for parallel work, create a new topic with the + button "
|
||||
"instead of using /new here. /new replaces the session attached "
|
||||
"to the current topic."
|
||||
"Tip: for parallel work, open All Messages and send a message there "
|
||||
"to create a separate topic instead of using /new here. /new replaces "
|
||||
"the session attached to the current topic."
|
||||
)
|
||||
|
||||
def _record_telegram_topic_binding(
|
||||
|
|
@ -2767,6 +2770,10 @@ class GatewayRunner:
|
|||
Returns True if at least one adapter connected successfully.
|
||||
"""
|
||||
logger.info("Starting Hermes Gateway...")
|
||||
try:
|
||||
self._gateway_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._gateway_loop = None
|
||||
logger.info("Session storage: %s", self.config.sessions_dir)
|
||||
# Log the resolved max_iterations budget so operators can verify the
|
||||
# config.yaml → env bridge did the right thing at a glance (instead
|
||||
|
|
@ -9569,7 +9576,193 @@ class GatewayRunner:
|
|||
logger.warning("Manual compress failed: %s", e)
|
||||
return f"Compression failed: {e}"
|
||||
|
||||
async def _handle_topic_command(self, event: MessageEvent) -> str:
|
||||
async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict:
|
||||
"""Read Telegram private-topic capability flags via Bot API getMe."""
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
bot = getattr(adapter, "_bot", None)
|
||||
if bot is None or not hasattr(bot, "get_me"):
|
||||
return {"checked": False}
|
||||
try:
|
||||
me = await bot.get_me()
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch Telegram getMe topic capabilities", exc_info=True)
|
||||
return {"checked": False}
|
||||
|
||||
def _field(name: str):
|
||||
if hasattr(me, name):
|
||||
return getattr(me, name)
|
||||
api_kwargs = getattr(me, "api_kwargs", None)
|
||||
if isinstance(api_kwargs, dict) and name in api_kwargs:
|
||||
return api_kwargs.get(name)
|
||||
if isinstance(me, dict):
|
||||
return me.get(name)
|
||||
return None
|
||||
|
||||
return {
|
||||
"checked": True,
|
||||
"has_topics_enabled": _field("has_topics_enabled"),
|
||||
"allows_users_to_create_topics": _field("allows_users_to_create_topics"),
|
||||
}
|
||||
|
||||
async def _ensure_telegram_system_topic(self, source: SessionSource) -> None:
|
||||
"""Create/pin the managed System topic after /topic activation when possible."""
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
if adapter is None or not source.chat_id:
|
||||
return
|
||||
|
||||
thread_id = None
|
||||
create_topic = getattr(adapter, "_create_dm_topic", None)
|
||||
if callable(create_topic):
|
||||
try:
|
||||
thread_id = await create_topic(int(source.chat_id), "System")
|
||||
except Exception:
|
||||
logger.debug("Failed to create Telegram System topic", exc_info=True)
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
message_id = None
|
||||
try:
|
||||
send_result = await adapter.send(
|
||||
source.chat_id,
|
||||
"System topic for Hermes commands and status.",
|
||||
metadata={"thread_id": str(thread_id)},
|
||||
)
|
||||
message_id = getattr(send_result, "message_id", None)
|
||||
except Exception:
|
||||
logger.debug("Failed to send Telegram System topic intro", exc_info=True)
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
bot = getattr(adapter, "_bot", None)
|
||||
if bot is None or not hasattr(bot, "pin_chat_message"):
|
||||
return
|
||||
try:
|
||||
await bot.pin_chat_message(
|
||||
chat_id=int(source.chat_id),
|
||||
message_id=int(message_id),
|
||||
disable_notification=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to pin Telegram System topic intro", exc_info=True)
|
||||
|
||||
async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None:
|
||||
"""Send the bundled BotFather Threads Settings screenshot when available."""
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
if adapter is None or not source.chat_id or not hasattr(adapter, "send_image_file"):
|
||||
return
|
||||
image_path = Path(__file__).resolve().parent / "assets" / "telegram-botfather-threads-settings.jpg"
|
||||
if not image_path.exists():
|
||||
return
|
||||
try:
|
||||
await adapter.send_image_file(
|
||||
chat_id=source.chat_id,
|
||||
image_path=str(image_path),
|
||||
caption="BotFather → Bot Settings → Threads Settings",
|
||||
metadata={"thread_id": str(source.thread_id)} if source.thread_id else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to send Telegram topic setup image", exc_info=True)
|
||||
|
||||
def _sanitize_telegram_topic_title(self, title: str) -> str:
|
||||
"""Return a Bot API-safe forum topic name from a generated session title."""
|
||||
cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
|
||||
if not cleaned:
|
||||
return "Hermes Chat"
|
||||
# Telegram forum topic names are short (currently 1-128 chars). Keep
|
||||
# extra room for multi-byte titles and avoid trailing ellipsis churn.
|
||||
if len(cleaned) > 120:
|
||||
cleaned = cleaned[:117].rstrip() + "..."
|
||||
return cleaned
|
||||
|
||||
async def _rename_telegram_topic_for_session_title(
|
||||
self,
|
||||
source: SessionSource,
|
||||
session_id: str,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""Best-effort rename of a Telegram DM topic when Hermes auto-titles a session."""
|
||||
if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id:
|
||||
return
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
if session_db is not None:
|
||||
try:
|
||||
binding = session_db.get_telegram_topic_binding(
|
||||
chat_id=str(source.chat_id),
|
||||
thread_id=str(source.thread_id),
|
||||
)
|
||||
if binding and str(binding.get("session_id") or "") != str(session_id):
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True)
|
||||
return
|
||||
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
if adapter is None:
|
||||
return
|
||||
topic_name = self._sanitize_telegram_topic_title(title)
|
||||
try:
|
||||
rename_topic = getattr(adapter, "rename_dm_topic", None)
|
||||
if rename_topic is not None:
|
||||
await rename_topic(
|
||||
chat_id=str(source.chat_id),
|
||||
thread_id=str(source.thread_id),
|
||||
name=topic_name,
|
||||
)
|
||||
return
|
||||
|
||||
bot = getattr(adapter, "_bot", None)
|
||||
edit_forum_topic = getattr(bot, "edit_forum_topic", None) if bot is not None else None
|
||||
if edit_forum_topic is None:
|
||||
edit_forum_topic = getattr(bot, "editForumTopic", None) if bot is not None else None
|
||||
if edit_forum_topic is None:
|
||||
return
|
||||
try:
|
||||
await edit_forum_topic(
|
||||
chat_id=int(source.chat_id),
|
||||
message_thread_id=int(source.thread_id),
|
||||
name=topic_name,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
await edit_forum_topic(
|
||||
chat_id=source.chat_id,
|
||||
message_thread_id=source.thread_id,
|
||||
name=topic_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True)
|
||||
|
||||
def _schedule_telegram_topic_title_rename(
|
||||
self,
|
||||
source: SessionSource,
|
||||
session_id: str,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""Schedule a topic rename from the auto-title background thread."""
|
||||
if not title or not self._is_telegram_topic_lane(source):
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = getattr(self, "_gateway_loop", None)
|
||||
if loop is None or loop.is_closed():
|
||||
return
|
||||
try:
|
||||
copied_source = dataclasses.replace(source)
|
||||
except Exception:
|
||||
copied_source = source
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._rename_telegram_topic_for_session_title(copied_source, session_id, title),
|
||||
loop,
|
||||
)
|
||||
def _log_rename_failure(fut) -> None:
|
||||
try:
|
||||
fut.result()
|
||||
except Exception:
|
||||
logger.debug("Telegram topic title rename failed", exc_info=True)
|
||||
|
||||
future.add_done_callback(_log_rename_failure)
|
||||
|
||||
async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str:
|
||||
"""Handle /topic for Telegram DM user-managed topic sessions."""
|
||||
source = event.source
|
||||
if source.platform != Platform.TELEGRAM or source.chat_type != "dm":
|
||||
|
|
@ -9581,20 +9774,48 @@ class GatewayRunner:
|
|||
if args:
|
||||
if not source.thread_id:
|
||||
return (
|
||||
"To restore a session, first create or open a Telegram topic "
|
||||
"with the + button, then send /topic <session-id> inside that topic."
|
||||
"To restore a session, first create or open a Telegram topic, "
|
||||
"then send /topic <session-id> inside that topic. To create a "
|
||||
"new topic, open All Messages and send any message there."
|
||||
)
|
||||
return await self._restore_telegram_topic_session(event, args)
|
||||
|
||||
capabilities = await self._get_telegram_topic_capabilities(source)
|
||||
if capabilities.get("checked"):
|
||||
if capabilities.get("has_topics_enabled") is False:
|
||||
await self._send_telegram_topic_setup_image(source)
|
||||
return (
|
||||
"Telegram topics are not enabled for this bot yet.\n\n"
|
||||
"How to enable them:\n"
|
||||
"1. Open @BotFather.\n"
|
||||
"2. Choose your bot.\n"
|
||||
"3. Open Bot Settings → Threads Settings.\n"
|
||||
"4. Turn on Threaded Mode and make sure users are allowed to create new threads.\n\n"
|
||||
"Then send /topic again."
|
||||
)
|
||||
if capabilities.get("allows_users_to_create_topics") is False:
|
||||
await self._send_telegram_topic_setup_image(source)
|
||||
return (
|
||||
"Telegram topics are enabled, but users are not allowed to create topics.\n\n"
|
||||
"Open @BotFather → choose your bot → Bot Settings → Threads Settings, "
|
||||
"then turn off 'Disallow users to create new threads'.\n\n"
|
||||
"Then send /topic again."
|
||||
)
|
||||
|
||||
try:
|
||||
self._session_db.enable_telegram_topic_mode(
|
||||
chat_id=str(source.chat_id),
|
||||
user_id=str(source.user_id),
|
||||
has_topics_enabled=capabilities.get("has_topics_enabled"),
|
||||
allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to enable Telegram topic mode")
|
||||
return f"Failed to enable Telegram topic mode: {exc}"
|
||||
|
||||
if not source.thread_id:
|
||||
await self._ensure_telegram_system_topic(source)
|
||||
|
||||
if source.thread_id:
|
||||
try:
|
||||
binding = self._session_db.get_telegram_topic_binding(
|
||||
|
|
@ -9617,13 +9838,14 @@ class GatewayRunner:
|
|||
f"Session: {session_label}\n"
|
||||
f"ID: {session_id}\n\n"
|
||||
"Use /new to replace this topic with a fresh session.\n"
|
||||
"For parallel work, create another topic with the + button."
|
||||
"For parallel work, open All Messages and send a message there "
|
||||
"to create another topic."
|
||||
)
|
||||
return (
|
||||
"Telegram multi-session topics are enabled.\n\n"
|
||||
"This topic will be used as an independent Hermes session. "
|
||||
"Use /new to replace this topic's current session. For parallel "
|
||||
"work, create another topic with the + button."
|
||||
"work, open All Messages and send a message there to create another topic."
|
||||
)
|
||||
|
||||
return self._telegram_topic_root_status_message(source)
|
||||
|
|
@ -9632,7 +9854,9 @@ class GatewayRunner:
|
|||
lines = [
|
||||
"Telegram multi-session topics are enabled.",
|
||||
"",
|
||||
"Create new Hermes chats with the + button in this bot interface.",
|
||||
"To create a new Hermes chat, open All Messages at the top of this "
|
||||
"bot interface and send any message there. Telegram will create a "
|
||||
"new topic for it.",
|
||||
"",
|
||||
]
|
||||
try:
|
||||
|
|
@ -9658,7 +9882,7 @@ class GatewayRunner:
|
|||
lines.extend([
|
||||
"",
|
||||
"To restore one:",
|
||||
"1. Create or open a topic with the + button.",
|
||||
"1. Create or open a topic. To create a new one, open All Messages and send any message there.",
|
||||
"2. Send /topic <session-id> inside that topic.",
|
||||
f"Example: Send /topic {sessions[0].get('id')} inside a topic.",
|
||||
])
|
||||
|
|
@ -9667,9 +9891,8 @@ class GatewayRunner:
|
|||
"No previous unlinked Telegram sessions found.",
|
||||
"",
|
||||
"To restore a previous session later:",
|
||||
"1. Create a new topic with the + button.",
|
||||
"2. Open that topic.",
|
||||
"3. Send /topic <session-id>.",
|
||||
"1. Create or open a topic. To create a new one, open All Messages and send any message there.",
|
||||
"2. Send /topic <session-id> inside that topic.",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
@ -13549,20 +13772,29 @@ class GatewayRunner:
|
|||
_title_failure_cb = getattr(
|
||||
agent, "_emit_auxiliary_failure", None
|
||||
)
|
||||
maybe_auto_title(
|
||||
self._session_db,
|
||||
effective_session_id,
|
||||
message,
|
||||
final_response,
|
||||
all_msgs,
|
||||
failure_callback=_title_failure_cb,
|
||||
main_runtime={
|
||||
maybe_auto_title_kwargs = {
|
||||
"failure_callback": _title_failure_cb,
|
||||
"main_runtime": {
|
||||
"model": getattr(agent, "model", None),
|
||||
"provider": getattr(agent, "provider", None),
|
||||
"base_url": getattr(agent, "base_url", None),
|
||||
"api_key": getattr(agent, "api_key", None),
|
||||
"api_mode": getattr(agent, "api_mode", None),
|
||||
} if agent else None,
|
||||
}
|
||||
if self._is_telegram_topic_lane(source):
|
||||
maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_telegram_topic_title_rename(
|
||||
source,
|
||||
effective_session_id,
|
||||
title,
|
||||
)
|
||||
maybe_auto_title(
|
||||
self._session_db,
|
||||
effective_session_id,
|
||||
message,
|
||||
final_response,
|
||||
all_msgs,
|
||||
**maybe_auto_title_kwargs,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
|
|||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
gateway = ["assets/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
|
|
|
|||
|
|
@ -136,6 +136,21 @@ class TestAutoTitleSession:
|
|||
auto_title_session(db, "sess-1", "hi", "hello")
|
||||
db.set_session_title.assert_called_once_with("sess-1", "New Title")
|
||||
|
||||
def test_invokes_title_callback_after_setting_title(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
seen = []
|
||||
with patch("agent.title_generator.generate_title", return_value="Readable Session"):
|
||||
auto_title_session(
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
title_callback=seen.append,
|
||||
)
|
||||
db.set_session_title.assert_called_once_with("sess-1", "Readable Session")
|
||||
assert seen == ["Readable Session"]
|
||||
|
||||
def test_skips_if_generation_fails(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
|
|
@ -182,7 +197,13 @@ class TestMaybeAutoTitle:
|
|||
import time
|
||||
time.sleep(0.3)
|
||||
mock_auto.assert_called_once_with(
|
||||
db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
failure_callback=None,
|
||||
main_runtime=None,
|
||||
title_callback=None,
|
||||
)
|
||||
|
||||
def test_forwards_failure_callback_to_worker(self):
|
||||
|
|
@ -202,7 +223,13 @@ class TestMaybeAutoTitle:
|
|||
import time
|
||||
time.sleep(0.3)
|
||||
mock_auto.assert_called_once_with(
|
||||
db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
failure_callback=_cb,
|
||||
main_runtime=None,
|
||||
title_callback=None,
|
||||
)
|
||||
|
||||
def test_skips_if_no_response(self):
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ def _make_runner(session_db=None):
|
|||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
adapter.send_image_file = AsyncMock()
|
||||
adapter._bot = None
|
||||
adapter._create_dm_topic = AsyncMock(return_value=None)
|
||||
adapter.rename_dm_topic = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(
|
||||
|
|
@ -150,7 +154,7 @@ async def test_root_telegram_dm_prompt_is_system_lobby_when_topic_mode_enabled(m
|
|||
result = await runner._handle_message(_make_event("hello from root"))
|
||||
|
||||
assert "main chat is reserved for system commands" in result
|
||||
assert "+ button" in result
|
||||
assert "All Messages" in result
|
||||
runner._run_agent.assert_not_called()
|
||||
runner.session_store.get_or_create_session.assert_not_called()
|
||||
|
||||
|
|
@ -172,8 +176,8 @@ async def test_root_telegram_dm_new_shows_create_topic_instruction(monkeypatch):
|
|||
result = await runner._handle_message(_make_event("/new"))
|
||||
|
||||
assert "create a new topic" in result
|
||||
assert "+ button" in result
|
||||
assert "Use /new inside a topic" in result
|
||||
assert "All Messages" in result
|
||||
assert "Use /new inside" in result
|
||||
runner._run_agent.assert_not_called()
|
||||
runner.session_store.reset_session.assert_not_called()
|
||||
runner.session_store.get_or_create_session.assert_not_called()
|
||||
|
|
@ -357,8 +361,8 @@ async def test_new_inside_telegram_topic_resets_current_topic_with_parallel_tip(
|
|||
result = await runner._handle_message(_make_event("/new", thread_id="17585"))
|
||||
|
||||
assert "Started a new Hermes session in this topic" in result
|
||||
assert "for parallel work" in result
|
||||
assert "+ button" in result
|
||||
assert "parallel work" in result
|
||||
assert "All Messages" in result
|
||||
runner.session_store.reset_session.assert_called_once_with(topic_key)
|
||||
|
||||
|
||||
|
|
@ -379,7 +383,7 @@ async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp
|
|||
result = await runner._handle_message(_make_event("/topic"))
|
||||
|
||||
assert "Telegram multi-session topics are enabled" in result
|
||||
assert "+ button" in result
|
||||
assert "All Messages" in result
|
||||
assert session_db.get_meta("telegram_dm_topic_schema_version") == "1"
|
||||
assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988")
|
||||
assert runner._telegram_topic_mode_enabled(_make_source()) is True
|
||||
|
|
@ -462,7 +466,7 @@ async def test_topic_root_command_handles_no_unlinked_sessions(tmp_path, monkeyp
|
|||
|
||||
assert "Telegram multi-session topics are enabled" in result
|
||||
assert "No previous unlinked Telegram sessions found" in result
|
||||
assert "+ button" in result
|
||||
assert "All Messages" in result
|
||||
runner._run_agent.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -623,3 +627,124 @@ async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkey
|
|||
assert binding["user_id"] == "208214988"
|
||||
assert binding["session_id"] == "sess-topic"
|
||||
assert binding["session_key"] == build_session_key(_make_source(thread_id="17585"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topic_root_command_checks_getme_capabilities_before_enabling(tmp_path, monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
runner = _make_runner(session_db=session_db)
|
||||
bot = AsyncMock()
|
||||
bot.get_me.return_value = SimpleNamespace(
|
||||
has_topics_enabled=False,
|
||||
allows_users_to_create_topics=True,
|
||||
)
|
||||
runner.adapters[Platform.TELEGRAM]._bot = bot
|
||||
runner._run_agent = AsyncMock(
|
||||
side_effect=AssertionError("/topic capability failure must not enter the agent loop")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/topic"))
|
||||
|
||||
assert "topics are not enabled" in result
|
||||
assert "Open @BotFather" in result
|
||||
assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") is False
|
||||
bot.get_me.assert_awaited_once()
|
||||
runner.adapters[Platform.TELEGRAM].send_image_file.assert_awaited_once()
|
||||
image_kwargs = runner.adapters[Platform.TELEGRAM].send_image_file.await_args.kwargs
|
||||
assert image_kwargs["chat_id"] == "208214988"
|
||||
assert image_kwargs["image_path"].endswith("telegram-botfather-threads-settings.jpg")
|
||||
runner._run_agent.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topic_root_command_creates_and_pins_system_topic(tmp_path, monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
runner = _make_runner(session_db=session_db)
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
adapter._create_dm_topic.return_value = 4242
|
||||
adapter.send.return_value = SimpleNamespace(success=True, message_id="777")
|
||||
bot = AsyncMock()
|
||||
bot.get_me.return_value = {
|
||||
"has_topics_enabled": True,
|
||||
"allows_users_to_create_topics": True,
|
||||
}
|
||||
adapter._bot = bot
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/topic"))
|
||||
|
||||
assert "Telegram multi-session topics are enabled" in result
|
||||
adapter._create_dm_topic.assert_awaited_once_with(208214988, "System")
|
||||
adapter.send.assert_awaited_once_with(
|
||||
"208214988",
|
||||
"System topic for Hermes commands and status.",
|
||||
metadata={"thread_id": "4242"},
|
||||
)
|
||||
bot.pin_chat_message.assert_awaited_once_with(
|
||||
chat_id=208214988,
|
||||
message_id=777,
|
||||
disable_notification=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_generated_title_renames_bound_telegram_topic(tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.apply_telegram_topic_migration()
|
||||
db.create_session("sess-topic", source="telegram", user_id="208214988")
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="42",
|
||||
user_id="208214988",
|
||||
session_key="agent:main:telegram:dm:208214988:42",
|
||||
session_id="sess-topic",
|
||||
)
|
||||
runner = _make_runner(session_db=db)
|
||||
runner._telegram_topic_mode_enabled = lambda source: True
|
||||
|
||||
await runner._rename_telegram_topic_for_session_title(
|
||||
_make_source(thread_id="42"),
|
||||
"sess-topic",
|
||||
" Build Telegram Topic UX ",
|
||||
)
|
||||
|
||||
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_awaited_once_with(
|
||||
chat_id="208214988",
|
||||
thread_id="42",
|
||||
name="Build Telegram Topic UX",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session(tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.apply_telegram_topic_migration()
|
||||
db.create_session("sess-other", source="telegram", user_id="208214988")
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="42",
|
||||
user_id="208214988",
|
||||
session_key="agent:main:telegram:dm:208214988:42",
|
||||
session_id="sess-other",
|
||||
)
|
||||
runner = _make_runner(session_db=db)
|
||||
runner._telegram_topic_mode_enabled = lambda source: True
|
||||
|
||||
await runner._rename_telegram_topic_for_session_title(
|
||||
_make_source(thread_id="42"),
|
||||
"sess-topic",
|
||||
"Wrong Session Title",
|
||||
)
|
||||
|
||||
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue