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:
kshitijk4poor
2026-05-12 16:59:12 +05:30
committed by Teknium
parent 4f988634f8
commit cc8e5ec2af
41 changed files with 728 additions and 504 deletions

View File

@@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig:
ac = ",".join(str(v) for v in ac) ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(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 # Bridge top-level require_mention to Telegram when the telegram: section
# does not already provide one. Users often write "require_mention: true" # does not already provide one. Users often write "require_mention: true"
# at the top level alongside group_sessions_per_user, expecting it to work # at the top level alongside group_sessions_per_user, expecting it to work

View File

@@ -5897,6 +5897,12 @@ class GatewayRunner:
if platform_registry.is_registered(platform.value): if platform_registry.is_registered(platform.value):
adapter = platform_registry.create_adapter(platform.value, config) adapter = platform_registry.create_adapter(platform.value, config)
if adapter is not None: 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 return adapter
# Registered but failed to instantiate — don't silently fall # Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms). # through to built-ins (there are none for plugin platforms).
@@ -5939,15 +5945,6 @@ class GatewayRunner:
adapter._notifications_mode = _notify_mode adapter._notifications_mode = _notify_mode
return adapter 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: elif platform == Platform.WHATSAPP:
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
if not check_whatsapp_requirements(): if not check_whatsapp_requirements():

View File

@@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave :func:`hermes_cli.plugins.PluginContext.register_command`. They behave
like ``CommandDef`` entries for gateway surfacing: they appear in the like ``CommandDef`` entries for gateway surfacing: they appear in the
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and 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. Discord's native slash command picker.
Lookup is lazy so importing this module never forces plugin discovery Lookup is lazy so importing this module never forces plugin discovery

View File

@@ -3327,34 +3327,9 @@ _PLATFORMS = [
"help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."}, "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."},
], ],
}, },
{ # Discord moved to plugins/platforms/discord/ — its setup metadata is
"key": "discord", # discovered dynamically via _all_platforms() from the platform registry
"label": "Discord", # entry registered by plugins/platforms/discord/adapter.py::register().
"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)."},
],
},
{ {
"key": "slack", "key": "slack",
"label": "Slack", "label": "Slack",
@@ -4747,7 +4722,9 @@ def _builtin_setup_fn(key: str):
from hermes_cli import setup as _s from hermes_cli import setup as _s
return { return {
"telegram": _s._setup_telegram, "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, "slack": _s._setup_slack,
"matrix": _s._setup_matrix, "matrix": _s._setup_matrix,
"mattermost": _s._setup_mattermost, "mattermost": _s._setup_mattermost,

View File

@@ -2034,74 +2034,6 @@ def _setup_telegram():
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) 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(): def _setup_slack():
"""Configure Slack bot credentials.""" """Configure Slack bot credentials."""
print_header("Slack") print_header("Slack")

View File

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

View File

@@ -1489,7 +1489,8 @@ class DiscordAdapter(BasePlatformAdapter):
reported in ``raw_response['warnings']`` so the caller can surface reported in ``raw_response['warnings']`` so the caller can surface
partial-send issues. 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) formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) 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 ForumChannel accepts the same file/files/content kwargs as
``channel.send``, creating the thread and starter message atomically. ``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: if not thread_name:
# Prefer the text content, fall back to the first attached # Prefer the text content, fall back to the first attached
@@ -5699,7 +5701,485 @@ def _define_discord_view_classes() -> None:
self.resolved = True self.resolved = True
for child in self.children: for child in self.children:
child.disabled = True child.disabled = True
if DISCORD_AVAILABLE: if DISCORD_AVAILABLE:
_define_discord_view_classes() _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
YAMLenv translation here, next to the adapter that consumes it.
Env vars take precedence over YAML every assignment is guarded by
``not os.getenv(...)`` so explicit env vars survive a config.yaml
update. Returns ``None`` because no extras are seeded into
``PlatformConfig.extra`` directly (everything flows through env).
"""
if "require_mention" in 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,
)

View 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

View File

@@ -119,7 +119,7 @@ _ensure_slack_mock()
import discord # noqa: E402 — mocked above import discord # noqa: E402 — mocked above
from gateway.platforms.telegram import TelegramAdapter # noqa: E402 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 import gateway.platforms.slack as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True _slack_mod.SLACK_AVAILABLE = True

View File

@@ -81,7 +81,7 @@ def _ensure_discord_mock():
_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. # The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.

View File

@@ -58,7 +58,7 @@ def _ensure_discord_mock():
_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 from gateway.platforms.base import MessageType # noqa: E402
@@ -146,10 +146,10 @@ class TestCacheDiscordImage:
att = _make_attachment_with_read(_PNG_BYTES) att = _make_attachment_with_read(_PNG_BYTES)
with patch( with patch(
"gateway.platforms.discord.cache_image_from_bytes", "plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/cached.png", return_value="/tmp/cached.png",
) as mock_bytes, patch( ) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url", "plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_url: ) as mock_url:
result = await adapter._cache_discord_image(att, ".png") result = await adapter._cache_discord_image(att, ".png")
@@ -165,9 +165,9 @@ class TestCacheDiscordImage:
att = _make_attachment_without_read() att = _make_attachment_without_read()
with patch( with patch(
"gateway.platforms.discord.cache_image_from_bytes", "plugins.platforms.discord.adapter.cache_image_from_bytes",
) as mock_bytes, patch( ) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url", "plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/tmp/from_url.png", return_value="/tmp/from_url.png",
) as mock_url: ) as mock_url:
@@ -186,10 +186,10 @@ class TestCacheDiscordImage:
att = _make_attachment_with_read(b"<html>forbidden</html>") att = _make_attachment_with_read(b"<html>forbidden</html>")
with patch( with patch(
"gateway.platforms.discord.cache_image_from_bytes", "plugins.platforms.discord.adapter.cache_image_from_bytes",
side_effect=ValueError("not a valid image"), side_effect=ValueError("not a valid image"),
), patch( ), patch(
"gateway.platforms.discord.cache_image_from_url", "plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/tmp/fallback.png", return_value="/tmp/fallback.png",
) as mock_url: ) as mock_url:
@@ -210,10 +210,10 @@ class TestCacheDiscordAudio:
att = _make_attachment_with_read(_OGG_BYTES) att = _make_attachment_with_read(_OGG_BYTES)
with patch( with patch(
"gateway.platforms.discord.cache_audio_from_bytes", "plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice.ogg", return_value="/tmp/voice.ogg",
) as mock_bytes, patch( ) as mock_bytes, patch(
"gateway.platforms.discord.cache_audio_from_url", "plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_url: ) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg") result = await adapter._cache_discord_audio(att, ".ogg")
@@ -228,7 +228,7 @@ class TestCacheDiscordAudio:
att = _make_attachment_without_read() att = _make_attachment_without_read()
with patch( with patch(
"gateway.platforms.discord.cache_audio_from_url", "plugins.platforms.discord.adapter.cache_audio_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/tmp/from_url.ogg", return_value="/tmp/from_url.ogg",
) as mock_url: ) as mock_url:
@@ -267,7 +267,7 @@ class TestCacheDiscordDocument:
att = _make_attachment_without_read() # no .read → forces fallback att = _make_attachment_without_read() # no .read → forces fallback
with patch( 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: ) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
with pytest.raises(ValueError, match="SSRF"): with pytest.raises(ValueError, match="SSRF"):
await adapter._cache_discord_document(att, ".pdf") await adapter._cache_discord_document(att, ".pdf")
@@ -295,7 +295,7 @@ class TestCacheDiscordDocument:
session.__aexit__ = AsyncMock(return_value=False) session.__aexit__ = AsyncMock(return_value=False)
with patch( 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): ), patch("aiohttp.ClientSession", return_value=session):
result = await adapter._cache_discord_document(att, ".pdf") result = await adapter._cache_discord_document(att, ".pdf")
@@ -320,10 +320,10 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
with patch( with patch(
"gateway.platforms.discord.cache_image_from_bytes", "plugins.platforms.discord.adapter.cache_image_from_bytes",
return_value="/tmp/img_from_read.png", return_value="/tmp/img_from_read.png",
), patch( ), patch(
"gateway.platforms.discord.cache_image_from_url", "plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_url_download: ) as mock_url_download:
att = SimpleNamespace( att = SimpleNamespace(
@@ -342,7 +342,7 @@ class TestHandleMessageUsesAuthenticatedRead:
# Patch the DMChannel isinstance check so our fake counts as DM. # Patch the DMChannel isinstance check so our fake counts as DM.
monkeypatch.setattr( monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel", "plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel, _FakeDMChannel,
) )
chan = _FakeDMChannel() chan = _FakeDMChannel()
@@ -368,7 +368,7 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
with patch( with patch(
"gateway.platforms.discord.cache_audio_from_bytes", "plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/voice_from_read.ogg", return_value="/tmp/voice_from_read.ogg",
): ):
att = SimpleNamespace( att = SimpleNamespace(
@@ -386,7 +386,7 @@ class TestHandleMessageUsesAuthenticatedRead:
name = "dm" name = "dm"
monkeypatch.setattr( monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel", "plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel, _FakeDMChannel,
) )
chan = _FakeDMChannel() chan = _FakeDMChannel()
@@ -412,7 +412,7 @@ class TestHandleMessageUsesAuthenticatedRead:
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
with patch( with patch(
"gateway.platforms.discord.cache_audio_from_bytes", "plugins.platforms.discord.adapter.cache_audio_from_bytes",
return_value="/tmp/audio_from_read.ogg", return_value="/tmp/audio_from_read.ogg",
): ):
att = SimpleNamespace( att = SimpleNamespace(
@@ -430,7 +430,7 @@ class TestHandleMessageUsesAuthenticatedRead:
name = "dm" name = "dm"
monkeypatch.setattr( monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel", "plugins.platforms.discord.adapter.discord.DMChannel",
_FakeDMChannel, _FakeDMChannel,
) )
chan = _FakeDMChannel() chan = _FakeDMChannel()

View File

@@ -45,8 +45,8 @@ def _ensure_discord_mock():
_ensure_discord_mock() _ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402 import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeDMChannel: class FakeDMChannel:

View File

@@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch):
def _make_adapter(): def _make_adapter():
_ensure_discord_mock() _ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
adapter = object.__new__(DiscordAdapter) adapter = object.__new__(DiscordAdapter)
adapter.config = MagicMock() adapter.config = MagicMock()

View File

@@ -5,7 +5,7 @@ import pytest
def _make_adapter(): def _make_adapter():
"""Create a minimal DiscordAdapter with mocked config.""" """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 = object.__new__(DiscordAdapter)
adapter.config = MagicMock() adapter.config = MagicMock()
adapter.config.extra = {} adapter.config.extra = {}

View File

@@ -26,7 +26,7 @@ if _repo not in sys.path:
# Triggers the shared discord mock from tests/gateway/conftest.py before # Triggers the shared discord mock from tests/gateway/conftest.py before
# importing the production module. # importing the production module.
from gateway.platforms.discord import ( # noqa: E402 from plugins.platforms.discord.adapter import ( # noqa: E402
ClarifyChoiceView, ClarifyChoiceView,
DiscordAdapter, DiscordAdapter,
) )

