mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add QQ Bot platform adapter (Official API v2)
Add full QQ Bot integration via the Official QQ Bot API (v2): - WebSocket gateway for inbound events (C2C, group, guild, DM) - REST API for outbound text/markdown/media messages - Voice transcription (Tencent ASR + configurable STT provider) - Attachment processing (images, voice, files) - User authorization (allowlist + allow-all + DM pairing) Integration points: - gateway: Platform.QQ enum, adapter factory, allowlist maps - CLI: setup wizard, gateway config, status display, tools config - tools: send_message cross-platform routing, toolsets - cron: delivery platform support - docs: QQ Bot setup guide
This commit is contained in:
parent
eb44abd6b1
commit
87bfc28e70
18 changed files with 2679 additions and 5 deletions
|
|
@ -552,6 +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)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
|
|
@ -561,6 +562,7 @@ platform_toolsets:
|
|||
slack: [hermes-slack]
|
||||
signal: [hermes-signal]
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qq: [hermes-qq]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Available toolsets (use these names in platform_toolsets or the toolsets list)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ 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",
|
||||
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", "qq",
|
||||
})
|
||||
|
||||
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
|
||||
|
|
@ -254,6 +254,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,
|
||||
}
|
||||
platform = platform_map.get(platform_name.lower())
|
||||
if not platform:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class Platform(Enum):
|
|||
WECOM_CALLBACK = "wecom_callback"
|
||||
WEIXIN = "weixin"
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQ = "qq"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -303,6 +304,9 @@ 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"):
|
||||
connected.append(platform)
|
||||
return connected
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
|
|
@ -1109,6 +1113,32 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# QQ (Official Bot API v2)
|
||||
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 qq_app_id:
|
||||
extra["app_id"] = qq_app_id
|
||||
if qq_client_secret:
|
||||
extra["client_secret"] = qq_client_secret
|
||||
qq_allowed_users = os.getenv("QQ_ALLOWED_USERS", "").strip()
|
||||
if qq_allowed_users:
|
||||
extra["allow_from"] = qq_allowed_users
|
||||
qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip()
|
||||
if qq_group_allowed:
|
||||
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,
|
||||
chat_id=qq_home,
|
||||
name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ Each adapter handles:
|
|||
"""
|
||||
|
||||
from .base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from .qq import QQAdapter
|
||||
|
||||
__all__ = [
|
||||
"BasePlatformAdapter",
|
||||
"MessageEvent",
|
||||
"SendResult",
|
||||
"QQAdapter",
|
||||
]
|
||||
|
|
|
|||
1915
gateway/platforms/qq.py
Normal file
1915
gateway/platforms/qq.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1499,6 +1499,7 @@ class GatewayRunner:
|
|||
"WECOM_CALLBACK_ALLOWED_USERS",
|
||||
"WEIXIN_ALLOWED_USERS",
|
||||
"BLUEBUBBLES_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
|
|
@ -1512,7 +1513,8 @@ class GatewayRunner:
|
|||
"WECOM_ALLOW_ALL_USERS",
|
||||
"WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS")
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
|
|
@ -2255,8 +2257,12 @@ class GatewayRunner:
|
|||
return None
|
||||
return BlueBubblesAdapter(config)
|
||||
|
||||
elif platform == Platform.QQ:
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(config)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
|
|
@ -2296,6 +2302,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_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
|
|
@ -2313,6 +2320,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",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
|
|
|
|||
|
|
@ -45,6 +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",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
|
|
@ -1331,6 +1334,53 @@ OPTIONAL_ENV_VARS = {
|
|||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all BlueBubbles users without allowlist",
|
||||
"prompt": "Allow All BlueBubbles Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_APP_ID": {
|
||||
"description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
|
||||
"prompt": "QQ App ID",
|
||||
"url": "https://q.qq.com",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_CLIENT_SECRET": {
|
||||
"description": "QQ Bot Client Secret from QQ Open Platform",
|
||||
"prompt": "QQ Client Secret",
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_ALLOWED_USERS": {
|
||||
"description": "Comma-separated QQ user IDs allowed to use the bot",
|
||||
"prompt": "QQ Allowed Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_GROUP_ALLOWED_USERS": {
|
||||
"description": "Comma-separated QQ group IDs allowed to interact with the bot",
|
||||
"prompt": "QQ Group Allowed Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all QQ users without an allowlist (true/false)",
|
||||
"prompt": "Allow All QQ Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_HOME_CHANNEL": {
|
||||
"description": "Default QQ channel/group for cron delivery and notifications",
|
||||
"prompt": "QQ Home Channel",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_HOME_CHANNEL_NAME": {
|
||||
"description": "Display name for the QQ home channel",
|
||||
"prompt": "QQ Home Channel Name",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_SANDBOX": {
|
||||
"description": "Enable QQ sandbox mode for development testing (true/false)",
|
||||
"prompt": "QQ Sandbox Mode",
|
||||
"category": "messaging",
|
||||
},
|
||||
"GATEWAY_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||||
"prompt": "Allow all users (true/false)",
|
||||
|
|
|
|||
|
|
@ -1913,6 +1913,30 @@ _PLATFORMS = [
|
|||
"help": "Phone number or Apple ID to deliver cron results and notifications to."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "qq",
|
||||
"label": "QQ Bot",
|
||||
"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",
|
||||
],
|
||||
"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,
|
||||
"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."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +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")),
|
||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -2034,6 +2034,15 @@ def _setup_bluebubbles():
|
|||
print_info(" Install: https://docs.bluebubbles.app/helper-bundle/installation")
|
||||
|
||||
|
||||
def _setup_qq():
|
||||
"""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)
|
||||
if qq_platform:
|
||||
from hermes_cli.gateway import _setup_standard_platform
|
||||
_setup_standard_platform(qq_platform)
|
||||
|
||||
|
||||
def _setup_webhooks():
|
||||
"""Configure webhook integration."""
|
||||
print_header("Webhooks")
|
||||
|
|
@ -2097,6 +2106,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),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +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"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
|
|
|
|||
|
|
@ -426,6 +426,8 @@ def _get_enabled_platforms() -> List[str]:
|
|||
enabled.append("slack")
|
||||
if get_env_value("WHATSAPP_ENABLED"):
|
||||
enabled.append("whatsapp")
|
||||
if get_env_value("QQ_APP_ID"):
|
||||
enabled.append("qq")
|
||||
return enabled
|
||||
|
||||
|
||||
|
|
|
|||
460
tests/gateway/test_qq.py
Normal file
460
tests/gateway/test_qq.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
"""Tests for the QQ Bot platform adapter."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config(**extra):
|
||||
"""Build a PlatformConfig(enabled=True, extra=extra) for testing."""
|
||||
return PlatformConfig(enabled=True, extra=extra)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_qq_requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQRequirements:
|
||||
def test_returns_bool(self):
|
||||
from gateway.platforms.qq import check_qq_requirements
|
||||
result = check_qq_requirements()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQAdapter.__init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQAdapterInit:
|
||||
def _make(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_basic_attributes(self):
|
||||
adapter = self._make(app_id="123", client_secret="sec")
|
||||
assert adapter._app_id == "123"
|
||||
assert adapter._client_secret == "sec"
|
||||
|
||||
def test_env_fallback(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id", "QQ_CLIENT_SECRET": "env_sec"}, clear=False):
|
||||
adapter = self._make()
|
||||
assert adapter._app_id == "env_id"
|
||||
assert adapter._client_secret == "env_sec"
|
||||
|
||||
def test_env_fallback_extra_wins(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id"}, clear=False):
|
||||
adapter = self._make(app_id="extra_id", client_secret="sec")
|
||||
assert adapter._app_id == "extra_id"
|
||||
|
||||
def test_dm_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._dm_policy == "open"
|
||||
|
||||
def test_dm_policy_explicit(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", dm_policy="allowlist")
|
||||
assert adapter._dm_policy == "allowlist"
|
||||
|
||||
def test_group_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._group_policy == "open"
|
||||
|
||||
def test_allow_from_parsing_string(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from="x, y , z")
|
||||
assert adapter._allow_from == ["x", "y", "z"]
|
||||
|
||||
def test_allow_from_parsing_list(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from=["a", "b"])
|
||||
assert adapter._allow_from == ["a", "b"]
|
||||
|
||||
def test_allow_from_default_empty(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._allow_from == []
|
||||
|
||||
def test_group_allow_from(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", group_allow_from="g1,g2")
|
||||
assert adapter._group_allow_from == ["g1", "g2"]
|
||||
|
||||
def test_markdown_support_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._markdown_support is True
|
||||
|
||||
def test_markdown_support_false(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", markdown_support=False)
|
||||
assert adapter._markdown_support is False
|
||||
|
||||
def test_name_property(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter.name == "QQ"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _coerce_list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCoerceList:
|
||||
def _fn(self, value):
|
||||
from gateway.platforms.qq import _coerce_list
|
||||
return _coerce_list(value)
|
||||
|
||||
def test_none(self):
|
||||
assert self._fn(None) == []
|
||||
|
||||
def test_string(self):
|
||||
assert self._fn("a, b ,c") == ["a", "b", "c"]
|
||||
|
||||
def test_list(self):
|
||||
assert self._fn(["x", "y"]) == ["x", "y"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == []
|
||||
|
||||
def test_tuple(self):
|
||||
assert self._fn(("a", "b")) == ["a", "b"]
|
||||
|
||||
def test_single_item_string(self):
|
||||
assert self._fn("hello") == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_voice_content_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsVoiceContentType:
|
||||
def _fn(self, content_type, filename):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter._is_voice_content_type(content_type, filename)
|
||||
|
||||
def test_voice_content_type(self):
|
||||
assert self._fn("voice", "msg.silk") is True
|
||||
|
||||
def test_audio_content_type(self):
|
||||
assert self._fn("audio/mp3", "file.mp3") is True
|
||||
|
||||
def test_voice_extension(self):
|
||||
assert self._fn("", "file.silk") is True
|
||||
|
||||
def test_non_voice(self):
|
||||
assert self._fn("image/jpeg", "photo.jpg") is False
|
||||
|
||||
def test_audio_extension_amr(self):
|
||||
assert self._fn("", "recording.amr") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_at_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStripAtMention:
|
||||
def _fn(self, content):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter._strip_at_mention(content)
|
||||
|
||||
def test_removes_mention(self):
|
||||
result = self._fn("@BotUser hello there")
|
||||
assert result == "hello there"
|
||||
|
||||
def test_no_mention(self):
|
||||
result = self._fn("just text")
|
||||
assert result == "just text"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == ""
|
||||
|
||||
def test_only_mention(self):
|
||||
assert self._fn("@Someone ") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_dm_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDmAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="open")
|
||||
assert adapter._is_dm_allowed("any_user") is True
|
||||
|
||||
def test_disabled_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="disabled")
|
||||
assert adapter._is_dm_allowed("any_user") is False
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user3") is False
|
||||
|
||||
def test_allowlist_wildcard(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="*")
|
||||
assert adapter._is_dm_allowed("anyone") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_group_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGroupAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="open")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp2", "user1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_stt_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveSTTConfig:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_no_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
assert adapter._resolve_stt_config() is None
|
||||
|
||||
def test_env_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {
|
||||
"QQ_STT_API_KEY": "key123",
|
||||
"QQ_STT_BASE_URL": "https://example.com/v1",
|
||||
"QQ_STT_MODEL": "my-model",
|
||||
}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["api_key"] == "key123"
|
||||
assert cfg["base_url"] == "https://example.com/v1"
|
||||
assert cfg["model"] == "my-model"
|
||||
|
||||
def test_extra_config(self):
|
||||
stt_cfg = {
|
||||
"baseUrl": "https://custom.api/v4",
|
||||
"apiKey": "sk_extra",
|
||||
"model": "glm-asr",
|
||||
}
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", stt=stt_cfg)
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["base_url"] == "https://custom.api/v4"
|
||||
assert cfg["api_key"] == "sk_extra"
|
||||
assert cfg["model"] == "glm-asr"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_message_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectMessageType:
|
||||
def _fn(self, media_urls, media_types):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter._detect_message_type(media_urls, media_types)
|
||||
|
||||
def test_no_media(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn([], []) == MessageType.TEXT
|
||||
|
||||
def test_image(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["file.jpg"], ["image/jpeg"]) == MessageType.PHOTO
|
||||
|
||||
def test_voice(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["voice.silk"], ["audio/silk"]) == MessageType.VOICE
|
||||
|
||||
def test_video(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["vid.mp4"], ["video/mp4"]) == MessageType.VIDEO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQCloseError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQCloseError:
|
||||
def test_attributes(self):
|
||||
from gateway.platforms.qq 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
|
||||
err = QQCloseError(None, "")
|
||||
assert err.code is None
|
||||
|
||||
def test_string_to_int(self):
|
||||
from gateway.platforms.qq 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
|
||||
err = QQCloseError(4008, "rate limit")
|
||||
assert "4008" in str(err)
|
||||
assert "rate limit" in str(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatchPayload:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
adapter = QQAdapter(_make_config(**extra))
|
||||
return adapter
|
||||
|
||||
def test_unknown_op(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 99, "d": {}})
|
||||
# last_seq should remain None
|
||||
assert adapter._last_seq is None
|
||||
|
||||
def test_op10_updates_heartbeat_interval(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 10, "d": {"heartbeat_interval": 50000}})
|
||||
# Should be 50000 / 1000 * 0.8 = 40.0
|
||||
assert adapter._heartbeat_interval == 40.0
|
||||
|
||||
def test_op11_heartbeat_ack(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 11, "t": "HEARTBEAT_ACK", "s": 42})
|
||||
|
||||
def test_seq_tracking(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 100, "d": {}})
|
||||
assert adapter._last_seq == 100
|
||||
|
||||
def test_seq_increments(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 5, "d": {}})
|
||||
adapter._dispatch_payload({"op": 0, "t": "SOME_EVENT", "s": 10, "d": {}})
|
||||
assert adapter._last_seq == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READY / RESUMED handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadyHandling:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_ready_stores_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "READY",
|
||||
"s": 1,
|
||||
"d": {"session_id": "sess_abc123"},
|
||||
})
|
||||
assert adapter._session_id == "sess_abc123"
|
||||
|
||||
def test_resumed_preserves_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._session_id = "old_sess"
|
||||
adapter._last_seq = 50
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "RESUMED", "s": 60, "d": {},
|
||||
})
|
||||
# Session should remain unchanged on RESUMED
|
||||
assert adapter._session_id == "old_sess"
|
||||
assert adapter._last_seq == 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseJson:
|
||||
def _fn(self, raw):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter._parse_json(raw)
|
||||
|
||||
def test_valid_json(self):
|
||||
result = self._fn('{"op": 10, "d": {}}')
|
||||
assert result == {"op": 10, "d": {}}
|
||||
|
||||
def test_invalid_json(self):
|
||||
result = self._fn("not json")
|
||||
assert result is None
|
||||
|
||||
def test_none_input(self):
|
||||
result = self._fn(None)
|
||||
assert result is None
|
||||
|
||||
def test_non_dict_json(self):
|
||||
result = self._fn('"just a string"')
|
||||
assert result is None
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = self._fn('{}')
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_text_body
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildTextBody:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qq import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_plain_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("hello world")
|
||||
assert body["msg_type"] == 0 # MSG_TYPE_TEXT
|
||||
assert body["content"] == "hello world"
|
||||
|
||||
def test_markdown_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=True)
|
||||
body = adapter._build_text_body("**bold** text")
|
||||
assert body["msg_type"] == 2 # MSG_TYPE_MARKDOWN
|
||||
assert body["markdown"]["content"] == "**bold** text"
|
||||
|
||||
def test_truncation(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
long_text = "x" * 10000
|
||||
body = adapter._build_text_body(long_text)
|
||||
assert len(body["content"]) == adapter.MAX_MESSAGE_LENGTH
|
||||
|
||||
def test_empty_string(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("")
|
||||
assert body["content"] == ""
|
||||
|
||||
def test_reply_to(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("reply text", reply_to="msg_123")
|
||||
assert body.get("message_reference", {}).get("message_id") == "msg_123"
|
||||
|
|
@ -160,6 +160,7 @@ def _handle_send(args):
|
|||
"wecom": Platform.WECOM,
|
||||
"wecom_callback": Platform.WECOM_CALLBACK,
|
||||
"weixin": Platform.WEIXIN,
|
||||
"qq": Platform.QQ,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
}
|
||||
|
|
@ -426,6 +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)
|
||||
else:
|
||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||
|
||||
|
|
@ -968,6 +971,25 @@ 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:
|
||||
|
|
|
|||
|
|
@ -359,6 +359,12 @@ TOOLSETS = {
|
|||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-qq": {
|
||||
"description": "QQ Bot toolset - QQ messaging via Official Bot API v2 (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-wecom": {
|
||||
"description": "WeCom bot toolset - enterprise WeChat messaging (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
|
|
@ -386,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-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-qq", "hermes-webhook"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,20 @@ 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 |
|
||||
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal,
|
|||
|
||||
# Messaging Gateway
|
||||
|
||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||
|
||||
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
||||
|
||||
|
|
@ -30,6 +30,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
|
|||
| WeCom Callback | — | — | — | — | — | — | — |
|
||||
| Weixin | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||
| BlueBubbles | — | ✅ | ✅ | — | ✅ | ✅ | — |
|
||||
| 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.
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ flowchart TB
|
|||
wcb[WeCom Callback]
|
||||
wx[Weixin]
|
||||
bb[BlueBubbles]
|
||||
qq[QQ]
|
||||
api["API Server<br/>(OpenAI-compatible)"]
|
||||
wh[Webhooks]
|
||||
end
|
||||
|
|
@ -369,6 +371,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 |
|
||||
| API Server | `hermes` (default) | Full tools including terminal |
|
||||
| Webhooks | `hermes-webhook` | Full tools including terminal |
|
||||
|
||||
|
|
@ -390,5 +393,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)
|
||||
- [Open WebUI + API Server](open-webui.md)
|
||||
- [Webhooks](webhooks.md)
|
||||
|
|
|
|||
122
website/docs/user-guide/messaging/qq.md
Normal file
122
website/docs/user-guide/messaging/qq.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# QQ Bot
|
||||
|
||||
Connect Hermes to QQ via the **Official QQ Bot API (v2)** — supporting private (C2C), group @-mentions, guild, and direct messages with voice transcription.
|
||||
|
||||
## Overview
|
||||
|
||||
The QQ Bot adapter uses the [Official QQ Bot API](https://bot.q.qq.com/wiki/develop/api-v2/) to:
|
||||
|
||||
- Receive messages via a persistent **WebSocket** connection to the QQ Gateway
|
||||
- Send text and markdown replies via the **REST API**
|
||||
- Download and process images, voice messages, and file attachments
|
||||
- Transcribe voice messages using Tencent's built-in ASR or a configurable STT provider
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **QQ Bot Application** — Register at [q.qq.com](https://q.qq.com):
|
||||
- Create a new application and note your **App ID** and **App Secret**
|
||||
- Enable the required intents: C2C messages, Group @-messages, Guild messages
|
||||
- Configure your bot in sandbox mode for testing, or publish for production
|
||||
|
||||
2. **Dependencies** — The adapter requires `aiohttp` and `httpx`:
|
||||
```bash
|
||||
pip install aiohttp httpx
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Interactive setup
|
||||
|
||||
```bash
|
||||
hermes setup gateway
|
||||
```
|
||||
|
||||
Select **QQ Bot** from the platform list and follow the prompts.
|
||||
|
||||
### Manual configuration
|
||||
|
||||
Set the required environment variables in `~/.hermes/.env`:
|
||||
|
||||
```bash
|
||||
QQ_APP_ID=your-app-id
|
||||
QQ_CLIENT_SECRET=your-app-secret
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `QQ_APP_ID` | QQ Bot App ID (required) | — |
|
||||
| `QQ_CLIENT_SECRET` | QQ Bot App Secret (required) | — |
|
||||
| `QQ_HOME_CHANNEL` | OpenID for cron/notification delivery | — |
|
||||
| `QQ_HOME_CHANNEL_NAME` | Display name for home channel | `Home` |
|
||||
| `QQ_ALLOWED_USERS` | Comma-separated user OpenIDs for DM access | open (all users) |
|
||||
| `QQ_ALLOW_ALL_USERS` | Set to `true` to allow all DMs | `false` |
|
||||
| `QQ_MARKDOWN_SUPPORT` | Enable QQ markdown (msg_type 2) | `true` |
|
||||
| `QQ_STT_API_KEY` | API key for voice-to-text provider | — |
|
||||
| `QQ_STT_BASE_URL` | Base URL for STT provider | `https://open.bigmodel.cn/api/coding/paas/v4` |
|
||||
| `QQ_STT_MODEL` | STT model name | `glm-asr` |
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
For fine-grained control, add platform settings to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
qq:
|
||||
enabled: true
|
||||
extra:
|
||||
app_id: "your-app-id"
|
||||
client_secret: "your-secret"
|
||||
markdown_support: true
|
||||
dm_policy: "open" # open | allowlist | disabled
|
||||
allow_from:
|
||||
- "user_openid_1"
|
||||
group_policy: "open" # open | allowlist | disabled
|
||||
group_allow_from:
|
||||
- "group_openid_1"
|
||||
stt:
|
||||
provider: "zai" # zai (GLM-ASR), openai (Whisper), etc.
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
apiKey: "your-stt-key"
|
||||
model: "glm-asr"
|
||||
```
|
||||
|
||||
## Voice Messages (STT)
|
||||
|
||||
Voice transcription works in two stages:
|
||||
|
||||
1. **QQ built-in ASR** (free, always tried first) — QQ provides `asr_refer_text` in voice message attachments, which uses Tencent's own speech recognition
|
||||
2. **Configured STT provider** (fallback) — If QQ's ASR doesn't return text, the adapter calls an OpenAI-compatible STT API:
|
||||
|
||||
- **Zhipu/GLM (zai)**: Default provider, uses `glm-asr` model
|
||||
- **OpenAI Whisper**: Set `QQ_STT_BASE_URL` and `QQ_STT_MODEL`
|
||||
- Any OpenAI-compatible STT endpoint
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot disconnects immediately (quick disconnect)
|
||||
|
||||
This usually means:
|
||||
- **Invalid App ID / Secret** — Double-check your credentials at q.qq.com
|
||||
- **Missing permissions** — Ensure the bot has the required intents enabled
|
||||
- **Sandbox-only bot** — If the bot is in sandbox mode, it can only receive messages from QQ's sandbox test channel
|
||||
|
||||
### Voice messages not transcribed
|
||||
|
||||
1. Check if QQ's built-in `asr_refer_text` is present in the attachment data
|
||||
2. If using a custom STT provider, verify `QQ_STT_API_KEY` is set correctly
|
||||
3. Check gateway logs for STT error messages
|
||||
|
||||
### Messages not delivered
|
||||
|
||||
- Verify the bot's **intents** are enabled at q.qq.com
|
||||
- Check `QQ_ALLOWED_USERS` if DM access is restricted
|
||||
- For group messages, ensure the bot is **@mentioned** (group policy may require allowlisting)
|
||||
- Check `QQ_HOME_CHANNEL` for cron/notification delivery
|
||||
|
||||
### Connection errors
|
||||
|
||||
- Ensure `aiohttp` and `httpx` are installed: `pip install aiohttp httpx`
|
||||
- Check network connectivity to `api.sgroup.qq.com` and the WebSocket gateway
|
||||
- Review gateway logs for detailed error messages and reconnect behavior
|
||||
Loading…
Add table
Add a link
Reference in a new issue