@zhin.js/adapter-icqq 1.0.70 → 1.1.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.
Files changed (111) hide show
  1. package/CHANGELOG.md +1134 -51
  2. package/README.md +116 -431
  3. package/adapters/icqq.js +31 -0
  4. package/adapters/icqq.ts +42 -0
  5. package/agent/PERMITS.md +24 -0
  6. package/agent/prompt-sections/platform.ts +17 -0
  7. package/commands/endpoint/add/[[id]].js +3 -0
  8. package/commands/endpoint/add/[[id]].ts +3 -0
  9. package/commands/endpoint/list.js +3 -0
  10. package/commands/endpoint/list.ts +3 -0
  11. package/commands/endpoint/remove/[id].js +3 -0
  12. package/commands/endpoint/remove/[id].ts +3 -0
  13. package/lib/client.d.ts +19 -0
  14. package/lib/client.js +3 -0
  15. package/lib/cq-message.d.ts +14 -0
  16. package/lib/cq-message.js +106 -0
  17. package/lib/endpoint.d.ts +31 -0
  18. package/lib/endpoint.js +1046 -0
  19. package/lib/forward-msg.d.ts +6 -0
  20. package/lib/forward-msg.js +107 -0
  21. package/lib/icqq-endpoint-commands.d.ts +1 -0
  22. package/lib/icqq-endpoint-commands.js +26 -0
  23. package/lib/icqq-guild.d.ts +57 -0
  24. package/lib/icqq-guild.js +109 -0
  25. package/lib/icqq-inbound.d.ts +165 -0
  26. package/lib/icqq-inbound.js +651 -0
  27. package/lib/icqq-inbox.d.ts +25 -0
  28. package/lib/icqq-inbox.js +129 -0
  29. package/lib/icqq-login-assist.d.ts +27 -0
  30. package/lib/icqq-login-assist.js +109 -0
  31. package/lib/icqq-runtime-state.d.ts +1 -0
  32. package/lib/icqq-runtime-state.js +6 -0
  33. package/lib/index.d.ts +5 -18
  34. package/lib/index.js +5 -124
  35. package/lib/outbound-media.d.ts +14 -0
  36. package/lib/outbound-media.js +76 -0
  37. package/lib/protocol.d.ts +276 -0
  38. package/lib/protocol.js +425 -0
  39. package/lib/types.d.ts +37 -11
  40. package/lib/types.js +0 -1
  41. package/package.json +80 -32
  42. package/plugin.js +19 -0
  43. package/schema.json +162 -0
  44. package/src/client.ts +29 -0
  45. package/src/cq-message.ts +113 -0
  46. package/src/endpoint.ts +1138 -0
  47. package/src/forward-msg.ts +112 -0
  48. package/src/icqq-endpoint-commands.ts +28 -0
  49. package/src/icqq-guild.ts +154 -0
  50. package/src/icqq-inbound.ts +814 -0
  51. package/src/icqq-inbox.ts +147 -0
  52. package/src/icqq-login-assist.ts +132 -0
  53. package/src/icqq-runtime-state.ts +7 -0
  54. package/src/index.ts +30 -149
  55. package/src/outbound-media.ts +103 -0
  56. package/src/protocol.ts +575 -0
  57. package/src/types.ts +40 -11
  58. package/tools/announce.js +16 -0
  59. package/tools/announce.ts +15 -0
  60. package/tools/essence.js +22 -0
  61. package/tools/essence.ts +20 -0
  62. package/tools/friend_list.js +18 -0
  63. package/tools/friend_list.ts +18 -0
  64. package/tools/group_files.js +22 -0
  65. package/tools/group_files.ts +20 -0
  66. package/tools/group_invite.js +23 -0
  67. package/tools/group_invite.ts +20 -0
  68. package/tools/group_kick.js +26 -0
  69. package/tools/group_kick.ts +22 -0
  70. package/tools/group_list.js +19 -0
  71. package/tools/group_list.ts +19 -0
  72. package/tools/group_member_info.js +24 -0
  73. package/tools/group_member_info.ts +26 -0
  74. package/tools/group_sign.js +19 -0
  75. package/tools/group_sign.ts +17 -0
  76. package/tools/list_muted.js +17 -0
  77. package/tools/list_muted.ts +16 -0
  78. package/tools/poke.js +16 -0
  79. package/tools/poke.ts +15 -0
  80. package/tools/send_user_like.js +25 -0
  81. package/tools/send_user_like.ts +22 -0
  82. package/tools/set_anonymous.js +21 -0
  83. package/tools/set_anonymous.ts +19 -0
  84. package/tools/set_mute.js +24 -0
  85. package/tools/set_mute.ts +21 -0
  86. package/tools/set_title.js +25 -0
  87. package/tools/set_title.ts +22 -0
  88. package/tools/user_info.js +14 -0
  89. package/tools/user_info.ts +13 -0
  90. package/LICENSE +0 -21
  91. package/client/ICQQManagement.tsx +0 -139
  92. package/client/LoginAssistPanel.tsx +0 -293
  93. package/client/index.tsx +0 -11
  94. package/client/tsconfig.json +0 -7
  95. package/client/utils/api.ts +0 -20
  96. package/dist/index.js +0 -1
  97. package/lib/adapter.d.ts +0 -34
  98. package/lib/adapter.d.ts.map +0 -1
  99. package/lib/adapter.js +0 -396
  100. package/lib/adapter.js.map +0 -1
  101. package/lib/bot.d.ts +0 -46
  102. package/lib/bot.d.ts.map +0 -1
  103. package/lib/bot.js +0 -457
  104. package/lib/bot.js.map +0 -1
  105. package/lib/index.d.ts.map +0 -1
  106. package/lib/index.js.map +0 -1
  107. package/lib/types.d.ts.map +0 -1
  108. package/lib/types.js.map +0 -1
  109. package/skills/icqq/SKILL.md +0 -22
  110. package/src/adapter.ts +0 -448
  111. package/src/bot.ts +0 -521
