feat: complete plugin platform parity — all 12 integration points

Extends the platform plugin interface from Phase 1 to cover every
touchpoint where built-in platforms have hardcoded behavior.

- allowed_users_env / allow_all_env: per-platform auth env vars
- max_message_length: smart-chunking for send_message tool
- pii_safe: session PII redaction flag
- emoji: CLI/gateway display
- allow_update_command: /update access control

send_message tool (tools/send_message_tool.py):
- Replaced hardcoded platform_map dict with Platform() call
- Added _send_via_adapter() for plugin platforms — routes through
  live gateway adapter when available
- Registry-aware max message length for smart chunking

Cron delivery (cron/scheduler.py):
- Replaced hardcoded 15-entry platform_map with Platform() call
- Plugin platforms now work as cron delivery targets

User authorization (gateway/run.py _is_user_authorized):
- Registry fallback: checks PlatformEntry.allowed_users_env and
  allow_all_env when platform not in hardcoded maps
- Plugin platforms get per-platform auth support

_UPDATE_ALLOWED_PLATFORMS: checks registry allow_update_command flag
Channel directory: includes plugin platforms in session enumeration
Orphaned config warning: descriptive message when plugin platform is
  in config but no plugin registered it
Gateway weakref: _gateway_runner_ref for cross-module adapter access

hermes status: shows plugin platforms with (plugin) tag
hermes gateway setup: plugin platforms appear in menu with setup hints
hermes_cli/platforms.py: get_all_platforms() merges with registry,
  platform_label() falls back to registry for plugin names

- 8 new tests (extended fields, cron resolution, platforms merge)
- Updated 3 tests for new Platform() based resolution
- 2829 passed, 24 pre-existing failures, zero new failures
This commit is contained in:
Teknium 2026-04-11 15:10:03 -07:00 committed by Teknium
parent 8f144fe36b
commit 2e20f6ae2d
11 changed files with 376 additions and 86 deletions

View file

@ -341,26 +341,27 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
from tools.send_message_tool import _send_to_platform
from gateway.config import load_gateway_config, Platform
platform_map = {
"telegram": Platform.TELEGRAM,
"discord": Platform.DISCORD,
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
"matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN,
"email": Platform.EMAIL,
"sms": Platform.SMS,
"bluebubbles": Platform.BLUEBUBBLES,
"qqbot": Platform.QQBOT,
"yuanbao": Platform.YUANBAO,
}
# Accept any platform name — built-in names resolve to their enum
# member, plugin platform names create dynamic members via _missing_().
try:
platform = Platform(platform_name.lower())
except (ValueError, KeyError):
msg = f"unknown platform '{platform_name}'"
logger.warning("Job '%s': %s", job["id"], msg)
return msg
try:
config = load_gateway_config()
except Exception as e:
msg = f"failed to load gateway config: {e}"
logger.error("Job '%s': %s", job["id"], msg)
return msg
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
msg = f"platform '{platform_name}' not configured/enabled"
logger.warning("Job '%s': %s", job["id"], msg)
return msg
# Optionally wrap the content with a header/footer so the user knows this
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false

View file

@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them).
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
platforms[entry.name] = _build_from_sessions(entry.name)
except Exception:
pass
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,

View file

@ -67,6 +67,28 @@ class PlatformEntry:
# "builtin" or "plugin"
source: str = "plugin"
# ── Auth env var names (for _is_user_authorized integration) ──
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
allowed_users_env: str = ""
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
allow_all_env: str = ""
# ── Message limits ──
# Max message length for smart-chunking. 0 = no limit.
max_message_length: int = 0
# ── Privacy ──
# If True, session descriptions redact PII (phone numbers, etc.)
pii_safe: bool = False
# ── Display ──
# Emoji for CLI/gateway display (e.g. "💬")
emoji: str = "🔌"
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
# (allows /update command from this platform).
allow_update_command: bool = True
class PlatformRegistry:
"""Central registry of platform adapters.

View file

@ -782,6 +782,13 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
return None
# Module-level weak reference to the active GatewayRunner instance.
# Used by tools (e.g. send_message) that need to route through a live
# adapter for plugin platforms. Set in GatewayRunner.__init__().
import weakref as _weakref
_gateway_runner_ref: _weakref.ref = lambda: None
class GatewayRunner:
"""
Main gateway controller.
@ -806,9 +813,11 @@ class GatewayRunner:
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
self._warn_if_docker_media_delivery_is_risky()
_gateway_runner_ref = _weakref.ref(self)
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@ -2483,7 +2492,17 @@ class GatewayRunner:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
logger.warning("No adapter available for %s", platform.value)
# Distinguish between missing builtin deps and missing plugin
_pval = platform.value
_builtin_names = {m.value for m in Platform.__members__.values()}
if _pval not in _builtin_names:
logger.warning(
"No adapter for '%s' — is the plugin installed? "
"(platform is enabled in config.yaml but no plugin registered it)",
_pval,
)
else:
logger.warning("No adapter available for %s", _pval)
continue
# Set up message + fatal error handlers
@ -3462,6 +3481,19 @@ class GatewayRunner:
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
}
# Plugin platforms: check the registry for auth env var names
if source.platform not in platform_env_map:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(source.platform.value)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"):
@ -8761,8 +8793,16 @@ class GatewayRunner:
# Block non-messaging platforms (API server, webhooks, ACP)
platform = event.source.platform
if platform not in self._UPDATE_ALLOWED_PLATFORMS:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
_allowed = self._UPDATE_ALLOWED_PLATFORMS
# Plugin platforms with allow_update_command=True are also allowed
if platform not in _allowed:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if not entry or not entry.allow_update_command:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
except Exception:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
if is_managed():
return f"{format_managed_message('update Hermes Agent')}"

