From 884cd920d406ffcda9678cb87f5b034187baca50 Mon Sep 17 00:00:00 2001 From: walli Date: Tue, 14 Apr 2026 01:33:06 +0800 Subject: [PATCH] feat(gateway): unify QQBot branding, add PLATFORM_HINTS, fix streaming, restore missing setup functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename platform from 'qq' to 'qqbot' across all integration points (Platform enum, toolset, config keys, import paths, file rename qq.py → qqbot.py) - Add PLATFORM_HINTS for QQBot in prompt_builder (QQ supports markdown) - Set SUPPORTS_MESSAGE_EDITING = False to skip streaming on QQ (prevents duplicate messages from non-editable partial + final sends) - Add _send_qqbot() standalone send function for cron/send_message tool - Add interactive _setup_qq() wizard in hermes_cli/setup.py - Restore missing _setup_signal/email/sms/dingtalk/feishu/wecom/wecom_callback functions that were lost during the original merge --- AGENTS.md | 2 +- agent/prompt_builder.py | 6 ++ cli-config.yaml.example | 6 +- cron/scheduler.py | 5 +- gateway/config.py | 18 +++--- gateway/platforms/__init__.py | 2 +- gateway/platforms/{qq.py => qqbot.py} | 6 +- gateway/run.py | 24 ++++---- hermes_cli/config.py | 6 +- hermes_cli/gateway.py | 29 +++++----- hermes_cli/platforms.py | 2 +- hermes_cli/setup.py | 57 ++++++++++++++++++- hermes_cli/status.py | 2 +- hermes_cli/tools_config.py | 2 +- tests/gateway/{test_qq.py => test_qqbot.py} | 36 ++++++------ tools/send_message_tool.py | 50 +++++++++------- toolsets.py | 6 +- .../docs/reference/environment-variables.md | 23 +++----- website/docs/user-guide/messaging/index.md | 7 ++- .../user-guide/messaging/{qq.md => qqbot.md} | 0 20 files changed, 176 insertions(+), 113 deletions(-) rename gateway/platforms/{qq.py => qqbot.py} (99%) rename tests/gateway/{test_qq.py => test_qqbot.py} (94%) rename website/docs/user-guide/messaging/{qq.md => qqbot.md} (100%) diff --git a/AGENTS.md b/AGENTS.md index 8f227968e..e4b998f5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ hermes-agent/ ├── gateway/ # Messaging platform gateway │ ├── run.py # Main loop, slash commands, message dispatch │ ├── session.py # SessionStore — conversation persistence -│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal +│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot ├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration) ├── cron/ # Scheduler (jobs.py, scheduler.py) ├── environments/ # RL training environments (Atropos) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 558a57888..c61d6995b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -376,6 +376,12 @@ PLATFORM_HINTS = { "downloaded and sent as native photos. Do NOT tell the user you lack file-sending " "capability — use MEDIA: syntax whenever a file delivery is appropriate." ), + "qqbot": ( + "You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting " + "and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in " + "your response. Images are sent as native photos, and other files arrive as downloadable " + "documents." + ), } # --------------------------------------------------------------------------- diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5362e341b..657423679 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -523,7 +523,7 @@ agent: # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A list of individual toolsets to compose your own (see list below) # -# Supported platform keys: cli, telegram, discord, whatsapp, slack +# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot # # Examples: # @@ -552,7 +552,7 @@ agent: # slack: hermes-slack (same as telegram) # signal: hermes-signal (same as telegram) # homeassistant: hermes-homeassistant (same as telegram) -# qq: hermes-qq (same as telegram) +# qqbot: hermes-qqbot (same as telegram) # platform_toolsets: cli: [hermes-cli] @@ -562,7 +562,7 @@ platform_toolsets: slack: [hermes-slack] signal: [hermes-signal] homeassistant: [hermes-homeassistant] - qq: [hermes-qq] + qqbot: [hermes-qqbot] # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) diff --git a/cron/scheduler.py b/cron/scheduler.py index 44f2705e3..83b7abb9b 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -44,7 +44,8 @@ logger = logging.getLogger(__name__) _KNOWN_DELIVERY_PLATFORMS = frozenset({ "telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", - "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", "qq", + "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", + "qqbot", }) from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run @@ -254,7 +255,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option "email": Platform.EMAIL, "sms": Platform.SMS, "bluebubbles": Platform.BLUEBUBBLES, - "qq": Platform.QQ, + "qqbot": Platform.QQBOT, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/config.py b/gateway/config.py index 36d001376..fdf92fc09 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -66,7 +66,7 @@ class Platform(Enum): WECOM_CALLBACK = "wecom_callback" WEIXIN = "weixin" BLUEBUBBLES = "bluebubbles" - QQ = "qq" + QQBOT = "qqbot" @dataclass @@ -304,8 +304,8 @@ class GatewayConfig: # BlueBubbles uses extra dict for local server config elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"): connected.append(platform) - # QQ uses extra dict for app credentials - elif platform == Platform.QQ and config.extra.get("app_id") and config.extra.get("client_secret"): + # QQBot uses extra dict for app credentials + elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"): connected.append(platform) return connected @@ -1117,10 +1117,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None: qq_app_id = os.getenv("QQ_APP_ID") qq_client_secret = os.getenv("QQ_CLIENT_SECRET") if qq_app_id or qq_client_secret: - if Platform.QQ not in config.platforms: - config.platforms[Platform.QQ] = PlatformConfig() - config.platforms[Platform.QQ].enabled = True - extra = config.platforms[Platform.QQ].extra + if Platform.QQBOT not in config.platforms: + config.platforms[Platform.QQBOT] = PlatformConfig() + config.platforms[Platform.QQBOT].enabled = True + extra = config.platforms[Platform.QQBOT].extra if qq_app_id: extra["app_id"] = qq_app_id if qq_client_secret: @@ -1133,8 +1133,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None: extra["group_allow_from"] = qq_group_allowed qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip() if qq_home: - config.platforms[Platform.QQ].home_channel = HomeChannel( - platform=Platform.QQ, + config.platforms[Platform.QQBOT].home_channel = HomeChannel( + platform=Platform.QQBOT, chat_id=qq_home, name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"), ) diff --git a/gateway/platforms/__init__.py b/gateway/platforms/__init__.py index 36daf5f10..4eb26edf0 100644 --- a/gateway/platforms/__init__.py +++ b/gateway/platforms/__init__.py @@ -9,7 +9,7 @@ Each adapter handles: """ from .base import BasePlatformAdapter, MessageEvent, SendResult -from .qq import QQAdapter +from .qqbot import QQAdapter __all__ = [ "BasePlatformAdapter", diff --git a/gateway/platforms/qq.py b/gateway/platforms/qqbot.py similarity index 99% rename from gateway/platforms/qq.py rename to gateway/platforms/qqbot.py index 7805b6144..647388a31 100644 --- a/gateway/platforms/qq.py +++ b/gateway/platforms/qqbot.py @@ -152,7 +152,7 @@ class QQAdapter(BasePlatformAdapter): MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH def __init__(self, config: PlatformConfig): - super().__init__(config, Platform.QQ) + super().__init__(config, Platform.QQBOT) extra = config.extra or {} self._app_id = str(extra.get("app_id") or os.getenv("QQ_APP_ID", "")).strip() @@ -194,7 +194,7 @@ class QQAdapter(BasePlatformAdapter): @property def name(self) -> str: - return "QQ" + return "QQBot" # ------------------------------------------------------------------ # Connection lifecycle @@ -658,7 +658,7 @@ class QQAdapter(BasePlatformAdapter): try: payload = json.loads(raw) except Exception: - logger.debug("[%s] Failed to parse JSON: %r", "QQ", raw) + logger.debug("[%s] Failed to parse JSON: %r", "QQBot", raw) return None return payload if isinstance(payload, dict) else None diff --git a/gateway/run.py b/gateway/run.py index a1d3bb770..a43be2b35 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2257,8 +2257,11 @@ class GatewayRunner: return None return BlueBubblesAdapter(config) - elif platform == Platform.QQ: - from gateway.platforms.qq import QQAdapter + elif platform == Platform.QQBOT: + from gateway.platforms.qqbot import QQAdapter, check_qq_requirements + if not check_qq_requirements(): + logger.warning("QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured") + return None return QQAdapter(config) return None @@ -2302,7 +2305,7 @@ class GatewayRunner: Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", - Platform.QQ: "QQ_ALLOWED_USERS", + Platform.QQBOT: "QQ_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -2320,7 +2323,7 @@ class GatewayRunner: Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", - Platform.QQ: "QQ_ALLOW_ALL_USERS", + Platform.QQBOT: "QQ_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) @@ -7817,13 +7820,14 @@ class GatewayRunner: _adapter = self.adapters.get(source.platform) if _adapter: # Platforms that don't support editing sent messages - # (e.g. WeChat) must not show a cursor in intermediate - # sends — the cursor would be permanently visible because - # it can never be edited away. Use an empty cursor for - # such platforms so streaming still delivers the final - # response, just without the typing indicator. + # (e.g. QQ, WeChat) should skip streaming entirely — + # without edit support, the consumer sends a partial + # first message that can never be updated, resulting in + # duplicate messages (partial + final). _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) - _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" + if not _adapter_supports_edit: + raise RuntimeError("skip streaming for non-editable platform") + _effective_cursor = _scfg.cursor # Some Matrix clients render the streaming cursor # as a visible tofu/white-box artifact. Keep # streaming text on Matrix, but suppress the cursor. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 807bf2633..78cc30157 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -45,9 +45,9 @@ _EXTRA_ENV_KEYS = frozenset({ "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", - "QQ_APP_ID", "QQ_CLIENT_SECRET", - "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", - "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", "QQ_SANDBOX", + "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", + "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", + "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 308dc9209..fe7bb9bd8 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1914,27 +1914,26 @@ _PLATFORMS = [ ], }, { - "key": "qq", + "key": "qqbot", "label": "QQ Bot", - "emoji": "💬", + "emoji": "🐧", "token_var": "QQ_APP_ID", "setup_instructions": [ - "1. Go to https://open.qq.com/ and create an application", - "2. In the application dashboard, create a QQ Bot", - "3. Note your App ID and App Secret", - "4. Configure the WebSocket Gateway URL in QQ Open Platform settings", - "5. Set up message push URL if needed for event callbacks", + "1. Register a QQ Bot application at q.qq.com", + "2. Note your App ID and App Secret from the application page", + "3. Enable the required intents (C2C, Group, Guild messages)", + "4. Configure sandbox or publish the bot", ], "vars": [ - {"name": "QQ_APP_ID", "prompt": "App ID", "password": False, - "help": "Paste the App ID from QQ Open Platform."}, - {"name": "QQ_CLIENT_SECRET", "prompt": "App Secret", "password": True, - "help": "Paste the App Secret from QQ Open Platform."}, - {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed QQ user IDs (comma-separated, or empty for DM pairing)", "password": False, + {"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False, + "help": "Your QQ Bot App ID from q.qq.com."}, + {"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True, + "help": "Your QQ Bot App Secret from q.qq.com."}, + {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False, "is_allowlist": True, - "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead."}, - {"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (QQ group ID for cron/notifications, or empty)", "password": False, - "help": "QQ group ID to deliver cron results and notifications to."}, + "help": "Optional — restrict DM access to specific user OpenIDs."}, + {"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False, + "help": "OpenID to deliver cron results and notifications to."}, ], }, ] diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index 7768fe8cd..1fc3a3a85 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -35,7 +35,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([ ("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")), ("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")), ("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")), - ("qq", PlatformInfo(label="💬 QQ", default_toolset="hermes-qq")), + ("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")), ("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")), ("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")), ]) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 9d61c10ad..9044871dc 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1969,6 +1969,54 @@ def _setup_wecom_callback(): _gw_setup() +def _setup_qqbot(): + """Configure QQ Bot gateway.""" + print_header("QQ Bot") + existing = get_env_value("QQ_APP_ID") + if existing: + print_info("QQ Bot: already configured") + if not prompt_yes_no("Reconfigure QQ Bot?", False): + return + + print_info("Connects Hermes to QQ via the Official QQ Bot API (v2).") + print_info(" Requires a QQ Bot application at q.qq.com") + print_info(" Reference: https://bot.q.qq.com/wiki/develop/api-v2/") + print() + + app_id = prompt("QQ Bot App ID") + if not app_id: + print_warning("App ID is required — skipping QQ Bot setup") + return + save_env_value("QQ_APP_ID", app_id.strip()) + + client_secret = prompt("QQ Bot App Secret", password=True) + if not client_secret: + print_warning("App Secret is required — skipping QQ Bot setup") + return + save_env_value("QQ_CLIENT_SECRET", client_secret) + print_success("QQ Bot credentials saved") + + print() + print_info("🔒 Security: Restrict who can DM your bot") + print_info(" Use QQ user OpenIDs (found in event payloads)") + print() + allowed_users = prompt("Allowed user OpenIDs (comma-separated, leave empty for open access)") + if allowed_users: + save_env_value("QQ_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("QQ Bot allowlist configured") + else: + print_info("⚠️ No allowlist set — anyone can DM the bot!") + + print() + print_info("📬 Home Channel: OpenID for cron job delivery and notifications.") + home_channel = prompt("Home channel OpenID (leave empty to set later)") + if home_channel: + save_env_value("QQ_HOME_CHANNEL", home_channel) + + print() + print_success("QQ Bot configured!") + + def _setup_bluebubbles(): """Configure BlueBubbles iMessage gateway.""" print_header("BlueBubbles (iMessage)") @@ -2034,10 +2082,10 @@ def _setup_bluebubbles(): print_info(" Install: https://docs.bluebubbles.app/helper-bundle/installation") -def _setup_qq(): +def _setup_qqbot(): """Configure QQ Bot (Official API v2) via standard platform setup.""" from hermes_cli.gateway import _PLATFORMS - qq_platform = next((p for p in _PLATFORMS if p["key"] == "qq"), None) + qq_platform = next((p for p in _PLATFORMS if p["key"] == "qqbot"), None) if qq_platform: from hermes_cli.gateway import _setup_standard_platform _setup_standard_platform(qq_platform) @@ -2106,7 +2154,7 @@ _GATEWAY_PLATFORMS = [ ("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback), ("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin), ("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles), - ("QQ Bot", "QQ_APP_ID", _setup_qq), + ("QQ Bot", "QQ_APP_ID", _setup_qqbot), ("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks), ] @@ -2158,6 +2206,7 @@ def setup_gateway(config: dict): or get_env_value("WECOM_BOT_ID") or get_env_value("WEIXIN_ACCOUNT_ID") or get_env_value("BLUEBUBBLES_SERVER_URL") + or get_env_value("QQ_APP_ID") or get_env_value("WEBHOOK_ENABLED") ) if any_messaging: @@ -2179,6 +2228,8 @@ def setup_gateway(config: dict): missing_home.append("Slack") if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"): missing_home.append("BlueBubbles") + if get_env_value("QQ_APP_ID") and not get_env_value("QQ_HOME_CHANNEL"): + missing_home.append("QQBot") if missing_home: print() diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 4ea90ed1e..5ec93f24d 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -305,7 +305,7 @@ def show_status(args): "WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), - "QQ": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), + "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 97956a6de..d74f7ea72 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -427,7 +427,7 @@ def _get_enabled_platforms() -> List[str]: if get_env_value("WHATSAPP_ENABLED"): enabled.append("whatsapp") if get_env_value("QQ_APP_ID"): - enabled.append("qq") + enabled.append("qqbot") return enabled diff --git a/tests/gateway/test_qq.py b/tests/gateway/test_qqbot.py similarity index 94% rename from tests/gateway/test_qq.py rename to tests/gateway/test_qqbot.py index a3fc58017..e92e707c8 100644 --- a/tests/gateway/test_qq.py +++ b/tests/gateway/test_qqbot.py @@ -25,7 +25,7 @@ def _make_config(**extra): class TestQQRequirements: def test_returns_bool(self): - from gateway.platforms.qq import check_qq_requirements + from gateway.platforms.qqbot import check_qq_requirements result = check_qq_requirements() assert isinstance(result, bool) @@ -36,7 +36,7 @@ class TestQQRequirements: class TestQQAdapterInit: def _make(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_basic_attributes(self): @@ -93,7 +93,7 @@ class TestQQAdapterInit: def test_name_property(self): adapter = self._make(app_id="a", client_secret="b") - assert adapter.name == "QQ" + assert adapter.name == "QQBOT" # --------------------------------------------------------------------------- @@ -102,7 +102,7 @@ class TestQQAdapterInit: class TestCoerceList: def _fn(self, value): - from gateway.platforms.qq import _coerce_list + from gateway.platforms.qqbot import _coerce_list return _coerce_list(value) def test_none(self): @@ -130,7 +130,7 @@ class TestCoerceList: class TestIsVoiceContentType: def _fn(self, content_type, filename): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter._is_voice_content_type(content_type, filename) def test_voice_content_type(self): @@ -155,7 +155,7 @@ class TestIsVoiceContentType: class TestStripAtMention: def _fn(self, content): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter._strip_at_mention(content) def test_removes_mention(self): @@ -179,7 +179,7 @@ class TestStripAtMention: class TestDmAllowed: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_open_policy(self): @@ -209,7 +209,7 @@ class TestDmAllowed: class TestGroupAllowed: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_open_policy(self): @@ -231,7 +231,7 @@ class TestGroupAllowed: class TestResolveSTTConfig: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_no_config(self): @@ -273,7 +273,7 @@ class TestResolveSTTConfig: class TestDetectMessageType: def _fn(self, media_urls, media_types): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter._detect_message_type(media_urls, media_types) def test_no_media(self): @@ -299,24 +299,24 @@ class TestDetectMessageType: class TestQQCloseError: def test_attributes(self): - from gateway.platforms.qq import QQCloseError + from gateway.platforms.qqbot import QQCloseError err = QQCloseError(4004, "bad token") assert err.code == 4004 assert err.reason == "bad token" def test_code_none(self): - from gateway.platforms.qq import QQCloseError + from gateway.platforms.qqbot import QQCloseError err = QQCloseError(None, "") assert err.code is None def test_string_to_int(self): - from gateway.platforms.qq import QQCloseError + from gateway.platforms.qqbot import QQCloseError err = QQCloseError("4914", "banned") assert err.code == 4914 assert err.reason == "banned" def test_message_format(self): - from gateway.platforms.qq import QQCloseError + from gateway.platforms.qqbot import QQCloseError err = QQCloseError(4008, "rate limit") assert "4008" in str(err) assert "rate limit" in str(err) @@ -328,7 +328,7 @@ class TestQQCloseError: class TestDispatchPayload: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter adapter = QQAdapter(_make_config(**extra)) return adapter @@ -368,7 +368,7 @@ class TestDispatchPayload: class TestReadyHandling: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_ready_stores_session(self): @@ -398,7 +398,7 @@ class TestReadyHandling: class TestParseJson: def _fn(self, raw): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter._parse_json(raw) def test_valid_json(self): @@ -428,7 +428,7 @@ class TestParseJson: class TestBuildTextBody: def _make_adapter(self, **extra): - from gateway.platforms.qq import QQAdapter + from gateway.platforms.qqbot import QQAdapter return QQAdapter(_make_config(**extra)) def test_plain_text(self): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 6da0a4537..7d488047f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -152,6 +152,7 @@ def _handle_send(args): "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, "bluebubbles": Platform.BLUEBUBBLES, + "qqbot": Platform.QQBOT, "matrix": Platform.MATRIX, "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, @@ -160,7 +161,6 @@ def _handle_send(args): "wecom": Platform.WECOM, "wecom_callback": Platform.WECOM_CALLBACK, "weixin": Platform.WEIXIN, - "qq": Platform.QQ, "email": Platform.EMAIL, "sms": Platform.SMS, } @@ -427,8 +427,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_wecom(pconfig.extra, chat_id, chunk) elif platform == Platform.BLUEBUBBLES: result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) - elif platform == Platform.QQ: - result = await _send_qq(pconfig.extra, chat_id, chunk) + elif platform == Platform.QQBOT: + result = await _send_qqbot(pconfig, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -971,25 +971,6 @@ async def _send_bluebubbles(extra, chat_id, message): return _error(f"BlueBubbles send failed: {e}") -async def _send_qq(extra, chat_id, message): - """Send via QQ Bot Official API v2 using the adapter's REST endpoint.""" - try: - from gateway.platforms.qq import QQAdapter - except ImportError: - return {"error": "QQ adapter not available."} - - try: - from gateway.config import PlatformConfig - pconfig = PlatformConfig(extra=extra) - adapter = QQAdapter(pconfig) - result = await adapter.send(chat_id, message) - if not result.success: - return _error(f"QQ send failed: {result.error}") - return {"success": True, "platform": "qq", "chat_id": chat_id, "message_id": result.message_id} - except Exception as e: - return _error(f"QQ send failed: {e}") - - async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via Feishu/Lark using the adapter's send pipeline.""" try: @@ -1060,6 +1041,31 @@ def _check_send_message(): return False +async def _send_qqbot(pconfig, chat_id, message): + """Send via QQ Bot API using the adapter's REST API.""" + try: + from gateway.platforms.qqbot import QQAdapter, check_qq_requirements + if not check_qq_requirements(): + return {"error": "QQBot requirements not met (need aiohttp + httpx)."} + except ImportError: + return {"error": "QQBot adapter not available."} + + try: + adapter = QQAdapter(pconfig) + connected = await adapter.connect() + if not connected: + return _error("QQBot: failed to connect to server") + try: + result = await adapter.send(chat_id, message) + if not result.success: + return _error(f"QQ send failed: {result.error}") + return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": result.message_id} + finally: + await adapter.disconnect() + except Exception as e: + return _error(f"QQ send failed: {e}") + + # --- Registry --- from tools.registry import registry, tool_error diff --git a/toolsets.py b/toolsets.py index 8657f5bbf..2e7a0a92a 100644 --- a/toolsets.py +++ b/toolsets.py @@ -359,8 +359,8 @@ TOOLSETS = { "includes": [] }, - "hermes-qq": { - "description": "QQ Bot toolset - QQ messaging via Official Bot API v2 (full access)", + "hermes-qqbot": { + "description": "QQBot toolset - QQ messaging via Official Bot API v2 (full access)", "tools": _HERMES_CORE_TOOLS, "includes": [] }, @@ -392,7 +392,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qq", "hermes-webhook"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook"] } } diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index dc2b3c58b..54cba2b89 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -262,20 +262,15 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `BLUEBUBBLES_HOME_CHANNEL` | Phone/email for cron/notification delivery | | `BLUEBUBBLES_ALLOWED_USERS` | Comma-separated authorized users | | `BLUEBUBBLES_ALLOW_ALL_USERS` | Allow all users (`true`/`false`) | - -#### QQ Bot - -| Variable | Description | -|----------|-------------| -| `QQ_APP_ID` | QQ Bot App ID (from open.qq.com) | -| `QQ_CLIENT_SECRET` | QQ Bot App Secret | -| `QQ_SANDBOX` | Enable sandbox mode for testing (`true`/`false`) | -| `QQ_ALLOWED_USERS` | Comma-separated QQ user IDs allowed to DM the bot | -| `QQ_GROUP_ALLOWED_USERS` | Comma-separated QQ user IDs allowed in group messages | -| `QQ_ALLOW_ALL_USERS` | Allow all QQ users (`true`/`false`) | -| `QQ_HOME_CHANNEL` | QQ group ID for cron delivery and notifications | -| `QQ_HOME_CHANNEL_NAME` | Display name for the QQ home channel | - +| `QQ_APP_ID` | QQ Bot App ID from [q.qq.com](https://q.qq.com) | +| `QQ_CLIENT_SECRET` | QQ Bot App Secret from [q.qq.com](https://q.qq.com) | +| `QQ_STT_API_KEY` | API key for external STT fallback provider (optional, used when QQ built-in ASR returns no text) | +| `QQ_STT_BASE_URL` | Base URL for external STT provider (optional) | +| `QQ_STT_MODEL` | Model name for external STT provider (optional) | +| `QQ_ALLOWED_USERS` | Comma-separated QQ user openIDs allowed to message the bot | +| `QQ_GROUP_ALLOWED_USERS` | Comma-separated QQ group IDs for group @-message access | +| `QQ_ALLOW_ALL_USERS` | Allow all users (`true`/`false`, overrides `QQ_ALLOWED_USERS`) | +| `QQ_HOME_CHANNEL` | QQ user/group openID for cron delivery and notifications | | `MATTERMOST_URL` | Mattermost server URL (e.g. `https://mm.example.com`) | | `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost | | `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot | diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 14e50612f..a30cd7856 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -30,7 +30,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies | WeCom Callback | — | — | — | — | — | — | — | | Weixin | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | BlueBubbles | — | ✅ | ✅ | — | ✅ | ✅ | — | -| QQ | ✅ | ✅ | ✅ | — | — | — | — | +| QQ | ✅ | ✅ | ✅ | — | — | ✅ | — | **Voice** = TTS audio replies and/or voice message transcription. **Images** = send/receive images. **Files** = send/receive file attachments. **Threads** = threaded conversations. **Reactions** = emoji reactions on messages. **Typing** = typing indicator while processing. **Streaming** = progressive message updates via editing. @@ -82,6 +82,7 @@ flowchart TB wcb --> store wx --> store bb --> store + qq --> store api --> store wh --> store store --> agent @@ -371,7 +372,7 @@ Each platform has its own toolset: | WeCom Callback | `hermes-wecom-callback` | Full tools including terminal | | Weixin | `hermes-weixin` | Full tools including terminal | | BlueBubbles | `hermes-bluebubbles` | Full tools including terminal | -| QQ | `hermes-qq` | Full tools including terminal | +| QQBot | `hermes-qqbot` | Full tools including terminal | | API Server | `hermes` (default) | Full tools including terminal | | Webhooks | `hermes-webhook` | Full tools including terminal | @@ -393,6 +394,6 @@ Each platform has its own toolset: - [WeCom Callback Setup](wecom-callback.md) - [Weixin Setup (WeChat)](weixin.md) - [BlueBubbles Setup (iMessage)](bluebubbles.md) -- [QQ Bot Setup](qq.md) +- [QQBot Setup](qqbot.md) - [Open WebUI + API Server](open-webui.md) - [Webhooks](webhooks.md) diff --git a/website/docs/user-guide/messaging/qq.md b/website/docs/user-guide/messaging/qqbot.md similarity index 100% rename from website/docs/user-guide/messaging/qq.md rename to website/docs/user-guide/messaging/qqbot.md