@@ -0,0 +1,1138 @@
1
+ import {
2
+ Client,
3
+ parseGroupMessageId,
4
+ type Config as NativeIcqqClientConfig,
5
+ type FileElem,
6
+ type PttElem,
7
+ type Sendable,
8
+ type VideoElem,
9
+ } from '@icqqjs/icqq';
10
+ import { inspect } from 'node:util';
11
+ import { AsyncLocalStorage } from 'node:async_hooks';
12
+ import { Endpoint } from 'zhin.js/adapter';
13
+ import type {
14
+ EndpointControl,
15
+ EndpointManagement,
16
+ EndpointPendingRequest,
17
+ EndpointSendRequest,
18
+ } from 'zhin.js/adapter';
19
+ import type { EndpointContentPort, EndpointContentResolveContext } from '@zhin.js/adapter';
20
+ import { receiveOneBotLikeSideEvent, SystemEvent, toCanonicalSegments, type LoginAssist } from '@zhin.js/core';
21
+ import type {
22
+ ConversationMessage,
23
+ ConversationReference,
24
+ ForwardEntry,
25
+ MessageRef,
26
+ Segment,
27
+ } from '@zhin.js/im-contract';
28
+ import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
29
+ import type { CapabilityId } from 'zhin.js';
30
+ import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
31
+ import {
32
+ InboundMessageDeduper,
33
+ findIcqqNestedMessageSource,
34
+ isIcqqBotMentioned,
35
+ isIcqqMessagePostType,
36
+ normalizeIcqqInboundMessage,
37
+ messageLookupFromIcqqSource,
38
+ resolveIcqqQuoteIdFromEvent,
39
+ resolveIcqqInboundMedia,
40
+ shouldSkipSelfInboundMessage,
41
+ type IcqqMessageEvent,
42
+ } from './icqq-inbound.js';
43
+ import {
44
+ isIcqqNoticePayload,
45
+ isIcqqRequestPayload,
46
+ } from './icqq-inbox.js';
47
+ import {
48
+ IcqqGuildCatalog,
49
+ normalizeIcqqGuildInboundMessage,
50
+ } from './icqq-guild.js';
51
+ import {
52
+ prepareIcqqOutboundMedia,
53
+ resolveIcqqOutboundMediaMode,
54
+ } from './outbound-media.js';
55
+ import {
56
+ formatInboundContent,
57
+ formatOutboundBody,
58
+ icqqInboundConversation,
59
+ icqqOutboundTarget,
60
+ type IcqqInboundMessage,
61
+ type ResolvedIcqqConfig,
62
+ } from './protocol.js';
63
+ import type { SystemMessage as IcqqSystemMessage } from './types.js';
64
+ import { normalizeForwardMsgResponse } from './forward-msg.js';
65
+
66
+ export interface IcqqEndpointOptions {
67
+ readonly id: CapabilityId;
68
+ readonly config: ResolvedIcqqConfig;
69
+ readonly loginAssist: LoginAssist;
70
+ }
71
+
72
+ const BOUND_EVENTS = [
73
+ 'message.private',
74
+ 'message.group',
75
+ 'message.guild',
76
+ 'request.friend',
77
+ 'request.group',
78
+ 'notice.friend',
79
+ 'notice.group',
80
+ 'system.online',
81
+ 'system.offline',
82
+ 'system.offline.network',
83
+ 'system.offline.kickoff',
84
+ 'system.login.qrcode',
85
+ 'system.login.device',
86
+ 'system.login.slider',
87
+ 'system.login.error',
88
+ 'system.login.auth',
89
+ ] as const;
90
+
91
+ class ManagedIcqqClient extends Client {
92
+ cancelReconnect(): void {
93
+ if (!this.login_timer) return;
94
+ clearTimeout(this.login_timer);
95
+ this.login_timer = null;
96
+ }
97
+ }
98
+
99
+ export class IcqqEndpoint extends Endpoint<Client> {
100
+ readonly client: ManagedIcqqClient;
101
+ readonly #logger: ReturnType<typeof getAdapterLogger>;
102
+ readonly #options: IcqqEndpointOptions;
103
+ readonly endpointName: string;
104
+ readonly #guildCatalog = new IcqqGuildCatalog();
105
+ readonly #inboundDeduper = new InboundMessageDeduper();
106
+ readonly #sideEventDeduper = new InboundMessageDeduper();
107
+ readonly #loginAssistOwner = Object.freeze({});
108
+ #open = false;
109
+ #retiring = false;
110
+ #started = false;
111
+ #retirementEpoch = 0;
112
+ #retirementArmed = false;
113
+ #disposeNativeEvents?: () => void;
114
+ #resolveStartOnline?: () => void;
115
+ #heldInbound: IcqqInboundMessage[] = [];
116
+ readonly #inflightInbound = new Set<Promise<void>>();
117
+ readonly #inboundOwner = new AsyncLocalStorage<symbol>();
118
+ readonly #messageContent = new Map<string, ConversationMessage>();
119
+
120
+ readonly content: EndpointContentPort = Object.freeze({
121
+ resolve: async (reference: ConversationReference, context: EndpointContentResolveContext) => {
122
+ context.signal.throwIfAborted();
123
+ if (reference.kind === 'message') {
124
+ const cached = this.#messageContent.get(reference.message.id);
125
+ return cached
126
+ ? Object.freeze({ status: 'resolved' as const, reference, value: cached })
127
+ : Object.freeze({ status: 'not_found' as const, code: 'icqq_message_not_observed' });
128
+ }
129
+ if (reference.kind === 'forward') {
130
+ try {
131
+ const entries = await this.#resolveForwardEntries(
132
+ reference.forwardId,
133
+ context,
134
+ { remainingEntries: context.maxEntries, path: new Set<string>() },
135
+ 0,
136
+ );
137
+ if (entries.length === 0) return Object.freeze({ status: 'not_found' as const, code: 'icqq_forward_not_found' });
138
+ return Object.freeze({ status: 'resolved' as const, reference, value: Object.freeze(entries) });
139
+ } catch (error) {
140
+ if (context.signal.aborted) throw error;
141
+ return Object.freeze({
142
+ status: 'failed' as const,
143
+ code: 'icqq_forward_fetch_failed',
144
+ message: error instanceof Error ? error.message : String(error),
145
+ });
146
+ }
147
+ }
148
+ if (reference.media.kind === 'url' || reference.media.kind === 'base64' || reference.media.kind === 'path') {
149
+ return Object.freeze({ status: 'resolved' as const, reference, value: reference.media });
150
+ }
151
+ return Object.freeze({ status: 'unsupported' as const, code: 'icqq_media_reference_unsupported' });
152
+ },
153
+ });
154
+
155
+ async #resolveForwardEntries(
156
+ forwardId: string,
157
+ context: EndpointContentResolveContext,
158
+ state: { remainingEntries: number; readonly path: Set<string> },
159
+ depth: number,
160
+ ): Promise<readonly ForwardEntry[]> {
161
+ context.signal.throwIfAborted();
162
+ if (state.remainingEntries <= 0 || state.path.has(forwardId)) return Object.freeze([]);
163
+ state.path.add(forwardId);
164
+ try {
165
+ // ICQQ does not expose a cancellable getForwardMsg API. Keep the caller's
166
+ // generation lease until the native operation actually settles, then honor
167
+ // cancellation before publishing any resolved content.
168
+ const raw = await this.client.getForwardMsg(forwardId);
169
+ context.signal.throwIfAborted();
170
+ const entries = normalizeForwardMsgResponse(raw).slice(0, state.remainingEntries);
171
+ state.remainingEntries -= entries.length;
172
+ const expanded: ForwardEntry[] = [];
173
+ for (const entry of entries) {
174
+ context.signal.throwIfAborted();
175
+ const segments: Segment[] = [];
176
+ for (const segment of entry.segments) {
177
+ if (segment.type !== 'forward' || depth >= context.maxDepth) {
178
+ segments.push(segment);
179
+ continue;
180
+ }
181
+ const nestedId = String((segment.data as { forward_id?: unknown }).forward_id ?? '').trim();
182
+ if (!nestedId || state.path.has(nestedId) || state.remainingEntries <= 0) {
183
+ segments.push(segment);
184
+ continue;
185
+ }
186
+ const nested = await this.#resolveForwardEntries(nestedId, context, state, depth + 1);
187
+ segments.push(Object.freeze({
188
+ ...segment,
189
+ data: Object.freeze({ ...segment.data, ...(nested.length > 0 ? { entries: nested } : {}) }),
190
+ }));
191
+ }
192
+ expanded.push(Object.freeze({ ...entry, segments: Object.freeze(segments) }));
193
+ }
194
+ return Object.freeze(expanded);
195
+ } finally {
196
+ state.path.delete(forwardId);
197
+ }
198
+ }
199
+
200
+ readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
201
+ listFriends: async () =>
202
+ Array.from(this.client.fl.values()).map((f) => ({
203
+ user_id: f.user_id,
204
+ nickname: f.nickname,
205
+ remark: f.remark ?? '',
206
+ })),
207
+ listGroups: async () =>
208
+ Array.from(this.client.gl.values()).map((g) => ({
209
+ group_id: g.group_id,
210
+ name: g.group_name,
211
+ })),
212
+ listChannels: async () => {
213
+ await this.#guildCatalog.syncAll(this.client);
214
+ return this.#guildCatalog.getGuildChannelList();
215
+ },
216
+ listGroupMembers: async (groupId) =>
217
+ Array.from((await this.client.getGroupMemberList(Number(groupId))).values()),
218
+ listRequests: () => this.#listPendingRequests(),
219
+ approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
220
+ rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
221
+ kickGroupMember: async (groupId, userId) => {
222
+ await this.client.setGroupKick(Number(groupId), Number(userId));
223
+ },
224
+ muteGroupMember: async (groupId, userId, duration) => {
225
+ await this.client.setGroupBan(Number(groupId), Number(userId), duration);
226
+ },
227
+ setGroupAdmin: async (groupId, userId, enabled) => {
228
+ await this.client.setGroupAdmin(Number(groupId), Number(userId), enabled);
229
+ },
230
+ deleteFriend: async (userId) => {
231
+ await this.client.deleteFriend(Number(userId));
232
+ },
233
+ });
234
+
235
+ readonly control: EndpointControl = Object.freeze({
236
+ recall: async (message: MessageRef) => {
237
+ if (!message.id || message.id.startsWith('outbound:')) return;
238
+ await this.client.deleteMsg(message.id);
239
+ },
240
+ addReaction: async (message: MessageRef, emoji: string) => {
241
+ const target = resolveIcqqGroupReactionTarget(message);
242
+ if (!target) return null;
243
+ try {
244
+ // Do not await protocol ACK — packet timeout must not stall the AI reply.
245
+ this.#ignoreReactionAck(
246
+ 'add',
247
+ this.client.pickGroup(target.groupId).setReaction(target.seq, emoji),
248
+ );
249
+ } catch (error) {
250
+ this.#logger.debug(formatCompact({
251
+ op: 'reaction_add_failed',
252
+ error: error instanceof Error ? error.message : String(error),
253
+ }));
254
+ }
255
+ return emoji;
256
+ },
257
+ removeReaction: async (message: MessageRef, reactionId: string) => {
258
+ const target = resolveIcqqGroupReactionTarget(message);
259
+ if (!target) return;
260
+ try {
261
+ this.#ignoreReactionAck(
262
+ 'remove',
263
+ this.client.pickGroup(target.groupId).delReaction(target.seq, reactionId),
264
+ );
265
+ } catch (error) {
266
+ this.#logger.debug(formatCompact({
267
+ op: 'reaction_remove_failed',
268
+ error: error instanceof Error ? error.message : String(error),
269
+ }));
270
+ }
271
+ },
272
+ });
273
+
274
+ constructor(options: IcqqEndpointOptions) {
275
+ super();
276
+ const nativeConfig = resolveNativeClientConfig(options.config);
277
+ this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig);
278
+ this.#logger = getAdapterLogger('icqq', options.config.id);
279
+ this.#options = options;
280
+ this.endpointName = options.config.id;
281
+ }
282
+
283
+ #ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
284
+ void Promise.resolve(task).catch((error) => {
285
+ this.#logger.debug(formatCompact({
286
+ op: `reaction_${action}_failed`,
287
+ error: error instanceof Error ? error.message : String(error),
288
+ }));
289
+ });
290
+ }
291
+
292
+ async start(signal: AbortSignal): Promise<void> {
293
+ if (this.#started) return;
294
+ signal.throwIfAborted();
295
+ // Invalidate a guard left by a prior lifecycle epoch before admitting a
296
+ // new login attempt on this endpoint instance.
297
+ this.#retirementEpoch += 1;
298
+ this.#retirementArmed = false;
299
+ this.#retiring = false;
300
+ this.client.off('system.online');
301
+ this.#started = true;
302
+ let resolveOnline!: () => void;
303
+ const onlineReady = new Promise<void>((resolve) => {
304
+ resolveOnline = resolve;
305
+ });
306
+ const onAbort = () => resolveOnline();
307
+ this.#resolveStartOnline = resolveOnline;
308
+ signal.addEventListener('abort', onAbort, { once: true });
309
+ try {
310
+ this.#bindClientEvents();
311
+ await raceAbort(Promise.resolve(this.client.login(this.#options.config.password)), signal);
312
+ await onlineReady;
313
+ signal.throwIfAborted();
314
+ await this.#pullPendingSystemMessages(signal);
315
+ signal.throwIfAborted();
316
+ this.#logger.info(
317
+ `connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
318
+ );
319
+ } catch (error) {
320
+ await this.stop();
321
+ this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
322
+ throw error;
323
+ } finally {
324
+ signal.removeEventListener('abort', onAbort);
325
+ if (this.#resolveStartOnline === resolveOnline) this.#resolveStartOnline = undefined;
326
+ }
327
+ }
328
+
329
+ open(): void {
330
+ if (this.#retirementArmed) {
331
+ this.#retirementEpoch += 1;
332
+ this.#retirementArmed = false;
333
+ this.client.off('system.online');
334
+ this.#bindSystemOnlineEvent();
335
+ }
336
+ this.#retiring = false;
337
+ this.#open = true;
338
+ const held = this.#heldInbound.splice(0);
339
+ for (const msg of held) {
340
+ void this.admit(msg).catch((error) => {
341
+ this.#logInboundHandlerFailure('held', error);
342
+ });
343
+ }
344
+ }
345
+
346
+ async close(): Promise<void> {
347
+ this.#assertExternalLifecycle('close');
348
+ this.#armRetirementFence();
349
+ await Promise.allSettled([...this.#inflightInbound]);
350
+ }
351
+
352
+ async stop(): Promise<void> {
353
+ this.#assertExternalLifecycle('stop');
354
+ this.#armRetirementFence();
355
+ await Promise.allSettled([...this.#inflightInbound]);
356
+ this.#resolveStartOnline?.();
357
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'endpoint_stopped');
358
+ this.#heldInbound.length = 0;
359
+ this.#disposeNativeEvents?.();
360
+ this.#disposeNativeEvents = undefined;
361
+ for (const event of BOUND_EVENTS) {
362
+ // The retirement guard owns this matcher until a late login handshake
363
+ // has finished. start()/open() replace it with the normal listener.
364
+ if (event !== 'system.online') this.client.off(event);
365
+ }
366
+ // `Client.logout()` sends an account-level unregister packet before it
367
+ // closes this transport. During config HMR the replacement endpoint for
368
+ // the same UIN is already online, so unregistering the retiring generation
369
+ // also closes the replacement session. Endpoint ownership is transport
370
+ // scoped: retire only this Client's TCP connection.
371
+ try {
372
+ this.client.terminate();
373
+ } catch { /* ignore */ }
374
+ this.#inboundDeduper.clear();
375
+ this.#sideEventDeduper.clear();
376
+ this.#guildCatalog.clear();
377
+ this.#started = false;
378
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
379
+ }
380
+
381
+ #armRetirementFence(): void {
382
+ this.#retiring = true;
383
+ this.#open = false;
384
+ if (this.#retirementArmed) return;
385
+ this.#retirementArmed = true;
386
+ const retirementEpoch = ++this.#retirementEpoch;
387
+ // ICQQ owns reconnect through this per-Client timer. Clear it before any
388
+ // asynchronous drain so a queued callback cannot enter while close waits.
389
+ this.client.cancelReconnect();
390
+ // A login callback may already be inside the SDK handshake, which has no
391
+ // abort API. Replace the normal online listener with an epoch-owned guard
392
+ // before draining inbound work. Transport termination is account-local;
393
+ // logout would also unregister an HMR replacement using the same UIN.
394
+ this.client.off('system.online');
395
+ this.client.on('system.online', () => {
396
+ if (this.#retirementEpoch !== retirementEpoch || !this.#retiring) return;
397
+ try {
398
+ this.client.terminate();
399
+ } catch { /* already closed */ }
400
+ });
401
+ }
402
+
403
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
404
+ if (!this.#started) throw new Error('icqq endpoint 未连接');
405
+ const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
406
+ const prepared = isIcqqSegmentPayload(payload)
407
+ ? prepareIcqqOutboundMedia(payload, mediaMode)
408
+ : { content: payload, dispose() {} };
409
+ try {
410
+ const message = formatOutboundBody(prepared.content);
411
+ const preview = formatIcqqSendablePreview(message);
412
+ const parsed = icqqOutboundTarget(conversation);
413
+ let result: { message_id?: unknown };
414
+ try {
415
+ result = await this.#sendNative(parsed, message);
416
+ } catch (error) {
417
+ this.#logger.warn(formatCompact({
418
+ op: 'icqq_send_failed',
419
+ target: `${conversation.kind}:${conversation.id}`,
420
+ error: describeUnknownError(error),
421
+ preview: truncatePreview(preview, 120),
422
+ }));
423
+ throw error;
424
+ }
425
+ const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
426
+ this.#logger.info(
427
+ `send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(preview, 80)}`,
428
+ );
429
+ return messageId;
430
+ } finally {
431
+ prepared.dispose();
432
+ }
433
+ }
434
+
435
+ async #sendNative(
436
+ target: ReturnType<typeof icqqOutboundTarget>,
437
+ message: Sendable,
438
+ ): Promise<{ message_id?: unknown }> {
439
+ if (isIcqqFileElement(message)) {
440
+ switch (target.kind) {
441
+ case 'private': {
442
+ const id = await this.client.pickFriend(target.userId).sendFile(message.file, message.name);
443
+ return { message_id: id };
444
+ }
445
+ case 'group': {
446
+ const stat = await this.client.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
447
+ return { message_id: stat.fid };
448
+ }
449
+ case 'temp':
450
+ case 'channel':
451
+ throw new TypeError(`ICQQ ${target.kind} conversation does not support file delivery`);
452
+ }
453
+ }
454
+ switch (target.kind) {
455
+ case 'private':
456
+ return this.client.sendPrivateMsg(target.userId, message);
457
+ case 'group':
458
+ return this.client.sendGroupMsg(target.groupId, message);
459
+ case 'temp':
460
+ return this.client.sendTempMsg(target.groupId, target.userId, message);
461
+ case 'channel':
462
+ return this.client.sendGuildMsg(target.guildId, target.channelId, message) as unknown as { message_id?: unknown };
463
+ }
464
+ }
465
+
466
+ admit(msg: IcqqInboundMessage): Promise<void> {
467
+ return this.#trackInbound(() => this.#deliverInbound(msg));
468
+ }
469
+
470
+ #trackInbound(run: () => Promise<void>): Promise<void> {
471
+ const owner = Symbol('icqq-inbound');
472
+ let settle!: () => void;
473
+ const settlement = new Promise<void>((resolve) => { settle = resolve; });
474
+ this.#inflightInbound.add(settlement);
475
+ let operation: Promise<void>;
476
+ try {
477
+ operation = this.#inboundOwner.run(owner, run);
478
+ } catch (error) {
479
+ this.#inflightInbound.delete(settlement);
480
+ settle();
481
+ return Promise.reject(error);
482
+ }
483
+ return operation.finally(() => {
484
+ this.#inflightInbound.delete(settlement);
485
+ settle();
486
+ });
487
+ }
488
+
489
+ #assertExternalLifecycle(operation: 'close' | 'stop'): void {
490
+ if (!this.#inboundOwner.getStore()) return;
491
+ throw new Error(
492
+ `ICQQ endpoint ${operation} cannot run inside its own inbound receive; invoke it from the owning lifecycle`,
493
+ );
494
+ }
495
+
496
+ async #deliverInbound(msg: IcqqInboundMessage, admittedBeforeRetire = false): Promise<void> {
497
+ const conversation = msg.conversation;
498
+ if (!this.#open && !admittedBeforeRetire) {
499
+ if (this.#retiring) return;
500
+ if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
501
+ const dropped = this.#heldInbound.shift();
502
+ this.#logger.warn(formatCompact({
503
+ op: 'icqq_inbound_hold_drop',
504
+ endpoint: this.endpointName,
505
+ id: dropped?.id,
506
+ target: dropped
507
+ ? `${dropped.conversation.kind}:${dropped.conversation.id}`
508
+ : undefined,
509
+ limit: INBOUND_HOLD_LIMIT,
510
+ }));
511
+ }
512
+ this.#heldInbound.push(msg);
513
+ this.#logger.info(
514
+ `hold ${conversation.kind}:${conversation.id}`
515
+ + (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
516
+ + ` until open | ${truncatePreview(msg.content, 80)}`,
517
+ );
518
+ return;
519
+ }
520
+ const mentioned = isIcqqBotMentioned({
521
+ uin: this.endpointName,
522
+ content: msg.segments,
523
+ rawMessage: msg.content,
524
+ });
525
+ this.#logger.info(
526
+ `recv ${conversation.kind}:${conversation.id}`
527
+ + (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
528
+ + (mentioned ? ' (mentioned)' : '')
529
+ + ` | ${truncatePreview(msg.content, 80)}`,
530
+ );
531
+ await this.emit('message.receive', {
532
+ conversation,
533
+ message: { conversation, id: msg.id },
534
+ content: msg.content,
535
+ ...(msg.segments ? { segments: msg.segments } : {}),
536
+ sender: msg.sender,
537
+ endpointId: this.endpointName,
538
+ ...(mentioned ? { mentioned: true } : {}),
539
+ ...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
540
+ metadata: Object.freeze({
541
+ channelType: msg.channelType,
542
+ ...(msg.metadata ?? {}),
543
+ }),
544
+ }).catch((err) => {
545
+ this.#logger.warn(formatCompact({
546
+ op: 'recv',
547
+ endpoint: this.endpointName,
548
+ target: `${conversation.kind}:${conversation.id}`,
549
+ ok: false,
550
+ error: err instanceof Error ? err.message : String(err),
551
+ }));
552
+ });
553
+ }
554
+
555
+ #bindClientEvents(): void {
556
+ this.#bindNativeEvents();
557
+ const safe = (label: string, fn: () => void): void => {
558
+ try {
559
+ fn();
560
+ } catch (error) {
561
+ this.#logger.warn(formatCompact({
562
+ op: 'icqq_event_handler',
563
+ endpoint: this.endpointName,
564
+ event: label,
565
+ error: error instanceof Error ? error.message : String(error),
566
+ }));
567
+ }
568
+ };
569
+ this.client.on('message.private', (event) => {
570
+ this.#startMessageEvent('message.private', event);
571
+ });
572
+ this.client.on('message.group', (event) => {
573
+ this.#startMessageEvent('message.group', event);
574
+ });
575
+ this.client.on('message.guild', (event) => {
576
+ void this.#handleGuildEvent(event).catch((error) => {
577
+ this.#logInboundHandlerFailure('message.guild', error);
578
+ });
579
+ });
580
+ this.client.on('request.friend', (event) => safe('request.friend', () => {
581
+ this.#onRequestEvent(serializeIcqqEvent(event));
582
+ }));
583
+ this.client.on('request.group', (event) => safe('request.group', () => {
584
+ this.#onRequestEvent(serializeIcqqEvent(event));
585
+ }));
586
+ this.client.on('notice.friend', (event) => safe('notice.friend', () => {
587
+ this.#onNoticeEvent(serializeIcqqEvent(event));
588
+ }));
589
+ this.client.on('notice.group', (event) => safe('notice.group', () => {
590
+ this.#onNoticeEvent(serializeIcqqEvent(event));
591
+ }));
592
+ this.#bindSystemOnlineEvent();
593
+ this.client.on('system.offline', (event) => {
594
+ this.#logSystemEvent('offline', event);
595
+ });
596
+ this.client.on('system.offline.network', (event) => {
597
+ this.#logSystemEvent('offline.network', event);
598
+ });
599
+ this.client.on('system.offline.kickoff', (event) => {
600
+ this.#logSystemEvent('offline.kickoff', event);
601
+ });
602
+ this.client.on('system.login.qrcode', (event) => {
603
+ this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
604
+ this.#handleLoginChallenge('qrcode', event);
605
+ });
606
+ this.client.on('system.login.device', (event) => {
607
+ this.#handleLoginChallenge('device', event);
608
+ });
609
+ this.client.on('system.login.slider', (event) => {
610
+ this.#handleLoginChallenge('slider', event);
611
+ });
612
+ this.client.on('system.login.error', (event) => {
613
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
614
+ this.#logSystemEvent('login.error', event);
615
+ });
616
+ this.client.on('system.login.auth', (event) => {
617
+ this.#handleLoginChallenge('auth', event);
618
+ });
619
+ }
620
+
621
+ #bindSystemOnlineEvent(): void {
622
+ this.client.on('system.online', (event) => {
623
+ if (this.#retiring) return;
624
+ try {
625
+ this.#resolveStartOnline?.();
626
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
627
+ this.#logSystemEvent('online', event);
628
+ } catch (error) {
629
+ this.#logger.warn(formatCompact({
630
+ op: 'icqq_event_handler',
631
+ endpoint: this.endpointName,
632
+ event: 'system.online',
633
+ error: error instanceof Error ? error.message : String(error),
634
+ }));
635
+ }
636
+ });
637
+ }
638
+
639
+ /** Forward every public SDK event, including protocol extensions unknown to Zhin. */
640
+ #bindNativeEvents(): void {
641
+ this.#disposeNativeEvents?.();
642
+ let matchedName: string | undefined;
643
+ const registration = this.client.on(
644
+ (eventName: unknown) => {
645
+ matchedName = typeof eventName === 'string' && !eventName.startsWith('internal.')
646
+ ? eventName
647
+ : undefined;
648
+ return matchedName !== undefined;
649
+ },
650
+ (event: unknown) => {
651
+ const name = matchedName;
652
+ if (name) this.#emitNativeEvent(name, event);
653
+ },
654
+ );
655
+ this.#disposeNativeEvents = () => registration();
656
+ }
657
+
658
+ #emitNativeEvent(name: string, event: unknown): void {
659
+ void this.emitPlatform(name, event).catch((error) => {
660
+ this.#logger.warn(formatCompact({
661
+ op: 'icqq_platform_event_failed',
662
+ endpoint: this.endpointName,
663
+ event: name,
664
+ error: error instanceof Error ? error.message : String(error),
665
+ }));
666
+ });
667
+ }
668
+
669
+ #handleLoginChallenge(
670
+ step: 'qrcode' | 'slider' | 'device' | 'auth',
671
+ event: unknown,
672
+ ): void {
673
+ const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
674
+ ? event as Record<string, unknown>
675
+ : null);
676
+ this.#logSystemEvent(`login.${step}`, event);
677
+ void runIcqqLoginAssistStep({
678
+ assist: this.#options.loginAssist,
679
+ client: this.client,
680
+ adapter: 'icqq',
681
+ endpointKey: this.endpointName,
682
+ owner: this.#loginAssistOwner,
683
+ logger: this.#logger,
684
+ step,
685
+ event: payload ?? event,
686
+ });
687
+ }
688
+
689
+ #logSystemEvent(type: string, event: unknown): void {
690
+ const payload = serializeIcqqEvent(event);
691
+ this.#logger.warn(formatCompact({
692
+ op: `system.${type}`,
693
+ endpoint: this.endpointName,
694
+ ...(payload ? { event: JSON.stringify(payload) } : {}),
695
+ }));
696
+ this.#dispatchIcqqSystemSideEvent(type, payload);
697
+ }
698
+
699
+ #dispatchIcqqSideEvent(payload: Record<string, unknown>): void {
700
+ const postType = String(payload.post_type ?? '');
701
+ const isRequest = postType === 'request' || postType.startsWith('request.');
702
+ const flag = payload.flag != null ? String(payload.flag) : '';
703
+ void receiveOneBotLikeSideEvent((name, payload) => this.emit(name, payload), {
704
+ adapter: 'icqq',
705
+ endpointKey: this.endpointName,
706
+ platform: 'icqq',
707
+ raw: payload,
708
+ ...(isRequest ? {
709
+ approve: (id, remark) => this.#approveRequest(flag || id, remark),
710
+ reject: (id, reason) => this.#rejectRequest(flag || id, reason),
711
+ } : {}),
712
+ }).catch((err) => {
713
+ this.#logger.warn(formatCompact({
714
+ op: 'icqq_side_event_failed',
715
+ endpoint: this.endpointName,
716
+ post_type: postType || undefined,
717
+ error: err instanceof Error ? err.message : String(err),
718
+ }));
719
+ });
720
+ }
721
+
722
+ #dispatchIcqqSystemSideEvent(type: string, payload: Record<string, unknown> | null): void {
723
+ const { scene_type, sub_type } = parseIcqqSystemScene(type);
724
+ void this.emit('system.receive', SystemEvent.from(payload ?? {}, {
725
+ $id: `system:${this.endpointName}:${type}:${Date.now()}`,
726
+ $adapter: 'icqq' as never,
727
+ $endpoint: this.endpointName,
728
+ $type: 'system',
729
+ $scene_id: this.endpointName,
730
+ $scene_type: scene_type,
731
+ $sub_type: sub_type,
732
+ $timestamp: Date.now(),
733
+ })).catch((err) => {
734
+ this.#logger.warn(formatCompact({
735
+ op: 'icqq_system_side_event_failed',
736
+ endpoint: this.endpointName,
737
+ type,
738
+ error: err instanceof Error ? err.message : String(err),
739
+ }));
740
+ });
741
+ }
742
+
743
+ async #handleMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): Promise<void> {
744
+ if (this.#retiring) return;
745
+ const admittedBeforeRetire = this.#open;
746
+ const payload = serializeIcqqEvent(event);
747
+ if (!payload || !isIcqqMessagePostType(payload)) {
748
+ this.#logger.debug(formatCompact({
749
+ op: 'icqq_inbound_skip',
750
+ reason: payload ? 'not_message_post' : 'serialize_failed',
751
+ endpoint: this.endpointName,
752
+ event: eventName,
753
+ }));
754
+ return;
755
+ }
756
+ const data = payload as IcqqMessageEvent;
757
+ if (shouldSkipSelfInboundMessage(data)) {
758
+ this.#logger.debug(formatCompact({
759
+ op: 'icqq_inbound_skip',
760
+ reason: 'self',
761
+ endpoint: this.endpointName,
762
+ user_id: data.user_id,
763
+ self_id: data.self_id,
764
+ }));
765
+ return;
766
+ }
767
+ const contact = this.#resolveInboundContact(data);
768
+ if (contact) {
769
+ await resolveIcqqInboundMedia(data.message, {
770
+ getPttUrl: (element) => contact.getPttUrl(element as PttElem),
771
+ getVideoUrl: (element) => contact.getVideoUrl(element as VideoElem),
772
+ });
773
+ if (!this.#started) return;
774
+ }
775
+ const normalized = normalizeIcqqInboundMessage(data);
776
+ if (!normalized) {
777
+ this.#logger.debug(formatCompact({
778
+ op: 'icqq_inbound_skip',
779
+ reason: 'normalize_failed',
780
+ endpoint: this.endpointName,
781
+ event: eventName,
782
+ message_id: data.message_id,
783
+ }));
784
+ return;
785
+ }
786
+ if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
787
+ this.#logger.debug(formatCompact({
788
+ op: 'icqq_inbound_skip',
789
+ reason: 'dedupe',
790
+ endpoint: this.endpointName,
791
+ id: normalized.messageId,
792
+ }));
793
+ return;
794
+ }
795
+ const conversation = icqqInboundConversation(String(this.#options.id), {
796
+ channelType: normalized.channelType,
797
+ channelId: normalized.channelId,
798
+ channelParentGroupId: normalized.channelParentGroupId,
799
+ });
800
+ const quoteId = resolveIcqqQuoteIdFromEvent(data);
801
+ const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
802
+ if (quoteId && quoted) {
803
+ this.#messageContent.set(quoteId, Object.freeze({
804
+ ref: Object.freeze({ conversation, id: quoteId }),
805
+ ...(quoted.sender?.id ? { actor: Object.freeze({
806
+ id: quoted.sender.id,
807
+ ...(quoted.sender.name ? { displayName: quoted.sender.name } : {}),
808
+ }) } : {}),
809
+ segments: Object.freeze(toCanonicalSegments(
810
+ Array.isArray(quoted.content)
811
+ ? quoted.content
812
+ : [{ type: 'text', data: { text: quoted.content } }],
813
+ )),
814
+ timestamp: quoted.time ? quoted.time * (quoted.time < 1e12 ? 1000 : 1) : Date.now(),
815
+ }));
816
+ }
817
+ await this.#deliverInbound({
818
+ id: normalized.messageId,
819
+ conversation,
820
+ content: formatInboundContent(normalized.rawMessage),
821
+ segments: normalized.content,
822
+ sender: {
823
+ id: normalized.userId,
824
+ name: normalized.nickname || undefined,
825
+ ...(normalized.senderRole ? { roles: [normalized.senderRole] } : {}),
826
+ },
827
+ channelType: normalized.channelType,
828
+ ...(quoteId ? { replyTo: { id: quoteId } } : {}),
829
+ metadata: {
830
+ nickname: normalized.nickname,
831
+ senderRole: normalized.senderRole,
832
+ },
833
+ }, admittedBeforeRetire);
834
+ }
835
+
836
+ #startMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): void {
837
+ const operation = this.#trackInbound(() => this.#handleMessageEvent(eventName, event));
838
+ void operation.catch((error) => {
839
+ this.#logInboundHandlerFailure(eventName, error);
840
+ });
841
+ }
842
+
843
+ #resolveInboundContact(data: IcqqMessageEvent) {
844
+ const userId = Number(data.user_id ?? data.from_id);
845
+ if (!Number.isFinite(userId) || userId <= 0) return null;
846
+ if (data.message_type === 'group' || data.group_id != null) {
847
+ const groupId = Number(data.group_id);
848
+ return Number.isFinite(groupId) && groupId > 0 ? this.client.pickGroup(groupId) : null;
849
+ }
850
+ const sourceGroupId = Number(data.sender?.group_id);
851
+ if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
852
+ return this.client.pickMember(sourceGroupId, userId);
853
+ }
854
+ return this.client.pickFriend(userId);
855
+ }
856
+
857
+ #logInboundHandlerFailure(event: string, error: unknown): void {
858
+ this.#logger.warn(formatCompact({
859
+ op: 'icqq_inbound_handler_failed',
860
+ endpoint: this.endpointName,
861
+ event,
862
+ error: describeUnknownError(error),
863
+ }));
864
+ }
865
+
866
+ async #handleGuildEvent(event: unknown): Promise<void> {
867
+ const payload = serializeIcqqEvent(event);
868
+ if (!payload || typeof payload !== 'object') return;
869
+ const normalized = normalizeIcqqGuildInboundMessage(
870
+ payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
871
+ );
872
+ if (!normalized) {
873
+ this.#logger.debug(formatCompact({
874
+ op: 'icqq_inbound_skip',
875
+ reason: 'guild_normalize_failed',
876
+ endpoint: this.endpointName,
877
+ }));
878
+ return;
879
+ }
880
+ this.#guildCatalog.upsertFromInbound(normalized.raw);
881
+ if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
882
+ this.#logger.debug(formatCompact({
883
+ op: 'icqq_inbound_skip',
884
+ reason: 'dedupe',
885
+ endpoint: this.endpointName,
886
+ id: `guild:${normalized.messageId}`,
887
+ }));
888
+ return;
889
+ }
890
+ await this.admit({
891
+ id: normalized.messageId,
892
+ conversation: icqqInboundConversation(String(this.#options.id), {
893
+ channelType: 'channel',
894
+ channelId: normalized.channelId,
895
+ guildId: normalized.guildId,
896
+ }),
897
+ content: formatInboundContent(normalized.rawMessage),
898
+ segments: normalized.content,
899
+ sender: { id: normalized.userId, name: normalized.nickname || undefined },
900
+ channelType: 'channel',
901
+ metadata: {
902
+ guildId: normalized.guildId,
903
+ nickname: normalized.nickname,
904
+ },
905
+ });
906
+ }
907
+
908
+ #onRequestEvent(payload: Record<string, unknown> | null): void {
909
+ if (!payload || !isIcqqRequestPayload(payload)) return;
910
+ const flag = payload.flag != null ? String(payload.flag) : (payload.seq != null ? String(payload.seq) : '');
911
+ if (flag && !this.#sideEventDeduper.shouldProcess(`request:${flag}`)) return;
912
+ this.#dispatchIcqqSideEvent(payload);
913
+ }
914
+
915
+ #onNoticeEvent(payload: Record<string, unknown> | null): void {
916
+ if (!payload || !isIcqqNoticePayload(payload)) return;
917
+ const key = [
918
+ payload.time,
919
+ payload.operator_id ?? payload.user_id,
920
+ payload.notice_type ?? payload.type,
921
+ payload.sub_type,
922
+ ].join('_');
923
+ if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`)) return;
924
+ this.#dispatchIcqqSideEvent(payload);
925
+ }
926
+
927
+ async #listPendingRequests(): Promise<readonly EndpointPendingRequest[]> {
928
+ const messages = await this.client.getSystemMsg();
929
+ const rows: EndpointPendingRequest[] = [];
930
+ for (const raw of messages) {
931
+ const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
932
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
933
+ const platformRequestId = (msg.flag && String(msg.flag).trim())
934
+ || (msg.seq != null ? String(msg.seq) : '');
935
+ if (!platformRequestId || msg.user_id == null) continue;
936
+ const createdAt = Number(msg.time);
937
+ rows.push(Object.freeze({
938
+ platform_request_id: platformRequestId,
939
+ type: kind,
940
+ scene_type: kind === 'group' ? 'group' : null,
941
+ scene_id: String(kind === 'group' ? (msg.group_id ?? msg.user_id) : msg.user_id),
942
+ sub_type: kind === 'group' ? (msg.type != null ? String(msg.type) : null) : null,
943
+ actor_id: String(msg.user_id),
944
+ actor_name: msg.nickname != null ? String(msg.nickname) : null,
945
+ comment: msg.comment != null ? String(msg.comment) : null,
946
+ created_at: Number.isFinite(createdAt) && createdAt > 0
947
+ ? (createdAt < 1e12 ? createdAt * 1000 : createdAt)
948
+ : Date.now(),
949
+ }));
950
+ }
951
+ return Object.freeze(rows);
952
+ }
953
+
954
+ async #pullPendingSystemMessages(signal: AbortSignal): Promise<void> {
955
+ try {
956
+ const messages = await raceAbort(Promise.resolve(this.client.getSystemMsg()), signal);
957
+ for (const raw of messages) {
958
+ signal.throwIfAborted();
959
+ const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
960
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
961
+ const flag = msg.flag != null ? String(msg.flag) : (msg.seq != null ? String(msg.seq) : '');
962
+ if (!flag || !this.#sideEventDeduper.shouldProcess(`request:${flag}`)) continue;
963
+ this.#dispatchIcqqSideEvent({
964
+ post_type: 'request',
965
+ request_type: kind,
966
+ flag: msg.flag,
967
+ user_id: msg.user_id,
968
+ group_id: msg.group_id,
969
+ comment: msg.comment,
970
+ time: msg.time,
971
+ sub_type: msg.type,
972
+ });
973
+ }
974
+ } catch (error) {
975
+ if (signal.aborted) throw signal.reason ?? error;
976
+ this.#logger.debug(formatCompact({
977
+ op: 'side_event_pull_system_msg',
978
+ endpoint: this.endpointName,
979
+ error: error instanceof Error ? error.message : String(error),
980
+ }));
981
+ }
982
+ }
983
+
984
+ async #approveRequest(id: string, remark?: string): Promise<void> {
985
+ const messages = await this.client.getSystemMsg();
986
+ const target = messages.find((m) => {
987
+ const msg = m as unknown as IcqqSystemMessage;
988
+ return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
989
+ }) as unknown as IcqqSystemMessage | undefined;
990
+ if (!target?.flag) {
991
+ throw new Error(`icqq 未找到待处理请求: ${id}`);
992
+ }
993
+ if ((target as { request_type?: string }).request_type === 'friend') {
994
+ await this.client.setFriendAddRequest(target.flag, true, remark);
995
+ } else {
996
+ await this.client.setGroupAddRequest(target.flag, true);
997
+ }
998
+ }
999
+
1000
+ async #rejectRequest(id: string, reason?: string): Promise<void> {
1001
+ const messages = await this.client.getSystemMsg();
1002
+ const target = messages.find((m) => {
1003
+ const msg = m as unknown as IcqqSystemMessage;
1004
+ return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
1005
+ }) as unknown as IcqqSystemMessage | undefined;
1006
+ if (!target?.flag) {
1007
+ throw new Error(`icqq 未找到待处理请求: ${id}`);
1008
+ }
1009
+ if ((target as { request_type?: string }).request_type === 'friend') {
1010
+ await this.client.setFriendAddRequest(target.flag, false);
1011
+ } else {
1012
+ await this.client.setGroupAddRequest(target.flag, false, reason);
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ function formatIcqqSendablePreview(message: Sendable): string {
1018
+ if (typeof message === 'string') return message;
1019
+ try {
1020
+ return JSON.stringify(message, (_key, value) => Buffer.isBuffer(value) ? '<buffer>' : value);
1021
+ } catch {
1022
+ return '[ICQQ Sendable]';
1023
+ }
1024
+ }
1025
+
1026
+ function isIcqqSegmentPayload(payload: unknown): payload is import('zhin.js').SendContent {
1027
+ if (Array.isArray(payload)) return true;
1028
+ return typeof payload === 'object'
1029
+ && payload !== null
1030
+ && typeof (payload as { type?: unknown }).type === 'string';
1031
+ }
1032
+
1033
+ function isIcqqFileElement(message: Sendable): message is FileElem {
1034
+ return !Array.isArray(message)
1035
+ && typeof message !== 'string'
1036
+ && message.type === 'file';
1037
+ }
1038
+
1039
+ async function raceAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
1040
+ signal.throwIfAborted();
1041
+ let rejectAbort!: (reason: unknown) => void;
1042
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject; });
1043
+ const onAbort = () => rejectAbort(signal.reason ?? new Error('ICQQ operation aborted'));
1044
+ signal.addEventListener('abort', onAbort, { once: true });
1045
+ try {
1046
+ return await Promise.race([operation, aborted]);
1047
+ } finally {
1048
+ signal.removeEventListener('abort', onAbort);
1049
+ }
1050
+ }
1051
+
1052
+ function describeUnknownError(error: unknown): string {
1053
+ if (error instanceof Error) return error.stack ?? error.message;
1054
+ if (error && typeof error === 'object') {
1055
+ try {
1056
+ return JSON.stringify(error);
1057
+ } catch {
1058
+ return inspect(error, { depth: 4, breakLength: 120 });
1059
+ }
1060
+ }
1061
+ return String(error);
1062
+ }
1063
+
1064
+ function resolveIcqqGroupReactionTarget(
1065
+ message: MessageRef,
1066
+ ): { groupId: number; seq: number } | null {
1067
+ if (message.conversation.kind !== 'group') return null;
1068
+ const groupId = Number(message.conversation.id);
1069
+ if (!Number.isFinite(groupId) || groupId <= 0) return null;
1070
+ const raw = message.id?.trim();
1071
+ if (!raw || raw.startsWith('outbound:')) return null;
1072
+ if (/^[1-9]\d*$/.test(raw)) return { groupId, seq: Number(raw) };
1073
+ try {
1074
+ const parsed = parseGroupMessageId(raw);
1075
+ if (parsed.seq > 0) return { groupId: parsed.group_id || groupId, seq: parsed.seq };
1076
+ } catch {
1077
+ // not a cqhttp group message id
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ function resolveNativeClientConfig(config: ResolvedIcqqConfig): NativeIcqqClientConfig {
1083
+ return {
1084
+ log_level: 'info',
1085
+ platform: config.platform,
1086
+ ...(config.ver ? { ver: config.ver } : {}),
1087
+ ...(config.dataDir ? { data_dir: config.dataDir } : {}),
1088
+ ...(config.signApiAddr ? { sign_api_addr: config.signApiAddr } : {}),
1089
+ ...(config.ignoreSelf != null ? { ignore_self: config.ignoreSelf } : {}),
1090
+ ...(config.resend != null ? { resend: config.resend } : {}),
1091
+ ...(config.cacheGroupMember != null ? { cache_group_member: config.cacheGroupMember } : {}),
1092
+ ...(config.autoServer != null ? { auto_server: config.autoServer } : {}),
1093
+ ...(config.qqnt != null ? { QQNT: config.qqnt } : {}),
1094
+ ...(config.ntLogin != null ? { NTLogin: config.ntLogin } : {}),
1095
+ reconn_interval: config.autoReconnect ? 5 : 0,
1096
+ };
1097
+ }
1098
+
1099
+ function serializeIcqqEvent(event: unknown): Record<string, unknown> | null {
1100
+ if (!event || typeof event !== 'object') return null;
1101
+ const nativeToJSON = (event as { toJSON?: (keys: string[]) => unknown }).toJSON;
1102
+ if (typeof nativeToJSON === 'function') {
1103
+ try {
1104
+ const json = nativeToJSON.call(event, [...ICQQ_SERIALIZE_SKIP]);
1105
+ if (json && typeof json === 'object' && !Array.isArray(json)) {
1106
+ return json as Record<string, unknown>;
1107
+ }
1108
+ } catch {
1109
+ /* JSON.stringify 也会无参调 toJSON,不能当回退 */
1110
+ }
1111
+ }
1112
+ const out: Record<string, unknown> = {};
1113
+ for (const [key, value] of Object.entries(event as Record<string, unknown>)) {
1114
+ if (typeof value === 'function' || (ICQQ_SERIALIZE_SKIP as readonly string[]).includes(key)) continue;
1115
+ out[key] = value;
1116
+ }
1117
+ return Object.keys(out).length > 0 ? out : null;
1118
+ }
1119
+
1120
+ function parseIcqqSystemScene(type: string): { scene_type: string; sub_type: string } {
1121
+ if (type.startsWith('login.')) {
1122
+ return { scene_type: 'login', sub_type: type.slice('login.'.length) || 'unknown' };
1123
+ }
1124
+ if (type.startsWith('offline.')) {
1125
+ return { scene_type: 'offline', sub_type: type.slice('offline.'.length) || 'unknown' };
1126
+ }
1127
+ if (type === 'online') {
1128
+ return { scene_type: 'online', sub_type: 'online' };
1129
+ }
1130
+ if (type === 'offline') {
1131
+ return { scene_type: 'offline', sub_type: 'offline' };
1132
+ }
1133
+ return { scene_type: 'system', sub_type: type };
1134
+ }
1135
+
1136
+ const INBOUND_HOLD_LIMIT = 32;
1137
+
1138
+ const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'] as const;