@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/src/endpoint.ts
CHANGED
|
@@ -17,12 +17,14 @@ import {
|
|
|
17
17
|
type EndpointPendingRequest,
|
|
18
18
|
type EndpointSendRequest,
|
|
19
19
|
type EndpointTransportState,
|
|
20
|
+
type AdapterOutageNotifier,
|
|
20
21
|
} from 'zhin.js/adapter';
|
|
21
22
|
import type { EndpointContentPort } from '@zhin.js/adapter';
|
|
22
|
-
import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist } from '@zhin.js/core';
|
|
23
|
-
import { EndpointDeliveryError, type MessageRef } from '@zhin.js/im-contract';
|
|
23
|
+
import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type PendingLoginTask } from '@zhin.js/core';
|
|
24
|
+
import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
|
|
24
25
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
25
26
|
import type { CapabilityId } from 'zhin.js';
|
|
27
|
+
import type { IcqqLoginControl } from './icqq-login-control.js';
|
|
26
28
|
import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
|
|
27
29
|
import { IcqqContentResolver } from './content-resolver.js';
|
|
28
30
|
import {
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
type IcqqMessageEvent,
|
|
40
42
|
} from './icqq-inbound.js';
|
|
41
43
|
import {
|
|
44
|
+
icqqNoticeDedupeKey,
|
|
42
45
|
isIcqqNoticePayload,
|
|
43
46
|
isIcqqRequestPayload,
|
|
44
47
|
} from './icqq-inbox.js';
|
|
@@ -52,7 +55,7 @@ import {
|
|
|
52
55
|
} from './outbound-media.js';
|
|
53
56
|
import {
|
|
54
57
|
formatInboundContent,
|
|
55
|
-
|
|
58
|
+
formatOutboundMessageParts,
|
|
56
59
|
normalizeIcqqImageMedia,
|
|
57
60
|
icqqInboundConversation,
|
|
58
61
|
icqqOutboundTarget,
|
|
@@ -62,12 +65,15 @@ import {
|
|
|
62
65
|
import type { SystemMessage as IcqqSystemMessage } from './types.js';
|
|
63
66
|
import { ManagedIcqqClient, type IcqqBackgroundOperation } from './managed-client.js';
|
|
64
67
|
import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
|
|
68
|
+
import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
|
|
65
69
|
|
|
66
70
|
export interface IcqqEndpointOptions {
|
|
67
71
|
readonly id: CapabilityId;
|
|
68
72
|
readonly config: ResolvedIcqqConfig;
|
|
69
73
|
readonly loginAssist: LoginAssist;
|
|
70
74
|
readonly locale?: import('zhin.js/i18n').Locale;
|
|
75
|
+
/** Out-of-band outage notifier; absent in tests/hosts without outbound. */
|
|
76
|
+
readonly notifier?: AdapterOutageNotifier;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
const BOUND_EVENTS = [
|
|
@@ -112,9 +118,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
112
118
|
readonly #inboundOwner = new AsyncLocalStorage<symbol>();
|
|
113
119
|
readonly #contentResolver: IcqqContentResolver;
|
|
114
120
|
readonly content: EndpointContentPort;
|
|
121
|
+
readonly loginControl: IcqqLoginControl;
|
|
122
|
+
#reloginTimer?: ReturnType<typeof setTimeout>;
|
|
123
|
+
#reloginAttempts = 0;
|
|
124
|
+
#reloginInFlight?: Promise<void>;
|
|
125
|
+
#offlineNoticeAt = 0;
|
|
126
|
+
#challengeNoticeAt = 0;
|
|
115
127
|
|
|
116
128
|
readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
117
129
|
inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
|
|
130
|
+
inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
|
|
131
|
+
inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
|
|
118
132
|
moderateGroupMember: (group, user, action, signal) => moderateGroupMember(
|
|
119
133
|
this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped,
|
|
120
134
|
),
|
|
@@ -206,6 +220,19 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
206
220
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
207
221
|
this.#options = options;
|
|
208
222
|
this.endpointName = options.config.id;
|
|
223
|
+
this.loginControl = Object.freeze({
|
|
224
|
+
endpointId: this.endpointName,
|
|
225
|
+
state: () => ({
|
|
226
|
+
endpointId: this.endpointName,
|
|
227
|
+
transportState: this.transportState,
|
|
228
|
+
online: this.client.isOnline(),
|
|
229
|
+
pending: this.#pendingLoginTasks(),
|
|
230
|
+
}),
|
|
231
|
+
pending: () => this.#pendingLoginTasks(),
|
|
232
|
+
relogin: () => this.relogin(),
|
|
233
|
+
submit: (taskId: string, value: string | Record<string, unknown>) => this.#options.loginAssist.submit(taskId, value),
|
|
234
|
+
cancel: (taskId: string, reason?: string) => this.#options.loginAssist.cancel(taskId, reason),
|
|
235
|
+
});
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
#handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
|
|
@@ -224,6 +251,141 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
224
251
|
this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
/** Force a login attempt (manual recovery command). */
|
|
255
|
+
relogin(): Promise<void> {
|
|
256
|
+
return this.#startRelogin(true);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#startRelogin(resetAttempts: boolean): Promise<void> {
|
|
260
|
+
if (this.#stopped || this.#retiring) {
|
|
261
|
+
return Promise.reject(new Error('ICQQ endpoint 已停止'));
|
|
262
|
+
}
|
|
263
|
+
if (this.#reloginInFlight) return this.#reloginInFlight;
|
|
264
|
+
if (resetAttempts) this.#reloginAttempts = 0;
|
|
265
|
+
const task = this.#performRelogin();
|
|
266
|
+
this.#reloginInFlight = task;
|
|
267
|
+
void task.catch(() => undefined).finally(() => {
|
|
268
|
+
if (this.#reloginInFlight === task) this.#reloginInFlight = undefined;
|
|
269
|
+
if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
|
|
270
|
+
this.#scheduleRelogin('relogin_failed');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return task;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #performRelogin(): Promise<void> {
|
|
277
|
+
this.#clearReloginTimer();
|
|
278
|
+
this.client.cancelReconnect();
|
|
279
|
+
this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
|
|
280
|
+
try {
|
|
281
|
+
await this.client.login(this.#options.config.password);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
this.#logger.warn(formatCompact({
|
|
284
|
+
op: 'icqq_relogin_failed',
|
|
285
|
+
endpoint: this.endpointName,
|
|
286
|
+
error: error instanceof Error ? error.message : String(error),
|
|
287
|
+
}));
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#scheduleRelogin(reason: string): void {
|
|
293
|
+
if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring) return;
|
|
294
|
+
if (this.#reloginTimer || this.#reloginInFlight) return;
|
|
295
|
+
if (this.#reloginAttempts >= 8) return;
|
|
296
|
+
const attempt = this.#reloginAttempts++;
|
|
297
|
+
const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
|
|
298
|
+
this.#logger.info(formatCompact({
|
|
299
|
+
op: 'icqq_relogin_scheduled',
|
|
300
|
+
endpoint: this.endpointName,
|
|
301
|
+
reason,
|
|
302
|
+
attempt: attempt + 1,
|
|
303
|
+
delay_ms: delayMs,
|
|
304
|
+
}));
|
|
305
|
+
this.#reloginTimer = setTimeout(() => {
|
|
306
|
+
this.#reloginTimer = undefined;
|
|
307
|
+
void this.#startRelogin(false).catch(() => {
|
|
308
|
+
// #performRelogin already scheduled the next attempt.
|
|
309
|
+
});
|
|
310
|
+
}, delayMs);
|
|
311
|
+
this.#reloginTimer.unref?.();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#clearReloginTimer(): void {
|
|
315
|
+
if (!this.#reloginTimer) return;
|
|
316
|
+
clearTimeout(this.#reloginTimer);
|
|
317
|
+
this.#reloginTimer = undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#pendingLoginTasks(): readonly PendingLoginTask[] {
|
|
321
|
+
return this.#options.loginAssist.listPending()
|
|
322
|
+
.filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#notifyOffline(reason: string, willRetry: boolean): void {
|
|
326
|
+
const notifier = this.#options.notifier;
|
|
327
|
+
if (!notifier?.configured) return;
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
if (now - this.#offlineNoticeAt < 60_000) return;
|
|
330
|
+
this.#offlineNoticeAt = now;
|
|
331
|
+
const recovery = willRetry
|
|
332
|
+
? '已开始自动重新登录。'
|
|
333
|
+
: '请手动重新登录。';
|
|
334
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
335
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
336
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
337
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
|
|
338
|
+
+ `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
|
|
339
|
+
+ `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
|
|
340
|
+
void notifier.send(text).then((sent) => {
|
|
341
|
+
if (sent) return;
|
|
342
|
+
this.#logger.warn(formatCompact({
|
|
343
|
+
op: 'icqq_notify_failed',
|
|
344
|
+
endpoint: this.endpointName,
|
|
345
|
+
reason,
|
|
346
|
+
}));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#notifyChallenge(step: 'qrcode' | 'slider' | 'device' | 'auth'): void {
|
|
351
|
+
const notifier = this.#options.notifier;
|
|
352
|
+
if (!notifier?.configured) return;
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
if (now - this.#challengeNoticeAt < 30_000) return;
|
|
355
|
+
this.#challengeNoticeAt = now;
|
|
356
|
+
const label = step === 'qrcode' ? '二维码验证'
|
|
357
|
+
: step === 'slider' ? '滑块验证'
|
|
358
|
+
: step === 'device' ? '设备确认'
|
|
359
|
+
: '登录确认';
|
|
360
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
361
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
362
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
363
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
|
|
364
|
+
+ (step === 'qrcode' || step === 'auth'
|
|
365
|
+
? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
|
|
366
|
+
: `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
|
|
367
|
+
void notifier.send(text).then((sent) => {
|
|
368
|
+
if (sent) return;
|
|
369
|
+
this.#logger.warn(formatCompact({
|
|
370
|
+
op: 'icqq_notify_failed',
|
|
371
|
+
endpoint: this.endpointName,
|
|
372
|
+
reason: `login_${step}`,
|
|
373
|
+
}));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#notifyRecovered(): void {
|
|
378
|
+
const notifier = this.#options.notifier;
|
|
379
|
+
const hadOfflineNotice = this.#offlineNoticeAt > 0;
|
|
380
|
+
this.#offlineNoticeAt = 0;
|
|
381
|
+
this.#challengeNoticeAt = 0;
|
|
382
|
+
if (!notifier?.configured || !hadOfflineNotice) return;
|
|
383
|
+
void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
|
|
384
|
+
if (sent) return;
|
|
385
|
+
this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
227
389
|
#ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
|
|
228
390
|
void Promise.resolve(task).catch((error) => {
|
|
229
391
|
this.#logger.debug(formatCompact({
|
|
@@ -270,6 +432,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
270
432
|
`connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
|
|
271
433
|
);
|
|
272
434
|
} catch (error) {
|
|
435
|
+
this.#notifyOffline('start_failed', false);
|
|
273
436
|
await this.stop();
|
|
274
437
|
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
275
438
|
throw error;
|
|
@@ -304,6 +467,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
304
467
|
|
|
305
468
|
async stop(): Promise<void> {
|
|
306
469
|
this.#stopped = true;
|
|
470
|
+
this.#clearReloginTimer();
|
|
307
471
|
this.#assertExternalLifecycle('stop');
|
|
308
472
|
this.#armRetirementFence();
|
|
309
473
|
await Promise.allSettled([...this.#inflightInbound]);
|
|
@@ -354,7 +518,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
354
518
|
});
|
|
355
519
|
}
|
|
356
520
|
|
|
357
|
-
async send({ conversation, payload }: EndpointSendRequest): Promise<
|
|
521
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<EndpointSendResult> {
|
|
358
522
|
if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
|
|
359
523
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
360
524
|
const prepared = isIcqqSegmentPayload(payload)
|
|
@@ -362,10 +526,10 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
362
526
|
: { content: payload, dispose() {} };
|
|
363
527
|
try {
|
|
364
528
|
// Standalone elements (share / file / …) are split into ordered messages.
|
|
365
|
-
const messages =
|
|
529
|
+
const messages = formatOutboundMessageParts(prepared.content);
|
|
366
530
|
const parsed = icqqOutboundTarget(conversation);
|
|
367
|
-
|
|
368
|
-
for (const [index, message] of messages.entries()) {
|
|
531
|
+
const parts: EndpointSendPart[] = [];
|
|
532
|
+
for (const [index, { message, sourceIndices }] of messages.entries()) {
|
|
369
533
|
const preview = formatIcqqSendablePreview(message);
|
|
370
534
|
let result: { message_id?: unknown };
|
|
371
535
|
try {
|
|
@@ -379,48 +543,30 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
379
543
|
preview: truncatePreview(preview, 120),
|
|
380
544
|
}));
|
|
381
545
|
if (index === 0) throw error;
|
|
382
|
-
// Earlier
|
|
383
|
-
throw new EndpointDeliveryError('partial_delivery', `ICQQ
|
|
546
|
+
// Earlier sends completed; retrying can duplicate accepted messages, even without ids.
|
|
547
|
+
throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
|
|
384
548
|
}
|
|
385
549
|
const rawId = result?.message_id;
|
|
386
550
|
const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
|
|
387
551
|
? String(rawId) : undefined;
|
|
388
|
-
messageId
|
|
552
|
+
parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
|
|
389
553
|
this.#logger.info(
|
|
390
554
|
`send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`,
|
|
391
555
|
);
|
|
392
556
|
}
|
|
393
|
-
//
|
|
394
|
-
if (!messageId) {
|
|
395
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown');
|
|
557
|
+
// A resolved share/file call has no message identity; retain that unknown slot in the batch.
|
|
558
|
+
if (!parts.some(part => part.messageId)) {
|
|
559
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
|
|
396
560
|
}
|
|
397
|
-
return
|
|
561
|
+
return { parts };
|
|
398
562
|
} finally {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
* The card is the bot's own newest card message in the conversation history, so read the id
|
|
406
|
-
* from there (once more after a short wait, history can lag). Undefined keeps the delivery
|
|
407
|
-
* unconfirmed rather than inventing an id.
|
|
408
|
-
*/
|
|
409
|
-
async #findOwnCard(
|
|
410
|
-
history: () => Promise<readonly { message_id?: unknown; time?: number; message?: readonly { type?: string }[]; sender?: { user_id?: number }; user_id?: number }[]>,
|
|
411
|
-
sentAt: number,
|
|
412
|
-
): Promise<string | undefined> {
|
|
413
|
-
const self = Number(this.client.uin);
|
|
414
|
-
for (const wait of [0, 500]) {
|
|
415
|
-
if (wait) await new Promise(resolve => setTimeout(resolve, wait));
|
|
416
|
-
// A missing or throwing history API must not turn an accepted share into a send failure.
|
|
417
|
-
const messages = await Promise.resolve().then(history).catch(() => []);
|
|
418
|
-
const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
|
|
419
|
-
&& (item.time ?? 0) >= sentAt - 2
|
|
420
|
-
&& (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
|
|
421
|
-
if (card && card.message_id != null && String(card.message_id).trim()) return String(card.message_id);
|
|
563
|
+
try {
|
|
564
|
+
prepared.dispose();
|
|
565
|
+
} catch (error) {
|
|
566
|
+
// Temporary-file cleanup cannot change an already observed platform send outcome.
|
|
567
|
+
this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
|
|
568
|
+
}
|
|
422
569
|
}
|
|
423
|
-
return undefined;
|
|
424
570
|
}
|
|
425
571
|
|
|
426
572
|
async #sendNative(
|
|
@@ -430,16 +576,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
430
576
|
if (isIcqqShareElement(message)) {
|
|
431
577
|
switch (target.kind) {
|
|
432
578
|
case 'private': {
|
|
433
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
434
579
|
const friend = this.client.pickFriend(target.userId);
|
|
435
580
|
await friend.share(message);
|
|
436
|
-
return {
|
|
581
|
+
return {};
|
|
437
582
|
}
|
|
438
583
|
case 'group': {
|
|
439
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
440
584
|
const group = this.client.pickGroup(target.groupId);
|
|
441
585
|
await group.share(message);
|
|
442
|
-
return {
|
|
586
|
+
return {};
|
|
443
587
|
}
|
|
444
588
|
case 'temp':
|
|
445
589
|
await this.client.pickMember(target.groupId, target.userId).share(message);
|
|
@@ -610,12 +754,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
610
754
|
this.#bindSystemOnlineEvent();
|
|
611
755
|
this.client.on('system.offline', (event) => {
|
|
612
756
|
this.#logSystemEvent('offline', event);
|
|
757
|
+
this.#notifyOffline('offline', this.#options.config.autoReconnect);
|
|
613
758
|
});
|
|
614
759
|
this.client.on('system.offline.network', (event) => {
|
|
615
760
|
this.#logSystemEvent('offline.network', event);
|
|
761
|
+
this.#notifyOffline('network', this.#options.config.autoReconnect);
|
|
616
762
|
});
|
|
617
763
|
this.client.on('system.offline.kickoff', (event) => {
|
|
618
764
|
this.#logSystemEvent('offline.kickoff', event);
|
|
765
|
+
this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
|
|
766
|
+
this.#scheduleRelogin('kickoff');
|
|
619
767
|
});
|
|
620
768
|
this.client.on('system.login.qrcode', (event) => {
|
|
621
769
|
this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
|
|
@@ -630,6 +778,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
630
778
|
this.client.on('system.login.error', (event) => {
|
|
631
779
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
|
|
632
780
|
this.#logSystemEvent('login.error', event);
|
|
781
|
+
this.#notifyOffline('login_error', this.#options.config.autoReconnect);
|
|
782
|
+
this.#scheduleRelogin('login_error');
|
|
633
783
|
});
|
|
634
784
|
this.client.on('system.login.auth', (event) => {
|
|
635
785
|
this.#handleLoginChallenge('auth', event);
|
|
@@ -640,6 +790,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
640
790
|
this.client.on('system.online', (event) => {
|
|
641
791
|
if (this.#retiring) return;
|
|
642
792
|
this.#everOnline = true;
|
|
793
|
+
this.#clearReloginTimer();
|
|
794
|
+
this.#reloginAttempts = 0;
|
|
795
|
+
this.#notifyRecovered();
|
|
643
796
|
try {
|
|
644
797
|
this.#resolveStartOnline?.();
|
|
645
798
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
@@ -703,6 +856,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
703
856
|
? event as Record<string, unknown>
|
|
704
857
|
: null);
|
|
705
858
|
this.#logSystemEvent(`login.${step}`, event);
|
|
859
|
+
this.#clearReloginTimer();
|
|
860
|
+
this.#notifyChallenge(step);
|
|
706
861
|
void runIcqqLoginAssistStep({
|
|
707
862
|
assist: this.#options.loginAssist,
|
|
708
863
|
locale: this.#options.locale,
|
|
@@ -823,6 +978,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
823
978
|
channelType: normalized.channelType,
|
|
824
979
|
channelId: normalized.channelId,
|
|
825
980
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
981
|
+
name: normalized.groupName,
|
|
826
982
|
});
|
|
827
983
|
const quoteId = resolveIcqqQuoteIdFromEvent(data);
|
|
828
984
|
const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
@@ -942,13 +1098,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
942
1098
|
|
|
943
1099
|
#onNoticeEvent(payload: Record<string, unknown> | null): void {
|
|
944
1100
|
if (!payload || !isIcqqNoticePayload(payload)) return;
|
|
945
|
-
|
|
946
|
-
payload.time,
|
|
947
|
-
payload.operator_id ?? payload.user_id,
|
|
948
|
-
payload.notice_type ?? payload.type,
|
|
949
|
-
payload.sub_type,
|
|
950
|
-
].join('_');
|
|
951
|
-
if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`)) return;
|
|
1101
|
+
if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload))) return;
|
|
952
1102
|
this.#dispatchIcqqSideEvent(payload);
|
|
953
1103
|
}
|
|
954
1104
|
|
package/src/group-moderation.ts
CHANGED
|
@@ -8,17 +8,22 @@ export async function inspectGroupMember(
|
|
|
8
8
|
signal.throwIfAborted();
|
|
9
9
|
const group = numericId(groupId);
|
|
10
10
|
const user = numericId(userId);
|
|
11
|
+
const selfLookup = client.getGroupMemberInfo(group, client.uin, true);
|
|
11
12
|
const [self, member] = await Promise.all([
|
|
12
|
-
|
|
13
|
-
client.getGroupMemberInfo(group, user, true),
|
|
13
|
+
selfLookup,
|
|
14
|
+
user === client.uin ? selfLookup : client.getGroupMemberInfo(group, user, true),
|
|
14
15
|
]);
|
|
15
16
|
signal.throwIfAborted();
|
|
16
17
|
const joinedAt = member.join_time * 1000;
|
|
18
|
+
const platformRole = member.user_id === user && member.group_id === group
|
|
19
|
+
&& (member.role === 'owner' || member.role === 'admin' || member.role === 'member')
|
|
20
|
+
? member.role : undefined;
|
|
17
21
|
return {
|
|
18
22
|
canModerate: client.isOnline() && user !== client.uin
|
|
19
23
|
&& self.user_id === client.uin && self.group_id === group
|
|
20
24
|
&& member.user_id === user && member.group_id === group
|
|
21
25
|
&& (self.role === 'owner' || self.role === 'admin') && member.role === 'member',
|
|
26
|
+
...(platformRole ? { platformRole } : {}),
|
|
22
27
|
...(Number.isSafeInteger(joinedAt) && joinedAt > 0 && joinedAt <= Date.now() ? { joinedAt } : {}),
|
|
23
28
|
};
|
|
24
29
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Client } from '@icqqjs/icqq';
|
|
2
|
+
import type { GroupMemberProfile, GroupSceneFacts } from 'zhin.js/adapter';
|
|
3
|
+
|
|
4
|
+
/** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
|
|
5
|
+
export const ADMIN_SCAN_MAX_MEMBERS = 1000;
|
|
6
|
+
const MAX_ADMIN_IDS = 20;
|
|
7
|
+
|
|
8
|
+
function positiveId(value: unknown): string | undefined {
|
|
9
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function numericId(value: string): number {
|
|
13
|
+
const id = Number(value);
|
|
14
|
+
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id)) throw new Error('Invalid QQ identifier');
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
|
|
20
|
+
* admins. A roster failure leaves adminIds unknown instead of failing the other facts.
|
|
21
|
+
*/
|
|
22
|
+
export async function inspectGroupScene(client: Client, groupId: string, signal: AbortSignal): Promise<GroupSceneFacts> {
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
const group = numericId(groupId);
|
|
25
|
+
const info = await client.getGroupInfo(group, true);
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
if (info.group_id !== group) return {};
|
|
28
|
+
const memberCount = Number.isSafeInteger(info.member_count) && info.member_count > 0 ? info.member_count : undefined;
|
|
29
|
+
const ownerId = positiveId(info.owner_id);
|
|
30
|
+
let adminIds: string[] | undefined;
|
|
31
|
+
if (memberCount === undefined || memberCount <= ADMIN_SCAN_MAX_MEMBERS) {
|
|
32
|
+
try {
|
|
33
|
+
const roster = await client.getGroupMemberList(group);
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
adminIds = [...roster.values()]
|
|
36
|
+
.filter(member => member.role === 'admin' && member.group_id === group)
|
|
37
|
+
.map(member => positiveId(member.user_id))
|
|
38
|
+
.filter((id): id is string => id !== undefined)
|
|
39
|
+
.slice(0, MAX_ADMIN_IDS);
|
|
40
|
+
} catch {
|
|
41
|
+
signal.throwIfAborted();
|
|
42
|
+
adminIds = undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const name = typeof info.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
|
|
46
|
+
const nowSeconds = Date.now() / 1000;
|
|
47
|
+
return {
|
|
48
|
+
...(name ? { name } : {}),
|
|
49
|
+
...(ownerId ? { ownerId } : {}),
|
|
50
|
+
...(adminIds ? { adminIds } : {}),
|
|
51
|
+
...(memberCount !== undefined ? { memberCount } : {}),
|
|
52
|
+
...(typeof info.shutup_time_whole === 'number' ? { muteAll: info.shutup_time_whole > nowSeconds } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
|
|
57
|
+
export async function inspectGroupMemberProfile(
|
|
58
|
+
client: Client, groupId: string, userId: string, signal: AbortSignal,
|
|
59
|
+
): Promise<GroupMemberProfile> {
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
const group = numericId(groupId);
|
|
62
|
+
const user = numericId(userId);
|
|
63
|
+
const member = await client.getGroupMemberInfo(group, user, true);
|
|
64
|
+
signal.throwIfAborted();
|
|
65
|
+
if (member.user_id !== user || member.group_id !== group) return {};
|
|
66
|
+
const card = typeof member.card === 'string' && member.card.trim() ? member.card.trim() : undefined;
|
|
67
|
+
const title = typeof member.title === 'string' && member.title.trim() ? member.title.trim() : undefined;
|
|
68
|
+
const shutupSeconds = member.shutup_time;
|
|
69
|
+
return {
|
|
70
|
+
...(member.role === 'owner' || member.role === 'admin' || member.role === 'member' ? { platformRole: member.role } : {}),
|
|
71
|
+
...(card ? { card } : {}),
|
|
72
|
+
...(title ? { title } : {}),
|
|
73
|
+
...(typeof shutupSeconds === 'number' && Number.isFinite(shutupSeconds)
|
|
74
|
+
? { mutedUntil: shutupSeconds * 1000 > Date.now() ? shutupSeconds * 1000 : 0 } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/icqq-inbound.ts
CHANGED
|
@@ -129,6 +129,8 @@ export interface NormalizedIcqqInbound {
|
|
|
129
129
|
channelId: string;
|
|
130
130
|
/** 群临时会话:来源群号 */
|
|
131
131
|
channelParentGroupId?: string;
|
|
132
|
+
/** 群聊时来自 group_name */
|
|
133
|
+
groupName?: string;
|
|
132
134
|
userId: string;
|
|
133
135
|
nickname: string;
|
|
134
136
|
/** 群聊时来自 sender.role(私聊无) */
|
|
@@ -847,6 +849,9 @@ export function normalizeIcqqInboundMessage(
|
|
|
847
849
|
channelType,
|
|
848
850
|
channelId,
|
|
849
851
|
channelParentGroupId,
|
|
852
|
+
...(channelType === "group" && typeof ext.group_name === "string" && ext.group_name.trim()
|
|
853
|
+
? { groupName: ext.group_name.trim() }
|
|
854
|
+
: {}),
|
|
850
855
|
userId: String(userIdNum),
|
|
851
856
|
nickname,
|
|
852
857
|
senderRole,
|
package/src/icqq-inbox.ts
CHANGED
|
@@ -20,6 +20,21 @@ export function isIcqqNoticePayload(payload: unknown): boolean {
|
|
|
20
20
|
&& (postType === 'notice' || postType.startsWith('notice.'));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
|
|
25
|
+
* 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
|
|
26
|
+
*/
|
|
27
|
+
export function icqqNoticeDedupeKey(payload: Record<string, unknown>): string {
|
|
28
|
+
return `notice:${[
|
|
29
|
+
payload.time,
|
|
30
|
+
payload.group_id,
|
|
31
|
+
payload.user_id,
|
|
32
|
+
payload.operator_id,
|
|
33
|
+
payload.notice_type ?? payload.type,
|
|
34
|
+
payload.sub_type,
|
|
35
|
+
].join('_')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* request 事件载荷 → unified_inbox_request 行。
|
|
25
40
|
* platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
import type { PendingLoginTask } from '@zhin.js/core';
|
|
8
|
+
|
|
9
|
+
export interface IcqqLoginControlState {
|
|
10
|
+
readonly endpointId: string;
|
|
11
|
+
readonly transportState: string;
|
|
12
|
+
readonly online: boolean;
|
|
13
|
+
readonly pending: readonly PendingLoginTask[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IcqqLoginControl {
|
|
17
|
+
readonly endpointId: string;
|
|
18
|
+
state(): IcqqLoginControlState;
|
|
19
|
+
pending(): readonly PendingLoginTask[];
|
|
20
|
+
/** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
|
|
21
|
+
relogin(): Promise<void>;
|
|
22
|
+
submit(taskId: string, value: string | Record<string, unknown>): boolean;
|
|
23
|
+
cancel(taskId: string, reason?: string): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `endpoint id (QQ uin)` → control for the current generation. */
|
|
27
|
+
export const icqqLoginControlToken = createToken<Map<string, IcqqLoginControl>>(
|
|
28
|
+
'zhin.icqq.login-control',
|
|
29
|
+
'ICQQ login control surface for recovery commands',
|
|
30
|
+
);
|
package/src/index.ts
CHANGED
|
@@ -3,12 +3,14 @@ export {
|
|
|
3
3
|
formatInboundContent,
|
|
4
4
|
formatOutboundBody,
|
|
5
5
|
formatOutboundMessages,
|
|
6
|
+
formatOutboundMessageParts,
|
|
6
7
|
icqqInboundConversation,
|
|
7
8
|
icqqOutboundTarget,
|
|
8
9
|
type ActionResult,
|
|
9
10
|
type IcqqEndpointConfig,
|
|
10
11
|
type IcqqInboundMessage,
|
|
11
12
|
type IcqqWireSegment,
|
|
13
|
+
type IcqqOutboundMessagePart,
|
|
12
14
|
type ParsedIcqqSendTarget,
|
|
13
15
|
type ResolvedIcqqConfig,
|
|
14
16
|
} from './protocol.js';
|