View File

@@ -18,7 +18,7 @@ import pytest
# Trigger the shared discord mock from tests/gateway/conftest.py before # Trigger the shared discord mock from tests/gateway/conftest.py before
# importing the production module. # importing the production module.
from gateway.platforms.discord import ( # noqa: E402 from plugins.platforms.discord.adapter import ( # noqa: E402
ExecApprovalView, ExecApprovalView,
ModelPickerView, ModelPickerView,
SlashConfirmView, SlashConfirmView,

View File

@@ -67,8 +67,8 @@ def _ensure_discord_mock():
_ensure_discord_mock() _ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402 import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -57,8 +57,8 @@ def _ensure_discord_mock():
_ensure_discord_mock() _ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402 import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # 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): async def test_image_attachment_unaffected(self, adapter):
"""Image attachments should still go through the image path, not the document path.""" """Image attachments should still go through the image path, not the document path."""
with patch( with patch(
"gateway.platforms.discord.cache_image_from_url", "plugins.platforms.discord.adapter.cache_image_from_url",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/tmp/cached_image.png", return_value="/tmp/cached_image.png",
): ):

View File

@@ -45,8 +45,8 @@ def _ensure_discord_mock():
_ensure_discord_mock() _ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402 import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class FakeDMChannel: class FakeDMChannel:

View File

@@ -14,10 +14,13 @@ class TestDiscordImportSafety:
raise ImportError("discord unavailable for test") raise ImportError("discord unavailable for test")
return original_import(name, globals, locals, fromlist, level) 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) 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_AVAILABLE is False
assert module.discord is None assert module.discord is None

