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
│ ├── 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)

View file

@ -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."
),
}
# ---------------------------------------------------------------------------

View file

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

View file

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

View file

@ -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"),
)

View file

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

View file

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

View file

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

View file

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

View file

@ -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."},
],
},
]

View file

@ -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")),
])

View file

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

View file

@ -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():

View file

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

View file

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

View file

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

View file

@ -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"]
}
}

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

View file

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