@zhin.js/adapter-icqq 8.0.9 → 8.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +36 -28
  3. package/adapters/icqq.js +4 -9
  4. package/adapters/icqq.ts +5 -14
  5. package/agent/PERMITS.md +2 -0
  6. package/lib/agent-prompt.js +17 -22
  7. package/lib/cq-message.d.ts +2 -2
  8. package/lib/cq-message.js +2 -2
  9. package/lib/endpoint.d.ts +4 -74
  10. package/lib/endpoint.js +348 -507
  11. package/lib/forward-msg.d.ts +7 -8
  12. package/lib/forward-msg.js +11 -24
  13. package/lib/icqq-agent-deps.d.ts +3 -15
  14. package/lib/icqq-agent-deps.js +0 -5
  15. package/lib/icqq-endpoint-commands.js +4 -13
  16. package/lib/icqq-guild.d.ts +15 -11
  17. package/lib/icqq-guild.js +9 -17
  18. package/lib/icqq-inbound.d.ts +20 -20
  19. package/lib/icqq-inbound.js +9 -9
  20. package/lib/icqq-inbox.d.ts +2 -10
  21. package/lib/index.d.ts +2 -3
  22. package/lib/index.js +2 -3
  23. package/lib/outbound-media.d.ts +3 -8
  24. package/lib/outbound-media.js +3 -8
  25. package/lib/protocol.d.ts +38 -57
  26. package/lib/protocol.js +24 -21
  27. package/lib/types.d.ts +4 -40
  28. package/lib/types.js +0 -5
  29. package/package.json +25 -13
  30. package/plugin.js +6 -1
  31. package/schema.json +119 -61
  32. package/src/agent-prompt.ts +17 -22
  33. package/src/cq-message.ts +2 -2
  34. package/src/endpoint.ts +358 -600
  35. package/src/forward-msg.ts +12 -26
  36. package/src/icqq-agent-deps.ts +3 -17
  37. package/src/icqq-endpoint-commands.ts +4 -13
  38. package/src/icqq-guild.ts +16 -25
  39. package/src/icqq-inbound.ts +26 -26
  40. package/src/icqq-inbox.ts +2 -10
  41. package/src/icqqjs-icqq.d.ts +88 -0
  42. package/src/index.ts +2 -9
  43. package/src/outbound-media.ts +4 -9
  44. package/src/protocol.ts +60 -66
  45. package/src/types.ts +4 -41
  46. package/tools/announce.js +19 -0
  47. package/tools/announce.ts +18 -0
  48. package/tools/essence.js +24 -0
  49. package/{agent/tools → tools}/essence.ts +8 -8
  50. package/tools/friend_list.js +20 -0
  51. package/{agent/tools → tools}/friend_list.ts +7 -7
  52. package/tools/group_files.js +25 -0
  53. package/tools/group_files.ts +23 -0
  54. package/tools/group_invite.js +25 -0
  55. package/tools/group_invite.ts +22 -0
  56. package/tools/group_kick.js +28 -0
  57. package/tools/group_kick.ts +24 -0
  58. package/tools/group_list.js +21 -0
  59. package/{agent/tools → tools}/group_list.ts +7 -7
  60. package/tools/group_sign.js +21 -0
  61. package/tools/group_sign.ts +19 -0
  62. package/tools/list_muted.js +20 -0
  63. package/tools/list_muted.ts +19 -0
  64. package/tools/poke.js +19 -0
  65. package/tools/poke.ts +18 -0
  66. package/tools/send_user_like.js +28 -0
  67. package/tools/send_user_like.ts +25 -0
  68. package/tools/set_anonymous.js +23 -0
  69. package/tools/set_anonymous.ts +21 -0
  70. package/tools/set_mute.js +26 -0
  71. package/tools/set_mute.ts +23 -0
  72. package/tools/set_title.js +27 -0
  73. package/tools/set_title.ts +24 -0
  74. package/tools/user_info.js +17 -0
  75. package/tools/user_info.ts +16 -0
  76. package/agent/tools/announce.ts +0 -21
  77. package/agent/tools/group_files.ts +0 -27
  78. package/agent/tools/group_invite.ts +0 -20
  79. package/agent/tools/group_sign.ts +0 -19
  80. package/agent/tools/list_muted.ts +0 -22
  81. package/agent/tools/poke.ts +0 -20
  82. package/agent/tools/send_user_like.ts +0 -22
  83. package/agent/tools/set_anonymous.ts +0 -22
  84. package/agent/tools/set_title.ts +0 -25
  85. package/lib/ipc-client.d.ts +0 -73
  86. package/lib/ipc-client.js +0 -358
  87. package/src/ipc-client.ts +0 -417
