@nextclaw/channel-extension-qq 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/main.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/main.js ADDED
@@ -0,0 +1,10 @@
1
+ import { QQChannel } from "./services/qq-channel.service.js";
2
+ import { startBusChannelExtension, warnNcpEventError } from "@nextclaw/extension-sdk";
3
+ //#region src/main.ts
4
+ await startBusChannelExtension({
5
+ channelId: "qq",
6
+ createChannel: ({ config, bus }) => new QQChannel(config, bus),
7
+ onChannelStartError: warnNcpEventError("qq")
8
+ });
9
+ //#endregion
10
+ export {};
@@ -0,0 +1,352 @@
1
+ import { Bot, ReceiverMode, SessionEvents } from "qq-official-bot";
2
+ //#region src/services/qq-channel.service.ts
3
+ var QQChannel = class {
4
+ name = "qq";
5
+ running = false;
6
+ bot = null;
7
+ processedIds = [];
8
+ processedSet = /* @__PURE__ */ new Set();
9
+ senderNameCache = /* @__PURE__ */ new Map();
10
+ reconnectTimer = null;
11
+ connectTask = null;
12
+ reconnectAttempt = 0;
13
+ reconnectBaseMs = 1e3;
14
+ reconnectMaxMs = 6e4;
15
+ connectTimeoutMs = 9e4;
16
+ constructor(config, bus) {
17
+ this.config = config;
18
+ this.bus = bus;
19
+ }
20
+ start = async () => {
21
+ if (!this.config.appId || !this.config.secret) {
22
+ this.running = false;
23
+ throw new Error("QQ appId/appSecret not configured");
24
+ }
25
+ this.running = true;
26
+ this.reconnectAttempt = 0;
27
+ this.clearReconnectTimer();
28
+ this.tryConnect("startup");
29
+ await this.connectTask;
30
+ };
31
+ stop = async () => {
32
+ this.running = false;
33
+ this.clearReconnectTimer();
34
+ this.reconnectAttempt = 0;
35
+ await this.teardownBot();
36
+ if (this.connectTask) await this.connectTask;
37
+ };
38
+ send = async (msg) => {
39
+ if (!this.bot) return;
40
+ const qqMeta = msg.metadata?.qq ?? {};
41
+ const messageType = qqMeta.messageType ?? "private";
42
+ const metadataMessageId = msg.metadata?.message_id ?? null;
43
+ const sourceId = msg.replyTo ?? metadataMessageId ?? void 0;
44
+ const source = sourceId ? { id: sourceId } : void 0;
45
+ const rawContent = msg.content ?? "";
46
+ const payload = rawContent;
47
+ try {
48
+ await this.sendByMessageType({
49
+ messageType,
50
+ qqMeta,
51
+ msg,
52
+ payload,
53
+ source
54
+ });
55
+ } catch (error) {
56
+ if (!this.isDisallowedUrlParamError(error)) throw error;
57
+ const safeText = this.toQqSafeText(rawContent, error);
58
+ await this.sendByMessageType({
59
+ messageType,
60
+ qqMeta,
61
+ msg,
62
+ payload: safeText,
63
+ source
64
+ });
65
+ }
66
+ };
67
+ sendByMessageType = async (params) => {
68
+ const { messageType, qqMeta, msg, payload, source } = params;
69
+ if (messageType === "group") {
70
+ const groupId = qqMeta.groupId ?? msg.chatId;
71
+ await this.sendWithTokenRetry(() => this.bot?.sendGroupMessage(groupId, payload, source));
72
+ return;
73
+ }
74
+ const userId = qqMeta.userId ?? msg.chatId;
75
+ await this.sendWithTokenRetry(() => this.bot?.sendPrivateMessage(userId, payload, source));
76
+ };
77
+ handleIncoming = async (event) => {
78
+ const identity = this.resolveIncomingIdentity(event);
79
+ if (!identity) return;
80
+ const content = event.raw_message?.trim() || "[empty message]";
81
+ const senderName = this.resolveIncomingSenderName(identity.senderId, identity.rawEvent, content);
82
+ const route = this.resolveIncomingRoute(event, identity.rawEvent, identity.senderId, senderName);
83
+ if (!route.chatId || !this.isAllowed(identity.senderId)) return;
84
+ await this.bus.publishInbound({
85
+ channel: this.name,
86
+ senderId: identity.senderId,
87
+ chatId: route.chatId,
88
+ content: this.decorateSpeakerPrefix({
89
+ content,
90
+ messageType: route.messageType,
91
+ senderId: identity.senderId,
92
+ senderName
93
+ }),
94
+ metadata: {
95
+ message_id: identity.messageId,
96
+ qq: route.metadata
97
+ }
98
+ });
99
+ };
100
+ resolveIncomingIdentity = (event) => {
101
+ const messageId = event.message_id || event.id || "";
102
+ if (messageId && this.isDuplicate(messageId)) return null;
103
+ const rawEvent = event;
104
+ if (this.isSelfEvent(event)) return null;
105
+ const senderId = this.resolveSenderId(event, rawEvent);
106
+ return senderId ? {
107
+ messageId,
108
+ rawEvent,
109
+ senderId
110
+ } : null;
111
+ };
112
+ resolveIncomingSenderName = (senderId, rawEvent, content) => {
113
+ const eventSenderName = this.resolveSenderName(rawEvent);
114
+ if (eventSenderName) this.senderNameCache.set(senderId, eventSenderName);
115
+ const declaredName = this.extractDeclaredName(content);
116
+ if (declaredName) this.senderNameCache.set(senderId, declaredName);
117
+ return declaredName ?? eventSenderName ?? this.senderNameCache.get(senderId) ?? null;
118
+ };
119
+ resolveIncomingRoute = (event, rawEvent, senderId, senderName) => {
120
+ let chatId = senderId;
121
+ let messageType = "private";
122
+ const qqMeta = {};
123
+ if (event.message_type === "group") {
124
+ messageType = "group";
125
+ const groupId = event.group_id || rawEvent.group_openid || "";
126
+ chatId = groupId;
127
+ qqMeta.groupId = groupId;
128
+ qqMeta.userId = senderId;
129
+ if (senderName) qqMeta.userName = senderName;
130
+ } else {
131
+ qqMeta.userId = senderId;
132
+ if (senderName) qqMeta.userName = senderName;
133
+ }
134
+ qqMeta.messageType = messageType;
135
+ return {
136
+ chatId,
137
+ messageType,
138
+ metadata: qqMeta
139
+ };
140
+ };
141
+ isAllowed = (senderId) => {
142
+ const allowList = this.config.allowFrom ?? [];
143
+ if (!allowList.length || allowList.includes(senderId)) return true;
144
+ return senderId.includes("|") && senderId.split("|").some((part) => allowList.includes(part));
145
+ };
146
+ isSelfEvent = (event) => {
147
+ const userId = typeof event.user_id === "string" ? event.user_id : "";
148
+ const selfId = typeof event.self_id === "string" ? event.self_id : "";
149
+ return Boolean(userId && selfId && userId === selfId);
150
+ };
151
+ resolveSenderId = (event, rawEvent) => {
152
+ return this.readFirstString([
153
+ event.user_id,
154
+ rawEvent.sender?.member_openid,
155
+ rawEvent.sender?.user_openid,
156
+ rawEvent.sender?.user_id,
157
+ rawEvent.author?.member_openid,
158
+ rawEvent.author?.user_openid,
159
+ rawEvent.author?.id
160
+ ]) ?? "";
161
+ };
162
+ resolveSenderName = (rawEvent) => {
163
+ return this.readFirstString([
164
+ rawEvent.sender?.card,
165
+ rawEvent.sender?.nickname,
166
+ rawEvent.sender?.nick,
167
+ rawEvent.sender?.username,
168
+ rawEvent.sender?.user_name,
169
+ rawEvent.author?.username
170
+ ]);
171
+ };
172
+ readFirstString = (values) => {
173
+ for (const value of values) {
174
+ if (typeof value !== "string") continue;
175
+ const normalized = value.trim();
176
+ if (normalized) return normalized;
177
+ }
178
+ return null;
179
+ };
180
+ decorateSpeakerPrefix = (params) => {
181
+ const { content, senderId, senderName } = params;
182
+ const userId = this.sanitizeSpeakerToken(senderId);
183
+ if (!userId) return content;
184
+ const name = this.sanitizeSpeakerToken(senderName ?? "");
185
+ const speakerFields = [`user_id=${userId}`];
186
+ if (name) speakerFields.push(`name=${name}`);
187
+ return `[speaker:${speakerFields.join(";")}] ${content}`;
188
+ };
189
+ sanitizeSpeakerToken = (value) => {
190
+ return value.replace(/[\r\n;\]]/g, " ").trim();
191
+ };
192
+ extractDeclaredName = (content) => {
193
+ const trimmed = content.trim();
194
+ for (const pattern of [
195
+ /^我的昵称是\s*([^\s,。!?!?,]{1,24})$/u,
196
+ /^我叫\s*([^\s,。!?!?,]{1,24})$/u,
197
+ /^叫我\s*([^\s,。!?!?,]{1,24})$/u
198
+ ]) {
199
+ const match = trimmed.match(pattern);
200
+ if (!match) continue;
201
+ const candidate = this.sanitizeSpeakerToken(match[1] ?? "");
202
+ if (candidate) return candidate;
203
+ }
204
+ return null;
205
+ };
206
+ isDuplicate = (messageId) => {
207
+ if (this.processedSet.has(messageId)) return true;
208
+ this.processedSet.add(messageId);
209
+ this.processedIds.push(messageId);
210
+ if (this.processedIds.length > 1e3) {
211
+ const removed = this.processedIds.splice(0, 500);
212
+ for (const id of removed) this.processedSet.delete(id);
213
+ }
214
+ return false;
215
+ };
216
+ sendWithTokenRetry = async (send) => {
217
+ try {
218
+ await send();
219
+ } catch (error) {
220
+ if (!this.isTokenExpiredError(error) || !this.bot) throw error;
221
+ await this.bot.sessionManager.getAccessToken();
222
+ await send();
223
+ }
224
+ };
225
+ isTokenExpiredError = (error) => {
226
+ const message = error instanceof Error ? error.message : String(error);
227
+ return message.includes("code(11244)") || message.toLowerCase().includes("token not exist or expire");
228
+ };
229
+ isDisallowedUrlParamError = (error) => {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ return message.includes("code(40034028)") || message.includes("请求参数不允许包含url");
232
+ };
233
+ toQqSafeText = (content, error) => {
234
+ let safe = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").replace(/https?:\/\/\S+/gi, "[link]").replace(/www\.\S+/gi, "[link]").replace(/\b[a-z0-9._/-]+\.md\b/gi, "[file]");
235
+ const blocked = this.extractBlockedUrlToken(error);
236
+ if (blocked) safe = safe.replaceAll(blocked, "[link]");
237
+ return safe;
238
+ };
239
+ extractBlockedUrlToken = (error) => {
240
+ const match = (error instanceof Error ? error.message : String(error)).match(/包含url\s+([^\s]+)/);
241
+ if (!match) return null;
242
+ const token = match[1].trim();
243
+ return token.length > 0 ? token : null;
244
+ };
245
+ tryConnect = (trigger) => {
246
+ if (!this.running || this.bot || this.connectTask) return;
247
+ this.connectTask = this.connect(trigger).finally(() => {
248
+ this.connectTask = null;
249
+ });
250
+ };
251
+ connect = async (trigger) => {
252
+ let candidate = null;
253
+ try {
254
+ candidate = this.createBot();
255
+ await this.startBotWithTimeout(candidate);
256
+ if (!this.running) {
257
+ await this.safeStopBot(candidate);
258
+ return;
259
+ }
260
+ this.bot = candidate;
261
+ this.reconnectAttempt = 0;
262
+ console.log("QQ bot connected");
263
+ } catch (error) {
264
+ if (candidate) await this.safeStopBot(candidate);
265
+ if (!this.running) return;
266
+ this.reconnectAttempt += 1;
267
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
268
+ console.error(`[qq] start failed (${trigger}, attempt ${this.reconnectAttempt}), retry in ${delayMs}ms: ${this.formatError(error)}`);
269
+ this.scheduleReconnect(delayMs, `${trigger}-retry`);
270
+ }
271
+ };
272
+ createBot = () => {
273
+ const bot = new Bot({
274
+ appid: this.config.appId,
275
+ secret: this.config.secret,
276
+ mode: ReceiverMode.WEBSOCKET,
277
+ intents: ["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"],
278
+ removeAt: true,
279
+ logLevel: "info"
280
+ });
281
+ bot.on("message.private", async (event) => {
282
+ await this.handleIncoming(event);
283
+ });
284
+ bot.on("message.group", async (event) => {
285
+ await this.handleIncoming(event);
286
+ });
287
+ bot.sessionManager.on(SessionEvents.DEAD, () => {
288
+ this.handleSessionDead(bot);
289
+ });
290
+ return bot;
291
+ };
292
+ handleSessionDead = async (bot) => {
293
+ if (!this.running || this.bot !== bot) return;
294
+ this.bot = null;
295
+ await this.safeStopBot(bot);
296
+ this.reconnectAttempt += 1;
297
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
298
+ console.error(`[qq] session dead, reconnect in ${delayMs}ms`);
299
+ this.scheduleReconnect(delayMs, "session-dead");
300
+ };
301
+ scheduleReconnect = (delayMs, trigger) => {
302
+ if (!this.running) return;
303
+ this.clearReconnectTimer();
304
+ this.reconnectTimer = setTimeout(() => {
305
+ this.reconnectTimer = null;
306
+ this.tryConnect(trigger);
307
+ }, delayMs);
308
+ };
309
+ clearReconnectTimer = () => {
310
+ if (!this.reconnectTimer) return;
311
+ clearTimeout(this.reconnectTimer);
312
+ this.reconnectTimer = null;
313
+ };
314
+ teardownBot = async () => {
315
+ if (!this.bot) return;
316
+ const bot = this.bot;
317
+ this.bot = null;
318
+ await this.safeStopBot(bot);
319
+ };
320
+ safeStopBot = async (bot) => {
321
+ bot.removeAllListeners("message.private");
322
+ bot.removeAllListeners("message.group");
323
+ bot.sessionManager.removeAllListeners(SessionEvents.DEAD);
324
+ try {
325
+ await bot.stop();
326
+ } catch {}
327
+ };
328
+ startBotWithTimeout = async (bot) => {
329
+ let timer = null;
330
+ try {
331
+ await Promise.race([bot.start(), new Promise((_, reject) => {
332
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`QQ bot start timed out after ${this.connectTimeoutMs}ms`)), this.connectTimeoutMs);
333
+ })]);
334
+ } finally {
335
+ if (timer) clearTimeout(timer);
336
+ }
337
+ };
338
+ get isRunning() {
339
+ return this.bot !== null;
340
+ }
341
+ getBackoffDelayMs = (attempt) => {
342
+ const jitter = Math.floor(Math.random() * 500);
343
+ const exp = Math.min(this.reconnectMaxMs, this.reconnectBaseMs * 2 ** Math.max(0, attempt - 1));
344
+ return Math.min(this.reconnectMaxMs, exp + jitter);
345
+ };
346
+ formatError = (error) => {
347
+ if (error instanceof Error) return error.stack ?? error.message;
348
+ return String(error);
349
+ };
350
+ };
351
+ //#endregion
352
+ export { QQChannel };
@@ -0,0 +1,32 @@
1
+ {
2
+ "id": "nextclaw-channel-extension-qq",
3
+ "name": "NextClaw QQ Channel Extension",
4
+ "version": "0.1.0",
5
+ "server": {
6
+ "type": "stdio",
7
+ "command": "node",
8
+ "args": ["dist/main.js"]
9
+ },
10
+ "contributes": {
11
+ "channels": [
12
+ {
13
+ "id": "qq",
14
+ "name": "QQ",
15
+ "description": "QQ official bot channel",
16
+ "configSchema": {
17
+ "type": "object",
18
+ "additionalProperties": true,
19
+ "properties": {
20
+ "enabled": { "type": "boolean" },
21
+ "appId": { "type": "string" },
22
+ "secret": { "type": "string" },
23
+ "allowFrom": {
24
+ "type": "array",
25
+ "items": { "type": "string" }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ]
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@nextclaw/channel-extension-qq",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "NextClaw QQ channel extension process.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "nextclaw.extension.json"
18
+ ],
19
+ "dependencies": {
20
+ "qq-official-bot": "1.0.12",
21
+ "@nextclaw/extension-sdk": "0.1.7"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.17.6",
25
+ "tsx": "^4.19.2",
26
+ "typescript": "^5.6.3"
27
+ },
28
+ "scripts": {
29
+ "build": "tsdown src/index.ts src/main.ts --dts --clean --target es2022 --no-fixedExtension --unbundle",
30
+ "lint": "eslint src --max-warnings=0",
31
+ "test": "node --import tsx --test src/tests/*.test.ts",
32
+ "tsc": "tsc -p tsconfig.json"
33
+ }
34
+ }