dsh-qqbot 1.0.0
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/README.md +98 -0
- package/lib/client.js +556 -0
- package/lib/index.js +811 -0
- package/lib/invariant.js +23 -0
- package/lib/types/client/QqbotSettingsSection.d.ts +12 -0
- package/lib/types/client/QqbotToggle.d.ts +10 -0
- package/lib/types/client/index.d.ts +60 -0
- package/lib/types/client/locales.d.ts +63 -0
- package/lib/types/client/settings-controller.d.ts +75 -0
- package/lib/types/gateway.d.ts +96 -0
- package/lib/types/index.d.ts +64 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/protocol.d.ts +110 -0
- package/lib/types/types.d.ts +62 -0
- package/package.json +100 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
//#region lib/types/protocol.js
|
|
5
|
+
/**
|
|
6
|
+
* Pure QQ official bot protocol helpers: gateway opcodes, event intents,
|
|
7
|
+
* REST endpoints, access-token and gateway URL fetches, inbound dispatch
|
|
8
|
+
* parsing, and outbound text chunking. Everything here is I/O- and timer-free
|
|
9
|
+
* except the two `fetch` wrappers, so the rest is unit-testable directly.
|
|
10
|
+
* @module @deepseek-ai/dsh-qqbot/protocol
|
|
11
|
+
*/
|
|
12
|
+
/** Gateway opcodes from the QQ official bot WebSocket protocol. */
|
|
13
|
+
const QQ_OP = {
|
|
14
|
+
DISPATCH: 0,
|
|
15
|
+
HEARTBEAT: 1,
|
|
16
|
+
IDENTIFY: 2,
|
|
17
|
+
RESUME: 6,
|
|
18
|
+
RECONNECT: 7,
|
|
19
|
+
INVALID_SESSION: 9,
|
|
20
|
+
HELLO: 10,
|
|
21
|
+
HEARTBEAT_ACK: 11
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Intent bit for group @-mention and C2C private-message events. The single
|
|
25
|
+
* `1 << 25` (`GROUP_AND_C2C_EVENT`) intent delivers both, so the bridge
|
|
26
|
+
* subscribes to it and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
const QQ_INTENT_GROUP_AND_C2C = 1 << 25;
|
|
29
|
+
/** Access-token mint endpoint; independent of the sandbox selection. */
|
|
30
|
+
const QQ_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
31
|
+
const API_HOST = "https://api.sgroup.qq.com";
|
|
32
|
+
const SANDBOX_HOST = "https://sandbox.api.sgroup.qq.com";
|
|
33
|
+
/** Default access-token TTL (seconds) when the response omits `expires_in`. */
|
|
34
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 7200;
|
|
35
|
+
/** Default outbound chunk length in UTF-16 code units. */
|
|
36
|
+
const QQ_MAX_MESSAGE_LENGTH = 2e3;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the REST API base for the selected environment.
|
|
39
|
+
* @param sandbox - whether the sandbox host is in use.
|
|
40
|
+
* @returns the `https://api.sgroup.qq.com` or sandbox equivalent origin.
|
|
41
|
+
*/
|
|
42
|
+
function apiBase(sandbox) {
|
|
43
|
+
return sandbox ? SANDBOX_HOST : API_HOST;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* REST path for sending a message to a group.
|
|
47
|
+
* @param groupOpenid - the target group's openid.
|
|
48
|
+
* @returns the `/v2/groups/{groupOpenid}/messages` path.
|
|
49
|
+
*/
|
|
50
|
+
function groupMessagesPath(groupOpenid) {
|
|
51
|
+
return `/v2/groups/${groupOpenid}/messages`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* REST path for sending a private message to a user.
|
|
55
|
+
* @param userOpenid - the target user's openid.
|
|
56
|
+
* @returns the `/v2/users/{userOpenid}/messages` path.
|
|
57
|
+
*/
|
|
58
|
+
function c2cMessagesPath(userOpenid) {
|
|
59
|
+
return `/v2/users/${userOpenid}/messages`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mint an access token from the robot app id and secret.
|
|
63
|
+
* @param creds - the robot credentials.
|
|
64
|
+
* @param fetchImpl - injectable fetch (defaults to the global).
|
|
65
|
+
* @param now - injectable epoch-ms clock (defaults to `Date.now`).
|
|
66
|
+
* @returns the token with its absolute expiry.
|
|
67
|
+
*/
|
|
68
|
+
async function fetchAccessToken(creds, fetchImpl = fetch, now = Date.now) {
|
|
69
|
+
const response = await fetchImpl(QQ_TOKEN_URL, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
appId: creds.appId,
|
|
74
|
+
clientSecret: creds.appSecret
|
|
75
|
+
})
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok) throw new Error(`qqbot: token request failed (HTTP ${response.status})`);
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
if (typeof data.access_token !== "string" || data.access_token.length === 0) throw new Error("qqbot: token response missing access_token");
|
|
80
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : DEFAULT_EXPIRES_IN_SECONDS;
|
|
81
|
+
return {
|
|
82
|
+
accessToken: data.access_token,
|
|
83
|
+
expiresAt: now() + expiresIn * 1e3
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the WebSocket gateway URL for the selected environment.
|
|
88
|
+
* @param accessToken - the current access token.
|
|
89
|
+
* @param sandbox - whether the sandbox host is in use.
|
|
90
|
+
* @param fetchImpl - injectable fetch (defaults to the global).
|
|
91
|
+
* @returns the gateway WebSocket URL.
|
|
92
|
+
*/
|
|
93
|
+
async function fetchGatewayUrl(accessToken, sandbox, fetchImpl = fetch) {
|
|
94
|
+
const response = await fetchImpl(`${apiBase(sandbox)}/gateway`, { headers: { Authorization: `QQBot ${accessToken}` } });
|
|
95
|
+
if (!response.ok) throw new Error(`qqbot: gateway request failed (HTTP ${response.status})`);
|
|
96
|
+
const data = await response.json();
|
|
97
|
+
if (typeof data.url !== "string" || data.url.length === 0) throw new Error("qqbot: gateway response missing url");
|
|
98
|
+
return data.url;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse one gateway text frame into its opcode and the fields that op carries.
|
|
102
|
+
* @param raw - the frame payload (expected to be a JSON string).
|
|
103
|
+
* @returns the parsed message, or `undefined` for non-text or malformed frames.
|
|
104
|
+
*/
|
|
105
|
+
function parseRawGatewayMessage(raw) {
|
|
106
|
+
if (typeof raw !== "string") return void 0;
|
|
107
|
+
let payload;
|
|
108
|
+
try {
|
|
109
|
+
payload = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (payload === null || typeof payload !== "object") return void 0;
|
|
114
|
+
const record = payload;
|
|
115
|
+
const op = record["op"];
|
|
116
|
+
if (typeof op !== "number") return void 0;
|
|
117
|
+
const data = record["d"];
|
|
118
|
+
if (op === QQ_OP.DISPATCH) {
|
|
119
|
+
const type = record["t"];
|
|
120
|
+
const seq = record["s"];
|
|
121
|
+
return {
|
|
122
|
+
op,
|
|
123
|
+
...typeof type === "string" ? { type } : {},
|
|
124
|
+
...typeof seq === "number" ? { seq } : {},
|
|
125
|
+
data
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (op === QQ_OP.HELLO && data !== null && typeof data === "object") {
|
|
129
|
+
const interval = data["heartbeat_interval"];
|
|
130
|
+
return {
|
|
131
|
+
op,
|
|
132
|
+
...typeof interval === "number" ? { heartbeatInterval: interval } : {}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { op };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Narrow a dispatch event to an answerable {@link QqMessage}. Group events
|
|
139
|
+
* require a `group_openid`; C2C events require an `author.user_openid`. Other
|
|
140
|
+
* event types (READY, RESUMED, and every non-message event) return `undefined`.
|
|
141
|
+
* @param type - the dispatch event type (`t`).
|
|
142
|
+
* @param data - the dispatch payload (`d`).
|
|
143
|
+
* @returns the normalized message, or `undefined` when the event is not answerable.
|
|
144
|
+
*/
|
|
145
|
+
function parseMessageEvent(type, data) {
|
|
146
|
+
if (data === null || typeof data !== "object") return void 0;
|
|
147
|
+
const record = data;
|
|
148
|
+
const id = record["id"];
|
|
149
|
+
const content = record["content"];
|
|
150
|
+
const authorRaw = record["author"];
|
|
151
|
+
if (typeof id !== "string" || typeof content !== "string") return void 0;
|
|
152
|
+
const author = authorRaw !== null && typeof authorRaw === "object" ? authorRaw : {};
|
|
153
|
+
const authorBlock = {
|
|
154
|
+
...typeof author["id"] === "string" ? { id: author["id"] } : {},
|
|
155
|
+
...typeof author["user_openid"] === "string" ? { userOpenid: author["user_openid"] } : {},
|
|
156
|
+
...typeof author["member_openid"] === "string" ? { memberOpenid: author["member_openid"] } : {},
|
|
157
|
+
...typeof author["username"] === "string" ? { username: author["username"] } : {}
|
|
158
|
+
};
|
|
159
|
+
if (type === "GROUP_AT_MESSAGE_CREATE") {
|
|
160
|
+
const groupOpenid = record["group_openid"];
|
|
161
|
+
if (typeof groupOpenid !== "string" || groupOpenid.length === 0) return void 0;
|
|
162
|
+
return {
|
|
163
|
+
kind: "group",
|
|
164
|
+
id,
|
|
165
|
+
content,
|
|
166
|
+
author: authorBlock,
|
|
167
|
+
groupOpenid
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (type === "C2C_MESSAGE_CREATE") {
|
|
171
|
+
const userOpenid = author["user_openid"];
|
|
172
|
+
if (typeof userOpenid !== "string" || userOpenid.length === 0) return void 0;
|
|
173
|
+
return {
|
|
174
|
+
kind: "c2c",
|
|
175
|
+
id,
|
|
176
|
+
content,
|
|
177
|
+
author: authorBlock,
|
|
178
|
+
userOpenid
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Strip QQ mention tags from group message text. The platform may deliver
|
|
184
|
+
* `<@!12345>` or `<@OPENID>`; both are removed so only the user's words remain.
|
|
185
|
+
* @param text - the raw group message content.
|
|
186
|
+
* @returns the text with mentions removed and surrounding whitespace trimmed.
|
|
187
|
+
*/
|
|
188
|
+
function stripMentions(text) {
|
|
189
|
+
return text.replace(/<@[^>]{1,64}>/g, "").trim();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Split outbound text into chunks no longer than `max` UTF-16 code units.
|
|
193
|
+
* An empty input yields no chunks (nothing to send).
|
|
194
|
+
* @param text - the reply text.
|
|
195
|
+
* @param max - the per-chunk ceiling.
|
|
196
|
+
* @returns zero or more bounded chunks.
|
|
197
|
+
*/
|
|
198
|
+
function splitMessage(text, max = QQ_MAX_MESSAGE_LENGTH) {
|
|
199
|
+
if (text.length === 0) return [];
|
|
200
|
+
if (text.length <= max) return [text];
|
|
201
|
+
const chunks = [];
|
|
202
|
+
for (let index = 0; index < text.length; index += max) chunks.push(text.slice(index, index + max));
|
|
203
|
+
return chunks;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Build the ordered request bodies for one reply. The first chunk carries the
|
|
207
|
+
* triggering `msg_id` so the platform renders it as a reply; subsequent chunks
|
|
208
|
+
* share it with an increasing `msg_seq`, streaming the multi-chunk reply.
|
|
209
|
+
* @param text - the reply text.
|
|
210
|
+
* @param msgId - the inbound message id being answered, when known.
|
|
211
|
+
* @returns the ordered request bodies, or an empty array for empty text.
|
|
212
|
+
*/
|
|
213
|
+
function buildSendPayloads(text, msgId) {
|
|
214
|
+
return splitMessage(text).map((chunk, index) => {
|
|
215
|
+
const body = {
|
|
216
|
+
content: chunk,
|
|
217
|
+
msg_type: 0
|
|
218
|
+
};
|
|
219
|
+
if (msgId !== void 0 && msgId.length > 0) {
|
|
220
|
+
body["msg_id"] = msgId;
|
|
221
|
+
body["msg_seq"] = index + 1;
|
|
222
|
+
}
|
|
223
|
+
return body;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region lib/types/gateway.js
|
|
228
|
+
/**
|
|
229
|
+
* QQ official bot gateway client: token refresh, WebSocket connection,
|
|
230
|
+
* heartbeat, reconnect, inbound dispatch, and outbound message sending.
|
|
231
|
+
* The socket and fetch implementations are injectable so tests can drive the
|
|
232
|
+
* state machine without a live platform.
|
|
233
|
+
* @module @deepseek-ai/dsh-qqbot/gateway
|
|
234
|
+
*/
|
|
235
|
+
const DEFAULT_TOKEN_MARGIN_MS = 6e4;
|
|
236
|
+
const DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
|
|
237
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 45e3;
|
|
238
|
+
const MIN_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
239
|
+
/**
|
|
240
|
+
* Long-lived connection to the QQ gateway. `start()` connects once; a drop
|
|
241
|
+
* after READY schedules an exponential-backoff reconnect. The client owns the
|
|
242
|
+
* access token and exposes {@link sendText} for reply delivery.
|
|
243
|
+
*/
|
|
244
|
+
var QqGatewayClient = class {
|
|
245
|
+
creds;
|
|
246
|
+
sandbox;
|
|
247
|
+
onMessage;
|
|
248
|
+
logger;
|
|
249
|
+
fetchImpl;
|
|
250
|
+
socketFactory;
|
|
251
|
+
tokenMarginMs;
|
|
252
|
+
maxReconnectDelayMs;
|
|
253
|
+
now;
|
|
254
|
+
accessToken;
|
|
255
|
+
tokenExpiresAt = 0;
|
|
256
|
+
socket;
|
|
257
|
+
heartbeatTimer;
|
|
258
|
+
reconnectTimer;
|
|
259
|
+
seq = 0;
|
|
260
|
+
reconnectAttempts = 0;
|
|
261
|
+
closed = false;
|
|
262
|
+
constructor(options) {
|
|
263
|
+
this.creds = options.creds;
|
|
264
|
+
this.sandbox = options.sandbox;
|
|
265
|
+
this.onMessage = options.onMessage;
|
|
266
|
+
this.logger = options.logger ?? { warn: () => {} };
|
|
267
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
268
|
+
this.socketFactory = options.socketFactory ?? ((url) => new WebSocket(url));
|
|
269
|
+
this.tokenMarginMs = options.tokenMarginMs ?? DEFAULT_TOKEN_MARGIN_MS;
|
|
270
|
+
this.maxReconnectDelayMs = options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
|
|
271
|
+
this.now = options.now ?? Date.now;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Return a usable access token, refreshing it when absent or near expiry.
|
|
275
|
+
* @returns the current access token.
|
|
276
|
+
*/
|
|
277
|
+
async ensureToken() {
|
|
278
|
+
if (this.accessToken !== void 0 && this.tokenExpiresAt - this.now() > this.tokenMarginMs) return this.accessToken;
|
|
279
|
+
const token = await fetchAccessToken(this.creds, this.fetchImpl, this.now);
|
|
280
|
+
this.accessToken = token.accessToken;
|
|
281
|
+
this.tokenExpiresAt = token.expiresAt;
|
|
282
|
+
return token.accessToken;
|
|
283
|
+
}
|
|
284
|
+
/** Connect (and, on a later drop, reconnect) to the gateway. */
|
|
285
|
+
async start() {
|
|
286
|
+
if (this.closed) return;
|
|
287
|
+
try {
|
|
288
|
+
await this.connectOnce();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
291
|
+
this.logger.warn(`qqbot: connect failed: ${detail}`);
|
|
292
|
+
this.scheduleReconnect();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Permanently close the client: stop timers, cancel reconnects, and close
|
|
297
|
+
* the live socket. Idempotent.
|
|
298
|
+
*/
|
|
299
|
+
close() {
|
|
300
|
+
this.closed = true;
|
|
301
|
+
this.stopHeartbeat();
|
|
302
|
+
if (this.reconnectTimer !== void 0) {
|
|
303
|
+
clearTimeout(this.reconnectTimer);
|
|
304
|
+
this.reconnectTimer = void 0;
|
|
305
|
+
}
|
|
306
|
+
const socket = this.socket;
|
|
307
|
+
this.socket = void 0;
|
|
308
|
+
if (socket !== void 0) socket.close(1e3);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Deliver one reply to a chat. Chunks the text to the platform limit and
|
|
312
|
+
* posts each chunk with the triggering message id threaded as a reply.
|
|
313
|
+
* @param path - the REST path from {@link groupMessagesPath} or {@link c2cMessagesPath}.
|
|
314
|
+
* @param text - the reply text.
|
|
315
|
+
* @param msgId - the inbound message id being answered.
|
|
316
|
+
*/
|
|
317
|
+
async sendText(path, text, msgId) {
|
|
318
|
+
const token = await this.ensureToken();
|
|
319
|
+
const base = apiBase(this.sandbox);
|
|
320
|
+
for (const body of buildSendPayloads(text, msgId)) {
|
|
321
|
+
const response = await this.fetchImpl(`${base}${path}`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
Authorization: `QQBot ${token}`
|
|
326
|
+
},
|
|
327
|
+
body: JSON.stringify(body)
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const detail = await response.text().catch(() => "");
|
|
331
|
+
throw new Error(`qqbot: send failed (HTTP ${response.status}): ${detail}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Mint a token, resolve the gateway URL, and complete one socket session. */
|
|
336
|
+
async connectOnce() {
|
|
337
|
+
const token = await this.ensureToken();
|
|
338
|
+
const url = await fetchGatewayUrl(token, this.sandbox, this.fetchImpl);
|
|
339
|
+
await this.openSocket(url, token);
|
|
340
|
+
}
|
|
341
|
+
/** Open the socket and resolve once READY (or RESUME) arrives. */
|
|
342
|
+
openSocket(url, token) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const socket = this.socketFactory(url);
|
|
345
|
+
this.socket = socket;
|
|
346
|
+
let ready = false;
|
|
347
|
+
socket.onopen = () => {
|
|
348
|
+
this.logger.debug?.(`qqbot: socket open ${url}`);
|
|
349
|
+
};
|
|
350
|
+
socket.onmessage = (event) => {
|
|
351
|
+
const message = parseRawGatewayMessage(event.data);
|
|
352
|
+
if (message === void 0) return;
|
|
353
|
+
switch (message.op) {
|
|
354
|
+
case QQ_OP.HELLO:
|
|
355
|
+
this.startHeartbeat(message.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
356
|
+
socket.send(JSON.stringify({
|
|
357
|
+
op: QQ_OP.IDENTIFY,
|
|
358
|
+
d: {
|
|
359
|
+
token: `QQBot ${token}`,
|
|
360
|
+
intents: QQ_INTENT_GROUP_AND_C2C,
|
|
361
|
+
shard: [0, 1]
|
|
362
|
+
}
|
|
363
|
+
}));
|
|
364
|
+
break;
|
|
365
|
+
case QQ_OP.DISPATCH:
|
|
366
|
+
if (message.seq !== void 0) this.seq = message.seq;
|
|
367
|
+
if (message.type === "READY" || message.type === "RESUMED") {
|
|
368
|
+
ready = true;
|
|
369
|
+
this.reconnectAttempts = 0;
|
|
370
|
+
resolve();
|
|
371
|
+
} else if (message.type !== void 0) {
|
|
372
|
+
const inbound = parseMessageEvent(message.type, message.data);
|
|
373
|
+
if (inbound !== void 0) this.onMessage(inbound);
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
case QQ_OP.HEARTBEAT_ACK: break;
|
|
377
|
+
case QQ_OP.RECONNECT:
|
|
378
|
+
case QQ_OP.INVALID_SESSION:
|
|
379
|
+
this.logger.warn("qqbot: gateway requested reconnect");
|
|
380
|
+
socket.close(4e3);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
socket.onerror = (error) => {
|
|
384
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
385
|
+
this.logger.warn(`qqbot: socket error: ${detail}`);
|
|
386
|
+
};
|
|
387
|
+
socket.onclose = (event) => {
|
|
388
|
+
this.stopHeartbeat();
|
|
389
|
+
if (this.socket !== socket) return;
|
|
390
|
+
this.socket = void 0;
|
|
391
|
+
if (!ready) reject(/* @__PURE__ */ new Error(`qqbot: socket closed before READY (code=${event.code ?? "unknown"})`));
|
|
392
|
+
else if (!this.closed) this.scheduleReconnect();
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Start the heartbeat interval, replacing any previous one. */
|
|
397
|
+
startHeartbeat(intervalMs) {
|
|
398
|
+
this.stopHeartbeat();
|
|
399
|
+
const interval = Math.max(intervalMs, MIN_HEARTBEAT_INTERVAL_MS);
|
|
400
|
+
this.heartbeatTimer = setInterval(() => {
|
|
401
|
+
const socket = this.socket;
|
|
402
|
+
if (socket === void 0) return;
|
|
403
|
+
socket.send(JSON.stringify({
|
|
404
|
+
op: QQ_OP.HEARTBEAT,
|
|
405
|
+
d: this.seq
|
|
406
|
+
}));
|
|
407
|
+
}, interval);
|
|
408
|
+
}
|
|
409
|
+
stopHeartbeat() {
|
|
410
|
+
if (this.heartbeatTimer !== void 0) {
|
|
411
|
+
clearInterval(this.heartbeatTimer);
|
|
412
|
+
this.heartbeatTimer = void 0;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** Schedule one reconnect attempt with exponential backoff. */
|
|
416
|
+
scheduleReconnect() {
|
|
417
|
+
if (this.closed || this.reconnectTimer !== void 0) return;
|
|
418
|
+
const exponent = Math.min(this.reconnectAttempts, 5);
|
|
419
|
+
const delay = Math.min(1e3 * 2 ** exponent, this.maxReconnectDelayMs);
|
|
420
|
+
this.reconnectAttempts += 1;
|
|
421
|
+
this.reconnectTimer = setTimeout(() => {
|
|
422
|
+
this.reconnectTimer = void 0;
|
|
423
|
+
this.start();
|
|
424
|
+
}, delay);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region lib/types/index.js
|
|
429
|
+
/**
|
|
430
|
+
* QQ official bot bridge for DeepSeek Harness.
|
|
431
|
+
*
|
|
432
|
+
* Answers @-mentions in group chat and private C2C messages by routing them
|
|
433
|
+
* into one harness session the user selects from the conversation window. The
|
|
434
|
+
* `/qqbot on` command binds the dispatching agent (its context and cwd) to the
|
|
435
|
+
* QQ gateway; `/qqbot off` disconnects. Nothing connects until a session opts
|
|
436
|
+
* in, and QQ messages then arrive as ordinary user messages on that agent.
|
|
437
|
+
*
|
|
438
|
+
* The robot identity (AppID/AppSecret) lives in the `qqbot` settings section —
|
|
439
|
+
* the Settings → QQ Bot page of the web GUI — with the composition entry as
|
|
440
|
+
* the base layer and `QQBOT_APP_ID`/`QQBOT_APP_SECRET` as an environment
|
|
441
|
+
* fallback. Committed settings changes restart a live gateway in place.
|
|
442
|
+
*
|
|
443
|
+
* The bridge is also an approval answerer for its bound agent: a tool call that
|
|
444
|
+
* asks for confirmation is forwarded to the originating QQ chat as a prompt,
|
|
445
|
+
* and the user's "允许" / "拒绝" reply settles the request.
|
|
446
|
+
*
|
|
447
|
+
* @module dsh-qqbot
|
|
448
|
+
*/
|
|
449
|
+
const name = "qqbot";
|
|
450
|
+
/** The bridge binds the session's existing agent; commands and session events drive it. */
|
|
451
|
+
const inject = ["agents", "commands"];
|
|
452
|
+
/** Plugin config: robot identity plus the API environment selection. */
|
|
453
|
+
const Config = Schema.object({
|
|
454
|
+
appId: Schema.string(),
|
|
455
|
+
appSecret: Schema.string().role("secret"),
|
|
456
|
+
sandbox: Schema.boolean()
|
|
457
|
+
});
|
|
458
|
+
/** Environment variable fallback for the robot application id. */
|
|
459
|
+
const QQBOT_APP_ID_ENV = "QQBOT_APP_ID";
|
|
460
|
+
/** Environment variable fallback for the robot application secret. */
|
|
461
|
+
const QQBOT_APP_SECRET_ENV = "QQBOT_APP_SECRET";
|
|
462
|
+
/** Settings namespace carrying the robot identity (Settings → QQ Bot). */
|
|
463
|
+
const QQBOT_SETTINGS_NAMESPACE = settingsNamespace("qqbot");
|
|
464
|
+
/** Prompt section name registered on the bound agent's scope. */
|
|
465
|
+
const QQBOT_CHANNEL_SECTION = "qqbot:channel";
|
|
466
|
+
/** Prompt section order: after the persona, before tool guidance. */
|
|
467
|
+
const QQBOT_CHANNEL_ORDER = 10;
|
|
468
|
+
/**
|
|
469
|
+
* The QQ-channel instructions, registered verbatim on the bound agent while a
|
|
470
|
+
* session is connected. Kept as a literal export so the README and tests quote
|
|
471
|
+
* the exact text.
|
|
472
|
+
*/
|
|
473
|
+
const QQBOT_CHANNEL_PROMPT = [
|
|
474
|
+
"你正在通过 QQ 与用户交流,消息可能来自私聊或群聊(群聊中用户会 @你)。",
|
|
475
|
+
"",
|
|
476
|
+
"请遵守以下约定:",
|
|
477
|
+
"- 默认使用与用户相同的语言回复,中文用户请使用简体中文。",
|
|
478
|
+
"- 回复简洁自然、直奔主题,单条控制在 2000 字以内;更长的内容会被自动分段发送。",
|
|
479
|
+
"- 可以使用 Markdown(加粗、行内代码、代码块、列表),但避免复杂表格和深层嵌套。",
|
|
480
|
+
"- 群聊消息以 `[昵称]: 内容` 的形式出现,直接回答该用户的问题,不要评论格式本身。",
|
|
481
|
+
"- 不要主动提及你通过 QQ 接入、底层模型或平台,除非用户直接询问。"
|
|
482
|
+
].join("\n");
|
|
483
|
+
/** The plugin's channel section, built from the exported literals. */
|
|
484
|
+
const channelSection = {
|
|
485
|
+
name: QQBOT_CHANNEL_SECTION,
|
|
486
|
+
order: 10,
|
|
487
|
+
text: QQBOT_CHANNEL_PROMPT
|
|
488
|
+
};
|
|
489
|
+
/** Whole-answer words accepted as an approval grant. */
|
|
490
|
+
const ALLOW_ANSWERS = /* @__PURE__ */ new Set([
|
|
491
|
+
"允许",
|
|
492
|
+
"允许执行",
|
|
493
|
+
"同意",
|
|
494
|
+
"可以",
|
|
495
|
+
"是",
|
|
496
|
+
"确认",
|
|
497
|
+
"y",
|
|
498
|
+
"yes",
|
|
499
|
+
"ok",
|
|
500
|
+
"allow",
|
|
501
|
+
"accept",
|
|
502
|
+
"1"
|
|
503
|
+
]);
|
|
504
|
+
/** Whole-answer words accepted as an approval rejection. */
|
|
505
|
+
const REJECT_ANSWERS = /* @__PURE__ */ new Set([
|
|
506
|
+
"拒绝",
|
|
507
|
+
"不同意",
|
|
508
|
+
"禁止",
|
|
509
|
+
"否",
|
|
510
|
+
"取消",
|
|
511
|
+
"n",
|
|
512
|
+
"no",
|
|
513
|
+
"deny",
|
|
514
|
+
"reject",
|
|
515
|
+
"0"
|
|
516
|
+
]);
|
|
517
|
+
/**
|
|
518
|
+
* Parse an inbound QQ reply into an approval decision, or leave it undefined
|
|
519
|
+
* when it is not an answer. Matches the whole normalized text, then its first
|
|
520
|
+
* whitespace/punctuation-delimited token ("允许。" → allow, "同意 执行" → allow).
|
|
521
|
+
* @param text - the raw inbound message content.
|
|
522
|
+
* @returns the decision, or undefined when the text is not an answer.
|
|
523
|
+
*/
|
|
524
|
+
function parseAnswer(text) {
|
|
525
|
+
const normalized = stripMentions(text).trim().toLowerCase();
|
|
526
|
+
if (normalized.length === 0) return void 0;
|
|
527
|
+
if (ALLOW_ANSWERS.has(normalized)) return "allowed-once";
|
|
528
|
+
if (REJECT_ANSWERS.has(normalized)) return "rejected";
|
|
529
|
+
const first = normalized.split(/[\s,,。.!!??、::;;/]+/u)[0] ?? "";
|
|
530
|
+
if (ALLOW_ANSWERS.has(first)) return "allowed-once";
|
|
531
|
+
if (REJECT_ANSWERS.has(first)) return "rejected";
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Resolve robot credentials from a config section, then environment, then
|
|
535
|
+
* fail loud.
|
|
536
|
+
* @param config - the validated section (settings-resolved or composition entry).
|
|
537
|
+
* @returns the concrete credentials.
|
|
538
|
+
*/
|
|
539
|
+
function resolveCredentials(config) {
|
|
540
|
+
const appId = config.appId ?? process.env["QQBOT_APP_ID"];
|
|
541
|
+
const appSecret = config.appSecret ?? process.env["QQBOT_APP_SECRET"];
|
|
542
|
+
if (appId === void 0 || appId.length === 0 || appSecret === void 0 || appSecret.length === 0) throw new Error(`qqbot: appId and appSecret are required; set them in Settings → QQ Bot, or export ${QQBOT_APP_ID_ENV}/${QQBOT_APP_SECRET_ENV}`);
|
|
543
|
+
return {
|
|
544
|
+
appId,
|
|
545
|
+
appSecret
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Mount the QQ bot bridge. It registers the `/qqbot` command and connects the
|
|
550
|
+
* gateway only when a session runs `/qqbot on`; the bound agent's own scope,
|
|
551
|
+
* history, and cwd are reused unchanged. While bound, it also answers that
|
|
552
|
+
* agent's approval requests through the originating QQ chat.
|
|
553
|
+
* @param ctx - Cordis context carrying the agent registry, commands, and session events.
|
|
554
|
+
* @param config - composition entry; the user layer of the `qqbot` settings
|
|
555
|
+
* namespace resolves over it once a settings provider is mounted.
|
|
556
|
+
*/
|
|
557
|
+
function apply(ctx, config) {
|
|
558
|
+
const logger = ctx.logger;
|
|
559
|
+
let readSection = () => config;
|
|
560
|
+
let boundAgent;
|
|
561
|
+
let boundSectionDispose;
|
|
562
|
+
let gateway;
|
|
563
|
+
let gatewaySettings;
|
|
564
|
+
const originByMessage = /* @__PURE__ */ new Map();
|
|
565
|
+
const originByTurn = /* @__PURE__ */ new Map();
|
|
566
|
+
const replyText = /* @__PURE__ */ new Map();
|
|
567
|
+
let currentOrigin;
|
|
568
|
+
let pendingApproval;
|
|
569
|
+
/** Resolve the identity the bridge uses right now, or throw for the caller. */
|
|
570
|
+
function resolveCurrentCredentials() {
|
|
571
|
+
return resolveCredentials(readSection());
|
|
572
|
+
}
|
|
573
|
+
/** Resolve the reply path for one inbound message. */
|
|
574
|
+
function replyPathOf(message) {
|
|
575
|
+
if (message.kind === "c2c") return c2cMessagesPath(message.userOpenid);
|
|
576
|
+
return groupMessagesPath(message.groupOpenid);
|
|
577
|
+
}
|
|
578
|
+
/** Normalize inbound content into the model-visible user text, or drop it. */
|
|
579
|
+
function toUserText(message) {
|
|
580
|
+
const trimmed = message.content.trim();
|
|
581
|
+
if (message.kind === "group") {
|
|
582
|
+
const cleaned = stripMentions(trimmed);
|
|
583
|
+
if (cleaned.length === 0) return "";
|
|
584
|
+
return `[${message.author.username ?? message.author.id ?? "QQ 用户"}]: ${cleaned}`;
|
|
585
|
+
}
|
|
586
|
+
return trimmed;
|
|
587
|
+
}
|
|
588
|
+
/** Route one inbound message into the bound agent, recording its reply origin. */
|
|
589
|
+
function routeMessage(message) {
|
|
590
|
+
const pending = pendingApproval;
|
|
591
|
+
if (pending !== void 0) {
|
|
592
|
+
const answer = parseAnswer(message.content);
|
|
593
|
+
if (answer !== void 0) pending.settle(answer);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const agent = boundAgent;
|
|
597
|
+
if (agent === void 0) return;
|
|
598
|
+
const text = toUserText(message);
|
|
599
|
+
if (text.length === 0) return;
|
|
600
|
+
const userMessage = createUserMessage({
|
|
601
|
+
content: [{
|
|
602
|
+
type: "text",
|
|
603
|
+
text
|
|
604
|
+
}],
|
|
605
|
+
source: { kind: "user" }
|
|
606
|
+
});
|
|
607
|
+
originByMessage.set(userMessage.id, {
|
|
608
|
+
replyPath: replyPathOf(message),
|
|
609
|
+
msgId: message.id
|
|
610
|
+
});
|
|
611
|
+
agent.followup(userMessage);
|
|
612
|
+
}
|
|
613
|
+
/** Connect the gateway for the first bound session, or reuse the live one. */
|
|
614
|
+
function ensureGateway() {
|
|
615
|
+
if (gateway === void 0) {
|
|
616
|
+
const creds = resolveCurrentCredentials();
|
|
617
|
+
gatewaySettings = {
|
|
618
|
+
appId: creds.appId,
|
|
619
|
+
appSecret: creds.appSecret,
|
|
620
|
+
sandbox: readSection().sandbox === true
|
|
621
|
+
};
|
|
622
|
+
gateway = new QqGatewayClient({
|
|
623
|
+
creds: {
|
|
624
|
+
appId: creds.appId,
|
|
625
|
+
appSecret: creds.appSecret
|
|
626
|
+
},
|
|
627
|
+
sandbox: gatewaySettings.sandbox,
|
|
628
|
+
logger,
|
|
629
|
+
onMessage: (message) => {
|
|
630
|
+
routeMessage(message);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return gateway;
|
|
635
|
+
}
|
|
636
|
+
/** Bind a session: register its channel section, then connect the gateway. */
|
|
637
|
+
function bind(agent) {
|
|
638
|
+
try {
|
|
639
|
+
resolveCurrentCredentials();
|
|
640
|
+
} catch (_missingCredentials) {
|
|
641
|
+
return `QQ bot credentials are not configured. Set AppID/AppSecret in Settings → QQ Bot, or export ${QQBOT_APP_ID_ENV}/${QQBOT_APP_SECRET_ENV}.`;
|
|
642
|
+
}
|
|
643
|
+
boundAgent = agent;
|
|
644
|
+
boundSectionDispose = agent.ctx.systemPrompt.section(channelSection);
|
|
645
|
+
ensureGateway().start().catch((error) => {
|
|
646
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
647
|
+
logger.warn(`qqbot: gateway start failed: ${detail}`);
|
|
648
|
+
});
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Re-evaluate the settings section against the live gateway: removed
|
|
653
|
+
* credentials close it; a changed identity or sandbox flag restarts it with
|
|
654
|
+
* the new values, keeping the binding.
|
|
655
|
+
*/
|
|
656
|
+
function rejudgeSettings() {
|
|
657
|
+
const active = gateway;
|
|
658
|
+
if (active === void 0) return;
|
|
659
|
+
let creds;
|
|
660
|
+
try {
|
|
661
|
+
creds = resolveCurrentCredentials();
|
|
662
|
+
} catch (_missingCredentials) {
|
|
663
|
+
logger.warn("qqbot: credentials removed while connected; gateway closed");
|
|
664
|
+
active.close();
|
|
665
|
+
gateway = void 0;
|
|
666
|
+
gatewaySettings = void 0;
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const sandbox = readSection().sandbox === true;
|
|
670
|
+
const current = gatewaySettings;
|
|
671
|
+
if (current !== void 0 && current.appId === creds.appId && current.appSecret === creds.appSecret && current.sandbox === sandbox) return;
|
|
672
|
+
active.close();
|
|
673
|
+
gateway = void 0;
|
|
674
|
+
gatewaySettings = void 0;
|
|
675
|
+
if (boundAgent === void 0) return;
|
|
676
|
+
ensureGateway().start().catch((error) => {
|
|
677
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
678
|
+
logger.warn(`qqbot: gateway restart failed: ${detail}`);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
/** Disconnect and drop the binding, unwinding the scoped section. */
|
|
682
|
+
function unbind() {
|
|
683
|
+
if (pendingApproval !== void 0) pendingApproval.settle("cancelled");
|
|
684
|
+
boundAgent = void 0;
|
|
685
|
+
if (boundSectionDispose !== void 0) {
|
|
686
|
+
boundSectionDispose();
|
|
687
|
+
boundSectionDispose = void 0;
|
|
688
|
+
}
|
|
689
|
+
originByMessage.clear();
|
|
690
|
+
originByTurn.clear();
|
|
691
|
+
replyText.clear();
|
|
692
|
+
currentOrigin = void 0;
|
|
693
|
+
if (gateway !== void 0) {
|
|
694
|
+
gateway.close();
|
|
695
|
+
gateway = void 0;
|
|
696
|
+
}
|
|
697
|
+
gatewaySettings = void 0;
|
|
698
|
+
}
|
|
699
|
+
/** Forward one approval to the originating QQ chat and await the user's answer. */
|
|
700
|
+
function forwardApproval(req, origin) {
|
|
701
|
+
const current = gateway;
|
|
702
|
+
if (current === void 0) return Promise.resolve("unavailable");
|
|
703
|
+
const reason = req.reason ?? "";
|
|
704
|
+
const prompt = reason.length > 0 ? `[审批] 工具 \`${req.toolName}\`(${reason})请求执行。回复「允许」或「拒绝」。` : `[审批] 工具 \`${req.toolName}\` 请求执行。回复「允许」或「拒绝」。`;
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
let settled = false;
|
|
707
|
+
const settle = (outcome) => {
|
|
708
|
+
if (settled) return;
|
|
709
|
+
settled = true;
|
|
710
|
+
pendingApproval = void 0;
|
|
711
|
+
req.signal?.removeEventListener("abort", onAbort);
|
|
712
|
+
resolve(outcome);
|
|
713
|
+
};
|
|
714
|
+
const onAbort = () => {
|
|
715
|
+
settle("cancelled");
|
|
716
|
+
};
|
|
717
|
+
pendingApproval = { settle };
|
|
718
|
+
req.signal?.addEventListener("abort", onAbort, { once: true });
|
|
719
|
+
current.sendText(origin.replyPath, prompt, origin.msgId).catch(() => {
|
|
720
|
+
settle("unavailable");
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
ctx.on("agent/inbox/claimed", ({ agent, message, turn }) => {
|
|
725
|
+
if (agent !== boundAgent) return;
|
|
726
|
+
const origin = originByMessage.get(message.id);
|
|
727
|
+
if (origin !== void 0) {
|
|
728
|
+
originByTurn.set(turn, origin);
|
|
729
|
+
currentOrigin = origin;
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
ctx.on("session/event", (session, event) => {
|
|
733
|
+
const agent = boundAgent;
|
|
734
|
+
if (agent === void 0 || session !== agent.session) return;
|
|
735
|
+
if (event.type === "assistant/message") {
|
|
736
|
+
const text = event.data.message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
737
|
+
replyText.set(event.data.turn, text);
|
|
738
|
+
} else if (event.type === "turn/end") {
|
|
739
|
+
currentOrigin = void 0;
|
|
740
|
+
const origin = originByTurn.get(event.data.turn);
|
|
741
|
+
const text = replyText.get(event.data.turn);
|
|
742
|
+
originByTurn.delete(event.data.turn);
|
|
743
|
+
replyText.delete(event.data.turn);
|
|
744
|
+
if (origin === void 0 || text === void 0 || text.trim().length === 0) return;
|
|
745
|
+
const current = gateway;
|
|
746
|
+
if (current === void 0) return;
|
|
747
|
+
current.sendText(origin.replyPath, text, origin.msgId).catch((error) => {
|
|
748
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
749
|
+
logger.warn(`qqbot: reply failed: ${detail}`);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
ctx.on("approval/request", (req, next) => {
|
|
754
|
+
if (req.agent !== boundAgent) return next();
|
|
755
|
+
const origin = currentOrigin;
|
|
756
|
+
if (origin === void 0) return next();
|
|
757
|
+
return forwardApproval(req, origin);
|
|
758
|
+
}, { prepend: true });
|
|
759
|
+
ctx.on("agent/disposed", ({ agent }) => {
|
|
760
|
+
if (agent !== boundAgent) return;
|
|
761
|
+
if (pendingApproval !== void 0) pendingApproval.settle("unavailable");
|
|
762
|
+
boundAgent = void 0;
|
|
763
|
+
boundSectionDispose = void 0;
|
|
764
|
+
originByMessage.clear();
|
|
765
|
+
originByTurn.clear();
|
|
766
|
+
replyText.clear();
|
|
767
|
+
currentOrigin = void 0;
|
|
768
|
+
});
|
|
769
|
+
ctx.commands.register({
|
|
770
|
+
name: "qqbot",
|
|
771
|
+
description: "Connect or disconnect the QQ official bot for this session (/qqbot on | off)",
|
|
772
|
+
handler: (invocation) => {
|
|
773
|
+
const input = invocation.rawInput.trim();
|
|
774
|
+
if (input === "on") {
|
|
775
|
+
const failure = bind(invocation.agent);
|
|
776
|
+
if (failure !== null) return {
|
|
777
|
+
kind: "error",
|
|
778
|
+
text: failure
|
|
779
|
+
};
|
|
780
|
+
return {
|
|
781
|
+
kind: "success",
|
|
782
|
+
text: "QQ bot connected; this session now answers QQ messages."
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
if (input === "off") {
|
|
786
|
+
unbind();
|
|
787
|
+
return {
|
|
788
|
+
kind: "success",
|
|
789
|
+
text: "QQ bot disconnected."
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
kind: "error",
|
|
794
|
+
text: "usage: /qqbot on | /qqbot off"
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
installSettingsSection(ctx, QQBOT_SETTINGS_NAMESPACE, Config, config, {
|
|
799
|
+
setSource: (current) => {
|
|
800
|
+
readSection = current;
|
|
801
|
+
},
|
|
802
|
+
onChange: () => {
|
|
803
|
+
rejudgeSettings();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
ctx.effect(() => () => {
|
|
807
|
+
unbind();
|
|
808
|
+
}, "qqbot");
|
|
809
|
+
}
|
|
810
|
+
//#endregion
|
|
811
|
+
export { Config, QQBOT_APP_ID_ENV, QQBOT_APP_SECRET_ENV, QQBOT_CHANNEL_ORDER, QQBOT_CHANNEL_PROMPT, QQBOT_CHANNEL_SECTION, QQBOT_SETTINGS_NAMESPACE, apply, inject, name, parseAnswer };
|