package/src/endpoint.ts CHANGED
@@ -1,6 +1,10 @@
1
- /**
2
- * IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
3
- */
1
+ import {
2
+ Client,
3
+ parseGroupMessageId,
4
+ type Config as NativeIcqqClientConfig,
5
+ type Sendable,
6
+ } from '@icqqjs/icqq';
7
+ import { inspect } from 'node:util';
4
8
  import type {
5
9
  EndpointControl,
6
10
  EndpointInstance,
@@ -21,8 +25,7 @@ import {
21
25
  quotedPayloadFromIcqqSource,
22
26
  resolveIcqqQuoteIdFromEvent,
23
27
  shouldSkipSelfInboundMessage,
24
- unwrapIcqqIpcEventPayload,
25
- type IcqqIpcMessageEvent,
28
+ type IcqqMessageEvent,
26
29
  } from './icqq-inbound.js';
27
30
  import {
28
31
  buildIcqqInboxNoticeRow,
@@ -33,45 +36,22 @@ import {
33
36
  } from './icqq-inbox.js';
34
37
  import {
35
38
  IcqqGuildCatalog,
36
- isIcqqGuildIpcEvent,
37
39
  normalizeIcqqGuildInboundMessage,
38
40
  } from './icqq-guild.js';
39
- import { IpcClient } from './ipc-client.js';
40
41
  import {
41
42
  materializeOutboundBase64,
42
43
  resolveIcqqOutboundMediaMode,
43
44
  } from './outbound-media.js';
44
45
  import {
45
- Actions,
46
46
  formatInboundContent,
47
47
  formatOutboundBody,
48
48
  icqqInboundConversation,
49
49
  icqqOutboundTarget,
50
50
  type IcqqInboundMessage,
51
- type IpcEvent,
52
- type IpcResponse,
53
51
  type ResolvedIcqqConfig,
54
52
  } from './protocol.js';
55
- import type { IpcFriendInfo, IpcGroupInfo, IpcMemberInfo, IpcSystemMessage } from './types.js';
56
-
57
- /** Minimal IPC surface used by the endpoint (real IpcClient or test mock). */
58
- export interface IcqqIpcTransport {
59
- request(action: string, params?: Record<string, unknown>): Promise<IpcResponse>;
60
- subscribe(
61
- action: string,
62
- params: Record<string, unknown>,
63
- handler: (event: IpcEvent) => void,
64
- ): { unsubscribe: () => Promise<void> };
65
- setOnRemoteDisconnect(handler: (() => void) | null): void;
66
- close(): void;
67
- }
68
-
69
- export type CreateIcqqIpc = (config: ResolvedIcqqConfig) => Promise<IcqqIpcTransport>;
53
+ import type { SystemMessage as IcqqSystemMessage } from './types.js';
70
54
 
71
- /**
72
- * 收件箱写/推钩子(由装配层注入,见 adapters/icqq.ts)。
73
- * record* 写 unified_inbox_request/notice;publish 向 console hub 推送实时事件。
74
- */
75
55
  export interface IcqqInboxHooks {
76
56
  recordRequest(row: Record<string, unknown>): void | Promise<void>;
77
57
  recordNotice(row: Record<string, unknown>): void | Promise<void>;
@@ -82,156 +62,156 @@ export interface IcqqEndpointOptions {
82
62
  readonly id: CapabilityId;
83
63
  readonly gateway: MessageGateway;
84
64
  readonly config: ResolvedIcqqConfig;
85
- readonly createIpc?: CreateIcqqIpc;
86
- /** 未注入时 request/notice 事件仅按非消息载荷忽略(不报错)。 */
87
65
  readonly inbox?: IcqqInboxHooks;
88
66
  }
89
67
 
90
- export class IcqqIpcEndpoint implements EndpointInstance {
91
- readonly #logger!: ReturnType<typeof getAdapterLogger>;
92
-
68
+ const BOUND_EVENTS = [
69
+ 'message.private',
70
+ 'message.group',
71
+ 'message.guild',
72
+ 'request.friend',
73
+ 'request.group',
74
+ 'notice.friend',
75
+ 'notice.group',
76
+ 'system.online',
77
+ 'system.offline',
78
+ 'system.offline.network',
79
+ 'system.offline.kickoff',
80
+ 'system.login.qrcode',
81
+ 'system.login.device',
82
+ 'system.login.slider',
83
+ 'system.login.error',
84
+ 'system.login.auth',
85
+ ] as const;
86
+
87
+ export class IcqqEndpoint extends Client implements EndpointInstance {
88
+ readonly #logger: ReturnType<typeof getAdapterLogger>;
93
89
  readonly #options: IcqqEndpointOptions;
94
- readonly #createIpc: CreateIcqqIpc;
95
- readonly name: string;
96
- /** Populated after start(); agent tools read this. */
97
- ipc!: IcqqIpcTransport;
98
- readonly friends = new Map<number, IpcFriendInfo>();
99
- readonly groups = new Map<number, IpcGroupInfo>();
90
+ readonly endpointName: string;
100
91
  readonly #guildCatalog = new IcqqGuildCatalog();
92
+ readonly #inboundDeduper = new InboundMessageDeduper();
93
+ readonly #inboxDeduper = new InboundMessageDeduper();
94
+ #open = false;
95
+ #started = false;
96
+ #heldInbound: IcqqInboundMessage[] = [];
97
+ #unregisterAgent?: () => void;
98
+
101
99
  readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
102
- listFriends: () => this.getFriendList(),
103
- listGroups: () => this.getGroupList(),
100
+ listFriends: async () =>
101
+ Array.from(this.fl.values()).map((f) => ({
102
+ user_id: f.user_id,
103
+ nickname: f.nickname,
104
+ remark: f.remark ?? '',
105
+ })),
106
+ listGroups: async () =>
107
+ Array.from(this.gl.values()).map((g) => ({
108
+ group_id: g.group_id,
109
+ name: g.group_name,
110
+ })),
104
111
  listChannels: async () => {
105
- await this.#guildCatalog.syncAll(this.ipc);
112
+ await this.#guildCatalog.syncAll(this);
106
113
  return this.#guildCatalog.getGuildChannelList();
107
114
  },
108
- listGroupMembers: (groupId) => this.getGroupMemberList(groupId),
109
- approveRequest: (requestId, remark) => this.approveRequest(requestId, remark),
110
- rejectRequest: (requestId, reason) => this.rejectRequest(requestId, reason),
111
- kickGroupMember: (groupId, userId) => this.removeMember(groupId, userId),
112
- muteGroupMember: (groupId, userId, duration) => this.muteMember(groupId, userId, duration),
113
- setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
114
- deleteFriend: (userId) => this.deleteFriend(userId),
115
+ listGroupMembers: async (groupId) =>
116
+ Array.from((await this.getGroupMemberList(Number(groupId))).values()),
117
+ approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
118
+ rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
119
+ kickGroupMember: async (groupId, userId) => {
120
+ await this.setGroupKick(Number(groupId), Number(userId));
121
+ },
122
+ muteGroupMember: async (groupId, userId, duration) => {
123
+ await this.setGroupBan(Number(groupId), Number(userId), duration);
124
+ },
125
+ setGroupAdmin: async (groupId, userId, enabled) => {
126
+ await this.setGroupAdmin(Number(groupId), Number(userId), enabled);
127
+ },
128
+ deleteFriend: async (userId) => {
129
+ await this.deleteFriend(Number(userId));
130
+ },
115
131
  });
132
+
116
133
  readonly control: EndpointControl = Object.freeze({
117
- recall: (message: MessageRef) => this.recallMessage(message.id),
118
- addReaction: (message: MessageRef, emoji: string) => this.addReaction(message.id, emoji),
119
- removeReaction: (message: MessageRef, reactionId: string) =>
120
- this.removeReaction(message.id, reactionId),
134
+ recall: async (message: MessageRef) => {
135
+ if (!message.id || message.id.startsWith('outbound:')) return;
136
+ await this.deleteMsg(message.id);
137
+ },
138
+ addReaction: async (message: MessageRef, emoji: string) => {
139
+ const target = resolveIcqqGroupReactionTarget(message);
140
+ if (!target) return null;
141
+ try {
142
+ // Do not await protocol ACK — packet timeout must not stall the AI reply.
143
+ this.#ignoreReactionAck(
144
+ 'add',
145
+ this.pickGroup(target.groupId).setReaction(target.seq, emoji),
146
+ );
147
+ } catch (error) {
148
+ this.#logger.debug(formatCompact({
149
+ op: 'reaction_add_failed',
150
+ error: error instanceof Error ? error.message : String(error),
151
+ }));
152
+ }
153
+ return emoji;
154
+ },
155
+ removeReaction: async (message: MessageRef, reactionId: string) => {
156
+ const target = resolveIcqqGroupReactionTarget(message);
157
+ if (!target) return;
158
+ try {
159
+ this.#ignoreReactionAck(
160
+ 'remove',
161
+ this.pickGroup(target.groupId).delReaction(target.seq, reactionId),
162
+ );
163
+ } catch (error) {
164
+ this.#logger.debug(formatCompact({
165
+ op: 'reaction_remove_failed',
166
+ error: error instanceof Error ? error.message : String(error),
167
+ }));
168
+ }
169
+ },
121
170
  });
