Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,25 @@ async def create_handoff_thread(
"""
return None

async def rename_conversation(
self,
source: Any,
name: str,
*,
session_id: Optional[str] = None,
session_db: Any = None,
) -> bool:
"""Rename the platform-visible conversation container for ``source``.

This is the title-sync counterpart to ``create_handoff_thread``: when
Hermes names a session, thread/topic-capable adapters can reflect that
title in the visible Discord thread, Telegram topic, etc.

Default implementation returns ``False`` — adapters that can safely
rename the active conversation override this.
"""
return False


async def edit_message(
self,
Expand Down
150 changes: 26 additions & 124 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -11276,135 +11276,37 @@ async def _send_telegram_topic_setup_image(self, source: SessionSource) -> 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(
async def _rename_visible_conversation_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

# Operator can fully disable per-topic auto-rename via
# extra.disable_topic_auto_rename. Useful when topics are managed
# by the user (ad-hoc Threaded Mode) and auto-rename would
# overwrite their chosen names every time the auto-title fires.
if self._telegram_topic_auto_rename_disabled(source):
return

# Skip rename when the topic is operator-declared via
# extra.dm_topics. Those topics have fixed names chosen by the
# operator (plus optional skill binding); auto-renaming would
# silently mutate operator config.
#
# Check the class, not the instance — getattr() on MagicMock
# auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")`
# would return True for every test double.
"""Best-effort sync of a session title to the platform-visible thread/topic."""
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
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 treat dict-shaped returns as operator-declared; a
# bare MagicMock or other sentinel shouldn't count.
if isinstance(operator_topic, dict):
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

if adapter is None:
return
topic_name = self._sanitize_telegram_topic_title(title)
rename_conversation = getattr(adapter, "rename_conversation", None)
if rename_conversation is None:
return
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,
)
await rename_conversation(
source,
title,
session_id=session_id,
session_db=getattr(self, "_session_db", None),
)
except Exception:
logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True)

def _telegram_topic_auto_rename_disabled(self, source: SessionSource) -> bool:
"""Return True when operator disabled per-topic auto-rename for this Telegram chat.
logger.debug("Failed to rename visible conversation for session title", exc_info=True)

Controlled via ``gateway.platforms.telegram.extra.disable_topic_auto_rename``.
Default is False (auto-rename enabled, preserves prior behaviour).
"""
platform_cfg = (
self.config.platforms.get(source.platform)
if getattr(self, "config", None) and getattr(self.config, "platforms", None)
else None
)
if platform_cfg is None:
return False
extra = getattr(platform_cfg, "extra", None) or {}
value = extra.get("disable_topic_auto_rename")
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)

def _schedule_telegram_topic_title_rename(
def _schedule_visible_conversation_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):
"""Schedule a platform-visible title sync from the auto-title background thread."""
if not title:
return
try:
loop = asyncio.get_running_loop()
Expand All @@ -11417,18 +11319,19 @@ def _schedule_telegram_topic_title_rename(
except Exception:
copied_source = source
future = safe_schedule_threadsafe(
self._rename_telegram_topic_for_session_title(copied_source, session_id, title),
self._rename_visible_conversation_for_session_title(copied_source, session_id, title),
loop,
logger=logger,
log_message="Telegram topic title rename failed to schedule",
log_message="Visible conversation title rename failed to schedule",
)
if future is None:
return

def _log_rename_failure(fut) -> None:
try:
fut.result()
except Exception:
logger.debug("Telegram topic title rename failed", exc_info=True)
logger.debug("Visible conversation title rename failed", exc_info=True)

future.add_done_callback(_log_rename_failure)

Expand Down Expand Up @@ -11977,8 +11880,8 @@ def _is_telegram_dm_topic_target(
# auto-creates a callable child for any attribute, so an instance-level
# lookup would report a DM topic for every test double. Only a
# dict-shaped return counts as an operator-declared topic — a bare
# MagicMock or other sentinel must not. Mirrors the guard in
# _rename_telegram_topic_for_session_title.
# MagicMock or other sentinel must not. Mirrors the guard in the
# Telegram adapter's visible conversation rename capability.
if adapter is not None and chat_id:
get_dm_topic_info = getattr(type(adapter), "_get_dm_topic_info", None)
if callable(get_dm_topic_info):
Expand Down Expand Up @@ -15995,12 +15898,11 @@ def _title_failure_cb(task: str, exc: BaseException) -> 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_kwargs["title_callback"] = lambda title: self._schedule_visible_conversation_title_rename(
source,
effective_session_id,
title,
)
maybe_auto_title(
self._session_db,
effective_session_id,
Expand Down
4 changes: 2 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2853,14 +2853,14 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
# and the topic kept its auto-assigned name. No-ops off
# Telegram topic lanes and when auto-rename is disabled.
schedule_rename = getattr(
self, "_schedule_telegram_topic_title_rename", None
self, "_schedule_visible_conversation_title_rename", None
)
if callable(schedule_rename):
try:
schedule_rename(source, session_id, sanitized)
except Exception:
logger.debug(
"Failed to rename Telegram topic from /title",
"Failed to rename visible conversation from /title",
exc_info=True,
)
return t("gateway.title.set_to", title=sanitized)
Expand Down
51 changes: 51 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4558,6 +4558,57 @@ async def _create_thread(
# Auto-thread helpers
# ------------------------------------------------------------------

def _sanitize_conversation_title(self, name: str) -> str:
cleaned = re.sub(r"\s+", " ", str(name or "")).strip()
if not cleaned:
return "Hermes Chat"
if len(cleaned) > 100:
cleaned = cleaned[:97].rstrip() + "..."
return cleaned

async def rename_conversation(
self,
source: Any,
name: str,
*,
session_id: Optional[str] = None,
session_db: Any = None,
) -> bool:
"""Rename the Discord thread/channel backing the current session."""
if getattr(source, "chat_type", None) != "thread":
return False
thread_id = str(getattr(source, "thread_id", None) or getattr(source, "chat_id", "") or "")
if not thread_id:
return False
return await self.rename_thread(thread_id, self._sanitize_conversation_title(name))

async def rename_thread(self, thread_id: str, name: str) -> bool:
"""Best-effort rename for an existing Discord thread/channel."""
if not self._client or not DISCORD_AVAILABLE:
return False
try:
tid = int(thread_id)
except (TypeError, ValueError):
return False

try:
thread = self._client.get_channel(tid)
if thread is None:
thread = await self._client.fetch_channel(tid)
except Exception as exc:
logger.debug("[%s] Could not resolve Discord thread %s for rename: %s", self.name, thread_id, exc)
return False

edit = getattr(thread, "edit", None)
if edit is None:
return False
try:
await edit(name=name, reason="Hermes session auto-title")
return True
except Exception as exc:
logger.debug("[%s] Failed to rename Discord thread %s: %s", self.name, thread_id, exc)
return False

async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
"""Create a thread from a user message for auto-threading.

Expand Down
62 changes: 62 additions & 0 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,68 @@ async def ensure_dm_topic(self, chat_id: str, topic_name: str, force_create: boo
self._persist_dm_topic_thread_id(chat_id_int, name, int(thread_id), replace_existing=force_create)
return str(thread_id)

def _sanitize_conversation_title(self, name: str) -> str:
cleaned = re.sub(r"\s+", " ", str(name or "")).strip()
if not cleaned:
return "Hermes Chat"
if len(cleaned) > 120:
cleaned = cleaned[:117].rstrip() + "..."
return cleaned

def _topic_auto_rename_disabled(self) -> bool:
value = (getattr(self.config, "extra", None) or {}).get("disable_topic_auto_rename")
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)

async def rename_conversation(
self,
source: Any,
name: str,
*,
session_id: Optional[str] = None,
session_db: Any = None,
) -> bool:
"""Rename the Telegram DM topic backing the current session."""
chat_id = getattr(source, "chat_id", None)
thread_id = getattr(source, "thread_id", None)
if getattr(source, "chat_type", None) != "dm" or not chat_id or not thread_id:
return False
if str(thread_id) in {"", "1"}:
return False
if self._topic_auto_rename_disabled():
return False

try:
operator_topic = self._get_dm_topic_info(str(chat_id), str(thread_id))
except Exception:
operator_topic = None
if isinstance(operator_topic, dict):
return False

if session_db is not None and session_id is not None:
try:
binding = session_db.get_telegram_topic_binding(
chat_id=str(chat_id),
thread_id=str(thread_id),
)
if binding and str(binding.get("session_id") or "") != str(session_id):
return False
except Exception:
logger.debug("[%s] Failed to verify Telegram topic binding before rename", self.name, exc_info=True)
return False

await self.rename_dm_topic(
chat_id=chat_id,
thread_id=thread_id,
name=self._sanitize_conversation_title(name),
)
return True

async def rename_dm_topic(
self,
chat_id: int,
Expand Down
Loading