View File

@@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses:
def test_registers_all_five_view_classes(self, monkeypatch): def test_registers_all_five_view_classes(self, monkeypatch):
"""Calling _define_discord_view_classes() must (re)define all 5 view classes.""" """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 # Remove the classes to simulate the state where the module was loaded
# with DISCORD_AVAILABLE=False (the lazy-install scenario). # 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): def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch):
"""check_discord_requirements() must call _define_discord_view_classes() on """check_discord_requirements() must call _define_discord_view_classes() on
a successful lazy install so view classes exist when DISCORD_AVAILABLE=True.""" 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. # Simulate discord not yet available at module load.
monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False) monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False)

View File

@@ -1,6 +1,6 @@
import inspect import inspect
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
def test_discord_media_methods_accept_metadata_kwarg(): def test_discord_media_methods_accept_metadata_kwarg():

View File

@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from gateway.platforms.discord import ModelPickerView from plugins.platforms.discord.adapter import ModelPickerView
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -8,14 +8,14 @@ class TestOpusFindLibrary:
def test_uses_find_library_first(self): def test_uses_find_library_first(self):
"""find_library must be the primary lookup strategy.""" """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) source = inspect.getsource(DiscordAdapter.connect)
assert "find_library" in source, \ assert "find_library" in source, \
"Opus loading must use ctypes.util.find_library" "Opus loading must use ctypes.util.find_library"
def test_homebrew_fallback_is_conditional(self): def test_homebrew_fallback_is_conditional(self):
"""Homebrew paths must only be tried when find_library returns None.""" """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) source = inspect.getsource(DiscordAdapter.connect)
# Homebrew fallback must exist # Homebrew fallback must exist
assert "/opt/homebrew" in source or "homebrew" in source, \ assert "/opt/homebrew" in source or "homebrew" in source, \
@@ -31,7 +31,7 @@ class TestOpusFindLibrary:
def test_opus_decode_error_logged(self): def test_opus_decode_error_logged(self):
"""Opus decode failure must log the error, not silently return.""" """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) source = inspect.getsource(VoiceReceiver._on_packet)
assert "logger" in source, \ assert "logger" in source, \
"_on_packet must log Opus decode errors" "_on_packet must log Opus decode errors"

View File

@@ -10,7 +10,7 @@ from gateway.config import Platform, PlatformConfig
def _make_adapter(): def _make_adapter():
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
adapter = object.__new__(DiscordAdapter) adapter = object.__new__(DiscordAdapter)
adapter._platform = Platform.DISCORD adapter._platform = Platform.DISCORD
@@ -60,7 +60,7 @@ async def test_concurrent_joins_do_not_double_connect():
channel.guild.id = 42 channel.guild.id = 42
channel.connect = lambda: slow_connect(channel) 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", with patch.object(discord_mod, "VoiceReceiver",
MagicMock(return_value=MagicMock(start=lambda: None))): MagicMock(return_value=MagicMock(start=lambda: None))):
with patch.object(discord_mod.asyncio, "ensure_future", with patch.object(discord_mod.asyncio, "ensure_future",

View File

@@ -40,7 +40,7 @@ def _ensure_discord_mock():
_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: class FakeTree:

View File

@@ -53,7 +53,7 @@ def _ensure_discord_mock():
_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() @pytest.fixture()

View File

@@ -20,7 +20,7 @@ from unittest.mock import MagicMock
import pytest 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): def _set_dm_role_auth_guild(monkeypatch, guild_id=None):

View File

@@ -42,7 +42,7 @@ def _ensure_discord_mock():
_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 @pytest.mark.asyncio

View File

@@ -85,7 +85,7 @@ def _ensure_discord_mock():
_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) @pytest.fixture(autouse=True)

View File

@@ -75,7 +75,7 @@ def _ensure_discord_mock():
_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: class FakeTree:

View File

@@ -17,7 +17,7 @@ class TestDiscordThreadPersistence:
def _make_adapter(self, tmp_path): def _make_adapter(self, tmp_path):
"""Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path.""" """Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path."""
from gateway.config import PlatformConfig 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") config = PlatformConfig(enabled=True, token="test-token")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):

View File

@@ -27,7 +27,7 @@ from unittest.mock import MagicMock
def _make_adapter(): def _make_adapter():
"""Construct a DiscordAdapter without going through __init__ / token checks.""" """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 from gateway.platforms.base import Platform
adapter = object.__new__(DiscordAdapter) adapter = object.__new__(DiscordAdapter)
adapter.config = MagicMock() adapter.config = MagicMock()

