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.
- QQBotAdapter/Converter.py +278 -0
- QQBotAdapter/Core.py +413 -0
- QQBotAdapter/WebSocket.py +245 -0
- QQBotAdapter/__init__.py +1 -0
- erispulse_qqbotadapter-1.0.0.dist-info/METADATA +259 -0
- erispulse_qqbotadapter-1.0.0.dist-info/RECORD +9 -0
- erispulse_qqbotadapter-1.0.0.dist-info/WHEEL +5 -0
- erispulse_qqbotadapter-1.0.0.dist-info/entry_points.txt +3 -0
- erispulse_qqbotadapter-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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 连接已关闭")
|
QQBotAdapter/__init__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
QQBotAdapter
|