feat(gateway): unify QQBot branding, add PLATFORM_HINTS, fix streaming, restore missing setup functions

- 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
This commit is contained in:
walli 2026-04-14 01:33:06 +08:00 committed by Teknium
parent 87bfc28e70
commit 884cd920d4
20 changed files with 176 additions and 113 deletions

View file

@ -55,7 +55,7 @@ hermes-agent/
├── gateway/ # Messaging platform gateway ├── gateway/ # Messaging platform gateway
│ ├── run.py # Main loop, slash commands, message dispatch │ ├── run.py # Main loop, slash commands, message dispatch
│ ├── session.py # SessionStore — conversation persistence │ ├── 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) ├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler (jobs.py, scheduler.py) ├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos) ├── environments/ # RL training environments (Atropos)

View file

@ -376,6 +376,12 @@ PLATFORM_HINTS = {
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending " "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." "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."
),
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -523,7 +523,7 @@ agent:
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
# - A list of individual toolsets to compose your own (see list below) # - 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: # Examples:
# #
@ -552,7 +552,7 @@ agent:
# slack: hermes-slack (same as telegram) # slack: hermes-slack (same as telegram)
# signal: hermes-signal (same as telegram) # signal: hermes-signal (same as telegram)
# homeassistant: hermes-homeassistant (same as telegram) # homeassistant: hermes-homeassistant (same as telegram)
# qq: hermes-qq (same as telegram) # qqbot: hermes-qqbot (same as telegram)
# #
platform_toolsets: platform_toolsets:
cli: [hermes-cli] cli: [hermes-cli]
@ -562,7 +562,7 @@ platform_toolsets:
slack: [hermes-slack] slack: [hermes-slack]
signal: [hermes-signal] signal: [hermes-signal]
homeassistant: [hermes-homeassistant] homeassistant: [hermes-homeassistant]
qq: [hermes-qq] qqbot: [hermes-qqbot]
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Available toolsets (use these names in platform_toolsets or the toolsets list) # Available toolsets (use these names in platform_toolsets or the toolsets list)

View file

@ -44,7 +44,8 @@ logger = logging.getLogger(__name__)
_KNOWN_DELIVERY_PLATFORMS = frozenset({ _KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram", "discord", "slack", "whatsapp", "signal", "telegram", "discord", "slack", "whatsapp", "signal",
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu", "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 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, "email": Platform.EMAIL,
"sms": Platform.SMS, "sms": Platform.SMS,
"bluebubbles": Platform.BLUEBUBBLES, "bluebubbles": Platform.BLUEBUBBLES,
"qq": Platform.QQ, "qqbot": Platform.QQBOT,
} }
platform = platform_map.get(platform_name.lower()) platform = platform_map.get(platform_name.lower())
if not platform: if not platform:

View file

@ -66,7 +66,7 @@ class Platform(Enum):
WECOM_CALLBACK = "wecom_callback" WECOM_CALLBACK = "wecom_callback"
WEIXIN = "weixin" WEIXIN = "weixin"
BLUEBUBBLES = "bluebubbles" BLUEBUBBLES = "bluebubbles"
QQ = "qq" QQBOT = "qqbot"
@dataclass @dataclass
@ -304,8 +304,8 @@ class GatewayConfig:
# BlueBubbles uses extra dict for local server config # BlueBubbles uses extra dict for local server config
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"): elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
connected.append(platform) connected.append(platform)
# QQ uses extra dict for app credentials # QQBot uses extra dict for app credentials
elif platform == Platform.QQ and config.extra.get("app_id") and config.extra.get("client_secret"): elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
connected.append(platform) connected.append(platform)
return connected return connected
@ -1117,10 +1117,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
qq_app_id = os.getenv("QQ_APP_ID") qq_app_id = os.getenv("QQ_APP_ID")
qq_client_secret = os.getenv("QQ_CLIENT_SECRET") qq_client_secret = os.getenv("QQ_CLIENT_SECRET")
if qq_app_id or qq_client_secret: if qq_app_id or qq_client_secret:
if Platform.QQ not in config.platforms: if Platform.QQBOT not in config.platforms:
config.platforms[Platform.QQ] = PlatformConfig() config.platforms[Platform.QQBOT] = PlatformConfig()
config.platforms[Platform.QQ].enabled = True config.platforms[Platform.QQBOT].enabled = True
extra = config.platforms[Platform.QQ].extra extra = config.platforms[Platform.QQBOT].extra
if qq_app_id: if qq_app_id:
extra["app_id"] = qq_app_id extra["app_id"] = qq_app_id
if qq_client_secret: if qq_client_secret:
@ -1133,8 +1133,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
extra["group_allow_from"] = qq_group_allowed extra["group_allow_from"] = qq_group_allowed
qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip() qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip()
if qq_home: if qq_home:
config.platforms[Platform.QQ].home_channel = HomeChannel( config.platforms[Platform.QQBOT].home_channel = HomeChannel(
platform=Platform.QQ, platform=Platform.QQBOT,
chat_id=qq_home, chat_id=qq_home,
name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"), name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"),
) )

View file

@ -9,7 +9,7 @@ Each adapter handles:
""" """
from .base import BasePlatformAdapter, MessageEvent, SendResult from .base import BasePlatformAdapter, MessageEvent, SendResult
from .qq import QQAdapter from .qqbot import QQAdapter
__all__ = [ __all__ = [
"BasePlatformAdapter", "BasePlatformAdapter",

View file

@ -152,7 +152,7 @@ class QQAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.QQ) super().__init__(config, Platform.QQBOT)
extra = config.extra or {} extra = config.extra or {}
self._app_id = str(extra.get("app_id") or os.getenv("QQ_APP_ID", "")).strip() self._app_id = str(extra.get("app_id") or os.getenv("QQ_APP_ID", "")).strip()
@ -194,7 +194,7 @@ class QQAdapter(BasePlatformAdapter):
@property @property
def name(self) -> str: def name(self) -> str:
return "QQ" return "QQBot"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Connection lifecycle # Connection lifecycle
@ -658,7 +658,7 @@ class QQAdapter(BasePlatformAdapter):
try: try:
payload = json.loads(raw) payload = json.loads(raw)
except Exception: 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 None
return payload if isinstance(payload, dict) else None return payload if isinstance(payload, dict) else None

View file

@ -2257,8 +2257,11 @@ class GatewayRunner:
return None return None
return BlueBubblesAdapter(config) return BlueBubblesAdapter(config)
elif platform == Platform.QQ: elif platform == Platform.QQBOT:
from gateway.platforms.qq import QQAdapter 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 QQAdapter(config)
return None return None
@ -2302,7 +2305,7 @@ class GatewayRunner:
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQ: "QQ_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS",
} }
platform_allow_all_map = { platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
@ -2320,7 +2323,7 @@ class GatewayRunner:
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_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) # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
@ -7817,13 +7820,14 @@ class GatewayRunner:
_adapter = self.adapters.get(source.platform) _adapter = self.adapters.get(source.platform)
if _adapter: if _adapter:
# Platforms that don't support editing sent messages # Platforms that don't support editing sent messages
# (e.g. WeChat) must not show a cursor in intermediate # (e.g. QQ, WeChat) should skip streaming entirely —
# sends — the cursor would be permanently visible because # without edit support, the consumer sends a partial
# it can never be edited away. Use an empty cursor for # first message that can never be updated, resulting in
# such platforms so streaming still delivers the final # duplicate messages (partial + final).
# response, just without the typing indicator.
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) _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 # Some Matrix clients render the streaming cursor
# as a visible tofu/white-box artifact. Keep # as a visible tofu/white-box artifact. Keep
# streaming text on Matrix, but suppress the cursor. # streaming text on Matrix, but suppress the cursor.

View file

@ -45,9 +45,9 @@ _EXTRA_ENV_KEYS = frozenset({
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "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_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", "QQ_SANDBOX", "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",

View file

@ -1914,27 +1914,26 @@ _PLATFORMS = [
], ],
}, },
{ {
"key": "qq", "key": "qqbot",
"label": "QQ Bot", "label": "QQ Bot",
"emoji": "💬", "emoji": "🐧",
"token_var": "QQ_APP_ID", "token_var": "QQ_APP_ID",
"setup_instructions": [ "setup_instructions": [
"1. Go to https://open.qq.com/ and create an application", "1. Register a QQ Bot application at q.qq.com",
"2. In the application dashboard, create a QQ Bot", "2. Note your App ID and App Secret from the application page",
"3. Note your App ID and App Secret", "3. Enable the required intents (C2C, Group, Guild messages)",
"4. Configure the WebSocket Gateway URL in QQ Open Platform settings", "4. Configure sandbox or publish the bot",
"5. Set up message push URL if needed for event callbacks",
], ],
"vars": [ "vars": [
{"name": "QQ_APP_ID", "prompt": "App ID", "password": False, {"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False,
"help": "Paste the App ID from QQ Open Platform."}, "help": "Your QQ Bot App ID from q.qq.com."},
{"name": "QQ_CLIENT_SECRET", "prompt": "App Secret", "password": True, {"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True,
"help": "Paste the App Secret from QQ Open Platform."}, "help": "Your QQ Bot App Secret from q.qq.com."},
{"name": "QQ_ALLOWED_USERS", "prompt": "Allowed QQ user IDs (comma-separated, or empty for DM pairing)", "password": False, {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False,
"is_allowlist": True, "is_allowlist": True,
"help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead."}, "help": "Optional — restrict DM access to specific user OpenIDs."},
{"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (QQ group ID for cron/notifications, or empty)", "password": False, {"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False,
"help": "QQ group ID to deliver cron results and notifications to."}, "help": "OpenID to deliver cron results and notifications to."},
], ],
}, },
] ]

View file

@ -35,7 +35,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")), ("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")),
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")), ("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")), ("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")), ("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")), ("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
]) ])

View file

@ -1969,6 +1969,54 @@ def _setup_wecom_callback():
_gw_setup() _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(): def _setup_bluebubbles():
"""Configure BlueBubbles iMessage gateway.""" """Configure BlueBubbles iMessage gateway."""
print_header("BlueBubbles (iMessage)") print_header("BlueBubbles (iMessage)")
@ -2034,10 +2082,10 @@ def _setup_bluebubbles():
print_info(" Install: https://docs.bluebubbles.app/helper-bundle/installation") 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.""" """Configure QQ Bot (Official API v2) via standard platform setup."""
from hermes_cli.gateway import _PLATFORMS 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: if qq_platform:
from hermes_cli.gateway import _setup_standard_platform from hermes_cli.gateway import _setup_standard_platform
_setup_standard_platform(qq_platform) _setup_standard_platform(qq_platform)
@ -2106,7 +2154,7 @@ _GATEWAY_PLATFORMS = [
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback), ("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin), ("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles), ("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), ("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("WECOM_BOT_ID")
or get_env_value("WEIXIN_ACCOUNT_ID") or get_env_value("WEIXIN_ACCOUNT_ID")
or get_env_value("BLUEBUBBLES_SERVER_URL") or get_env_value("BLUEBUBBLES_SERVER_URL")
or get_env_value("QQ_APP_ID")
or get_env_value("WEBHOOK_ENABLED") or get_env_value("WEBHOOK_ENABLED")
) )
if any_messaging: if any_messaging:
@ -2179,6 +2228,8 @@ def setup_gateway(config: dict):
missing_home.append("Slack") missing_home.append("Slack")
if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"): if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"):
missing_home.append("BlueBubbles") 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: if missing_home:
print() print()

View file

@ -305,7 +305,7 @@ def show_status(args):
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_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(): for name, (token_var, home_var) in platforms.items():

View file

@ -427,7 +427,7 @@ def _get_enabled_platforms() -> List[str]:
if get_env_value("WHATSAPP_ENABLED"): if get_env_value("WHATSAPP_ENABLED"):
enabled.append("whatsapp") enabled.append("whatsapp")
if get_env_value("QQ_APP_ID"): if get_env_value("QQ_APP_ID"):
enabled.append("qq") enabled.append("qqbot")
return enabled return enabled

View file

@ -25,7 +25,7 @@ def _make_config(**extra):
class TestQQRequirements: class TestQQRequirements:
def test_returns_bool(self): 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() result = check_qq_requirements()
assert isinstance(result, bool) assert isinstance(result, bool)
@ -36,7 +36,7 @@ class TestQQRequirements:
class TestQQAdapterInit: class TestQQAdapterInit:
def _make(self, **extra): def _make(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_basic_attributes(self): def test_basic_attributes(self):
@ -93,7 +93,7 @@ class TestQQAdapterInit:
def test_name_property(self): def test_name_property(self):
adapter = self._make(app_id="a", client_secret="b") 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: class TestCoerceList:
def _fn(self, value): def _fn(self, value):
from gateway.platforms.qq import _coerce_list from gateway.platforms.qqbot import _coerce_list
return _coerce_list(value) return _coerce_list(value)
def test_none(self): def test_none(self):
@ -130,7 +130,7 @@ class TestCoerceList:
class TestIsVoiceContentType: class TestIsVoiceContentType:
def _fn(self, content_type, filename): 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) return QQAdapter._is_voice_content_type(content_type, filename)
def test_voice_content_type(self): def test_voice_content_type(self):
@ -155,7 +155,7 @@ class TestIsVoiceContentType:
class TestStripAtMention: class TestStripAtMention:
def _fn(self, content): def _fn(self, content):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter._strip_at_mention(content) return QQAdapter._strip_at_mention(content)
def test_removes_mention(self): def test_removes_mention(self):
@ -179,7 +179,7 @@ class TestStripAtMention:
class TestDmAllowed: class TestDmAllowed:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_open_policy(self): def test_open_policy(self):
@ -209,7 +209,7 @@ class TestDmAllowed:
class TestGroupAllowed: class TestGroupAllowed:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_open_policy(self): def test_open_policy(self):
@ -231,7 +231,7 @@ class TestGroupAllowed:
class TestResolveSTTConfig: class TestResolveSTTConfig:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_no_config(self): def test_no_config(self):
@ -273,7 +273,7 @@ class TestResolveSTTConfig:
class TestDetectMessageType: class TestDetectMessageType:
def _fn(self, media_urls, media_types): 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) return QQAdapter._detect_message_type(media_urls, media_types)
def test_no_media(self): def test_no_media(self):
@ -299,24 +299,24 @@ class TestDetectMessageType:
class TestQQCloseError: class TestQQCloseError:
def test_attributes(self): def test_attributes(self):
from gateway.platforms.qq import QQCloseError from gateway.platforms.qqbot import QQCloseError
err = QQCloseError(4004, "bad token") err = QQCloseError(4004, "bad token")
assert err.code == 4004 assert err.code == 4004
assert err.reason == "bad token" assert err.reason == "bad token"
def test_code_none(self): def test_code_none(self):
from gateway.platforms.qq import QQCloseError from gateway.platforms.qqbot import QQCloseError
err = QQCloseError(None, "") err = QQCloseError(None, "")
assert err.code is None assert err.code is None
def test_string_to_int(self): def test_string_to_int(self):
from gateway.platforms.qq import QQCloseError from gateway.platforms.qqbot import QQCloseError
err = QQCloseError("4914", "banned") err = QQCloseError("4914", "banned")
assert err.code == 4914 assert err.code == 4914
assert err.reason == "banned" assert err.reason == "banned"
def test_message_format(self): def test_message_format(self):
from gateway.platforms.qq import QQCloseError from gateway.platforms.qqbot import QQCloseError
err = QQCloseError(4008, "rate limit") err = QQCloseError(4008, "rate limit")
assert "4008" in str(err) assert "4008" in str(err)
assert "rate limit" in str(err) assert "rate limit" in str(err)
@ -328,7 +328,7 @@ class TestQQCloseError:
class TestDispatchPayload: class TestDispatchPayload:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
adapter = QQAdapter(_make_config(**extra)) adapter = QQAdapter(_make_config(**extra))
return adapter return adapter
@ -368,7 +368,7 @@ class TestDispatchPayload:
class TestReadyHandling: class TestReadyHandling:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_ready_stores_session(self): def test_ready_stores_session(self):
@ -398,7 +398,7 @@ class TestReadyHandling:
class TestParseJson: class TestParseJson:
def _fn(self, raw): def _fn(self, raw):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter._parse_json(raw) return QQAdapter._parse_json(raw)
def test_valid_json(self): def test_valid_json(self):
@ -428,7 +428,7 @@ class TestParseJson:
class TestBuildTextBody: class TestBuildTextBody:
def _make_adapter(self, **extra): def _make_adapter(self, **extra):
from gateway.platforms.qq import QQAdapter from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra)) return QQAdapter(_make_config(**extra))
def test_plain_text(self): def test_plain_text(self):

View file

@ -152,6 +152,7 @@ def _handle_send(args):
"whatsapp": Platform.WHATSAPP, "whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL, "signal": Platform.SIGNAL,
"bluebubbles": Platform.BLUEBUBBLES, "bluebubbles": Platform.BLUEBUBBLES,
"qqbot": Platform.QQBOT,
"matrix": Platform.MATRIX, "matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST, "mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT, "homeassistant": Platform.HOMEASSISTANT,
@ -160,7 +161,6 @@ def _handle_send(args):
"wecom": Platform.WECOM, "wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK, "wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN, "weixin": Platform.WEIXIN,
"qq": Platform.QQ,
"email": Platform.EMAIL, "email": Platform.EMAIL,
"sms": Platform.SMS, "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) result = await _send_wecom(pconfig.extra, chat_id, chunk)
elif platform == Platform.BLUEBUBBLES: elif platform == Platform.BLUEBUBBLES:
result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) result = await _send_bluebubbles(pconfig.extra, chat_id, chunk)
elif platform == Platform.QQ: elif platform == Platform.QQBOT:
result = await _send_qq(pconfig.extra, chat_id, chunk) result = await _send_qqbot(pconfig, chat_id, chunk)
else: else:
result = {"error": f"Direct sending not yet implemented for {platform.value}"} 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}") 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): async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
"""Send via Feishu/Lark using the adapter's send pipeline.""" """Send via Feishu/Lark using the adapter's send pipeline."""
try: try:
@ -1060,6 +1041,31 @@ def _check_send_message():
return False 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 --- # --- Registry ---
from tools.registry import registry, tool_error from tools.registry import registry, tool_error

View file

@ -359,8 +359,8 @@ TOOLSETS = {
"includes": [] "includes": []
}, },
"hermes-qq": { "hermes-qqbot": {
"description": "QQ Bot toolset - QQ messaging via Official Bot API v2 (full access)", "description": "QQBot toolset - QQ messaging via Official Bot API v2 (full access)",
"tools": _HERMES_CORE_TOOLS, "tools": _HERMES_CORE_TOOLS,
"includes": [] "includes": []
}, },
@ -392,7 +392,7 @@ TOOLSETS = {
"hermes-gateway": { "hermes-gateway": {
"description": "Gateway toolset - union of all messaging platform tools", "description": "Gateway toolset - union of all messaging platform tools",
"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"]
} }
} }

View file

@ -262,20 +262,15 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `BLUEBUBBLES_HOME_CHANNEL` | Phone/email for cron/notification delivery | | `BLUEBUBBLES_HOME_CHANNEL` | Phone/email for cron/notification delivery |
| `BLUEBUBBLES_ALLOWED_USERS` | Comma-separated authorized users | | `BLUEBUBBLES_ALLOWED_USERS` | Comma-separated authorized users |
| `BLUEBUBBLES_ALLOW_ALL_USERS` | Allow all users (`true`/`false`) | | `BLUEBUBBLES_ALLOW_ALL_USERS` | Allow all users (`true`/`false`) |
| `QQ_APP_ID` | QQ Bot App ID from [q.qq.com](https://q.qq.com) |
#### QQ Bot | `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) |
| Variable | Description | | `QQ_STT_BASE_URL` | Base URL for external STT provider (optional) |
|----------|-------------| | `QQ_STT_MODEL` | Model name for external STT provider (optional) |
| `QQ_APP_ID` | QQ Bot App ID (from open.qq.com) | | `QQ_ALLOWED_USERS` | Comma-separated QQ user openIDs allowed to message the bot |
| `QQ_CLIENT_SECRET` | QQ Bot App Secret | | `QQ_GROUP_ALLOWED_USERS` | Comma-separated QQ group IDs for group @-message access |
| `QQ_SANDBOX` | Enable sandbox mode for testing (`true`/`false`) | | `QQ_ALLOW_ALL_USERS` | Allow all users (`true`/`false`, overrides `QQ_ALLOWED_USERS`) |
| `QQ_ALLOWED_USERS` | Comma-separated QQ user IDs allowed to DM the bot | | `QQ_HOME_CHANNEL` | QQ user/group openID for cron delivery and notifications |
| `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_URL` | Mattermost server URL (e.g. `https://mm.example.com`) |
| `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost | | `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost |
| `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot | | `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot |

View file

@ -30,7 +30,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
| WeCom Callback | — | — | — | — | — | — | — | | WeCom Callback | — | — | — | — | — | — | — |
| Weixin | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | Weixin | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
| BlueBubbles | — | ✅ | ✅ | — | ✅ | ✅ | — | | 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. **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 wcb --> store
wx --> store wx --> store
bb --> store bb --> store
qq --> store
api --> store api --> store
wh --> store wh --> store
store --> agent store --> agent
@ -371,7 +372,7 @@ Each platform has its own toolset:
| WeCom Callback | `hermes-wecom-callback` | Full tools including terminal | | WeCom Callback | `hermes-wecom-callback` | Full tools including terminal |
| Weixin | `hermes-weixin` | Full tools including terminal | | Weixin | `hermes-weixin` | Full tools including terminal |
| BlueBubbles | `hermes-bluebubbles` | 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 | | API Server | `hermes` (default) | Full tools including terminal |
| Webhooks | `hermes-webhook` | 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) - [WeCom Callback Setup](wecom-callback.md)
- [Weixin Setup (WeChat)](weixin.md) - [Weixin Setup (WeChat)](weixin.md)
- [BlueBubbles Setup (iMessage)](bluebubbles.md) - [BlueBubbles Setup (iMessage)](bluebubbles.md)
- [QQ Bot Setup](qq.md) - [QQBot Setup](qqbot.md)
- [Open WebUI + API Server](open-webui.md) - [Open WebUI + API Server](open-webui.md)
- [Webhooks](webhooks.md) - [Webhooks](webhooks.md)