From 820b7aeef445fe818560e64f81e95e7d91b0d46d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:46:49 -0700 Subject: [PATCH] refactor(slack): hoist setup manifest writer, extract slash text resolution and send chunk loop, pack headers --- plugins/platforms/slack/adapter.py | 212 ++++++++++++++--------------- 1 file changed, 102 insertions(+), 110 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 7656bd0adc..1ca64a179b 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -188,9 +188,7 @@ def _wrap_markdown_tables(text: str) -> str: if line.lstrip().startswith("```"): in_fence = not in_fence elif ( - not in_fence - and "|" in line - and i + 1 < len(lines) + not in_fence and "|" in line and i + 1 < len(lines) and _TABLE_SEPARATOR_RE.match(lines[i + 1])): block = [line, lines[i + 1]] j = i + 2 @@ -1242,8 +1240,7 @@ class SlackAdapter(BasePlatformAdapter): def _purge_stale_slash_contexts(self) -> None: now = time.monotonic() for k in [ - k - for k, v in self._slash_command_contexts.items() + k for k, v in self._slash_command_contexts.items() if now - v["ts"] > self._SLASH_CTX_TTL]: self._slash_command_contexts.pop(k, None) @@ -1912,28 +1909,8 @@ class SlackAdapter(BasePlatformAdapter): # delivery attempt, so the "is thinking..." status must clear. await self._clear_thread_status_quietly(chat_id, metadata) return SendResult(success=True) - chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) thread_ts = self._resolve_thread_ts(reply_to, metadata) - last_result = None - # reply_broadcast: also post thread replies to the main channel. - broadcast = self.config.extra.get("reply_broadcast", False) - # Block Kit only for single-chunk messages (a >39k response is pathological for the - # 50-block / 3000-char limits); ``text`` stays the notification/accessibility fallback. - blocks = self._maybe_blocks(content) if len(chunks) == 1 else None - for i, chunk in enumerate(chunks): - kwargs = { - "channel": chat_id, "text": chunk, - "mrkdwn": True, **_slack_unfurl_kwargs(self.config.extra)} - if blocks and i == 0: - kwargs["blocks"] = blocks - if thread_ts: - kwargs["thread_ts"] = thread_ts - # Only broadcast the first chunk of the first reply - if broadcast and i == 0: - kwargs["reply_broadcast"] = True - client_fn = lambda: self._get_client(chat_id, team_id=team_id) # noqa: E731 - last_result = await self._call_with_block_fallback( - client_fn, "chat_postMessage", kwargs, "send") + last_result = await self._post_chunks(chat_id, team_id, content, formatted, thread_ts) # Clear Slack Assistant status as soon as the final message is posted. if thread_ts: await self.stop_typing(chat_id, metadata=metadata) @@ -1956,6 +1933,32 @@ class SlackAdapter(BasePlatformAdapter): success=False, error=str(e), retryable=_retryable, retry_after=self._retry_after_from_exc(e) if _retryable else None) + async def _post_chunks( + self, chat_id: str, team_id: str, content: str, formatted: str, thread_ts: Optional[str] + ) -> Any: + """``chat.postMessage`` each ``MAX_MESSAGE_LENGTH`` chunk; returns the last response. + Block Kit only for single-chunk messages (a >39k response is pathological for the 50-block / + 3000-char limits); ``text`` stays the notification/accessibility fallback. With + ``reply_broadcast`` only the first chunk is also posted to the main channel.""" + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + broadcast = self.config.extra.get("reply_broadcast", False) + blocks = self._maybe_blocks(content) if len(chunks) == 1 else None + last_result = None + for i, chunk in enumerate(chunks): + kwargs = { + "channel": chat_id, "text": chunk, + "mrkdwn": True, **_slack_unfurl_kwargs(self.config.extra)} + if blocks and i == 0: + kwargs["blocks"] = blocks + if thread_ts: + kwargs["thread_ts"] = thread_ts + if broadcast and i == 0: + kwargs["reply_broadcast"] = True + client_fn = lambda: self._get_client(chat_id, team_id=team_id) # noqa: E731 + last_result = await self._call_with_block_fallback( + client_fn, "chat_postMessage", kwargs, "send") + return last_result + @staticmethod def _retry_after_from_exc(e: BaseException) -> Optional[float]: """``Retry-After`` header (seconds) from an SDK error response, else None.""" @@ -3263,10 +3266,7 @@ class SlackAdapter(BasePlatformAdapter): self, channel_id: str, thread_ts: str, title_source: str, *, team_id: str = "") -> None: """Best-effort title for visible Slack AI DM threads.""" if ( - not self._app - or not channel_id - or not thread_ts - or not title_source + not self._app or not channel_id or not thread_ts or not title_source or not self._assistant_thread_title_enabled()): return key = self._workspace_thread_key(team_id, channel_id, thread_ts) @@ -3704,8 +3704,7 @@ class SlackAdapter(BasePlatformAdapter): return False self_uids = {u for u in (bot_uid, self._bot_user_id) if u} if ( - self._slack_ignore_other_user_mentions() - and not is_mentioned + self._slack_ignore_other_user_mentions() and not is_mentioned and not self._slack_message_mentions_self(routing_text, self_uids) and self._slack_message_addressed_to_other_user(routing_text, self_uids)): logger.debug( @@ -4009,8 +4008,7 @@ class SlackAdapter(BasePlatformAdapter): # thread_require_mention, which it would defeat). Session-scoped ``thread_ts`` because a # top-level @mention STARTS a thread whose replies must trigger too. if ( - thread_ts - and not self._slack_strict_mention() + thread_ts and not self._slack_strict_mention() and not self._slack_thread_require_mention()): self._register_mentioned_thread(thread_ts, team_id=team_id) return text, original_text, command_probe_text, is_command_text @@ -4081,13 +4079,11 @@ class SlackAdapter(BasePlatformAdapter): if await self._peer_bot_drop(event, user_id, bot_uid, channel_id, team_id, is_mentioned): return if ( - not is_one_to_one_dm - and bot_uid - and not await self._channel_gate_allows( - channel_id=channel_id, routing_text=routing_text, bot_uid=bot_uid, - is_mentioned=is_mentioned, is_thread_reply=is_thread_reply, - event_thread_ts=event_thread_ts, user_id=user_id, team_id=team_id, is_dm=is_dm, - force_process=force_process)): + not is_one_to_one_dm and bot_uid and not await self._channel_gate_allows( + channel_id=channel_id, routing_text=routing_text, bot_uid=bot_uid, + is_mentioned=is_mentioned, is_thread_reply=is_thread_reply, + event_thread_ts=event_thread_ts, user_id=user_id, team_id=team_id, is_dm=is_dm, + force_process=force_process)): return # Claim the message ts HERE: a link unfurl emits `message_changed` with a different event # ts, so only the `_processed_message_ts` guard stops a duplicate turn, and it must be set @@ -5042,37 +5038,12 @@ class SlackAdapter(BasePlatformAdapter): async def _handle_slash_command(self, command: dict) -> None: """Slash commands: native ``/ [args]`` for every COMMAND_REGISTRY entry, or ``/hermes [args]``; other text after ``/hermes`` is a regular message.""" - slash_name = (command.get("command") or "").lstrip("/").strip() - raw_text = str(command.get("text") or "") - text = raw_text user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") team_id = command.get("team_id", "") if team_id and channel_id: self._remember_channel_team(channel_id, team_id) - if slash_name in {"hermes", ""}: - # /hermes [args] + free-form questions; empty - # slash_name lands here for callers that omit command["command"]. - legacy_text = raw_text.strip() - from hermes_cli.commands import slack_subcommand_map - - subcommand_map = slack_subcommand_map() - subcommand_map["compact"] = "/compress" - first_word = legacy_text.split()[0] if legacy_text.split() else "" - if first_word in subcommand_map: - rest = legacy_text[len(first_word) :].strip() - text = ( - f"{subcommand_map[first_word]} {rest}".strip() - if rest - else subcommand_map[first_word]) - elif legacy_text: - text = legacy_text # Treat as a regular question - else: - text = "/help" - else: - # Native slash: preserve Slack's raw argument payload verbatim, - # including internal/trailing spacing. - text = f"/{slash_name}" if not raw_text else f"/{slash_name} {raw_text}" + text = self._slash_command_text(command) thread_id = self._slash_thread_id(command) is_dm = str(channel_id).startswith("D") if is_dm and self._slack_disable_dms(): @@ -5100,6 +5071,26 @@ class SlackAdapter(BasePlatformAdapter): finally: _slash_user_id.reset(_slash_user_id_token) + @staticmethod + def _slash_command_text(command: dict) -> str: + """Gateway message text for a slash payload. Native slashes keep Slack's raw argument + payload verbatim (internal/trailing spacing). ``/hermes`` (or a missing ``command``) maps + `` [args]`` via the registry, else free-form text is a regular question.""" + slash_name = (command.get("command") or "").lstrip("/").strip() + raw_text = str(command.get("text") or "") + if slash_name not in {"hermes", ""}: + return f"/{slash_name}" if not raw_text else f"/{slash_name} {raw_text}" + legacy_text = raw_text.strip() + from hermes_cli.commands import slack_subcommand_map + + subcommand_map = slack_subcommand_map() + subcommand_map["compact"] = "/compress" + first_word = legacy_text.split()[0] if legacy_text.split() else "" + if first_word in subcommand_map: + rest = legacy_text[len(first_word) :].strip() + return f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + return legacy_text or "/help" + @staticmethod def _slash_thread_id(command: dict) -> Optional[str]: """Thread anchor for a slash payload so session-scoped commands (``/model``) @@ -5520,8 +5511,7 @@ async def _slack_json_post(session, token: str, method: str, payload: dict, req_ """POST ``payload`` to ``https://slack.com/api/`` with a bearer token; JSON body.""" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with session.post( - f"https://slack.com/api/{method}", headers=headers, json=payload, **req_kw - ) as resp: + f"https://slack.com/api/{method}", headers=headers, json=payload, **req_kw) as resp: return await resp.json() @@ -5748,41 +5738,55 @@ async def _standalone_send( return {"error": f"Slack send failed: {e}"} +_SETUP_STEPS = ( + "Steps to create a Slack app:", + " 1. Go to https://api.slack.com/apps → Create New App", + " Pick 'From an app manifest' — we'll generate one for you below.", + " 2. Enable Socket Mode: Settings → Socket Mode → Enable", + " • Create an App-Level Token with 'connections:write' scope", + " 3. Install to Workspace: Settings → Install App", + " 4. After installing, invite the bot to channels: /invite @YourBot",) +_SETUP_HOME_CHANNEL_HELP = ( + "📬 Home Channel: where Hermes delivers cron job results,", + " cross-platform messages, and notifications.", + " To get a channel ID: open the channel in Slack, then right-click", + " the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).", + " You can also set this later by typing /set-home in a Slack channel.",) + + +def _write_slack_manifest_and_instruct() -> None: + """Write the manifest under HERMES_HOME and print paste instructions; non-fatal.""" + from hermes_cli.cli_output import print_info, print_success, print_warning + + try: + from hermes_cli.slack_cli import _build_full_manifest + from hermes_constants import get_hermes_home + + manifest = _build_full_manifest(bot_name="Hermes", bot_description="Your Hermes agent on Slack") + target = _Path(get_hermes_home()) / "slack-manifest.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print_success(f"Slack app manifest written to: {target}") + print_info( + " Paste it into https://api.slack.com/apps → your app → Features " + "→ App Manifest → Edit, then Save. Slack will prompt to " + "reinstall if scopes or slash commands changed.") + print_info( + " Re-run `hermes slack manifest --write` anytime to refresh after " + "Hermes adds new commands.") + except Exception as e: + print_warning(f"Could not write Slack manifest: {e}") + + def interactive_setup() -> None: """Guide the user through Slack bot setup (manifest, tokens, allowlist, home channel). CLI helpers are lazy-imported to keep the plugin's import surface small.""" - from pathlib import Path from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, print_header, print_info, print_success, print_warning) - def _write_slack_manifest_and_instruct() -> None: - """Write the manifest under HERMES_HOME and print paste instructions; non-fatal.""" - try: - from hermes_cli.slack_cli import _build_full_manifest - from hermes_constants import get_hermes_home - import json as _json - - manifest = _build_full_manifest( - bot_name="Hermes", bot_description="Your Hermes agent on Slack") - target = Path(get_hermes_home()) / "slack-manifest.json" - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text( - _json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - print_success(f"Slack app manifest written to: {target}") - print_info( - " Paste it into https://api.slack.com/apps → your app → Features " - "→ App Manifest → Edit, then Save. Slack will prompt to " - "reinstall if scopes or slash commands changed.") - print_info( - " Re-run `hermes slack manifest --write` anytime to refresh after " - "Hermes adds new commands.") - except Exception as e: - print_warning(f"Could not write Slack manifest: {e}") - print_header("Slack") - existing = get_env_value("SLACK_BOT_TOKEN") - if existing: + if get_env_value("SLACK_BOT_TOKEN"): print_info("Slack: already configured") if not prompt_yes_no("Reconfigure Slack?", False): # Still offer a manifest refresh so new commands get registered. @@ -5791,14 +5795,7 @@ def interactive_setup() -> None: "list? (recommended after `hermes update`)", True): _write_slack_manifest_and_instruct() return - for line in ( - "Steps to create a Slack app:", - " 1. Go to https://api.slack.com/apps → Create New App", - " Pick 'From an app manifest' — we'll generate one for you below.", - " 2. Enable Socket Mode: Settings → Socket Mode → Enable", - " • Create an App-Level Token with 'connections:write' scope", - " 3. Install to Workspace: Settings → Install App", - " 4. After installing, invite the bot to channels: /invite @YourBot",): + for line in _SETUP_STEPS: print_info(line) print() print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/") @@ -5830,12 +5827,7 @@ def interactive_setup() -> None: " Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access." ) print() - for line in ( - "📬 Home Channel: where Hermes delivers cron job results,", - " cross-platform messages, and notifications.", - " To get a channel ID: open the channel in Slack, then right-click", - " the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).", - " You can also set this later by typing /set-home in a Slack channel.",): + for line in _SETUP_HOME_CHANNEL_HELP: print_info(line) home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip() if home_channel: