refactor(gateway): run_topics collapses boolean ladders, shares title sanitizer, uses is_truthy_value
This commit is contained in:
+68
-112
@@ -20,6 +20,7 @@ from agent.i18n import t
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, _prefix_within_utf16_limit, utf16_len
|
||||
from gateway.session import SessionSource
|
||||
from utils import is_truthy_value
|
||||
|
||||
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
|
||||
from gateway.run import GatewayRunner, TurnRunner # noqa: F401
|
||||
@@ -33,6 +34,10 @@ _TOPIC_RESTORE_STEPS = (
|
||||
)
|
||||
|
||||
|
||||
def _collapse_title(title: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(title or "")).strip() or "Hermes Chat"
|
||||
|
||||
|
||||
class GatewayTopicThreadsMixin:
|
||||
"""Telegram forum-topic and Discord auto-thread binding/rename methods for GatewayRunner."""
|
||||
|
||||
@@ -45,8 +50,7 @@ class GatewayTopicThreadsMixin:
|
||||
Use the profile stamped on the routed event (``source.profile``), never the process-global
|
||||
active profile — under multiplex that mis-attributes topic state across bots sharing state.db.
|
||||
"""
|
||||
name = str(getattr(source, "profile", None) or "").strip()
|
||||
return name if name else "default"
|
||||
return str(getattr(source, "profile", None) or "").strip() or "default"
|
||||
|
||||
def _sync_session_db(self):
|
||||
"""The sync SessionDB handle, or None. Only for callers that provably run off-loop
|
||||
@@ -60,9 +64,7 @@ class GatewayTopicThreadsMixin:
|
||||
|
||||
def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool:
|
||||
"""Return whether Telegram DM topic mode is active for this chat."""
|
||||
if not self._is_telegram_dm(source):
|
||||
return False
|
||||
session_db = self._sync_session_db()
|
||||
session_db = self._sync_session_db() if self._is_telegram_dm(source) else None
|
||||
if session_db is None:
|
||||
return False
|
||||
try:
|
||||
@@ -79,24 +81,24 @@ class GatewayTopicThreadsMixin:
|
||||
|
||||
def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool:
|
||||
"""True for the main Telegram DM (or General topic) when topic mode has made it a lobby."""
|
||||
if not self._is_telegram_dm(source) or not self._telegram_topic_mode_enabled(source):
|
||||
return False
|
||||
return str(source.thread_id or "") in self._TELEGRAM_GENERAL_TOPIC_IDS
|
||||
return (
|
||||
self._is_telegram_dm(source) and self._telegram_topic_mode_enabled(source)
|
||||
and str(source.thread_id or "") in self._TELEGRAM_GENERAL_TOPIC_IDS
|
||||
)
|
||||
|
||||
def _is_telegram_topic_lane(self, source: SessionSource) -> bool:
|
||||
"""True for a user-created Telegram private-chat topic lane."""
|
||||
if not self._is_telegram_dm(source) or not self._telegram_topic_mode_enabled(source):
|
||||
return False
|
||||
tid = str(source.thread_id or "")
|
||||
return bool(tid) and tid not in self._TELEGRAM_GENERAL_TOPIC_IDS
|
||||
return (
|
||||
self._is_telegram_dm(source) and self._telegram_topic_mode_enabled(source)
|
||||
and bool(tid) and tid not in self._TELEGRAM_GENERAL_TOPIC_IDS
|
||||
)
|
||||
|
||||
def _telegram_topic_cooldown_key(self, source: SessionSource) -> Optional[str]:
|
||||
"""Cooldown key (profile, chat_id): profiles sharing a Telegram private chat_id under
|
||||
multiplex must not suppress each other's lobby reminders / capability hints."""
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
return None
|
||||
return f"{self._telegram_topic_profile_name(source)}:{chat_id}"
|
||||
return f"{self._telegram_topic_profile_name(source)}:{chat_id}" if chat_id else None
|
||||
|
||||
def _telegram_cooldown_elapsed(self, source: SessionSource, attr: str, cooldown_s: float) -> bool:
|
||||
"""Per-(profile, chat) debounce: True (and stamp now) when the window has elapsed."""
|
||||
@@ -146,14 +148,12 @@ class GatewayTopicThreadsMixin:
|
||||
)
|
||||
|
||||
def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]:
|
||||
if not self._is_telegram_topic_lane(source):
|
||||
return None
|
||||
return (
|
||||
"Started a new Hermes session in this topic.\n\n"
|
||||
"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."
|
||||
)
|
||||
) if self._is_telegram_topic_lane(source) else None
|
||||
|
||||
def _telegram_topic_help_text(self) -> str:
|
||||
return (
|
||||
@@ -192,12 +192,8 @@ class GatewayTopicThreadsMixin:
|
||||
)
|
||||
|
||||
def _sync_telegram_topic_binding(self, source: SessionSource, session_entry, *, reason: str) -> None:
|
||||
"""Update the topic binding to point at ``session_entry.session_id``.
|
||||
|
||||
Topic lanes persist (chat_id, thread_id) -> session_id so reopening a topic resumes the
|
||||
right session. When compression rotates the id mid-turn a stale binding reloads the
|
||||
oversized parent next message, retriggering preflight compression — sometimes in a loop.
|
||||
"""
|
||||
"""Update the topic binding to ``session_entry.session_id``: a stale binding after a mid-turn
|
||||
compression rotation reloads the oversized parent next message, retriggering compression."""
|
||||
if not self._is_telegram_topic_lane(source):
|
||||
return
|
||||
try:
|
||||
@@ -206,18 +202,11 @@ class GatewayTopicThreadsMixin:
|
||||
logger.debug("telegram topic binding refresh failed (%s)", reason, exc_info=True)
|
||||
|
||||
def _recover_telegram_topic_thread_id(self, source: SessionSource) -> Optional[str]:
|
||||
"""Pin DM-topic routing to the user's last-active topic.
|
||||
|
||||
Telegram can omit ``message_thread_id`` or surface General (``1``) for topic-mode DM
|
||||
replies; in those lobby-shaped cases keep the conversation on the user's most-recent bound
|
||||
topic. Do not rewrite a non-lobby, previously-unbound thread id: a brand-new DM topic is
|
||||
also "unknown" until its first inbound message is recorded, and rewriting would send its
|
||||
answer into an older lane. Returns None to leave the source alone.
|
||||
"""
|
||||
"""Pin lobby-shaped topic-mode DM replies (missing ``message_thread_id`` or General) to the
|
||||
user's most-recent bound topic. Never rewrite a non-lobby, unbound thread id: a brand-new DM
|
||||
topic is also "unknown" until its first message is recorded. None = leave the source alone."""
|
||||
if (
|
||||
not self._is_telegram_dm(source)
|
||||
or not source.chat_id
|
||||
or not source.user_id
|
||||
not self._is_telegram_dm(source) or not source.chat_id or not source.user_id
|
||||
or not self._telegram_topic_mode_enabled(source)
|
||||
):
|
||||
return None
|
||||
@@ -259,7 +248,7 @@ class GatewayTopicThreadsMixin:
|
||||
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)
|
||||
return api_kwargs[name]
|
||||
return me.get(name) if isinstance(me, dict) else None
|
||||
|
||||
return {
|
||||
@@ -324,22 +313,14 @@ class GatewayTopicThreadsMixin:
|
||||
|
||||
def _sanitize_telegram_topic_title(self, title: str) -> str:
|
||||
"""Bot API-safe forum topic name: names are 1-128 chars; keep room for multi-byte titles."""
|
||||
cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
|
||||
if not cleaned:
|
||||
return "Hermes Chat"
|
||||
if len(cleaned) > 120:
|
||||
cleaned = cleaned[:117].rstrip() + "..."
|
||||
return cleaned
|
||||
cleaned = _collapse_title(title)
|
||||
return cleaned if len(cleaned) <= 120 else cleaned[:117].rstrip() + "..."
|
||||
|
||||
def _sanitize_discord_thread_title(self, title: str) -> str:
|
||||
"""Discord-safe thread title: the 100-char cap is measured in UTF-16 code units (emoji count
|
||||
double), so truncate with the UTF-16 helpers rather than Python code-point slices."""
|
||||
cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
|
||||
if not cleaned:
|
||||
return "Hermes Chat"
|
||||
if utf16_len(cleaned) > 80:
|
||||
cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..."
|
||||
return cleaned
|
||||
cleaned = _collapse_title(title)
|
||||
return cleaned if utf16_len(cleaned) <= 80 else _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..."
|
||||
|
||||
# ── Discord auto-thread lanes ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -368,21 +349,20 @@ class GatewayTopicThreadsMixin:
|
||||
)
|
||||
|
||||
def _relay_auto_thread_info(self, source: SessionSource) -> Optional[Tuple[str, str]]:
|
||||
"""(thread_id, initial_name) when the RELAY connector auto-threaded our reply to this
|
||||
source's chat — the title-turn sibling of _is_discord_auto_thread_lane.
|
||||
"""(thread_id, initial_name) when the RELAY connector auto-threaded our reply — the title-turn
|
||||
sibling of _is_discord_auto_thread_lane (whose markers only exist from turn 2 on; the title
|
||||
turn's source is the PARENT channel event).
|
||||
|
||||
The marker check only matches events ARRIVING IN an auto-created thread (turn 2+); the
|
||||
auto-title fires on the FIRST exchange, whose source is the PARENT channel event with no
|
||||
markers. Preferred: the connector's ``prospective_thread_id`` stamp (anchor message id ==
|
||||
the thread it will create) — per-message, so it names the EXACT thread even when several
|
||||
auto-threads spawn from one channel; the connector's created-name guard enforces
|
||||
no-clobber. Fallback: the per-chat send-result thread_id/auto_thread_name cache (older
|
||||
connectors), which only ever renamed the FIRST thread.
|
||||
Preferred: the connector's per-message ``prospective_thread_id`` stamp (anchor message id ==
|
||||
the thread it will create), exact even when several auto-threads spawn from one channel;
|
||||
the connector's created-name guard enforces no-clobber. Fallback: the per-chat send-result
|
||||
cache (older connectors), which only ever renamed the FIRST thread.
|
||||
"""
|
||||
from gateway.run import _as_thread_info
|
||||
if source.platform != Platform.DISCORD or not source.chat_id:
|
||||
return None
|
||||
if not getattr(source, "delivered_via_upstream_relay", False):
|
||||
if (
|
||||
source.platform != Platform.DISCORD or not source.chat_id
|
||||
or not getattr(source, "delivered_via_upstream_relay", False)
|
||||
):
|
||||
return None
|
||||
prospective = getattr(source, "prospective_thread_id", None)
|
||||
if prospective:
|
||||
@@ -398,13 +378,9 @@ class GatewayTopicThreadsMixin:
|
||||
return None
|
||||
|
||||
async def _await_relay_auto_thread_info(self, source: SessionSource) -> Optional[Tuple[str, str]]:
|
||||
"""``_relay_auto_thread_info``, waited out until this turn delivers.
|
||||
|
||||
The legacy send-result path can only answer once the reply is sent, and the caller asks
|
||||
at title time — one turn early. The adapter answers on the send either way, so the
|
||||
timeout is only a backstop for a turn that never sends at all; the turn's own inactivity
|
||||
limit is exactly how long that turn could still be alive.
|
||||
"""
|
||||
"""``_relay_auto_thread_info``, waited out until this turn delivers (the legacy send-result
|
||||
path can only answer once the reply is sent; the caller asks at title time, one turn early).
|
||||
The timeout is only a backstop for a turn that never sends: the turn's own inactivity limit."""
|
||||
from gateway.run import _as_thread_info, _float_env
|
||||
# The connector-stamped prospective id is known at ingest, so most sessions answer here.
|
||||
known = self._relay_auto_thread_info(source)
|
||||
@@ -424,21 +400,17 @@ class GatewayTopicThreadsMixin:
|
||||
self, source: SessionSource, session_id: str, title: str,
|
||||
relay_info: Optional[Tuple[str, str]] = None,
|
||||
) -> None:
|
||||
"""Best-effort semantic rename of a newly auto-created Discord thread.
|
||||
|
||||
``relay_info`` is the (thread_id, initial_name) pair from the relay connector's send-
|
||||
result feedback — supplied on the title turn, where the source is the parent-channel
|
||||
event and carries no auto-thread markers (see _relay_auto_thread_info).
|
||||
"""
|
||||
"""Best-effort semantic rename of a newly auto-created Discord thread. ``relay_info`` is the
|
||||
connector's (thread_id, initial_name) feedback, supplied on the title turn where the source
|
||||
is the parent-channel event without auto-thread markers (see _relay_auto_thread_info)."""
|
||||
if relay_info is None and not await asyncio.to_thread(self._is_discord_auto_thread_lane, source):
|
||||
# Relay title turn with no feedback captured at schedule time: the title comes off the
|
||||
# user's opening message, so it beats the delivery that produces the connector's
|
||||
# send-result feedback by the whole length of the turn.
|
||||
# send-result feedback by the whole length of the turn. None here = a true miss.
|
||||
if not self._is_relay_discord_channel_lane(source):
|
||||
return
|
||||
relay_info = await self._await_relay_auto_thread_info(source)
|
||||
if relay_info is None:
|
||||
# True miss: the connector did not auto-thread this reply.
|
||||
return
|
||||
adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None
|
||||
rename_thread = getattr(adapter, "rename_thread", None)
|
||||
@@ -466,14 +438,10 @@ class GatewayTopicThreadsMixin:
|
||||
)
|
||||
try:
|
||||
renamed = await rename_thread(target_thread_id, thread_name, **rename_kwargs)
|
||||
logger.info(
|
||||
"discord auto-thread rename result: thread=%s applied=%s",
|
||||
target_thread_id, bool(renamed),
|
||||
)
|
||||
logger.info("discord auto-thread rename result: thread=%s applied=%s", target_thread_id, bool(renamed))
|
||||
except TypeError:
|
||||
logger.warning(
|
||||
"Discord semantic thread rename raised TypeError (adapter=%s)",
|
||||
type(adapter).__name__, exc_info=True,
|
||||
"Discord semantic thread rename raised TypeError (adapter=%s)", type(adapter).__name__, exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True)
|
||||
@@ -499,8 +467,6 @@ class GatewayTopicThreadsMixin:
|
||||
future = safe_schedule_threadsafe(
|
||||
make_coro(copied_source), loop, logger=logger, log_message=f"{label} failed to schedule",
|
||||
)
|
||||
if future is None:
|
||||
return
|
||||
|
||||
def _log_rename_failure(fut) -> None:
|
||||
try:
|
||||
@@ -508,13 +474,14 @@ class GatewayTopicThreadsMixin:
|
||||
except Exception:
|
||||
logger.debug("%s failed", label, exc_info=True)
|
||||
|
||||
future.add_done_callback(_log_rename_failure)
|
||||
if future is not None:
|
||||
future.add_done_callback(_log_rename_failure)
|
||||
|
||||
def _schedule_discord_semantic_thread_rename(self, source: SessionSource, session_id: str, title: str) -> None:
|
||||
"""Schedule Discord auto-thread rename from the auto-title background thread."""
|
||||
relay_info = None
|
||||
if not title:
|
||||
return
|
||||
relay_info = None
|
||||
if not self._is_discord_auto_thread_lane(source):
|
||||
# Relay title turn: the source is the PARENT channel event (thread didn't exist at
|
||||
# ingest). The auto-title races the delivery that fills the send-result cache, so a
|
||||
@@ -533,9 +500,7 @@ class GatewayTopicThreadsMixin:
|
||||
|
||||
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
|
||||
if self._telegram_topic_auto_rename_disabled(source):
|
||||
if not title or not self._is_telegram_topic_lane(source) or self._telegram_topic_auto_rename_disabled(source):
|
||||
return
|
||||
self._schedule_rename_from_title_thread(
|
||||
source,
|
||||
@@ -549,12 +514,7 @@ class GatewayTopicThreadsMixin:
|
||||
platform_cfg = config.platforms.get(source.platform) if config and getattr(config, "platforms", None) else None
|
||||
if platform_cfg is None:
|
||||
return False
|
||||
value = (getattr(platform_cfg, "extra", None) or {}).get("disable_topic_auto_rename")
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
return is_truthy_value((getattr(platform_cfg, "extra", None) or {}).get("disable_topic_auto_rename"))
|
||||
|
||||
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."""
|
||||
@@ -566,18 +526,17 @@ class GatewayTopicThreadsMixin:
|
||||
return
|
||||
# Skip operator-declared topics (extra.dm_topics): fixed names chosen by the operator;
|
||||
# auto-renaming would silently mutate operator config. Check the class, not the instance —
|
||||
# getattr() on a MagicMock auto-creates attributes, so every test double would match.
|
||||
# getattr() on a MagicMock auto-creates attributes, so every test double would match. Only
|
||||
# dict-shaped returns count; a bare MagicMock or other sentinel shouldn't.
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter is not None:
|
||||
get_info = getattr(type(adapter), "_get_dm_topic_info", None)
|
||||
if callable(get_info):
|
||||
try:
|
||||
operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id))
|
||||
except Exception:
|
||||
operator_topic = None
|
||||
# Only dict-shaped returns count; a bare MagicMock or other sentinel shouldn't.
|
||||
if isinstance(operator_topic, dict):
|
||||
return
|
||||
get_info = getattr(type(adapter), "_get_dm_topic_info", None) if adapter is not None else None
|
||||
if callable(get_info):
|
||||
try:
|
||||
operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id))
|
||||
except Exception:
|
||||
operator_topic = None
|
||||
if isinstance(operator_topic, dict):
|
||||
return
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
if session_db is not None:
|
||||
try:
|
||||
@@ -639,11 +598,10 @@ class GatewayTopicThreadsMixin:
|
||||
return f"Failed to disable topic mode: {exc}"
|
||||
# Reset per-profile+chat debounce state so the next activation doesn't see a stale cooldown.
|
||||
cooldown_key = self._telegram_topic_cooldown_key(source)
|
||||
if cooldown_key:
|
||||
for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"):
|
||||
store = getattr(self, attr, None)
|
||||
if isinstance(store, dict):
|
||||
store.pop(cooldown_key, None)
|
||||
for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts") if cooldown_key else ():
|
||||
store = getattr(self, attr, None)
|
||||
if isinstance(store, dict):
|
||||
store.pop(cooldown_key, None)
|
||||
return (
|
||||
"Multi-session topic mode is now OFF for this chat.\n\n"
|
||||
"Existing topics in Telegram aren't removed — they'll just stop "
|
||||
@@ -732,6 +690,4 @@ class GatewayTopicThreadsMixin:
|
||||
except Exception:
|
||||
last_assistant = None
|
||||
response = f"Session restored: {title}"
|
||||
if last_assistant:
|
||||
response += f"\n\nLast Hermes message:\n{last_assistant}"
|
||||
return response
|
||||
return response + (f"\n\nLast Hermes message:\n{last_assistant}" if last_assistant else "")
|
||||
|
||||
Reference in New Issue
Block a user