View file

@ -3779,35 +3779,61 @@ def gateway_setup():
print()
print_header("Messaging Platforms")
# Build menu from built-in platforms + plugin platforms
_plugin_entries = []
try:
from gateway.platform_registry import platform_registry
_plugin_entries = platform_registry.plugin_entries()
except Exception:
pass
menu_items = []
for plat in _PLATFORMS:
status = _platform_status(plat)
menu_items.append(f"{plat['label']} ({status})")
for pentry in _plugin_entries:
configured = pentry.check_fn()
status_str = "configured" if configured else "not configured"
menu_items.append(f"{pentry.emoji} {pentry.label} ({status_str}) [plugin]")
menu_items.append("Done")
_total_platforms = len(_PLATFORMS) + len(_plugin_entries)
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
if choice == len(_PLATFORMS):
if choice == _total_platforms:
break
platform = _PLATFORMS[choice]
if choice < len(_PLATFORMS):
platform = _PLATFORMS[choice]
if platform["key"] == "whatsapp":
_setup_whatsapp()
elif platform["key"] == "signal":
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
elif platform["key"] == "dingtalk":
_setup_dingtalk()
elif platform["key"] == "feishu":
_setup_feishu()
elif platform["key"] == "qqbot":
_setup_qqbot()
elif platform["key"] == "wecom":
_setup_wecom()
if platform["key"] == "whatsapp":
_setup_whatsapp()
elif platform["key"] == "signal":
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
elif platform["key"] == "dingtalk":
_setup_dingtalk()
elif platform["key"] == "feishu":
_setup_feishu()
elif platform["key"] == "qqbot":
_setup_qqbot()
elif platform["key"] == "wecom":
_setup_wecom()
else:
_setup_standard_platform(platform)
else:
_setup_standard_platform(platform)
# Plugin platform — show env var setup instructions
pentry = _plugin_entries[choice - len(_PLATFORMS)]
print(f"\n {pentry.label} (plugin platform)")
if pentry.required_env:
print(f" Required env vars: {', '.join(pentry.required_env)}")
print(f" Set these in ~/.hermes/.env or config.yaml gateway.platforms.{pentry.name}.extra")
else:
print(f" Configure in config.yaml under gateway.platforms.{pentry.name}")
if pentry.install_hint:
print(f" {pentry.install_hint}")
print()
# ── Post-setup: offer to install/restart gateway ──
any_configured = any(

View file

@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
def platform_label(key: str, default: str = "") -> str:
"""Return the display label for a platform key, or *default*."""
"""Return the display label for a platform key, or *default*.
Checks the static PLATFORMS dict first, then the plugin platform
registry for dynamically registered platforms.
"""
info = PLATFORMS.get(key)
return info.label if info is not None else default
if info is not None:
return info.label
# Check plugin registry
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(key)
if entry:
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
except Exception:
pass
return default
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
"""Return PLATFORMS merged with any plugin-registered platforms.
Plugin platforms are appended after builtins. This is the function
that tools_config and skills_config should use for platform menus.
"""
merged = OrderedDict(PLATFORMS)
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in merged:
merged[entry.name] = PlatformInfo(
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
default_toolset=f"hermes-{entry.name}",
)
except Exception:
pass
return merged

View file

@ -91,12 +91,12 @@ def show_status(args):
"""Show status of all Hermes Agent components."""
show_all = getattr(args, 'all', False)
deep = getattr(args, 'deep', False)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
# =========================================================================
# Environment
# =========================================================================
@ -104,7 +104,7 @@ def show_status(args):
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
print(f" Project: {PROJECT_ROOT}")
print(f" Python: {sys.version.split()[0]}")
env_path = get_env_path()
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
@ -115,13 +115,13 @@ def show_status(args):
print(f" Model: {_configured_model_label(config)}")
print(f" Provider: {_effective_provider_label()}")
# =========================================================================
# API Keys
# =========================================================================
print()
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
keys = {
"OpenRouter": "OPENROUTER_API_KEY",
"OpenAI": "OPENAI_API_KEY",
@ -140,7 +140,7 @@ def show_status(args):
"ElevenLabs": "ELEVENLABS_API_KEY",
"GitHub": "GITHUB_TOKEN",
}
for name, env_var in keys.items():
value = get_env_value(env_var) or ""
has_key = bool(value)
@ -322,13 +322,13 @@ def show_status(args):
# =========================================================================
print()
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
terminal_env = os.getenv("TERMINAL_ENV", "")
if not terminal_env:
terminal_env = terminal_cfg.get("backend", "local")
print(f" Backend: {terminal_env}")
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
@ -357,16 +357,16 @@ def show_status(args):
print(f" Auth detail: {line}")
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
# =========================================================================
# Messaging Platforms
# =========================================================================
print()
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
platforms = {
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
@ -384,7 +384,7 @@ def show_status(args):
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
token = os.getenv(token_var, "")
has_token = bool(token)
@ -401,7 +401,18 @@ def show_status(args):
status += f" (home: {home_channel})"
print(f" {name:<12} {check_mark(has_token)} {status}")
# Plugin-registered platforms
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
configured = entry.check_fn()
status_str = "configured" if configured else "not configured"
label = entry.label
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
except Exception:
pass
# =========================================================================
# Gateway Status
# =========================================================================
@ -437,13 +448,13 @@ def show_status(args):
else:
print(f" Status: {color('N/A', Colors.DIM)}")
print(" Manager: (not supported on this platform)")
# =========================================================================
# Cron Jobs
# =========================================================================
print()
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
jobs_file = get_hermes_home() / "cron" / "jobs.json"
if jobs_file.exists():
import json
@ -457,13 +468,13 @@ def show_status(args):
print(" Jobs: (error reading jobs file)")
else:
print(" Jobs: 0")
# =========================================================================
# Sessions
# =========================================================================
print()
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
if sessions_file.exists():
import json
@ -475,7 +486,7 @@ def show_status(args):
print(" Active: (error reading sessions file)")
else:
print(" Active: 0")
# =========================================================================
# Deep checks
# =========================================================================
@ -511,7 +522,7 @@ def show_status(args):
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
except OSError:
pass
print()
print(color("" * 60, Colors.DIM))
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))

