mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
refactor(gateway): migrate Discord adapter to bundled plugin (full Teams parity)
First migration of an existing built-in platform adapter to the plugin system established by IRC / Teams / LINE / Google Chat. Closes #24325; advances the umbrella refactor in #3823. Matches Teams' shape exactly — adapter under ``plugins/platforms/discord/`` with the standard ``__init__.py`` / ``adapter.py`` / ``plugin.yaml`` shell, ``register(ctx)`` entry point, **no back-compat shim** at the old import path, and full parity for the four hooks Teams uses plus the ``apply_yaml_config_fn`` hook that landed in #25443 (the Discord plugin is the first consumer of that hook): * ``standalone_sender_fn`` — out-of-process cron delivery via REST API * ``setup_fn`` — interactive ``hermes setup gateway`` wizard * ``apply_yaml_config_fn`` — translate ``config.yaml`` ``discord:`` keys into ``DISCORD_*`` env vars (replaces the hardcoded block in ``gateway/config.py``) * ``is_connected`` — declares connection state from ``DISCORD_BOT_TOKEN`` * ``check_fn`` — lazy-installs ``discord.py`` on demand * plus ``allowed_users_env``, ``allow_all_env``, ``cron_deliver_env_var``, ``max_message_length``, ``emoji``, ``required_env``, ``install_hint`` * ``gateway/platforms/discord.py`` (5,101 LOC) → ``plugins/platforms/discord/adapter.py`` (git rename, R090). * New ``plugins/platforms/discord/{__init__.py, plugin.yaml}`` with ``requires_env`` / ``optional_env`` declarations. * Append ``register(ctx)`` block + new hook implementations (``_standalone_send``, ``interactive_setup``, ``_apply_yaml_config``, ``_clean_discord_user_ids``, ``_is_connected``, ``_build_adapter``, plus helpers ``_DISCORD_CHANNEL_TYPE_PROBE_CACHE`` etc.) to the adapter. * Replace the ``Platform.DISCORD elif`` branch in ``GatewayRunner._create_adapter()`` (−9 LOC) with a generic post-creation hook (+6 LOC) in the registry path: any plugin adapter that declares a ``gateway_runner`` attribute now gets it auto-injected. Webhook's built-in branch is unchanged (it doesn't go through the registry path). * Move ``_send_discord`` (190 LOC) and helpers (``_DISCORD_CHANNEL_TYPE_PROBE_CACHE``, ``_remember_channel_is_forum``, ``_probe_is_forum_cached``, ``_derive_forum_thread_name``) from ``tools/send_message_tool.py`` into the plugin as ``_standalone_send``. * Wire via ``standalone_sender_fn=_standalone_send`` (Teams pattern; same gap fixed in #21804 for other plugin platforms). * Replace the Discord ``elif`` in ``tools/send_message_tool.py`` ``_send_to_platform`` with a 10-line registry-hook dispatch. * Drop the ``DiscordAdapter`` import and the ``Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH`` ``_MAX_LENGTHS`` entry — the registry's ``max_message_length=2000`` covers it. * Move ``_setup_discord`` and ``_clean_discord_user_ids`` (68 LOC) from ``hermes_cli/setup.py`` into the plugin as ``interactive_setup``. * Wire via ``setup_fn=interactive_setup``. CLI helpers (``prompt``, ``print_info``, etc.) are lazy-imported so the plugin's module-load surface stays minimal. * Remove ``"discord": _s._setup_discord`` from ``hermes_cli/gateway.py::_builtin_setup_fn``. * Remove the entire 32-line ``_PLATFORMS["discord"]`` static dict entry — Discord's setup metadata is now discovered dynamically via ``_all_platforms()`` from the registry entry. * Move the 59-line ``discord_cfg`` YAML→env bridge from ``gateway/config.py::load_gateway_config()`` into the plugin as ``_apply_yaml_config``. Covers ``require_mention``, ``thread_require_mention``, ``free_response_channels``, ``auto_thread``, ``reactions``, ``ignored_channels``, ``allowed_channels``, ``no_thread_channels``, ``allow_mentions.{everyone,roles,users, replied_user}``, and ``reply_to_mode`` (including the YAML 1.1 ``off``-as-False coercion and the ``extra.reply_to_mode`` fallback). * Wire via ``apply_yaml_config_fn=_apply_yaml_config``. * The hook runs BEFORE ``_apply_env_overrides`` and after the generic shared-key loop, exactly as documented in ``website/docs/developer-guide/adding-platform-adapters.md``. * Behavior is preserved exactly — every assignment still uses ``not os.getenv(...)`` guards so env vars take precedence over YAML. All 78 references to the old import path are rewritten — no back-compat shim: * 51 ``from gateway.platforms.discord import X`` → ``from plugins.platforms.discord.adapter import X`` * 5 ``import gateway.platforms.discord as discord_platform`` → ``import plugins.platforms.discord.adapter as discord_platform`` * 1 ``from gateway.platforms import discord as discord_mod`` → ``from plugins.platforms.discord import adapter as discord_mod`` * 21 ``mock.patch("gateway.platforms.discord.X")`` strings → ``mock.patch("plugins.platforms.discord.adapter.X")`` * 1 docstring reference in ``hermes_cli/commands.py`` * 1 import in ``tools/send_message_tool.py`` (now removed entirely) The import-safety test in ``tests/gateway/test_discord_imports.py`` is updated to purge the new canonical module name from ``sys.modules``. **38 files changed, +621 / −473** — net positive due to the YAML hook implementation (89 new LOC in the plugin trading for 59 deleted in core), but every line moved has a clear plugin home now. The git rename is detected at R090 because the adapter gained ~340 LOC of moved-in hook implementations (``_standalone_send`` + ``interactive_setup`` + ``_apply_yaml_config`` + helpers). * All 568 Discord-specific tests pass across 25 ``test_discord_*.py`` files plus voice/send/text-batching/reload-skills/stream-consumer/ integration tests. * All 147 tests in the YAML-touching subset (``test_discord_reply_mode``, ``test_discord_free_response``, ``test_discord_allowed_channels``, ``test_discord_allowed_mentions``, ``test_discord_channel_controls``, ``test_discord_reactions``, ``test_discord_thread_persistence``, ``test_runtime_footer``) pass — this is the strongest signal that the YAML→env hook behaves identically to the legacy block. * Broader gateway/cron/integration sweep (1297 tests) introduces zero new failures vs ``main``. Pre-existing failures in ``tests/gateway/test_tts_media_routing.py`` and ``tests/e2e/test_platform_commands.py`` reproduce identically on the unchanged ``main`` revision. * Plugin discovery sanity check confirms Discord registers alongside the other four platform plugins: Registered platforms: ['discord', 'google_chat', 'irc', 'line', 'teams'] These Discord-shaped tendrils in core were **deliberately not moved** — they are generic platform-registry concerns affecting every platform, not Discord-specific: * ``gateway/config.py:1205`` ``DISCORD_BOT_TOKEN → config.token`` env enablement — same shape Telegram has. The existing ``env_enablement_fn`` registry hook only seeds ``extra``, not ``.token``, so it can't replace this without an adapter refactor to read from ``extra["bot_token"]``. * ``gateway/run.py`` voice-mode hooks (``self.adapters.get(Platform.DISCORD)`` for ``start_voice_mode``/``stop_voice_mode``), role-based auth, ``DISCORD_ALLOW_BOTS`` branch in ``_is_user_authorized``, ``_UPDATE_ALLOWED_PLATFORMS`` frozenset, and the per-platform allowlist maps — generic platform-registry concerns. * ``Platform.DISCORD`` enum literal — stable identifier used as dict keys throughout the codebase; removing it is a separate refactor with no real benefit. * ``tools/discord_tool.py`` and ``tools/environments/local.py`` — first-class agent tools and env-passthrough config, neither is the gateway adapter. Each of these is worth its own scoping issue when the time comes.
This commit is contained in:
parent
4f988634f8
commit
cc8e5ec2af
41 changed files with 728 additions and 504 deletions
|
|
@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig:
|
|||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
if isinstance(discord_cfg, dict):
|
||||
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||
# ignored_channels: channels where bot never responds (even when mentioned)
|
||||
ic = discord_cfg.get("ignored_channels")
|
||||
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
|
||||
if isinstance(ic, list):
|
||||
ic = ",".join(str(v) for v in ic)
|
||||
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = discord_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
|
||||
# no_thread_channels: channels where bot responds directly without creating thread
|
||||
ntc = discord_cfg.get("no_thread_channels")
|
||||
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||
if isinstance(ntc, list):
|
||||
ntc = ",".join(str(v) for v in ntc)
|
||||
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||
# history_backfill: recover missed channel messages for shared sessions
|
||||
# when require_mention is active. Fetches messages between bot turns
|
||||
# and prepends them to the user message for context.
|
||||
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
|
||||
hbl = discord_cfg.get("history_backfill_limit")
|
||||
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
|
||||
# allow_mentions: granular control over what the bot can ping.
|
||||
# Safe defaults (no @everyone/roles) are applied in the adapter;
|
||||
# these YAML keys only override when set and let users opt back
|
||||
# into unsafe modes (e.g. roles=true) if they actually want it.
|
||||
allow_mentions_cfg = discord_cfg.get("allow_mentions")
|
||||
if isinstance(allow_mentions_cfg, dict):
|
||||
for yaml_key, env_key in (
|
||||
("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
|
||||
("roles", "DISCORD_ALLOW_MENTION_ROLES"),
|
||||
("users", "DISCORD_ALLOW_MENTION_USERS"),
|
||||
("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
|
||||
):
|
||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
|
||||
_discord_rtm = (
|
||||
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
|
||||
else _discord_extra.get("reply_to_mode")
|
||||
)
|
||||
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
|
||||
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
|
||||
|
||||
# Bridge top-level require_mention to Telegram when the telegram: section
|
||||
# does not already provide one. Users often write "require_mention: true"
|
||||
# at the top level alongside group_sessions_per_user, expecting it to work
|
||||
|
|
|
|||
|
|
@ -5897,6 +5897,12 @@ class GatewayRunner:
|
|||
if platform_registry.is_registered(platform.value):
|
||||
adapter = platform_registry.create_adapter(platform.value, config)
|
||||
if adapter is not None:
|
||||
# Adapters that need a back-reference to the gateway runner
|
||||
# (e.g. for cross-platform admin alerts) declare a
|
||||
# ``gateway_runner`` attribute. Inject it after creation so
|
||||
# plugin adapters don't need a custom factory signature.
|
||||
if hasattr(adapter, "gateway_runner"):
|
||||
adapter.gateway_runner = self
|
||||
return adapter
|
||||
# Registered but failed to instantiate — don't silently fall
|
||||
# through to built-ins (there are none for plugin platforms).
|
||||
|
|
@ -5939,15 +5945,6 @@ class GatewayRunner:
|
|||
adapter._notifications_mode = _notify_mode
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.DISCORD:
|
||||
from gateway.platforms.discord import DiscordAdapter, check_discord_requirements
|
||||
if not check_discord_requirements():
|
||||
logger.warning("Discord: discord.py not installed")
|
||||
return None
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter.gateway_runner = self # For cross-platform admin alerts on unauthorized slash
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.WHATSAPP:
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
|
||||
if not check_whatsapp_requirements():
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
|||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||||
(via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in
|
||||
Discord's native slash command picker.
|
||||
|
||||
Lookup is lazy so importing this module never forces plugin discovery
|
||||
|
|
|
|||
|
|
@ -3327,34 +3327,9 @@ _PLATFORMS = [
|
|||
"help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "discord",
|
||||
"label": "Discord",
|
||||
"emoji": "💬",
|
||||
"token_var": "DISCORD_BOT_TOKEN",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://discord.com/developers/applications → New Application",
|
||||
"2. Go to Bot → Reset Token → copy the bot token",
|
||||
"3. Enable: Bot → Privileged Gateway Intents → Message Content Intent",
|
||||
"4. Invite the bot to your server:",
|
||||
" OAuth2 → URL Generator → check BOTH scopes:",
|
||||
" - bot",
|
||||
" - applications.commands (required for slash commands!)",
|
||||
" Bot Permissions: Send Messages, Read Message History, Attach Files",
|
||||
" Copy the URL and open it in your browser to invite.",
|
||||
"5. Get your user ID: enable Developer Mode in Discord settings,",
|
||||
" then right-click your name → Copy ID",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "DISCORD_BOT_TOKEN", "prompt": "Bot token", "password": True,
|
||||
"help": "Paste the token from step 2 above."},
|
||||
{"name": "DISCORD_ALLOWED_USERS", "prompt": "Allowed user IDs or usernames (comma-separated)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Paste your user ID from step 5 above."},
|
||||
{"name": "DISCORD_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False,
|
||||
"help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."},
|
||||
],
|
||||
},
|
||||
# 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",
|
||||
|
|
@ -4747,7 +4722,9 @@ def _builtin_setup_fn(key: str):
|
|||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
# 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,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
|
|
|
|||
|
|
@ -2034,74 +2034,6 @@ def _setup_telegram():
|
|||
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_discord():
|
||||
"""Configure Discord bot credentials and allowlist."""
|
||||
print_header("Discord")
|
||||
existing = get_env_value("DISCORD_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Discord: already configured")
|
||||
if not prompt_yes_no("Reconfigure Discord?", False):
|
||||
if not get_env_value("DISCORD_ALLOWED_USERS"):
|
||||
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
|
||||
if prompt_yes_no("Add allowed users now?", True):
|
||||
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
return
|
||||
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
print_success("Discord token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your Discord user ID:")
|
||||
print_info(" 1. Enable Developer Mode in Discord settings")
|
||||
print_info(" 2. Right-click your name → Copy ID")
|
||||
print()
|
||||
print_info(" You can also use Discord usernames (resolved on gateway start).")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
||||
|
||||
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: right-click a channel → Copy Channel ID")
|
||||
print_info(" (requires Developer Mode in Discord settings)")
|
||||
print_info(" You can also set this later by typing /set-home in a Discord channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _clean_discord_user_ids(raw: str) -> list:
|
||||
"""Strip common Discord mention prefixes from a comma-separated ID string."""
|
||||
cleaned = []
|
||||
for uid in raw.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned.append(uid)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _setup_slack():
|
||||
"""Configure Slack bot credentials."""
|
||||
print_header("Slack")
|
||||
|
|
|
|||
3
plugins/platforms/discord/__init__.py
Normal file
3
plugins/platforms/discord/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -1489,7 +1489,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
reported in ``raw_response['warnings']`` so the caller can surface
|
||||
partial-send issues.
|
||||
"""
|
||||
from tools.send_message_tool import _derive_forum_thread_name
|
||||
# _derive_forum_thread_name is defined further down in this same
|
||||
# module — no cross-module import needed.
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
|
|
@ -1551,7 +1552,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
ForumChannel accepts the same file/files/content kwargs as
|
||||
``channel.send``, creating the thread and starter message atomically.
|
||||
"""
|
||||
from tools.send_message_tool import _derive_forum_thread_name
|
||||
# _derive_forum_thread_name is defined further down in this same
|
||||
# module — no cross-module import needed.
|
||||
|
||||
if not thread_name:
|
||||
# Prefer the text content, fall back to the first attached
|
||||
|
|
@ -5699,7 +5701,485 @@ def _define_discord_view_classes() -> None:
|
|||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
|
||||
if DISCORD_AVAILABLE:
|
||||
_define_discord_view_classes()
|
||||
|
||||
|
||||
# ── Standalone (out-of-process) sender ────────────────────────────────────────
|
||||
# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner
|
||||
# is not in this process (e.g. ``hermes cron`` running standalone) and no live
|
||||
# DiscordAdapter instance is available. Implements the same forum/thread/
|
||||
# multipart logic the live adapter would use, via Discord's REST API directly.
|
||||
#
|
||||
# This block was previously hosted in ``tools/send_message_tool.py`` as
|
||||
# ``_send_discord``. It moved into the plugin so all Discord-specific HTTP
|
||||
# logic lives next to the adapter — same shape as Teams' ``_standalone_send``.
|
||||
|
||||
# Process-local cache for Discord channel-type probes. Avoids re-probing the
|
||||
# same channel on every send when the directory cache has no entry (e.g. fresh
|
||||
# install, or channel created after the last directory build).
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
|
||||
|
||||
|
||||
def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
|
||||
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
|
||||
|
||||
|
||||
def _derive_forum_thread_name(message: str) -> str:
|
||||
"""Derive a thread name from the first line of the message, capped at 100 chars."""
|
||||
first_line = message.strip().split("\n", 1)[0].strip()
|
||||
# Strip common markdown heading prefixes
|
||||
first_line = first_line.lstrip("#").strip()
|
||||
if not first_line:
|
||||
first_line = "New Post"
|
||||
return first_line[:100]
|
||||
|
||||
|
||||
def _standalone_sanitize_error(text) -> str:
|
||||
"""Local copy of tools.send_message_tool._sanitize_error_text — strips bot
|
||||
tokens from any error payload before bubbling it up. Inlined so the
|
||||
plugin doesn't introduce a hard dependency on send_message_tool internals.
|
||||
"""
|
||||
s = str(text)
|
||||
# Mask anything that looks like a Bot token in an Authorization header.
|
||||
import re as _re_san
|
||||
return _re_san.sub(
|
||||
r"(Authorization:\s*Bot\s+)\S+",
|
||||
r"\1***",
|
||||
s,
|
||||
flags=_re_san.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[list] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send via Discord REST API without a live gateway adapter.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process. Reads ``DISCORD_BOT_TOKEN`` from
|
||||
``pconfig.token`` (set by the gateway config loader from env) and falls
|
||||
back to the ``DISCORD_BOT_TOKEN`` env var.
|
||||
|
||||
Forum channels (type 15) reject ``POST /messages`` — a thread post is
|
||||
created automatically via ``POST /channels/{id}/threads``. Media files
|
||||
are uploaded as multipart attachments on the starter message of the new
|
||||
thread. Channel type is resolved from the channel directory first, then
|
||||
a process-local probe cache, and only as a last resort with a live
|
||||
``GET /channels/{id}`` probe (whose result is memoized).
|
||||
|
||||
``force_document`` is accepted for signature parity but unused — Discord
|
||||
treats every uploaded file as a generic attachment.
|
||||
"""
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
|
||||
token = (getattr(pconfig, "token", None) or os.getenv("DISCORD_BOT_TOKEN", "")).strip()
|
||||
if not token:
|
||||
return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"}
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
auth_headers = {"Authorization": f"Bot {token}"}
|
||||
json_headers = {**auth_headers, "Content-Type": "application/json"}
|
||||
media_files = media_files or []
|
||||
last_data = None
|
||||
warnings = []
|
||||
|
||||
# Thread endpoint: Discord threads are channels; send directly to the thread ID.
|
||||
if thread_id:
|
||||
url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
|
||||
else:
|
||||
# Check if the target channel is a forum channel (type 15).
|
||||
# Forum channels reject POST /messages — create a thread post instead.
|
||||
# Three-layer detection: directory cache → process-local probe
|
||||
# cache → GET /channels/{id} probe (with result memoized).
|
||||
_channel_type = None
|
||||
try:
|
||||
from gateway.channel_directory import lookup_channel_type
|
||||
_channel_type = lookup_channel_type("discord", chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _channel_type == "forum":
|
||||
is_forum = True
|
||||
elif _channel_type is not None:
|
||||
is_forum = False
|
||||
else:
|
||||
cached = _probe_is_forum_cached(chat_id)
|
||||
if cached is not None:
|
||||
is_forum = cached
|
||||
else:
|
||||
is_forum = False
|
||||
try:
|
||||
info_url = f"https://discord.com/api/v10/channels/{chat_id}"
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
|
||||
async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
|
||||
if info_resp.status == 200:
|
||||
info = await info_resp.json()
|
||||
is_forum = info.get("type") == 15
|
||||
_remember_channel_is_forum(chat_id, is_forum)
|
||||
except Exception:
|
||||
logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
|
||||
|
||||
if is_forum:
|
||||
thread_name = _derive_forum_thread_name(message)
|
||||
thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
|
||||
|
||||
# Filter to readable media files up front so we can pick the
|
||||
# right code path (JSON vs multipart) before opening a session.
|
||||
valid_media = []
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
valid_media.append(media_path)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
|
||||
if valid_media:
|
||||
# Multipart: payload_json + files[N] creates a forum
|
||||
# thread with the starter message plus attachments in
|
||||
# a single API call.
|
||||
attachments_meta = [
|
||||
{"id": str(idx), "filename": os.path.basename(path)}
|
||||
for idx, path in enumerate(valid_media)
|
||||
]
|
||||
starter_message = {"content": message, "attachments": attachments_meta}
|
||||
payload_json = json.dumps({"name": thread_name, "message": starter_message})
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("payload_json", payload_json, content_type="application/json")
|
||||
|
||||
try:
|
||||
for idx, media_path in enumerate(valid_media):
|
||||
with open(media_path, "rb") as fh:
|
||||
form.add_field(
|
||||
f"files[{idx}]",
|
||||
fh.read(),
|
||||
filename=os.path.basename(media_path),
|
||||
)
|
||||
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
except Exception as e:
|
||||
return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")}
|
||||
else:
|
||||
# No media — simple JSON POST creates the thread with
|
||||
# just the text starter.
|
||||
async with session.post(
|
||||
thread_url,
|
||||
headers=json_headers,
|
||||
json={
|
||||
"name": thread_name,
|
||||
"message": {"content": message},
|
||||
},
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
|
||||
thread_id_created = data.get("id")
|
||||
starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
|
||||
result = {
|
||||
"success": True,
|
||||
"platform": "discord",
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id_created,
|
||||
"message_id": starter_msg_id,
|
||||
}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
|
||||
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
||||
# Send text message (skip if empty and media is present)
|
||||
if message.strip() or not media_files:
|
||||
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord API error ({resp.status}): {body}"}
|
||||
last_data = await resp.json()
|
||||
|
||||
# Send each media file as a separate multipart upload
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
try:
|
||||
form = aiohttp.FormData()
|
||||
filename = os.path.basename(media_path)
|
||||
with open(media_path, "rb") as f:
|
||||
form.add_field("files[0]", f, filename=filename)
|
||||
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
last_data = await resp.json()
|
||||
except Exception as e:
|
||||
warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
|
||||
if last_data is None:
|
||||
error = "No deliverable text or media remained after processing"
|
||||
if warnings:
|
||||
return {"error": error, "warnings": warnings}
|
||||
return {"error": error}
|
||||
|
||||
result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": _standalone_sanitize_error(f"Discord send failed: {e}")}
|
||||
|
||||
|
||||
# ── Plugin entry point ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean_discord_user_ids(raw: str) -> list:
|
||||
"""Strip common Discord mention prefixes from a comma-separated ID string."""
|
||||
cleaned = []
|
||||
for uid in raw.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned.append(uid)
|
||||
return cleaned
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through Discord bot setup.
|
||||
|
||||
Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so
|
||||
the plugin's import surface stays small, prompts for the bot token,
|
||||
captures an allowlist, and offers to set a home channel.
|
||||
"""
|
||||
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_header("Discord")
|
||||
existing = get_env_value("DISCORD_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Discord: already configured")
|
||||
if not prompt_yes_no("Reconfigure Discord?", False):
|
||||
if not get_env_value("DISCORD_ALLOWED_USERS"):
|
||||
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
|
||||
if prompt_yes_no("Add allowed users now?", True):
|
||||
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
return
|
||||
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
print_success("Discord token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your Discord user ID:")
|
||||
print_info(" 1. Enable Developer Mode in Discord settings")
|
||||
print_info(" 2. Right-click your name → Copy ID")
|
||||
print()
|
||||
print_info(" You can also use Discord usernames (resolved on gateway start).")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
||||
|
||||
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: right-click a channel → Copy Channel ID")
|
||||
print_info(" (requires Developer Mode in Discord settings)")
|
||||
print_info(" You can also set this later by typing /set-home in a Discord channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
||||
"""Translate ``config.yaml`` ``discord:`` keys into env vars.
|
||||
|
||||
Implements the ``apply_yaml_config_fn`` contract (#24836). Mirrors the
|
||||
legacy ``discord_cfg`` block that used to live in
|
||||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
|
||||
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
|
||||
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
|
||||
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
|
||||
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
|
||||
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
|
||||
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
|
||||
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
|
||||
inside the adapter to read from ``PlatformConfig.extra`` instead, this
|
||||
hook keeps the existing env-driven model and merely 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 discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||
# ignored_channels: channels where bot never responds (even when mentioned)
|
||||
ic = discord_cfg.get("ignored_channels")
|
||||
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
|
||||
if isinstance(ic, list):
|
||||
ic = ",".join(str(v) for v in ic)
|
||||
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = discord_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
|
||||
# no_thread_channels: channels where bot responds directly without creating thread
|
||||
ntc = discord_cfg.get("no_thread_channels")
|
||||
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||
if isinstance(ntc, list):
|
||||
ntc = ",".join(str(v) for v in ntc)
|
||||
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||
# history_backfill: recover missed channel messages for shared sessions
|
||||
# when require_mention is active. Fetches messages between bot turns
|
||||
# and prepends them to the user message for context.
|
||||
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
|
||||
hbl = discord_cfg.get("history_backfill_limit")
|
||||
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
|
||||
# allow_mentions: granular control over what the bot can ping.
|
||||
# Safe defaults (no @everyone/roles) are applied in the adapter;
|
||||
# these YAML keys only override when set and let users opt back
|
||||
# into unsafe modes (e.g. roles=true) if they actually want it.
|
||||
allow_mentions_cfg = discord_cfg.get("allow_mentions")
|
||||
if isinstance(allow_mentions_cfg, dict):
|
||||
for yaml_key, env_key in (
|
||||
("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
|
||||
("roles", "DISCORD_ALLOW_MENTION_ROLES"),
|
||||
("users", "DISCORD_ALLOW_MENTION_USERS"),
|
||||
("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
|
||||
):
|
||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode.
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
|
||||
_discord_rtm = (
|
||||
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
|
||||
else _discord_extra.get("reply_to_mode")
|
||||
)
|
||||
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
|
||||
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
|
||||
return None # all settings flow through env; nothing to merge into extras
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Discord is considered connected when DISCORD_BOT_TOKEN is set."""
|
||||
import os
|
||||
return bool(os.getenv("DISCORD_BOT_TOKEN", "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs DiscordAdapter from a PlatformConfig."""
|
||||
return DiscordAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="discord",
|
||||
label="Discord",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_discord_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["DISCORD_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[discord]'",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
|
||||
setup_fn=interactive_setup,
|
||||
# YAML→env config bridge — owns the translation of ``config.yaml``
|
||||
# ``discord:`` keys (require_mention, free_response_channels,
|
||||
# auto_thread, reactions, ignored_channels, allowed_channels,
|
||||
# no_thread_channels, allow_mentions.*, reply_to_mode,
|
||||
# thread_require_mention) into ``DISCORD_*`` env vars that the
|
||||
# adapter reads via ``os.getenv()``. Replaces the hardcoded block
|
||||
# that used to live in ``gateway/config.py``. Hook contract: #24836.
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="DISCORD_ALLOWED_USERS",
|
||||
allow_all_env="DISCORD_ALLOW_ALL_USERS",
|
||||
# Cron home-channel delivery
|
||||
cron_deliver_env_var="DISCORD_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via Discord REST API. Without this
|
||||
# hook, ``deliver=discord`` cron jobs fail with "No live adapter"
|
||||
# when cron runs separately from the gateway. Mirrors Teams pattern.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Discord hard limit per message
|
||||
max_message_length=2000,
|
||||
# Display
|
||||
emoji="🎮",
|
||||
allow_update_command=True,
|
||||
)
|
||||
34
plugins/platforms/discord/plugin.yaml
Normal file
34
plugins/platforms/discord/plugin.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: discord-platform
|
||||
label: Discord
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Discord gateway adapter for Hermes Agent.
|
||||
Connects to Discord via the discord.py library and relays messages
|
||||
between Discord guilds/DMs and the Hermes agent. Supports voice mode,
|
||||
slash commands, free-response channels, role-based DM auth, threads,
|
||||
reactions, and channel skill bindings.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: DISCORD_BOT_TOKEN
|
||||
description: "Discord bot token"
|
||||
prompt: "Discord bot token"
|
||||
url: "https://discord.com/developers/applications"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: DISCORD_ALLOWED_USERS
|
||||
description: "Comma-separated Discord user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: DISCORD_ALLOW_ALL_USERS
|
||||
description: "Allow any Discord user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Discord home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
|
|
@ -119,7 +119,7 @@ _ensure_slack_mock()
|
|||
|
||||
import discord # noqa: E402 — mocked above
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import _build_allowed_mentions # noqa: E402
|
||||
from plugins.platforms.discord.adapter import _build_allowed_mentions # noqa: E402
|
||||
|
||||
|
||||
# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
from gateway.platforms.base import MessageType # noqa: E402
|
||||
|
||||
|
||||
|
|
@ -146,10 +146,10 @@ class TestCacheDiscordImage:
|
|||
att = _make_attachment_with_read(_PNG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
return_value="/tmp/cached.png",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_image(att, ".png")
|
||||
|
|
@ -165,9 +165,9 @@ class TestCacheDiscordImage:
|
|||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.png",
|
||||
) as mock_url:
|
||||
|
|
@ -186,10 +186,10 @@ class TestCacheDiscordImage:
|
|||
att = _make_attachment_with_read(b"<html>forbidden</html>")
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
side_effect=ValueError("not a valid image"),
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/fallback.png",
|
||||
) as mock_url:
|
||||
|
|
@ -210,10 +210,10 @@ class TestCacheDiscordAudio:
|
|||
att = _make_attachment_with_read(_OGG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/voice.ogg",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_audio(att, ".ogg")
|
||||
|
|
@ -228,7 +228,7 @@ class TestCacheDiscordAudio:
|
|||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.ogg",
|
||||
) as mock_url:
|
||||
|
|
@ -267,7 +267,7 @@ class TestCacheDiscordDocument:
|
|||
att = _make_attachment_without_read() # no .read → forces fallback
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=False
|
||||
"plugins.platforms.discord.adapter.is_safe_url", return_value=False
|
||||
) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
|
||||
with pytest.raises(ValueError, match="SSRF"):
|
||||
await adapter._cache_discord_document(att, ".pdf")
|
||||
|
|
@ -295,7 +295,7 @@ class TestCacheDiscordDocument:
|
|||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=True
|
||||
"plugins.platforms.discord.adapter.is_safe_url", return_value=True
|
||||
), patch("aiohttp.ClientSession", return_value=session):
|
||||
result = await adapter._cache_discord_document(att, ".pdf")
|
||||
|
||||
|
|
@ -320,10 +320,10 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
return_value="/tmp/img_from_read.png",
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url_download:
|
||||
att = SimpleNamespace(
|
||||
|
|
@ -342,7 +342,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
|
||||
# Patch the DMChannel isinstance check so our fake counts as DM.
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
|
|
@ -368,7 +368,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/voice_from_read.ogg",
|
||||
):
|
||||
att = SimpleNamespace(
|
||||
|
|
@ -386,7 +386,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
name = "dm"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
|
|
@ -412,7 +412,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/audio_from_read.ogg",
|
||||
):
|
||||
att = SimpleNamespace(
|
||||
|
|
@ -430,7 +430,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
|||
name = "dm"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeDMChannel:
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch):
|
|||
|
||||
def _make_adapter():
|
||||
_ensure_discord_mock()
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import pytest
|
|||
|
||||
def _make_adapter():
|
||||
"""Create a minimal DiscordAdapter with mocked config."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ if _repo not in sys.path:
|
|||
|
||||
# Triggers the shared discord mock from tests/gateway/conftest.py before
|
||||
# importing the production module.
|
||||
from gateway.platforms.discord import ( # noqa: E402
|
||||
from plugins.platforms.discord.adapter import ( # noqa: E402
|
||||
ClarifyChoiceView,
|
||||
DiscordAdapter,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import pytest
|
|||
|
||||
# Trigger the shared discord mock from tests/gateway/conftest.py before
|
||||
# importing the production module.
|
||||
from gateway.platforms.discord import ( # noqa: E402
|
||||
from plugins.platforms.discord.adapter import ( # noqa: E402
|
||||
ExecApprovalView,
|
||||
ModelPickerView,
|
||||
SlashConfirmView,
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -371,7 +371,7 @@ class TestIncomingDocumentHandling:
|
|||
async def test_image_attachment_unaffected(self, adapter):
|
||||
"""Image attachments should still go through the image path, not the document path."""
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/cached_image.png",
|
||||
):
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeDMChannel:
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ class TestDiscordImportSafety:
|
|||
raise ImportError("discord unavailable for test")
|
||||
return original_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.delitem(sys.modules, "gateway.platforms.discord", raising=False)
|
||||
# Purge the cached module so the import below actually re-runs the
|
||||
# module body with discord.py simulated-missing.
|
||||
monkeypatch.delitem(sys.modules, "plugins.platforms.discord.adapter", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "plugins.platforms.discord", raising=False)
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
module = importlib.import_module("gateway.platforms.discord")
|
||||
module = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
assert module.DISCORD_AVAILABLE is False
|
||||
assert module.discord is None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses:
|
|||
|
||||
def test_registers_all_five_view_classes(self, monkeypatch):
|
||||
"""Calling _define_discord_view_classes() must (re)define all 5 view classes."""
|
||||
dp = importlib.import_module("gateway.platforms.discord")
|
||||
dp = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
# Remove the classes to simulate the state where the module was loaded
|
||||
# with DISCORD_AVAILABLE=False (the lazy-install scenario).
|
||||
|
|
@ -54,7 +54,7 @@ class TestDefineDiscordViewClasses:
|
|||
def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch):
|
||||
"""check_discord_requirements() must call _define_discord_view_classes() on
|
||||
a successful lazy install so view classes exist when DISCORD_AVAILABLE=True."""
|
||||
dp = importlib.import_module("gateway.platforms.discord")
|
||||
dp = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
# Simulate discord not yet available at module load.
|
||||
monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import inspect
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
def test_discord_media_methods_accept_metadata_kwarg():
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
|
|||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.discord import ModelPickerView
|
||||
from plugins.platforms.discord.adapter import ModelPickerView
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ class TestOpusFindLibrary:
|
|||
|
||||
def test_uses_find_library_first(self):
|
||||
"""find_library must be the primary lookup strategy."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.connect)
|
||||
assert "find_library" in source, \
|
||||
"Opus loading must use ctypes.util.find_library"
|
||||
|
||||
def test_homebrew_fallback_is_conditional(self):
|
||||
"""Homebrew paths must only be tried when find_library returns None."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.connect)
|
||||
# Homebrew fallback must exist
|
||||
assert "/opt/homebrew" in source or "homebrew" in source, \
|
||||
|
|
@ -31,7 +31,7 @@ class TestOpusFindLibrary:
|
|||
|
||||
def test_opus_decode_error_logged(self):
|
||||
"""Opus decode failure must log the error, not silently return."""
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
source = inspect.getsource(VoiceReceiver._on_packet)
|
||||
assert "logger" in source, \
|
||||
"_on_packet must log Opus decode errors"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from gateway.config import Platform, PlatformConfig
|
|||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._platform = Platform.DISCORD
|
||||
|
|
@ -60,7 +60,7 @@ async def test_concurrent_joins_do_not_double_connect():
|
|||
channel.guild.id = 42
|
||||
channel.connect = lambda: slow_connect(channel)
|
||||
|
||||
from gateway.platforms import discord as discord_mod
|
||||
from plugins.platforms.discord import adapter as discord_mod
|
||||
with patch.object(discord_mod, "VoiceReceiver",
|
||||
MagicMock(return_value=MagicMock(start=lambda: None))):
|
||||
with patch.object(discord_mod.asyncio, "ensure_future",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
def _set_dm_role_auth_guild(monkeypatch, guild_id=None):
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class TestDiscordThreadPersistence:
|
|||
def _make_adapter(self, tmp_path):
|
||||
"""Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
def _make_adapter():
|
||||
"""Construct a DiscordAdapter without going through __init__ / token checks."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.platforms.base import Platform
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ def _ensure_discord_mock():
|
|||
_ensure_discord_mock()
|
||||
|
||||
import discord as discord_mod_ref # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestDiscordSendImageFile:
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ def _ensure_discord_mock():
|
|||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestDiscordMultiImage:
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class TestEditMessageFinalizeSignature:
|
|||
"module_path,class_name",
|
||||
[
|
||||
("gateway.platforms.telegram", "TelegramAdapter"),
|
||||
("gateway.platforms.discord", "DiscordAdapter"),
|
||||
("plugins.platforms.discord.adapter", "DiscordAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("gateway.platforms.mattermost", "MattermostAdapter"),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def _make_event(
|
|||
|
||||
def _make_discord_adapter():
|
||||
"""Create a minimal DiscordAdapter for testing text batching."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
|
|
|
|||
|
|
@ -511,7 +511,7 @@ class TestDiscordPlayTtsSkip:
|
|||
"""Discord adapter skips play_tts when bot is in a voice channel."""
|
||||
|
||||
def _make_discord_adapter(self):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -599,7 +599,7 @@ class TestVoiceReceiver:
|
|||
"""Test VoiceReceiver silence detection, SSRC mapping, and lifecycle."""
|
||||
|
||||
def _make_receiver(self):
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
mock_vc = MagicMock()
|
||||
mock_vc._connection.secret_key = [0] * 32
|
||||
mock_vc._connection.dave_session = None
|
||||
|
|
@ -1066,7 +1066,7 @@ class TestDiscordVoiceChannelMethods:
|
|||
"""Test DiscordAdapter voice channel methods (join, leave, play, etc.)."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -1208,7 +1208,7 @@ class TestDiscordVoiceChannelMethods:
|
|||
|
||||
pcm_data = b"\x00" * 96000
|
||||
|
||||
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
|
||||
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
|
||||
patch("tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": True, "transcript": "Hello"}), \
|
||||
patch("tools.voice_mode.is_whisper_hallucination", return_value=False):
|
||||
|
|
@ -1223,7 +1223,7 @@ class TestDiscordVoiceChannelMethods:
|
|||
callback = AsyncMock()
|
||||
adapter._voice_input_callback = callback
|
||||
|
||||
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
|
||||
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
|
||||
patch("tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": True, "transcript": "Thank you."}), \
|
||||
patch("tools.voice_mode.is_whisper_hallucination", return_value=True):
|
||||
|
|
@ -1238,7 +1238,7 @@ class TestDiscordVoiceChannelMethods:
|
|||
callback = AsyncMock()
|
||||
adapter._voice_input_callback = callback
|
||||
|
||||
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \
|
||||
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \
|
||||
patch("tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": False, "error": "API error"}):
|
||||
await adapter._process_voice_input(111, 42, b"\x00" * 96000)
|
||||
|
|
@ -1251,7 +1251,7 @@ class TestDiscordVoiceChannelMethods:
|
|||
adapter = self._make_adapter()
|
||||
adapter._voice_input_callback = AsyncMock()
|
||||
|
||||
with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav",
|
||||
with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav",
|
||||
side_effect=RuntimeError("ffmpeg not found")):
|
||||
await adapter._process_voice_input(111, 42, b"\x00" * 96000)
|
||||
# Should not raise
|
||||
|
|
@ -1269,7 +1269,7 @@ class TestVoiceReceiverThreadSafety:
|
|||
"""Verify that VoiceReceiver buffer access is protected by lock."""
|
||||
|
||||
def _make_receiver(self):
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
mock_vc = MagicMock()
|
||||
mock_vc._connection.secret_key = [0] * 32
|
||||
mock_vc._connection.dave_session = None
|
||||
|
|
@ -1282,7 +1282,7 @@ class TestVoiceReceiverThreadSafety:
|
|||
def test_check_silence_holds_lock(self):
|
||||
"""check_silence must hold lock while iterating buffers."""
|
||||
import ast, inspect, textwrap
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence))
|
||||
tree = ast.parse(source)
|
||||
# Find 'with self._lock:' that contains buffer iteration
|
||||
|
|
@ -1303,7 +1303,7 @@ class TestVoiceReceiverThreadSafety:
|
|||
def test_on_packet_buffer_write_holds_lock(self):
|
||||
"""_on_packet must hold lock when writing to buffers."""
|
||||
import ast, inspect, textwrap
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet))
|
||||
tree = ast.parse(source)
|
||||
# Find 'with self._lock:' that contains buffer extend
|
||||
|
|
@ -1670,7 +1670,7 @@ class TestStopAcquiresLock:
|
|||
|
||||
@staticmethod
|
||||
def _make_receiver():
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
vc = MagicMock()
|
||||
vc._connection.secret_key = [0] * 32
|
||||
vc._connection.dave_session = None
|
||||
|
|
@ -1772,7 +1772,7 @@ class TestPacketDebugCounterIsInstanceLevel:
|
|||
|
||||
@staticmethod
|
||||
def _make_receiver():
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
vc = MagicMock()
|
||||
vc._connection.secret_key = [0] * 32
|
||||
vc._connection.dave_session = None
|
||||
|
|
@ -1805,7 +1805,7 @@ class TestPlayInVoiceChannelUsesRunningLoop:
|
|||
def test_source_uses_get_running_loop(self):
|
||||
"""The method source code calls get_running_loop, not get_event_loop."""
|
||||
import inspect
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
|
||||
assert "get_running_loop" in source, \
|
||||
"play_in_voice_channel should use asyncio.get_running_loop()"
|
||||
|
|
@ -1849,7 +1849,7 @@ class TestVoiceTimeoutCleansRunnerState:
|
|||
|
||||
@staticmethod
|
||||
def _make_discord_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -1940,7 +1940,7 @@ class TestPlaybackTimeout:
|
|||
|
||||
@staticmethod
|
||||
def _make_discord_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -1964,7 +1964,7 @@ class TestPlaybackTimeout:
|
|||
def test_source_has_wait_for_timeout(self):
|
||||
"""The method uses asyncio.wait_for with timeout."""
|
||||
import inspect
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
|
||||
assert "wait_for" in source, \
|
||||
"play_in_voice_channel must use asyncio.wait_for for timeout"
|
||||
|
|
@ -1973,14 +1973,14 @@ class TestPlaybackTimeout:
|
|||
|
||||
def test_playback_timeout_constant_exists(self):
|
||||
"""PLAYBACK_TIMEOUT constant is defined on DiscordAdapter."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
assert hasattr(DiscordAdapter, "PLAYBACK_TIMEOUT")
|
||||
assert DiscordAdapter.PLAYBACK_TIMEOUT > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playback_timeout_fires(self):
|
||||
"""When done event is never set, playback times out gracefully."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
adapter = self._make_discord_adapter()
|
||||
|
||||
mock_vc = MagicMock()
|
||||
|
|
@ -2008,7 +2008,7 @@ class TestPlaybackTimeout:
|
|||
@pytest.mark.asyncio
|
||||
async def test_is_playing_wait_has_timeout(self):
|
||||
"""While loop waiting for previous playback has a timeout."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
adapter = self._make_discord_adapter()
|
||||
|
||||
mock_vc = MagicMock()
|
||||
|
|
@ -2124,7 +2124,7 @@ class TestVoiceChannelAwareness:
|
|||
"""Tests for get_voice_channel_info() and get_voice_channel_context()."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -2267,7 +2267,7 @@ class TestVoiceReception:
|
|||
|
||||
@staticmethod
|
||||
def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999):
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
vc = MagicMock()
|
||||
vc._connection.secret_key = [0] * 32
|
||||
vc._connection.dave_session = MagicMock() if dave else None
|
||||
|
|
@ -2451,7 +2451,7 @@ class TestVoiceReception:
|
|||
|
||||
def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None):
|
||||
"""Create a receiver that can process _on_packet with mocked NaCl + Opus."""
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
vc = MagicMock()
|
||||
vc._connection.secret_key = [0] * 32
|
||||
vc._connection.dave_session = dave_session
|
||||
|
|
@ -2593,7 +2593,7 @@ class TestVoiceTTSPlayback:
|
|||
|
||||
@staticmethod
|
||||
def _make_discord_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
|
|
@ -2766,14 +2766,14 @@ class TestUDPKeepalive:
|
|||
"""UDP keepalive prevents Discord from dropping the voice session."""
|
||||
|
||||
def test_keepalive_interval_is_reasonable(self):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
interval = DiscordAdapter._KEEPALIVE_INTERVAL
|
||||
assert 5 <= interval <= 30, f"Keepalive interval {interval}s should be between 5-30s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keepalive_sends_silence_frame(self):
|
||||
"""Listen loop sends silence frame via send_packet after interval."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
|
|
@ -2795,7 +2795,7 @@ class TestUDPKeepalive:
|
|||
adapter._voice_clients[111] = mock_vc
|
||||
mock_vc._connection = mock_conn
|
||||
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
mock_receiver_vc = MagicMock()
|
||||
mock_receiver_vc._connection.secret_key = [0] * 32
|
||||
mock_receiver_vc._connection.dave_session = None
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ except Exception:
|
|||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,16 +28,93 @@ def _reset_signal_scheduler():
|
|||
|
||||
from gateway.config import Platform
|
||||
from tools.send_message_tool import (
|
||||
_derive_forum_thread_name,
|
||||
_is_telegram_thread_not_found,
|
||||
_parse_target_ref,
|
||||
_send_discord,
|
||||
_send_matrix_via_adapter,
|
||||
_send_signal,
|
||||
_send_telegram,
|
||||
_send_to_platform,
|
||||
send_message_tool,
|
||||
)
|
||||
# Discord helpers moved to the plugin in #24325. Import from the new path
|
||||
# and provide a thin ``_send_discord(token, ...)`` shim that mirrors the
|
||||
# pre-migration signature so the existing test bodies keep working.
|
||||
from plugins.platforms.discord.adapter import (
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE,
|
||||
_derive_forum_thread_name,
|
||||
_probe_is_forum_cached,
|
||||
_remember_channel_is_forum,
|
||||
_standalone_send,
|
||||
)
|
||||
|
||||
|
||||
async def _send_discord(
|
||||
token,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
):
|
||||
"""Pre-migration ``(token, chat_id, message, …)`` adapter around the
|
||||
plugin's ``_standalone_send(pconfig, …)``. Lets test bodies continue
|
||||
to call ``_send_discord("tok", ...)`` without rewriting every signature.
|
||||
"""
|
||||
pconfig = SimpleNamespace(token=token, extra={})
|
||||
return await _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
thread_id=thread_id,
|
||||
media_files=media_files,
|
||||
)
|
||||
|
||||
|
||||
def _discord_entry():
|
||||
"""Return the live Discord 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("discord")
|
||||
|
||||
|
||||
class _patch_discord_sender:
|
||||
"""Patch the Discord registry entry's ``standalone_sender_fn`` with the
|
||||
given mock and translate the production ``(pconfig, ...)`` call shape
|
||||
back to the pre-migration ``(token, ...)`` shape the test mocks expect.
|
||||
|
||||
Use as a context manager:
|
||||
|
||||
send_mock = AsyncMock(return_value={...})
|
||||
with _patch_discord_sender(send_mock):
|
||||
asyncio.run(_send_to_platform(Platform.DISCORD, ...))
|
||||
send_mock.assert_awaited_once_with("tok", "chat", "msg",
|
||||
thread_id=None, media_files=[])
|
||||
"""
|
||||
|
||||
def __init__(self, mock):
|
||||
self._mock = mock
|
||||
self._entry = None
|
||||
self._original = None
|
||||
|
||||
async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None):
|
||||
token = getattr(pconfig, "token", None)
|
||||
return await self._mock(
|
||||
token, chat_id, message,
|
||||
thread_id=thread_id, media_files=media_files,
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self._entry = _discord_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):
|
||||
|
|
@ -446,7 +523,7 @@ class TestSendToPlatformChunking:
|
|||
"""Messages exceeding the platform limit are split into multiple sends."""
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
|
||||
with patch("tools.send_message_tool._send_discord", send):
|
||||
with _patch_discord_sender(send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1176,7 +1253,7 @@ class TestSendToPlatformDiscordThread:
|
|||
"""Discord platform with thread_id passes it to _send_discord."""
|
||||
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", send_mock):
|
||||
with _patch_discord_sender(send_mock):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1196,7 +1273,7 @@ class TestSendToPlatformDiscordThread:
|
|||
"""Discord platform without thread_id passes None."""
|
||||
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", send_mock):
|
||||
with _patch_discord_sender(send_mock):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1360,7 +1437,7 @@ class TestSendToPlatformDiscordMedia:
|
|||
# A message long enough to get chunked (Discord limit is 2000)
|
||||
long_msg = "A" * 1900 + " " + "B" * 1900
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", side_effect=mock_send_discord):
|
||||
with _patch_discord_sender(AsyncMock(side_effect=mock_send_discord)):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1380,7 +1457,7 @@ class TestSendToPlatformDiscordMedia:
|
|||
"""Short message (single chunk) gets media_files directly."""
|
||||
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", send_mock):
|
||||
with _patch_discord_sender(send_mock):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1618,7 +1695,7 @@ class TestSendToPlatformDiscordForum:
|
|||
"""Discord messages are routed through _send_discord, which handles forum detection."""
|
||||
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", send_mock):
|
||||
with _patch_discord_sender(send_mock):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1637,7 +1714,7 @@ class TestSendToPlatformDiscordForum:
|
|||
"""Thread ID is still passed through when sending to Discord."""
|
||||
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_discord", send_mock):
|
||||
with _patch_discord_sender(send_mock):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
|
|
@ -1775,11 +1852,11 @@ class TestForumProbeCache:
|
|||
"""_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results."""
|
||||
|
||||
def setup_method(self):
|
||||
from tools import send_message_tool as smt
|
||||
smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
|
||||
from plugins.platforms.discord import adapter as discord_adapter
|
||||
discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
|
||||
|
||||
def test_cache_round_trip(self):
|
||||
from tools.send_message_tool import (
|
||||
from plugins.platforms.discord.adapter import (
|
||||
_probe_is_forum_cached,
|
||||
_remember_channel_is_forum,
|
||||
)
|
||||
|
|
@ -1819,7 +1896,7 @@ class TestForumProbeCache:
|
|||
thread_session.post = MagicMock(return_value=thread_resp)
|
||||
|
||||
# Two _send_discord calls: first does probe + thread-create; second should skip probe
|
||||
from tools import send_message_tool as smt
|
||||
from plugins.platforms.discord import adapter as discord_adapter
|
||||
|
||||
sessions_created = []
|
||||
|
||||
|
|
@ -1837,7 +1914,7 @@ class TestForumProbeCache:
|
|||
with patch("aiohttp.ClientSession", side_effect=session_factory):
|
||||
result1 = asyncio.run(_send_discord("tok", "ch1", "first"))
|
||||
assert result1["success"] is True
|
||||
assert smt._probe_is_forum_cached("ch1") is True
|
||||
assert discord_adapter._probe_is_forum_cached("ch1") is True
|
||||
|
||||
# Second call: cache hits, no new probe session needed. We need to only
|
||||
# return thread_session now since probe is skipped.
|
||||
|
|
|
|||
|
|
@ -563,7 +563,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.discord import DiscordAdapter
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
|
||||
# Telegram adapter import is optional (requires python-telegram-bot)
|
||||
|
|
@ -589,10 +588,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||
except Exception:
|
||||
logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
|
||||
|
||||
# Platform message length limits (from adapter class attributes)
|
||||
# Platform message length limits (from adapter class attributes for
|
||||
# built-in platforms; from PlatformEntry.max_message_length for plugins).
|
||||
_MAX_LENGTHS = {
|
||||
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
|
||||
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
||||
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
||||
}
|
||||
if _feishu_available:
|
||||
|
|
@ -642,17 +641,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||
if platform == Platform.WEIXIN:
|
||||
return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
|
||||
|
||||
# --- Discord: special handling for media attachments ---
|
||||
# --- Discord: chunked delivery via the registry's standalone_sender_fn.
|
||||
# The plugin's ``_standalone_send`` (registered in
|
||||
# plugins/platforms/discord/adapter.py) handles forum channels, threads,
|
||||
# and multipart media uploads. ``_send_via_adapter`` tries the live
|
||||
# in-process adapter first via ``adapter.send()``, but Discord's elif
|
||||
# historically went straight to the HTTP path; we preserve that by
|
||||
# explicitly invoking the registry hook here so behavior is unchanged.
|
||||
if platform == Platform.DISCORD:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get("discord")
|
||||
if entry is None or entry.standalone_sender_fn is None:
|
||||
return {"error": "Discord plugin not registered or missing standalone_sender_fn"}
|
||||
last_result = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
is_last = (i == len(chunks) - 1)
|
||||
result = await _send_discord(
|
||||
pconfig.token,
|
||||
result = await entry.standalone_sender_fn(
|
||||
pconfig,
|
||||
chat_id,
|
||||
chunk,
|
||||
media_files=media_files if is_last else [],
|
||||
thread_id=thread_id,
|
||||
media_files=media_files if is_last else [],
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
|
|
@ -1026,227 +1035,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
|||
return _error(f"Telegram send failed: {e}")
|
||||
|
||||
|
||||
def _derive_forum_thread_name(message: str) -> str:
|
||||
"""Derive a thread name from the first line of the message, capped at 100 chars."""
|
||||
first_line = message.strip().split("\n", 1)[0].strip()
|
||||
# Strip common markdown heading prefixes
|
||||
first_line = first_line.lstrip("#").strip()
|
||||
if not first_line:
|
||||
first_line = "New Post"
|
||||
return first_line[:100]
|
||||
|
||||
|
||||
# Process-local cache for Discord channel-type probes. Avoids re-probing the
|
||||
# same channel on every send when the directory cache has no entry (e.g. fresh
|
||||
# install, or channel created after the last directory build).
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
|
||||
|
||||
|
||||
def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
|
||||
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
|
||||
|
||||
|
||||
async def _send_discord(token, chat_id, message, thread_id=None, media_files=None):
|
||||
"""Send a single message via Discord REST API (no websocket client needed).
|
||||
|
||||
Chunking is handled by _send_to_platform() before this is called.
|
||||
|
||||
When thread_id is provided, the message is sent directly to that thread
|
||||
via the /channels/{thread_id}/messages endpoint.
|
||||
|
||||
Media files are uploaded one-by-one via multipart/form-data after the
|
||||
text message is sent (same pattern as Telegram).
|
||||
|
||||
Forum channels (type 15) reject POST /messages — a thread post is created
|
||||
automatically via POST /channels/{id}/threads. Media files are uploaded
|
||||
as multipart attachments on the starter message of the new thread.
|
||||
|
||||
Channel type is resolved from the channel directory first, then a
|
||||
process-local probe cache, and only as a last resort with a live
|
||||
GET /channels/{id} probe (whose result is memoized).
|
||||
"""
|
||||
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(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
auth_headers = {"Authorization": f"Bot {token}"}
|
||||
json_headers = {**auth_headers, "Content-Type": "application/json"}
|
||||
media_files = media_files or []
|
||||
last_data = None
|
||||
warnings = []
|
||||
|
||||
# Thread endpoint: Discord threads are channels; send directly to the thread ID.
|
||||
if thread_id:
|
||||
url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
|
||||
else:
|
||||
# Check if the target channel is a forum channel (type 15).
|
||||
# Forum channels reject POST /messages — create a thread post instead.
|
||||
# Three-layer detection: directory cache → process-local probe
|
||||
# cache → GET /channels/{id} probe (with result memoized).
|
||||
_channel_type = None
|
||||
try:
|
||||
from gateway.channel_directory import lookup_channel_type
|
||||
_channel_type = lookup_channel_type("discord", chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _channel_type == "forum":
|
||||
is_forum = True
|
||||
elif _channel_type is not None:
|
||||
is_forum = False
|
||||
else:
|
||||
cached = _probe_is_forum_cached(chat_id)
|
||||
if cached is not None:
|
||||
is_forum = cached
|
||||
else:
|
||||
is_forum = False
|
||||
try:
|
||||
info_url = f"https://discord.com/api/v10/channels/{chat_id}"
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
|
||||
async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
|
||||
if info_resp.status == 200:
|
||||
info = await info_resp.json()
|
||||
is_forum = info.get("type") == 15
|
||||
_remember_channel_is_forum(chat_id, is_forum)
|
||||
except Exception:
|
||||
logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
|
||||
|
||||
if is_forum:
|
||||
thread_name = _derive_forum_thread_name(message)
|
||||
thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
|
||||
|
||||
# Filter to readable media files up front so we can pick the
|
||||
# right code path (JSON vs multipart) before opening a session.
|
||||
valid_media = []
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
valid_media.append(media_path)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
|
||||
if valid_media:
|
||||
# Multipart: payload_json + files[N] creates a forum
|
||||
# thread with the starter message plus attachments in
|
||||
# a single API call.
|
||||
attachments_meta = [
|
||||
{"id": str(idx), "filename": os.path.basename(path)}
|
||||
for idx, path in enumerate(valid_media)
|
||||
]
|
||||
starter_message = {"content": message, "attachments": attachments_meta}
|
||||
payload_json = json.dumps({"name": thread_name, "message": starter_message})
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("payload_json", payload_json, content_type="application/json")
|
||||
|
||||
# Buffer file bytes up front — aiohttp's FormData can
|
||||
# read lazily and we don't want handles closing under
|
||||
# it on retry.
|
||||
try:
|
||||
for idx, media_path in enumerate(valid_media):
|
||||
with open(media_path, "rb") as fh:
|
||||
form.add_field(
|
||||
f"files[{idx}]",
|
||||
fh.read(),
|
||||
filename=os.path.basename(media_path),
|
||||
)
|
||||
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
except Exception as e:
|
||||
return _error(_sanitize_error_text(f"Discord forum thread upload failed: {e}"))
|
||||
else:
|
||||
# No media — simple JSON POST creates the thread with
|
||||
# just the text starter.
|
||||
async with session.post(
|
||||
thread_url,
|
||||
headers=json_headers,
|
||||
json={
|
||||
"name": thread_name,
|
||||
"message": {"content": message},
|
||||
},
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
|
||||
thread_id_created = data.get("id")
|
||||
starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
|
||||
result = {
|
||||
"success": True,
|
||||
"platform": "discord",
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id_created,
|
||||
"message_id": starter_msg_id,
|
||||
}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
|
||||
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
||||
# Send text message (skip if empty and media is present)
|
||||
if message.strip() or not media_files:
|
||||
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord API error ({resp.status}): {body}")
|
||||
last_data = await resp.json()
|
||||
|
||||
# Send each media file as a separate multipart upload
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
try:
|
||||
form = aiohttp.FormData()
|
||||
filename = os.path.basename(media_path)
|
||||
with open(media_path, "rb") as f:
|
||||
form.add_field("files[0]", f, filename=filename)
|
||||
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
last_data = await resp.json()
|
||||
except Exception as e:
|
||||
warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
|
||||
if last_data is None:
|
||||
error = "No deliverable text or media remained after processing"
|
||||
if warnings:
|
||||
return {"error": error, "warnings": warnings}
|
||||
return {"error": error}
|
||||
|
||||
result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
except Exception as e:
|
||||
return _error(f"Discord send failed: {e}")
|
||||
|
||||
|
||||
async def _send_slack(token, chat_id, message):
|
||||
"""Send via Slack Web API."""
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue