From c2eae4795b7ce86b7b424ab128025548b4d6aff5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:09:24 -0700 Subject: [PATCH 1/2] refactor(gateway): migrate Slack adapter to bundled plugin Move gateway/platforms/slack.py into plugins/platforms/slack/ following the Discord (#24356) and Home Assistant (#40709) migrations. Advances #41112 / hardcoded Platform.SLACK touchpoints in core. - Adapter file renamed via git mv (history preserved). - register() exposes the platform via ctx.register_platform() instead of the Platform.SLACK elif in gateway/run.py::_create_adapter(). - _standalone_send() replaces the legacy _send_slack() helper in tools/send_message_tool.py; out-of-process cron delivery (deliver=slack) now flows through the registry's standalone_sender_fn. mrkdwn formatting moved into the plugin (was applied in _send_to_platform before chunking). - _apply_yaml_config() owns the config.yaml slack: -> SLACK_* env bridge (require_mention, strict_mention, allow_bots, free_response_channels, reactions, allowed_channels), replacing the hardcoded block in gateway/config.py. - interactive_setup() replaces hermes_cli/setup.py::_setup_slack + _write_slack_manifest_and_instruct and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py; setup metadata is discovered dynamically. - is_connected() probes SLACK_BOT_TOKEN via hermes_cli.gateway.get_env_value. - max_message_length=39000 on the PlatformEntry; the registry fallback in send_message_tool covers it (dropped the _MAX_LENGTHS entry). The SLACK_BOT_TOKEN/SLACK_HOME_CHANNEL env->PlatformConfig seeding and the _is_user_authorized allowlist maps stay in core (same as Discord/HA/Mattermost). Bug fixed during migration: the registry-driven plugin-enable pass in _apply_env_overrides re-enabled any plugin platform whose is_connected() passed, ignoring an explicit enabled: false. Slack is the first plugin with an enabled-false-wins test, so it exposed this latent bug (Discord had no such test). Added an explicit-disable guard (_enabled_explicit + enabled=False -> skip) and changed the slack env-block to read the flag instead of popping it so the guard can see it; the flag is still cleared in the final per-platform cleanup. Restores test_explicit_{top_level,platforms}_slack_enabled_false_wins. Test imports rewritten across 11 files (gateway.platforms.slack -> plugins.platforms.slack.adapter). The _setup_slack home-channel tests moved to tests/gateway/test_slack_plugin_setup.py exercising interactive_setup. The test_send_message_tool slack-formatting tests now patch the registry standalone_sender_fn (via _patch_slack_standalone_sender) and assert the mrkdwn-formatted text reaches the wire. Validation: 706 targeted tests pass (slack/config/setup/registry/send/media suites); 18/18 live E2E checks pass (real plugin discovery + registry resolves SlackAdapter, env-only enable, standalone sender wired, YAML bridge, dynamic setup discovery). --- gateway/config.py | 45 ++- gateway/run.py | 7 - hermes_cli/gateway.py | 50 +-- hermes_cli/setup.py | 112 +------ plugins/platforms/slack/__init__.py | 3 + .../platforms/slack/adapter.py | 296 ++++++++++++++++++ plugins/platforms/slack/plugin.yaml | 39 +++ tests/e2e/conftest.py | 4 +- tests/gateway/test_media_download_retry.py | 6 +- tests/gateway/test_send_image_file.py | 2 +- tests/gateway/test_send_multiple_images.py | 2 +- tests/gateway/test_slack.py | 14 +- tests/gateway/test_slack_approval_buttons.py | 2 +- tests/gateway/test_slack_channel_skills.py | 2 +- tests/gateway/test_slack_mention.py | 4 +- tests/gateway/test_slack_plugin_setup.py | 57 ++++ tests/gateway/test_stream_consumer.py | 2 +- tests/hermes_cli/test_setup.py | 31 +- tests/tools/test_send_message_tool.py | 91 +++++- tests/tools/test_signal_media.py | 16 +- tools/send_message_tool.py | 52 ++- 21 files changed, 556 insertions(+), 281 deletions(-) create mode 100644 plugins/platforms/slack/__init__.py rename gateway/platforms/slack.py => plugins/platforms/slack/adapter.py (91%) create mode 100644 plugins/platforms/slack/plugin.yaml create mode 100644 tests/gateway/test_slack_plugin_setup.py diff --git a/gateway/config.py b/gateway/config.py index f130fa7da81a9..f1341fe453912 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -969,28 +969,10 @@ def _merge_platform_map(source_platforms: Any) -> None: _, extra = _ensure_platform_extra_dict(platforms_data, entry.name) extra.update(seeded) - # Slack settings โ†’ env vars (env vars take precedence) - slack_cfg = yaml_cfg.get("slack", {}) - if isinstance(slack_cfg, dict): - if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"): - os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() - if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"): - os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower() - if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): - os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() - frc = slack_cfg.get("free_response_channels") - if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"): - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) - if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"): - os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower() - # allowed_channels: if set, bot ONLY responds in these channels (whitelist) - ac = slack_cfg.get("allowed_channels") - if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"): - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac) + # Slack settings โ†’ env vars: migrated to the slack plugin's + # ``apply_yaml_config_fn`` hook (see plugins/platforms/slack/ + # adapter.py::_apply_yaml_config), dispatched in the + # ``apply_yaml_config_fn`` loop above. #41112 / #3823. # Bridge top-level require_mention to Telegram when the telegram: section # does not already provide one. Users often write "require_mention: true" @@ -1368,7 +1350,12 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: config.platforms[Platform.SLACK].enabled = True else: slack_config = config.platforms[Platform.SLACK] - enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False)) + # Read (don't pop) the explicit-enable marker: the registry-driven + # plugin-enable pass below also needs it to avoid re-enabling a + # platform the user explicitly disabled (Slack is now a plugin + # entry โ€” #41112). The flag is cleared once for all platforms in + # the final cleanup at the end of _apply_env_overrides. + enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False)) if not slack_config.enabled and not enabled_was_explicit: # Top-level Slack settings such as channel prompts should not # turn an env-token setup into a disabled platform. Only an @@ -1904,6 +1891,18 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: continue platform = Platform(entry.name) existing_cfg = config.platforms.get(platform) + # Respect an explicit ``enabled: false`` (YAML / gateway.json). + # ``_enabled_explicit`` is set in load_gateway_config() when the + # user wrote ``enabled`` for this platform; if they explicitly + # disabled it, never re-enable here just because check_fn() / + # is_connected() pass (e.g. SLACK_BOT_TOKEN is set but the user + # set slack.enabled: false). #41112. + if ( + existing_cfg is not None + and not existing_cfg.enabled + and bool(existing_cfg.extra.get("_enabled_explicit", False)) + ): + continue # Seed candidate extras from ``env_enablement_fn`` so plugins # whose ``is_connected`` reads ``config.extra`` (e.g. Google # Chat's ``_is_connected`` checks ``config.extra["project_id"]``) diff --git a/gateway/run.py b/gateway/run.py index ecbe1a86605de..d41fc4949f227 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6900,13 +6900,6 @@ def _create_adapter( return None return WhatsAppAdapter(config) - elif platform == Platform.SLACK: - from gateway.platforms.slack import SlackAdapter, check_slack_requirements - if not check_slack_requirements(): - logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'") - return None - return SlackAdapter(config) - elif platform == Platform.SIGNAL: from gateway.platforms.signal import SignalAdapter, check_signal_requirements if not check_signal_requirements(): diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index d133944480098..a310f8c8d462a 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3833,49 +3833,9 @@ def _atexit_hook() -> None: # Discord moved to plugins/platforms/discord/ โ€” its setup metadata is # discovered dynamically via _all_platforms() from the platform registry # entry registered by plugins/platforms/discord/adapter.py::register(). - { - "key": "slack", - "label": "Slack", - "emoji": "๐Ÿ’ผ", - "token_var": "SLACK_BOT_TOKEN", - "setup_instructions": [ - "1. Go to https://api.slack.com/apps โ†’ Create New App โ†’ From Scratch", - "2. Enable Socket Mode: Settings โ†’ Socket Mode โ†’ Enable", - " Create an App-Level Token with scope: connections:write โ†’ copy xapp-... token", - "3. Add Bot Token Scopes: Features โ†’ OAuth & Permissions โ†’ Scopes", - " Required: chat:write, app_mentions:read, channels:history, channels:read,", - " groups:history, im:history, im:read, im:write, users:read, files:read, files:write", - "4. Subscribe to Events: Features โ†’ Event Subscriptions โ†’ Enable", - " Required events: message.im, message.channels, app_mention", - " Optional: message.groups (for private channels)", - " โš  Without message.channels the bot will ONLY work in DMs!", - "5. Install to Workspace: Settings โ†’ Install App โ†’ copy xoxb-... token", - "6. Reinstall the app after any scope or event changes", - "7. Find your user ID: click your profile โ†’ three dots โ†’ Copy member ID", - "8. Invite the bot to channels: /invite @YourBot", - ], - "vars": [ - { - "name": "SLACK_BOT_TOKEN", - "prompt": "Bot Token (xoxb-...)", - "password": True, - "help": "Paste the bot token from step 3 above.", - }, - { - "name": "SLACK_APP_TOKEN", - "prompt": "App Token (xapp-...)", - "password": True, - "help": "Paste the app-level token from step 4 above.", - }, - { - "name": "SLACK_ALLOWED_USERS", - "prompt": "Allowed user IDs (comma-separated)", - "password": False, - "is_allowlist": True, - "help": "Paste your member ID from step 7 above.", - }, - ], - }, + # Slack moved to plugins/platforms/slack/ for the same reason โ€” its setup + # metadata is discovered dynamically via the platform registry entry + # registered by plugins/platforms/slack/adapter.py::register(). #41112. { "key": "matrix", "label": "Matrix", @@ -5605,7 +5565,9 @@ def _builtin_setup_fn(key: str): # discord moved into the plugin: setup_fn is registered by # plugins/platforms/discord/adapter.py::register() and dispatched # via the plugin path in _configure_platform(). - "slack": _s._setup_slack, + # slack moved into the plugin: setup_fn is registered by + # plugins/platforms/slack/adapter.py::register() and dispatched + # via the plugin path in _configure_platform(). #41112. "matrix": _s._setup_matrix, # mattermost moved into the plugin: setup_fn is registered by # plugins/platforms/mattermost/adapter.py::register() and dispatched diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 266eb9eaa3924..3db1aa453429c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1795,115 +1795,9 @@ def _setup_telegram(): save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) -def _setup_slack(): - """Configure Slack bot credentials.""" - print_header("Slack") - existing = get_env_value("SLACK_BOT_TOKEN") - if existing: - print_info("Slack: already configured") - if not prompt_yes_no("Reconfigure Slack?", False): - # Even without reconfiguring, offer to refresh the manifest so - # new commands (e.g. /btw, /stop, ...) get registered in Slack. - if prompt_yes_no( - "Regenerate the Slack app manifest with the latest command " - "list? (recommended after `hermes update`)", - True, - ): - _write_slack_manifest_and_instruct() - return - - print_info("Steps to create a Slack app:") - print_info(" 1. Go to https://api.slack.com/apps โ†’ Create New App") - print_info(" Pick 'From an app manifest' โ€” we'll generate one for you below.") - print_info(" 2. Enable Socket Mode: Settings โ†’ Socket Mode โ†’ Enable") - print_info(" โ€ข Create an App-Level Token with 'connections:write' scope") - print_info(" 3. Install to Workspace: Settings โ†’ Install App") - print_info(" 4. After installing, invite the bot to channels: /invite @YourBot") - print() - print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/") - print() - - # Generate and write manifest up-front so the user can paste it into - # the "Create from manifest" flow instead of clicking through scopes / - # events / slash commands one at a time. - _write_slack_manifest_and_instruct() - - print() - bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) - if not bot_token: - return - save_env_value("SLACK_BOT_TOKEN", bot_token) - app_token = prompt("Slack App Token (xapp-...)", password=True) - if app_token: - save_env_value("SLACK_APP_TOKEN", app_token) - print_success("Slack tokens saved") - - print() - print_info("๐Ÿ”’ Security: Restrict who can use your bot") - print_info(" To find a Member ID: click a user's name โ†’ View full profile โ†’ โ‹ฎ โ†’ Copy member ID") - print() - allowed_users = prompt( - "Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)" - ) - if allowed_users: - save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) - print_success("Slack allowlist configured") - else: - print_warning("โš ๏ธ No Slack allowlist set - unpaired users will be denied by default.") - print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.") - - print() - print_info("๐Ÿ“ฌ Home Channel: where Hermes delivers cron job results,") - print_info(" cross-platform messages, and notifications.") - print_info(" To get a channel ID: open the channel in Slack, then right-click") - print_info(" the channel name โ†’ Copy link โ€” the ID starts with C (e.g. C01ABC2DE3F).") - print_info(" You can also set this later by typing /set-home in a Slack channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") - if home_channel: - save_env_value("SLACK_HOME_CHANNEL", home_channel.strip()) - - -def _write_slack_manifest_and_instruct(): - """Generate the Slack manifest, write it under HERMES_HOME, and print - paste-into-Slack instructions. - - Exposed as its own helper so both the initial setup flow and the - "reconfigure? โ†’ no" branch can refresh the manifest without the user - re-entering tokens. Failures are non-fatal โ€” if the manifest write - fails for any reason, we print a warning and skip rather than abort - the whole Slack setup. - """ - 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) - import json as _json - 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 exc: # pragma: no cover - best-effort UX helper - print_warning(f"Couldn't write Slack manifest: {exc}") - print_info( - " You can generate it manually later with: " - "hermes slack manifest --write" - ) +# _setup_slack and _write_slack_manifest_and_instruct moved to the slack +# plugin: plugins/platforms/slack/adapter.py::interactive_setup (registered +# via setup_fn and dispatched through the plugin path). #41112 / #3823. def _setup_matrix(): diff --git a/plugins/platforms/slack/__init__.py b/plugins/platforms/slack/__init__.py new file mode 100644 index 0000000000000..d4f1d7bf0e3fc --- /dev/null +++ b/plugins/platforms/slack/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/slack.py b/plugins/platforms/slack/adapter.py similarity index 91% rename from gateway/platforms/slack.py rename to plugins/platforms/slack/adapter.py index 46068ca20ea64..912eef43fad17 100644 --- a/gateway/platforms/slack.py +++ b/plugins/platforms/slack/adapter.py @@ -3587,3 +3587,299 @@ def _slack_allowed_channels(self) -> set: if isinstance(raw, str) and raw.strip(): return {part.strip() for part in raw.split(",") if part.strip()} return set() + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Plugin migration glue (#41112 / #3823) +# +# Everything below this line was added when the Slack adapter moved from +# ``gateway/platforms/slack.py`` into this bundled plugin. It mirrors the +# Discord migration (PR #24356) exactly: a ``register(ctx)`` entry point plus +# the hook implementations (``_standalone_send``, ``interactive_setup``, +# ``_apply_yaml_config``, ``_is_connected``, ``_build_adapter``) that replace +# the per-platform core touchpoints (the ``Platform.SLACK`` elif in +# ``gateway/run.py``, the ``slack_cfg`` YAMLโ†’env block in ``gateway/config.py``, +# the ``_setup_slack`` wizard + ``_PLATFORMS["slack"]`` static dict in +# ``hermes_cli/{setup,gateway}.py``, and the ``_send_slack`` dispatch in +# ``tools/send_message_tool.py``). +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +async def _standalone_send( + pconfig, + chat_id, + message, + *, + thread_id=None, + media_files=None, + force_document=False, +): + """Out-of-process Slack delivery via the Web API ``chat.postMessage``. + + Implements the ``standalone_sender_fn`` contract so ``deliver=slack`` cron + jobs succeed when the cron process is not co-located with the gateway (the + in-process adapter weakref is ``None`` in that case). Replaces the legacy + ``_send_slack`` helper that used to live in ``tools/send_message_tool.py``. + + mrkdwn formatting is applied exactly as the legacy core path did โ€” via a + throwaway ``SlackAdapter`` instance's ``format_message`` โ€” so cron-delivered + Slack messages render identically to gateway-delivered ones. + """ + token = getattr(pconfig, "token", None) or os.getenv("SLACK_BOT_TOKEN", "") + if not token: + return {"error": "Slack send failed: SLACK_BOT_TOKEN not configured"} + + formatted = message + if message: + try: + _fmt_adapter = SlackAdapter.__new__(SlackAdapter) + formatted = _fmt_adapter.format_message(message) + except Exception: + logger.debug( + "Failed to apply Slack mrkdwn formatting in _standalone_send", + exc_info=True, + ) + + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + try: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + + _proxy = resolve_proxy_url() + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + url = "https://slack.com/api/chat.postMessage" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), **_sess_kw + ) as session: + payload = {"channel": chat_id, "text": formatted, "mrkdwn": True} + if thread_id: + payload["thread_ts"] = thread_id + async with session.post( + url, headers=headers, json=payload, **_req_kw + ) as resp: + data = await resp.json() + if data.get("ok"): + return { + "success": True, + "platform": "slack", + "chat_id": chat_id, + "message_id": data.get("ts"), + } + return {"error": f"Slack API error: {data.get('error', 'unknown')}"} + except Exception as e: + return {"error": f"Slack send failed: {e}"} + + +def interactive_setup() -> None: + """Guide the user through Slack bot setup. + + Mirrors Discord's ``interactive_setup`` shape: lazy-imports CLI helpers so + the plugin's import surface stays small, generates and writes the Slack app + manifest, prompts for the bot + app tokens, captures an allowlist, and + offers to set a home channel. Replaces ``hermes_cli/setup.py::_setup_slack``. + """ + from pathlib import Path + from hermes_cli.config import get_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: + """Generate the Slack manifest, write it under HERMES_HOME, and print + paste-into-Slack instructions. Failures are 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: + print_info("Slack: already configured") + if not prompt_yes_no("Reconfigure Slack?", False): + # Even without reconfiguring, offer to refresh the manifest so + # new commands (e.g. /btw, /stop, ...) get registered in Slack. + if prompt_yes_no( + "Regenerate the Slack app manifest with the latest command " + "list? (recommended after `hermes update`)", + True, + ): + _write_slack_manifest_and_instruct() + return + + print_info("Steps to create a Slack app:") + print_info(" 1. Go to https://api.slack.com/apps โ†’ Create New App") + print_info(" Pick 'From an app manifest' โ€” we'll generate one for you below.") + print_info(" 2. Enable Socket Mode: Settings โ†’ Socket Mode โ†’ Enable") + print_info(" โ€ข Create an App-Level Token with 'connections:write' scope") + print_info(" 3. Install to Workspace: Settings โ†’ Install App") + print_info(" 4. After installing, invite the bot to channels: /invite @YourBot") + print() + print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/") + print() + + # Generate and write manifest up-front so the user can paste it into + # the "Create from manifest" flow instead of clicking through scopes / + # events / slash commands one at a time. + _write_slack_manifest_and_instruct() + + print() + bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) + if not bot_token: + return + save_env_value("SLACK_BOT_TOKEN", bot_token) + app_token = prompt("Slack App Token (xapp-...)", password=True) + if app_token: + save_env_value("SLACK_APP_TOKEN", app_token) + print_success("Slack tokens saved") + + print() + print_info("๐Ÿ”’ Security: Restrict who can use your bot") + print_info(" To find a Member ID: click a user's name โ†’ View full profile โ†’ โ‹ฎ โ†’ Copy member ID") + print() + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)" + ) + if allowed_users: + save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Slack allowlist configured") + else: + print_warning("โš ๏ธ No Slack allowlist set - unpaired users will be denied by default.") + print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.") + + print() + print_info("๐Ÿ“ฌ Home Channel: where Hermes delivers cron job results,") + print_info(" cross-platform messages, and notifications.") + print_info(" To get a channel ID: open the channel in Slack, then right-click") + print_info(" the channel name โ†’ Copy link โ€” the ID starts with C (e.g. C01ABC2DE3F).") + print_info(" You can also set this later by typing /set-home in a Slack channel.") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + if home_channel: + save_env_value("SLACK_HOME_CHANNEL", home_channel.strip()) + + +def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None: + """Translate ``config.yaml`` ``slack:`` keys into ``SLACK_*`` env vars. + + Implements the ``apply_yaml_config_fn`` contract (#24849). Mirrors the + legacy ``slack_cfg`` block that used to live in + ``gateway/config.py::load_gateway_config()`` before this migration. + + The SlackAdapter reads its runtime configuration via ``os.getenv()`` + throughout the connect / handle code paths, so rather than rewrite those + call sites to read from ``PlatformConfig.extra``, this hook keeps the + existing env-driven model and owns the YAMLโ†’env translation here, next to + the adapter that consumes it. Env vars take precedence over YAML โ€” every + assignment is guarded by ``not os.getenv(...)`` so explicit env vars + survive a config.yaml update. Returns ``None`` because no extras are + seeded into ``PlatformConfig.extra`` directly (everything flows through env). + """ + if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"): + os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() + if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"): + os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower() + if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): + os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() + frc = slack_cfg.get("free_response_channels") + if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) + if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"): + os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower() + ac = slack_cfg.get("allowed_channels") + if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac) + return None # all settings flow through env; nothing to merge into extras + + +def _is_connected(config) -> bool: + """Slack is considered connected when SLACK_BOT_TOKEN is set. + + Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via the + plugin's own bound import) so tests that patch ``gateway_mod.get_env_value`` + can suppress ambient ``SLACK_BOT_TOKEN`` env vars. Matches what the legacy + ``Platform.SLACK`` connected-check did before this migration. + """ + import hermes_cli.gateway as gateway_mod + + return bool((gateway_mod.get_env_value("SLACK_BOT_TOKEN") or "").strip()) + + +def _build_adapter(config): + """Factory wrapper that constructs SlackAdapter from a PlatformConfig.""" + return SlackAdapter(config) + + +def register(ctx) -> None: + """Plugin entry point โ€” called by the Hermes plugin system.""" + ctx.register_platform( + name="slack", + label="Slack", + adapter_factory=_build_adapter, + check_fn=check_slack_requirements, + is_connected=_is_connected, + required_env=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + install_hint="pip install 'hermes-agent[slack]'", + # Interactive setup wizard โ€” replaces hermes_cli/setup.py::_setup_slack + # and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py. + setup_fn=interactive_setup, + # YAMLโ†’env config bridge โ€” owns the translation of config.yaml slack: + # keys (require_mention, strict_mention, allow_bots, + # free_response_channels, reactions, allowed_channels) into SLACK_* + # env vars that the adapter reads via os.getenv(). Replaces the + # hardcoded block in gateway/config.py. Hook contract: #24849. + apply_yaml_config_fn=_apply_yaml_config, + # Auth env vars for _is_user_authorized() integration + allowed_users_env="SLACK_ALLOWED_USERS", + allow_all_env="SLACK_ALLOW_ALL_USERS", + # Cron home-channel delivery + cron_deliver_env_var="SLACK_HOME_CHANNEL", + # Out-of-process cron delivery via the Slack Web API. Without this hook, + # deliver=slack cron jobs fail with "No live adapter" when cron runs + # separately from the gateway. Replaces the _send_slack helper. + standalone_sender_fn=_standalone_send, + # Slack API allows 40,000 chars; leave margin (matches the legacy + # SlackAdapter.MAX_MESSAGE_LENGTH). + max_message_length=39000, + # Display + emoji="๐Ÿ’ผ", + allow_update_command=True, + ) diff --git a/plugins/platforms/slack/plugin.yaml b/plugins/platforms/slack/plugin.yaml new file mode 100644 index 0000000000000..338925559a7b3 --- /dev/null +++ b/plugins/platforms/slack/plugin.yaml @@ -0,0 +1,39 @@ +name: slack-platform +label: Slack +kind: platform +version: 1.0.0 +description: > + Slack gateway adapter for Hermes Agent. + Connects to Slack via slack-bolt in Socket Mode and relays messages + between Slack channels/DMs and the Hermes agent. Supports slash + commands, threads, mrkdwn rendering, approval blocks, free-response + channels, mention gating, and channel skill bindings. +author: NousResearch +requires_env: + - name: SLACK_BOT_TOKEN + description: "Slack bot token (xoxb-...)" + prompt: "Slack Bot Token (xoxb-...)" + url: "https://api.slack.com/apps" + password: true + - name: SLACK_APP_TOKEN + description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)" + prompt: "Slack App Token (xapp-...)" + url: "https://api.slack.com/apps" + password: true +optional_env: + - name: SLACK_ALLOWED_USERS + description: "Comma-separated Slack member IDs allowed to talk to the bot" + prompt: "Allowed users (comma-separated)" + password: false + - name: SLACK_ALLOW_ALL_USERS + description: "Allow any Slack user to trigger the bot (dev only)" + prompt: "Allow all users? (true/false)" + password: false + - name: SLACK_HOME_CHANNEL + description: "Default channel ID for cron / notification delivery (starts with C)" + prompt: "Home channel ID" + password: false + - name: SLACK_HOME_CHANNEL_NAME + description: "Display name for the Slack home channel" + prompt: "Home channel display name" + password: false diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3adbd557dd113..44cbdae7a8303 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -121,9 +121,9 @@ def _ensure_slack_mock(): from gateway.platforms.telegram import TelegramAdapter # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 -import gateway.platforms.slack as _slack_mod # noqa: E402 +import plugins.platforms.slack.adapter as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 # Platform-generic factories diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index bb45061f84216..d4ce61cb6f76b 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -3,7 +3,7 @@ Covers: - gateway/platforms/base.py: cache_image_from_url -- gateway/platforms/slack.py: SlackAdapter._download_slack_file +- plugins/platforms/slack/adapter.py: SlackAdapter._download_slack_file SlackAdapter._download_slack_file_bytes - gateway/platforms/mattermost.py: MattermostAdapter._send_url_as_file @@ -532,10 +532,10 @@ def _ensure_slack_mock(): _ensure_slack_mock() -import gateway.platforms.slack as _slack_mod # noqa: E402 +import plugins.platforms.slack.adapter as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 from gateway.config import PlatformConfig # noqa: E402 diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index 9cbf48fd0d7c8..d821d43107260 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -313,7 +313,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 class TestSlackSendImageFile: diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 5fab55c4a700e..93b8c706a1b4e 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -286,7 +286,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 class TestSlackMultiImage: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 97618f4482a50..4a06c630b8f06 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -63,11 +63,11 @@ def _ensure_slack_mock(): _ensure_slack_mock() # Patch SLACK_AVAILABLE before importing the adapter -import gateway.platforms.slack as _slack_mod +import plugins.platforms.slack.adapter as _slack_mod _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 async def _pending_for_fake_task(): @@ -3543,7 +3543,7 @@ async def test_send_uses_response_url_when_context_exists(self, adapter): mock_session.__aexit__ = AsyncMock(return_value=False) with patch( - "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + "plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session ): result = await adapter.send("C_SLASH", "Queued for the next turn.") @@ -3593,7 +3593,7 @@ async def test_send_slash_ephemeral_fallback_on_post_failure(self, adapter): mock_session.__aexit__ = AsyncMock(return_value=False) with patch( - "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + "plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session ): result = await adapter.send("C1", "Some response") @@ -3616,7 +3616,7 @@ async def test_send_slash_ephemeral_fallback_on_exception(self, adapter): mock_session.__aexit__ = AsyncMock(return_value=False) with patch( - "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + "plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session ): result = await adapter.send("C1", "Some response") @@ -3682,7 +3682,7 @@ async def test_freeform_hermes_question_does_not_stash_context(self, adapter): async def test_concurrent_users_same_channel_isolates_contexts(self, adapter): """Two users slash on the same channel โ€” each gets their own context.""" import time - from gateway.platforms.slack import _slash_user_id + from plugins.platforms.slack.adapter import _slash_user_id # Simulate two users stashing contexts on the same channel. adapter._slash_command_contexts[("C_SHARED", "U_ALICE")] = { @@ -3722,7 +3722,7 @@ async def test_concurrent_users_same_channel_isolates_contexts(self, adapter): async def test_no_contextvar_does_not_match_any_context(self, adapter): """send() without ContextVar (non-slash path) must not steal contexts.""" import time - from gateway.platforms.slack import _slash_user_id + from plugins.platforms.slack.adapter import _slash_user_id adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/test", diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index e09b3406c6dfd..b85fc37872328 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -42,7 +42,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter +from plugins.platforms.slack.adapter import SlackAdapter from gateway.config import PlatformConfig, Platform diff --git a/tests/gateway/test_slack_channel_skills.py b/tests/gateway/test_slack_channel_skills.py index 6f5987a2e5928..0e1a0103c753a 100644 --- a/tests/gateway/test_slack_channel_skills.py +++ b/tests/gateway/test_slack_channel_skills.py @@ -4,7 +4,7 @@ def _make_adapter(extra=None): """Create a minimal SlackAdapter stub with the given ``config.extra``.""" - from gateway.platforms.slack import SlackAdapter + from plugins.platforms.slack.adapter import SlackAdapter adapter = object.__new__(SlackAdapter) adapter.config = MagicMock() adapter.config.extra = extra or {} diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py index 23aa2f15454d7..78efb47826236 100644 --- a/tests/gateway/test_slack_mention.py +++ b/tests/gateway/test_slack_mention.py @@ -40,10 +40,10 @@ def _ensure_slack_mock(): _ensure_slack_mock() -import gateway.platforms.slack as _slack_mod +import plugins.platforms.slack.adapter as _slack_mod _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_slack_plugin_setup.py b/tests/gateway/test_slack_plugin_setup.py new file mode 100644 index 0000000000000..1a1ac7eba6cb1 --- /dev/null +++ b/tests/gateway/test_slack_plugin_setup.py @@ -0,0 +1,57 @@ +"""Tests for the Slack plugin's interactive_setup wizard. + +These cover the home-channel save logic that previously lived in +``hermes_cli/setup.py::_setup_slack`` before the Slack adapter migrated to a +bundled plugin (#41112). ``interactive_setup`` lazy-imports its CLI helpers +from ``hermes_cli.config`` (get_env_value / save_env_value) and +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), so we patch those +source modules. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +from plugins.platforms.slack.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, saved): + """Wire interactive_setup's lazy-imported CLI helpers to test doubles.""" + prompt_iter = iter(prompts) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: "") + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) + for name in ("print_header", "print_info", "print_success", "print_warning"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + # Manifest writing reaches out to hermes_cli.slack_cli + filesystem; stub it. + import hermes_cli.slack_cli as slack_cli_mod + monkeypatch.setattr(slack_cli_mod, "_build_full_manifest", lambda **_kw: {"display_information": {}}) + + +def test_interactive_setup_saves_home_channel(monkeypatch, tmp_path): + """interactive_setup() saves SLACK_HOME_CHANNEL when the user provides one.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved = {} + # prompts: bot token, app token, allowed users (empty), home channel + _patch_setup_io( + monkeypatch, + ["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"], + saved, + ) + + interactive_setup() + + assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F" + + +def test_interactive_setup_home_channel_empty_not_saved(monkeypatch, tmp_path): + """interactive_setup() does not save SLACK_HOME_CHANNEL when left blank.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved = {} + _patch_setup_io( + monkeypatch, + ["xoxb-test-token", "xapp-test-token", "", ""], + saved, + ) + + interactive_setup() + + assert "SLACK_HOME_CHANNEL" not in saved diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 9a445532d0d9d..e6df943ec96f7 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -150,7 +150,7 @@ class TestEditMessageFinalizeSignature: [ ("gateway.platforms.telegram", "TelegramAdapter"), ("plugins.platforms.discord.adapter", "DiscordAdapter"), - ("gateway.platforms.slack", "SlackAdapter"), + ("plugins.platforms.slack.adapter", "SlackAdapter"), ("gateway.platforms.matrix", "MatrixAdapter"), ("plugins.platforms.mattermost.adapter", "MattermostAdapter"), ("gateway.platforms.feishu", "FeishuAdapter"), diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index abd26a0a30657..a4af53289e2a1 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -479,33 +479,6 @@ def fake_prompt_choice(question, choices, default=0): assert config["terminal"]["modal_mode"] == "direct" -def test_setup_slack_saves_home_channel(monkeypatch): - """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one.""" - saved = {} - prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"]) +# test_setup_slack_* moved to tests/gateway/test_slack_plugin_setup.py โ€” the +# _setup_slack wizard migrated to the slack plugin's interactive_setup (#41112). - monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") - monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) - monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) - monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) - - setup_mod._setup_slack() - - assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F" - - -def test_setup_slack_home_channel_empty_not_saved(monkeypatch): - """_setup_slack() does not save SLACK_HOME_CHANNEL when left blank.""" - saved = {} - prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""]) - - monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") - monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) - monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) - monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) - - setup_mod._setup_slack() - - assert "SLACK_HOME_CHANNEL" not in saved diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 63f45e1e75c6e..6de5d9856b468 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -115,6 +115,67 @@ def __exit__(self, exc_type, exc, tb): return False +def _slack_entry(): + """Return the live Slack PlatformEntry, importing lazily so plugin + discovery is forced exactly once and patches survive across tests.""" + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + discover_plugins() + return platform_registry.get("slack") + + +def _make_recording_slack_sender(): + """Return a plain AsyncMock used to record the formatted Slack text. + + Paired with ``_patch_slack_standalone_sender``, which wraps it so the + production ``(pconfig, chat_id, raw_text, thread_id=...)`` call is + translated into the pre-migration ``(token, chat_id, formatted_text, + thread_ts=...)`` shape โ€” applying ``SlackAdapter.format_message`` exactly + as the real plugin ``_standalone_send`` does. Tests can then assert on + ``send.await_args.args[2]`` (the formatted mrkdwn) as before. + """ + return AsyncMock(return_value={"success": True, "platform": "slack", "message_id": "1"}) + + +class _patch_slack_standalone_sender: + """Patch the Slack registry entry's ``standalone_sender_fn`` with a wrapper + that replicates the plugin's mrkdwn formatting then delegates to the given + mock in the pre-migration call shape. Mirrors ``_patch_discord_sender``. + + Slack mrkdwn formatting moved INTO the plugin's ``_standalone_send`` when + the adapter migrated (#41112) โ€” previously ``_send_to_platform`` formatted + the message before calling the old ``_send_slack`` helper. This wrapper + keeps the "markdown โ†’ Slack mrkdwn reaches the wire" behavior tests valid. + """ + + def __init__(self, mock): + self._mock = mock + self._entry = None + self._original = None + + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, **_kw): + from plugins.platforms.slack.adapter import SlackAdapter + formatted = message + if message: + try: + formatted = SlackAdapter.__new__(SlackAdapter).format_message(message) + except Exception: + pass + token = getattr(pconfig, "token", None) + return await self._mock(token, chat_id, formatted, thread_ts=thread_id) + + def __enter__(self): + self._entry = _slack_entry() + self._original = self._entry.standalone_sender_fn + self._entry.standalone_sender_fn = self._adapter + return self._mock + + def __exit__(self, exc_type, exc, tb): + if self._entry is not None: + self._entry.standalone_sender_fn = self._original + return False + + def _run_async_immediately(coro): return asyncio.run(coro) @@ -617,12 +678,12 @@ def test_long_message_is_chunked(self): def test_slack_messages_are_formatted_before_send(self, monkeypatch): _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) + send = _make_recording_slack_sender() - with patch("tools.send_message_tool._send_slack", send): + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -643,11 +704,11 @@ def test_slack_messages_are_formatted_before_send(self, monkeypatch): def test_slack_bold_italic_formatted_before_send(self, monkeypatch): """Bold+italic ***text*** survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -663,11 +724,11 @@ def test_slack_bold_italic_formatted_before_send(self, monkeypatch): def test_slack_blockquote_formatted_before_send(self, monkeypatch): """Blockquote '>' markers must survive formatting (not escaped to '>').""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -685,10 +746,10 @@ def test_slack_blockquote_formatted_before_send(self, monkeypatch): def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -706,10 +767,10 @@ def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): """Wikipedia-style URL with parens survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, diff --git a/tests/tools/test_signal_media.py b/tests/tools/test_signal_media.py index 6d1bc2112ebd0..db40d45e331a2 100644 --- a/tests/tools/test_signal_media.py +++ b/tests/tools/test_signal_media.py @@ -156,13 +156,23 @@ def test_warning_includes_signal_when_media_omitted(self): if not hasattr(httpx, 'Proxy') or not hasattr(httpx, 'URL'): pytest.skip("httpx type annotations incompatible with telegram library") from tools.send_message_tool import _send_to_platform + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry config = MagicMock() config.platforms = {Platform.SLACK: MagicMock(enabled=True)} config.get_home_channel.return_value = None - # Mock _send_slack so it succeeds -> then warning gets attached to result - with patch("tools.send_message_tool._send_slack", new=AsyncMock(return_value={"success": True})): + # Slack migrated to a bundled plugin (#41112) โ€” delivery now flows + # through the registry's standalone_sender_fn instead of the old + # tools.send_message_tool._send_slack helper. Patch the registry entry's + # sender so the slack send succeeds and the media-omitted warning (which + # must mention signal) gets attached to the result. + discover_plugins() + slack_entry = platform_registry.get("slack") + original_sender = slack_entry.standalone_sender_fn + slack_entry.standalone_sender_fn = AsyncMock(return_value={"success": True}) + try: result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -172,6 +182,8 @@ def test_warning_includes_signal_when_media_omitted(self): media_files=[("/tmp/test.png", False)] ) ) + finally: + slack_entry.standalone_sender_fn = original_sender assert result.get("warnings") is not None # Check that the warning mentions signal as supported diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 53a9fc6003753..9c4d25b7edebf 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -589,7 +589,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, """ from gateway.config import Platform from gateway.platforms.base import BasePlatformAdapter, utf16_len - from gateway.platforms.slack import SlackAdapter # Telegram adapter import is optional (requires python-telegram-bot) try: @@ -607,18 +606,16 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files = media_files or [] - if platform == Platform.SLACK and message: - try: - slack_adapter = SlackAdapter.__new__(SlackAdapter) - message = slack_adapter.format_message(message) - except Exception: - logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) + # Slack mrkdwn formatting is applied inside the slack plugin's + # _standalone_send (the registry standalone_sender_fn) rather than here โ€” + # the SlackAdapter moved to plugins/platforms/slack/ in #41112. # Platform message length limits (from adapter class attributes for - # built-in platforms; from PlatformEntry.max_message_length for plugins). + # built-in platforms; from PlatformEntry.max_message_length for plugins, + # resolved via the registry fallback below โ€” this covers Slack, which + # migrated to a plugin in #41112). _MAX_LENGTHS = { Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, - Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, } if _feishu_available: _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH @@ -777,7 +774,17 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, chunk, thread_ts=thread_id) + # Slack migrated to a bundled plugin (#41112); delivery flows + # through the registry's standalone_sender_fn, which applies + # mrkdwn formatting and posts via the Slack Web API. + from gateway.platform_registry import platform_registry + _slack_entry = platform_registry.get("slack") + if _slack_entry is None or _slack_entry.standalone_sender_fn is None: + result = {"error": "Slack plugin not registered or missing standalone_sender_fn"} + else: + result = await _slack_entry.standalone_sender_fn( + pconfig, chat_id, chunk, thread_id=thread_id + ) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) elif platform == Platform.SIGNAL: @@ -1057,29 +1064,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_slack(token, chat_id, message, thread_ts=None): - """Send via Slack Web API.""" - try: - import aiohttp - except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} - try: - from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp - _proxy = resolve_proxy_url() - _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) - url = "https://slack.com/api/chat.postMessage" - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: - payload = {"channel": chat_id, "text": message, "mrkdwn": True} - if thread_ts: - payload["thread_ts"] = thread_ts - async with session.post(url, headers=headers, json=payload, **_req_kw) as resp: - data = await resp.json() - if data.get("ok"): - return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} - return _error(f"Slack API error: {data.get('error', 'unknown')}") - except Exception as e: - return _error(f"Slack send failed: {e}") +# _send_slack moved to the slack plugin as _standalone_send +# (plugins/platforms/slack/adapter.py), wired via standalone_sender_fn. #41112. async def _send_whatsapp(extra, chat_id, message): From 9ec2a470f9ee2a24484bc57d542f87814b4036e1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:10:09 -0700 Subject: [PATCH 2/2] test(discord): fix component-auth expectations after fail-closed change (#f6f363662) Pre-existing main breakage inherited via rebase, not slack-migration fallout: f6f363662 made discord component-button auth fail closed when no allowlist is set but only updated test_discord_component_auth.py. test_discord_model_picker and two test_discord_clarify_buttons tests drove the authorized-proceed path with an empty allowlist, which now correctly rejects. Give the interacting user an allowlist entry so the proceed path runs (dedicated *_unauthorized_* tests still cover rejection). Same fix as #41284; both PRs un-break main's currently-red CI. --- tests/gateway/test_discord_clarify_buttons.py | 4 ++-- tests/gateway/test_discord_model_picker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index d2157f2eb9b33..95c5bb5cc6309 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -174,7 +174,7 @@ async def test_choice_falls_back_to_label_text_when_entry_missing(self): view = ClarifyChoiceView( choices=["alpha"], clarify_id="cidGone", - allowed_user_ids=set(), + allowed_user_ids={"42"}, ) interaction = _make_interaction() # Doesn't raise; resolve_gateway_clarify returns False quietly @@ -245,7 +245,7 @@ async def test_other_flips_entry_to_awaiting_text(self): view = ClarifyChoiceView( choices=["x", "y"], clarify_id="cidD", - allowed_user_ids=set(), + allowed_user_ids={"42"}, ) interaction = _make_interaction() diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py index 2ee4e86a38de4..2720e34811c64 100644 --- a/tests/gateway/test_discord_model_picker.py +++ b/tests/gateway/test_discord_model_picker.py @@ -54,7 +54,7 @@ async def edit_original_response(**kwargs): current_provider="copilot", session_key="session-1", on_model_selected=on_model_selected, - allowed_user_ids=set(), + allowed_user_ids={"123"}, ) view._selected_provider = "copilot"