@zhin.js/adapter-icqq 1.1.17 → 1.1.19
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 +83 -0
- package/README.md +55 -10
- package/adapters/icqq/index.js +3 -19
- package/adapters/icqq/index.ts +2 -25
- package/commands/icqq/endpoint/definition.js +3 -25
- package/commands/icqq/endpoint/definition.ts +3 -24
- 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/adapter.d.ts +13 -0
- package/lib/adapter.js +27 -0
- package/lib/client.d.ts +6 -9
- package/lib/client.js +1 -3
- package/lib/console-spec.d.ts +17 -0
- package/lib/console-spec.js +21 -0
- package/lib/console.d.ts +2 -0
- package/lib/console.js +13 -0
- package/lib/endpoint.d.ts +12 -15
- package/lib/endpoint.js +373 -164
- package/lib/group-moderation.d.ts +6 -3
- package/lib/group-moderation.js +9 -8
- package/lib/group-scene.d.ts +3 -3
- package/lib/group-scene.js +8 -7
- package/lib/icqq-login-assist.d.ts +3 -1
- package/lib/icqq-login-assist.js +3 -1
- package/lib/icqq-login-control.d.ts +24 -0
- package/lib/icqq-login-control.js +25 -0
- package/lib/index.d.ts +3 -2
- package/lib/index.js +2 -2
- package/lib/protocol.d.ts +13 -0
- package/lib/protocol.js +24 -9
- package/package.json +25 -17
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +3 -5
- package/schema.json +174 -0
- package/src/adapter.ts +29 -0
- package/src/client.ts +6 -11
- package/src/console-spec.ts +26 -0
- package/src/console.ts +17 -0
- package/src/endpoint.ts +381 -178
- package/src/group-moderation.ts +10 -10
- package/src/group-scene.ts +8 -8
- package/src/icqq-login-assist.ts +6 -2
- package/src/icqq-login-control.ts +42 -0
- package/src/index.ts +4 -1
- package/src/protocol.ts +35 -8
- package/lib/icqq-runtime-state.d.ts +0 -1
- package/lib/icqq-runtime-state.js +0 -6
- package/src/icqq-runtime-state.ts +0 -7
package/src/endpoint.ts
CHANGED
|
@@ -11,18 +11,33 @@ import {
|
|
|
11
11
|
import { inspect } from 'node:util';
|
|
12
12
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
createEndpointSpecification,
|
|
15
|
+
endpointSpec,
|
|
16
|
+
type Endpoint,
|
|
17
|
+
type EndpointSpecification,
|
|
15
18
|
type EndpointControl,
|
|
16
19
|
type EndpointManagement,
|
|
17
20
|
type EndpointPendingRequest,
|
|
18
21
|
type EndpointSendRequest,
|
|
19
22
|
type EndpointTransportState,
|
|
23
|
+
type AdapterOutageNotifier,
|
|
24
|
+
createEndpointOperation,
|
|
25
|
+
type EndpointManagementRequest,
|
|
26
|
+
type EndpointGroupRequest,
|
|
27
|
+
type EndpointApproveRequest,
|
|
28
|
+
type EndpointRejectRequest,
|
|
29
|
+
type EndpointMemberRequest,
|
|
30
|
+
type EndpointMuteRequest,
|
|
31
|
+
type EndpointAdminRequest,
|
|
32
|
+
type EndpointUserRequest,
|
|
33
|
+
type EndpointGroupNoticeRequest,
|
|
20
34
|
} from 'zhin.js/adapter';
|
|
21
35
|
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';
|
|
36
|
+
import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type LoginAssistEndpointIdentity, type PendingLoginTask } from '@zhin.js/core';
|
|
37
|
+
import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
|
|
24
38
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
25
39
|
import type { CapabilityId } from 'zhin.js';
|
|
40
|
+
import type { IcqqLoginControl } from './icqq-login-control.js';
|
|
26
41
|
import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
|
|
27
42
|
import { IcqqContentResolver } from './content-resolver.js';
|
|
28
43
|
import {
|
|
@@ -53,7 +68,7 @@ import {
|
|
|
53
68
|
} from './outbound-media.js';
|
|
54
69
|
import {
|
|
55
70
|
formatInboundContent,
|
|
56
|
-
|
|
71
|
+
formatOutboundMessageParts,
|
|
57
72
|
normalizeIcqqImageMedia,
|
|
58
73
|
icqqInboundConversation,
|
|
59
74
|
icqqOutboundTarget,
|
|
@@ -68,8 +83,12 @@ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
|
|
|
68
83
|
export interface IcqqEndpointOptions {
|
|
69
84
|
readonly id: CapabilityId;
|
|
70
85
|
readonly config: ResolvedIcqqConfig;
|
|
86
|
+
readonly signal?: AbortSignal;
|
|
87
|
+
readonly endpointIdentity?: LoginAssistEndpointIdentity;
|
|
71
88
|
readonly loginAssist: LoginAssist;
|
|
72
89
|
readonly locale?: import('zhin.js/i18n').Locale;
|
|
90
|
+
/** Out-of-band outage notifier; absent in tests/hosts without outbound. */
|
|
91
|
+
readonly notifier?: AdapterOutageNotifier;
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
const BOUND_EVENTS = [
|
|
@@ -91,8 +110,8 @@ const BOUND_EVENTS = [
|
|
|
91
110
|
'system.login.auth',
|
|
92
111
|
] as const;
|
|
93
112
|
|
|
94
|
-
export class IcqqEndpoint extends Endpoint
|
|
95
|
-
readonly
|
|
113
|
+
export class IcqqEndpoint extends ManagedIcqqClient implements Endpoint {
|
|
114
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
96
115
|
readonly #logger: ReturnType<typeof getAdapterLogger>;
|
|
97
116
|
readonly #options: IcqqEndpointOptions;
|
|
98
117
|
readonly endpointName: string;
|
|
@@ -113,69 +132,79 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
113
132
|
readonly #inflightInbound = new Set<Promise<void>>();
|
|
114
133
|
readonly #inboundOwner = new AsyncLocalStorage<symbol>();
|
|
115
134
|
readonly #contentResolver: IcqqContentResolver;
|
|
116
|
-
readonly content: EndpointContentPort;
|
|
135
|
+
readonly #content: EndpointContentPort;
|
|
136
|
+
readonly loginControl: IcqqLoginControl;
|
|
137
|
+
#reloginTimer?: ReturnType<typeof setTimeout>;
|
|
138
|
+
#reloginAttempts = 0;
|
|
139
|
+
#reloginInFlight?: Promise<void>;
|
|
140
|
+
#offlineNoticeAt = 0;
|
|
141
|
+
#challengeNoticeAt = 0;
|
|
117
142
|
|
|
118
|
-
readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
119
|
-
inspectGroupMember:
|
|
120
|
-
inspectGroupScene:
|
|
121
|
-
inspectGroupMemberProfile:
|
|
122
|
-
moderateGroupMember: (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
listFriends: async () =>
|
|
126
|
-
Array.from(this.client.fl.values()).map((f) => ({
|
|
143
|
+
readonly #management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
144
|
+
inspectGroupMember: request => inspectGroupMember(this, request),
|
|
145
|
+
inspectGroupScene: request => inspectGroupScene(this, request),
|
|
146
|
+
inspectGroupMemberProfile: request => inspectGroupMemberProfile(this, request),
|
|
147
|
+
moderateGroupMember: request => moderateGroupMember({ client: this, isActive: () => this.#open && !this.#retiring && !this.#stopped }, request),
|
|
148
|
+
listFriends: async (request: EndpointManagementRequest = {}) =>
|
|
149
|
+
createEndpointOperation(request).run(() => Array.from(this.fl.values()).map((f) => ({
|
|
127
150
|
user_id: f.user_id,
|
|
128
151
|
nickname: f.nickname,
|
|
129
152
|
remark: f.remark ?? '',
|
|
130
|
-
})),
|
|
131
|
-
listGroups: async () =>
|
|
132
|
-
Array.from(this.
|
|
153
|
+
}))),
|
|
154
|
+
listGroups: async (request: EndpointManagementRequest = {}) =>
|
|
155
|
+
createEndpointOperation(request).run(() => Array.from(this.gl.values()).map((g) => ({
|
|
133
156
|
group_id: g.group_id,
|
|
134
157
|
name: g.group_name,
|
|
135
|
-
})),
|
|
136
|
-
listChannels: async () => {
|
|
137
|
-
|
|
138
|
-
|
|
158
|
+
}))),
|
|
159
|
+
listChannels: async (request: EndpointManagementRequest = {}) => {
|
|
160
|
+
const operation = createEndpointOperation(request);
|
|
161
|
+
await operation.run(() => this.#guildCatalog.syncAll(this));
|
|
162
|
+
return operation.run(() => this.#guildCatalog.getGuildChannelList());
|
|
139
163
|
},
|
|
140
|
-
listGroupMembers: async (groupId) =>
|
|
141
|
-
Array.from((await this.
|
|
142
|
-
listRequests: () => this.#listPendingRequests(),
|
|
143
|
-
approveRequest:
|
|
144
|
-
rejectRequest:
|
|
145
|
-
kickGroupMember: async (groupId, userId) => {
|
|
146
|
-
|
|
164
|
+
listGroupMembers: async ({ groupId, signal }: EndpointGroupRequest) =>
|
|
165
|
+
Array.from((await createEndpointOperation({ signal }).run(() => this.getGroupMemberList(Number(groupId)))).values()),
|
|
166
|
+
listRequests: (request: EndpointManagementRequest = {}) => createEndpointOperation(request).run(() => this.#listPendingRequests()),
|
|
167
|
+
approveRequest: request => this.#approveRequest(request),
|
|
168
|
+
rejectRequest: request => this.#rejectRequest(request),
|
|
169
|
+
kickGroupMember: async ({ groupId, userId, signal }: EndpointMemberRequest) => {
|
|
170
|
+
const operation = createEndpointOperation({ signal });
|
|
171
|
+
await operation.run(() => this.setGroupKick(Number(groupId), Number(userId)));
|
|
147
172
|
},
|
|
148
|
-
muteGroupMember: async (groupId, userId, duration) => {
|
|
149
|
-
|
|
173
|
+
muteGroupMember: async ({ groupId, userId, durationSeconds: duration, signal }: EndpointMuteRequest) => {
|
|
174
|
+
const operation = createEndpointOperation({ signal });
|
|
175
|
+
await operation.run(() => this.setGroupBan(Number(groupId), Number(userId), duration));
|
|
150
176
|
},
|
|
151
|
-
setGroupAdmin: async (groupId, userId, enabled) => {
|
|
152
|
-
|
|
177
|
+
setGroupAdmin: async ({ groupId, userId, enabled, signal }: EndpointAdminRequest) => {
|
|
178
|
+
const operation = createEndpointOperation({ signal });
|
|
179
|
+
await operation.run(() => this.setGroupAdmin(Number(groupId), Number(userId), enabled));
|
|
153
180
|
},
|
|
154
|
-
deleteFriend: async (userId) => {
|
|
155
|
-
|
|
181
|
+
deleteFriend: async ({ userId, signal }: EndpointUserRequest) => {
|
|
182
|
+
const operation = createEndpointOperation({ signal });
|
|
183
|
+
await operation.run(() => this.deleteFriend(Number(userId)));
|
|
156
184
|
},
|
|
157
|
-
sendGroupNotice: async (groupId, content) => {
|
|
158
|
-
|
|
185
|
+
sendGroupNotice: async ({ groupId, content, signal }: EndpointGroupNoticeRequest) => {
|
|
186
|
+
const operation = createEndpointOperation({ signal });
|
|
187
|
+
if (await operation.run(() => this.sendGroupNotice(Number(groupId), content)) === false) {
|
|
159
188
|
throw new Error('QQ did not confirm the group notice');
|
|
160
189
|
}
|
|
161
190
|
},
|
|
162
191
|
});
|
|
163
192
|
|
|
164
|
-
readonly control: EndpointControl = Object.freeze({
|
|
165
|
-
recall: async (message
|
|
193
|
+
readonly #control: EndpointControl = Object.freeze<EndpointControl>({
|
|
194
|
+
recall: async ({ message, signal }) => {
|
|
166
195
|
if (!message.id || message.id.startsWith('outbound:')) return;
|
|
167
|
-
if (!await this.
|
|
196
|
+
if (!await createEndpointOperation({ signal }).run(() => this.deleteMsg(message.id))) throw new Error('QQ did not confirm message recall');
|
|
168
197
|
},
|
|
169
|
-
addReaction: async (message
|
|
198
|
+
addReaction: async ({ message, emoji, signal }) => {
|
|
170
199
|
const target = resolveIcqqGroupReactionTarget(message);
|
|
171
200
|
if (!target) return null;
|
|
172
201
|
try {
|
|
173
202
|
// Do not await protocol ACK — packet timeout must not stall the AI reply.
|
|
174
|
-
|
|
175
|
-
'add',
|
|
176
|
-
|
|
177
|
-
);
|
|
203
|
+
await createEndpointOperation({ signal }).run(() => {
|
|
204
|
+
this.#ignoreReactionAck('add', this.pickGroup(target.groupId).setReaction(target.seq, emoji));
|
|
205
|
+
});
|
|
178
206
|
} catch (error) {
|
|
207
|
+
if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
|
|
179
208
|
this.#logger.debug(formatCompact({
|
|
180
209
|
op: 'reaction_add_failed',
|
|
181
210
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -183,15 +212,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
183
212
|
}
|
|
184
213
|
return emoji;
|
|
185
214
|
},
|
|
186
|
-
removeReaction: async (message
|
|
215
|
+
removeReaction: async ({ message, reactionId, signal }) => {
|
|
187
216
|
const target = resolveIcqqGroupReactionTarget(message);
|
|
188
217
|
if (!target) return;
|
|
189
218
|
try {
|
|
190
|
-
|
|
191
|
-
'remove',
|
|
192
|
-
|
|
193
|
-
);
|
|
219
|
+
await createEndpointOperation({ signal }).run(() => {
|
|
220
|
+
this.#ignoreReactionAck('remove', this.pickGroup(target.groupId).delReaction(target.seq, reactionId));
|
|
221
|
+
});
|
|
194
222
|
} catch (error) {
|
|
223
|
+
if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
|
|
195
224
|
this.#logger.debug(formatCompact({
|
|
196
225
|
op: 'reaction_remove_failed',
|
|
197
226
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -201,15 +230,36 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
201
230
|
});
|
|
202
231
|
|
|
203
232
|
constructor(options: IcqqEndpointOptions) {
|
|
204
|
-
super()
|
|
205
|
-
const nativeConfig = resolveNativeClientConfig(options.config);
|
|
206
|
-
this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig,
|
|
233
|
+
super(Number(options.config.id), resolveNativeClientConfig(options.config),
|
|
207
234
|
(operation, error) => this.#handleBackgroundFailure(operation, error));
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
const name = () => this.endpointName;
|
|
236
|
+
const state = () => this.#transportState;
|
|
237
|
+
const content = () => this.#content;
|
|
238
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
239
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(), stop: () => this.#stopFramework(),
|
|
240
|
+
send: request => this.#sendFramework(request), get name() { return name(); },
|
|
241
|
+
get transportState() { return state(); },
|
|
242
|
+
management: this.#management, control: this.#control,
|
|
243
|
+
get content() { return content(); },
|
|
244
|
+
});
|
|
245
|
+
this.#contentResolver = new IcqqContentResolver(this);
|
|
246
|
+
this.#content = this.#contentResolver.port;
|
|
210
247
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
211
248
|
this.#options = options;
|
|
212
249
|
this.endpointName = options.config.id;
|
|
250
|
+
this.loginControl = Object.freeze({
|
|
251
|
+
endpointId: this.endpointName,
|
|
252
|
+
state: () => ({
|
|
253
|
+
endpointId: this.endpointName,
|
|
254
|
+
transportState: this.#transportState,
|
|
255
|
+
online: this.isOnline(),
|
|
256
|
+
pending: this.#pendingLoginTasks(),
|
|
257
|
+
}),
|
|
258
|
+
pending: () => this.#pendingLoginTasks(),
|
|
259
|
+
relogin: async () => { this.#options.signal?.throwIfAborted(); await this.relogin(); },
|
|
260
|
+
submit: (taskId: string, value: string | Record<string, unknown>) => !this.#options.signal?.aborted && this.#options.loginAssist.submit(taskId, value, this.#options.endpointIdentity),
|
|
261
|
+
cancel: (taskId: string, reason?: string) => !this.#options.signal?.aborted && this.#options.loginAssist.cancel(taskId, reason, this.#options.endpointIdentity),
|
|
262
|
+
});
|
|
213
263
|
}
|
|
214
264
|
|
|
215
265
|
#handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
|
|
@@ -221,11 +271,152 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
221
271
|
: typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
|
|
222
272
|
...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
|
|
223
273
|
}));
|
|
224
|
-
if (operation === 'sso-heartbeat' && !this.
|
|
274
|
+
if (operation === 'sso-heartbeat' && !this.isOnline()) return;
|
|
225
275
|
if (operation === 'login' && !this.#everOnline) return;
|
|
226
276
|
// Delegate to the SDK's existing transport termination, offline event and
|
|
227
277
|
// reconn_interval policy. No account-level logout or second retry timer.
|
|
228
|
-
this.
|
|
278
|
+
this.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Force a login attempt (manual recovery command). */
|
|
282
|
+
relogin(): Promise<void> {
|
|
283
|
+
return this.#startRelogin(true);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#startRelogin(resetAttempts: boolean): Promise<void> {
|
|
287
|
+
if (this.#stopped || this.#retiring) {
|
|
288
|
+
return Promise.reject(new Error('ICQQ endpoint 已停止'));
|
|
289
|
+
}
|
|
290
|
+
if (this.#reloginInFlight) return this.#reloginInFlight;
|
|
291
|
+
if (resetAttempts) this.#reloginAttempts = 0;
|
|
292
|
+
const task = this.#performRelogin();
|
|
293
|
+
this.#reloginInFlight = task;
|
|
294
|
+
void task.catch(() => undefined).finally(() => {
|
|
295
|
+
if (this.#reloginInFlight === task) this.#reloginInFlight = undefined;
|
|
296
|
+
if (this.#started && !this.#stopped && !this.#retiring && !this.isOnline()) {
|
|
297
|
+
this.#scheduleRelogin('relogin_failed');
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return task;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async #performRelogin(): Promise<void> {
|
|
304
|
+
this.#clearReloginTimer();
|
|
305
|
+
this.cancelReconnect();
|
|
306
|
+
this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
|
|
307
|
+
try {
|
|
308
|
+
await this.login(this.#options.config.password);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
this.#logger.warn(formatCompact({
|
|
311
|
+
op: 'icqq_relogin_failed',
|
|
312
|
+
endpoint: this.endpointName,
|
|
313
|
+
error: error instanceof Error ? error.message : String(error),
|
|
314
|
+
}));
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#scheduleRelogin(reason: string): void {
|
|
320
|
+
if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring) return;
|
|
321
|
+
if (this.#reloginTimer || this.#reloginInFlight) return;
|
|
322
|
+
if (this.#reloginAttempts >= 8) return;
|
|
323
|
+
const attempt = this.#reloginAttempts++;
|
|
324
|
+
const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
|
|
325
|
+
this.#logger.info(formatCompact({
|
|
326
|
+
op: 'icqq_relogin_scheduled',
|
|
327
|
+
endpoint: this.endpointName,
|
|
328
|
+
reason,
|
|
329
|
+
attempt: attempt + 1,
|
|
330
|
+
delay_ms: delayMs,
|
|
331
|
+
}));
|
|
332
|
+
this.#reloginTimer = setTimeout(() => {
|
|
333
|
+
this.#reloginTimer = undefined;
|
|
334
|
+
void this.#startRelogin(false).catch(() => {
|
|
335
|
+
// #performRelogin already scheduled the next attempt.
|
|
336
|
+
});
|
|
337
|
+
}, delayMs);
|
|
338
|
+
this.#reloginTimer.unref?.();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#clearReloginTimer(): void {
|
|
342
|
+
if (!this.#reloginTimer) return;
|
|
343
|
+
clearTimeout(this.#reloginTimer);
|
|
344
|
+
this.#reloginTimer = undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#pendingLoginTasks(): readonly PendingLoginTask[] {
|
|
348
|
+
if (this.#options.signal?.aborted) return [];
|
|
349
|
+
return this.#options.loginAssist.listPending()
|
|
350
|
+
.filter((task) => task.adapter === 'icqq' && (this.#options.endpointIdentity
|
|
351
|
+
? task.endpointIdentity?.canonicalId === this.#options.endpointIdentity.canonicalId
|
|
352
|
+
&& task.endpointIdentity.instanceId === this.#options.endpointIdentity.instanceId
|
|
353
|
+
&& task.endpointIdentity.epoch === this.#options.endpointIdentity.epoch
|
|
354
|
+
&& task.endpointIdentity.incarnationId === this.#options.endpointIdentity.incarnationId
|
|
355
|
+
: task.endpointKey === this.endpointName));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#notifyOffline(reason: string, willRetry: boolean): void {
|
|
359
|
+
const notifier = this.#options.notifier;
|
|
360
|
+
if (!notifier?.configured) return;
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
if (now - this.#offlineNoticeAt < 60_000) return;
|
|
363
|
+
this.#offlineNoticeAt = now;
|
|
364
|
+
const recovery = willRetry
|
|
365
|
+
? '已开始自动重新登录。'
|
|
366
|
+
: '请手动重新登录。';
|
|
367
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
368
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
369
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
370
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
|
|
371
|
+
+ `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
|
|
372
|
+
+ `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
|
|
373
|
+
void notifier.send(text).then((sent) => {
|
|
374
|
+
if (sent) return;
|
|
375
|
+
this.#logger.warn(formatCompact({
|
|
376
|
+
op: 'icqq_notify_failed',
|
|
377
|
+
endpoint: this.endpointName,
|
|
378
|
+
reason,
|
|
379
|
+
}));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#notifyChallenge(step: 'qrcode' | 'slider' | 'device' | 'auth'): void {
|
|
384
|
+
const notifier = this.#options.notifier;
|
|
385
|
+
if (!notifier?.configured) return;
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (now - this.#challengeNoticeAt < 30_000) return;
|
|
388
|
+
this.#challengeNoticeAt = now;
|
|
389
|
+
const label = step === 'qrcode' ? '二维码验证'
|
|
390
|
+
: step === 'slider' ? '滑块验证'
|
|
391
|
+
: step === 'device' ? '设备确认'
|
|
392
|
+
: '登录确认';
|
|
393
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
394
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
395
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
396
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
|
|
397
|
+
+ (step === 'qrcode' || step === 'auth'
|
|
398
|
+
? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
|
|
399
|
+
: `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
|
|
400
|
+
void notifier.send(text).then((sent) => {
|
|
401
|
+
if (sent) return;
|
|
402
|
+
this.#logger.warn(formatCompact({
|
|
403
|
+
op: 'icqq_notify_failed',
|
|
404
|
+
endpoint: this.endpointName,
|
|
405
|
+
reason: `login_${step}`,
|
|
406
|
+
}));
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#notifyRecovered(): void {
|
|
411
|
+
const notifier = this.#options.notifier;
|
|
412
|
+
const hadOfflineNotice = this.#offlineNoticeAt > 0;
|
|
413
|
+
this.#offlineNoticeAt = 0;
|
|
414
|
+
this.#challengeNoticeAt = 0;
|
|
415
|
+
if (!notifier?.configured || !hadOfflineNotice) return;
|
|
416
|
+
void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
|
|
417
|
+
if (sent) return;
|
|
418
|
+
this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
|
|
419
|
+
});
|
|
229
420
|
}
|
|
230
421
|
|
|
231
422
|
#ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
|
|
@@ -237,15 +428,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
237
428
|
});
|
|
238
429
|
}
|
|
239
430
|
|
|
240
|
-
get transportState(): EndpointTransportState {
|
|
431
|
+
get #transportState(): EndpointTransportState {
|
|
241
432
|
if (this.#stopped || this.#retiring) return 'stopped';
|
|
242
433
|
if (!this.#started) return 'idle';
|
|
243
|
-
if (this.
|
|
434
|
+
if (this.isOnline()) return 'open';
|
|
244
435
|
if (!this.#everOnline) return 'connecting';
|
|
245
436
|
return this.#options.config.autoReconnect ? 'reconnecting' : 'closed';
|
|
246
437
|
}
|
|
247
438
|
|
|
248
|
-
async
|
|
439
|
+
async #startFramework(signal: AbortSignal): Promise<void> {
|
|
249
440
|
if (this.#started) return;
|
|
250
441
|
signal.throwIfAborted();
|
|
251
442
|
this.#stopped = false;
|
|
@@ -254,7 +445,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
254
445
|
this.#retirementEpoch += 1;
|
|
255
446
|
this.#retirementArmed = false;
|
|
256
447
|
this.#retiring = false;
|
|
257
|
-
this.
|
|
448
|
+
this.off('system.online');
|
|
258
449
|
this.#started = true;
|
|
259
450
|
let resolveOnline!: () => void;
|
|
260
451
|
const onlineReady = new Promise<void>((resolve) => {
|
|
@@ -265,16 +456,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
265
456
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
266
457
|
try {
|
|
267
458
|
this.#bindClientEvents();
|
|
268
|
-
await raceAbort(Promise.resolve(this.
|
|
459
|
+
await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
|
|
269
460
|
await onlineReady;
|
|
270
461
|
signal.throwIfAborted();
|
|
271
462
|
await this.#pullPendingSystemMessages(signal);
|
|
272
463
|
signal.throwIfAborted();
|
|
273
464
|
this.#logger.info(
|
|
274
|
-
`connected (direct) | friends: ${this.
|
|
465
|
+
`connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
|
|
275
466
|
);
|
|
276
467
|
} catch (error) {
|
|
277
|
-
|
|
468
|
+
this.#notifyOffline('start_failed', false);
|
|
469
|
+
await this.#stopFramework();
|
|
278
470
|
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
279
471
|
throw error;
|
|
280
472
|
} finally {
|
|
@@ -283,11 +475,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
283
475
|
}
|
|
284
476
|
}
|
|
285
477
|
|
|
286
|
-
|
|
478
|
+
#openFramework(): void {
|
|
287
479
|
if (this.#retirementArmed) {
|
|
288
480
|
this.#retirementEpoch += 1;
|
|
289
481
|
this.#retirementArmed = false;
|
|
290
|
-
this.
|
|
482
|
+
this.off('system.online');
|
|
291
483
|
this.#bindSystemOnlineEvent();
|
|
292
484
|
}
|
|
293
485
|
this.#retiring = false;
|
|
@@ -300,15 +492,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
300
492
|
}
|
|
301
493
|
}
|
|
302
494
|
|
|
303
|
-
async
|
|
495
|
+
async #closeFramework(): Promise<void> {
|
|
304
496
|
this.#assertExternalLifecycle('close');
|
|
305
497
|
this.#armRetirementFence();
|
|
306
498
|
await Promise.allSettled([...this.#inflightInbound]);
|
|
307
499
|
}
|
|
308
500
|
|
|
309
|
-
async
|
|
310
|
-
this.#stopped = true;
|
|
501
|
+
async #stopFramework(): Promise<void> {
|
|
311
502
|
this.#assertExternalLifecycle('stop');
|
|
503
|
+
this.#stopped = true;
|
|
504
|
+
this.#clearReloginTimer();
|
|
312
505
|
this.#armRetirementFence();
|
|
313
506
|
await Promise.allSettled([...this.#inflightInbound]);
|
|
314
507
|
this.#resolveStartOnline?.();
|
|
@@ -319,7 +512,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
319
512
|
for (const event of BOUND_EVENTS) {
|
|
320
513
|
// The retirement guard owns this matcher until a late login handshake
|
|
321
514
|
// has finished. start()/open() replace it with the normal listener.
|
|
322
|
-
if (event !== 'system.online') this.
|
|
515
|
+
if (event !== 'system.online') this.off(event);
|
|
323
516
|
}
|
|
324
517
|
// `Client.logout()` sends an account-level unregister packet before it
|
|
325
518
|
// closes this transport. During config HMR the replacement endpoint for
|
|
@@ -327,7 +520,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
327
520
|
// also closes the replacement session. Endpoint ownership is transport
|
|
328
521
|
// scoped: retire only this Client's TCP connection.
|
|
329
522
|
try {
|
|
330
|
-
this.
|
|
523
|
+
this.terminate();
|
|
331
524
|
} catch { /* ignore */ }
|
|
332
525
|
this.#inboundDeduper.clear();
|
|
333
526
|
this.#sideEventDeduper.clear();
|
|
@@ -344,36 +537,47 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
344
537
|
const retirementEpoch = ++this.#retirementEpoch;
|
|
345
538
|
// ICQQ owns reconnect through this per-Client timer. Clear it before any
|
|
346
539
|
// asynchronous drain so a queued callback cannot enter while close waits.
|
|
347
|
-
this.
|
|
540
|
+
this.cancelReconnect();
|
|
348
541
|
// A login callback may already be inside the SDK handshake, which has no
|
|
349
542
|
// abort API. Replace the normal online listener with an epoch-owned guard
|
|
350
543
|
// before draining inbound work. Transport termination is account-local;
|
|
351
544
|
// logout would also unregister an HMR replacement using the same UIN.
|
|
352
|
-
this.
|
|
353
|
-
this.
|
|
545
|
+
this.off('system.online');
|
|
546
|
+
this.on('system.online', () => {
|
|
354
547
|
if (this.#retirementEpoch !== retirementEpoch || !this.#retiring) return;
|
|
355
548
|
try {
|
|
356
|
-
this.
|
|
549
|
+
this.terminate();
|
|
357
550
|
} catch { /* already closed */ }
|
|
358
551
|
});
|
|
359
552
|
}
|
|
360
553
|
|
|
361
|
-
async
|
|
554
|
+
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<EndpointSendResult> {
|
|
362
555
|
if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
|
|
556
|
+
const operation = createEndpointOperation({ signal });
|
|
363
557
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
364
|
-
const prepared = isIcqqSegmentPayload(payload)
|
|
558
|
+
const prepared = await operation.prepare(() => isIcqqSegmentPayload(payload)
|
|
365
559
|
? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
|
|
366
|
-
: { content: payload, dispose() {} };
|
|
560
|
+
: { content: payload, dispose() {} });
|
|
561
|
+
// Cancellation ends our wait, not the SDK's use of temporary media. The actual native
|
|
562
|
+
// promises own that resource until they settle, including a late rejection after cancellation.
|
|
563
|
+
const pendingSends = new Set<Promise<{ message_id?: unknown }>>();
|
|
564
|
+
const sendNative = (target: ReturnType<typeof icqqOutboundTarget>, message: Sendable) => {
|
|
565
|
+
const task = this.#sendNative(target, message);
|
|
566
|
+
pendingSends.add(task);
|
|
567
|
+
const settled = () => { pendingSends.delete(task); };
|
|
568
|
+
void task.then(settled, settled);
|
|
569
|
+
return task;
|
|
570
|
+
};
|
|
367
571
|
try {
|
|
368
572
|
// Standalone elements (share / file / …) are split into ordered messages.
|
|
369
|
-
const messages =
|
|
573
|
+
const messages = formatOutboundMessageParts(prepared.content);
|
|
370
574
|
const parsed = icqqOutboundTarget(conversation);
|
|
371
|
-
|
|
372
|
-
for (const [index, message] of messages.entries()) {
|
|
575
|
+
const parts: EndpointSendPart[] = [];
|
|
576
|
+
for (const [index, { message, sourceIndices }] of messages.entries()) {
|
|
373
577
|
const preview = formatIcqqSendablePreview(message);
|
|
374
578
|
let result: { message_id?: unknown };
|
|
375
579
|
try {
|
|
376
|
-
result = await
|
|
580
|
+
result = await operation.run(() => sendNative(parsed, message));
|
|
377
581
|
} catch (error) {
|
|
378
582
|
this.#logger.warn(formatCompact({
|
|
379
583
|
op: 'icqq_send_failed',
|
|
@@ -383,48 +587,32 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
383
587
|
preview: truncatePreview(preview, 120),
|
|
384
588
|
}));
|
|
385
589
|
if (index === 0) throw error;
|
|
386
|
-
// Earlier
|
|
387
|
-
throw new EndpointDeliveryError('partial_delivery', `ICQQ
|
|
590
|
+
// Earlier sends completed; retrying can duplicate accepted messages, even without ids.
|
|
591
|
+
throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
|
|
388
592
|
}
|
|
389
593
|
const rawId = result?.message_id;
|
|
390
594
|
const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
|
|
391
595
|
? String(rawId) : undefined;
|
|
392
|
-
messageId
|
|
596
|
+
parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
|
|
393
597
|
this.#logger.info(
|
|
394
598
|
`send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`,
|
|
395
599
|
);
|
|
396
600
|
}
|
|
397
|
-
//
|
|
398
|
-
if (!messageId) {
|
|
399
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown');
|
|
601
|
+
// A resolved share/file call has no message identity; retain that unknown slot in the batch.
|
|
602
|
+
if (!parts.some(part => part.messageId)) {
|
|
603
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
|
|
400
604
|
}
|
|
401
|
-
return
|
|
605
|
+
return { parts };
|
|
402
606
|
} finally {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
* unconfirmed rather than inventing an id.
|
|
412
|
-
*/
|
|
413
|
-
async #findOwnCard(
|
|
414
|
-
history: () => Promise<readonly { message_id?: unknown; time?: number; message?: readonly { type?: string }[]; sender?: { user_id?: number }; user_id?: number }[]>,
|
|
415
|
-
sentAt: number,
|
|
416
|
-
): Promise<string | undefined> {
|
|
417
|
-
const self = Number(this.client.uin);
|
|
418
|
-
for (const wait of [0, 500]) {
|
|
419
|
-
if (wait) await new Promise(resolve => setTimeout(resolve, wait));
|
|
420
|
-
// A missing or throwing history API must not turn an accepted share into a send failure.
|
|
421
|
-
const messages = await Promise.resolve().then(history).catch(() => []);
|
|
422
|
-
const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
|
|
423
|
-
&& (item.time ?? 0) >= sentAt - 2
|
|
424
|
-
&& (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
|
|
425
|
-
if (card && card.message_id != null && String(card.message_id).trim()) return String(card.message_id);
|
|
607
|
+
const disposeMedia = () => {
|
|
608
|
+
try { prepared.dispose(); } catch (error) {
|
|
609
|
+
// Temporary-file cleanup cannot change an observed send result.
|
|
610
|
+
this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
if (pendingSends.size) void Promise.allSettled([...pendingSends]).then(disposeMedia);
|
|
614
|
+
else disposeMedia();
|
|
426
615
|
}
|
|
427
|
-
return undefined;
|
|
428
616
|
}
|
|
429
617
|
|
|
430
618
|
async #sendNative(
|
|
@@ -434,22 +622,20 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
434
622
|
if (isIcqqShareElement(message)) {
|
|
435
623
|
switch (target.kind) {
|
|
436
624
|
case 'private': {
|
|
437
|
-
const
|
|
438
|
-
const friend = this.client.pickFriend(target.userId);
|
|
625
|
+
const friend = this.pickFriend(target.userId);
|
|
439
626
|
await friend.share(message);
|
|
440
|
-
return {
|
|
627
|
+
return {};
|
|
441
628
|
}
|
|
442
629
|
case 'group': {
|
|
443
|
-
const
|
|
444
|
-
const group = this.client.pickGroup(target.groupId);
|
|
630
|
+
const group = this.pickGroup(target.groupId);
|
|
445
631
|
await group.share(message);
|
|
446
|
-
return {
|
|
632
|
+
return {};
|
|
447
633
|
}
|
|
448
634
|
case 'temp':
|
|
449
|
-
await this.
|
|
635
|
+
await this.pickMember(target.groupId, target.userId).share(message);
|
|
450
636
|
return {};
|
|
451
637
|
case 'channel': {
|
|
452
|
-
const channel = this.
|
|
638
|
+
const channel = this.pickGuild(target.guildId).channels.get(target.channelId);
|
|
453
639
|
if (!channel) {
|
|
454
640
|
throw new TypeError(`ICQQ guild channel is unavailable: ${target.guildId}/${target.channelId}`);
|
|
455
641
|
}
|
|
@@ -461,11 +647,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
461
647
|
if (isIcqqFileElement(message)) {
|
|
462
648
|
switch (target.kind) {
|
|
463
649
|
case 'private': {
|
|
464
|
-
await this.
|
|
650
|
+
await this.pickFriend(target.userId).sendFile(message.file, message.name);
|
|
465
651
|
return {};
|
|
466
652
|
}
|
|
467
653
|
case 'group': {
|
|
468
|
-
await this.
|
|
654
|
+
await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
|
|
469
655
|
return {};
|
|
470
656
|
}
|
|
471
657
|
case 'temp':
|
|
@@ -475,13 +661,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
475
661
|
}
|
|
476
662
|
switch (target.kind) {
|
|
477
663
|
case 'private':
|
|
478
|
-
return this.
|
|
664
|
+
return this.sendPrivateMsg(target.userId, message);
|
|
479
665
|
case 'group':
|
|
480
|
-
return this.
|
|
666
|
+
return this.sendGroupMsg(target.groupId, message);
|
|
481
667
|
case 'temp':
|
|
482
|
-
return this.
|
|
668
|
+
return this.sendTempMsg(target.groupId, target.userId, message);
|
|
483
669
|
case 'channel':
|
|
484
|
-
return this.
|
|
670
|
+
return this.sendGuildMsg(target.guildId, target.channelId, message) as unknown as { message_id?: unknown };
|
|
485
671
|
}
|
|
486
672
|
}
|
|
487
673
|
|
|
@@ -496,7 +682,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
496
682
|
this.#inflightInbound.add(settlement);
|
|
497
683
|
let operation: Promise<void>;
|
|
498
684
|
try {
|
|
499
|
-
operation = this.#inboundOwner.run(owner, run);
|
|
685
|
+
operation = this.#inboundOwner.run(owner, () => this[endpointSpec].events.run(run));
|
|
500
686
|
} catch (error) {
|
|
501
687
|
this.#inflightInbound.delete(settlement);
|
|
502
688
|
settle();
|
|
@@ -550,7 +736,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
550
736
|
+ (mentioned ? ' (mentioned)' : '')
|
|
551
737
|
+ ` | ${truncatePreview(msg.content, 80)}`,
|
|
552
738
|
);
|
|
553
|
-
await this.emit('message.receive', {
|
|
739
|
+
await this[endpointSpec].events.emit('message.receive', {
|
|
740
|
+
event: msg.event,
|
|
741
|
+
platformSentAt: Number.isFinite(msg.platformSentAt) ? msg.platformSentAt : undefined,
|
|
554
742
|
conversation,
|
|
555
743
|
message: { conversation, id: msg.id },
|
|
556
744
|
content: msg.content,
|
|
@@ -577,79 +765,81 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
577
765
|
#bindClientEvents(): void {
|
|
578
766
|
this.#bindNativeEvents();
|
|
579
767
|
const safe = (label: string, fn: () => void): void => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
this.#logger.warn(formatCompact({
|
|
584
|
-
op: 'icqq_event_handler',
|
|
585
|
-
endpoint: this.endpointName,
|
|
586
|
-
event: label,
|
|
587
|
-
error: error instanceof Error ? error.message : String(error),
|
|
588
|
-
}));
|
|
589
|
-
}
|
|
768
|
+
void this[endpointSpec].events.run(fn).catch(() => {
|
|
769
|
+
this.#logger.debug(formatCompact({ op: 'icqq_event_not_admitted', endpoint: this.endpointName, event: label }));
|
|
770
|
+
});
|
|
590
771
|
};
|
|
591
|
-
this.
|
|
772
|
+
this.on('message.private', (event) => {
|
|
592
773
|
this.#startMessageEvent('message.private', event);
|
|
593
774
|
});
|
|
594
|
-
this.
|
|
775
|
+
this.on('message.group', (event) => {
|
|
595
776
|
this.#startMessageEvent('message.group', event);
|
|
596
777
|
});
|
|
597
|
-
this.
|
|
598
|
-
void this.#handleGuildEvent(event).catch((error) => {
|
|
778
|
+
this.on('message.guild', (event) => {
|
|
779
|
+
void this.#trackInbound(() => this.#handleGuildEvent(event)).catch((error) => {
|
|
599
780
|
this.#logInboundHandlerFailure('message.guild', error);
|
|
600
781
|
});
|
|
601
782
|
});
|
|
602
|
-
this.
|
|
783
|
+
this.on('request.friend', (event) => safe('request.friend', () => {
|
|
603
784
|
this.#onRequestEvent(serializeIcqqEvent(event));
|
|
604
785
|
}));
|
|
605
|
-
this.
|
|
786
|
+
this.on('request.group', (event) => safe('request.group', () => {
|
|
606
787
|
this.#onRequestEvent(serializeIcqqEvent(event));
|
|
607
788
|
}));
|
|
608
|
-
this.
|
|
789
|
+
this.on('notice.friend', (event) => safe('notice.friend', () => {
|
|
609
790
|
this.#onNoticeEvent(serializeIcqqEvent(event));
|
|
610
791
|
}));
|
|
611
|
-
this.
|
|
792
|
+
this.on('notice.group', (event) => safe('notice.group', () => {
|
|
612
793
|
this.#onNoticeEvent(serializeIcqqEvent(event));
|
|
613
794
|
}));
|
|
614
795
|
this.#bindSystemOnlineEvent();
|
|
615
|
-
this.
|
|
796
|
+
this.on('system.offline', (event) => {
|
|
616
797
|
this.#logSystemEvent('offline', event);
|
|
798
|
+
this.#notifyOffline('offline', this.#options.config.autoReconnect);
|
|
617
799
|
});
|
|
618
|
-
this.
|
|
800
|
+
this.on('system.offline.network', (event) => {
|
|
619
801
|
this.#logSystemEvent('offline.network', event);
|
|
802
|
+
this.#notifyOffline('network', this.#options.config.autoReconnect);
|
|
620
803
|
});
|
|
621
|
-
this.
|
|
804
|
+
this.on('system.offline.kickoff', (event) => {
|
|
622
805
|
this.#logSystemEvent('offline.kickoff', event);
|
|
806
|
+
this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
|
|
807
|
+
this.#scheduleRelogin('kickoff');
|
|
623
808
|
});
|
|
624
|
-
this.
|
|
809
|
+
this.on('system.login.qrcode', (event) => {
|
|
625
810
|
this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
|
|
626
811
|
this.#handleLoginChallenge('qrcode', event);
|
|
627
812
|
});
|
|
628
|
-
this.
|
|
813
|
+
this.on('system.login.device', (event) => {
|
|
629
814
|
this.#handleLoginChallenge('device', event);
|
|
630
815
|
});
|
|
631
|
-
this.
|
|
816
|
+
this.on('system.login.slider', (event) => {
|
|
632
817
|
this.#handleLoginChallenge('slider', event);
|
|
633
818
|
});
|
|
634
|
-
this.
|
|
819
|
+
this.on('system.login.error', (event) => {
|
|
635
820
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
|
|
636
821
|
this.#logSystemEvent('login.error', event);
|
|
822
|
+
this.#notifyOffline('login_error', this.#options.config.autoReconnect);
|
|
823
|
+
this.#scheduleRelogin('login_error');
|
|
637
824
|
});
|
|
638
|
-
this.
|
|
825
|
+
this.on('system.login.auth', (event) => {
|
|
639
826
|
this.#handleLoginChallenge('auth', event);
|
|
640
827
|
});
|
|
641
828
|
}
|
|
642
829
|
|
|
643
830
|
#bindSystemOnlineEvent(): void {
|
|
644
|
-
this.
|
|
831
|
+
this.on('system.online', (event) => {
|
|
645
832
|
if (this.#retiring) return;
|
|
646
833
|
this.#everOnline = true;
|
|
834
|
+
this.#clearReloginTimer();
|
|
835
|
+
this.#reloginAttempts = 0;
|
|
836
|
+
this.#notifyRecovered();
|
|
647
837
|
try {
|
|
648
838
|
this.#resolveStartOnline?.();
|
|
649
839
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
650
840
|
try {
|
|
651
841
|
// transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
|
|
652
|
-
this.reportSelfUserId(resolveIcqqSelfUserId(this
|
|
842
|
+
this[endpointSpec].reportSelfUserId(resolveIcqqSelfUserId(this));
|
|
653
843
|
} catch (error) {
|
|
654
844
|
this.#logger.warn(formatCompact({
|
|
655
845
|
op: 'icqq_report_self_user_id_failed',
|
|
@@ -673,7 +863,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
673
863
|
#bindNativeEvents(): void {
|
|
674
864
|
this.#disposeNativeEvents?.();
|
|
675
865
|
let matchedName: string | undefined;
|
|
676
|
-
const registration = this.
|
|
866
|
+
const registration = this.on(
|
|
677
867
|
(eventName: unknown) => {
|
|
678
868
|
matchedName = typeof eventName === 'string' && !eventName.startsWith('internal.')
|
|
679
869
|
? eventName
|
|
@@ -689,7 +879,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
689
879
|
}
|
|
690
880
|
|
|
691
881
|
#emitNativeEvent(name: string, event: unknown): void {
|
|
692
|
-
void this.
|
|
882
|
+
void this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
693
883
|
this.#logger.warn(formatCompact({
|
|
694
884
|
op: 'icqq_platform_event_failed',
|
|
695
885
|
endpoint: this.endpointName,
|
|
@@ -703,17 +893,21 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
703
893
|
step: 'qrcode' | 'slider' | 'device' | 'auth',
|
|
704
894
|
event: unknown,
|
|
705
895
|
): void {
|
|
896
|
+
if (this.#retiring || this.#stopped || this.#options.signal?.aborted) return;
|
|
706
897
|
const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
|
|
707
898
|
? event as Record<string, unknown>
|
|
708
899
|
: null);
|
|
709
900
|
this.#logSystemEvent(`login.${step}`, event);
|
|
901
|
+
this.#clearReloginTimer();
|
|
902
|
+
this.#notifyChallenge(step);
|
|
710
903
|
void runIcqqLoginAssistStep({
|
|
711
904
|
assist: this.#options.loginAssist,
|
|
712
905
|
locale: this.#options.locale,
|
|
713
|
-
client: this
|
|
906
|
+
client: this,
|
|
714
907
|
adapter: 'icqq',
|
|
715
908
|
endpointKey: this.endpointName,
|
|
716
909
|
owner: this.#loginAssistOwner,
|
|
910
|
+
signal: this.#options.signal, endpointIdentity: this.#options.endpointIdentity,
|
|
717
911
|
logger: this.#logger,
|
|
718
912
|
step,
|
|
719
913
|
event: payload ?? event,
|
|
@@ -734,14 +928,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
734
928
|
const postType = String(payload.post_type ?? '');
|
|
735
929
|
const isRequest = postType === 'request' || postType.startsWith('request.');
|
|
736
930
|
const flag = payload.flag != null ? String(payload.flag) : '';
|
|
737
|
-
void receiveOneBotLikeSideEvent((name, payload) => this.emit(name, payload), {
|
|
931
|
+
void receiveOneBotLikeSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), {
|
|
738
932
|
adapter: 'icqq',
|
|
739
933
|
endpointKey: this.endpointName,
|
|
740
934
|
platform: 'icqq',
|
|
741
935
|
raw: payload,
|
|
742
936
|
...(isRequest ? {
|
|
743
|
-
approve: (id, remark) => this.#approveRequest(flag || id, remark),
|
|
744
|
-
reject: (id, reason) => this.#rejectRequest(flag || id, reason),
|
|
937
|
+
approve: (id, remark) => this.#approveRequest({ requestId: flag || id, remark }),
|
|
938
|
+
reject: (id, reason) => this.#rejectRequest({ requestId: flag || id, reason }),
|
|
745
939
|
} : {}),
|
|
746
940
|
}).catch((err) => {
|
|
747
941
|
this.#logger.warn(formatCompact({
|
|
@@ -754,7 +948,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
754
948
|
}
|
|
755
949
|
|
|
756
950
|
#dispatchIcqqSystemSideEvent(type: string, payload: Record<string, unknown> | null): void {
|
|
757
|
-
void this.emit('system.receive', buildSystem(payload ?? {}, {
|
|
951
|
+
void this[endpointSpec].events.emit('system.receive', buildSystem(payload ?? {}, {
|
|
758
952
|
id: `system:${this.endpointName}:${type}:${Date.now()}`,
|
|
759
953
|
clientAdapter: 'icqq',
|
|
760
954
|
endpointId: this.endpointName,
|
|
@@ -847,6 +1041,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
847
1041
|
}));
|
|
848
1042
|
}
|
|
849
1043
|
await this.#deliverInbound({
|
|
1044
|
+
event,
|
|
1045
|
+
platformSentAt: normalized.timestampMs,
|
|
850
1046
|
id: normalized.messageId,
|
|
851
1047
|
conversation,
|
|
852
1048
|
content: formatInboundContent(normalized.rawMessage),
|
|
@@ -878,13 +1074,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
878
1074
|
if (!Number.isFinite(userId) || userId <= 0) return null;
|
|
879
1075
|
if (data.message_type === 'group' || data.group_id != null) {
|
|
880
1076
|
const groupId = Number(data.group_id);
|
|
881
|
-
return Number.isFinite(groupId) && groupId > 0 ? this.
|
|
1077
|
+
return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
|
|
882
1078
|
}
|
|
883
1079
|
const sourceGroupId = Number(data.sender?.group_id);
|
|
884
1080
|
if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
|
|
885
|
-
return this.
|
|
1081
|
+
return this.pickMember(sourceGroupId, userId);
|
|
886
1082
|
}
|
|
887
|
-
return this.
|
|
1083
|
+
return this.pickFriend(userId);
|
|
888
1084
|
}
|
|
889
1085
|
|
|
890
1086
|
#logInboundHandlerFailure(event: string, error: unknown): void {
|
|
@@ -921,6 +1117,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
921
1117
|
return;
|
|
922
1118
|
}
|
|
923
1119
|
await this.admit({
|
|
1120
|
+
event,
|
|
1121
|
+
platformSentAt: typeof normalized.raw.time === 'number' && Number.isFinite(normalized.raw.time)
|
|
1122
|
+
? normalized.timestampMs : undefined,
|
|
924
1123
|
id: normalized.messageId,
|
|
925
1124
|
conversation: icqqInboundConversation(String(this.#options.id), {
|
|
926
1125
|
channelType: 'channel',
|
|
@@ -952,7 +1151,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
952
1151
|
}
|
|
953
1152
|
|
|
954
1153
|
async #listPendingRequests(): Promise<readonly EndpointPendingRequest[]> {
|
|
955
|
-
const messages = await this.
|
|
1154
|
+
const messages = await this.getSystemMsg();
|
|
956
1155
|
const rows: EndpointPendingRequest[] = [];
|
|
957
1156
|
for (const raw of messages) {
|
|
958
1157
|
const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
|
|
@@ -980,7 +1179,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
980
1179
|
|
|
981
1180
|
async #pullPendingSystemMessages(signal: AbortSignal): Promise<void> {
|
|
982
1181
|
try {
|
|
983
|
-
const messages = await raceAbort(Promise.resolve(this.
|
|
1182
|
+
const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
|
|
984
1183
|
for (const raw of messages) {
|
|
985
1184
|
signal.throwIfAborted();
|
|
986
1185
|
const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
|
|
@@ -1008,8 +1207,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
1008
1207
|
}
|
|
1009
1208
|
}
|
|
1010
1209
|
|
|
1011
|
-
async #approveRequest(
|
|
1012
|
-
const
|
|
1210
|
+
async #approveRequest({ requestId: id, remark, signal }: EndpointApproveRequest): Promise<void> {
|
|
1211
|
+
const operation = createEndpointOperation({ signal });
|
|
1212
|
+
const messages = await operation.run(() => this.getSystemMsg());
|
|
1013
1213
|
const target = messages.find((m) => {
|
|
1014
1214
|
const msg = m as unknown as IcqqSystemMessage;
|
|
1015
1215
|
return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
|
|
@@ -1017,15 +1217,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
1017
1217
|
if (!target?.flag) {
|
|
1018
1218
|
throw new Error(`icqq 未找到待处理请求: ${id}`);
|
|
1019
1219
|
}
|
|
1220
|
+
const flag = target.flag;
|
|
1020
1221
|
if ((target as { request_type?: string }).request_type === 'friend') {
|
|
1021
|
-
await this.
|
|
1222
|
+
await operation.run(() => this.setFriendAddRequest(flag, true, remark));
|
|
1022
1223
|
} else {
|
|
1023
|
-
await this.
|
|
1224
|
+
await operation.run(() => this.setGroupAddRequest(flag, true));
|
|
1024
1225
|
}
|
|
1025
1226
|
}
|
|
1026
1227
|
|
|
1027
|
-
async #rejectRequest(
|
|
1028
|
-
const
|
|
1228
|
+
async #rejectRequest({ requestId: id, reason, signal }: EndpointRejectRequest): Promise<void> {
|
|
1229
|
+
const operation = createEndpointOperation({ signal });
|
|
1230
|
+
const messages = await operation.run(() => this.getSystemMsg());
|
|
1029
1231
|
const target = messages.find((m) => {
|
|
1030
1232
|
const msg = m as unknown as IcqqSystemMessage;
|
|
1031
1233
|
return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
|
|
@@ -1033,10 +1235,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
1033
1235
|
if (!target?.flag) {
|
|
1034
1236
|
throw new Error(`icqq 未找到待处理请求: ${id}`);
|
|
1035
1237
|
}
|
|
1238
|
+
const flag = target.flag;
|
|
1036
1239
|
if ((target as { request_type?: string }).request_type === 'friend') {
|
|
1037
|
-
await this.
|
|
1240
|
+
await operation.run(() => this.setFriendAddRequest(flag, false));
|
|
1038
1241
|
} else {
|
|
1039
|
-
await this.
|
|
1242
|
+
await operation.run(() => this.setGroupAddRequest(flag, false, reason));
|
|
1040
1243
|
}
|
|
1041
1244
|
}
|
|
1042
1245
|
}
|