mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 05:09:01 +08:00
Compare commits
2 Commits
feat/plugi
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec2a470f9 | ||
|
|
c2eae4795b |
@@ -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"]``)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
3
plugins/platforms/slack/__init__.py
Normal file
3
plugins/platforms/slack/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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 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,
|
||||
)
|
||||
39
plugins/platforms/slack/plugin.yaml
Normal file
39
plugins/platforms/slack/plugin.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
57
tests/gateway/test_slack_plugin_setup.py
Normal file
57
tests/gateway/test_slack_plugin_setup.py
Normal 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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user