View File

@@ -190,7 +190,7 @@ def _ensure_discord_mock():
_ensure_discord_mock() _ensure_discord_mock()
import discord as discord_mod_ref # noqa: E402 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: class TestDiscordSendImageFile:

View File

@@ -210,7 +210,7 @@ def _ensure_discord_mock():
_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: class TestDiscordMultiImage:

View File

@@ -149,7 +149,7 @@ class TestEditMessageFinalizeSignature:
"module_path,class_name", "module_path,class_name",
[ [
("gateway.platforms.telegram", "TelegramAdapter"), ("gateway.platforms.telegram", "TelegramAdapter"),
("gateway.platforms.discord", "DiscordAdapter"), ("plugins.platforms.discord.adapter", "DiscordAdapter"),
("gateway.platforms.slack", "SlackAdapter"), ("gateway.platforms.slack", "SlackAdapter"),
("gateway.platforms.matrix", "MatrixAdapter"), ("gateway.platforms.matrix", "MatrixAdapter"),
("gateway.platforms.mattermost", "MattermostAdapter"), ("gateway.platforms.mattermost", "MattermostAdapter"),

View File

@@ -41,7 +41,7 @@ def _make_event(
def _make_discord_adapter(): def _make_discord_adapter():
"""Create a minimal DiscordAdapter for testing text batching.""" """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") config = PlatformConfig(enabled=True, token="test-token")
adapter = object.__new__(DiscordAdapter) adapter = object.__new__(DiscordAdapter)

View File

@@ -511,7 +511,7 @@ class TestDiscordPlayTtsSkip:
"""Discord adapter skips play_tts when bot is in a voice channel.""" """Discord adapter skips play_tts when bot is in a voice channel."""
def _make_discord_adapter(self): def _make_discord_adapter(self):
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform, PlatformConfig from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -599,7 +599,7 @@ class TestVoiceReceiver:
"""Test VoiceReceiver silence detection, SSRC mapping, and lifecycle.""" """Test VoiceReceiver silence detection, SSRC mapping, and lifecycle."""
def _make_receiver(self): def _make_receiver(self):
from gateway.platforms.discord import VoiceReceiver from plugins.platforms.discord.adapter import VoiceReceiver
mock_vc = MagicMock() mock_vc = MagicMock()
mock_vc._connection.secret_key = [0] * 32 mock_vc._connection.secret_key = [0] * 32
mock_vc._connection.dave_session = None mock_vc._connection.dave_session = None
@@ -1066,7 +1066,7 @@ class TestDiscordVoiceChannelMethods:
"""Test DiscordAdapter voice channel methods (join, leave, play, etc.).""" """Test DiscordAdapter voice channel methods (join, leave, play, etc.)."""
def _make_adapter(self): def _make_adapter(self):
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import Platform, PlatformConfig from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -1208,7 +1208,7 @@ class TestDiscordVoiceChannelMethods:
pcm_data = b"\x00" * 96000 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", patch("tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "Hello"}), \ return_value={"success": True, "transcript": "Hello"}), \
patch("tools.voice_mode.is_whisper_hallucination", return_value=False): patch("tools.voice_mode.is_whisper_hallucination", return_value=False):
@@ -1223,7 +1223,7 @@ class TestDiscordVoiceChannelMethods:
callback = AsyncMock() callback = AsyncMock()
adapter._voice_input_callback = callback 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", patch("tools.transcription_tools.transcribe_audio",
return_value={"success": True, "transcript": "Thank you."}), \ return_value={"success": True, "transcript": "Thank you."}), \
patch("tools.voice_mode.is_whisper_hallucination", return_value=True): patch("tools.voice_mode.is_whisper_hallucination", return_value=True):
@@ -1238,7 +1238,7 @@ class TestDiscordVoiceChannelMethods:
callback = AsyncMock() callback = AsyncMock()
adapter._voice_input_callback = callback 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", patch("tools.transcription_tools.transcribe_audio",
return_value={"success": False, "error": "API error"}): return_value={"success": False, "error": "API error"}):
await adapter._process_voice_input(111, 42, b"\x00" * 96000) await adapter._process_voice_input(111, 42, b"\x00" * 96000)
@@ -1251,7 +1251,7 @@ class TestDiscordVoiceChannelMethods:
adapter = self._make_adapter() adapter = self._make_adapter()
adapter._voice_input_callback = AsyncMock() 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")): side_effect=RuntimeError("ffmpeg not found")):
await adapter._process_voice_input(111, 42, b"\x00" * 96000) await adapter._process_voice_input(111, 42, b"\x00" * 96000)
# Should not raise # Should not raise
@@ -1269,7 +1269,7 @@ class TestVoiceReceiverThreadSafety:
"""Verify that VoiceReceiver buffer access is protected by lock.""" """Verify that VoiceReceiver buffer access is protected by lock."""
def _make_receiver(self): def _make_receiver(self):
from gateway.platforms.discord import VoiceReceiver from plugins.platforms.discord.adapter import VoiceReceiver
mock_vc = MagicMock() mock_vc = MagicMock()
mock_vc._connection.secret_key = [0] * 32 mock_vc._connection.secret_key = [0] * 32
mock_vc._connection.dave_session = None mock_vc._connection.dave_session = None
@@ -1282,7 +1282,7 @@ class TestVoiceReceiverThreadSafety:
def test_check_silence_holds_lock(self): def test_check_silence_holds_lock(self):
"""check_silence must hold lock while iterating buffers.""" """check_silence must hold lock while iterating buffers."""
import ast, inspect, textwrap 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)) source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence))
tree = ast.parse(source) tree = ast.parse(source)
# Find 'with self._lock:' that contains buffer iteration # Find 'with self._lock:' that contains buffer iteration
@@ -1303,7 +1303,7 @@ class TestVoiceReceiverThreadSafety:
def test_on_packet_buffer_write_holds_lock(self): def test_on_packet_buffer_write_holds_lock(self):
"""_on_packet must hold lock when writing to buffers.""" """_on_packet must hold lock when writing to buffers."""
import ast, inspect, textwrap 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)) source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet))
tree = ast.parse(source) tree = ast.parse(source)
# Find 'with self._lock:' that contains buffer extend # Find 'with self._lock:' that contains buffer extend
@@ -1670,7 +1670,7 @@ class TestStopAcquiresLock:
@staticmethod @staticmethod
def _make_receiver(): def _make_receiver():
from gateway.platforms.discord import VoiceReceiver from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock() vc = MagicMock()
vc._connection.secret_key = [0] * 32 vc._connection.secret_key = [0] * 32
vc._connection.dave_session = None vc._connection.dave_session = None
@@ -1772,7 +1772,7 @@ class TestPacketDebugCounterIsInstanceLevel:
@staticmethod @staticmethod
def _make_receiver(): def _make_receiver():
from gateway.platforms.discord import VoiceReceiver from plugins.platforms.discord.adapter import VoiceReceiver
vc = MagicMock() vc = MagicMock()
vc._connection.secret_key = [0] * 32 vc._connection.secret_key = [0] * 32
vc._connection.dave_session = None vc._connection.dave_session = None
@@ -1805,7 +1805,7 @@ class TestPlayInVoiceChannelUsesRunningLoop:
def test_source_uses_get_running_loop(self): def test_source_uses_get_running_loop(self):
"""The method source code calls get_running_loop, not get_event_loop.""" """The method source code calls get_running_loop, not get_event_loop."""
import inspect import inspect
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.play_in_voice_channel) source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
assert "get_running_loop" in source, \ assert "get_running_loop" in source, \
"play_in_voice_channel should use asyncio.get_running_loop()" "play_in_voice_channel should use asyncio.get_running_loop()"
@@ -1849,7 +1849,7 @@ class TestVoiceTimeoutCleansRunnerState:
@staticmethod @staticmethod
def _make_discord_adapter(): def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -1940,7 +1940,7 @@ class TestPlaybackTimeout:
@staticmethod @staticmethod
def _make_discord_adapter(): def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -1964,7 +1964,7 @@ class TestPlaybackTimeout:
def test_source_has_wait_for_timeout(self): def test_source_has_wait_for_timeout(self):
"""The method uses asyncio.wait_for with timeout.""" """The method uses asyncio.wait_for with timeout."""
import inspect import inspect
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
source = inspect.getsource(DiscordAdapter.play_in_voice_channel) source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
assert "wait_for" in source, \ assert "wait_for" in source, \
"play_in_voice_channel must use asyncio.wait_for for timeout" "play_in_voice_channel must use asyncio.wait_for for timeout"
@@ -1973,14 +1973,14 @@ class TestPlaybackTimeout:
def test_playback_timeout_constant_exists(self): def test_playback_timeout_constant_exists(self):
"""PLAYBACK_TIMEOUT constant is defined on DiscordAdapter.""" """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 hasattr(DiscordAdapter, "PLAYBACK_TIMEOUT")
assert DiscordAdapter.PLAYBACK_TIMEOUT > 0 assert DiscordAdapter.PLAYBACK_TIMEOUT > 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_playback_timeout_fires(self): async def test_playback_timeout_fires(self):
"""When done event is never set, playback times out gracefully.""" """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() adapter = self._make_discord_adapter()
mock_vc = MagicMock() mock_vc = MagicMock()
@@ -2008,7 +2008,7 @@ class TestPlaybackTimeout:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_is_playing_wait_has_timeout(self): async def test_is_playing_wait_has_timeout(self):
"""While loop waiting for previous playback has a timeout.""" """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() adapter = self._make_discord_adapter()
mock_vc = MagicMock() mock_vc = MagicMock()
@@ -2124,7 +2124,7 @@ class TestVoiceChannelAwareness:
"""Tests for get_voice_channel_info() and get_voice_channel_context().""" """Tests for get_voice_channel_info() and get_voice_channel_context()."""
def _make_adapter(self): def _make_adapter(self):
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig from gateway.config import PlatformConfig
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -2267,7 +2267,7 @@ class TestVoiceReception:
@staticmethod @staticmethod
def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999): 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 = MagicMock()
vc._connection.secret_key = [0] * 32 vc._connection.secret_key = [0] * 32
vc._connection.dave_session = MagicMock() if dave else None 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): def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None):
"""Create a receiver that can process _on_packet with mocked NaCl + Opus.""" """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 = MagicMock()
vc._connection.secret_key = [0] * 32 vc._connection.secret_key = [0] * 32
vc._connection.dave_session = dave_session vc._connection.dave_session = dave_session
@@ -2593,7 +2593,7 @@ class TestVoiceTTSPlayback:
@staticmethod @staticmethod
def _make_discord_adapter(): def _make_discord_adapter():
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import PlatformConfig, Platform from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
config.token = "fake-token" config.token = "fake-token"
@@ -2766,14 +2766,14 @@ class TestUDPKeepalive:
"""UDP keepalive prevents Discord from dropping the voice session.""" """UDP keepalive prevents Discord from dropping the voice session."""
def test_keepalive_interval_is_reasonable(self): def test_keepalive_interval_is_reasonable(self):
from gateway.platforms.discord import DiscordAdapter from plugins.platforms.discord.adapter import DiscordAdapter
interval = DiscordAdapter._KEEPALIVE_INTERVAL interval = DiscordAdapter._KEEPALIVE_INTERVAL
assert 5 <= interval <= 30, f"Keepalive interval {interval}s should be between 5-30s" assert 5 <= interval <= 30, f"Keepalive interval {interval}s should be between 5-30s"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_keepalive_sends_silence_frame(self): async def test_keepalive_sends_silence_frame(self):
"""Listen loop sends silence frame via send_packet after interval.""" """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 from gateway.config import PlatformConfig, Platform
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
@@ -2795,7 +2795,7 @@ class TestUDPKeepalive:
adapter._voice_clients[111] = mock_vc adapter._voice_clients[111] = mock_vc
mock_vc._connection = mock_conn 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 = MagicMock()
mock_receiver_vc._connection.secret_key = [0] * 32 mock_receiver_vc._connection.secret_key = [0] * 32
mock_receiver_vc._connection.dave_session = None mock_receiver_vc._connection.dave_session = None

