diff --git a/cron/scheduler.py b/cron/scheduler.py index b9027013815..a9feb051a96 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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 diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 94936ac9dd5..ff4af85a89a 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -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, diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 1279b61a7b6..9096f76fda3 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -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. diff --git a/gateway/run.py b/gateway/run.py index c4337565622..ebfcdc88535 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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')}" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 3ee4dbafe02..264fbc702c0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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( diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index bc609277c46..e341b734ee1 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -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 diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 2f072c0e317..fb2d010a4e2 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -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)) diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index 20a7bf7a5db..5b464d02fd6 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -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, ) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 7c1d0d48e17..e7adfdb16db 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -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.""" diff --git a/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index 08451ae7cb8..f81781246f2 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -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") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index e282f4c2691..2c15af4c356 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -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