Compare commits

...

2 Commits

Author SHA1 Message Date
teknium1
9ec2a470f9 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.
2026-06-07 07:10:09 -07:00
teknium1
c2eae4795b 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).
2026-06-07 07:04:43 -07:00
23 changed files with 559 additions and 284 deletions

View File

@@ -969,28 +969,10 @@ def load_gateway_config() -> GatewayConfig:
_, 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 _apply_env_overrides(config: GatewayConfig) -> None:
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 _apply_env_overrides(config: GatewayConfig) -> None:
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"]``)

View File

@@ -6900,13 +6900,6 @@ class GatewayRunner:
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():

View File

@@ -3833,49 +3833,9 @@ _PLATFORMS = [
# 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

View File

@@ -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():

View File

@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@@ -3587,3 +3587,299 @@ class SlackAdapter(BasePlatformAdapter):
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 YAMLenv 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,
)

View File

@@ -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

View File

@@ -121,9 +121,9 @@ import discord # noqa: E402 — mocked above
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

View File

@@ -174,7 +174,7 @@ class TestClarifyChoiceResolve:
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 @@ class TestClarifyOtherButton:
view = ClarifyChoiceView(
choices=["x", "y"],
clarify_id="cidD",
allowed_user_ids=set(),
allowed_user_ids={"42"},
)
interaction = _make_interaction()

View File

@@ -54,7 +54,7 @@ async def test_model_picker_clears_controls_before_running_switch_callback():
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"

View File

@@ -3,7 +3,7 @@ Tests for media download retry logic added in PR #2982.
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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 @@ class TestSlashEphemeralAck:
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 @@ class TestSlashEphemeralAck:
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 @@ class TestSlashEphemeralAck:
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 @@ class TestSlashEphemeralAck:
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 @@ class TestSlashEphemeralAck:
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",

View File

@@ -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

View File

@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
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 {}

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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"),

View File

@@ -479,33 +479,6 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
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

View File

@@ -115,6 +115,67 @@ class _patch_discord_sender:
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 @@ class TestSendToPlatformChunking:
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 @@ class TestSendToPlatformChunking:
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 @@ class TestSendToPlatformChunking:
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 @@ class TestSendToPlatformChunking:
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 @@ class TestSendToPlatformChunking:
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,

View File

@@ -156,13 +156,23 @@ class TestSendSignalMediaWarningMessages:
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 @@ class TestSendSignalMediaWarningMessages:
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

View File

@@ -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):