122
- #open = false;
123
- #started = false;
124
- #subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
125
- #inboundDeduper = new InboundMessageDeduper();
126
- /** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
127
- #inboxDeduper = new InboundMessageDeduper();
128
- #unregisterAgent?: () => void;
129
- /** 用户主动 stop 时为 true,阻止自动重连 */
130
- #intentionalDisconnect = false;
131
- /** 是否已有重连循环在跑(避免多次 schedule 叠套) */
132
- #reconnectRunning = false;
133
171
 
134
172
  constructor(options: IcqqEndpointOptions) {
173
+ const nativeConfig = resolveNativeClientConfig(options.config);
174
+ super(Number(options.config.id), nativeConfig);
135
175
  this.#logger = getAdapterLogger('icqq', options.config.id);
136
176
  this.#options = options;
137
- this.name = options.config.id;
138
- this.#createIpc = options.createIpc ?? defaultCreateIpc;
177
+ this.endpointName = options.config.id;
139
178
  }
140
179
 
141
- async request(action: string, params?: Record<string, unknown>): Promise<IpcResponse> {
142
- if (!this.ipc) {
143
- throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发起请求: ${action}`);
144
- }
145
- return this.ipc.request(action, params);
180
+ #ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
181
+ void Promise.resolve(task).catch((error) => {
182
+ this.#logger.debug(formatCompact({
183
+ op: `reaction_${action}_failed`,
184
+ error: error instanceof Error ? error.message : String(error),
185
+ }));
186
+ });
146
187
  }
147
188
 
148
189
  async start(): Promise<void> {
149
190
  if (this.#started) return;
150
191
  this.#started = true;
151
- this.#intentionalDisconnect = false;
152
192
  try {
153
- await this.#bindIpcSession();
154
- this.#unregisterAgent = registerIcqqAgentEndpoint(this.name, this);
193
+ const onlineReady = new Promise<void>((resolve) => {
194
+ this.on('system.online', () => resolve());
195
+ });
196
+ this.#bindClientEvents();
197
+ await this.login(this.#options.config.password);
198
+ await onlineReady;
199
+ await this.#pullPendingSystemMessages();
200
+ this.#unregisterAgent = registerIcqqAgentEndpoint(this.endpointName, this);
155
201
  this.#logger.info(
156
- `connected (${this.#options.config.rpc ? 'rpc' : 'ipc'}) | friends: ${this.friends.size} | groups: ${this.groups.size}`,
202
+ `connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
157
203
  );
158
204
  } catch (error) {
159
205
  await this.stop();
160
- // Startup connect failures are surfaced once (with stack) by AdapterIndex;
161
- // keep this at debug to avoid a duplicate error-level log.
162
- this.#logger.debug(`Failed to connect ICQQ IPC: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
206
+ this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
163
207
  throw error;
164
208
  }
165
209
  }
166
210
 
167
- /** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
168
- async #bindIpcSession(): Promise<void> {
169
- // 旧连接上的订阅句柄已失效,不再对死 socket 发 unsubscribe
170
- this.#subscriptions = [];
171
- this.ipc = await this.#createIpc(this.#options.config);
172
- if (this.#options.config.autoReconnect !== false) {
173
- this.ipc.setOnRemoteDisconnect(() => this.#scheduleReconnect());
174
- }
175
- await this.#refreshLists();
176
- const sub = this.ipc.subscribe(Actions.SUBSCRIBE, {}, (event) => {
177
- this.#handleEvent(event);
178
- });
179
- this.#subscriptions.push(sub);
180
- // daemon 推送 client.em 全部事件,request/notice 实时到达;
181
- // 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
182
- void this.#pullPendingSystemMessages();
183
- }
184
-
185
- /** IPC/RPC 意外断开时调度重连(指数退避) */
186
- #scheduleReconnect(): void {
187
- if (this.#options.config.autoReconnect === false) return;
188
- if (this.#intentionalDisconnect) return;
189
- if (this.#reconnectRunning) return;
190
- this.#reconnectRunning = true;
191
- void this.#runReconnectLoop();
192
- }
193
-
194
- async #runReconnectLoop(): Promise<void> {
195
- try {
196
- for (let attempt = 0; !this.#intentionalDisconnect; attempt++) {
197
- const base = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6));
198
- const jitter = Math.floor(Math.random() * 400);
199
- const delayMs = base + jitter;
200
- // 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏
201
- const disconnectLog = attempt === 0 ? this.#logger.warn.bind(this.#logger) : this.#logger.debug.bind(this.#logger);
202
- disconnectLog(formatCompact({
203
- op: 'disconnect',
204
- ok: false,
205
- delay_ms: delayMs,
206
- attempt: attempt + 1,
207
- }));
208
- await new Promise<void>((r) => setTimeout(r, delayMs));
209
- if (this.#intentionalDisconnect) break;
210
- try {
211
- await this.#bindIpcSession();
212
- this.#logger.info(
213
- `reconnected after ${attempt + 1} attempt(s)`
214
- + ` | friends: ${this.friends.size} | groups: ${this.groups.size}`,
215
- );
216
- break;
217
- } catch (error) {
218
- const retryLog = attempt === 0 ? this.#logger.warn.bind(this.#logger) : this.#logger.debug.bind(this.#logger);
219
- retryLog(formatCompact({
220
- op: 'reconnect',
221
- endpoint: this.name,
222
- ok: false,
223
- attempt: attempt + 1,
224
- error: error instanceof Error ? error.message : String(error),
225
- }));
226
- }
227
- }
228
- } finally {
229
- this.#reconnectRunning = false;
230
- }
231
- }
232
-
233
211
  open(): void {
234
212
  this.#open = true;
213
+ const held = this.#heldInbound.splice(0);
214
+ for (const msg of held) this.admit(msg);
235
215
  }
236
216
 
237
217
  close(): void {
@@ -239,409 +219,89 @@ export class IcqqIpcEndpoint implements EndpointInstance {
239
219
  }
240
220
 
241
221
  async stop(): Promise<void> {
242
- this.#intentionalDisconnect = true;
243
222
  this.#open = false;
244
- this.ipc?.setOnRemoteDisconnect(null);
245
- for (const sub of this.#subscriptions.splice(0)) {
246
- await sub.unsubscribe().catch(() => { /* ignore */ });
247
- }
223
+ this.#heldInbound.length = 0;
248
224
  this.#unregisterAgent?.();
249
225
  this.#unregisterAgent = undefined;
226
+ for (const event of BOUND_EVENTS) this.off(event);
227
+ try {
228
+ await this.logout?.();
229
+ } catch { /* ignore */ }
230
+ try {
231
+ this.terminate();
232
+ } catch { /* ignore */ }
250
233
  this.#inboundDeduper.clear();
251
234
  this.#inboxDeduper.clear();
252
- this.friends.clear();
253
- this.groups.clear();
254
235
  this.#guildCatalog.clear();
255
- try {
256
- this.ipc?.close();
257
- } catch {
258
- /* ignore */
259
- }
260
- // 置空 ipc:start 前 / stop 后的 request() 走防御性报错而非 TypeError
261
- this.ipc = undefined as unknown as IcqqIpcTransport;
262
236
  this.#started = false;
263
237
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
264
238
  }
265
239
 
266
240
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
267
- if (!this.ipc) {
268
- throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发送消息`);
269
- }
241
+ if (!this.#started) throw new Error('icqq endpoint 未连接');
270
242
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
271
243
  const content = Array.isArray(payload)
272
244
  ? materializeOutboundBase64(payload, mediaMode)
273
245
  : payload;
274
246
  const message = formatOutboundBody(content);
275
247
  const parsed = icqqOutboundTarget(conversation);
276
- let action: string;
277
- let params: Record<string, unknown>;
278
- switch (parsed.kind) {
279
- case 'private':
280
- action = Actions.SEND_PRIVATE_MSG;
281
- params = { user_id: parsed.userId, message };
282
- break;
283
- case 'group':
284
- action = Actions.SEND_GROUP_MSG;
285
- params = { group_id: parsed.groupId, message };
286
- break;
287
- case 'temp':
288
- action = Actions.SEND_TEMP_MSG;
289
- params = {
290
- group_id: parsed.groupId,
291
- user_id: parsed.userId,
292
- message,
293
- };
294
- break;
295
- case 'channel':
296
- action = Actions.GUILD_SEND_MSG;
297
- params = {
298
- guild_id: parsed.guildId,
299
- channel_id: parsed.channelId,
300
- message,
301
- };
302
- break;
303
- }
304
- const resp = await this.ipc.request(action, params);
305
- if (!resp.ok) {
306
- this.#logger.warn(
307
- `send failed ${conversation.kind}:${conversation.id} | ${resp.error} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
308
- );
309
- throw new Error(`发送消息失败: ${resp.error}`);
310
- }
311
- const messageId = String(
312
- (resp.data as { message_id?: unknown } | undefined)?.message_id ?? `sent_${Date.now()}`,
313
- );
314
- this.#logger.info(
315
- `send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
316
- );
317
- return messageId;
318
- }
319
-
320
- // ── 消息回应(activity-feedback / typing reaction)────────────────────
321
-
322
- private static readonly EMOJI_MAP: Record<string, string> = {
323
- '⏳': '1468368274',
324
- '👍': '128077',
325
- '❤️': '10084',
326
- '😊': '128522',
327
- '🎉': '127881',
328
- '🔥': '128293',
329
- '✅': '9989',
330
- '❌': '10060',
331
- '⭐': '11088',
332
- '💯': '128175',
333
- };
334
-
335
- private getEmojiId(emoji: string): string {
336
- if (/^\d+$/u.test(emoji)) return emoji;
337
- return IcqqIpcEndpoint.EMOJI_MAP[emoji] ?? String(emoji.codePointAt(0) || 0);
338
- }
339
-
340
- /** Activity-feedback / OutboundHost reaction surface. */
341
- async addReaction(messageId: string, emoji: string): Promise<string | null> {
342
- try {
343
- const emojiId = this.getEmojiId(emoji);
344
- const resp = await this.request(Actions.GROUP_SET_REACTION, {
345
- message_id: messageId,
346
- id: emojiId,
347
- });
348
- if (!resp.ok) {
349
- this.#logger.warn(formatCompact({
350
- op: 'add_reaction',
351
- endpoint: this.name,
352
- message_id: messageId,
353
- emoji,
354
- id: emojiId,
355
- ok: false,
356
- error: resp.error,
357
- }));
358
- return null;
359
- }
360
- this.#logger.debug(formatCompact({
361
- op: 'add_reaction',
362
- endpoint: this.name,
363
- message_id: messageId,
364
- emoji,
365
- id: emojiId,
366
- ok: true,
367
- }));
368
- return emojiId;
369
- } catch (error) {
370
- this.#logger.warn(formatCompact({
371
- op: 'add_reaction',
372
- endpoint: this.name,
373
- message_id: messageId,
374
- emoji,
375
- ok: false,
376
- error: error instanceof Error ? error.message : String(error),
377
- }));
378
- return null;
379
- }
380
- }
381
-
382
- async removeReaction(messageId: string, reactionId: string): Promise<void> {
383
- try {
384
- const parts = reactionId.split('_');
385
- const emojiId = parts.length >= 2 ? parts[1]! : reactionId;
386
- const resp = await this.request(Actions.GROUP_DEL_REACTION, {
387
- message_id: messageId,
388
- id: emojiId,
389
- });
390
- if (!resp.ok) {
391
- this.#logger.warn(formatCompact({
392
- op: 'remove_reaction',
393
- endpoint: this.name,
394
- message_id: messageId,
395
- reaction_id: reactionId,
396
- id: emojiId,
397
- ok: false,
398
- error: resp.error,
399
- }));
400
- return;
401
- }
402
- this.#logger.debug(formatCompact({
403
- op: 'remove_reaction',
404
- endpoint: this.name,
405
- message_id: messageId,
406
- reaction_id: reactionId,
407
- id: emojiId,
408
- ok: true,
409
- }));
410
- } catch (error) {
411
- this.#logger.warn(formatCompact({
412
- op: 'remove_reaction',
413
- endpoint: this.name,
414
- message_id: messageId,
415
- reaction_id: reactionId,
416
- ok: false,
417
- error: error instanceof Error ? error.message : String(error),
418
- }));
419
- }
420
- }
421
-
422
- /** Activity-feedback autoRemove / Console recall. */
423
- async recallMessage(messageId: string): Promise<void> {
424
- if (!messageId || messageId.startsWith('outbound:')) return;
248
+ let result: { message_id?: unknown };
425
249
  try {
426
- const resp = await this.request(Actions.RECALL_MSG, { message_id: messageId });
427
- if (!resp.ok) {
428
- this.#logger.warn(formatCompact({
429
- op: 'recall_message',
430
- endpoint: this.name,
431
- message_id: messageId,
432
- ok: false,
433
- error: resp.error,
434
- }));
435
- return;
250
+ switch (parsed.kind) {
251
+ case 'private':
252
+ result = await this.sendPrivateMsg(parsed.userId, message as Sendable);
253
+ break;
254
+ case 'group':
255
+ result = await this.sendGroupMsg(parsed.groupId, message as Sendable);
256
+ break;
257
+ case 'temp':
258
+ result = await this.sendTempMsg(parsed.groupId, parsed.userId, message as Sendable);
259
+ break;
260
+ case 'channel':
261
+ result = await this.sendGuildMsg(parsed.guildId, parsed.channelId, message as Sendable) as unknown as { message_id?: unknown };
262
+ break;
436
263
  }
437
- this.#logger.debug(formatCompact({
438
- op: 'recall_message',
439
- endpoint: this.name,
440
- message_id: messageId,
441
- ok: true,
442
- }));
443
264
  } catch (error) {
444
265
  this.#logger.warn(formatCompact({
445
- op: 'recall_message',
446
- endpoint: this.name,
447
- message_id: messageId,
448
- ok: false,
449
- error: error instanceof Error ? error.message : String(error),
266
+ op: 'icqq_send_failed',
267
+ target: `${conversation.kind}:${conversation.id}`,
268
+ error: describeUnknownError(error),
269
+ preview: truncatePreview(typeof message === 'string' ? message : String(message), 120),
450
270
  }));
271
+ throw error;
451
272
  }
452
- }
453
-
454
- // ── 社交/群管(Console endpoint RPC 探测面)────────────────────────
455
- // console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
456
-
457
- /** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
458
- async getFriendList(): Promise<Array<{ user_id: number; nickname: string; remark: string }>> {
459
- const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
460
- const list = Array.isArray(resp.data) ? (resp.data as IpcFriendInfo[]) : [];
461
- return list.map((f) => ({
462
- user_id: f.user_id,
463
- nickname: f.nickname,
464
- remark: f.remark ?? '',
465
- }));
466
- }
467
-
468
- /** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
469
- async getGroupList(): Promise<Array<{ group_id: number; name: string }>> {
470
- const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
471
- const list = Array.isArray(resp.data) ? (resp.data as IpcGroupInfo[]) : [];
472
- return list.map((g) => ({
473
- group_id: g.group_id,
474
- name: g.group_name,
475
- }));
476
- }
477
-
478
- /** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
479
- async getGroupMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
480
- const resp = await this.#mustRequest(
481
- Actions.LIST_GROUP_MEMBERS,
482
- { group_id: toNumericId(groupId, 'group_id') },
483
- '获取群成员列表',
484
- );
485
- return Array.isArray(resp.data) ? (resp.data as IpcMemberInfo[]) : [];
486
- }
487
-
488
- /** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
489
- listMembers(groupId: number | string): Promise<IpcMemberInfo[]> {
490
- return this.getGroupMemberList(groupId);
491
- }
492
-
493
- getMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
494
- return this.getGroupMemberList(groupId);
495
- }
496
-
497
- /**
498
- * EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
499
- * 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
500
- */
501
- async approveRequest(id: string, remark?: string): Promise<void> {
502
- await this.#handleSystemRequest(id, true, { remark });
503
- }
504
-
505
- async rejectRequest(id: string, reason?: string): Promise<void> {
506
- await this.#handleSystemRequest(id, false, { reason });
507
- }
508
-
509
- /** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
510
- async deleteFriend(userId: number | string): Promise<void> {
511
- await this.#mustRequest(
512
- Actions.FRIEND_DELETE,
513
- { user_id: toNumericId(userId, 'user_id') },
514
- '删除好友',
515
- );
516
- }
517
-
518
- /** deleteFriend 别名(console 探测 delete_friend)。 */
519
- delete_friend(userId: number | string): Promise<void> {
520
- return this.deleteFriend(userId);
521
- }
522
-
523
- /** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
524
- async removeMember(groupId: number | string, userId: number | string): Promise<void> {
525
- await this.#mustRequest(
526
- Actions.GROUP_KICK,
527
- { group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') },
528
- '踢出群成员',
529
- );
530
- }
531
-
532
- kickMember(groupId: number | string, userId: number | string): Promise<void> {
533
- return this.removeMember(groupId, userId);
534
- }
535
-
536
- setGroupKick(groupId: number | string, userId: number | string): Promise<void> {
537
- return this.removeMember(groupId, userId);
538
- }
539
-
540
- /** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
541
- async muteMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
542
- await this.#mustRequest(
543
- Actions.GROUP_MUTE,
544
- {
545
- group_id: toNumericId(groupId, 'group_id'),
546
- user_id: toNumericId(userId, 'user_id'),
547
- duration,
548
- },
549
- '禁言群成员',
550
- );
551
- }
552
-
553
- banMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
554
- return this.muteMember(groupId, userId, duration);
555
- }
556
-
557
- setGroupMute(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
558
- return this.muteMember(groupId, userId, duration);
559
- }
560
-
561
- /** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
562
- async setModerator(groupId: number | string, userId: number | string, enable = true): Promise<void> {
563
- await this.#mustRequest(
564
- Actions.SET_GROUP_ADMIN,
565
- {
566
- group_id: toNumericId(groupId, 'group_id'),
567
- user_id: toNumericId(userId, 'user_id'),
568
- enable,
569
- },
570
- '设置群管理员',
273
+ const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
274
+ this.#logger.info(
275
+ `send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
571
276
  );
