diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3dd0f73a7..65483496e 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -115,6 +115,11 @@ def _scan_context_content(content: str, filename: str) -> str: "attachments, audio as file attachments. You can also include image URLs " "in markdown format ![alt](url) and they will be sent as attachments." ), + "qq": ( + "You are on QQ bot messaging. Keep formatting plain and concise. " + "Prefer plain text over markdown-heavy formatting because rendering is limited. " + "Address replies as chat messages, not emails or terminal output." + ), "slack": ( "You are in a Slack workspace communicating with your user. " "You can send media files natively: include MEDIA:/absolute/path/to/file " diff --git a/cron/scheduler.py b/cron/scheduler.py index 348a25c24..8bbb797f1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -100,6 +100,7 @@ def _deliver_result(job: dict, content: str) -> None: platform_map = { "telegram": Platform.TELEGRAM, "discord": Platform.DISCORD, + "qq": Platform.QQ, "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 4d11c3a91..3151ec89d 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -60,8 +60,8 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: except Exception as e: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) - # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal", "email"): + # Telegram, QQ, WhatsApp, Signal, and Email can't enumerate chats here. + for plat_name in ("telegram", "qq", "whatsapp", "signal", "email"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index 5d3dfa9f5..fa961c643 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -24,6 +24,7 @@ class Platform(Enum): LOCAL = "local" TELEGRAM = "telegram" DISCORD = "discord" + QQ = "qq" WHATSAPP = "whatsapp" SLACK = "slack" SIGNAL = "signal" @@ -159,10 +160,12 @@ def get_connected_platforms(self) -> List[Platform]: for platform, config in self.platforms.items(): if not config.enabled: continue + if platform == Platform.QQ and config.token and config.api_key: + connected.append(platform) # Platforms that use token/api_key auth - if config.token or config.api_key: + elif platform != Platform.QQ and (config.token or config.api_key): connected.append(platform) - # WhatsApp uses enabled flag only (bridge handles auth) + # Platforms that use token/api_key auth elif platform == Platform.WHATSAPP: connected.append(platform) # Signal uses extra dict for config (http_url + account) @@ -331,6 +334,7 @@ def load_gateway_config() -> GatewayConfig: _token_env_names = { Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN", Platform.DISCORD: "DISCORD_BOT_TOKEN", + Platform.QQ: "QQ_BOT_APP_ID", Platform.SLACK: "SLACK_BOT_TOKEN", } for platform, pconfig in config.platforms.items(): @@ -381,6 +385,27 @@ def _apply_env_overrides(config: GatewayConfig) -> None: chat_id=discord_home, name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), ) + + # QQ + qq_app_id = os.getenv("QQ_BOT_APP_ID") + qq_secret = os.getenv("QQ_BOT_SECRET") + if qq_app_id and qq_secret: + if Platform.QQ not in config.platforms: + config.platforms[Platform.QQ] = PlatformConfig() + config.platforms[Platform.QQ].enabled = True + config.platforms[Platform.QQ].token = qq_app_id + config.platforms[Platform.QQ].api_key = qq_secret + config.platforms[Platform.QQ].extra["sandbox"] = ( + os.getenv("QQ_BOT_SANDBOX", "").lower() in ("true", "1", "yes") + ) + + qq_home = os.getenv("QQ_HOME_CHANNEL") + if qq_home and Platform.QQ in config.platforms: + config.platforms[Platform.QQ].home_channel = HomeChannel( + platform=Platform.QQ, + chat_id=qq_home, + name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"), + ) # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") diff --git a/gateway/platforms/qq.py b/gateway/platforms/qq.py new file mode 100644 index 000000000..3a7843796 --- /dev/null +++ b/gateway/platforms/qq.py @@ -0,0 +1,278 @@ +""" +QQ platform adapter. + +Uses qq-botpy for: +- Receiving C2C and group @ messages over the QQ bot gateway +- Sending text responses back via the QQ bot API +""" + +import asyncio +import logging +import os +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +try: + import botpy + from botpy.message import C2CMessage, GroupMessage + QQ_AVAILABLE = True +except ImportError: + botpy = None + C2CMessage = Any + GroupMessage = Any + QQ_AVAILABLE = False + +import sys +from pathlib import Path as _Path +sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + + +def check_qq_requirements() -> bool: + """Check if QQ dependencies are available.""" + return QQ_AVAILABLE + + +class _HermesQQClient(botpy.Client if QQ_AVAILABLE else object): + """Thin botpy client wrapper that forwards events to the adapter.""" + + def __init__(self, adapter: "QQAdapter"): + intents = botpy.Intents(public_messages=True) + super().__init__( + intents=intents, + timeout=adapter.CONNECT_TIMEOUT_SECONDS, + is_sandbox=adapter.is_sandbox, + ) + self._adapter = adapter + + async def on_ready(self): + robot = getattr(self, "robot", None) + logger.info("[QQ] Connected as %s", getattr(robot, "name", "unknown")) + self._adapter._ready_event.set() + + async def on_group_at_message_create(self, message: GroupMessage): + await self._adapter._handle_group_message(message) + + async def on_c2c_message_create(self, message: C2CMessage): + await self._adapter._handle_c2c_message(message) + + async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: + logger.exception("[QQ] botpy client error during %s", event_method) + + +class QQAdapter(BasePlatformAdapter): + """QQ bot adapter using the official qq-botpy websocket client.""" + + MAX_MESSAGE_LENGTH = 2000 + CONNECT_TIMEOUT_SECONDS = 30 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.QQ) + self.app_id = str(config.token or "").strip() + self.secret = str(config.api_key or "").strip() + self.is_sandbox = bool(config.extra.get("sandbox")) or ( + os.getenv("QQ_BOT_SANDBOX", "").strip().lower() in ("1", "true", "yes") + ) + self._client: Optional[_HermesQQClient] = None + self._client_task: Optional[asyncio.Task] = None + self._ready_event = asyncio.Event() + + async def connect(self) -> bool: + """Connect to the QQ bot gateway and start receiving messages.""" + if not QQ_AVAILABLE: + logger.error("[%s] qq-botpy not installed. Run: pip install qq-botpy", self.name) + return False + + if not self.app_id or not self.secret: + logger.error("[%s] QQ app_id or secret missing", self.name) + return False + + try: + self._ready_event.clear() + self._client = _HermesQQClient(self) + self._client_task = asyncio.create_task( + self._client.start(appid=self.app_id, secret=self.secret), + name="qq-botpy-client", + ) + self._client_task.add_done_callback(self._on_client_done) + await asyncio.wait_for(self._ready_event.wait(), timeout=self.CONNECT_TIMEOUT_SECONDS) + self._running = True + return True + except asyncio.TimeoutError: + logger.error("[%s] Timeout waiting for QQ gateway ready event", self.name) + except Exception as e: + logger.error("[%s] Failed to connect to QQ: %s", self.name, e, exc_info=True) + + await self.disconnect() + return False + + def _on_client_done(self, task: asyncio.Task) -> None: + if task.cancelled(): + return + try: + task.result() + except Exception as e: + logger.error("[%s] QQ client task exited with error: %s", self.name, e, exc_info=True) + finally: + self._running = False + + async def disconnect(self) -> None: + """Disconnect from the QQ gateway.""" + self._running = False + + if self._client: + try: + await self._client.close() + except Exception as e: + logger.warning("[%s] Error closing QQ client: %s", self.name, e, exc_info=True) + + if self._client_task: + self._client_task.cancel() + try: + await self._client_task + except asyncio.CancelledError: + pass + except Exception: + pass + + self._client = None + self._client_task = None + self._ready_event.clear() + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message to a QQ group or C2C chat.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH) + message_ids = [] + for index, chunk in enumerate(chunks): + msg_id = reply_to if index == 0 else None + response = await self._send_text_chunk(chat_id, chunk, msg_id=msg_id) + response_id = self._extract_message_id(response) + if response_id: + message_ids.append(response_id) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids}, + ) + except Exception as e: + logger.error("[%s] Failed to send QQ message: %s", self.name, e, exc_info=True) + return SendResult(success=False, error=str(e)) + + async def _send_text_chunk(self, chat_id: str, content: str, msg_id: Optional[str] = None): + target_type, target_id = self._parse_target(chat_id) + kwargs = { + "msg_type": 0, + "content": content, + "msg_id": msg_id, + } + if target_type == "group": + return await self._client.api.post_group_message(group_openid=target_id, **kwargs) + return await self._client.api.post_c2c_message(openid=target_id, **kwargs) + + @staticmethod + def _extract_message_id(response: Any) -> Optional[str]: + if isinstance(response, dict): + value = response.get("id") + else: + value = getattr(response, "id", None) + return str(value) if value else None + + @staticmethod + def _parse_target(chat_id: str) -> tuple[str, str]: + if ":" in chat_id: + target_type, target_id = chat_id.split(":", 1) + if target_type in ("group", "user") and target_id: + return target_type, target_id + return "user", chat_id + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """QQ bot API does not expose a typing indicator.""" + return None + + async def _handle_group_message(self, message: GroupMessage) -> None: + await self.handle_message(self._event_from_group_message(message)) + + async def _handle_c2c_message(self, message: C2CMessage) -> None: + await self.handle_message(self._event_from_c2c_message(message)) + + def _event_from_group_message(self, message: GroupMessage) -> MessageEvent: + author = getattr(message, "author", None) + user_id = ( + getattr(author, "member_openid", None) + or getattr(author, "id", None) + or getattr(message, "member_openid", None) + ) + user_name = ( + getattr(author, "username", None) + or getattr(author, "nick", None) + or getattr(message, "member_name", None) + ) + group_openid = getattr(message, "group_openid", None) + text = (getattr(message, "content", None) or "").strip() + return MessageEvent( + text=text, + message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT, + source=self.build_source( + chat_id=f"group:{group_openid}", + chat_name=getattr(message, "group_name", None) or group_openid, + chat_type="group", + user_id=user_id, + user_name=user_name, + ), + raw_message=message, + message_id=str(getattr(message, "id", "") or ""), + ) + + def _event_from_c2c_message(self, message: C2CMessage) -> MessageEvent: + author = getattr(message, "author", None) + user_id = ( + getattr(author, "user_openid", None) + or getattr(author, "id", None) + or getattr(message, "user_openid", None) + ) + user_name = ( + getattr(author, "username", None) + or getattr(author, "nick", None) + or getattr(message, "author_name", None) + ) + text = (getattr(message, "content", None) or "").strip() + return MessageEvent( + text=text, + message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT, + source=self.build_source( + chat_id=f"user:{user_id}", + chat_name=user_name or user_id, + chat_type="dm", + user_id=user_id, + user_name=user_name, + ), + raw_message=message, + message_id=str(getattr(message, "id", "") or ""), + ) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + target_type, target_id = self._parse_target(chat_id) + return { + "name": target_id, + "type": "group" if target_type == "group" else "dm", + "chat_id": chat_id, + } diff --git a/gateway/run.py b/gateway/run.py index 3c2abd834..10b6480b0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -673,6 +673,13 @@ def _create_adapter( logger.warning("Discord: discord.py not installed") return None return DiscordAdapter(config) + + elif platform == Platform.QQ: + from gateway.platforms.qq import QQAdapter, check_qq_requirements + if not check_qq_requirements(): + logger.warning("QQ: qq-botpy not installed. Run: pip install qq-botpy") + return None + return QQAdapter(config) elif platform == Platform.WHATSAPP: from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements @@ -735,6 +742,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: platform_env_map = { Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.QQ: "QQ_ALLOWED_USERS", Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", Platform.SLACK: "SLACK_ALLOWED_USERS", Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", @@ -743,6 +751,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", + Platform.QQ: "QQ_ALLOW_ALL_USERS", Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", Platform.SLACK: "SLACK_ALLOW_ALL_USERS", Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", @@ -2037,6 +2046,7 @@ async def _run_background_task( Platform.LOCAL: "hermes-cli", Platform.TELEGRAM: "hermes-telegram", Platform.DISCORD: "hermes-discord", + Platform.QQ: "hermes-qq", Platform.WHATSAPP: "hermes-whatsapp", Platform.SLACK: "hermes-slack", Platform.SIGNAL: "hermes-signal", @@ -2058,6 +2068,7 @@ async def _run_background_task( Platform.LOCAL: "cli", Platform.TELEGRAM: "telegram", Platform.DISCORD: "discord", + Platform.QQ: "qq", Platform.WHATSAPP: "whatsapp", Platform.SLACK: "slack", Platform.SIGNAL: "signal", @@ -2855,6 +2866,7 @@ async def _run_agent( Platform.LOCAL: "hermes-cli", Platform.TELEGRAM: "hermes-telegram", Platform.DISCORD: "hermes-discord", + Platform.QQ: "hermes-qq", Platform.WHATSAPP: "hermes-whatsapp", Platform.SLACK: "hermes-slack", Platform.SIGNAL: "hermes-signal", @@ -2879,6 +2891,7 @@ async def _run_agent( Platform.LOCAL: "cli", Platform.TELEGRAM: "telegram", Platform.DISCORD: "discord", + Platform.QQ: "qq", Platform.WHATSAPP: "whatsapp", Platform.SLACK: "slack", Platform.SIGNAL: "signal", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0094b94b5..77ecb87e1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -474,6 +474,27 @@ def ensure_hermes_home(): "password": False, "category": "messaging", }, + "QQ_BOT_APP_ID": { + "description": "QQ bot AppID for gateway mode", + "prompt": "QQ bot AppID", + "url": "https://q.qq.com/", + "password": False, + "category": "messaging", + }, + "QQ_BOT_SECRET": { + "description": "QQ bot AppSecret for gateway mode", + "prompt": "QQ bot AppSecret", + "url": "https://q.qq.com/", + "password": True, + "category": "messaging", + }, + "QQ_ALLOWED_USERS": { + "description": "Comma-separated QQ user openids allowed to use the bot", + "prompt": "Allowed QQ user openids (comma-separated)", + "url": "https://q.qq.com/", + "password": False, + "category": "messaging", + }, "SLACK_BOT_TOKEN": { "description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. " "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, " diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 26a8f5987..417384370 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -475,6 +475,30 @@ def run_gateway(verbose: bool = False, replace: bool = False): "help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."}, ], }, + { + "key": "qq", + "label": "QQ", + "emoji": "🐧", + "token_var": "QQ_BOT_APP_ID", + "setup_instructions": [ + "1. Open the QQ Bot developer console and create a bot application", + "2. Copy the AppID and AppSecret for your bot", + "3. Enable QQ group/C2C public message events", + "4. Add your user openid to the allowlist below", + "5. Use home targets like user:USER_OPENID or group:GROUP_OPENID", + ], + "vars": [ + {"name": "QQ_BOT_APP_ID", "prompt": "QQ AppID", "password": False, + "help": "Paste the AppID from the QQ bot console."}, + {"name": "QQ_BOT_SECRET", "prompt": "QQ AppSecret", "password": True, + "help": "Paste the AppSecret from the QQ bot console."}, + {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user openids (comma-separated)", "password": False, + "is_allowlist": True, + "help": "Only these QQ users will be able to interact with Hermes."}, + {"name": "QQ_HOME_CHANNEL", "prompt": "Home target (user:OPENID or group:OPENID)", "password": False, + "help": "Used for cron/notification delivery when the user says just 'qq'."}, + ], + }, { "key": "slack", "label": "Slack", @@ -569,6 +593,13 @@ def _platform_status(platform: dict) -> str: if val or account: return "partially configured" return "not configured" + if platform.get("key") == "qq": + secret = get_env_value("QQ_BOT_SECRET") + if val and secret: + return "configured" + if val or secret: + return "partially configured" + return "not configured" if platform.get("key") == "email": pwd = get_env_value("EMAIL_PASSWORD") imap = get_env_value("EMAIL_IMAP_HOST") diff --git a/pyproject.toml b/pyproject.toml index eb1ae9e53..78ca8b437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,10 @@ dependencies = [ modal = ["swe-rex[modal]>=1.4.0"] daytona = ["daytona>=0.148.0"] dev = ["pytest", "pytest-asyncio", "pytest-xdist", "mcp>=1.2.0"] -messaging = ["python-telegram-bot>=20.0", "discord.py>=2.0", "aiohttp>=3.9.0", "slack-bolt>=1.18.0", "slack-sdk>=3.27.0"] +messaging = ["python-telegram-bot>=20.0", "discord.py>=2.0", "qq-botpy>=1.2.1", "aiohttp>=3.9.0", "slack-bolt>=1.18.0", "slack-sdk>=3.27.0"] cron = ["croniter"] slack = ["slack-bolt>=1.18.0", "slack-sdk>=3.27.0"] +qq = ["qq-botpy>=1.2.1"] cli = ["simple-term-menu"] tts-premium = ["elevenlabs"] pty = [ @@ -63,6 +64,7 @@ all = [ "hermes-agent[dev]", "hermes-agent[tts-premium]", "hermes-agent[slack]", + "hermes-agent[qq]", "hermes-agent[pty]", "hermes-agent[honcho]", "hermes-agent[mcp]", diff --git a/requirements.txt b/requirements.txt index 030c84656..244e60c78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,4 +37,5 @@ croniter # Optional: For messaging platform integrations (gateway) python-telegram-bot>=20.0 discord.py>=2.0 +qq-botpy>=1.2.1 aiohttp>=3.9.0 diff --git a/tests/gateway/test_qq.py b/tests/gateway/test_qq.py new file mode 100644 index 000000000..25c86e065 --- /dev/null +++ b/tests/gateway/test_qq.py @@ -0,0 +1,220 @@ +"""Tests for native QQ gateway platform support.""" + +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import SendResult + + +class TestQQPlatformEnum: + def test_qq_enum_exists(self): + assert Platform.QQ.value == "qq" + + def test_qq_in_platform_list(self): + assert "qq" in [platform.value for platform in Platform] + + +class TestQQConfigLoading: + def test_apply_env_overrides_qq(self, monkeypatch): + monkeypatch.setenv("QQ_BOT_APP_ID", "1024") + monkeypatch.setenv("QQ_BOT_SECRET", "secret-xyz") + monkeypatch.setenv("QQ_HOME_CHANNEL", "group:home") + + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.QQ in config.platforms + qq_config = config.platforms[Platform.QQ] + assert qq_config.enabled is True + assert qq_config.token == "1024" + assert qq_config.api_key == "secret-xyz" + assert qq_config.home_channel.chat_id == "group:home" + + def test_qq_not_loaded_without_secret(self, monkeypatch): + monkeypatch.setenv("QQ_BOT_APP_ID", "1024") + monkeypatch.delenv("QQ_BOT_SECRET", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.QQ not in config.platforms + + def test_connected_platforms_includes_qq(self, monkeypatch): + monkeypatch.setenv("QQ_BOT_APP_ID", "1024") + monkeypatch.setenv("QQ_BOT_SECRET", "secret-xyz") + + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.QQ in config.get_connected_platforms() + + def test_connected_platforms_excludes_half_configured_qq(self): + from gateway.config import GatewayConfig + + config = GatewayConfig( + platforms={ + Platform.QQ: PlatformConfig(enabled=True, token="1024", api_key=""), + } + ) + + assert Platform.QQ not in config.get_connected_platforms() + + +class TestQQAdapterHelpers: + def test_check_requirements_reflects_sdk_availability(self, monkeypatch): + from gateway.platforms import qq as qq_mod + + monkeypatch.setattr(qq_mod, "QQ_AVAILABLE", True) + assert qq_mod.check_qq_requirements() is True + + monkeypatch.setattr(qq_mod, "QQ_AVAILABLE", False) + assert qq_mod.check_qq_requirements() is False + + @pytest.mark.asyncio + async def test_send_routes_group_targets_via_group_api(self): + from gateway.platforms.qq import QQAdapter + + adapter = QQAdapter(PlatformConfig(enabled=True, token="1024", api_key="secret")) + adapter._client = SimpleNamespace(api=SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "group-msg-1"}), + post_c2c_message=AsyncMock(), + )) + + result = await adapter.send("group:group-openid", "hello QQ") + + assert isinstance(result, SendResult) + assert result.success is True + adapter._client.api.post_group_message.assert_awaited_once_with( + group_openid="group-openid", + msg_type=0, + content="hello QQ", + msg_id=None, + ) + adapter._client.api.post_c2c_message.assert_not_called() + + @pytest.mark.asyncio + async def test_send_routes_dm_targets_via_c2c_api(self): + from gateway.platforms.qq import QQAdapter + + adapter = QQAdapter(PlatformConfig(enabled=True, token="1024", api_key="secret")) + adapter._client = SimpleNamespace(api=SimpleNamespace( + post_group_message=AsyncMock(), + post_c2c_message=AsyncMock(return_value={"id": "dm-msg-1"}), + )) + + result = await adapter.send("user:user-openid", "hello DM") + + assert result.success is True + adapter._client.api.post_c2c_message.assert_awaited_once_with( + openid="user-openid", + msg_type=0, + content="hello DM", + msg_id=None, + ) + adapter._client.api.post_group_message.assert_not_called() + + def test_normalize_group_message_event(self): + from gateway.platforms.qq import QQAdapter + + adapter = QQAdapter(PlatformConfig(enabled=True, token="1024", api_key="secret")) + raw_message = SimpleNamespace( + id="m-1", + content="hi from group", + group_openid="group-openid", + author=SimpleNamespace(id="member-openid", username="Alice"), + ) + + event = adapter._event_from_group_message(raw_message) + + assert event.text == "hi from group" + assert event.message_id == "m-1" + assert event.source.platform == Platform.QQ + assert event.source.chat_id == "group:group-openid" + assert event.source.chat_type == "group" + assert event.source.user_id == "member-openid" + assert event.source.user_name == "Alice" + + def test_normalize_c2c_message_event(self): + from gateway.platforms.qq import QQAdapter + + adapter = QQAdapter(PlatformConfig(enabled=True, token="1024", api_key="secret")) + raw_message = SimpleNamespace( + id="m-2", + content="hi from dm", + author=SimpleNamespace(id="user-openid", username="Bob"), + ) + + event = adapter._event_from_c2c_message(raw_message) + + assert event.text == "hi from dm" + assert event.message_id == "m-2" + assert event.source.platform == Platform.QQ + assert event.source.chat_id == "user:user-openid" + assert event.source.chat_type == "dm" + assert event.source.user_id == "user-openid" + assert event.source.user_name == "Bob" + + +class TestQQIntegrationPoints: + def test_qq_in_adapter_factory(self): + import gateway.run + + source = inspect.getsource(gateway.run.GatewayRunner._create_adapter) + assert "Platform.QQ" in source + + def test_qq_in_auth_maps(self): + import gateway.run + + source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized) + assert "QQ_ALLOWED_USERS" in source + assert "QQ_ALLOW_ALL_USERS" in source + + def test_qq_in_send_message_tool(self): + import tools.send_message_tool as send_message_tool + + handle_send_src = inspect.getsource(send_message_tool._handle_send) + route_src = inspect.getsource(send_message_tool._send_to_platform) + assert '"qq"' in handle_send_src + assert "Platform.QQ" in route_src + + def test_qq_in_cron_delivery_map(self): + import cron.scheduler + + source = inspect.getsource(cron.scheduler) + assert '"qq"' in source + + def test_qq_in_toolsets(self): + from toolsets import TOOLSETS + + assert "hermes-qq" in TOOLSETS + assert "hermes-qq" in TOOLSETS["hermes-gateway"]["includes"] + + def test_qq_in_platform_hints(self): + from agent.prompt_builder import PLATFORM_HINTS + + assert "qq" in PLATFORM_HINTS + assert "qq" in PLATFORM_HINTS["qq"].lower() + + def test_qq_in_channel_directory(self): + import gateway.channel_directory + + source = inspect.getsource(gateway.channel_directory.build_channel_directory) + assert '"qq"' in source + + def test_qq_in_gateway_setup(self): + import hermes_cli.gateway + + source = inspect.getsource(hermes_cli.gateway) + assert '"key": "qq"' in source + assert "QQ_BOT_APP_ID" in source + assert "QQ_BOT_SECRET" in source diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 13c345070..7802a6489 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -136,7 +136,7 @@ def test_all_includes_reference_existing_toolsets(self): def test_hermes_platforms_share_core_tools(self): """All hermes-* platform toolsets should have the same tools.""" - platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] + platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-qq", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] tool_sets = [set(TOOLSETS[p]["tools"]) for p in platforms] # All platform toolsets should be identical for ts in tool_sets[1:]: diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index fc037bc84..cc8537512 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2,11 +2,14 @@ import asyncio import json +import sys from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import pytest + from gateway.config import Platform -from tools.send_message_tool import send_message_tool +from tools.send_message_tool import _send_qq, send_message_tool def _run_async_immediately(coro): @@ -44,6 +47,93 @@ def test_sends_to_explicit_telegram_topic_target(self): send_mock.assert_awaited_once_with(Platform.TELEGRAM, telegram_cfg, "-1001", "hello", thread_id="17585") mirror_mock.assert_called_once_with("telegram", "-1001", "hello", source_label="cli", thread_id="17585") + +class TestQQStandaloneSend: + @pytest.mark.asyncio + async def test_send_qq_imports_httpx_and_sends_message(self, monkeypatch): + sent_payloads = [] + + class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + if url.endswith("/getAppAccessToken"): + return _Response({"access_token": "qq-token"}) + sent_payloads.append((url, json, headers)) + return _Response({"id": f"msg-{len(sent_payloads)}"}) + + fake_httpx = SimpleNamespace(AsyncClient=lambda timeout=30.0: _Client()) + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + result = await _send_qq( + SimpleNamespace(token="1024", api_key="secret", extra={}), + "user:user-openid", + "hello qq", + ) + + assert result["success"] is True + assert sent_payloads == [ + ( + "https://api.sgroup.qq.com/v2/users/user-openid/messages", + {"msg_type": 0, "content": "hello qq"}, + {"Authorization": "QQBot qq-token", "X-Union-Appid": "1024"}, + ) + ] + + @pytest.mark.asyncio + async def test_send_qq_chunks_long_messages(self, monkeypatch): + sent_payloads = [] + + class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + if url.endswith("/getAppAccessToken"): + return _Response({"access_token": "qq-token"}) + sent_payloads.append(json["content"]) + return _Response({"id": f"msg-{len(sent_payloads)}"}) + + fake_httpx = SimpleNamespace(AsyncClient=lambda timeout=30.0: _Client()) + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + result = await _send_qq( + SimpleNamespace(token="1024", api_key="secret", extra={}), + "group:group-openid", + "x" * 2001, + ) + + assert result["success"] is True + assert sent_payloads == ["x" * 2000, "x"] + assert result["message_ids"] == ["msg-1", "msg-2"] + def test_resolved_telegram_topic_name_preserves_thread_id(self): config, telegram_cfg = _make_config() diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 561763860..cc60b7861 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -36,7 +36,7 @@ }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or Telegram topic 'telegram:chat_id:thread_id'. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:#bot-home', 'slack:#engineering', 'signal:+15551234567'" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or Telegram topic 'telegram:chat_id:thread_id'. QQ targets use 'qq:group:GROUP_OPENID' or 'qq:user:USER_OPENID'. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:#bot-home', 'slack:#engineering', 'signal:+15551234567', 'qq:group:abcdef1234'" }, "message": { "type": "string", @@ -116,6 +116,7 @@ def _handle_send(args): platform_map = { "telegram": Platform.TELEGRAM, "discord": Platform.DISCORD, + "qq": Platform.QQ, "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, @@ -170,6 +171,8 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref) if match: return match.group(1), match.group(2), True + if platform_name == "qq" and re.fullmatch(r"(group|user):[^:\s]+", target_ref): + return target_ref, None, True if target_ref.lstrip("-").isdigit(): return target_ref, None, True return None, None, False @@ -182,6 +185,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None) return await _send_telegram(pconfig.token, chat_id, message, thread_id=thread_id) elif platform == Platform.DISCORD: return await _send_discord(pconfig.token, chat_id, message) + elif platform == Platform.QQ: + return await _send_qq(pconfig, chat_id, message) elif platform == Platform.SLACK: return await _send_slack(pconfig.token, chat_id, message) elif platform == Platform.SIGNAL: @@ -231,6 +236,71 @@ async def _send_discord(token, chat_id, message): return {"error": f"Discord send failed: {e}"} +async def _send_qq(pconfig, chat_id, message): + """Send via QQ bot REST API using AppID/AppSecret.""" + try: + import httpx + except ImportError: + return {"error": "QQ sending requires the 'httpx' package. Please install it (e.g. 'pip install httpx')."} + + app_id = str(getattr(pconfig, "token", "") or "").strip() + secret = str(getattr(pconfig, "api_key", "") or "").strip() + sandbox = bool(getattr(pconfig, "extra", {}).get("sandbox")) + + if not app_id or not secret: + return {"error": "QQ bot not configured (QQ_BOT_APP_ID and QQ_BOT_SECRET required)"} + + if ":" in chat_id: + target_type, target_id = chat_id.split(":", 1) + else: + target_type, target_id = "user", chat_id + if target_type not in ("group", "user") or not target_id: + return {"error": "QQ target must be 'group:GROUP_OPENID' or 'user:USER_OPENID'"} + + api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com" + token_url = "https://bots.qq.com/app/getAppAccessToken" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + token_resp = await client.post(token_url, json={"appId": app_id, "clientSecret": secret}) + token_resp.raise_for_status() + token_data = token_resp.json() + access_token = token_data.get("access_token") + if not access_token: + return {"error": f"QQ token request failed: {token_data}"} + + headers = { + "Authorization": f"QQBot {access_token}", + "X-Union-Appid": app_id, + } + if target_type == "group": + url = f"{api_base}/v2/groups/{target_id}/messages" + else: + url = f"{api_base}/v2/users/{target_id}/messages" + chunks = [message[i:i+2000] for i in range(0, len(message), 2000)] or [""] + message_ids = [] + for chunk in chunks: + resp = await client.post( + url, + headers=headers, + json={"msg_type": 0, "content": chunk}, + ) + resp.raise_for_status() + data = resp.json() + message_id = data.get("id") + if message_id: + message_ids.append(message_id) + return { + "success": True, + "platform": "qq", + "chat_id": chat_id, + "message_id": message_ids[0] if message_ids else None, + "message_ids": message_ids, + } + except Exception as e: + return {"error": f"QQ send failed: {e}"} + + async def _send_slack(token, chat_id, message): """Send via Slack Web API.""" try: diff --git a/toolsets.py b/toolsets.py index 4aa37f877..cdf33c383 100644 --- a/toolsets.py +++ b/toolsets.py @@ -242,6 +242,12 @@ "tools": _HERMES_CORE_TOOLS, "includes": [] }, + + "hermes-qq": { + "description": "QQ bot toolset - full access for QQ group and C2C messaging", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, "hermes-whatsapp": { "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", @@ -276,7 +282,7 @@ "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-qq", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"] } }