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