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

@ -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