ErisPulse-QQBotAdapter 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ import time
2
+ import uuid
3
+ from datetime import datetime, timezone
4
+ from typing import Dict, Optional, List
5
+
6
+
7
+ class QQBotConverter:
8
+ def __init__(self, bot_id_getter=None):
9
+ self._bot_id_getter = bot_id_getter
10
+ self._event_type_map = {
11
+ "C2C_MESSAGE_CREATE": ("message", "private"),
12
+ "GROUP_AT_MESSAGE_CREATE": ("message", "group"),
13
+ "AT_MESSAGE_CREATE": ("message", "channel"),
14
+ "MESSAGE_CREATE": ("message", "channel"),
15
+ "DIRECT_MESSAGE_CREATE": ("message", "private"),
16
+ "FRIEND_ADD": ("notice", "friend_add"),
17
+ "FRIEND_DEL": ("notice", "friend_del"),
18
+ "C2C_MSG_REJECT": ("notice", "private_block"),
19
+ "C2C_MSG_RECEIVE": ("notice", "private_allow"),
20
+ "GROUP_ADD_ROBOT": ("notice", "group_increase"),
21
+ "GROUP_DEL_ROBOT": ("notice", "group_decrease"),
22
+ "GROUP_MSG_REJECT": ("notice", "group_block"),
23
+ "GROUP_MSG_RECEIVE": ("notice", "group_allow"),
24
+ "GUILD_CREATE": ("notice", "guild_create"),
25
+ "GUILD_UPDATE": ("notice", "guild_update"),
26
+ "GUILD_DELETE": ("notice", "guild_delete"),
27
+ "CHANNEL_CREATE": ("notice", "channel_create"),
28
+ "CHANNEL_UPDATE": ("notice", "channel_update"),
29
+ "CHANNEL_DELETE": ("notice", "channel_delete"),
30
+ "GUILD_MEMBER_ADD": ("notice", "group_member_increase"),
31
+ "GUILD_MEMBER_UPDATE": ("notice", "group_member_update"),
32
+ "GUILD_MEMBER_REMOVE": ("notice", "group_member_decrease"),
33
+ "MESSAGE_REACTION_ADD": ("notice", "qqbot_reaction_add"),
34
+ "MESSAGE_REACTION_REMOVE": ("notice", "qqbot_reaction_remove"),
35
+ "INTERACTION_CREATE": ("notice", "qqbot_interaction"),
36
+ "MESSAGE_AUDIT_PASS": ("notice", "qqbot_audit_pass"),
37
+ "MESSAGE_AUDIT_REJECT": ("notice", "qqbot_audit_reject"),
38
+ "AUDIO_START": ("notice", "qqbot_audio_start"),
39
+ "AUDIO_FINISH": ("notice", "qqbot_audio_finish"),
40
+ "AT_MESSAGE_DELETE": ("notice", "qqbot_message_delete"),
41
+ "PUBLIC_MESSAGE_DELETE": ("notice", "qqbot_message_delete"),
42
+ "DIRECT_MESSAGE_DELETE": ("notice", "qqbot_message_delete"),
43
+ }
44
+
45
+ def convert(self, raw_event: Dict, ws_event_type: str = "") -> Optional[Dict]:
46
+ if not isinstance(raw_event, dict):
47
+ return None
48
+
49
+ event_id = raw_event.get("id") or str(uuid.uuid4())
50
+
51
+ timestamp = raw_event.get("timestamp")
52
+ if timestamp:
53
+ event_time = self._parse_timestamp(timestamp)
54
+ else:
55
+ event_time = int(time.time())
56
+
57
+ raw_type = ws_event_type
58
+ if not raw_type:
59
+ for key in raw_event:
60
+ if key not in ("id", "timestamp", "version", "shard") and key.isupper():
61
+ raw_type = key
62
+ break
63
+
64
+ if not raw_type:
65
+ inner_data = raw_event.get("d", raw_event)
66
+ if isinstance(inner_data, dict):
67
+ for key in inner_data:
68
+ if key.isupper() and "_" in key:
69
+ raw_type = key
70
+ break
71
+
72
+ event_info = self._event_type_map.get(raw_type, None)
73
+ if event_info is None:
74
+ return self._create_unknown_event(raw_event, event_id, raw_type or "unknown")
75
+
76
+ event_type, detail_type = event_info
77
+
78
+ base_event = {
79
+ "id": str(event_id),
80
+ "time": event_time,
81
+ "type": event_type,
82
+ "detail_type": detail_type,
83
+ "platform": "qqbot",
84
+ "self": {
85
+ "platform": "qqbot",
86
+ "user_id": self._bot_id_getter() if self._bot_id_getter else "",
87
+ },
88
+ "qqbot_raw": raw_event,
89
+ "qqbot_raw_type": raw_type,
90
+ }
91
+
92
+ handler = getattr(self, f"_handle_{event_type}", None)
93
+ if handler:
94
+ return handler(raw_event, base_event, raw_type)
95
+
96
+ return base_event
97
+
98
+ def _handle_message(self, raw_event: Dict, base_event: Dict, raw_type: str) -> Dict:
99
+ detail_type = base_event["detail_type"]
100
+
101
+ if raw_type == "C2C_MESSAGE_CREATE":
102
+ author = raw_event.get("author", {})
103
+ base_event["user_id"] = str(author.get("user_openid", ""))
104
+ base_event["user_nickname"] = ""
105
+ base_event["message_id"] = raw_event.get("id", "")
106
+ base_event["qqbot_openid"] = author.get("user_openid", "")
107
+ elif raw_type == "GROUP_AT_MESSAGE_CREATE":
108
+ author = raw_event.get("author", {})
109
+ base_event["user_id"] = str(author.get("member_openid", ""))
110
+ base_event["user_nickname"] = ""
111
+ base_event["group_id"] = raw_event.get("group_openid", "")
112
+ base_event["message_id"] = raw_event.get("id", "")
113
+ base_event["qqbot_group_openid"] = raw_event.get("group_openid", "")
114
+ base_event["qqbot_member_openid"] = author.get("member_openid", "")
115
+ elif raw_type in ("AT_MESSAGE_CREATE", "MESSAGE_CREATE"):
116
+ author = raw_event.get("author", {})
117
+ base_event["user_id"] = str(author.get("user_openid", author.get("id", "")))
118
+ base_event["user_nickname"] = author.get("nick", author.get("username", ""))
119
+ base_event["channel_id"] = raw_event.get("channel_id", "")
120
+ base_event["group_id"] = raw_event.get("guild_id", "")
121
+ base_event["message_id"] = raw_event.get("id", "")
122
+ elif raw_type == "DIRECT_MESSAGE_CREATE":
123
+ author = raw_event.get("author", {})
124
+ base_event["user_id"] = str(author.get("user_openid", author.get("id", "")))
125
+ base_event["user_nickname"] = author.get("nick", author.get("username", ""))
126
+ base_event["message_id"] = raw_event.get("id", "")
127
+ base_event["qqbot_guild_id"] = raw_event.get("guild_id", "")
128
+
129
+ content = raw_event.get("content", "")
130
+ attachments = raw_event.get("attachments", [])
131
+ message_segments = []
132
+
133
+ if raw_event.get("event_id"):
134
+ pass
135
+
136
+ if "reply_token" in raw_event and raw_type in ("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"):
137
+ pass
138
+
139
+ for mention in raw_event.get("mentions", []):
140
+ message_segments.append({
141
+ "type": "mention",
142
+ "data": {
143
+ "user_id": str(mention.get("id", "")),
144
+ "user_name": mention.get("nick", mention.get("username", "")),
145
+ },
146
+ })
147
+
148
+ if content:
149
+ message_segments.append({"type": "text", "data": {"text": content.strip()}})
150
+
151
+ for attachment in attachments:
152
+ content_type = attachment.get("content_type", "")
153
+ url = attachment.get("url", "")
154
+ if content_type.startswith("image"):
155
+ message_segments.append({
156
+ "type": "image",
157
+ "data": {"url": url, "qqbot_attachment": attachment},
158
+ })
159
+ elif content_type.startswith("video"):
160
+ message_segments.append({
161
+ "type": "video",
162
+ "data": {"url": url, "qqbot_attachment": attachment},
163
+ })
164
+ elif content_type.startswith("audio"):
165
+ message_segments.append({
166
+ "type": "voice",
167
+ "data": {"url": url, "qqbot_attachment": attachment},
168
+ })
169
+ else:
170
+ message_segments.append({
171
+ "type": "file",
172
+ "data": {"url": url, "qqbot_attachment": attachment},
173
+ })
174
+
175
+ base_event["message"] = message_segments
176
+ base_event["alt_message"] = self._generate_alt_message(message_segments)
177
+
178
+ if raw_type == "C2C_MESSAGE_CREATE":
179
+ base_event["qqbot_event_id"] = raw_event.get("id", "")
180
+ base_event["qqbot_reply_token"] = raw_event.get("reply_token", "")
181
+ elif raw_type == "GROUP_AT_MESSAGE_CREATE":
182
+ base_event["qqbot_event_id"] = raw_event.get("id", "")
183
+ base_event["qqbot_reply_token"] = raw_event.get("reply_token", "")
184
+
185
+ return base_event
186
+
187
+ def _handle_notice(self, raw_event: Dict, base_event: Dict, raw_type: str) -> Dict:
188
+ if raw_type == "GUILD_MEMBER_ADD":
189
+ base_event["user_id"] = str(raw_event.get("user", {}).get("id", ""))
190
+ base_event["user_nickname"] = raw_event.get("user", {}).get("nick", "")
191
+ base_event["group_id"] = raw_event.get("guild_id", "")
192
+ base_event["operator_id"] = ""
193
+ elif raw_type in ("GUILD_MEMBER_UPDATE", "GUILD_MEMBER_REMOVE"):
194
+ base_event["user_id"] = str(raw_event.get("user", {}).get("id", ""))
195
+ base_event["user_nickname"] = raw_event.get("user", {}).get("nick", "")
196
+ base_event["group_id"] = raw_event.get("guild_id", "")
197
+ base_event["operator_id"] = str(raw_event.get("op_user_id", ""))
198
+ elif raw_type in ("GROUP_ADD_ROBOT", "GROUP_DEL_ROBOT", "GROUP_MSG_REJECT", "GROUP_MSG_RECEIVE"):
199
+ base_event["group_id"] = raw_event.get("group_openid", "")
200
+ base_event["operator_id"] = raw_event.get("op_member_openid", "")
201
+ elif raw_type in ("FRIEND_ADD", "FRIEND_DEL"):
202
+ base_event["user_id"] = raw_event.get("openid", "")
203
+ base_event["user_nickname"] = ""
204
+ elif raw_type in ("C2C_MSG_REJECT", "C2C_MSG_RECEIVE"):
205
+ base_event["user_id"] = raw_event.get("openid", "")
206
+ elif raw_type == "INTERACTION_CREATE":
207
+ base_event["qqbot_interaction_id"] = raw_event.get("id", "")
208
+ base_event["qqbot_interaction_type"] = raw_event.get("type", "")
209
+ base_event["qqbot_interaction_data"] = raw_event.get("data", {})
210
+ elif raw_type in ("MESSAGE_AUDIT_PASS", "MESSAGE_AUDIT_REJECT"):
211
+ base_event["qqbot_audit_id"] = raw_event.get("audit_id", "")
212
+ base_event["qqbot_message_id"] = raw_event.get("message_id", "")
213
+ if raw_type == "MESSAGE_AUDIT_REJECT":
214
+ base_event["qqbot_audit_reject_reason"] = raw_event.get("audit_reject_reason", "")
215
+ elif raw_type in ("CHANNEL_CREATE", "CHANNEL_UPDATE", "CHANNEL_DELETE"):
216
+ base_event["channel_id"] = raw_event.get("id", "")
217
+ base_event["group_id"] = raw_event.get("guild_id", "")
218
+ base_event["qqbot_channel_name"] = raw_event.get("name", "")
219
+ elif raw_type in ("GUILD_CREATE", "GUILD_UPDATE", "GUILD_DELETE"):
220
+ base_event["group_id"] = raw_event.get("id", "")
221
+ base_event["qqbot_guild_name"] = raw_event.get("name", "")
222
+ elif raw_type == "qqbot_message_delete":
223
+ base_event["message_id"] = raw_event.get("id", "")
224
+ base_event["operator_id"] = raw_event.get("op_user_id", "")
225
+
226
+ return base_event
227
+
228
+ def _create_unknown_event(self, raw_event: Dict, event_id: str, raw_type: str) -> Dict:
229
+ return {
230
+ "id": str(event_id),
231
+ "time": int(time.time()),
232
+ "type": "unknown",
233
+ "detail_type": "unknown",
234
+ "platform": "qqbot",
235
+ "self": {"platform": "qqbot", "user_id": self._bot_id_getter() if self._bot_id_getter else ""},
236
+ "qqbot_raw": raw_event,
237
+ "qqbot_raw_type": raw_type,
238
+ "warning": f"Unsupported event type: {raw_type}",
239
+ "alt_message": "This event type is not supported by this system.",
240
+ }
241
+
242
+ def _parse_timestamp(self, timestamp) -> int:
243
+ if isinstance(timestamp, (int, float)):
244
+ ts = int(timestamp)
245
+ return ts // 1000 if ts > 10**12 else ts
246
+ if isinstance(timestamp, str):
247
+ try:
248
+ ts = int(timestamp)
249
+ return ts // 1000 if ts > 10**12 else ts
250
+ except ValueError:
251
+ pass
252
+ try:
253
+ dt = datetime.fromisoformat(timestamp)
254
+ return int(dt.timestamp())
255
+ except (ValueError, TypeError):
256
+ pass
257
+ return int(time.time())
258
+
259
+ def _generate_alt_message(self, segments: List[Dict]) -> str:
260
+ parts = []
261
+ for seg in segments:
262
+ seg_type = seg["type"]
263
+ data = seg.get("data", {})
264
+ if seg_type == "text":
265
+ parts.append(data.get("text", ""))
266
+ elif seg_type == "image":
267
+ parts.append("[图片]")
268
+ elif seg_type == "video":
269
+ parts.append("[视频]")
270
+ elif seg_type == "voice":
271
+ parts.append("[语音]")
272
+ elif seg_type == "file":
273
+ parts.append("[文件]")
274
+ elif seg_type in ("at", "mention"):
275
+ parts.append(f"@{data.get('user_name', data.get('user_id', ''))}")
276
+ elif seg_type == "reply":
277
+ parts.append("[回复]")
278
+ return " ".join(parts).strip()
QQBotAdapter/Core.py ADDED
@@ -0,0 +1,413 @@
1
+ import asyncio
2
+ import aiohttp
3
+ import json
4
+ import uuid
5
+ from typing import Dict, List, Any, Optional
6
+ from ErisPulse import sdk
7
+ from ErisPulse.Core import router
8
+ from .Converter import QQBotConverter
9
+ from .WebSocket import QQBotWebSocket
10
+
11
+
12
+ class QQBotAdapter(sdk.BaseAdapter):
13
+ class Send(sdk.BaseAdapter.Send):
14
+ def __init__(self, adapter, target_type=None, target_id=None, account_id=None):
15
+ super().__init__(adapter, target_type, target_id, account_id)
16
+ self._at_user_ids = []
17
+ self._at_all = False
18
+ self._reply_message_id = None
19
+ self._keyboard = None
20
+
21
+ def Text(self, text: str):
22
+ return self.Raw_ob12([{"type": "text", "data": {"text": text}}])
23
+
24
+ def Image(self, file: bytes | str):
25
+ return self.Raw_ob12([{"type": "image", "data": {"file": file}}])
26
+
27
+ def Markdown(self, content: str):
28
+ return self.Raw_ob12([{"type": "markdown", "data": {"content": content}}])
29
+
30
+ def Ark(self, template_id: int, kv: list):
31
+ return self.Raw_ob12([{"type": "ark", "data": {"template_id": template_id, "kv": kv}}])
32
+
33
+ def Embed(self, embed_data: dict):
34
+ return self.Raw_ob12([{"type": "embed", "data": embed_data}])
35
+
36
+ def Keyboard(self, keyboard: dict):
37
+ self._keyboard = keyboard
38
+ return self
39
+
40
+ def Reply(self, message_id: str) -> "Send":
41
+ self._reply_message_id = message_id
42
+ return self
43
+
44
+ def At(self, user_id: str) -> "Send":
45
+ self._at_user_ids.append(user_id)
46
+ return self
47
+
48
+ def AtAll(self) -> "Send":
49
+ self._at_all = True
50
+ return self
51
+
52
+ def _reset_modifiers(self):
53
+ self._at_user_ids = []
54
+ self._at_all = False
55
+ self._reply_message_id = None
56
+ self._keyboard = None
57
+
58
+ def Raw_ob12(self, message: List[Dict], **kwargs):
59
+ return asyncio.create_task(self._do_send_raw_ob12(message, **kwargs))
60
+
61
+ async def _do_send_raw_ob12(self, message: List[Dict], **kwargs):
62
+ text_parts = []
63
+ media_info = None
64
+ msg_type = 0
65
+ markdown_data = None
66
+ ark_data = None
67
+ embed_data = None
68
+
69
+ for segment in message:
70
+ seg_type = segment.get("type")
71
+ data = segment.get("data", {})
72
+
73
+ if seg_type == "text":
74
+ text_parts.append(data.get("text", ""))
75
+ elif seg_type == "image":
76
+ file = data.get("file", data.get("url", ""))
77
+ if isinstance(file, bytes):
78
+ media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 1)
79
+ elif file:
80
+ media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 1)
81
+ if media_info:
82
+ msg_type = 7
83
+ elif seg_type == "voice":
84
+ file = data.get("file", data.get("url", ""))
85
+ if isinstance(file, (bytes, str)) and file:
86
+ media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 3)
87
+ if media_info:
88
+ msg_type = 7
89
+ elif seg_type == "video":
90
+ file = data.get("file", data.get("url", ""))
91
+ if isinstance(file, (bytes, str)) and file:
92
+ media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 2)
93
+ if media_info:
94
+ msg_type = 7
95
+ elif seg_type == "file":
96
+ file = data.get("file", data.get("url", ""))
97
+ if isinstance(file, (bytes, str)) and file:
98
+ media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 4)
99
+ if media_info:
100
+ msg_type = 7
101
+ elif seg_type == "markdown":
102
+ msg_type = 2
103
+ markdown_data = data
104
+ elif seg_type == "ark":
105
+ msg_type = 3
106
+ ark_data = data
107
+ elif seg_type == "embed":
108
+ msg_type = 4
109
+ embed_data = data
110
+
111
+ for uid in self._at_user_ids:
112
+ text_parts.append(f"<@{uid}>")
113
+ if self._at_all:
114
+ text_parts.insert(0, "@所有人")
115
+
116
+ content = "".join(text_parts) or " "
117
+ msg_id = self._reply_message_id or kwargs.get("msg_id", "")
118
+ if not msg_id and self._target_type and self._target_id:
119
+ msg_id = self._adapter._pending_msg_ids.get(f"{self._target_type}:{self._target_id}", "")
120
+ params = {"msg_type": msg_type, "content": content if msg_type in (0, 7) else "", "msg_id": msg_id}
121
+
122
+ if msg_type == 2 and markdown_data:
123
+ params["markdown"] = {"content": markdown_data.get("content", "")}
124
+ elif msg_type == 3 and ark_data:
125
+ params["ark"] = {"template_id": ark_data.get("template_id", 0), "kv": ark_data.get("kv", [])}
126
+ elif msg_type == 4 and embed_data:
127
+ params["embed"] = embed_data
128
+ elif msg_type == 7 and media_info:
129
+ params["media"] = {"file_info": media_info}
130
+
131
+ if self._keyboard:
132
+ params["keyboard"] = self._keyboard
133
+
134
+ endpoint = self._get_send_endpoint()
135
+ if not endpoint:
136
+ self._reset_modifiers()
137
+ return {
138
+ "status": "failed",
139
+ "retcode": 10003,
140
+ "data": None,
141
+ "message_id": "",
142
+ "message": "无法确定发送目标",
143
+ "qqbot_raw": None,
144
+ }
145
+
146
+ self._reset_modifiers()
147
+ return await self._adapter.call_api(endpoint=endpoint, **params)
148
+
149
+ def _get_send_endpoint(self) -> Optional[str]:
150
+ if self._target_type == "user":
151
+ return f"/v2/users/{self._target_id}/messages"
152
+ elif self._target_type == "group":
153
+ return f"/v2/groups/{self._target_id}/messages"
154
+ elif self._target_type == "channel":
155
+ return f"/channels/{self._target_id}/messages"
156
+ elif self._target_type == "dms":
157
+ return f"/dms/{self._target_id}/messages"
158
+ return None
159
+
160
+ def __init__(self, sdk):
161
+ super().__init__()
162
+ self.sdk = sdk
163
+ self.logger = sdk.logger
164
+ self.config = self._load_config()
165
+ self.appid = self.config.get("appid", "")
166
+ self.secret = self.config.get("secret", "")
167
+ self._access_token = None
168
+ self._token_expires = 0
169
+ self.bot_id = ""
170
+ self.session: Optional[aiohttp.ClientSession] = None
171
+ self.ws_client: Optional[QQBotWebSocket] = None
172
+ self._heartbeat_meta_task: Optional[asyncio.Task] = None
173
+ self._pending_msg_ids: Dict[str, str] = {}
174
+
175
+ converter = QQBotConverter(bot_id_getter=lambda: self.bot_id)
176
+ self.convert = converter.convert
177
+
178
+ def _load_config(self):
179
+ config = self.sdk.config.getConfig("QQBot_Adapter")
180
+ if not config:
181
+ default_config = {
182
+ "appid": "YOUR_APPID",
183
+ "secret": "YOUR_CLIENT_SECRET",
184
+ "sandbox": False,
185
+ "intents": [1, 30, 25],
186
+ }
187
+ try:
188
+ self.sdk.config.setConfig("QQBot_Adapter", default_config)
189
+ self.logger.warning("QQBot适配器配置不存在,已自动创建默认配置")
190
+ except Exception as e:
191
+ self.logger.error(f"保存默认配置失败: {e}")
192
+ return default_config
193
+ return config
194
+
195
+ def _get_base_url(self) -> str:
196
+ if self.config.get("sandbox", False):
197
+ return "https://sandbox.api.sgroup.qq.com"
198
+ return "https://api.sgroup.qq.com"
199
+
200
+ def _get_intents_value(self) -> int:
201
+ intents = self.config.get("intents", [1, 30, 25])
202
+ value = 0
203
+ for intent in intents:
204
+ value |= (1 << intent)
205
+ return value
206
+
207
+ async def _ensure_token(self) -> str:
208
+ import time as _time
209
+ if self._access_token and _time.time() < self._token_expires - 120:
210
+ return self._access_token
211
+ await self._refresh_token()
212
+ return self._access_token
213
+
214
+ async def _refresh_token(self):
215
+ import time as _time
216
+ url = "https://bots.qq.com/app/getAppAccessToken"
217
+ payload = {"appId": self.appid, "clientSecret": self.secret}
218
+ try:
219
+ async with self.session.post(url, json=payload) as resp:
220
+ data = await resp.json()
221
+ self._access_token = data.get("access_token", "")
222
+ expires_in = int(data.get("expires_in", 7200))
223
+ self._token_expires = _time.time() + expires_in
224
+ self.logger.debug("Access Token 已获取/刷新")
225
+ except Exception as e:
226
+ self.logger.error(f"获取 Access Token 失败: {e}")
227
+ raise
228
+
229
+ async def _upload_media(self, file, target_type: str, target_id: str, file_type: int) -> Optional[str]:
230
+ url = f"{self._get_base_url()}"
231
+ if target_type == "user":
232
+ url += f"/v2/users/{target_id}/files"
233
+ elif target_type == "group":
234
+ url += f"/v2/groups/{target_id}/files"
235
+ else:
236
+ return None
237
+
238
+ token = await self._ensure_token()
239
+ headers = {"Authorization": f"QQBot {token}", "Content-Type": "application/json"}
240
+
241
+ payload = {"file_type": file_type, "srv_send_msg": False}
242
+
243
+ if isinstance(file, str) and file.startswith(("http://", "https://")):
244
+ payload["url"] = file
245
+ elif isinstance(file, bytes):
246
+ import base64
247
+ payload["file_data"] = base64.b64encode(file).decode("utf-8")
248
+ elif isinstance(file, str):
249
+ try:
250
+ import os
251
+ if os.path.isfile(file):
252
+ with open(file, "rb") as f:
253
+ file_bytes = f.read()
254
+ import base64
255
+ payload["file_data"] = base64.b64encode(file_bytes).decode("utf-8")
256
+ else:
257
+ self.logger.error(f"文件不存在: {file}")
258
+ return None
259
+ except Exception as e:
260
+ self.logger.error(f"读取本地文件失败: {e}")
261
+ return None
262
+
263
+ try:
264
+ async with self.session.post(url, json=payload, headers=headers) as resp:
265
+ data = await resp.json()
266
+ self.logger.debug(f"上传媒体响应: {data}")
267
+ file_info = data.get("file_info", "")
268
+ if not file_info:
269
+ self.logger.error(f"上传媒体失败: {data}")
270
+ return None
271
+ return file_info
272
+ except Exception as e:
273
+ self.logger.error(f"上传媒体异常: {e}")
274
+ return None
275
+
276
+ async def call_api(self, endpoint: str, **params):
277
+ token = await self._ensure_token()
278
+ url = f"{self._get_base_url()}{endpoint}"
279
+ headers = {"Authorization": f"QQBot {token}", "Content-Type": "application/json"}
280
+
281
+ try:
282
+ async with self.session.post(url, json=params, headers=headers) as resp:
283
+ raw_response = await resp.json()
284
+
285
+ self.logger.debug(f"QQBot API 请求: {url}")
286
+ self.logger.debug(f"QQBot API 响应: {raw_response}")
287
+
288
+ if not isinstance(raw_response, dict):
289
+ return {
290
+ "status": "failed",
291
+ "retcode": 34000,
292
+ "data": None,
293
+ "message_id": "",
294
+ "message": f"API 返回了意外格式: {type(raw_response)}",
295
+ "qqbot_raw": raw_response,
296
+ }
297
+
298
+ code = raw_response.get("code", 0)
299
+ success = 200 <= resp.status < 300 and code == 0
300
+ msg_id = raw_response.get("id", raw_response.get("data", {}).get("id", ""))
301
+
302
+ return {
303
+ "status": "ok" if success else "failed",
304
+ "retcode": 0 if success else code or 34000,
305
+ "data": raw_response.get("data", raw_response),
306
+ "message_id": str(msg_id) if msg_id else "",
307
+ "message": "" if success else raw_response.get("message", "Unknown QQBot API error"),
308
+ "qqbot_raw": raw_response,
309
+ }
310
+
311
+ except asyncio.TimeoutError:
312
+ self.logger.error(f"QQBot API 请求超时: {endpoint}")
313
+ return {
314
+ "status": "failed",
315
+ "retcode": 32000,
316
+ "data": None,
317
+ "message_id": "",
318
+ "message": "请求超时",
319
+ "qqbot_raw": None,
320
+ }
321
+ except Exception as e:
322
+ self.logger.error(f"调用 QQBot API 失败: {e}")
323
+ return {
324
+ "status": "failed",
325
+ "retcode": 33000,
326
+ "data": None,
327
+ "message_id": "",
328
+ "message": f"API调用失败: {str(e)}",
329
+ "qqbot_raw": None,
330
+ }
331
+
332
+ def _store_event_msg_id(self, event: Dict):
333
+ if event.get("type") != "message":
334
+ return
335
+ detail_type = event.get("detail_type", "")
336
+ msg_id = event.get("message_id", "")
337
+ if not msg_id:
338
+ return
339
+ if detail_type == "group" and "group_id" in event:
340
+ self._pending_msg_ids[f"group:{event['group_id']}"] = msg_id
341
+ elif detail_type == "private":
342
+ user_id = event.get("user_id", "")
343
+ if user_id:
344
+ self._pending_msg_ids[f"user:{user_id}"] = msg_id
345
+ elif detail_type == "channel":
346
+ channel_id = event.get("channel_id", "")
347
+ if channel_id:
348
+ self._pending_msg_ids[f"channel:{channel_id}"] = msg_id
349
+
350
+ async def _on_connect(self):
351
+ await self.sdk.adapter.emit({
352
+ "type": "meta",
353
+ "detail_type": "connect",
354
+ "platform": "qqbot",
355
+ "self": {"platform": "qqbot", "user_id": self.bot_id},
356
+ })
357
+
358
+ self._heartbeat_meta_task = asyncio.create_task(self._heartbeat_meta_loop())
359
+
360
+ async def _heartbeat_meta_loop(self):
361
+ try:
362
+ while True:
363
+ await asyncio.sleep(30)
364
+ await self.sdk.adapter.emit({
365
+ "type": "meta",
366
+ "detail_type": "heartbeat",
367
+ "platform": "qqbot",
368
+ "self": {"platform": "qqbot", "user_id": self.bot_id},
369
+ })
370
+ except asyncio.CancelledError:
371
+ pass
372
+
373
+ async def start(self):
374
+ self.session = aiohttp.ClientSession()
375
+
376
+ try:
377
+ await self._ensure_token()
378
+ except Exception as e:
379
+ self.logger.error(f"初始化 Token 失败: {e}")
380
+ if self.session:
381
+ await self.session.close()
382
+ raise
383
+
384
+ self.ws_client = QQBotWebSocket(self)
385
+ await self.ws_client.connect()
386
+ await self.ws_client.start_token_refresh()
387
+ self.logger.info("QQBot 适配器已启动")
388
+
389
+ async def shutdown(self):
390
+ if self.bot_id:
391
+ await self.sdk.adapter.emit({
392
+ "type": "meta",
393
+ "detail_type": "disconnect",
394
+ "platform": "qqbot",
395
+ "self": {"platform": "qqbot", "user_id": self.bot_id},
396
+ })
397
+
398
+ if self._heartbeat_meta_task:
399
+ self._heartbeat_meta_task.cancel()
400
+ try:
401
+ await self._heartbeat_meta_task
402
+ except asyncio.CancelledError:
403
+ pass
404
+ self._heartbeat_meta_task = None
405
+
406
+ if self.ws_client:
407
+ await self.ws_client.close()
408
+ self.ws_client = None
409
+
410
+ if self.session:
411
+ await self.session.close()
412
+ self.session = None
413
+ self.logger.info("QQBot 适配器已关闭")
@@ -0,0 +1,245 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ import aiohttp
5
+ from typing import Optional
6
+
7
+
8
+ class QQBotWebSocket:
9
+ OP_HELLO = 10
10
+ OP_IDENTIFY = 2
11
+ OP_HEARTBEAT = 1
12
+ OP_HEARTBEAT_ACK = 11
13
+ OP_DISPATCH = 0
14
+ OP_RESUME = 6
15
+ OP_RECONNECT = 7
16
+ OP_INVALID_SESSION = 9
17
+
18
+ def __init__(self, adapter):
19
+ self.adapter = adapter
20
+ self.session: Optional[aiohttp.ClientSession] = None
21
+ self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
22
+ self.heartbeat_interval = 45000
23
+ self.heartbeat_task: Optional[asyncio.Task] = None
24
+ self.listen_task: Optional[asyncio.Task] = None
25
+ self.seq: Optional[int] = None
26
+ self.session_id: Optional[str] = None
27
+ self._connected = False
28
+ self._token_refresh_task: Optional[asyncio.Task] = None
29
+ self._reconnect_count = 0
30
+ self._max_reconnect = 50
31
+
32
+ async def connect(self):
33
+ self.session = aiohttp.ClientSession()
34
+ gateway_url = self.adapter.config.get("gateway_url", "wss://api.sgroup.qq.com/websocket/")
35
+ await self._connect(gateway_url)
36
+
37
+ async def _connect(self, url: str):
38
+ try:
39
+ self.ws = await self.session.ws_connect(url)
40
+ self.adapter.logger.info("WebSocket 已连接到 QQBot 网关")
41
+ self._connected = True
42
+
43
+ msg = await self.ws.receive_json()
44
+ if msg.get("op") == self.OP_HELLO:
45
+ data = msg.get("d", {})
46
+ self.heartbeat_interval = data.get("heartbeat_interval", 45000)
47
+
48
+ if self.session_id and self.seq is not None:
49
+ await self._resume()
50
+ else:
51
+ await self._identify()
52
+
53
+ self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
54
+ self.listen_task = asyncio.create_task(self._listen())
55
+
56
+ self._reconnect_count = 0
57
+ else:
58
+ self.adapter.logger.error(f"未收到 Hello 消息: {msg}")
59
+
60
+ except Exception as e:
61
+ self.adapter.logger.error(f"WebSocket 连接失败: {e}")
62
+ await self._reconnect()
63
+
64
+ async def _identify(self):
65
+ token = await self.adapter._ensure_token()
66
+ payload = {
67
+ "op": self.OP_IDENTIFY,
68
+ "d": {
69
+ "token": f"QQBot {token}",
70
+ "intents": self.adapter._get_intents_value(),
71
+ "shard": [0, 1],
72
+ },
73
+ }
74
+ await self.ws.send_json(payload)
75
+ self.adapter.logger.info("已发送 Identify")
76
+
77
+ async def _resume(self):
78
+ token = await self.adapter._ensure_token()
79
+ payload = {
80
+ "op": self.OP_RESUME,
81
+ "d": {
82
+ "token": f"QQBot {token}",
83
+ "session_id": self.session_id,
84
+ "seq": self.seq,
85
+ },
86
+ }
87
+ await self.ws.send_json(payload)
88
+ self.adapter.logger.info("已发送 Resume")
89
+
90
+ async def _heartbeat_loop(self):
91
+ try:
92
+ while self._connected and self.ws and not self.ws.closed:
93
+ heartbeat_payload = {
94
+ "op": self.OP_HEARTBEAT,
95
+ "d": self.seq,
96
+ }
97
+ try:
98
+ await self.ws.send_json(heartbeat_payload)
99
+ except Exception as e:
100
+ self.adapter.logger.error(f"发送心跳失败: {e}")
101
+ break
102
+ await asyncio.sleep(self.heartbeat_interval / 1000)
103
+ except asyncio.CancelledError:
104
+ pass
105
+ except Exception as e:
106
+ self.adapter.logger.error(f"心跳循环异常: {e}")
107
+ await self._reconnect()
108
+
109
+ async def _listen(self):
110
+ try:
111
+ async for msg in self.ws:
112
+ if msg.type == aiohttp.WSMsgType.TEXT:
113
+ data = json.loads(msg.data)
114
+ await self._handle_message(data)
115
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
116
+ self.adapter.logger.warning(f"WebSocket 关闭: {msg.type}")
117
+ break
118
+ except asyncio.CancelledError:
119
+ pass
120
+ except Exception as e:
121
+ self.adapter.logger.error(f"监听异常: {e}")
122
+ finally:
123
+ self._connected = False
124
+ if not self.listen_task or not self.listen_task.cancelled():
125
+ await self._reconnect()
126
+
127
+ async def _handle_message(self, data: dict):
128
+ op = data.get("op")
129
+ t = data.get("t")
130
+ d = data.get("d")
131
+ s = data.get("s")
132
+
133
+ if s is not None:
134
+ self.seq = s
135
+
136
+ if op == self.OP_DISPATCH:
137
+ if t == "READY":
138
+ self.session_id = d.get("session_id") if d else None
139
+ user = d.get("user", {}) if d else {}
140
+ self.adapter.bot_id = str(user.get("id", ""))
141
+ self.adapter.logger.info(f"QQBot 就绪, bot_id: {self.adapter.bot_id}")
142
+ await self.adapter._on_connect()
143
+ elif t == "RESUMED":
144
+ self.adapter.logger.info("QQBot 会话已恢复")
145
+ else:
146
+ if d:
147
+ onebot_event = self.adapter.convert(d, t or "")
148
+ if onebot_event:
149
+ self.adapter._store_event_msg_id(onebot_event)
150
+ from ErisPulse.Core import logger
151
+ logger.debug(f"收到事件: {onebot_event}")
152
+ await self.adapter.sdk.adapter.emit(onebot_event)
153
+
154
+ elif op == self.OP_HEARTBEAT_ACK:
155
+ pass
156
+
157
+ elif op == self.OP_RECONNECT:
158
+ self.adapter.logger.warning("收到 Reconnect 指令,重新连接")
159
+ await self._reconnect()
160
+
161
+ elif op == self.OP_INVALID_SESSION:
162
+ self.adapter.logger.warning("会话无效,重新 Identify")
163
+ self.session_id = None
164
+ self.seq = None
165
+ await self._reconnect()
166
+
167
+ elif op == self.OP_HELLO:
168
+ data_inner = d if d else {}
169
+ self.heartbeat_interval = data_inner.get("heartbeat_interval", 45000)
170
+
171
+ async def _reconnect(self):
172
+ if self.heartbeat_task:
173
+ self.heartbeat_task.cancel()
174
+ try:
175
+ await self.heartbeat_task
176
+ except asyncio.CancelledError:
177
+ pass
178
+ self.heartbeat_task = None
179
+
180
+ self._reconnect_count += 1
181
+ if self._reconnect_count > self._max_reconnect:
182
+ self.adapter.logger.error("已达到最大重连次数,停止重连")
183
+ return
184
+
185
+ wait_time = min(5 * (2 ** min(self._reconnect_count, 6)), 300)
186
+ self.adapter.logger.warning(f"{wait_time}秒后尝试第 {self._reconnect_count} 次重连")
187
+ await asyncio.sleep(wait_time)
188
+
189
+ try:
190
+ if self.ws and not self.ws.closed:
191
+ await self.ws.close()
192
+ except Exception:
193
+ pass
194
+
195
+ gateway_url = self.adapter.config.get("gateway_url", "wss://api.sgroup.qq.com/websocket/")
196
+ await self._connect(gateway_url)
197
+
198
+ async def start_token_refresh(self):
199
+ self._token_refresh_task = asyncio.create_task(self._token_refresh_loop())
200
+
201
+ async def _token_refresh_loop(self):
202
+ try:
203
+ while True:
204
+ await asyncio.sleep(7200 - 120)
205
+ await self.adapter._refresh_token()
206
+ self.adapter.logger.info("Access Token 已刷新")
207
+ except asyncio.CancelledError:
208
+ pass
209
+ except Exception as e:
210
+ self.adapter.logger.error(f"Token 刷新异常: {e}")
211
+
212
+ async def close(self):
213
+ self._connected = False
214
+
215
+ if self._token_refresh_task:
216
+ self._token_refresh_task.cancel()
217
+ try:
218
+ await self._token_refresh_task
219
+ except asyncio.CancelledError:
220
+ pass
221
+ self._token_refresh_task = None
222
+
223
+ if self.heartbeat_task:
224
+ self.heartbeat_task.cancel()
225
+ try:
226
+ await self.heartbeat_task
227
+ except asyncio.CancelledError:
228
+ pass
229
+ self.heartbeat_task = None
230
+
231
+ if self.listen_task:
232
+ self.listen_task.cancel()
233
+ try:
234
+ await self.listen_task
235
+ except asyncio.CancelledError:
236
+ pass
237
+ self.listen_task = None
238
+
239
+ if self.ws and not self.ws.closed:
240
+ await self.ws.close()
241
+ if self.session:
242
+ await self.session.close()
243
+ self.session = None
244
+
245
+ self.adapter.logger.info("WebSocket 连接已关闭")
@@ -0,0 +1 @@
1
+ from .Core import QQBotAdapter
@@ -0,0 +1,259 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-QQBotAdapter
3
+ Version: 1.0.0
4
+ Summary: ErisPulse的QQ官方机器人适配模块
5
+ Author-email: wsu2059 <wsu2059@qq.com>
6
+ Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-QQBotAdapter
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: aiohttp>=3.8.0
10
+
11
+ # QQBotAdapter 模块文档
12
+
13
+ ## 简介
14
+ QQBotAdapter 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构的QQ官方机器人协议适配器,通过WebSocket长连接接收事件,整合了群聊、私聊、频道等多种场景的功能模块,提供统一的事件处理和消息操作接口。
15
+
16
+ ## 使用示例
17
+
18
+ ### 平台原生事件映射关系
19
+ | 官方事件命名 | Adapter事件命名 |
20
+ |--------------|----------------|
21
+ | C2C_MESSAGE_CREATE | private_message |
22
+ | GROUP_AT_MESSAGE_CREATE | group_message |
23
+ | AT_MESSAGE_CREATE | channel_message |
24
+ | MESSAGE_CREATE | channel_message |
25
+ | DIRECT_MESSAGE_CREATE | direct_message |
26
+ | FRIEND_ADD | friend_add |
27
+ | FRIEND_DEL | friend_del |
28
+ | GROUP_ADD_ROBOT | group_add |
29
+ | GROUP_DEL_ROBOT | group_del |
30
+ | GROUP_MSG_REJECT | group_block |
31
+ | GROUP_MSG_RECEIVE | group_allow |
32
+ | C2C_MSG_REJECT | private_block |
33
+ | C2C_MSG_RECEIVE | private_allow |
34
+ | GUILD_MEMBER_ADD | guild_member_add |
35
+ | GUILD_MEMBER_UPDATE | guild_member_update |
36
+ | GUILD_MEMBER_REMOVE | guild_member_remove |
37
+ | INTERACTION_CREATE | interaction |
38
+ | MESSAGE_AUDIT_PASS | audit_pass |
39
+ | MESSAGE_AUDIT_REJECT | audit_reject |
40
+
41
+ 这仅仅在 `sdk.adapter.qqbot.on()` 的时候生效,你完全可以使用标准OneBot12事件(`sdk.adapter.on`)来获取信息。
42
+
43
+ ### OneBot12标准事件类型
44
+
45
+ QQBot适配器完全兼容 OneBot12 标准事件格式,并提供了一些扩展字段:
46
+
47
+ | 事件类型 | detail_type | 说明 |
48
+ |----------|-------------|------|
49
+ | 消息事件(私聊) | private | 用户发送的私聊消息 |
50
+ | 消息事件(群聊) | group | 群内用户@机器人的消息 |
51
+ | 消息事件(频道) | channel | 频道内消息 |
52
+ | 好友增加 | friend_add | 用户添加机器人为好友 |
53
+ | 好友删除 | friend_del | 用户删除机器人好友 |
54
+ | 群增加 | group_increase | 群添加机器人 |
55
+ | 群减少 | group_decrease | 群移除机器人 |
56
+ | 群消息屏蔽 | group_block | 群拒绝机器人消息 |
57
+ | 群消息允许 | group_allow | 群允许机器人消息 |
58
+ | 私聊屏蔽 | private_block | 用户拒绝机器人私聊 |
59
+ | 私聊允许 | private_allow | 用户允许机器人私聊 |
60
+ | 群成员增加 | group_member_increase | 频道成员加入 |
61
+ | 群成员更新 | group_member_update | 频道成员更新 |
62
+ | 群成员减少 | group_member_decrease | 频道成员退出 |
63
+ | 频道服务器创建 | guild_create | 频道服务器创建 |
64
+ | 频道服务器更新 | guild_update | 频道服务器更新 |
65
+ | 频道服务器删除 | guild_delete | 频道服务器删除 |
66
+ | 子频道创建 | channel_create | 子频道创建 |
67
+ | 子频道更新 | channel_update | 子频道更新 |
68
+ | 子频道删除 | channel_delete | 子频道删除 |
69
+ | QQBot交互事件 | qqbot_interaction | 按钮点击等交互 |
70
+ | QQBot审核通过 | qqbot_audit_pass | 消息审核通过 |
71
+ | QQBot审核拒绝 | qqbot_audit_reject | 消息审核拒绝 |
72
+ | QQBot表情回应添加 | qqbot_reaction_add | 消息表情回应添加 |
73
+ | QQBot表情回应移除 | qqbot_reaction_remove | 消息表情回应移除 |
74
+ | QQBot音频开始 | qqbot_audio_start | 音频开始播放 |
75
+ | QQBot音频结束 | qqbot_audio_finish | 音频播放结束 |
76
+ | QQBot消息删除 | qqbot_message_delete | 消息被删除 |
77
+
78
+ ---
79
+
80
+ ## 消息发送示例
81
+
82
+ ```python
83
+ from ErisPulse import sdk
84
+ qqbot = sdk.adapter.get("qqbot")
85
+
86
+ # 发送文本消息
87
+ await qqbot.Send.To("user", user_openid).Text("Hello World!")
88
+
89
+ # 发送带@的消息
90
+ await qqbot.Send.To("group", group_openid).At("member_openid").Text("@你")
91
+
92
+ # 发送带@所有人的消息
93
+ await qqbot.Send.To("group", group_openid).AtAll().Text("公告通知")
94
+
95
+ # 发送回复消息
96
+ await qqbot.Send.To("group", group_openid).Reply("msg_id").Text("回复内容")
97
+
98
+ # 发送图片(URL)
99
+ await qqbot.Send.To("group", group_openid).Image("https://example.com/image.png")
100
+
101
+ # 发送图片(二进制数据)
102
+ with open("image.png", "rb") as f:
103
+ image_data = f.read()
104
+ await qqbot.Send.To("user", user_openid).Image(image_data)
105
+
106
+ # 发送 Markdown 格式消息
107
+ await qqbot.Send.To("group", group_openid).Markdown("# 标题\n- 列表项")
108
+
109
+ # 发送 Ark 模板消息
110
+ await qqbot.Send.To("user", user_openid).Ark(template_id=1, kv=[{"key": "title", "value": "标题"}])
111
+
112
+ # 发送 Embed 消息
113
+ await qqbot.Send.To("group", group_openid).Embed({"title": "标题", "content": "内容"})
114
+
115
+ # 发送带键盘的消息
116
+ keyboard = {
117
+ "content": [
118
+ [
119
+ {"label": "确认", "type": 2, "data": "confirm"},
120
+ {"label": "取消", "type": 2, "data": "cancel"}
121
+ ]
122
+ ]
123
+ }
124
+ await qqbot.Send.To("group", group_openid).Keyboard(keyboard).Text("请选择")
125
+
126
+ # 组合使用:回复 + @ + 键盘
127
+ await qqbot.Send.To("group", group_openid).Reply("msg_id").At("member_openid").Keyboard(keyboard).Text("复合消息")
128
+
129
+ # 使用 Raw_ob12 发送 OneBot12 格式消息
130
+ message = [
131
+ {"type": "text", "data": {"text": "第一行"}},
132
+ {"type": "image", "data": {"file": "https://example.com/img.jpg"}},
133
+ {"type": "text", "data": {"text": "第二行"}}
134
+ ]
135
+ await qqbot.Send.To("group", group_openid).Raw_ob12(message)
136
+ ```
137
+
138
+ ---
139
+
140
+ ### 配置说明
141
+
142
+ 首次运行会自动生成默认配置。
143
+
144
+ ```toml
145
+ # config.toml
146
+ [QQBot_Adapter]
147
+ appid = "YOUR_APPID" # QQ机器人应用ID(必填)
148
+ secret = "YOUR_CLIENT_SECRET" # QQ机器人客户端密钥(必填)
149
+ sandbox = false # 是否使用沙盒环境(可选,默认为false)
150
+ intents = [1, 30, 25] # 订阅的事件 intents 位(可选)
151
+ gateway_url = "wss://api.sgroup.qq.com/websocket/" # WebSocket网关地址(可选)
152
+ ```
153
+
154
+ **配置项说明:**
155
+ - `appid`:QQ机器人的应用ID(必填),从QQ开放平台获取
156
+ - `secret`:QQ机器人的客户端密钥(必填),从QQ开放平台获取
157
+ - `sandbox`:是否使用沙盒环境,沙盒环境API地址为 `https://sandbox.api.sgroup.qq.com`
158
+ - `intents`:事件订阅 intents 列表,常用值:
159
+ - `1`:频道相关事件
160
+ - `25`:频道消息事件
161
+ - `30`:群@消息事件
162
+ - `gateway_url`:WebSocket 网关地址,默认为 `wss://api.sgroup.qq.com/websocket/`
163
+
164
+ ---
165
+
166
+ ## QQBot平台特有功能
167
+
168
+ 请参考 [QQBot平台特性文档](platform-features.md) 了解QQBot平台的特有功能,包括openid体系、频道系统、消息审核、交互事件、扩展字段说明等内容。
169
+
170
+ 详细的事件转换对照请参考 [转换对照文档](CoverToOnebot12.md)。
171
+
172
+ ## 事件监听示例
173
+
174
+ ### 使用 Event 模块(推荐)
175
+
176
+ ```python
177
+ from ErisPulse.Core.Event import message, notice
178
+
179
+ @message.on_message()
180
+ async def handle_message(event):
181
+ if event["platform"] == "qqbot":
182
+ detail_type = event["detail_type"]
183
+ if detail_type == "private":
184
+ # 处理私聊消息
185
+ pass
186
+ elif detail_type == "group":
187
+ # 处理群@消息
188
+ pass
189
+ elif detail_type == "channel":
190
+ # 处理频道消息
191
+ pass
192
+
193
+ @notice.on_notice()
194
+ async def handle_notice(event):
195
+ if event["platform"] == "qqbot":
196
+ detail_type = event["detail_type"]
197
+ if detail_type == "qqbot_interaction":
198
+ # 处理交互事件(按钮点击等)
199
+ interaction_id = event.get("qqbot_interaction_id", "")
200
+ elif detail_type == "qqbot_audit_pass":
201
+ # 消息审核通过
202
+ pass
203
+ elif detail_type == "qqbot_audit_reject":
204
+ # 消息审核拒绝
205
+ reason = event.get("qqbot_audit_reject_reason", "")
206
+ ```
207
+
208
+ ### 使用平台原生事件
209
+
210
+ ```python
211
+ qqbot = sdk.adapter.get("qqbot")
212
+
213
+ # 使用平台原始事件名
214
+ @qqbot.on("C2C_MESSAGE_CREATE")
215
+ async def handle_private_message(data):
216
+ pass
217
+
218
+ @qqbot.on("GROUP_AT_MESSAGE_CREATE")
219
+ async def handle_group_message(data):
220
+ pass
221
+
222
+ @qqbot.on("INTERACTION_CREATE")
223
+ async def handle_interaction(data):
224
+ pass
225
+ ```
226
+
227
+ ### 使用 OneBot12 标准事件
228
+
229
+ ```python
230
+ @sdk.adapter.on("message")
231
+ async def handle_message(event):
232
+ if event["platform"] == "qqbot":
233
+ bot_id = event["self"]["user_id"]
234
+ print(f"消息来自Bot: {bot_id}")
235
+
236
+ @sdk.adapter.on("notice")
237
+ async def handle_notice(event):
238
+ if event["platform"] == "qqbot":
239
+ # 处理QQBot通知事件
240
+ pass
241
+ ```
242
+
243
+ ## 注意事项:
244
+
245
+ 1. 确保在调用 `startup()` 前完成所有处理器的注册
246
+ 2. QQBot使用 openid 体系而非QQ号,用户和群的标识均为 openid 字符串
247
+ 3. 群消息仅在用户@机器人时才会收到(`GROUP_AT_MESSAGE_CREATE`)
248
+ 4. 发送的消息可能需要经过审核,通过 `qqbot_audit_pass`/`qqbot_audit_reject` 事件通知结果
249
+ 5. 媒体文件(图片、视频等)会上传后通过 file_info 发送,支持URL、本地路径和二进制数据
250
+ 6. 程序退出时请调用 `shutdown()` 确保资源释放
251
+ 7. access_token 有效期为7200秒,适配器会自动刷新
252
+
253
+ ---
254
+
255
+ ### 参考链接
256
+
257
+ - [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
258
+ - [QQBot 官方文档](https://bot.q.qq.com/wiki/)
259
+ - [模块开发指南](https://www.erisdev.com/#docs/developer-guide/README.md)
@@ -0,0 +1,9 @@
1
+ QQBotAdapter/Converter.py,sha256=2CAqEAs_L0uQQrjXU_iDNQEBMFVJ8OrMYhMSogTzkII,12967
2
+ QQBotAdapter/Core.py,sha256=QySpgLZybK4ofwmiPN4JobGWsTxx7IDnlctziCD1eVg,16365
3
+ QQBotAdapter/WebSocket.py,sha256=QRA4Ufxmvct3oPkaz1FebSbij9ADbeXEJr1WMgRf9uQ,8616
4
+ QQBotAdapter/__init__.py,sha256=MfTRRtt40DCHo8gkZXmd7aEID0rf-aEPv9U_cyeAotc,31
5
+ erispulse_qqbotadapter-1.0.0.dist-info/METADATA,sha256=nGgTtEZbDzQuAlrJSgVbwSunXJYQF4LK2VQ5_rLfLN4,9483
6
+ erispulse_qqbotadapter-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ erispulse_qqbotadapter-1.0.0.dist-info/entry_points.txt,sha256=BxZLlrit_qfC5CQpG8oF3BtwOjyrsL2UZCz4XpKFLFg,85
8
+ erispulse_qqbotadapter-1.0.0.dist-info/top_level.txt,sha256=7HVuc1difQDsRMt0gftUVO2lI5q1Cthpz1yCByikwGk,13
9
+ erispulse_qqbotadapter-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [erispulse.adapter]
2
+ qq = QQBotAdapter:QQBotAdapter
3
+ qqbot = QQBotAdapter:QQBotAdapter
@@ -0,0 +1 @@
1
+ QQBotAdapter