View file

@ -490,4 +490,14 @@ def register(ctx):
validate_config=validate_config,
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
install_hint="No extra packages needed (stdlib only)",
# Auth env vars for _is_user_authorized() integration
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
# IRC line limit after protocol overhead
max_message_length=450,
# Display
emoji="💬",
# IRC doesn't have phone numbers to redact
pii_safe=False,
allow_update_command=True,
)

View file

@ -235,6 +235,17 @@ class TestExtractAttachments(unittest.TestCase):
mock_cache.assert_called_once()
class TestCronDelivery(unittest.TestCase):
"""Verify email in cron scheduler platform_map."""
def test_email_resolves_for_cron(self):
"""Email platform resolves via Platform() for cron delivery."""
from gateway.config import Platform
p = Platform("email")
self.assertEqual(p, Platform.EMAIL)
self.assertEqual(p.value, "email")
class TestDispatchMessage(unittest.TestCase):
"""Test email message dispatch logic."""

View file

@ -265,3 +265,113 @@ class TestGatewayConfigPluginPlatform:
assert "badconfig" not in connected_values
finally:
_reg.unregister("badconfig")
# ── Extended PlatformEntry fields ─────────────────────────────────────
class TestPlatformEntryExtendedFields:
"""Test the auth, message length, and display fields on PlatformEntry."""
def test_default_field_values(self):
entry = PlatformEntry(
name="test",
label="Test",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
assert entry.allowed_users_env == ""
assert entry.allow_all_env == ""
assert entry.max_message_length == 0
assert entry.pii_safe is False
assert entry.emoji == "🔌"
assert entry.allow_update_command is True
def test_custom_auth_fields(self):
entry = PlatformEntry(
name="irc",
label="IRC",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
max_message_length=450,
pii_safe=False,
emoji="💬",
)
assert entry.allowed_users_env == "IRC_ALLOWED_USERS"
assert entry.allow_all_env == "IRC_ALLOW_ALL_USERS"
assert entry.max_message_length == 450
assert entry.emoji == "💬"
# ── Cron platform resolution ─────────────────────────────────────────
class TestCronPlatformResolution:
"""Test that cron delivery accepts plugin platform names."""
def test_builtin_platform_resolves(self):
"""Built-in platform names resolve via Platform() call."""
p = Platform("telegram")
assert p is Platform.TELEGRAM
def test_plugin_platform_resolves(self):
"""Plugin platform names create dynamic enum members."""
p = Platform("irc")
assert p.value == "irc"
def test_invalid_platform_type_rejected(self):
"""Non-string values are still rejected."""
with pytest.raises(ValueError):
Platform(None)
# ── platforms.py integration ──────────────────────────────────────────
class TestPlatformsMerge:
"""Test get_all_platforms() merges with registry."""
def test_get_all_platforms_includes_builtins(self):
from hermes_cli.platforms import get_all_platforms, PLATFORMS
merged = get_all_platforms()
for key in PLATFORMS:
assert key in merged
def test_get_all_platforms_includes_plugin(self):
from hermes_cli.platforms import get_all_platforms
from gateway.platform_registry import platform_registry as _reg
_reg.register(PlatformEntry(
name="testmerge",
label="TestMerge",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
source="plugin",
emoji="🧪",
))
try:
merged = get_all_platforms()
assert "testmerge" in merged
assert "TestMerge" in merged["testmerge"].label
finally:
_reg.unregister("testmerge")
def test_platform_label_plugin_fallback(self):
from hermes_cli.platforms import platform_label
from gateway.platform_registry import platform_registry as _reg
_reg.register(PlatformEntry(
name="labeltest",
label="LabelTest",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
source="plugin",
emoji="🏷️",
))
try:
label = platform_label("labeltest")
assert "LabelTest" in label
finally:
_reg.unregister("labeltest")

View file

@ -205,30 +205,12 @@ def _handle_send(args):
except Exception as e:
return json.dumps(_error(f"Failed to load gateway config: {e}"))
platform_map = {
"telegram": Platform.TELEGRAM,
"discord": Platform.DISCORD,
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
"bluebubbles": Platform.BLUEBUBBLES,
"qqbot": Platform.QQBOT,
"matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN,
"email": Platform.EMAIL,
"sms": Platform.SMS,
"yuanbao": Platform.YUANBAO,
}
platform = platform_map.get(platform_name)
if not platform:
avail = ", ".join(platform_map.keys())
return tool_error(f"Unknown platform: {platform_name}. Available: {avail}")
# Accept any platform name — built-in names resolve to their enum
# member, plugin platform names create dynamic members via _missing_().
try:
platform = Platform(platform_name)
except (ValueError, KeyError):
return tool_error(f"Unknown platform: {platform_name}")
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
@ -429,6 +411,27 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:
}
async def _send_via_adapter(platform, pconfig, chat_id, chunk):
"""Send a message via a live gateway adapter (for plugin platforms).
Falls back to error if no adapter is connected for this platform.
"""
try:
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
if runner:
adapter = runner.adapters.get(platform)
if adapter:
from gateway.platforms.base import SendResult
result = await adapter.send(chat_id=chat_id, content=chunk)
if result.success:
return {"success": True, "message_id": result.message_id}
return {"error": f"Adapter send failed: {result.error}"}
except Exception as e:
return {"error": f"Plugin platform send failed: {e}"}
return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"}
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
"""Route a message to the appropriate platform sender.
@ -473,6 +476,16 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
if _feishu_available:
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
# Check plugin registry for max_message_length
if platform not in _MAX_LENGTHS:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if entry and entry.max_message_length > 0:
_MAX_LENGTHS[platform] = entry.max_message_length
except Exception:
pass
# Smart-chunk the message to fit within platform limits.
# For short messages or platforms without a known limit this is a no-op.
# Telegram measures length in UTF-16 code units, not Unicode codepoints.
@ -617,7 +630,9 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
elif platform == Platform.YUANBAO:
result = await _send_yuanbao(chat_id, chunk)
else:
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
# Plugin platform — route through the gateway's live adapter
# if available, otherwise report the error.
result = await _send_via_adapter(platform, pconfig, chat_id, chunk)
if isinstance(result, dict) and result.get("error"):
return result