@zhin.js/adapter-icqq 8.0.13 → 9.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/CHANGELOG.md +58 -0
- package/README.md +8 -4
- package/adapters/icqq.js +3 -55
- package/adapters/icqq.ts +12 -62
- package/lib/cq-message.d.ts +5 -13
- package/lib/cq-message.js +6 -72
- package/lib/endpoint.d.ts +9 -10
- package/lib/endpoint.js +429 -141
- package/lib/forward-msg.d.ts +3 -22
- package/lib/forward-msg.js +38 -307
- package/lib/icqq-inbound.d.ts +29 -3
- package/lib/icqq-inbound.js +95 -12
- package/lib/icqq-login-assist.d.ts +27 -0
- package/lib/icqq-login-assist.js +109 -0
- package/lib/index.d.ts +1 -1
- package/lib/outbound-media.d.ts +6 -2
- package/lib/outbound-media.js +15 -8
- package/lib/protocol.d.ts +10 -1
- package/lib/protocol.js +82 -20
- package/package.json +13 -13
- package/src/cq-message.ts +7 -74
- package/src/endpoint.ts +463 -153
- package/src/forward-msg.ts +35 -342
- package/src/icqq-inbound.ts +112 -13
- package/src/icqq-login-assist.ts +132 -0
- package/src/index.ts +0 -1
- package/src/outbound-media.ts +20 -5
- package/src/protocol.ts +82 -22
- package/tools/announce.js +1 -1
- package/tools/announce.ts +1 -1
- package/tools/essence.js +1 -1
- package/tools/essence.ts +1 -1
- package/tools/friend_list.js +5 -4
- package/tools/friend_list.ts +7 -5
- package/tools/group_list.js +6 -5
- package/tools/group_list.ts +8 -6
- package/tools/group_member_info.js +27 -0
- package/tools/group_member_info.ts +30 -0
- package/tools/set_mute.js +1 -1
- package/tools/set_mute.ts +1 -1
- package/tools/set_title.js +1 -1
- package/tools/set_title.ts +1 -1
package/lib/endpoint.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Client, parseGroupMessageId, } from '@icqqjs/icqq';
|
|
2
2
|
import { inspect } from 'node:util';
|
|
3
|
+
import { receiveOneBotLikeSideEvent, SystemEvent, toCanonicalSegments } from '@zhin.js/core';
|
|
3
4
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
4
5
|
import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
|
|
7
|
+
import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
|
|
8
|
+
import { isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
7
9
|
import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
|
|
8
|
-
import {
|
|
10
|
+
import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
|
|
9
11
|
import { formatInboundContent, formatOutboundBody, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
12
|
+
import { normalizeForwardMsgResponse } from './forward-msg.js';
|
|
10
13
|
const BOUND_EVENTS = [
|
|
11
14
|
'message.private',
|
|
12
15
|
'message.group',
|
|
@@ -31,11 +34,89 @@ export class IcqqEndpoint extends Client {
|
|
|
31
34
|
endpointName;
|
|
32
35
|
#guildCatalog = new IcqqGuildCatalog();
|
|
33
36
|
#inboundDeduper = new InboundMessageDeduper();
|
|
34
|
-
#
|
|
37
|
+
#sideEventDeduper = new InboundMessageDeduper();
|
|
38
|
+
#loginAssistOwner = Object.freeze({});
|
|
35
39
|
#open = false;
|
|
40
|
+
#retiring = false;
|
|
36
41
|
#started = false;
|
|
42
|
+
#resolveStartOnline;
|
|
37
43
|
#heldInbound = [];
|
|
38
44
|
#unregisterAgent;
|
|
45
|
+
#inflightInbound = new Set();
|
|
46
|
+
#messageContent = new Map();
|
|
47
|
+
content = Object.freeze({
|
|
48
|
+
resolve: async (reference, context) => {
|
|
49
|
+
context.signal.throwIfAborted();
|
|
50
|
+
if (reference.kind === 'message') {
|
|
51
|
+
const cached = this.#messageContent.get(reference.message.id);
|
|
52
|
+
return cached
|
|
53
|
+
? Object.freeze({ status: 'resolved', reference, value: cached })
|
|
54
|
+
: Object.freeze({ status: 'not_found', code: 'icqq_message_not_observed' });
|
|
55
|
+
}
|
|
56
|
+
if (reference.kind === 'forward') {
|
|
57
|
+
try {
|
|
58
|
+
const entries = await this.#resolveForwardEntries(reference.forwardId, context, { remainingEntries: context.maxEntries, path: new Set() }, 0);
|
|
59
|
+
if (entries.length === 0)
|
|
60
|
+
return Object.freeze({ status: 'not_found', code: 'icqq_forward_not_found' });
|
|
61
|
+
return Object.freeze({ status: 'resolved', reference, value: Object.freeze(entries) });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (context.signal.aborted)
|
|
65
|
+
throw error;
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
status: 'failed',
|
|
68
|
+
code: 'icqq_forward_fetch_failed',
|
|
69
|
+
message: error instanceof Error ? error.message : String(error),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (reference.media.kind === 'url' || reference.media.kind === 'base64' || reference.media.kind === 'path') {
|
|
74
|
+
return Object.freeze({ status: 'resolved', reference, value: reference.media });
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze({ status: 'unsupported', code: 'icqq_media_reference_unsupported' });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
async #resolveForwardEntries(forwardId, context, state, depth) {
|
|
80
|
+
context.signal.throwIfAborted();
|
|
81
|
+
if (state.remainingEntries <= 0 || state.path.has(forwardId))
|
|
82
|
+
return Object.freeze([]);
|
|
83
|
+
state.path.add(forwardId);
|
|
84
|
+
try {
|
|
85
|
+
// ICQQ does not expose a cancellable getForwardMsg API. Keep the caller's
|
|
86
|
+
// generation lease until the native operation actually settles, then honor
|
|
87
|
+
// cancellation before publishing any resolved content.
|
|
88
|
+
const raw = await this.getForwardMsg(forwardId);
|
|
89
|
+
context.signal.throwIfAborted();
|
|
90
|
+
const entries = normalizeForwardMsgResponse(raw).slice(0, state.remainingEntries);
|
|
91
|
+
state.remainingEntries -= entries.length;
|
|
92
|
+
const expanded = [];
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
context.signal.throwIfAborted();
|
|
95
|
+
const segments = [];
|
|
96
|
+
for (const segment of entry.segments) {
|
|
97
|
+
if (segment.type !== 'forward' || depth >= context.maxDepth) {
|
|
98
|
+
segments.push(segment);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const nestedId = String(segment.data.forward_id ?? '').trim();
|
|
102
|
+
if (!nestedId || state.path.has(nestedId) || state.remainingEntries <= 0) {
|
|
103
|
+
segments.push(segment);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const nested = await this.#resolveForwardEntries(nestedId, context, state, depth + 1);
|
|
107
|
+
segments.push(Object.freeze({
|
|
108
|
+
...segment,
|
|
109
|
+
data: Object.freeze({ ...segment.data, ...(nested.length > 0 ? { entries: nested } : {}) }),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
expanded.push(Object.freeze({ ...entry, segments: Object.freeze(segments) }));
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze(expanded);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
state.path.delete(forwardId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
39
120
|
management = Object.freeze({
|
|
40
121
|
listFriends: async () => Array.from(this.fl.values()).map((f) => ({
|
|
41
122
|
user_id: f.user_id,
|
|
@@ -51,6 +132,7 @@ export class IcqqEndpoint extends Client {
|
|
|
51
132
|
return this.#guildCatalog.getGuildChannelList();
|
|
52
133
|
},
|
|
53
134
|
listGroupMembers: async (groupId) => Array.from((await this.getGroupMemberList(Number(groupId))).values()),
|
|
135
|
+
listRequests: () => this.#listPendingRequests(),
|
|
54
136
|
approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
|
|
55
137
|
rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
|
|
56
138
|
kickGroupMember: async (groupId, userId) => {
|
|
@@ -118,18 +200,25 @@ export class IcqqEndpoint extends Client {
|
|
|
118
200
|
}));
|
|
119
201
|
});
|
|
120
202
|
}
|
|
121
|
-
async start() {
|
|
203
|
+
async start(signal) {
|
|
122
204
|
if (this.#started)
|
|
123
205
|
return;
|
|
206
|
+
signal.throwIfAborted();
|
|
124
207
|
this.#started = true;
|
|
208
|
+
let resolveOnline;
|
|
209
|
+
const onlineReady = new Promise((resolve) => {
|
|
210
|
+
resolveOnline = resolve;
|
|
211
|
+
});
|
|
212
|
+
const onAbort = () => resolveOnline();
|
|
213
|
+
this.#resolveStartOnline = resolveOnline;
|
|
214
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
125
215
|
try {
|
|
126
|
-
const onlineReady = new Promise((resolve) => {
|
|
127
|
-
this.on('system.online', () => resolve());
|
|
128
|
-
});
|
|
129
216
|
this.#bindClientEvents();
|
|
130
|
-
await this.login(this.#options.config.password);
|
|
217
|
+
await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
|
|
131
218
|
await onlineReady;
|
|
132
|
-
|
|
219
|
+
signal.throwIfAborted();
|
|
220
|
+
await this.#pullPendingSystemMessages(signal);
|
|
221
|
+
signal.throwIfAborted();
|
|
133
222
|
this.#unregisterAgent = registerIcqqAgentEndpoint(this.endpointName, this);
|
|
134
223
|
this.#logger.info(`connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`);
|
|
135
224
|
}
|
|
@@ -138,18 +227,30 @@ export class IcqqEndpoint extends Client {
|
|
|
138
227
|
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
139
228
|
throw error;
|
|
140
229
|
}
|
|
230
|
+
finally {
|
|
231
|
+
signal.removeEventListener('abort', onAbort);
|
|
232
|
+
if (this.#resolveStartOnline === resolveOnline)
|
|
233
|
+
this.#resolveStartOnline = undefined;
|
|
234
|
+
}
|
|
141
235
|
}
|
|
142
236
|
open() {
|
|
237
|
+
this.#retiring = false;
|
|
143
238
|
this.#open = true;
|
|
144
239
|
const held = this.#heldInbound.splice(0);
|
|
145
240
|
for (const msg of held)
|
|
146
241
|
this.admit(msg);
|
|
147
242
|
}
|
|
148
|
-
close() {
|
|
243
|
+
async close() {
|
|
244
|
+
this.#retiring = true;
|
|
149
245
|
this.#open = false;
|
|
246
|
+
await Promise.allSettled([...this.#inflightInbound]);
|
|
150
247
|
}
|
|
151
248
|
async stop() {
|
|
249
|
+
this.#retiring = true;
|
|
152
250
|
this.#open = false;
|
|
251
|
+
await Promise.allSettled([...this.#inflightInbound]);
|
|
252
|
+
this.#resolveStartOnline?.();
|
|
253
|
+
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'endpoint_stopped');
|
|
153
254
|
this.#heldInbound.length = 0;
|
|
154
255
|
this.#unregisterAgent?.();
|
|
155
256
|
this.#unregisterAgent = undefined;
|
|
@@ -164,7 +265,7 @@ export class IcqqEndpoint extends Client {
|
|
|
164
265
|
}
|
|
165
266
|
catch { /* ignore */ }
|
|
166
267
|
this.#inboundDeduper.clear();
|
|
167
|
-
this.#
|
|
268
|
+
this.#sideEventDeduper.clear();
|
|
168
269
|
this.#guildCatalog.clear();
|
|
169
270
|
this.#started = false;
|
|
170
271
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
@@ -173,44 +274,66 @@ export class IcqqEndpoint extends Client {
|
|
|
173
274
|
if (!this.#started)
|
|
174
275
|
throw new Error('icqq endpoint 未连接');
|
|
175
276
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
176
|
-
const
|
|
177
|
-
?
|
|
178
|
-
: payload;
|
|
179
|
-
const message = formatOutboundBody(content);
|
|
180
|
-
const parsed = icqqOutboundTarget(conversation);
|
|
181
|
-
let result;
|
|
277
|
+
const prepared = isIcqqSegmentPayload(payload)
|
|
278
|
+
? prepareIcqqOutboundMedia(payload, mediaMode)
|
|
279
|
+
: { content: payload, dispose() { } };
|
|
182
280
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
281
|
+
const message = formatOutboundBody(prepared.content);
|
|
282
|
+
const preview = formatIcqqSendablePreview(message);
|
|
283
|
+
const parsed = icqqOutboundTarget(conversation);
|
|
284
|
+
let result;
|
|
285
|
+
try {
|
|
286
|
+
result = await this.#sendNative(parsed, message);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
this.#logger.warn(formatCompact({
|
|
290
|
+
op: 'icqq_send_failed',
|
|
291
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
292
|
+
error: describeUnknownError(error),
|
|
293
|
+
preview: truncatePreview(preview, 120),
|
|
294
|
+
}));
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
|
|
298
|
+
this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(preview, 80)}`);
|
|
299
|
+
return messageId;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
prepared.dispose();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async #sendNative(target, message) {
|
|
306
|
+
if (isIcqqFileElement(message)) {
|
|
307
|
+
switch (target.kind) {
|
|
308
|
+
case 'private': {
|
|
309
|
+
const id = await this.pickFriend(target.userId).sendFile(message.file, message.name);
|
|
310
|
+
return { message_id: id };
|
|
311
|
+
}
|
|
312
|
+
case 'group': {
|
|
313
|
+
const stat = await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
|
|
314
|
+
return { message_id: stat.fid };
|
|
315
|
+
}
|
|
190
316
|
case 'temp':
|
|
191
|
-
result = await this.sendTempMsg(parsed.groupId, parsed.userId, message);
|
|
192
|
-
break;
|
|
193
317
|
case 'channel':
|
|
194
|
-
|
|
195
|
-
break;
|
|
318
|
+
throw new TypeError(`ICQQ ${target.kind} conversation does not support file delivery`);
|
|
196
319
|
}
|
|
197
320
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
321
|
+
switch (target.kind) {
|
|
322
|
+
case 'private':
|
|
323
|
+
return this.sendPrivateMsg(target.userId, message);
|
|
324
|
+
case 'group':
|
|
325
|
+
return this.sendGroupMsg(target.groupId, message);
|
|
326
|
+
case 'temp':
|
|
327
|
+
return this.sendTempMsg(target.groupId, target.userId, message);
|
|
328
|
+
case 'channel':
|
|
329
|
+
return this.sendGuildMsg(target.guildId, target.channelId, message);
|
|
206
330
|
}
|
|
207
|
-
const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
|
|
208
|
-
this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`);
|
|
209
|
-
return messageId;
|
|
210
331
|
}
|
|
211
|
-
admit(msg) {
|
|
332
|
+
admit(msg, admittedBeforeRetire = false) {
|
|
212
333
|
const conversation = msg.conversation;
|
|
213
|
-
if (!this.#open) {
|
|
334
|
+
if (!this.#open && !admittedBeforeRetire) {
|
|
335
|
+
if (this.#retiring)
|
|
336
|
+
return;
|
|
214
337
|
if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
|
|
215
338
|
const dropped = this.#heldInbound.shift();
|
|
216
339
|
this.#logger.warn(formatCompact({
|
|
@@ -238,8 +361,6 @@ export class IcqqEndpoint extends Client {
|
|
|
238
361
|
+ (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
|
|
239
362
|
+ (mentioned ? ' (mentioned)' : '')
|
|
240
363
|
+ ` | ${truncatePreview(msg.content, 80)}`);
|
|
241
|
-
const quoteId = msg.metadata?.quote_id;
|
|
242
|
-
const { quote_id: _dropQuoteId, ...restMetadata } = msg.metadata ?? {};
|
|
243
364
|
void this.#options.gateway.receive({
|
|
244
365
|
conversation,
|
|
245
366
|
message: { conversation, id: msg.id },
|
|
@@ -248,10 +369,10 @@ export class IcqqEndpoint extends Client {
|
|
|
248
369
|
sender: msg.sender,
|
|
249
370
|
endpointId: this.endpointName,
|
|
250
371
|
...(mentioned ? { mentioned: true } : {}),
|
|
251
|
-
...(
|
|
372
|
+
...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
|
|
252
373
|
metadata: Object.freeze({
|
|
253
374
|
channelType: msg.channelType,
|
|
254
|
-
...
|
|
375
|
+
...(msg.metadata ?? {}),
|
|
255
376
|
}),
|
|
256
377
|
}).catch((err) => {
|
|
257
378
|
this.#logger.warn(formatCompact({
|
|
@@ -277,23 +398,48 @@ export class IcqqEndpoint extends Client {
|
|
|
277
398
|
}));
|
|
278
399
|
}
|
|
279
400
|
};
|
|
280
|
-
this.on('message.private', (event) =>
|
|
281
|
-
this.on('message.group', (event) =>
|
|
401
|
+
this.on('message.private', (event) => this.#startMessageEvent('message.private', event));
|
|
402
|
+
this.on('message.group', (event) => this.#startMessageEvent('message.group', event));
|
|
282
403
|
this.on('message.guild', (event) => safe('message.guild', () => this.#handleGuildEvent(event)));
|
|
283
|
-
this.on('request.friend', (event) => safe('request.friend', () => this.#
|
|
284
|
-
this.on('request.group', (event) => safe('request.group', () => this.#
|
|
285
|
-
this.on('notice.friend', (event) => safe('notice.friend', () => this.#
|
|
286
|
-
this.on('notice.group', (event) => safe('notice.group', () => this.#
|
|
404
|
+
this.on('request.friend', (event) => safe('request.friend', () => this.#onRequestEvent(serializeIcqqEvent(event))));
|
|
405
|
+
this.on('request.group', (event) => safe('request.group', () => this.#onRequestEvent(serializeIcqqEvent(event))));
|
|
406
|
+
this.on('notice.friend', (event) => safe('notice.friend', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
|
|
407
|
+
this.on('notice.group', (event) => safe('notice.group', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
|
|
408
|
+
this.on('system.online', (event) => safe('system.online', () => {
|
|
409
|
+
this.#resolveStartOnline?.();
|
|
410
|
+
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
411
|
+
this.#logSystemEvent('online', event);
|
|
412
|
+
}));
|
|
287
413
|
this.on('system.offline', (event) => this.#logSystemEvent('offline', event));
|
|
288
414
|
this.on('system.offline.network', (event) => this.#logSystemEvent('offline.network', event));
|
|
289
415
|
this.on('system.offline.kickoff', (event) => this.#logSystemEvent('offline.kickoff', event));
|
|
290
|
-
this.on('system.login.qrcode', () => {
|
|
291
|
-
this.#logger.info('icqq
|
|
416
|
+
this.on('system.login.qrcode', (event) => {
|
|
417
|
+
this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
|
|
418
|
+
this.#handleLoginChallenge('qrcode', event);
|
|
419
|
+
});
|
|
420
|
+
this.on('system.login.device', (event) => this.#handleLoginChallenge('device', event));
|
|
421
|
+
this.on('system.login.slider', (event) => this.#handleLoginChallenge('slider', event));
|
|
422
|
+
this.on('system.login.error', (event) => {
|
|
423
|
+
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
|
|
424
|
+
this.#logSystemEvent('login.error', event);
|
|
425
|
+
});
|
|
426
|
+
this.on('system.login.auth', (event) => this.#handleLoginChallenge('auth', event));
|
|
427
|
+
}
|
|
428
|
+
#handleLoginChallenge(step, event) {
|
|
429
|
+
const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
|
|
430
|
+
? event
|
|
431
|
+
: null);
|
|
432
|
+
this.#logSystemEvent(`login.${step}`, event);
|
|
433
|
+
void runIcqqLoginAssistStep({
|
|
434
|
+
assist: this.#options.loginAssist,
|
|
435
|
+
client: this,
|
|
436
|
+
adapter: 'icqq',
|
|
437
|
+
endpointKey: this.endpointName,
|
|
438
|
+
owner: this.#loginAssistOwner,
|
|
439
|
+
logger: this.#logger,
|
|
440
|
+
step,
|
|
441
|
+
event: payload ?? event,
|
|
292
442
|
});
|
|
293
|
-
this.on('system.login.device', (event) => this.#logSystemEvent('login.device', event));
|
|
294
|
-
this.on('system.login.slider', (event) => this.#logSystemEvent('login.slider', event));
|
|
295
|
-
this.on('system.login.error', (event) => this.#logSystemEvent('login.error', event));
|
|
296
|
-
this.on('system.login.auth', (event) => this.#logSystemEvent('login.auth', event));
|
|
297
443
|
}
|
|
298
444
|
#logSystemEvent(type, event) {
|
|
299
445
|
const payload = serializeIcqqEvent(event);
|
|
@@ -302,8 +448,56 @@ export class IcqqEndpoint extends Client {
|
|
|
302
448
|
endpoint: this.endpointName,
|
|
303
449
|
...(payload ? { event: JSON.stringify(payload) } : {}),
|
|
304
450
|
}));
|
|
451
|
+
this.#dispatchIcqqSystemSideEvent(type, payload);
|
|
452
|
+
}
|
|
453
|
+
#dispatchIcqqSideEvent(payload) {
|
|
454
|
+
const sideEvents = this.#options.sideEvents;
|
|
455
|
+
const postType = String(payload.post_type ?? '');
|
|
456
|
+
const isRequest = postType === 'request' || postType.startsWith('request.');
|
|
457
|
+
const flag = payload.flag != null ? String(payload.flag) : '';
|
|
458
|
+
void receiveOneBotLikeSideEvent(sideEvents, {
|
|
459
|
+
adapter: 'icqq',
|
|
460
|
+
endpointKey: this.endpointName,
|
|
461
|
+
platform: 'icqq',
|
|
462
|
+
raw: payload,
|
|
463
|
+
...(isRequest ? {
|
|
464
|
+
approve: (id, remark) => this.#approveRequest(flag || id, remark),
|
|
465
|
+
reject: (id, reason) => this.#rejectRequest(flag || id, reason),
|
|
466
|
+
} : {}),
|
|
467
|
+
}).catch((err) => {
|
|
468
|
+
this.#logger.warn(formatCompact({
|
|
469
|
+
op: 'icqq_side_event_failed',
|
|
470
|
+
endpoint: this.endpointName,
|
|
471
|
+
post_type: postType || undefined,
|
|
472
|
+
error: err instanceof Error ? err.message : String(err),
|
|
473
|
+
}));
|
|
474
|
+
});
|
|
305
475
|
}
|
|
306
|
-
#
|
|
476
|
+
#dispatchIcqqSystemSideEvent(type, payload) {
|
|
477
|
+
const sideEvents = this.#options.sideEvents;
|
|
478
|
+
const { scene_type, sub_type } = parseIcqqSystemScene(type);
|
|
479
|
+
void sideEvents.receiveSystem(SystemEvent.from(payload ?? {}, {
|
|
480
|
+
$id: `system:${this.endpointName}:${type}:${Date.now()}`,
|
|
481
|
+
$adapter: 'icqq',
|
|
482
|
+
$endpoint: this.endpointName,
|
|
483
|
+
$type: 'system',
|
|
484
|
+
$scene_id: this.endpointName,
|
|
485
|
+
$scene_type: scene_type,
|
|
486
|
+
$sub_type: sub_type,
|
|
487
|
+
$timestamp: Date.now(),
|
|
488
|
+
})).catch((err) => {
|
|
489
|
+
this.#logger.warn(formatCompact({
|
|
490
|
+
op: 'icqq_system_side_event_failed',
|
|
491
|
+
endpoint: this.endpointName,
|
|
492
|
+
type,
|
|
493
|
+
error: err instanceof Error ? err.message : String(err),
|
|
494
|
+
}));
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async #handleMessageEvent(eventName, event) {
|
|
498
|
+
if (this.#retiring)
|
|
499
|
+
return;
|
|
500
|
+
const admittedBeforeRetire = this.#open;
|
|
307
501
|
const payload = serializeIcqqEvent(event);
|
|
308
502
|
if (!payload || !isIcqqMessagePostType(payload)) {
|
|
309
503
|
this.#logger.debug(formatCompact({
|
|
@@ -325,6 +519,15 @@ export class IcqqEndpoint extends Client {
|
|
|
325
519
|
}));
|
|
326
520
|
return;
|
|
327
521
|
}
|
|
522
|
+
const contact = this.#resolveInboundContact(data);
|
|
523
|
+
if (contact) {
|
|
524
|
+
await resolveIcqqInboundMedia(data.message, {
|
|
525
|
+
getPttUrl: (element) => contact.getPttUrl(element),
|
|
526
|
+
getVideoUrl: (element) => contact.getVideoUrl(element),
|
|
527
|
+
});
|
|
528
|
+
if (!this.#started)
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
328
531
|
const normalized = normalizeIcqqInboundMessage(data);
|
|
329
532
|
if (!normalized) {
|
|
330
533
|
this.#logger.debug(formatCompact({
|
|
@@ -345,13 +548,29 @@ export class IcqqEndpoint extends Client {
|
|
|
345
548
|
}));
|
|
346
549
|
return;
|
|
347
550
|
}
|
|
551
|
+
const conversation = icqqInboundConversation(String(this.#options.id), {
|
|
552
|
+
channelType: normalized.channelType,
|
|
553
|
+
channelId: normalized.channelId,
|
|
554
|
+
channelParentGroupId: normalized.channelParentGroupId,
|
|
555
|
+
});
|
|
556
|
+
const quoteId = resolveIcqqQuoteIdFromEvent(data);
|
|
557
|
+
const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
558
|
+
if (quoteId && quoted) {
|
|
559
|
+
this.#messageContent.set(quoteId, Object.freeze({
|
|
560
|
+
ref: Object.freeze({ conversation, id: quoteId }),
|
|
561
|
+
...(quoted.sender?.id ? { actor: Object.freeze({
|
|
562
|
+
id: quoted.sender.id,
|
|
563
|
+
...(quoted.sender.name ? { displayName: quoted.sender.name } : {}),
|
|
564
|
+
}) } : {}),
|
|
565
|
+
segments: Object.freeze(toCanonicalSegments(Array.isArray(quoted.content)
|
|
566
|
+
? quoted.content
|
|
567
|
+
: [{ type: 'text', data: { text: quoted.content } }])),
|
|
568
|
+
timestamp: quoted.time ? quoted.time * (quoted.time < 1e12 ? 1000 : 1) : Date.now(),
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
348
571
|
this.admit({
|
|
349
572
|
id: normalized.messageId,
|
|
350
|
-
conversation
|
|
351
|
-
channelType: normalized.channelType,
|
|
352
|
-
channelId: normalized.channelId,
|
|
353
|
-
channelParentGroupId: normalized.channelParentGroupId,
|
|
354
|
-
}),
|
|
573
|
+
conversation,
|
|
355
574
|
content: formatInboundContent(normalized.rawMessage),
|
|
356
575
|
segments: normalized.content,
|
|
357
576
|
sender: {
|
|
@@ -360,12 +579,44 @@ export class IcqqEndpoint extends Client {
|
|
|
360
579
|
...(normalized.senderRole ? { roles: [normalized.senderRole] } : {}),
|
|
361
580
|
},
|
|
362
581
|
channelType: normalized.channelType,
|
|
363
|
-
|
|
582
|
+
...(quoteId ? { replyTo: { id: quoteId } } : {}),
|
|
583
|
+
metadata: {
|
|
364
584
|
nickname: normalized.nickname,
|
|
365
585
|
senderRole: normalized.senderRole,
|
|
366
|
-
}
|
|
586
|
+
},
|
|
587
|
+
}, admittedBeforeRetire);
|
|
588
|
+
}
|
|
589
|
+
#startMessageEvent(eventName, event) {
|
|
590
|
+
const operation = this.#handleMessageEvent(eventName, event);
|
|
591
|
+
this.#inflightInbound.add(operation);
|
|
592
|
+
void operation.catch((error) => {
|
|
593
|
+
this.#logInboundHandlerFailure(eventName, error);
|
|
594
|
+
}).finally(() => {
|
|
595
|
+
this.#inflightInbound.delete(operation);
|
|
367
596
|
});
|
|
368
597
|
}
|
|
598
|
+
#resolveInboundContact(data) {
|
|
599
|
+
const userId = Number(data.user_id ?? data.from_id);
|
|
600
|
+
if (!Number.isFinite(userId) || userId <= 0)
|
|
601
|
+
return null;
|
|
602
|
+
if (data.message_type === 'group' || data.group_id != null) {
|
|
603
|
+
const groupId = Number(data.group_id);
|
|
604
|
+
return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
|
|
605
|
+
}
|
|
606
|
+
const sourceGroupId = Number(data.sender?.group_id);
|
|
607
|
+
if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
|
|
608
|
+
return this.pickMember(sourceGroupId, userId);
|
|
609
|
+
}
|
|
610
|
+
return this.pickFriend(userId);
|
|
611
|
+
}
|
|
612
|
+
#logInboundHandlerFailure(event, error) {
|
|
613
|
+
this.#logger.warn(formatCompact({
|
|
614
|
+
op: 'icqq_inbound_handler_failed',
|
|
615
|
+
endpoint: this.endpointName,
|
|
616
|
+
event,
|
|
617
|
+
error: describeUnknownError(error),
|
|
618
|
+
}));
|
|
619
|
+
}
|
|
369
620
|
#handleGuildEvent(event) {
|
|
370
621
|
const payload = serializeIcqqEvent(event);
|
|
371
622
|
if (!payload || typeof payload !== 'object')
|
|
@@ -406,71 +657,81 @@ export class IcqqEndpoint extends Client {
|
|
|
406
657
|
},
|
|
407
658
|
});
|
|
408
659
|
}
|
|
409
|
-
#
|
|
660
|
+
#onRequestEvent(payload) {
|
|
410
661
|
if (!payload || !isIcqqRequestPayload(payload))
|
|
411
662
|
return;
|
|
412
|
-
const
|
|
413
|
-
if (!
|
|
414
|
-
return;
|
|
415
|
-
const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
|
|
416
|
-
if (!row)
|
|
663
|
+
const flag = payload.flag != null ? String(payload.flag) : (payload.seq != null ? String(payload.seq) : '');
|
|
664
|
+
if (flag && !this.#sideEventDeduper.shouldProcess(`request:${flag}`))
|
|
417
665
|
return;
|
|
418
|
-
|
|
419
|
-
return;
|
|
420
|
-
void hooks.recordRequest(row);
|
|
421
|
-
hooks.publish?.('endpoint:request', row);
|
|
666
|
+
this.#dispatchIcqqSideEvent(payload);
|
|
422
667
|
}
|
|
423
|
-
#
|
|
668
|
+
#onNoticeEvent(payload) {
|
|
424
669
|
if (!payload || !isIcqqNoticePayload(payload))
|
|
425
670
|
return;
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (!this.#
|
|
671
|
+
const key = [
|
|
672
|
+
payload.time,
|
|
673
|
+
payload.operator_id ?? payload.user_id,
|
|
674
|
+
payload.notice_type ?? payload.type,
|
|
675
|
+
payload.sub_type,
|
|
676
|
+
].join('_');
|
|
677
|
+
if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`))
|
|
433
678
|
return;
|
|
434
|
-
|
|
435
|
-
hooks.publish?.('endpoint:notice', row);
|
|
679
|
+
this.#dispatchIcqqSideEvent(payload);
|
|
436
680
|
}
|
|
437
|
-
#
|
|
438
|
-
const
|
|
439
|
-
|
|
681
|
+
async #listPendingRequests() {
|
|
682
|
+
const messages = await this.getSystemMsg();
|
|
683
|
+
const rows = [];
|
|
684
|
+
for (const raw of messages) {
|
|
685
|
+
const msg = raw;
|
|
686
|
+
const kind = msg.request_type === 'friend' ? 'friend' : 'group';
|
|
687
|
+
const platformRequestId = (msg.flag && String(msg.flag).trim())
|
|
688
|
+
|| (msg.seq != null ? String(msg.seq) : '');
|
|
689
|
+
if (!platformRequestId || msg.user_id == null)
|
|
690
|
+
continue;
|
|
691
|
+
const createdAt = Number(msg.time);
|
|
692
|
+
rows.push(Object.freeze({
|
|
693
|
+
platform_request_id: platformRequestId,
|
|
694
|
+
type: kind,
|
|
695
|
+
scene_type: kind === 'group' ? 'group' : null,
|
|
696
|
+
scene_id: String(kind === 'group' ? (msg.group_id ?? msg.user_id) : msg.user_id),
|
|
697
|
+
sub_type: kind === 'group' ? (msg.type != null ? String(msg.type) : null) : null,
|
|
698
|
+
actor_id: String(msg.user_id),
|
|
699
|
+
actor_name: msg.nickname != null ? String(msg.nickname) : null,
|
|
700
|
+
comment: msg.comment != null ? String(msg.comment) : null,
|
|
701
|
+
created_at: Number.isFinite(createdAt) && createdAt > 0
|
|
702
|
+
? (createdAt < 1e12 ? createdAt * 1000 : createdAt)
|
|
703
|
+
: Date.now(),
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
return Object.freeze(rows);
|
|
440
707
|
}
|
|
441
|
-
async #pullPendingSystemMessages() {
|
|
442
|
-
const hooks = this.#options.inbox;
|
|
443
|
-
if (!hooks)
|
|
444
|
-
return;
|
|
708
|
+
async #pullPendingSystemMessages(signal) {
|
|
445
709
|
try {
|
|
446
|
-
const messages = await this.getSystemMsg();
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
else {
|
|
454
|
-
groupRequests.push(msg);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
const base = this.#inboxBase();
|
|
458
|
-
const rows = [
|
|
459
|
-
...friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base)),
|
|
460
|
-
...groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base)),
|
|
461
|
-
];
|
|
462
|
-
for (const row of rows) {
|
|
463
|
-
if (!row)
|
|
710
|
+
const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
|
|
711
|
+
for (const raw of messages) {
|
|
712
|
+
signal.throwIfAborted();
|
|
713
|
+
const msg = raw;
|
|
714
|
+
const kind = msg.request_type === 'friend' ? 'friend' : 'group';
|
|
715
|
+
const flag = msg.flag != null ? String(msg.flag) : (msg.seq != null ? String(msg.seq) : '');
|
|
716
|
+
if (!flag || !this.#sideEventDeduper.shouldProcess(`request:${flag}`))
|
|
464
717
|
continue;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
718
|
+
this.#dispatchIcqqSideEvent({
|
|
719
|
+
post_type: 'request',
|
|
720
|
+
request_type: kind,
|
|
721
|
+
flag: msg.flag,
|
|
722
|
+
user_id: msg.user_id,
|
|
723
|
+
group_id: msg.group_id,
|
|
724
|
+
comment: msg.comment,
|
|
725
|
+
time: msg.time,
|
|
726
|
+
sub_type: msg.type,
|
|
727
|
+
});
|
|
469
728
|
}
|
|
470
729
|
}
|
|
471
730
|
catch (error) {
|
|
731
|
+
if (signal.aborted)
|
|
732
|
+
throw signal.reason ?? error;
|
|
472
733
|
this.#logger.debug(formatCompact({
|
|
473
|
-
op: '
|
|
734
|
+
op: 'side_event_pull_system_msg',
|
|
474
735
|
endpoint: this.endpointName,
|
|
475
736
|
error: error instanceof Error ? error.message : String(error),
|
|
476
737
|
}));
|
|
@@ -509,6 +770,41 @@ export class IcqqEndpoint extends Client {
|
|
|
509
770
|
}
|
|
510
771
|
}
|
|
511
772
|
}
|
|
773
|
+
function formatIcqqSendablePreview(message) {
|
|
774
|
+
if (typeof message === 'string')
|
|
775
|
+
return message;
|
|
776
|
+
try {
|
|
777
|
+
return JSON.stringify(message, (_key, value) => Buffer.isBuffer(value) ? '<buffer>' : value);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return '[ICQQ Sendable]';
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function isIcqqSegmentPayload(payload) {
|
|
784
|
+
if (Array.isArray(payload))
|
|
785
|
+
return true;
|
|
786
|
+
return typeof payload === 'object'
|
|
787
|
+
&& payload !== null
|
|
788
|
+
&& typeof payload.type === 'string';
|
|
789
|
+
}
|
|
790
|
+
function isIcqqFileElement(message) {
|
|
791
|
+
return !Array.isArray(message)
|
|
792
|
+
&& typeof message !== 'string'
|
|
793
|
+
&& message.type === 'file';
|
|
794
|
+
}
|
|
795
|
+
async function raceAbort(operation, signal) {
|
|
796
|
+
signal.throwIfAborted();
|
|
797
|
+
let rejectAbort;
|
|
798
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
|
|
799
|
+
const onAbort = () => rejectAbort(signal.reason ?? new Error('ICQQ operation aborted'));
|
|
800
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
801
|
+
try {
|
|
802
|
+
return await Promise.race([operation, aborted]);
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
signal.removeEventListener('abort', onAbort);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
512
808
|
function describeUnknownError(error) {
|
|
513
809
|
if (error instanceof Error)
|
|
514
810
|
return error.stack ?? error.message;
|
|
@@ -559,31 +855,6 @@ function resolveNativeClientConfig(config) {
|
|
|
559
855
|
reconn_interval: config.autoReconnect ? 5 : 0,
|
|
560
856
|
};
|
|
561
857
|
}
|
|
562
|
-
function buildIcqqQuoteMetadata(data, base) {
|
|
563
|
-
const metadata = { ...base };
|
|
564
|
-
const quoteId = resolveIcqqQuoteIdFromEvent(data);
|
|
565
|
-
if (quoteId)
|
|
566
|
-
metadata.quote_id = quoteId;
|
|
567
|
-
const quoted = quotedPayloadFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
568
|
-
if (quoted?.sender?.id)
|
|
569
|
-
metadata.quote_sender_id = quoted.sender.id;
|
|
570
|
-
if (quoted?.sender?.name)
|
|
571
|
-
metadata.quote_sender_name = quoted.sender.name;
|
|
572
|
-
const quoteText = quoted
|
|
573
|
-
? (typeof quoted.content === 'string'
|
|
574
|
-
? quoted.content
|
|
575
|
-
: quoted.content
|
|
576
|
-
.map((seg) => seg && typeof seg === 'object' && seg.type === 'text'
|
|
577
|
-
? String(seg.data?.text ?? '')
|
|
578
|
-
: '')
|
|
579
|
-
.join('')).trim() || quoted.raw || ''
|
|
580
|
-
: '';
|
|
581
|
-
if (quoteText)
|
|
582
|
-
metadata.quote_content = quoteText;
|
|
583
|
-
return metadata;
|
|
584
|
-
}
|
|
585
|
-
const INBOUND_HOLD_LIMIT = 32;
|
|
586
|
-
const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'];
|
|
587
858
|
function serializeIcqqEvent(event) {
|
|
588
859
|
if (!event || typeof event !== 'object')
|
|
589
860
|
return null;
|
|
@@ -607,3 +878,20 @@ function serializeIcqqEvent(event) {
|
|
|
607
878
|
}
|
|
608
879
|
return Object.keys(out).length > 0 ? out : null;
|
|
609
880
|
}
|
|
881
|
+
function parseIcqqSystemScene(type) {
|
|
882
|
+
if (type.startsWith('login.')) {
|
|
883
|
+
return { scene_type: 'login', sub_type: type.slice('login.'.length) || 'unknown' };
|
|
884
|
+
}
|
|
885
|
+
if (type.startsWith('offline.')) {
|
|
886
|
+
return { scene_type: 'offline', sub_type: type.slice('offline.'.length) || 'unknown' };
|
|
887
|
+
}
|
|
888
|
+
if (type === 'online') {
|
|
889
|
+
return { scene_type: 'online', sub_type: 'online' };
|
|
890
|
+
}
|
|
891
|
+
if (type === 'offline') {
|
|
892
|
+
return { scene_type: 'offline', sub_type: 'offline' };
|
|
893
|
+
}
|
|
894
|
+
return { scene_type: 'system', sub_type: type };
|
|
895
|
+
}
|
|
896
|
+
const INBOUND_HOLD_LIMIT = 32;
|
|
897
|
+
const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'];
|