@zhin.js/adapter-icqq 1.1.16 → 1.1.18
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 +72 -0
- package/README.md +44 -3
- package/adapters/icqq/index.js +10 -2
- package/adapters/icqq/index.ts +10 -2
- package/agents/icqq/members.js +16 -0
- package/agents/icqq/members.ts +17 -0
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +1 -1
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +1 -1
- package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.js +2 -2
- package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.ts +2 -2
- package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.js +1 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.ts +1 -0
- package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.js +2 -1
- package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.ts +2 -1
- package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.js +2 -2
- package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.ts +2 -2
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +4 -4
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +4 -4
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +4 -4
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +4 -4
- package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.js +16 -6
- package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.ts +16 -5
- package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.js +1 -1
- package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.ts +1 -1
- package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.js +1 -1
- package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.ts +1 -1
- package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.js +1 -1
- package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.ts +1 -1
- package/agents/icqq/system.md +3 -3
- package/commands/icqq/login/code/index.js +28 -0
- package/commands/icqq/login/code/index.ts +30 -0
- package/commands/icqq/login/confirm/index.js +20 -0
- package/commands/icqq/login/confirm/index.ts +21 -0
- package/commands/icqq/login/definition.js +65 -0
- package/commands/icqq/login/definition.ts +72 -0
- package/commands/icqq/login/qr/index.js +53 -0
- package/commands/icqq/login/qr/index.ts +55 -0
- package/commands/icqq/login/retry/index.js +21 -0
- package/commands/icqq/login/retry/index.ts +23 -0
- package/commands/icqq/login/status/index.js +31 -0
- package/commands/icqq/login/status/index.ts +34 -0
- package/lib/console-spec.d.ts +17 -0
- package/lib/console-spec.js +11 -0
- package/lib/console.d.ts +8 -0
- package/lib/console.js +16 -0
- package/lib/endpoint.d.ts +9 -2
- package/lib/endpoint.js +200 -45
- package/lib/group-moderation.js +7 -2
- package/lib/group-scene.d.ts +11 -0
- package/lib/group-scene.js +71 -0
- package/lib/icqq-inbound.d.ts +2 -0
- package/lib/icqq-inbound.js +3 -0
- package/lib/icqq-inbox.d.ts +5 -0
- package/lib/icqq-inbox.js +14 -0
- package/lib/icqq-login-control.d.ts +18 -0
- package/lib/icqq-login-control.js +8 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +12 -0
- package/lib/protocol.js +28 -11
- package/package.json +17 -9
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +6 -1
- package/schema.json +174 -0
- package/src/console-spec.ts +17 -0
- package/src/console.ts +21 -0
- package/src/endpoint.ts +199 -49
- package/src/group-moderation.ts +7 -2
- package/src/group-scene.ts +76 -0
- package/src/icqq-inbound.ts +5 -0
- package/src/icqq-inbox.ts +15 -0
- package/src/icqq-login-control.ts +30 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +38 -10
package/lib/endpoint.js
CHANGED
|
@@ -8,12 +8,13 @@ import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logge
|
|
|
8
8
|
import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
|
|
9
9
|
import { IcqqContentResolver } from './content-resolver.js';
|
|
10
10
|
import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, resolveIcqqSelfUserId, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
|
|
11
|
-
import { isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
11
|
+
import { icqqNoticeDedupeKey, isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
12
12
|
import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
|
|
13
13
|
import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
|
|
14
|
-
import { formatInboundContent,
|
|
14
|
+
import { formatInboundContent, formatOutboundMessageParts, normalizeIcqqImageMedia, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
15
15
|
import { ManagedIcqqClient } from './managed-client.js';
|
|
16
16
|
import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
|
|
17
|
+
import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
|
|
17
18
|
const BOUND_EVENTS = [
|
|
18
19
|
'message.private',
|
|
19
20
|
'message.group',
|
|
@@ -55,8 +56,16 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
55
56
|
#inboundOwner = new AsyncLocalStorage();
|
|
56
57
|
#contentResolver;
|
|
57
58
|
content;
|
|
59
|
+
loginControl;
|
|
60
|
+
#reloginTimer;
|
|
61
|
+
#reloginAttempts = 0;
|
|
62
|
+
#reloginInFlight;
|
|
63
|
+
#offlineNoticeAt = 0;
|
|
64
|
+
#challengeNoticeAt = 0;
|
|
58
65
|
management = Object.freeze({
|
|
59
66
|
inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
|
|
67
|
+
inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
|
|
68
|
+
inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
|
|
60
69
|
moderateGroupMember: (group, user, action, signal) => moderateGroupMember(this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped),
|
|
61
70
|
listFriends: async () => Array.from(this.client.fl.values()).map((f) => ({
|
|
62
71
|
user_id: f.user_id,
|
|
@@ -140,6 +149,19 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
140
149
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
141
150
|
this.#options = options;
|
|
142
151
|
this.endpointName = options.config.id;
|
|
152
|
+
this.loginControl = Object.freeze({
|
|
153
|
+
endpointId: this.endpointName,
|
|
154
|
+
state: () => ({
|
|
155
|
+
endpointId: this.endpointName,
|
|
156
|
+
transportState: this.transportState,
|
|
157
|
+
online: this.client.isOnline(),
|
|
158
|
+
pending: this.#pendingLoginTasks(),
|
|
159
|
+
}),
|
|
160
|
+
pending: () => this.#pendingLoginTasks(),
|
|
161
|
+
relogin: () => this.relogin(),
|
|
162
|
+
submit: (taskId, value) => this.#options.loginAssist.submit(taskId, value),
|
|
163
|
+
cancel: (taskId, reason) => this.#options.loginAssist.cancel(taskId, reason),
|
|
164
|
+
});
|
|
143
165
|
}
|
|
144
166
|
#handleBackgroundFailure(operation, error) {
|
|
145
167
|
if (!this.#started || this.#stopped || this.#retiring)
|
|
@@ -159,6 +181,148 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
159
181
|
// reconn_interval policy. No account-level logout or second retry timer.
|
|
160
182
|
this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
161
183
|
}
|
|
184
|
+
/** Force a login attempt (manual recovery command). */
|
|
185
|
+
relogin() {
|
|
186
|
+
return this.#startRelogin(true);
|
|
187
|
+
}
|
|
188
|
+
#startRelogin(resetAttempts) {
|
|
189
|
+
if (this.#stopped || this.#retiring) {
|
|
190
|
+
return Promise.reject(new Error('ICQQ endpoint 已停止'));
|
|
191
|
+
}
|
|
192
|
+
if (this.#reloginInFlight)
|
|
193
|
+
return this.#reloginInFlight;
|
|
194
|
+
if (resetAttempts)
|
|
195
|
+
this.#reloginAttempts = 0;
|
|
196
|
+
const task = this.#performRelogin();
|
|
197
|
+
this.#reloginInFlight = task;
|
|
198
|
+
void task.catch(() => undefined).finally(() => {
|
|
199
|
+
if (this.#reloginInFlight === task)
|
|
200
|
+
this.#reloginInFlight = undefined;
|
|
201
|
+
if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
|
|
202
|
+
this.#scheduleRelogin('relogin_failed');
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return task;
|
|
206
|
+
}
|
|
207
|
+
async #performRelogin() {
|
|
208
|
+
this.#clearReloginTimer();
|
|
209
|
+
this.client.cancelReconnect();
|
|
210
|
+
this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
|
|
211
|
+
try {
|
|
212
|
+
await this.client.login(this.#options.config.password);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.#logger.warn(formatCompact({
|
|
216
|
+
op: 'icqq_relogin_failed',
|
|
217
|
+
endpoint: this.endpointName,
|
|
218
|
+
error: error instanceof Error ? error.message : String(error),
|
|
219
|
+
}));
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
#scheduleRelogin(reason) {
|
|
224
|
+
if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring)
|
|
225
|
+
return;
|
|
226
|
+
if (this.#reloginTimer || this.#reloginInFlight)
|
|
227
|
+
return;
|
|
228
|
+
if (this.#reloginAttempts >= 8)
|
|
229
|
+
return;
|
|
230
|
+
const attempt = this.#reloginAttempts++;
|
|
231
|
+
const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
|
|
232
|
+
this.#logger.info(formatCompact({
|
|
233
|
+
op: 'icqq_relogin_scheduled',
|
|
234
|
+
endpoint: this.endpointName,
|
|
235
|
+
reason,
|
|
236
|
+
attempt: attempt + 1,
|
|
237
|
+
delay_ms: delayMs,
|
|
238
|
+
}));
|
|
239
|
+
this.#reloginTimer = setTimeout(() => {
|
|
240
|
+
this.#reloginTimer = undefined;
|
|
241
|
+
void this.#startRelogin(false).catch(() => {
|
|
242
|
+
// #performRelogin already scheduled the next attempt.
|
|
243
|
+
});
|
|
244
|
+
}, delayMs);
|
|
245
|
+
this.#reloginTimer.unref?.();
|
|
246
|
+
}
|
|
247
|
+
#clearReloginTimer() {
|
|
248
|
+
if (!this.#reloginTimer)
|
|
249
|
+
return;
|
|
250
|
+
clearTimeout(this.#reloginTimer);
|
|
251
|
+
this.#reloginTimer = undefined;
|
|
252
|
+
}
|
|
253
|
+
#pendingLoginTasks() {
|
|
254
|
+
return this.#options.loginAssist.listPending()
|
|
255
|
+
.filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
|
|
256
|
+
}
|
|
257
|
+
#notifyOffline(reason, willRetry) {
|
|
258
|
+
const notifier = this.#options.notifier;
|
|
259
|
+
if (!notifier?.configured)
|
|
260
|
+
return;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
if (now - this.#offlineNoticeAt < 60_000)
|
|
263
|
+
return;
|
|
264
|
+
this.#offlineNoticeAt = now;
|
|
265
|
+
const recovery = willRetry
|
|
266
|
+
? '已开始自动重新登录。'
|
|
267
|
+
: '请手动重新登录。';
|
|
268
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
269
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
270
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
271
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
|
|
272
|
+
+ `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
|
|
273
|
+
+ `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
|
|
274
|
+
void notifier.send(text).then((sent) => {
|
|
275
|
+
if (sent)
|
|
276
|
+
return;
|
|
277
|
+
this.#logger.warn(formatCompact({
|
|
278
|
+
op: 'icqq_notify_failed',
|
|
279
|
+
endpoint: this.endpointName,
|
|
280
|
+
reason,
|
|
281
|
+
}));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
#notifyChallenge(step) {
|
|
285
|
+
const notifier = this.#options.notifier;
|
|
286
|
+
if (!notifier?.configured)
|
|
287
|
+
return;
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
if (now - this.#challengeNoticeAt < 30_000)
|
|
290
|
+
return;
|
|
291
|
+
this.#challengeNoticeAt = now;
|
|
292
|
+
const label = step === 'qrcode' ? '二维码验证'
|
|
293
|
+
: step === 'slider' ? '滑块验证'
|
|
294
|
+
: step === 'device' ? '设备确认'
|
|
295
|
+
: '登录确认';
|
|
296
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
297
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
298
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
299
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
|
|
300
|
+
+ (step === 'qrcode' || step === 'auth'
|
|
301
|
+
? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
|
|
302
|
+
: `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
|
|
303
|
+
void notifier.send(text).then((sent) => {
|
|
304
|
+
if (sent)
|
|
305
|
+
return;
|
|
306
|
+
this.#logger.warn(formatCompact({
|
|
307
|
+
op: 'icqq_notify_failed',
|
|
308
|
+
endpoint: this.endpointName,
|
|
309
|
+
reason: `login_${step}`,
|
|
310
|
+
}));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
#notifyRecovered() {
|
|
314
|
+
const notifier = this.#options.notifier;
|
|
315
|
+
const hadOfflineNotice = this.#offlineNoticeAt > 0;
|
|
316
|
+
this.#offlineNoticeAt = 0;
|
|
317
|
+
this.#challengeNoticeAt = 0;
|
|
318
|
+
if (!notifier?.configured || !hadOfflineNotice)
|
|
319
|
+
return;
|
|
320
|
+
void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
|
|
321
|
+
if (sent)
|
|
322
|
+
return;
|
|
323
|
+
this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
162
326
|
#ignoreReactionAck(action, task) {
|
|
163
327
|
void Promise.resolve(task).catch((error) => {
|
|
164
328
|
this.#logger.debug(formatCompact({
|
|
@@ -207,6 +371,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
207
371
|
this.#logger.info(`connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`);
|
|
208
372
|
}
|
|
209
373
|
catch (error) {
|
|
374
|
+
this.#notifyOffline('start_failed', false);
|
|
210
375
|
await this.stop();
|
|
211
376
|
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
212
377
|
throw error;
|
|
@@ -240,6 +405,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
240
405
|
}
|
|
241
406
|
async stop() {
|
|
242
407
|
this.#stopped = true;
|
|
408
|
+
this.#clearReloginTimer();
|
|
243
409
|
this.#assertExternalLifecycle('stop');
|
|
244
410
|
this.#armRetirementFence();
|
|
245
411
|
await Promise.allSettled([...this.#inflightInbound]);
|
|
@@ -302,10 +468,10 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
302
468
|
: { content: payload, dispose() { } };
|
|
303
469
|
try {
|
|
304
470
|
// Standalone elements (share / file / …) are split into ordered messages.
|
|
305
|
-
const messages =
|
|
471
|
+
const messages = formatOutboundMessageParts(prepared.content);
|
|
306
472
|
const parsed = icqqOutboundTarget(conversation);
|
|
307
|
-
|
|
308
|
-
for (const [index, message] of messages.entries()) {
|
|
473
|
+
const parts = [];
|
|
474
|
+
for (const [index, { message, sourceIndices }] of messages.entries()) {
|
|
309
475
|
const preview = formatIcqqSendablePreview(message);
|
|
310
476
|
let result;
|
|
311
477
|
try {
|
|
@@ -321,60 +487,43 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
321
487
|
}));
|
|
322
488
|
if (index === 0)
|
|
323
489
|
throw error;
|
|
324
|
-
// Earlier
|
|
325
|
-
throw new EndpointDeliveryError('partial_delivery', `ICQQ
|
|
490
|
+
// Earlier sends completed; retrying can duplicate accepted messages, even without ids.
|
|
491
|
+
throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
|
|
326
492
|
}
|
|
327
493
|
const rawId = result?.message_id;
|
|
328
494
|
const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
|
|
329
495
|
? String(rawId) : undefined;
|
|
330
|
-
messageId
|
|
496
|
+
parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
|
|
331
497
|
this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`);
|
|
332
498
|
}
|
|
333
|
-
//
|
|
334
|
-
if (!messageId) {
|
|
335
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown');
|
|
499
|
+
// A resolved share/file call has no message identity; retain that unknown slot in the batch.
|
|
500
|
+
if (!parts.some(part => part.messageId)) {
|
|
501
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
|
|
336
502
|
}
|
|
337
|
-
return
|
|
503
|
+
return { parts };
|
|
338
504
|
}
|
|
339
505
|
finally {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
* unconfirmed rather than inventing an id.
|
|
348
|
-
*/
|
|
349
|
-
async #findOwnCard(history, sentAt) {
|
|
350
|
-
const self = Number(this.client.uin);
|
|
351
|
-
for (const wait of [0, 500]) {
|
|
352
|
-
if (wait)
|
|
353
|
-
await new Promise(resolve => setTimeout(resolve, wait));
|
|
354
|
-
// A missing or throwing history API must not turn an accepted share into a send failure.
|
|
355
|
-
const messages = await Promise.resolve().then(history).catch(() => []);
|
|
356
|
-
const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
|
|
357
|
-
&& (item.time ?? 0) >= sentAt - 2
|
|
358
|
-
&& (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
|
|
359
|
-
if (card && card.message_id != null && String(card.message_id).trim())
|
|
360
|
-
return String(card.message_id);
|
|
506
|
+
try {
|
|
507
|
+
prepared.dispose();
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
// Temporary-file cleanup cannot change an already observed platform send outcome.
|
|
511
|
+
this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
|
|
512
|
+
}
|
|
361
513
|
}
|
|
362
|
-
return undefined;
|
|
363
514
|
}
|
|
364
515
|
async #sendNative(target, message) {
|
|
365
516
|
if (isIcqqShareElement(message)) {
|
|
366
517
|
switch (target.kind) {
|
|
367
518
|
case 'private': {
|
|
368
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
369
519
|
const friend = this.client.pickFriend(target.userId);
|
|
370
520
|
await friend.share(message);
|
|
371
|
-
return {
|
|
521
|
+
return {};
|
|
372
522
|
}
|
|
373
523
|
case 'group': {
|
|
374
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
375
524
|
const group = this.client.pickGroup(target.groupId);
|
|
376
525
|
await group.share(message);
|
|
377
|
-
return {
|
|
526
|
+
return {};
|
|
378
527
|
}
|
|
379
528
|
case 'temp':
|
|
380
529
|
await this.client.pickMember(target.groupId, target.userId).share(message);
|
|
@@ -538,12 +687,16 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
538
687
|
this.#bindSystemOnlineEvent();
|
|
539
688
|
this.client.on('system.offline', (event) => {
|
|
540
689
|
this.#logSystemEvent('offline', event);
|
|
690
|
+
this.#notifyOffline('offline', this.#options.config.autoReconnect);
|
|
541
691
|
});
|
|
542
692
|
this.client.on('system.offline.network', (event) => {
|
|
543
693
|
this.#logSystemEvent('offline.network', event);
|
|
694
|
+
this.#notifyOffline('network', this.#options.config.autoReconnect);
|
|
544
695
|
});
|
|
545
696
|
this.client.on('system.offline.kickoff', (event) => {
|
|
546
697
|
this.#logSystemEvent('offline.kickoff', event);
|
|
698
|
+
this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
|
|
699
|
+
this.#scheduleRelogin('kickoff');
|
|
547
700
|
});
|
|
548
701
|
this.client.on('system.login.qrcode', (event) => {
|
|
549
702
|
this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
|
|
@@ -558,6 +711,8 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
558
711
|
this.client.on('system.login.error', (event) => {
|
|
559
712
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
|
|
560
713
|
this.#logSystemEvent('login.error', event);
|
|
714
|
+
this.#notifyOffline('login_error', this.#options.config.autoReconnect);
|
|
715
|
+
this.#scheduleRelogin('login_error');
|
|
561
716
|
});
|
|
562
717
|
this.client.on('system.login.auth', (event) => {
|
|
563
718
|
this.#handleLoginChallenge('auth', event);
|
|
@@ -568,6 +723,9 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
568
723
|
if (this.#retiring)
|
|
569
724
|
return;
|
|
570
725
|
this.#everOnline = true;
|
|
726
|
+
this.#clearReloginTimer();
|
|
727
|
+
this.#reloginAttempts = 0;
|
|
728
|
+
this.#notifyRecovered();
|
|
571
729
|
try {
|
|
572
730
|
this.#resolveStartOnline?.();
|
|
573
731
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
@@ -625,6 +783,8 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
625
783
|
? event
|
|
626
784
|
: null);
|
|
627
785
|
this.#logSystemEvent(`login.${step}`, event);
|
|
786
|
+
this.#clearReloginTimer();
|
|
787
|
+
this.#notifyChallenge(step);
|
|
628
788
|
void runIcqqLoginAssistStep({
|
|
629
789
|
assist: this.#options.loginAssist,
|
|
630
790
|
locale: this.#options.locale,
|
|
@@ -743,6 +903,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
743
903
|
channelType: normalized.channelType,
|
|
744
904
|
channelId: normalized.channelId,
|
|
745
905
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
906
|
+
name: normalized.groupName,
|
|
746
907
|
});
|
|
747
908
|
const quoteId = resolveIcqqQuoteIdFromEvent(data);
|
|
748
909
|
const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
@@ -857,13 +1018,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
857
1018
|
#onNoticeEvent(payload) {
|
|
858
1019
|
if (!payload || !isIcqqNoticePayload(payload))
|
|
859
1020
|
return;
|
|
860
|
-
|
|
861
|
-
payload.time,
|
|
862
|
-
payload.operator_id ?? payload.user_id,
|
|
863
|
-
payload.notice_type ?? payload.type,
|
|
864
|
-
payload.sub_type,
|
|
865
|
-
].join('_');
|
|
866
|
-
if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`))
|
|
1021
|
+
if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload)))
|
|
867
1022
|
return;
|
|
868
1023
|
this.#dispatchIcqqSideEvent(payload);
|
|
869
1024
|
}
|
package/lib/group-moderation.js
CHANGED
|
@@ -3,17 +3,22 @@ export async function inspectGroupMember(client, groupId, userId, signal) {
|
|
|
3
3
|
signal.throwIfAborted();
|
|
4
4
|
const group = numericId(groupId);
|
|
5
5
|
const user = numericId(userId);
|
|
6
|
+
const selfLookup = client.getGroupMemberInfo(group, client.uin, true);
|
|
6
7
|
const [self, member] = await Promise.all([
|
|
7
|
-
|
|
8
|
-
client.getGroupMemberInfo(group, user, true),
|
|
8
|
+
selfLookup,
|
|
9
|
+
user === client.uin ? selfLookup : client.getGroupMemberInfo(group, user, true),
|
|
9
10
|
]);
|
|
10
11
|
signal.throwIfAborted();
|
|
11
12
|
const joinedAt = member.join_time * 1000;
|
|
13
|
+
const platformRole = member.user_id === user && member.group_id === group
|
|
14
|
+
&& (member.role === 'owner' || member.role === 'admin' || member.role === 'member')
|
|
15
|
+
? member.role : undefined;
|
|
12
16
|
return {
|
|
13
17
|
canModerate: client.isOnline() && user !== client.uin
|
|
14
18
|
&& self.user_id === client.uin && self.group_id === group
|
|
15
19
|
&& member.user_id === user && member.group_id === group
|
|
16
20
|
&& (self.role === 'owner' || self.role === 'admin') && member.role === 'member',
|
|
21
|
+
...(platformRole ? { platformRole } : {}),
|
|
17
22
|
...(Number.isSafeInteger(joinedAt) && joinedAt > 0 && joinedAt <= Date.now() ? { joinedAt } : {}),
|
|
18
23
|
};
|
|
19
24
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Client } from '@icqqjs/icqq';
|
|
2
|
+
import type { GroupMemberProfile, GroupSceneFacts } from 'zhin.js/adapter';
|
|
3
|
+
/** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
|
|
4
|
+
export declare const ADMIN_SCAN_MAX_MEMBERS = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
|
|
7
|
+
* admins. A roster failure leaves adminIds unknown instead of failing the other facts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function inspectGroupScene(client: Client, groupId: string, signal: AbortSignal): Promise<GroupSceneFacts>;
|
|
10
|
+
/** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
|
|
11
|
+
export declare function inspectGroupMemberProfile(client: Client, groupId: string, userId: string, signal: AbortSignal): Promise<GroupMemberProfile>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
|
|
2
|
+
export const ADMIN_SCAN_MAX_MEMBERS = 1000;
|
|
3
|
+
const MAX_ADMIN_IDS = 20;
|
|
4
|
+
function positiveId(value) {
|
|
5
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : undefined;
|
|
6
|
+
}
|
|
7
|
+
function numericId(value) {
|
|
8
|
+
const id = Number(value);
|
|
9
|
+
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id))
|
|
10
|
+
throw new Error('Invalid QQ identifier');
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
|
|
15
|
+
* admins. A roster failure leaves adminIds unknown instead of failing the other facts.
|
|
16
|
+
*/
|
|
17
|
+
export async function inspectGroupScene(client, groupId, signal) {
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
const group = numericId(groupId);
|
|
20
|
+
const info = await client.getGroupInfo(group, true);
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
if (info.group_id !== group)
|
|
23
|
+
return {};
|
|
24
|
+
const memberCount = Number.isSafeInteger(info.member_count) && info.member_count > 0 ? info.member_count : undefined;
|
|
25
|
+
const ownerId = positiveId(info.owner_id);
|
|
26
|
+
let adminIds;
|
|
27
|
+
if (memberCount === undefined || memberCount <= ADMIN_SCAN_MAX_MEMBERS) {
|
|
28
|
+
try {
|
|
29
|
+
const roster = await client.getGroupMemberList(group);
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
adminIds = [...roster.values()]
|
|
32
|
+
.filter(member => member.role === 'admin' && member.group_id === group)
|
|
33
|
+
.map(member => positiveId(member.user_id))
|
|
34
|
+
.filter((id) => id !== undefined)
|
|
35
|
+
.slice(0, MAX_ADMIN_IDS);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
adminIds = undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const name = typeof info.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
|
|
43
|
+
const nowSeconds = Date.now() / 1000;
|
|
44
|
+
return {
|
|
45
|
+
...(name ? { name } : {}),
|
|
46
|
+
...(ownerId ? { ownerId } : {}),
|
|
47
|
+
...(adminIds ? { adminIds } : {}),
|
|
48
|
+
...(memberCount !== undefined ? { memberCount } : {}),
|
|
49
|
+
...(typeof info.shutup_time_whole === 'number' ? { muteAll: info.shutup_time_whole > nowSeconds } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
|
|
53
|
+
export async function inspectGroupMemberProfile(client, groupId, userId, signal) {
|
|
54
|
+
signal.throwIfAborted();
|
|
55
|
+
const group = numericId(groupId);
|
|
56
|
+
const user = numericId(userId);
|
|
57
|
+
const member = await client.getGroupMemberInfo(group, user, true);
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
if (member.user_id !== user || member.group_id !== group)
|
|
60
|
+
return {};
|
|
61
|
+
const card = typeof member.card === 'string' && member.card.trim() ? member.card.trim() : undefined;
|
|
62
|
+
const title = typeof member.title === 'string' && member.title.trim() ? member.title.trim() : undefined;
|
|
63
|
+
const shutupSeconds = member.shutup_time;
|
|
64
|
+
return {
|
|
65
|
+
...(member.role === 'owner' || member.role === 'admin' || member.role === 'member' ? { platformRole: member.role } : {}),
|
|
66
|
+
...(card ? { card } : {}),
|
|
67
|
+
...(title ? { title } : {}),
|
|
68
|
+
...(typeof shutupSeconds === 'number' && Number.isFinite(shutupSeconds)
|
|
69
|
+
? { mutedUntil: shutupSeconds * 1000 > Date.now() ? shutupSeconds * 1000 : 0 } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/lib/icqq-inbound.d.ts
CHANGED
package/lib/icqq-inbound.js
CHANGED
|
@@ -688,6 +688,9 @@ export function normalizeIcqqInboundMessage(data) {
|
|
|
688
688
|
channelType,
|
|
689
689
|
channelId,
|
|
690
690
|
channelParentGroupId,
|
|
691
|
+
...(channelType === "group" && typeof ext.group_name === "string" && ext.group_name.trim()
|
|
692
|
+
? { groupName: ext.group_name.trim() }
|
|
693
|
+
: {}),
|
|
691
694
|
userId: String(userIdNum),
|
|
692
695
|
nickname,
|
|
693
696
|
senderRole,
|
package/lib/icqq-inbox.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface IcqqInboxBase {
|
|
|
8
8
|
export declare function isIcqqRequestPayload(payload: unknown): boolean;
|
|
9
9
|
/** post_type=notice / notice.* 判定。 */
|
|
10
10
|
export declare function isIcqqNoticePayload(payload: unknown): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
|
|
13
|
+
* 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
|
|
14
|
+
*/
|
|
15
|
+
export declare function icqqNoticeDedupeKey(payload: Record<string, unknown>): string;
|
|
11
16
|
/**
|
|
12
17
|
* request 事件载荷 → unified_inbox_request 行。
|
|
13
18
|
* platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
|
package/lib/icqq-inbox.js
CHANGED
|
@@ -10,6 +10,20 @@ export function isIcqqNoticePayload(payload) {
|
|
|
10
10
|
return typeof postType === 'string'
|
|
11
11
|
&& (postType === 'notice' || postType.startsWith('notice.'));
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
|
|
15
|
+
* 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
|
|
16
|
+
*/
|
|
17
|
+
export function icqqNoticeDedupeKey(payload) {
|
|
18
|
+
return `notice:${[
|
|
19
|
+
payload.time,
|
|
20
|
+
payload.group_id,
|
|
21
|
+
payload.user_id,
|
|
22
|
+
payload.operator_id,
|
|
23
|
+
payload.notice_type ?? payload.type,
|
|
24
|
+
payload.sub_type,
|
|
25
|
+
].join('_')}`;
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* request 事件载荷 → unified_inbox_request 行。
|
|
15
29
|
* platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PendingLoginTask } from '@zhin.js/core';
|
|
2
|
+
export interface IcqqLoginControlState {
|
|
3
|
+
readonly endpointId: string;
|
|
4
|
+
readonly transportState: string;
|
|
5
|
+
readonly online: boolean;
|
|
6
|
+
readonly pending: readonly PendingLoginTask[];
|
|
7
|
+
}
|
|
8
|
+
export interface IcqqLoginControl {
|
|
9
|
+
readonly endpointId: string;
|
|
10
|
+
state(): IcqqLoginControlState;
|
|
11
|
+
pending(): readonly PendingLoginTask[];
|
|
12
|
+
/** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
|
|
13
|
+
relogin(): Promise<void>;
|
|
14
|
+
submit(taskId: string, value: string | Record<string, unknown>): boolean;
|
|
15
|
+
cancel(taskId: string, reason?: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** `endpoint id (QQ uin)` → control for the current generation. */
|
|
18
|
+
export declare const icqqLoginControlToken: import("zhin.js").Token<Map<string, IcqqLoginControl>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ login control surface exposed to the Plugin Runtime, Console and the
|
|
3
|
+
* recovery command. One control per endpoint instance (UIN), provided by the
|
|
4
|
+
* ICQQ plugin setup and filled by `adapters/icqq/create()`.
|
|
5
|
+
*/
|
|
6
|
+
import { createToken } from 'zhin.js';
|
|
7
|
+
/** `endpoint id (QQ uin)` → control for the current generation. */
|
|
8
|
+
export const icqqLoginControlToken = createToken('zhin.icqq.login-control', 'ICQQ login control surface for recovery commands');
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, icqqInboundConversation, icqqOutboundTarget, type ActionResult, type IcqqEndpointConfig, type IcqqInboundMessage, type IcqqWireSegment, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
|
|
1
|
+
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, formatOutboundMessageParts, icqqInboundConversation, icqqOutboundTarget, type ActionResult, type IcqqEndpointConfig, type IcqqInboundMessage, type IcqqWireSegment, type IcqqOutboundMessagePart, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
|
|
2
2
|
export { IcqqEndpoint, type IcqqEndpointOptions, } from './endpoint.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export { icqqClient, type IcqqClient, type IcqqClientEventMap, } from './client.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
1
|
+
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, formatOutboundMessageParts, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
2
2
|
export { IcqqEndpoint, } from './endpoint.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export { icqqClient, } from './client.js';
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Sendable } from '@icqqjs/icqq';
|
|
2
2
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
3
|
+
import { type AdapterNotifyTarget, type ResolvedAdapterNotifyTarget } from 'zhin.js/adapter';
|
|
3
4
|
import type { MessageSegment, SendContent } from "zhin.js";
|
|
4
5
|
export type ActionResult = {
|
|
5
6
|
id: string;
|
|
@@ -177,6 +178,8 @@ export interface IcqqEndpointConfig {
|
|
|
177
178
|
readonly autoServer?: boolean;
|
|
178
179
|
readonly qqnt?: boolean;
|
|
179
180
|
readonly ntLogin?: boolean;
|
|
181
|
+
/** Optional out-of-band outage notification target (usually a QQ adapter instance). */
|
|
182
|
+
readonly notify?: AdapterNotifyTarget;
|
|
180
183
|
}
|
|
181
184
|
export interface ResolvedIcqqConfig {
|
|
182
185
|
readonly context: 'icqq';
|
|
@@ -194,6 +197,7 @@ export interface ResolvedIcqqConfig {
|
|
|
194
197
|
readonly autoServer?: boolean;
|
|
195
198
|
readonly qqnt?: boolean;
|
|
196
199
|
readonly ntLogin?: boolean;
|
|
200
|
+
readonly notify?: ResolvedAdapterNotifyTarget;
|
|
197
201
|
}
|
|
198
202
|
export interface IcqqWireSegment {
|
|
199
203
|
readonly type: string;
|
|
@@ -246,6 +250,8 @@ export declare function icqqInboundConversation(endpointKey: string, input: {
|
|
|
246
250
|
readonly channelId: string;
|
|
247
251
|
readonly channelParentGroupId?: string;
|
|
248
252
|
readonly guildId?: string;
|
|
253
|
+
/** 群名(仅 group 会话使用) */
|
|
254
|
+
readonly name?: string;
|
|
249
255
|
}): ConversationRef;
|
|
250
256
|
/**
|
|
251
257
|
* 出站:从 ConversationRef 派生 action 路由。
|
|
@@ -267,4 +273,10 @@ export declare function formatOutboundBody(payload: unknown): Sendable;
|
|
|
267
273
|
* quote or whitespace are dropped: a standalone element cannot carry the quote.
|
|
268
274
|
*/
|
|
269
275
|
export declare function formatOutboundMessages(payload: unknown): Sendable[];
|
|
276
|
+
/** Native bubble and the exact source segment positions which produced it. */
|
|
277
|
+
export interface IcqqOutboundMessagePart {
|
|
278
|
+
readonly message: Sendable;
|
|
279
|
+
readonly sourceIndices?: readonly number[];
|
|
280
|
+
}
|
|
281
|
+
export declare function formatOutboundMessageParts(payload: unknown): IcqqOutboundMessagePart[];
|
|
270
282
|
export declare function formatInboundContent(rawMessage: string): string;
|