Skip to content
Open
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
72 changes: 72 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5585,6 +5585,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
effective_session_id,
title,
)
elif self._runner._is_mattermost_banner_thread_lane(ctx.source):
maybe_auto_title_kwargs["title_callback"] = lambda title: self._runner._schedule_mattermost_thread_title_rename(
ctx.source,
effective_session_id,
title,
)
maybe_auto_title(
getattr(self._runner._session_db, "_db", self._runner._session_db),
effective_session_id,
Expand Down Expand Up @@ -19768,6 +19774,72 @@ def _log_rename_failure(fut) -> None:

future.add_done_callback(_log_rename_failure)

def _is_mattermost_banner_thread_lane(self, source: SessionSource) -> bool:
"""True for Mattermost thread sessions that may sit under a bot banner root.

Kept deliberately thin: whether the thread root actually is a
retitleable session banner (bot-owned + stamped props) is verified
by the adapter's ``rename_thread`` against the live post, so user
created threads simply no-op there.
"""
return source.platform == Platform.MATTERMOST and bool(source.thread_id)

async def _rename_mattermost_thread_for_session_title(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Best-effort write of a generated session title into the Mattermost banner root."""
if not self._is_mattermost_banner_thread_lane(source):
return
adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None
if adapter is None:
return
rename_thread = getattr(adapter, "rename_thread", None)
if rename_thread is None:
return
try:
await rename_thread(str(source.thread_id), title)
except Exception:
logger.debug("Failed to rename Mattermost thread for generated session title", exc_info=True)

def _schedule_mattermost_thread_title_rename(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Schedule Mattermost banner retitle from the auto-title background thread."""
if not title or not self._is_mattermost_banner_thread_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 = safe_schedule_threadsafe(
self._rename_mattermost_thread_for_session_title(copied_source, session_id, title),
loop,
logger=logger,
log_message="Mattermost thread retitle failed to schedule",
)
if future is None:
return

def _log_mm_rename_failure(fut) -> None:
try:
fut.result()
except Exception:
logger.debug("Mattermost thread retitle failed", exc_info=True)

future.add_done_callback(_log_mm_rename_failure)

async def _rename_telegram_topic_for_session_title(
self,
source: SessionSource,
Expand Down
57 changes: 54 additions & 3 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,60 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
except Exception:
_tip_line = ""

if session_info:
return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}")
return EphemeralReply(f"{header}{_tip_line}")
final_text = (
f"{header}\n\n{session_info}{_tip_line}" if session_info else f"{header}{_tip_line}"
)

# Mattermost DM: when /new arrives outside a thread, post the reset
# banner as a flat root post via the adapter instead of the normal
# reply path. reply_mode=thread would anchor the banner under the
# user's own "/new" message, making the *user's* post the thread
# root β€” which the bot can never retitle. A flat bot-owned banner
# becomes the root of the next conversation thread, and the
# auto-title callback later rewrites it (Mattermost threads have no
# title field; the root post's text is what the Threads list shows).
if (
source.platform == Platform.MATTERMOST
and (source.chat_type or "").lower() == "dm"
and not source.thread_id
):
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
send_banner = getattr(adapter, "send_session_banner", None)
if send_banner is not None:
# Re-derive the sanitized manual title (pure function, no
# side effects): the banner collapses to this after the
# first exchange, mirroring the auto-title flow.
_manual_title = ""
if _title_arg:
try:
from hermes_state import SessionDB as _SDB
_manual_title = _SDB.sanitize_title(_title_arg) or ""
except Exception:
_manual_title = ""
hint_key = (
"gateway.reset.mattermost_thread_hint_titled"
if _manual_title
else "gateway.reset.mattermost_thread_hint"
)
banner_text = f"{final_text}\n\n{t(hint_key)}"
try:
banner_id = await send_banner(
source.chat_id,
banner_text,
manual_title=_manual_title or None,
)
except Exception:
logger.debug(
"Mattermost session banner send failed; falling back to reply path",
exc_info=True,
)
banner_id = None
if banner_id:
# Banner delivered by the adapter; suppress the normal
# reply so the reset notice isn't posted twice.
return EphemeralReply("")

return EphemeralReply(final_text)

async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile β€” show the profile serving this source and its home.
Expand Down
2 changes: 2 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ gateway:
title_error_untitled: "\n⚠️ {error} β€” session started untitled."
title_empty_untitled: "\n⚠️ Title is empty after cleanup β€” session started untitled."
tip: "\n✦ Tip: {tip}"
mattermost_thread_hint: "πŸ’¬ Reply in this thread to start the conversation β€” it will be titled automatically."
mattermost_thread_hint_titled: "πŸ’¬ Reply in this thread to start the conversation."

restart:
in_progress: "⏳ Gateway restart already in progress..."
Expand Down
102 changes: 102 additions & 0 deletions plugins/platforms/mattermost/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,108 @@ async def _resolve_root_id(self, post_id: str) -> str:
return data["root_id"]
return post_id

# ------------------------------------------------------------------
# Session banner & thread title (auto-title support)
# ------------------------------------------------------------------

_SESSION_BANNER_PROP = "hermes_session_banner"
_THREAD_TITLE_MAX_CHARS = 80
# What a banner collapses to once its session has a title (auto or
# manual). The root post's text is what the Threads list shows.
_TITLED_BANNER_FORMAT = "πŸ’¬ {title}"

def _auto_title_enabled(self) -> bool:
"""Check if session-banner threads + auto-title are enabled via env."""
return os.getenv("MATTERMOST_AUTO_TITLE", "true").lower() not in {"false", "0", "no"}

def _sanitize_thread_title(self, title: str) -> str:
"""Collapse whitespace and cap length for a banner title line."""
cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
if len(cleaned) > self._THREAD_TITLE_MAX_CHARS:
cleaned = cleaned[: self._THREAD_TITLE_MAX_CHARS - 3].rstrip() + "..."
return cleaned

async def send_session_banner(
self,
chat_id: str,
text: str,
manual_title: Optional[str] = None,
) -> Optional[str]:
"""Post a flat (non-threaded) session banner and return its post ID.

The banner becomes the root of a fresh conversation thread. Replies
under it get their own gateway session, and the title callback later
collapses the banner text via ``rename_thread`` β€” Mattermost threads
have no title field, so the root post's text *is* the title shown in
the Threads list. ``manual_title`` records a user-picked ``/new
<title>`` name: the banner then collapses to THAT title instead of
the generated one.
"""
if not self._auto_title_enabled():
return None
if not chat_id or not text or self._session is None:
return None
banner_meta: Dict[str, Any] = {"auto_title": manual_title is None}
if manual_title:
banner_meta["manual_title"] = manual_title
data = await self._api_post(
"posts",
{
"channel_id": chat_id,
"message": text,
"props": {self._SESSION_BANNER_PROP: banner_meta},
},
)
post_id = data.get("id") if data else None
return str(post_id) if post_id else None

async def rename_thread(self, thread_id: str, title: str) -> bool:
"""Collapse a banner root post to its session title line.

Only rewrites posts the bot itself created AND stamped with the
session-banner prop β€” a user's own thread root (or any ordinary bot
reply someone started a thread on) is never touched. The reset
notice and thread hint have served their purpose once the user is
chatting in the thread, so the banner becomes a single
``πŸ’¬ <title>`` line (which is also what the Threads list shows).
Manual ``/new <title>`` banners collapse to the user's own title
instead of the generated one.
"""
if not self._auto_title_enabled():
return False
title = self._sanitize_thread_title(title)
if not title or not thread_id or self._session is None:
return False
post = await self._api_get(f"posts/{thread_id}")
if not post or post.get("id") != thread_id:
return False
if post.get("root_id"):
return False # a reply, not a thread root
if post.get("user_id") != self._bot_user_id:
return False # not our post β€” never rewrite user content
props = post.get("props") or {}
banner_meta = props.get(self._SESSION_BANNER_PROP)
if not isinstance(banner_meta, dict):
return False # ordinary bot post, not a session banner
if banner_meta.get("auto_title") is False:
# /new <title>: the user picked the name β€” collapse to THEIR
# title, never the generated one. Legacy manual banners without
# a stored title are left untouched.
manual = self._sanitize_thread_title(str(banner_meta.get("manual_title") or ""))
if not manual:
return False
title = manual
new_props = dict(props)
new_props[self._SESSION_BANNER_PROP] = {**banner_meta, "titled": True}
data = await self._api_put(
f"posts/{thread_id}/patch",
{
"message": self._TITLED_BANNER_FORMAT.format(title=title),
"props": new_props,
},
)
return bool(data and data.get("id"))

async def send(
self,
chat_id: str,
Expand Down
4 changes: 4 additions & 0 deletions plugins/platforms/mattermost/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ optional_env:
description: "If set, the bot only responds in these channels (whitelist)."
prompt: "Allowed channel IDs (comma-separated)"
password: false
- name: MATTERMOST_AUTO_TITLE
description: "On /new in a DM, post a flat banner as the new thread root and auto-title it from the first exchange (default true)."
prompt: "Enable session-banner auto-title? (true/false)"
password: false
Loading