@ssfxx44533/onebot-qq 0.1.2

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/src/inbound.ts ADDED
@@ -0,0 +1,587 @@
1
+ import path from "node:path";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import {
6
+ issuePairingChallenge,
7
+ } from "openclaw/plugin-sdk/conversation-runtime";
8
+ import {
9
+ buildAgentMediaPayload,
10
+ resolveChannelMediaMaxBytes,
11
+ } from "openclaw/plugin-sdk/media-runtime";
12
+ import {
13
+ readStoreAllowFromForDmPolicy,
14
+ resolveDmGroupAccessWithCommandGate,
15
+ } from "openclaw/plugin-sdk/security-runtime";
16
+ import {
17
+ resolveInboundRouteEnvelopeBuilderWithRuntime,
18
+ } from "openclaw/plugin-sdk/googlechat";
19
+ import {
20
+ dispatchInboundReplyWithBase,
21
+ } from "openclaw/plugin-sdk/irc";
22
+
23
+ import {
24
+ matchAllowedSender,
25
+ normalizeOneBotUserId,
26
+ resolveOneBotGroupConfig,
27
+ } from "./config.js";
28
+ import {
29
+ buildAgentBody,
30
+ extractAttachments,
31
+ extractMessageText,
32
+ extractReplyToId,
33
+ isAtSelf,
34
+ makeEventKey,
35
+ } from "./message.js";
36
+ import {
37
+ enqueueSessionWork,
38
+ hasSeenEvent,
39
+ rememberEvent,
40
+ } from "./runtime.js";
41
+
42
+ const DEFAULT_INBOUND_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
43
+ const CACHED_MEDIA_KINDS = new Set(["image", "video", "audio"]);
44
+ const CONTENT_TYPES_BY_EXTENSION = new Map([
45
+ [".png", "image/png"],
46
+ [".jpg", "image/jpeg"],
47
+ [".jpeg", "image/jpeg"],
48
+ [".gif", "image/gif"],
49
+ [".webp", "image/webp"],
50
+ [".bmp", "image/bmp"],
51
+ [".mp4", "video/mp4"],
52
+ [".mov", "video/quicktime"],
53
+ [".webm", "video/webm"],
54
+ [".mkv", "video/x-matroska"],
55
+ [".mp3", "audio/mpeg"],
56
+ [".wav", "audio/wav"],
57
+ [".ogg", "audio/ogg"],
58
+ [".m4a", "audio/mp4"],
59
+ [".aac", "audio/aac"],
60
+ [".flac", "audio/flac"],
61
+ [".amr", "audio/amr"],
62
+ [".silk", "audio/silk"],
63
+ ]);
64
+
65
+ function hasControlCommand(text) {
66
+ return String(text ?? "").trim().startsWith("/");
67
+ }
68
+
69
+ function resolveSessionKey(account, event) {
70
+ if (event.message_type === "group") {
71
+ return `${account.accountId}:qq-group:${event.group_id}`;
72
+ }
73
+ return `${account.accountId}:qq-private:${normalizeOneBotUserId(event.user_id)}`;
74
+ }
75
+
76
+ function resolveDefaultReplyToId(event) {
77
+ const messageId = String(event?.message_id ?? "").trim();
78
+ return messageId || null;
79
+ }
80
+
81
+ function buildTargetInfo(event) {
82
+ const isGroup = event.message_type === "group";
83
+ const userId = normalizeOneBotUserId(event.user_id);
84
+ return {
85
+ chatType: isGroup ? "group" : "private",
86
+ targetId: String(isGroup ? event.group_id ?? "" : userId).trim(),
87
+ groupId: isGroup ? String(event.group_id ?? "").trim() || null : null,
88
+ userId: userId || null,
89
+ replyToId: resolveDefaultReplyToId(event),
90
+ };
91
+ }
92
+
93
+ async function resolveInboundAccess({
94
+ account,
95
+ cfg,
96
+ runtime,
97
+ event,
98
+ rawText,
99
+ wasMentioned,
100
+ targetInfo,
101
+ deliver,
102
+ }) {
103
+ const senderId = normalizeOneBotUserId(event.user_id);
104
+ const isGroup = event.message_type === "group";
105
+ if (!senderId) {
106
+ return { allowed: false, commandAuthorized: false, groupConfig: null };
107
+ }
108
+ const groupConfig = isGroup ? resolveOneBotGroupConfig(account, event.group_id) : null;
109
+ const groupAllowFrom = groupConfig?.allowFrom?.length > 0 ? groupConfig.allowFrom : account.groupAllowFrom;
110
+ const controlCommand = hasControlCommand(rawText);
111
+ const allowTextCommands = runtime?.commands?.shouldHandleTextCommands
112
+ ? runtime.commands.shouldHandleTextCommands({
113
+ cfg,
114
+ surface: "onebot-qq",
115
+ commandSource: "text",
116
+ })
117
+ : true;
118
+ const useAccessGroups = cfg?.commands?.useAccessGroups !== false;
119
+
120
+ if (isGroup) {
121
+ if (groupConfig && groupConfig.enabled === false) {
122
+ return { allowed: false, commandAuthorized: false, groupConfig };
123
+ }
124
+ }
125
+
126
+ const storeAllowFrom = runtime?.pairing?.readAllowFromStore
127
+ ? await readStoreAllowFromForDmPolicy({
128
+ provider: "onebot-qq",
129
+ accountId: account.accountId,
130
+ dmPolicy: account.dmPolicy,
131
+ readStore: async (provider, accountId) =>
132
+ await runtime.pairing.readAllowFromStore({
133
+ channel: provider,
134
+ accountId,
135
+ }),
136
+ })
137
+ : [];
138
+
139
+ const access = resolveDmGroupAccessWithCommandGate({
140
+ isGroup,
141
+ dmPolicy: account.dmPolicy,
142
+ groupPolicy: account.groupPolicy,
143
+ allowFrom: account.allowFrom,
144
+ groupAllowFrom,
145
+ storeAllowFrom,
146
+ isSenderAllowed: (allowFrom) => matchAllowedSender(senderId, allowFrom),
147
+ command: {
148
+ useAccessGroups,
149
+ allowTextCommands,
150
+ hasControlCommand: controlCommand,
151
+ },
152
+ });
153
+
154
+ if (access.decision === "pairing" && !isGroup && runtime?.pairing?.upsertPairingRequest) {
155
+ const senderName = String(
156
+ event.sender?.card || event.sender?.nickname || event.sender?.remark || "",
157
+ ).trim();
158
+ await issuePairingChallenge({
159
+ channel: "onebot-qq",
160
+ senderId,
161
+ senderIdLine: `Your QQ ID: ${senderId}`,
162
+ meta: {
163
+ name: senderName || undefined,
164
+ },
165
+ upsertPairingRequest: async ({ id, meta }) =>
166
+ await runtime.pairing.upsertPairingRequest({
167
+ channel: "onebot-qq",
168
+ accountId: account.accountId,
169
+ id,
170
+ meta,
171
+ }),
172
+ sendPairingReply: async (text) => {
173
+ await deliver({
174
+ account,
175
+ target: targetInfo,
176
+ payload: {
177
+ text,
178
+ replyToId: targetInfo.replyToId,
179
+ },
180
+ });
181
+ },
182
+ });
183
+ return { allowed: false, commandAuthorized: false, groupConfig };
184
+ }
185
+
186
+ if (access.decision !== "allow" || access.shouldBlockControlCommand) {
187
+ return {
188
+ allowed: false,
189
+ commandAuthorized: access.commandAuthorized,
190
+ groupConfig,
191
+ };
192
+ }
193
+
194
+ if (isGroup) {
195
+ const requireMention =
196
+ groupConfig?.requireMention ?? account.requireMention;
197
+ if (requireMention && !wasMentioned && !(controlCommand && access.commandAuthorized)) {
198
+ return {
199
+ allowed: false,
200
+ commandAuthorized: access.commandAuthorized,
201
+ groupConfig,
202
+ };
203
+ }
204
+ }
205
+
206
+ return {
207
+ allowed: true,
208
+ commandAuthorized: access.commandAuthorized,
209
+ groupConfig,
210
+ };
211
+ }
212
+
213
+ function buildContextAddresses(event) {
214
+ const isGroup = event.message_type === "group";
215
+ const userId = normalizeOneBotUserId(event.user_id);
216
+ const groupId = String(event.group_id ?? "").trim();
217
+ return {
218
+ from: `onebot-qq:user:${userId}`,
219
+ to: isGroup ? `onebot-qq:group:${groupId}` : `onebot-qq:private:${userId}`,
220
+ };
221
+ }
222
+
223
+ function buildConversationLabel(event) {
224
+ const nickname = String(
225
+ event.sender?.card || event.sender?.nickname || event.sender?.remark || event.user_id || "unknown",
226
+ ).trim();
227
+ if (event.message_type === "group") {
228
+ return `QQ Group ${String(event.group_id ?? "").trim()}`;
229
+ }
230
+ return nickname || `QQ ${String(event.user_id ?? "").trim()}`;
231
+ }
232
+
233
+ function buildFirstAttachmentContext(attachments) {
234
+ const first = attachments[0];
235
+ if (!first) {
236
+ return {};
237
+ }
238
+ return {
239
+ MediaType: first.kind,
240
+ MediaUrl: first.url || undefined,
241
+ };
242
+ }
243
+
244
+ function parseAttachmentSizeBytes(value) {
245
+ const parsed = Number.parseInt(String(value ?? "").trim(), 10);
246
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
247
+ }
248
+
249
+ function isSupportedCachedAttachment(attachment) {
250
+ return CACHED_MEDIA_KINDS.has(String(attachment?.kind ?? "").trim());
251
+ }
252
+
253
+ function resolveAttachmentOriginalName(attachment) {
254
+ const candidate = String(
255
+ attachment?.name || attachment?.file || attachment?.url || "",
256
+ ).trim();
257
+ if (!candidate) {
258
+ return undefined;
259
+ }
260
+ try {
261
+ if (/^(?:https?|file):\/\//i.test(candidate)) {
262
+ const fromUrl = path.basename(new URL(candidate).pathname);
263
+ return fromUrl || undefined;
264
+ }
265
+ } catch {
266
+ // fall through
267
+ }
268
+ const basename = path.basename(candidate);
269
+ return basename || undefined;
270
+ }
271
+
272
+ function guessAttachmentContentType(attachment, sourceValue = "") {
273
+ const originalName = resolveAttachmentOriginalName(attachment) ?? String(sourceValue ?? "").trim();
274
+ const ext = path.extname(originalName).toLowerCase();
275
+ if (CONTENT_TYPES_BY_EXTENSION.has(ext)) {
276
+ return CONTENT_TYPES_BY_EXTENSION.get(ext);
277
+ }
278
+ if (attachment?.kind === "image") {
279
+ return "image/jpeg";
280
+ }
281
+ if (attachment?.kind === "video") {
282
+ return "video/mp4";
283
+ }
284
+ if (attachment?.kind === "audio") {
285
+ return "audio/mpeg";
286
+ }
287
+ return undefined;
288
+ }
289
+
290
+ function resolveAttachmentSource(attachment) {
291
+ const attachmentUrl = String(attachment?.url ?? "").trim();
292
+ if (/^https?:\/\//i.test(attachmentUrl)) {
293
+ return { kind: "remote", value: attachmentUrl };
294
+ }
295
+ if (attachmentUrl.startsWith("file://")) {
296
+ return { kind: "local", value: fileURLToPath(new URL(attachmentUrl)) };
297
+ }
298
+ if (path.isAbsolute(attachmentUrl)) {
299
+ return { kind: "local", value: attachmentUrl };
300
+ }
301
+
302
+ const attachmentFile = String(attachment?.file ?? "").trim();
303
+ if (attachmentFile.startsWith("file://")) {
304
+ return { kind: "local", value: fileURLToPath(new URL(attachmentFile)) };
305
+ }
306
+ if (path.isAbsolute(attachmentFile)) {
307
+ return { kind: "local", value: attachmentFile };
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ function buildAttachmentMediaContext(attachments) {
314
+ const entries = attachments
315
+ .filter((attachment) => isSupportedCachedAttachment(attachment))
316
+ .map((attachment) => {
317
+ const source = resolveAttachmentSource(attachment);
318
+ const mediaUrl = source?.value || String(attachment?.url ?? "").trim();
319
+ if (!mediaUrl) {
320
+ return null;
321
+ }
322
+ return {
323
+ url: mediaUrl,
324
+ contentType: guessAttachmentContentType(attachment, mediaUrl),
325
+ };
326
+ })
327
+ .filter(Boolean);
328
+
329
+ if (entries.length === 0) {
330
+ return buildFirstAttachmentContext(attachments);
331
+ }
332
+
333
+ return {
334
+ MediaUrl: entries[0]?.url,
335
+ MediaUrls: entries.map((entry) => entry.url),
336
+ MediaType: entries[0]?.contentType,
337
+ MediaTypes: entries
338
+ .map((entry) => entry.contentType)
339
+ .filter(Boolean),
340
+ };
341
+ }
342
+
343
+ async function cacheInboundAttachments({
344
+ account,
345
+ cfg,
346
+ runtime,
347
+ attachments,
348
+ log,
349
+ }) {
350
+ if (!runtime?.media?.saveMediaBuffer || attachments.length === 0) {
351
+ return [];
352
+ }
353
+
354
+ const maxBytes = resolveChannelMediaMaxBytes({
355
+ cfg,
356
+ accountId: account.accountId,
357
+ resolveChannelLimitMb: ({ cfg, accountId }) =>
358
+ cfg.channels?.["onebot-qq"]?.accounts?.[accountId]?.mediaMaxMb ??
359
+ cfg.channels?.["onebot-qq"]?.mediaMaxMb,
360
+ }) ?? DEFAULT_INBOUND_MEDIA_MAX_BYTES;
361
+
362
+ const cached = [];
363
+ for (const attachment of attachments) {
364
+ if (!isSupportedCachedAttachment(attachment)) {
365
+ continue;
366
+ }
367
+
368
+ const source = resolveAttachmentSource(attachment);
369
+ if (!source) {
370
+ continue;
371
+ }
372
+
373
+ const advertisedSize = parseAttachmentSizeBytes(attachment.fileSize);
374
+ if (advertisedSize && advertisedSize > maxBytes) {
375
+ log?.debug?.(
376
+ `onebot-qq: skip inbound media cache (${attachment.kind}) size=${advertisedSize} > maxBytes=${maxBytes}`,
377
+ );
378
+ continue;
379
+ }
380
+
381
+ try {
382
+ let buffer;
383
+ let contentType;
384
+ let originalFilename = resolveAttachmentOriginalName(attachment);
385
+
386
+ if (source.kind === "remote") {
387
+ if (!runtime.media.fetchRemoteMedia) {
388
+ continue;
389
+ }
390
+ const fetched = await runtime.media.fetchRemoteMedia({
391
+ url: source.value,
392
+ maxBytes,
393
+ filePathHint: originalFilename,
394
+ });
395
+ buffer = fetched.buffer;
396
+ contentType = fetched.contentType ?? guessAttachmentContentType(attachment, source.value);
397
+ originalFilename ||= fetched.fileName;
398
+ } else {
399
+ const stats = await stat(source.value);
400
+ if (stats.size > maxBytes) {
401
+ log?.debug?.(
402
+ `onebot-qq: skip inbound local media cache (${attachment.kind}) size=${stats.size} > maxBytes=${maxBytes}`,
403
+ );
404
+ continue;
405
+ }
406
+ buffer = await readFile(source.value);
407
+ contentType = guessAttachmentContentType(attachment, source.value);
408
+ originalFilename ||= path.basename(source.value) || undefined;
409
+ }
410
+
411
+ const saved = await runtime.media.saveMediaBuffer(
412
+ Buffer.from(buffer),
413
+ contentType,
414
+ "inbound",
415
+ maxBytes,
416
+ originalFilename,
417
+ );
418
+ cached.push({
419
+ path: saved.path,
420
+ contentType: saved.contentType ?? contentType ?? undefined,
421
+ });
422
+ } catch (error) {
423
+ log?.debug?.(`onebot-qq: inbound media cache failed (${attachment.kind}): ${String(error)}`);
424
+ }
425
+ }
426
+
427
+ return cached;
428
+ }
429
+
430
+ export async function handleOneBotInboundMessage({
431
+ account,
432
+ cfg,
433
+ runtime,
434
+ event,
435
+ selfId,
436
+ deliver,
437
+ log,
438
+ onStatus,
439
+ }) {
440
+ if (!runtime) {
441
+ log?.error?.("onebot-qq: channelRuntime is unavailable; inbound message skipped");
442
+ return;
443
+ }
444
+
445
+ if (event?.post_type !== "message") {
446
+ return;
447
+ }
448
+
449
+ const eventKey = makeEventKey(account.accountId, event);
450
+ if (hasSeenEvent(eventKey)) {
451
+ return;
452
+ }
453
+ rememberEvent(eventKey);
454
+
455
+ if (selfId && String(event.user_id ?? "") === String(selfId)) {
456
+ return;
457
+ }
458
+
459
+ const text = extractMessageText(event, selfId);
460
+ const attachments = extractAttachments(event);
461
+ if (!text && attachments.length === 0) {
462
+ return;
463
+ }
464
+
465
+ const targetInfo = buildTargetInfo(event);
466
+ const wasMentioned = event.message_type === "group" ? isAtSelf(event, selfId) : false;
467
+ const access = await resolveInboundAccess({
468
+ account,
469
+ cfg,
470
+ runtime,
471
+ event,
472
+ rawText: text,
473
+ wasMentioned,
474
+ targetInfo,
475
+ deliver,
476
+ });
477
+ if (!access.allowed) {
478
+ return;
479
+ }
480
+
481
+ const sessionKey = resolveSessionKey(account, event);
482
+
483
+ try {
484
+ await enqueueSessionWork(sessionKey, async () => {
485
+ onStatus?.({ lastInboundAt: Date.now() });
486
+
487
+ const senderName = String(
488
+ event.sender?.card || event.sender?.nickname || event.sender?.remark || "",
489
+ ).trim();
490
+ const senderId = normalizeOneBotUserId(event.user_id);
491
+ const channelRuntime = runtime;
492
+ const cachedMedia = await cacheInboundAttachments({
493
+ account,
494
+ cfg,
495
+ runtime: channelRuntime,
496
+ attachments,
497
+ log,
498
+ });
499
+ const agentBody = buildAgentBody(text, attachments);
500
+ const bodyForAgent = agentBody || text || "[empty]";
501
+ const peer = targetInfo.chatType === "group"
502
+ ? { kind: "group", id: targetInfo.groupId }
503
+ : { kind: "direct", id: targetInfo.userId };
504
+
505
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
506
+ cfg,
507
+ channel: "onebot-qq",
508
+ accountId: account.accountId,
509
+ peer,
510
+ runtime: channelRuntime,
511
+ sessionStore: cfg.session?.store,
512
+ });
513
+
514
+ const { storePath, body } = buildEnvelope({
515
+ channel: "QQ",
516
+ from: buildConversationLabel(event),
517
+ timestamp: Number.isFinite(event.time) ? Number(event.time) * 1000 : Date.now(),
518
+ body: bodyForAgent,
519
+ });
520
+
521
+ const addresses = buildContextAddresses(event);
522
+ const ctxPayload = channelRuntime.reply.finalizeInboundContext({
523
+ Body: body,
524
+ BodyForAgent: bodyForAgent,
525
+ RawBody: text || bodyForAgent,
526
+ CommandBody: text || "",
527
+ From: addresses.from,
528
+ To: addresses.to,
529
+ SessionKey: route.sessionKey,
530
+ AccountId: route.accountId ?? account.accountId,
531
+ ChatType: targetInfo.chatType === "group" ? "group" : "direct",
532
+ ConversationLabel: buildConversationLabel(event),
533
+ SenderName: senderName || undefined,
534
+ SenderId: senderId,
535
+ WasMentioned: targetInfo.chatType === "group" ? wasMentioned : undefined,
536
+ CommandAuthorized: access.commandAuthorized,
537
+ Provider: "onebot-qq",
538
+ Surface: "onebot-qq",
539
+ MessageSid: String(event.message_id ?? ""),
540
+ MessageSidFull: String(event.message_id ?? ""),
541
+ ReplyToId: extractReplyToId(event),
542
+ ReplyToIdFull: extractReplyToId(event),
543
+ GroupSubject:
544
+ targetInfo.chatType === "group" && targetInfo.groupId
545
+ ? `QQ Group ${targetInfo.groupId}`
546
+ : undefined,
547
+ GroupSystemPrompt:
548
+ targetInfo.chatType === "group" ? access.groupConfig?.systemPrompt : undefined,
549
+ OriginatingChannel: "onebot-qq",
550
+ OriginatingTo: addresses.to,
551
+ ...buildAttachmentMediaContext(attachments),
552
+ ...buildAgentMediaPayload(cachedMedia),
553
+ });
554
+
555
+ try {
556
+ await dispatchInboundReplyWithBase({
557
+ cfg,
558
+ channel: "onebot-qq",
559
+ accountId: account.accountId,
560
+ route,
561
+ storePath,
562
+ ctxPayload,
563
+ core: { channel: channelRuntime },
564
+ deliver: async (payload) => {
565
+ await deliver({
566
+ account,
567
+ target: targetInfo,
568
+ payload,
569
+ });
570
+ onStatus?.({ lastOutboundAt: Date.now() });
571
+ },
572
+ onRecordError: (error) => {
573
+ log?.error?.(`onebot-qq: failed updating session meta: ${String(error)}`);
574
+ },
575
+ onDispatchError: (error, info) => {
576
+ log?.error?.(`onebot-qq ${info.kind} reply failed: ${String(error)}`);
577
+ },
578
+ });
579
+ } catch (error) {
580
+ log?.error?.(`onebot-qq: inbound dispatch failed: ${String(error)}`);
581
+ throw error;
582
+ }
583
+ });
584
+ } catch (error) {
585
+ log?.error?.(`onebot-qq: session work failed: ${String(error)}`);
586
+ }
587
+ }