277
+ return messageId;
572
278
  }
573
279
 
574
- setAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
575
- return this.setModerator(groupId, userId, enable);
576
- }
577
-
578
- setGroupAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
579
- return this.setModerator(groupId, userId, enable);
580
- }
581
-
582
- /** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
583
- async #mustRequest(
584
- action: string,
585
- params: Record<string, unknown> | undefined,
586
- label: string,
587
- ): Promise<IpcResponse> {
588
- const resp = await this.request(action, params);
589
- if (!resp.ok) {
590
- throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
591
- }
592
- return resp;
593
- }
594
-
595
- /** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
596
- async #handleSystemRequest(
597
- id: string,
598
- approve: boolean,
599
- extra: { remark?: string; reason?: string },
600
- ): Promise<void> {
601
- const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
602
- const data = resp.data as
603
- | { friendRequests?: unknown; groupRequests?: unknown }
604
- | undefined;
605
- const friendRequests = Array.isArray(data?.friendRequests)
606
- ? (data.friendRequests as IpcSystemMessage[])
607
- : [];
608
- const groupRequests = Array.isArray(data?.groupRequests)
609
- ? (data.groupRequests as IpcSystemMessage[])
610
- : [];
611
- const matches = (m: IpcSystemMessage): boolean =>
612
- m.flag === id || (m.seq != null && String(m.seq) === id);
613
- const friend = friendRequests.find(matches);
614
- const group = friend ? undefined : groupRequests.find(matches);
615
- const target = friend ?? group;
616
- if (!target?.flag) {
617
- throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
618
- }
619
- const params: Record<string, unknown> = { flag: target.flag, approve };
620
- if (friend) {
621
- if (extra.remark) params.remark = extra.remark;
622
- await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
623
- } else {
624
- if (extra.reason) params.reason = extra.reason;
625
- await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
626
- }
627
- }
628
-
629
- /** Test / internal: admit when open. */
630
280
  admit(msg: IcqqInboundMessage): void {
631
281
  const conversation = msg.conversation;
632
282
  if (!this.#open) {
633
- this.#logger.debug(formatCompact({
634
- op: 'icqq_inbound_skip',
635
- reason: 'endpoint_closed',
636
- endpoint: this.name,
637
- id: msg.id,
638
- target: `${conversation.kind}:${conversation.id}`,
639
- }));
283
+ if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
284
+ const dropped = this.#heldInbound.shift();
285
+ this.#logger.warn(formatCompact({
286
+ op: 'icqq_inbound_hold_drop',
287
+ endpoint: this.endpointName,
288
+ id: dropped?.id,
289
+ target: dropped
290
+ ? `${dropped.conversation.kind}:${dropped.conversation.id}`
291
+ : undefined,
292
+ limit: INBOUND_HOLD_LIMIT,
293
+ }));
294
+ }
295
+ this.#heldInbound.push(msg);
296
+ this.#logger.info(
297
+ `hold ${conversation.kind}:${conversation.id}`
298
+ + (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
299
+ + ` until open | ${truncatePreview(msg.content, 80)}`,
300
+ );
640
301
  return;