View File

@@ -38,7 +38,7 @@ except Exception:
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from gateway.platforms.discord import VoiceReceiver from plugins.platforms.discord.adapter import VoiceReceiver
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -28,16 +28,93 @@ def _reset_signal_scheduler():
from gateway.config import Platform from gateway.config import Platform
from tools.send_message_tool import ( from tools.send_message_tool import (
_derive_forum_thread_name,
_is_telegram_thread_not_found, _is_telegram_thread_not_found,
_parse_target_ref, _parse_target_ref,
_send_discord,
_send_matrix_via_adapter, _send_matrix_via_adapter,
_send_signal, _send_signal,
_send_telegram, _send_telegram,
_send_to_platform, _send_to_platform,
send_message_tool, 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): def _run_async_immediately(coro):
@@ -446,7 +523,7 @@ class TestSendToPlatformChunking:
"""Messages exceeding the platform limit are split into multiple sends.""" """Messages exceeding the platform limit are split into multiple sends."""
send = AsyncMock(return_value={"success": True, "message_id": "1"}) send = AsyncMock(return_value={"success": True, "message_id": "1"})
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1176,7 +1253,7 @@ class TestSendToPlatformDiscordThread:
"""Discord platform with thread_id passes it to _send_discord.""" """Discord platform with thread_id passes it to _send_discord."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1196,7 +1273,7 @@ class TestSendToPlatformDiscordThread:
"""Discord platform without thread_id passes None.""" """Discord platform without thread_id passes None."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1360,7 +1437,7 @@ class TestSendToPlatformDiscordMedia:
# A message long enough to get chunked (Discord limit is 2000) # A message long enough to get chunked (Discord limit is 2000)
long_msg = "A" * 1900 + " " + "B" * 1900 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1380,7 +1457,7 @@ class TestSendToPlatformDiscordMedia:
"""Short message (single chunk) gets media_files directly.""" """Short message (single chunk) gets media_files directly."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1618,7 +1695,7 @@ class TestSendToPlatformDiscordForum:
"""Discord messages are routed through _send_discord, which handles forum detection.""" """Discord messages are routed through _send_discord, which handles forum detection."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1637,7 +1714,7 @@ class TestSendToPlatformDiscordForum:
"""Thread ID is still passed through when sending to Discord.""" """Thread ID is still passed through when sending to Discord."""
send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 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( result = asyncio.run(
_send_to_platform( _send_to_platform(
Platform.DISCORD, Platform.DISCORD,
@@ -1775,11 +1852,11 @@ class TestForumProbeCache:
"""_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results.""" """_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results."""
def setup_method(self): def setup_method(self):
from tools import send_message_tool as smt from plugins.platforms.discord import adapter as discord_adapter
smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear() discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear()
def test_cache_round_trip(self): def test_cache_round_trip(self):
from tools.send_message_tool import ( from plugins.platforms.discord.adapter import (
_probe_is_forum_cached, _probe_is_forum_cached,
_remember_channel_is_forum, _remember_channel_is_forum,
) )
@@ -1819,7 +1896,7 @@ class TestForumProbeCache:
thread_session.post = MagicMock(return_value=thread_resp) thread_session.post = MagicMock(return_value=thread_resp)
# Two _send_discord calls: first does probe + thread-create; second should skip probe # 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 = [] sessions_created = []
@@ -1837,7 +1914,7 @@ class TestForumProbeCache:
with patch("aiohttp.ClientSession", side_effect=session_factory): with patch("aiohttp.ClientSession", side_effect=session_factory):
result1 = asyncio.run(_send_discord("tok", "ch1", "first")) result1 = asyncio.run(_send_discord("tok", "ch1", "first"))
assert result1["success"] is True 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 # Second call: cache hits, no new probe session needed. We need to only
# return thread_session now since probe is skipped. # return thread_session now since probe is skipped.

View File

@@ -563,7 +563,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
""" """
from gateway.config import Platform from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter, utf16_len from gateway.platforms.base import BasePlatformAdapter, utf16_len
from gateway.platforms.discord import DiscordAdapter
from gateway.platforms.slack import SlackAdapter from gateway.platforms.slack import SlackAdapter
# Telegram adapter import is optional (requires python-telegram-bot) # 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: except Exception:
logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) 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 = { _MAX_LENGTHS = {
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
} }
if _feishu_available: if _feishu_available:
@@ -642,17 +641,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
if platform == Platform.WEIXIN: if platform == Platform.WEIXIN:
return await _send_weixin(pconfig, chat_id, message, media_files=media_files) 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: 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 last_result = None
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1) is_last = (i == len(chunks) - 1)
result = await _send_discord( result = await entry.standalone_sender_fn(
pconfig.token, pconfig,
chat_id, chat_id,
chunk, chunk,
media_files=media_files if is_last else [],
thread_id=thread_id, thread_id=thread_id,
media_files=media_files if is_last else [],
) )
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
return result 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}") 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): async def _send_slack(token, chat_id, message):
"""Send via Slack Web API.""" """Send via Slack Web API."""
try: try: