Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
Platform.QQBOT: lambda cfg: bool(
cfg.extra.get("app_id") and cfg.extra.get("client_secret")
),
Platform.YUANBAO: lambda cfg: bool(
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
),
# yuanbao migrated to a bundled plugin (plugins/platforms/yuanbao/); its
# connection check is registered via is_connected on the PlatformEntry.
Platform.DINGTALK: lambda cfg: bool(
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
Expand Down
19 changes: 11 additions & 8 deletions gateway/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@

from .base import BasePlatformAdapter, MessageEvent, SendResult

# QQAdapter and YuanbaoAdapter were previously imported eagerly here, but
# nothing in the codebase consumes ``from gateway.platforms import
# QQAdapter`` (every real call site uses the long-form path
# ``from gateway.platforms.qqbot import QQAdapter``). The eager imports
# pulled in qqbot's chunked-upload + keyboards + onboard machinery and
# yuanbao's websocket stack — about 48 ms wall and ~8 MB RSS on every
# CLI invocation, even ones that never touch a gateway adapter.
# QQAdapter was previously imported eagerly here, but nothing in the codebase
# consumes ``from gateway.platforms import QQAdapter`` (every real call site
# uses the long-form path ``from gateway.platforms.qqbot import QQAdapter``).
# The eager import pulled in qqbot's chunked-upload + keyboards + onboard
# machinery — about 48 ms wall and ~8 MB RSS on every CLI invocation, even
# ones that never touch a gateway adapter.
#
# YuanbaoAdapter migrated to a bundled plugin (plugins/platforms/yuanbao/);
# its re-export is repointed there so any external code that imported
# ``from gateway.platforms import YuanbaoAdapter`` keeps working.
#
# Use PEP 562 module ``__getattr__`` to keep the public re-export working
# while deferring the actual import to first attribute access. This is
Expand All @@ -36,7 +39,7 @@ def __getattr__(name):
from .qqbot import QQAdapter # noqa: F401
return QQAdapter
if name == "YuanbaoAdapter":
from .yuanbao import YuanbaoAdapter # noqa: F401
from plugins.platforms.yuanbao.adapter import YuanbaoAdapter # noqa: F401
return YuanbaoAdapter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

Expand Down
8 changes: 2 additions & 6 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6999,12 +6999,8 @@ def _create_adapter(
return None
return QQAdapter(config)

elif platform == Platform.YUANBAO:
from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE
if not WEBSOCKETS_AVAILABLE:
logger.warning("Yuanbao: websockets not installed. Run: pip install websockets")
return None
return YuanbaoAdapter(config)
# yuanbao migrated to a bundled plugin (plugins/platforms/yuanbao/);
# the platform_registry check at the top of this method creates it.

return None

Expand Down
29 changes: 3 additions & 26 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -4304,32 +4304,9 @@ def _atexit_hook() -> None:
},
],
},
{
"key": "yuanbao",
"label": "Yuanbao",
"emoji": "💎",
"token_var": "YUANBAO_APP_ID",
"setup_instructions": [
"1. Download the Yuanbao app from https://yuanbao.tencent.com/",
"2. In the app, go to PAI → My Bot and create a new bot",
"3. After the bot is created, copy the App ID and App Secret",
"4. Enter them below and Hermes will connect automatically over WebSocket",
],
"vars": [
{
"name": "YUANBAO_APP_ID",
"prompt": "App ID",
"password": False,
"help": "The App ID from your Yuanbao IM Bot credentials.",
},
{
"name": "YUANBAO_APP_SECRET",
"prompt": "App Secret",
"password": True,
"help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot.",
},
],
},
# yuanbao migrated to a bundled plugin (plugins/platforms/yuanbao/);
# its setup wizard is registered via setup_fn on the PlatformEntry and
# surfaced through _all_platforms() + _configure_platform().
]


Expand Down
3 changes: 3 additions & 0 deletions plugins/platforms/yuanbao/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .adapter import register

__all__ = ["register"]
172 changes: 167 additions & 5 deletions gateway/platforms/yuanbao.py → plugins/platforms/yuanbao/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
cache_image_from_bytes,
)
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.yuanbao_media import (
from .yuanbao_media import (
download_url as media_download_url,
get_cos_credentials,
upload_to_cos,
Expand All @@ -66,7 +66,7 @@
guess_mime_type,
md5_hex,
)
from gateway.platforms.yuanbao_proto import (
from .yuanbao_proto import (
CMD_TYPE,
_fields_to_dict,
_get_string,
Expand Down Expand Up @@ -3696,7 +3696,7 @@ async def acquire_file(self, adapter, **kwargs):
return b"", "sticker", "application/octet-stream"

def build_msg_body(self, upload_result, **kwargs):
from gateway.platforms.yuanbao_sticker import (
from .yuanbao_sticker import (
get_sticker_by_name,
get_random_sticker,
build_face_msg_body,
Expand Down Expand Up @@ -3743,7 +3743,7 @@ async def query_group_info_raw(self, group_code: str) -> Optional[dict]:
if adapter._connection.ws is None:
return None
encoded = encode_query_group_info(group_code)
from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
from .yuanbao_proto import decode_conn_msg as _decode
decoded = _decode(encoded)
req_id = decoded["head"]["msg_id"]
try:
Expand Down Expand Up @@ -3776,7 +3776,7 @@ async def get_group_member_list_raw(
if adapter._connection.ws is None:
return None
encoded = encode_get_group_member_list(group_code, offset=offset, limit=limit)
from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
from .yuanbao_proto import decode_conn_msg as _decode
decoded = _decode(encoded)
req_id = decoded["head"]["msg_id"]
try:
Expand Down Expand Up @@ -4939,3 +4939,165 @@ async def send_yuanbao_direct(
) -> Dict[str, Any]:
"""Delegate to ``OutboundManager.send_direct``."""
return await adapter._outbound.send_direct(chat_id, message, media_files)


# ---------------------------------------------------------------------------
# Plugin registration entry point
# ---------------------------------------------------------------------------
#
# Yuanbao migrated from a built-in adapter (``gateway/platforms/yuanbao*.py``)
# into this bundled plugin. The hooks below replace the per-platform wiring
# that used to be scattered across core:
# - adapter_factory → the ``elif platform == Platform.YUANBAO`` branch
# in ``gateway/run.py::_create_adapter()``
# - check_fn → the websockets-availability guard in that branch
# - is_connected → the ``Platform.YUANBAO`` lambda in
# ``gateway/config.py::get_connected_platforms()``
# - setup_fn → the declarative ``yuanbao`` entry in
# ``hermes_cli/gateway.py::_PLATFORMS``
# - standalone_sender_fn → the ``Platform.YUANBAO`` dispatch in
# ``tools/send_message_tool.py``
# - cron_deliver_env_var → home-target resolution for ``deliver=yuanbao``
#
# Deliberately NOT migrated (these stay generic in core, same as every other
# platform — see references/platform-plugin-migration.md "What stays generic"):
# - the ``Platform.YUANBAO`` enum literal (stable repo-wide identifier)
# - the ``_apply_env_overrides`` YUANBAO_* → config.extra/token block in
# ``gateway/config.py`` (env-bridge, not a load_gateway_config YAML block;
# yuanbao has no per-platform YAML block, so no apply_yaml_config_fn)
# - the ``_is_user_authorized`` / ``_UPDATE_ALLOWED_PLATFORMS`` allowlist maps
# - the cron ``_KNOWN_DELIVERY_PLATFORMS`` frozenset


def check_yuanbao_requirements() -> bool:
"""Return True when the websockets dependency is importable.

Mirrors the guard that lived in ``gateway/run.py::_create_adapter()`` —
Yuanbao speaks a persistent WebSocket protocol, so without ``websockets``
the adapter cannot connect.
"""
return WEBSOCKETS_AVAILABLE


def _is_connected(config: PlatformConfig) -> bool:
"""Yuanbao is connected when both app_id and app_secret are present.

Ports the ``Platform.YUANBAO`` lambda that used to live in
``gateway/config.py::get_connected_platforms()``.
"""
extra = config.extra or {}
return bool(extra.get("app_id") and extra.get("app_secret"))


async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id=None,
media_files=None,
force_document: bool = False,
):
"""Deliver a message for ``deliver=yuanbao`` cron / send_message routing.

Yuanbao uses a single persistent WebSocket owned by the running gateway
adapter — there is no throwaway-client path the way HTTP platforms have.
So this sender obtains the live singleton via ``get_active_adapter()``
and delegates to ``send_yuanbao_direct``. When no gateway is running it
returns an error, exactly as the old ``tools/send_message_tool.py::
_send_yuanbao`` did. This is behavior-preserving: yuanbao cron delivery
has always required the gateway to be live in-process.
"""
adapter = get_active_adapter()
if adapter is None:
return {
"error": (
"Yuanbao adapter is not running. "
"Start the gateway with the yuanbao platform enabled first."
)
}
try:
return await send_yuanbao_direct(
adapter, chat_id, message, media_files=media_files
)
except Exception as e: # noqa: BLE001 — surface any send failure to cron
return {"error": f"Yuanbao send failed: {e}"}


def interactive_setup() -> None:
"""Interactive setup wizard — replaces the declarative ``yuanbao`` entry
in ``hermes_cli/gateway.py::_PLATFORMS``.

Lazy imports keep the plugin's load surface small (top-level imports of
``hermes_cli.cli_output`` would pull in prompt_toolkit + Rich on every
plugin discovery pass). Mirrors the Teams / Mattermost setup_fn shape.
"""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import (
prompt,
print_header,
print_info,
print_success,
)

print_header("Yuanbao")
print_info("1. Download the Yuanbao app from https://yuanbao.tencent.com/")
print_info("2. In the app, go to PAI → My Bot and create a new bot")
print_info("3. After the bot is created, copy the App ID and App Secret")
print_info("4. Enter them below and Hermes will connect automatically over WebSocket")
print()

existing = get_env_value("YUANBAO_APP_ID")
if existing:
print_info("Yuanbao: already configured")

app_id = prompt("App ID")
if not app_id:
return
save_env_value("YUANBAO_APP_ID", app_id)

app_secret = prompt("App Secret", password=True)
if app_secret:
save_env_value("YUANBAO_APP_SECRET", app_secret)
print_success("Yuanbao credentials saved")

print()
print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.")
print_info(" Format: 'group:<group_code>' or 'direct:<account_id>'.")
home_channel = prompt("Home channel (leave empty to set later)")
if home_channel:
save_env_value("YUANBAO_HOME_CHANNEL", home_channel)
print_info(" Open config in your editor: hermes config edit")


def _build_adapter(config):
"""Factory wrapper that constructs YuanbaoAdapter from a PlatformConfig."""
return YuanbaoAdapter(config)


def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="yuanbao",
label="Yuanbao",
adapter_factory=_build_adapter,
check_fn=check_yuanbao_requirements,
is_connected=_is_connected,
required_env=["YUANBAO_APP_ID", "YUANBAO_APP_SECRET"],
install_hint="pip install websockets",
# Interactive setup wizard — replaces the declarative yuanbao entry
# in hermes_cli/gateway.py::_PLATFORMS.
setup_fn=interactive_setup,
# Auth env vars for _is_user_authorized() integration.
allowed_users_env="YUANBAO_ALLOWED_USERS",
allow_all_env="YUANBAO_ALLOW_ALL_USERS",
# Cron home-channel delivery. The live gateway must be running for
# delivery to succeed — yuanbao has no out-of-process send path.
cron_deliver_env_var="YUANBAO_HOME_CHANNEL",
# Send routing for the send_message tool / cron. Uses the live
# WebSocket singleton; preserves the pre-migration behavior of
# _send_yuanbao in tools/send_message_tool.py.
standalone_sender_fn=_standalone_send,
# Display
emoji="💎",
)
65 changes: 65 additions & 0 deletions plugins/platforms/yuanbao/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: yuanbao-platform
label: Yuanbao
kind: platform
version: 1.0.0
description: >
Yuanbao (元宝) gateway adapter for Hermes Agent. Connects to a Tencent
Yuanbao IM Bot over a persistent WebSocket using HMAC-signed protobuf
frames and relays messages between Yuanbao group chats / DMs and the
Hermes agent. Supports native media attachments, stickers, group and DM
access policies, and home-channel cron delivery.
author: NousResearch
requires_env:
- name: YUANBAO_APP_ID
description: "App ID from your Yuanbao IM Bot credentials (PAI -> My Bot)."
prompt: "App ID"
password: false
- name: YUANBAO_APP_SECRET
description: "App Secret used for HMAC signing of WebSocket frames."
prompt: "App Secret"
password: true
optional_env:
- name: YUANBAO_BOT_ID
description: "Bot ID, if your deployment requires an explicit bot identifier."
prompt: "Bot ID (or empty)"
password: false
- name: YUANBAO_WS_URL
description: "Override the default Yuanbao WebSocket endpoint."
prompt: "WebSocket URL (or empty)"
password: false
- name: YUANBAO_API_DOMAIN
description: "Override the default Yuanbao API domain."
prompt: "API domain (or empty)"
password: false
- name: YUANBAO_ROUTE_ENV
description: "Routing environment selector (e.g. test / prod)."
prompt: "Route env (or empty)"
password: false
- name: YUANBAO_HOME_CHANNEL
description: "Default chat target for cron / notification delivery (group:<code> or direct:<account_id>)."
prompt: "Home channel (or empty)"
password: false
- name: YUANBAO_DM_POLICY
description: "DM access policy: open, allowlist, or closed."
prompt: "DM policy (or empty)"
password: false
- name: YUANBAO_DM_ALLOW_FROM
description: "Comma-separated account IDs allowed to DM when dm_policy=allowlist."
prompt: "DM allowlist (or empty)"
password: false
- name: YUANBAO_GROUP_POLICY
description: "Group access policy: open, allowlist, or closed."
prompt: "Group policy (or empty)"
password: false
- name: YUANBAO_GROUP_ALLOW_FROM
description: "Comma-separated group codes allowed when group_policy=allowlist."
prompt: "Group allowlist (or empty)"
password: false
- name: YUANBAO_ALLOWED_USERS
description: "Comma-separated account IDs allowed to talk to the bot."
prompt: "Allowed users (or empty)"
password: false
- name: YUANBAO_ALLOW_ALL_USERS
description: "Allow any Yuanbao user to trigger the bot (dev only)."
prompt: "Allow all users? (true/false)"
password: false
2 changes: 1 addition & 1 deletion tests/gateway/test_config_driven_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def test_base_adapter_defaults_to_not_owning_access_policy():
[
("gateway.platforms.wecom", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
("plugins.platforms.yuanbao.adapter", "YuanbaoAdapter"),
("gateway.platforms.qqbot.adapter", "QQAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
],
Expand Down
Loading
Loading