641
302
  }
642
- // 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
643
303
  const mentioned = isIcqqBotMentioned({
644
- uin: this.name,
304
+ uin: this.endpointName,
645
305
  content: msg.segments,
646
306
  rawMessage: msg.content,
647
307
  });
@@ -659,7 +319,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
659
319
  content: msg.content,
660
320
  ...(msg.segments ? { segments: msg.segments } : {}),
661
321
  sender: msg.sender,
662
- endpointId: this.name,
322
+ endpointId: this.endpointName,
663
323
  ...(mentioned ? { mentioned: true } : {}),
664
324
  ...(quoteId ? { replyTo: { id: String(quoteId) } } : {}),
665
325
  metadata: Object.freeze({
@@ -669,7 +329,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
669
329
  }).catch((err) => {
670
330
  this.#logger.warn(formatCompact({
671
331
  op: 'recv',
672
- endpoint: this.name,
332
+ endpoint: this.endpointName,
673
333
  target: `${conversation.kind}:${conversation.id}`,
674
334
  ok: false,
675
335
  error: err instanceof Error ? err.message : String(err),
@@ -677,47 +337,64 @@ export class IcqqIpcEndpoint implements EndpointInstance {
677
337
  });
678
338
  }
679
339
 
680
- async #refreshLists(): Promise<void> {
681
- const [flResp, glResp] = await Promise.all([
682
- this.ipc.request(Actions.LIST_FRIENDS),
683
- this.ipc.request(Actions.LIST_GROUPS),
684
- ]);
685
- this.friends.clear();
686
- if (flResp.ok && Array.isArray(flResp.data)) {
687
- for (const f of flResp.data as IpcFriendInfo[]) {
688
- this.friends.set(f.user_id, f);
689
- }
690
- }
691
- this.groups.clear();
692
- if (glResp.ok && Array.isArray(glResp.data)) {
693
- for (const g of glResp.data as IpcGroupInfo[]) {
694
- this.groups.set(g.group_id, g);
340
+ #bindClientEvents(): void {
341
+ const safe = (label: string, fn: () => void): void => {
342
+ try {
343
+ fn();
344
+ } catch (error) {
345
+ this.#logger.warn(formatCompact({
346
+ op: 'icqq_event_handler',
347
+ endpoint: this.endpointName,
348
+ event: label,
349
+ error: error instanceof Error ? error.message : String(error),
350
+ }));
695
351
  }
696
- }
352
+ };
353
+ this.on('message.private', (event) => safe('message.private', () => this.#handleMessageEvent('message.private', event)));
354
+ this.on('message.group', (event) => safe('message.group', () => this.#handleMessageEvent('message.group', event)));
355
+ this.on('message.guild', (event) => safe('message.guild', () => this.#handleGuildEvent(event)));
356
+ this.on('request.friend', (event) => safe('request.friend', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
357
+ this.on('request.group', (event) => safe('request.group', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
358
+ this.on('notice.friend', (event) => safe('notice.friend', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
359
+ this.on('notice.group', (event) => safe('notice.group', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
360
+ this.on('system.offline', (event) => this.#logSystemEvent('offline', event));
361
+ this.on('system.offline.network', (event) => this.#logSystemEvent('offline.network', event));
362
+ this.on('system.offline.kickoff', (event) => this.#logSystemEvent('offline.kickoff', event));
363
+ this.on('system.login.qrcode', () => {
364
+ this.#logger.info('icqq 收到二维码登录事件,请按 icqq 默认流程扫码后继续登录');
365
+ });
366
+ this.on('system.login.device', (event) => this.#logSystemEvent('login.device', event));
367
+ this.on('system.login.slider', (event) => this.#logSystemEvent('login.slider', event));
368
+ this.on('system.login.error', (event) => this.#logSystemEvent('login.error', event));
369
+ this.on('system.login.auth', (event) => this.#logSystemEvent('login.auth', event));
370
+ }
371
+
372
+ #logSystemEvent(type: string, event: unknown): void {
373
+ const payload = serializeIcqqEvent(event);
374
+ this.#logger.warn(formatCompact({
375
+ op: `system.${type}`,
376
+ endpoint: this.endpointName,
377
+ ...(payload ? { event: JSON.stringify(payload) } : {}),
378
+ }));
697
379
  }
698
380
 
699
- #handleEvent(event: IpcEvent): void {
700
- if (isIcqqGuildIpcEvent(event.event)) {
701
- this.#handleGuildEvent(event);
702
- return;
703
- }
704
- const payload = unwrapIcqqIpcEventPayload(event);
705
- if (!payload || typeof payload !== 'object') return;
706
- if (isIcqqRequestPayload(payload)) {
707
- this.#recordInboxRequest(payload as Record<string, unknown>);
708
- return;
709
- }
710
- if (isIcqqNoticePayload(payload)) {
711
- this.#recordInboxNotice(payload as Record<string, unknown>);
381
+ #handleMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): void {
382
+ const payload = serializeIcqqEvent(event);
383
+ if (!payload || !isIcqqMessagePostType(payload)) {
384
+ this.#logger.debug(formatCompact({
385
+ op: 'icqq_inbound_skip',
386
+ reason: payload ? 'not_message_post' : 'serialize_failed',
387
+ endpoint: this.endpointName,
388
+ event: eventName,
389
+ }));
712
390
  return;
713
391
  }
714
- if (!isIcqqMessagePostType(payload)) return;
715
- const data = payload as IcqqIpcMessageEvent;
392
+ const data = payload as IcqqMessageEvent;
716
393
  if (shouldSkipSelfInboundMessage(data)) {
717
394
  this.#logger.debug(formatCompact({
718
395
  op: 'icqq_inbound_skip',
719
396
  reason: 'self',
720
- endpoint: this.name,
397
+ endpoint: this.endpointName,
721
398
  user_id: data.user_id,
722
399
  self_id: data.self_id,
723
400
  }));
@@ -728,9 +405,9 @@ export class IcqqIpcEndpoint implements EndpointInstance {
728
405
  this.#logger.debug(formatCompact({
729
406
  op: 'icqq_inbound_skip',
730
407
  reason: 'normalize_failed',
731
- endpoint: this.name,
408
+ endpoint: this.endpointName,
409
+ event: eventName,
732
410
  message_id: data.message_id,
733
- message_type: data.message_type ?? data.type,
734
411
  }));
735
412
  return;
736
413
  }
@@ -738,7 +415,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
738
415
  this.#logger.debug(formatCompact({
739
416
  op: 'icqq_inbound_skip',
740
417
  reason: 'dedupe',
741
- endpoint: this.name,
418
+ endpoint: this.endpointName,
742
419
  id: normalized.messageId,
743
420
  }));
744
421
  return;
@@ -765,8 +442,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
765
442
  });
766
443
  }
767
444
 
768
- #handleGuildEvent(event: IpcEvent): void {
769
- const payload = event.data;
445
+ #handleGuildEvent(event: unknown): void {
446
+ const payload = serializeIcqqEvent(event);
770
447
  if (!payload || typeof payload !== 'object') return;
771
448
  const normalized = normalizeIcqqGuildInboundMessage(
772
449
  payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
@@ -775,7 +452,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
775
452
  this.#logger.debug(formatCompact({
776
453
  op: 'icqq_inbound_skip',
777
454
  reason: 'guild_normalize_failed',
778
- endpoint: this.name,
455
+ endpoint: this.endpointName,
779
456
  }));
780
457
  return;
781
458
  }
@@ -784,7 +461,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
784
461
  this.#logger.debug(formatCompact({
785
462
  op: 'icqq_inbound_skip',
786
463
  reason: 'dedupe',
787
- endpoint: this.name,
464
+ endpoint: this.endpointName,
788
465
  id: `guild:${normalized.messageId}`,
789
466
  }));
790
467
  return;
@@ -807,13 +484,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
807
484
  });
808
485
  }
809
486
 
810
- /** 收件箱行公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
811
- #inboxBase(): { adapter: string; endpointKey: string } {
812
- const id = String(this.#options.id);
813
- return { adapter: id.split('\0').pop() ?? id, endpointKey: this.name };
814
- }
815
-
816
- #recordInboxRequest(payload: Record<string, unknown>): void {
487
+ #recordInboxRequest(payload: Record<string, unknown> | null): void {
488
+ if (!payload || !isIcqqRequestPayload(payload)) return;
817
489
  const hooks = this.#options.inbox;
818
490
  if (!hooks) return;
819
491
  const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
@@ -823,7 +495,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
823
495
  hooks.publish?.('endpoint:request', row);
824
496
  }
825
497
 
826
- #recordInboxNotice(payload: Record<string, unknown>): void {
498
+ #recordInboxNotice(payload: Record<string, unknown> | null): void {
499
+ if (!payload || !isIcqqNoticePayload(payload)) return;
827
500
  const hooks = this.#options.inbox;
828
501
  if (!hooks) return;
829
502
  const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
@@ -833,26 +506,29 @@ export class IcqqIpcEndpoint implements EndpointInstance {
833
506
  hooks.publish?.('endpoint:notice', row);
834
507
  }
835
508
 
836
- /** 启动/重连后首拉 GET_SYSTEM_MSG:补录离线期间的好友/群待处理请求。 */
509
+ #inboxBase(): { adapter: string; endpointKey: string } {
510
+ const id = String(this.#options.id);
511
+ return { adapter: id.split('\0').pop() ?? id, endpointKey: this.endpointName };
512
+ }
513
+
837
514
  async #pullPendingSystemMessages(): Promise<void> {
838
515
  const hooks = this.#options.inbox;
839
- if (!hooks || !this.ipc) return;
516
+ if (!hooks) return;
840
517
  try {
841
- const resp = await this.ipc.request(Actions.GET_SYSTEM_MSG);
842
- if (!resp.ok) return;
843
- const data = resp.data as
844
- | { friendRequests?: unknown; groupRequests?: unknown }
845
- | undefined;
518
+ const messages = await this.getSystemMsg();
519
+ const friendRequests: IcqqSystemMessage[] = [];
520
+ const groupRequests: IcqqSystemMessage[] = [];
521
+ for (const msg of messages) {
522
+ if ((msg as { request_type?: string }).request_type === 'friend') {
523
+ friendRequests.push(msg as unknown as IcqqSystemMessage);
524
+ } else {
525
+ groupRequests.push(msg as unknown as IcqqSystemMessage);
526
+ }
527
+ }
846
528
  const base = this.#inboxBase();
847
529
  const rows = [
848
- ...(Array.isArray(data?.friendRequests)
849
- ? (data.friendRequests as IpcSystemMessage[]).map((m) =>
850
- buildIcqqSystemRequestRow(m, 'friend', base))
851
- : []),
852
- ...(Array.isArray(data?.groupRequests)
853
- ? (data.groupRequests as IpcSystemMessage[]).map((m) =>
854
- buildIcqqSystemRequestRow(m, 'group', base))
855
- : []),
530
+ ...friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base)),
531
+ ...groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base)),
856
532
  ];
857
533
  for (const row of rows) {
858
534
  if (!row) continue;
@@ -863,20 +539,94 @@ export class IcqqIpcEndpoint implements EndpointInstance {
863
539
  } catch (error) {
864
540
  this.#logger.debug(formatCompact({
865
541
  op: 'inbox_pull_system_msg',
866
- endpoint: this.name,
542
+ endpoint: this.endpointName,
867
543
  error: error instanceof Error ? error.message : String(error),
868
544
  }));
869
545
  }
870
546
  }
547
+
548
+ async #approveRequest(id: string, remark?: string): Promise<void> {
549
+ const messages = await this.getSystemMsg();
550
+ const target = messages.find((m) => {
551
+ const msg = m as unknown as IcqqSystemMessage;
552
+ return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
553
+ }) as unknown as IcqqSystemMessage | undefined;
554
+ if (!target?.flag) {
555
+ throw new Error(`icqq 未找到待处理请求: ${id}`);
556
+ }
557
+ if ((target as { request_type?: string }).request_type === 'friend') {
558
+ await this.setFriendAddRequest(target.flag, true, remark);
559
+ } else {
560
+ await this.setGroupAddRequest(target.flag, true);
561
+ }
562
+ }
563
+
564
+ async #rejectRequest(id: string, reason?: string): Promise<void> {
565
+ const messages = await this.getSystemMsg();
566
+ const target = messages.find((m) => {
567
+ const msg = m as unknown as IcqqSystemMessage;
568
+ return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
569
+ }) as unknown as IcqqSystemMessage | undefined;
570
+ if (!target?.flag) {
571
+ throw new Error(`icqq 未找到待处理请求: ${id}`);
572
+ }
573
+ if ((target as { request_type?: string }).request_type === 'friend') {
574
+ await this.setFriendAddRequest(target.flag, false);
575
+ } else {
576
+ await this.setGroupAddRequest(target.flag, false, reason);
577
+ }
578
+ }
579
+ }
580
+
581
+ function describeUnknownError(error: unknown): string {
582
+ if (error instanceof Error) return error.stack ?? error.message;
583
+ if (error && typeof error === 'object') {
584
+ try {
585
+ return JSON.stringify(error);
586
+ } catch {
587
+ return inspect(error, { depth: 4, breakLength: 120 });
588
+ }
589
+ }
590
+ return String(error);
591
+ }
592
+
593
+ function resolveIcqqGroupReactionTarget(
594
+ message: MessageRef,
595
+ ): { groupId: number; seq: number } | null {
596
+ if (message.conversation.kind !== 'group') return null;
597
+ const groupId = Number(message.conversation.id);
598
+ if (!Number.isFinite(groupId) || groupId <= 0) return null;
599
+ const raw = message.id?.trim();
600
+ if (!raw || raw.startsWith('outbound:')) return null;
601
+ if (/^[1-9]\d*$/.test(raw)) return { groupId, seq: Number(raw) };
602
+ try {
603
+ const parsed = parseGroupMessageId(raw);
604
+ if (parsed.seq > 0) return { groupId: parsed.group_id || groupId, seq: parsed.seq };
605
+ } catch {
606
+ // not a cqhttp group message id
607
+ }
608
+ return null;
609
+ }
610
+
611
+ function resolveNativeClientConfig(config: ResolvedIcqqConfig): NativeIcqqClientConfig {
612
+ return {
613
+ log_level: 'info',
614
+ platform: config.platform,
615
+ ...(config.ver ? { ver: config.ver } : {}),
616
+ ...(config.dataDir ? { data_dir: config.dataDir } : {}),
617
+ ...(config.signApiAddr ? { sign_api_addr: config.signApiAddr } : {}),
618
+ ...(config.ignoreSelf != null ? { ignore_self: config.ignoreSelf } : {}),
619
+ ...(config.resend != null ? { resend: config.resend } : {}),
620
+ ...(config.cacheGroupMember != null ? { cache_group_member: config.cacheGroupMember } : {}),
621
+ ...(config.autoServer != null ? { auto_server: config.autoServer } : {}),
622
+ ...(config.qqnt != null ? { QQNT: config.qqnt } : {}),
623
+ ...(config.ntLogin != null ? { NTLogin: config.ntLogin } : {}),
624
+ reconn_interval: config.autoReconnect ? 5 : 0,
625
+ };
871
626
  }
872
627
 
873
- /**
874
- * 组装入站 metadata:在基础字段上补充 quote 链路信息。
875
- * - quote_id:被引用消息 id(resolveIcqqQuoteIdFromEvent,无则不写)
876
- * - quote_sender_id / quote_sender_name / quote_content:source 可用时的平铺摘要
877
- */
878
628
  function buildIcqqQuoteMetadata(
879
- data: IcqqIpcMessageEvent,
629
+ data: IcqqMessageEvent,
880
630
  base: Record<string, unknown>,
881
631
  ): Record<string, unknown> {
882
632
  const metadata: Record<string, unknown> = { ...base };
@@ -901,19 +651,27 @@ function buildIcqqQuoteMetadata(
901
651
  return metadata;
902
652
  }
903
653
 
904
- /** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
905
- function toNumericId(value: number | string, label: string): number {
906
- const n = Number(value);
907
- if (!Number.isFinite(n) || String(value).trim() === '') {
908
- throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
909
- }
910
- return n;
911
- }
654
+ const INBOUND_HOLD_LIMIT = 32;
655
+
656
+ const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'] as const;
912
657
 
913
- async function defaultCreateIpc(config: ResolvedIcqqConfig): Promise<IcqqIpcTransport> {
914
- const uin = Number(config.id);
915
- if (config.rpc) {
916
- return IpcClient.connectRpc(config.rpc);
658
+ function serializeIcqqEvent(event: unknown): Record<string, unknown> | null {
659
+ if (!event || typeof event !== 'object') return null;
660
+ const nativeToJSON = (event as { toJSON?: (keys: string[]) => unknown }).toJSON;
661
+ if (typeof nativeToJSON === 'function') {
662
+ try {
663
+ const json = nativeToJSON.call(event, [...ICQQ_SERIALIZE_SKIP]);
664
+ if (json && typeof json === 'object' && !Array.isArray(json)) {
665
+ return json as Record<string, unknown>;
666
+ }
667
+ } catch {
668
+ /* JSON.stringify 也会无参调 toJSON,不能当回退 */
669
+ }
670
+ }
671
+ const out: Record<string, unknown> = {};
672
+ for (const [key, value] of Object.entries(event as Record<string, unknown>)) {
673
+ if (typeof value === 'function' || (ICQQ_SERIALIZE_SKIP as readonly string[]).includes(key)) continue;
674
+ out[key] = value;
917
675
  }
918
- return IpcClient.connect(uin);
676
+ return Object.keys(out).length > 0 ? out : null;
919
677
  }