Coverage for agentos/channels/adapters/qq.py: 0%
66 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 19:15 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 19:15 +0800
1"""
2AgentOS Channels — QQ 机器人适配器。
4Webhook 规范: https://bot.q.qq.com/wiki/develop/api-v2/
6特性:
7 - WebSocket 长连接(QQ 官方推荐) + HTTP webhook 双模式
8 - JSON 报文解析
9 - Bot Token 管理
10 - 主动推送 + 被动回复
11"""
13from __future__ import annotations
15import json
16import time
18import httpx
20from agentos.channels.base import BaseChannelAdapter, ChannelConfig, ReplyResult
21from agentos.channels.message import ChannelMessage, ChannelType, MessageType
24class QQAdapter(BaseChannelAdapter):
25 """QQ 机器人适配器。"""
27 channel_type = ChannelType.QQ
29 def __init__(self, config: ChannelConfig):
30 super().__init__(config)
31 self._token: str = config.bot_token or ""
32 self._token_expires: float = float("inf") # QQ Bot Token 由配置直接提供
34 # ── Webhook ──
36 def verify_signature(self, raw_body: bytes, headers: dict) -> bool:
37 """QQ Bot 暂不强验证签名。"""
38 return True
40 def parse_webhook(
41 self, raw_body: bytes, headers: dict
42 ) -> ChannelMessage | list[ChannelMessage]:
43 data = json.loads(raw_body.decode("utf-8"))
44 op = data.get("op", 0)
45 t = data.get("t", "")
46 event_data = data.get("d", {})
48 # 处理不同类型的 QQ 事件
49 if op == 10:
50 # Hello 事件
51 return ChannelMessage(
52 msg_id="hello",
53 channel=ChannelType.QQ,
54 msg_type=MessageType.EVENT,
55 content="hello",
56 extra={"op": 10, "heartbeat_interval": event_data.get("heartbeat_interval", 0)},
57 )
59 if op == 11:
60 # Heartbeat ACK
61 return ChannelMessage(
62 msg_id="heartbeat_ack",
63 channel=ChannelType.QQ,
64 msg_type=MessageType.EVENT,
65 content="heartbeat_ack",
66 extra={"op": 11},
67 )
69 # op == 0: Dispatch 事件
70 msg_map = {
71 "AT_MESSAGE_CREATE": "text",
72 "MESSAGE_CREATE": "text",
73 "DIRECT_MESSAGE_CREATE": "text",
74 "C2C_MESSAGE_CREATE": "text",
75 }
76 msg_type_str = msg_map.get(t, "text")
77 msg_type_map = {"text": MessageType.TEXT}
78 msg_type = msg_type_map.get(msg_type_str, MessageType.TEXT)
80 author = event_data.get("author", {})
81 content = event_data.get("content", "").strip()
83 # 去掉 @机器人 前缀
84 if content.startswith("<@"):
85 end = content.find(">")
86 if end > 0:
87 content = content[end + 1 :].strip()
89 return ChannelMessage(
90 msg_id=event_data.get("id", ""),
91 channel=ChannelType.QQ,
92 msg_type=msg_type,
93 content=content,
94 sender_id=author.get("id", ""),
95 sender_name=author.get("username", ""),
96 timestamp=float(time.time()),
97 conversation_id=event_data.get("channel_id", event_data.get("guild_id", "")),
98 reply_token="",
99 extra={
100 "guild_id": event_data.get("guild_id"),
101 "channel_id": event_data.get("channel_id"),
102 "member": event_data.get("member"),
103 "event_type": t,
104 "is_group": bool(event_data.get("guild_id")),
105 },
106 )
108 def build_reply(self, msg: ChannelMessage, reply_text: str) -> str:
109 return json.dumps(
110 {
111 "msg_type": 0,
112 "content": reply_text,
113 "msg_id": msg.msg_id,
114 "message_reference": {"message_id": msg.msg_id},
115 }
116 )
118 # ── 主动推送 ──
120 async def send_message(self, user_id: str, content: str, msg_type: str = "text") -> ReplyResult:
121 token = await self.get_access_token()
122 # QQ Bot 发消息需要知道 channel_id
123 channel_id = self.config.extra.get("channel_id", "")
124 if not channel_id:
125 return ReplyResult(success=False, error="no channel_id in config")
127 url = f"https://api.sgroup.qq.com/channels/{channel_id}/messages"
128 headers = {"Authorization": f"Bot {self.config.app_id}.{token}"}
129 payload = {"content": content, "msg_type": 0}
131 async with httpx.AsyncClient() as client:
132 resp = await client.post(url, json=payload, headers=headers, timeout=10)
133 data = resp.json()
134 msg_id = data.get("id", "")
135 if msg_id:
136 return ReplyResult(success=True, msg_id=msg_id)
137 return ReplyResult(success=False, error=f"qq error: {data}")
139 async def send_c2c_message(self, user_id: str, content: str) -> ReplyResult:
140 """发送私聊消息。"""
141 token = await self.get_access_token()
142 url = f"https://api.sgroup.qq.com/v2/users/{user_id}/messages"
143 headers = {"Authorization": f"Bot {self.config.app_id}.{token}"}
144 payload = {"content": content, "msg_type": 0}
146 async with httpx.AsyncClient() as client:
147 resp = await client.post(url, json=payload, headers=headers, timeout=10)
148 data = resp.json()
149 return ReplyResult(success="id" in data, msg_id=data.get("id", ""))
151 async def send_image(self, user_id: str, image_url: str) -> ReplyResult:
152 return await self.send_message(user_id, f"[图片] {image_url}")
154 async def send_file(self, user_id: str, file_url: str, filename: str) -> ReplyResult:
155 return await self.send_message(user_id, f"文件: {filename}\n{file_url}")
157 # ── Token ──
159 async def get_access_token(self) -> str:
160 return self._token or self.config.bot_token