@zhin.js/adapter-icqq 6.0.1 → 6.0.3

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 (125) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +32 -437
  3. package/adapters/icqq.ts +30 -0
  4. package/agent/tools/announce.ts +21 -0
  5. package/agent/tools/essence.ts +22 -0
  6. package/agent/tools/friend_list.ts +19 -0
  7. package/agent/tools/group_files.ts +27 -0
  8. package/agent/tools/group_invite.ts +20 -0
  9. package/agent/tools/group_list.ts +20 -0
  10. package/agent/tools/group_sign.ts +19 -0
  11. package/agent/tools/list_muted.ts +22 -0
  12. package/agent/tools/poke.ts +20 -0
  13. package/agent/tools/send_user_like.ts +22 -0
  14. package/agent/tools/set_anonymous.ts +22 -0
  15. package/agent/tools/set_title.ts +25 -0
  16. package/lib/agent-prompt.d.ts +0 -1
  17. package/lib/agent-prompt.js +0 -1
  18. package/lib/cq-message.d.ts +0 -1
  19. package/lib/cq-message.js +0 -1
  20. package/lib/endpoint.d.ts +45 -97
  21. package/lib/endpoint.js +357 -628
  22. package/lib/forward-msg.d.ts +1 -2
  23. package/lib/forward-msg.js +4 -2
  24. package/lib/icqq-agent-deps.d.ts +25 -0
  25. package/lib/icqq-agent-deps.js +32 -0
  26. package/lib/icqq-guild.d.ts +1 -2
  27. package/lib/icqq-guild.js +4 -2
  28. package/lib/icqq-inbound.d.ts +10 -1
  29. package/lib/icqq-inbound.js +27 -3
  30. package/lib/index.d.ts +6 -18
  31. package/lib/index.js +6 -50
  32. package/lib/ipc-client.d.ts +0 -1
  33. package/lib/ipc-client.js +0 -1
  34. package/lib/outbound-media.d.ts +0 -1
  35. package/lib/outbound-media.js +0 -1
  36. package/lib/protocol.d.ts +73 -2
  37. package/lib/protocol.js +124 -2
  38. package/lib/types.d.ts +0 -1
  39. package/lib/types.js +0 -1
  40. package/package.json +51 -43
  41. package/plugin.ts +32 -0
  42. package/schema.json +35 -0
  43. package/src/agent-prompt.ts +1 -2
  44. package/src/endpoint.ts +399 -800
  45. package/src/forward-msg.ts +2 -2
  46. package/src/icqq-agent-deps.ts +57 -0
  47. package/src/icqq-guild.ts +1 -2
  48. package/src/icqq-inbound.ts +31 -2
  49. package/src/index.ts +35 -69
  50. package/src/protocol.ts +191 -1
  51. package/client/ICQQManagement.tsx +0 -147
  52. package/client/LoginAssistPanel.tsx +0 -300
  53. package/client/index.tsx +0 -11
  54. package/client/tsconfig.json +0 -7
  55. package/client/utils/api.ts +0 -30
  56. package/dist/index.js +0 -34
  57. package/lib/adapter.d.ts +0 -46
  58. package/lib/adapter.d.ts.map +0 -1
  59. package/lib/adapter.js +0 -128
  60. package/lib/adapter.js.map +0 -1
  61. package/lib/agent-prompt.d.ts.map +0 -1
  62. package/lib/agent-prompt.js.map +0 -1
  63. package/lib/cq-message.d.ts.map +0 -1
  64. package/lib/cq-message.js.map +0 -1
  65. package/lib/endpoint.d.ts.map +0 -1
  66. package/lib/endpoint.js.map +0 -1
  67. package/lib/forward-msg.d.ts.map +0 -1
  68. package/lib/forward-msg.js.map +0 -1
  69. package/lib/get-msg.d.ts +0 -3
  70. package/lib/get-msg.d.ts.map +0 -1
  71. package/lib/get-msg.js +0 -46
  72. package/lib/get-msg.js.map +0 -1
  73. package/lib/icqq-guild.d.ts.map +0 -1
  74. package/lib/icqq-guild.js.map +0 -1
  75. package/lib/icqq-inbound.d.ts.map +0 -1
  76. package/lib/icqq-inbound.js.map +0 -1
  77. package/lib/icqq-login-assist-bridge.d.ts +0 -5
  78. package/lib/icqq-login-assist-bridge.d.ts.map +0 -1
  79. package/lib/icqq-login-assist-bridge.js +0 -148
  80. package/lib/icqq-login-assist-bridge.js.map +0 -1
  81. package/lib/icqq-side-events.d.ts +0 -34
  82. package/lib/icqq-side-events.d.ts.map +0 -1
  83. package/lib/icqq-side-events.js +0 -194
  84. package/lib/icqq-side-events.js.map +0 -1
  85. package/lib/index.d.ts.map +0 -1
  86. package/lib/index.js.map +0 -1
  87. package/lib/ipc-client.d.ts.map +0 -1
  88. package/lib/ipc-client.js.map +0 -1
  89. package/lib/login-assist-routes.d.ts +0 -7
  90. package/lib/login-assist-routes.d.ts.map +0 -1
  91. package/lib/login-assist-routes.js +0 -29
  92. package/lib/login-assist-routes.js.map +0 -1
  93. package/lib/login-ipc-contract.d.ts +0 -44
  94. package/lib/login-ipc-contract.d.ts.map +0 -1
  95. package/lib/login-ipc-contract.js +0 -35
  96. package/lib/login-ipc-contract.js.map +0 -1
  97. package/lib/outbound-media.d.ts.map +0 -1
  98. package/lib/outbound-media.js.map +0 -1
  99. package/lib/protocol.d.ts.map +0 -1
  100. package/lib/protocol.js.map +0 -1
  101. package/lib/routes.d.ts +0 -8
  102. package/lib/routes.d.ts.map +0 -1
  103. package/lib/routes.js +0 -67
  104. package/lib/routes.js.map +0 -1
  105. package/lib/segment-mapper.d.ts +0 -2
  106. package/lib/segment-mapper.d.ts.map +0 -1
  107. package/lib/segment-mapper.js +0 -2
  108. package/lib/segment-mapper.js.map +0 -1
  109. package/lib/tools/index.d.ts +0 -10
  110. package/lib/tools/index.d.ts.map +0 -1
  111. package/lib/tools/index.js +0 -341
  112. package/lib/tools/index.js.map +0 -1
  113. package/lib/types.d.ts.map +0 -1
  114. package/lib/types.js.map +0 -1
  115. package/plugin.yml +0 -3
  116. package/src/adapter.ts +0 -151
  117. package/src/get-msg.ts +0 -56
  118. package/src/icqq-login-assist-bridge.ts +0 -196
  119. package/src/icqq-side-events.ts +0 -228
  120. package/src/login-assist-routes.ts +0 -39
  121. package/src/login-ipc-contract.ts +0 -52
  122. package/src/routes.ts +0 -83
  123. package/src/segment-mapper.ts +0 -1
  124. package/src/tools/index.ts +0 -412
  125. /package/{skills/icqq → agent}/PERMITS.md +0 -0
package/src/endpoint.ts CHANGED
@@ -1,858 +1,307 @@
1
1
  /**
2
- * ICQQ Bot:通过 @icqqjs/cli 守护进程 IPC 通信,实现 zhin Endpoint 接口
3
- *
4
- * 不再直接依赖 @icqqjs/icqq 协议库。
5
- * 登录由 `icqq login` 完成,本 Endpoint 只负责连接守护进程并收发消息。
2
+ * IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
6
3
  */
7
- import { formatCompact, Endpoint, Message, segment, SendContent, SendOptions, expandInteractiveSegmentsInContent, type QuotedMessagePayload,} from 'zhin.js';
8
- import type {
9
- IcqqEndpointConfig,
10
- IcqqSenderInfo,
11
- IpcFriendInfo,
12
- IpcGroupInfo,
13
- IpcMemberInfo,
14
- } from "./types.js";
15
- import type { IcqqAdapter } from "./adapter.js";
16
- import { IpcClient } from "./ipc-client.js";
17
- import { Actions, type IpcEvent } from "./protocol.js";
18
- import type { IcqqIpcMessageEvent } from "./icqq-inbound.js";
4
+ import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { MessageGateway } from '@zhin.js/core/runtime';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
8
+ import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
19
9
  import {
20
- findIcqqNestedMessageSource,
21
10
  InboundMessageDeduper,
11
+ findIcqqNestedMessageSource,
12
+ isIcqqBotMentioned,
22
13
  isIcqqMessagePostType,
23
14
  normalizeIcqqInboundMessage,
24
15
  quotedPayloadFromIcqqSource,
25
16
  resolveIcqqQuoteIdFromEvent,
26
- resolveQuoteIdFromIcqqSource,
27
17
  shouldSkipSelfInboundMessage,
28
18
  unwrapIcqqIpcEventPayload,
29
- type NormalizedIcqqInbound,
30
- } from "./icqq-inbound.js";
19
+ type IcqqIpcMessageEvent,
20
+ } from './icqq-inbound.js';
31
21
  import {
32
- buildIcqqIpcMessage as buildIcqqIpcMessageImpl,
33
- parseCqMessage as parseCqMessageImpl,
34
- toCqString as toCqStringImpl,
35
- } from "./cq-message.js";
22
+ isIcqqGuildIpcEvent,
23
+ normalizeIcqqGuildInboundMessage,
24
+ } from './icqq-guild.js';
25
+ import { IpcClient } from './ipc-client.js';
36
26
  import {
37
27
  materializeOutboundBase64,
38
28
  resolveIcqqOutboundMediaMode,
39
- } from "./outbound-media.js";
40
- import {
41
- formatIcqqMetaLog,
42
- formatIcqqNotice,
43
- formatIcqqRequest,
44
- isIcqqMetaPayload,
45
- isIcqqNoticePayload,
46
- isIcqqRequestPayload,
47
- resolveIcqqEventPostType,
48
- resolveSideEventDedupeKey,
49
- shouldRefreshListsOnMeta,
50
- type IcqqIpcRawEvent,
51
- } from "./icqq-side-events.js";
29
+ } from './outbound-media.js';
52
30
  import {
53
- disposeIcqqLoginAssistBridge,
54
- handleIcqqLoginIpcEvent,
55
- } from "./icqq-login-assist-bridge.js";
56
- import { parseIcqqGetMsgResponse } from "./get-msg.js";
57
- import { enrichQuotedPayloadWithForward, isForwardPlaceholderPayload } from "./forward-msg.js";
58
- import {
59
- IcqqGuildCatalog,
60
- isIcqqGuildIpcEvent,
61
- normalizeIcqqGuildInboundMessage,
62
- type NormalizedIcqqGuildInbound,
63
- } from "./icqq-guild.js";
64
- import type { IpcGuildMessageEventData } from "./protocol.js";
31
+ Actions,
32
+ formatInboundContent,
33
+ formatInboundTarget,
34
+ formatOutboundBody,
35
+ parseSendTarget,
36
+ type IcqqInboundMessage,
37
+ type IpcEvent,
38
+ type IpcResponse,
39
+ type ResolvedIcqqConfig,
40
+ } from './protocol.js';
41
+ import type { IpcFriendInfo, IpcGroupInfo } from './types.js';
42
+
43
+ const logger = getLogger('icqq');
44
+
45
+ /** Minimal IPC surface used by the endpoint (real IpcClient or test mock). */
46
+ export interface IcqqIpcTransport {
47
+ request(action: string, params?: Record<string, unknown>): Promise<IpcResponse>;
48
+ subscribe(
49
+ action: string,
50
+ params: Record<string, unknown>,
51
+ handler: (event: IpcEvent) => void,
52
+ ): { unsubscribe: () => Promise<void> };
53
+ setOnRemoteDisconnect(handler: (() => void) | null): void;
54
+ close(): void;
55
+ }
65
56
 
66
- export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessageEvent> {
67
- $connected = false;
68
- $config: IcqqEndpointConfig;
69
- ipc!: IpcClient;
57
+ export type CreateIcqqIpc = (config: ResolvedIcqqConfig) => Promise<IcqqIpcTransport>;
70
58
 
71
- /** 缓存的好友列表 */
72
- friends = new Map<number, IpcFriendInfo>();
73
- /** 缓存的群列表 */
74
- groups = new Map<number, IpcGroupInfo>();
75
- /** QQ 频道(guild)子频道缓存 */
76
- readonly guildCatalog = new IcqqGuildCatalog();
59
+ export interface IcqqEndpointOptions {
60
+ readonly id: CapabilityId;
61
+ readonly gateway: MessageGateway;
62
+ readonly config: ResolvedIcqqConfig;
63
+ readonly createIpc?: CreateIcqqIpc;
64
+ }
77
65
 
78
- private subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
79
- /** 事件去重:覆盖多端回流/服务端重复推送等场景 */
80
- private readonly inboundDeduper = new InboundMessageDeduper();
81
- /** MessageEvent.source 解析结果,供 $getMsg 优先命中 */
82
- private readonly quotedSourceCache = new Map<string, QuotedMessagePayload>();
83
- /** 用户主动断开时为 true,阻止自动重连 */
84
- private intentionalDisconnect = false;
66
+ export class IcqqIpcEndpoint implements EndpointInstance {
67
+ readonly #options: IcqqEndpointOptions;
68
+ readonly #createIpc: CreateIcqqIpc;
69
+ readonly name: string;
70
+ /** Populated after start(); agent tools read this. */
71
+ ipc!: IcqqIpcTransport;
72
+ readonly friends = new Map<number, IpcFriendInfo>();
73
+ readonly groups = new Map<number, IpcGroupInfo>();
74
+ #open = false;
75
+ #started = false;
76
+ #subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
77
+ #inboundDeduper = new InboundMessageDeduper();
78
+ #unregisterAgent?: () => void;
79
+ /** 用户主动 stop 时为 true,阻止自动重连 */
80
+ #intentionalDisconnect = false;
85
81
  /** 是否已有重连循环在跑(避免多次 schedule 叠套) */
86
- private reconnectRunning = false;
82
+ #reconnectRunning = false;
87
83
 
88
- get $id() {
89
- return this.$config.name;
84
+ constructor(options: IcqqEndpointOptions) {
85
+ this.#options = options;
86
+ this.name = options.config.name;
87
+ this.#createIpc = options.createIpc ?? defaultCreateIpc;
90
88
  }
91
89
 
92
- get logger() {
93
- return this.adapter.plugin.logger;
94
- }
95
-
96
- constructor(
97
- public adapter: IcqqAdapter,
98
- config: IcqqEndpointConfig,
99
- ) {
100
- this.$config = config;
90
+ async request(action: string, params?: Record<string, unknown>): Promise<IpcResponse> {
91
+ if (!this.ipc) {
92
+ throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发起请求: ${action}`);
93
+ }
94
+ return this.ipc.request(action, params);
101
95
  }
102
96
 
103
- // ── 连接 ───────────────────────────────────────────────────────────
104
-
105
- async $connect(): Promise<void> {
106
- this.intentionalDisconnect = false;
107
- const rpc = this.$config.rpc;
108
-
109
- this.logger.debug(formatCompact( {
110
- connecting: this.$id,
111
- mode: rpc ? "rpc" : "ipc",
112
- host: rpc?.host,
113
- port: rpc?.port,
114
- }));
115
-
116
- await this.rebindIpcSession();
117
-
118
- this.logger.info(formatCompact( {
119
- 机器人: this.$id,
120
- 好友数: this.friends.size,
121
- 群组数: this.groups.size,
122
- }));
97
+ async start(): Promise<void> {
98
+ if (this.#started) return;
99
+ this.#started = true;
100
+ this.#intentionalDisconnect = false;
101
+ try {
102
+ await this.#bindIpcSession();
103
+ this.#unregisterAgent = registerIcqqAgentEndpoint(this.name, this);
104
+ logger.info(formatCompact({
105
+ op: 'connect',
106
+ endpoint: this.name,
107
+ mode: this.#options.config.rpc ? 'rpc' : 'ipc',
108
+ friends: this.friends.size,
109
+ groups: this.groups.size,
110
+ }));
111
+ } catch (error) {
112
+ await this.stop();
113
+ // Startup connect failures are surfaced once (with stack) by AdapterIndex;
114
+ // keep this at debug to avoid a duplicate error-level log.
115
+ logger.debug(`Failed to connect ICQQ IPC: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
116
+ throw error;
117
+ }
123
118
  }
124
119
 
125
120
  /** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
126
- private async rebindIpcSession(): Promise<void> {
127
- const uin = Number(this.$config.name);
128
- const rpc = this.$config.rpc;
129
-
121
+ async #bindIpcSession(): Promise<void> {
130
122
  // 旧连接上的订阅句柄已失效,不再对死 socket 发 unsubscribe
131
- this.subscriptions = [];
132
-
133
- let client: IpcClient;
134
- if (rpc) {
135
- client = await IpcClient.connectRpc(rpc);
136
- } else {
137
- client = await IpcClient.connect(uin);
138
- }
139
-
140
- if (this.intentionalDisconnect) {
141
- client.close();
142
- throw new Error("连接已取消");
143
- }
144
-
145
- this.ipc = client;
146
- if (this.$config.autoReconnect !== false) {
147
- this.ipc.setOnRemoteDisconnect(() => this.scheduleReconnect());
148
- } else {
149
- this.ipc.setOnRemoteDisconnect(null);
150
- }
151
-
152
- await this.refreshLists();
153
-
154
- // 新版 icqq cli 在认证后自动广播事件,订阅过滤改为客户端侧完成。
155
- const sub = this.ipc.subscribe(
156
- Actions.SUBSCRIBE,
157
- {},
158
- (event) => this.handleEvent(event),
159
- );
160
- this.subscriptions.push(sub);
161
-
162
- this.$connected = true;
123
+ this.#subscriptions = [];
124
+ this.ipc = await this.#createIpc(this.#options.config);
125
+ if (this.#options.config.autoReconnect !== false) {
126
+ this.ipc.setOnRemoteDisconnect(() => this.#scheduleReconnect());
127
+ }
128
+ await this.#refreshLists();
129
+ const sub = this.ipc.subscribe(Actions.SUBSCRIBE, {}, (event) => {
130
+ this.#handleEvent(event);
131
+ });
132
+ this.#subscriptions.push(sub);
163
133
  }
164
134
 
165
135
  /** IPC/RPC 意外断开时调度重连(指数退避) */
166
- private scheduleReconnect(): void {
167
- if (this.$config.autoReconnect === false) return;
168
- if (this.intentionalDisconnect) return;
169
- if (this.reconnectRunning) return;
170
- this.reconnectRunning = true;
171
- void this.runReconnectLoop();
136
+ #scheduleReconnect(): void {
137
+ if (this.#options.config.autoReconnect === false) return;
138
+ if (this.#intentionalDisconnect) return;
139
+ if (this.#reconnectRunning) return;
140
+ this.#reconnectRunning = true;
141
+ void this.#runReconnectLoop();
172
142
  }
173
143
 
174
- private async runReconnectLoop(): Promise<void> {
144
+ async #runReconnectLoop(): Promise<void> {
175
145
  try {
176
- for (let attempt = 0; !this.intentionalDisconnect; attempt++) {
146
+ for (let attempt = 0; !this.#intentionalDisconnect; attempt++) {
177
147
  const base = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6));
178
148
  const jitter = Math.floor(Math.random() * 400);
179
149
  const delayMs = base + jitter;
180
-
181
- this.$connected = false;
182
- this.logger.warn(formatCompact( {
183
- op: "disconnect",
184
- endpoint: this.$id,
150
+ // 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏
151
+ const disconnectLog = attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
152
+ disconnectLog(formatCompact({
153
+ op: 'disconnect',
154
+ endpoint: this.name,
185
155
  ok: false,
186
156
  delay_ms: delayMs,
187
157
  attempt: attempt + 1,
188
158
  }));
189
-
190
159
  await new Promise<void>((r) => setTimeout(r, delayMs));
191
- if (this.intentionalDisconnect) break;
192
-
160
+ if (this.#intentionalDisconnect) break;
193
161
  try {
194
- await this.rebindIpcSession();
195
- this.logger.info(formatCompact( {
196
- op: "reconnect",
197
- endpoint: this.$id,
162
+ await this.#bindIpcSession();
163
+ logger.info(formatCompact({
164
+ op: 'reconnect',
165
+ endpoint: this.name,
166
+ ok: true,
167
+ attempts: attempt + 1,
198
168
  friends: this.friends.size,
199
169
  groups: this.groups.size,
200
170
  }));
201
171
  break;
202
- } catch (e: unknown) {
203
- this.logger.warn(formatCompact( {
204
- op: "reconnect",
205
- endpoint: this.$id,
172
+ } catch (error) {
173
+ const retryLog = attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
174
+ retryLog(formatCompact({
175
+ op: 'reconnect',
176
+ endpoint: this.name,
206
177
  ok: false,
207
- error: e instanceof Error ? e.message : String(e),
178
+ attempt: attempt + 1,
179
+ error: error instanceof Error ? error.message : String(error),
208
180
  }));
209
181
  }
210
182
  }
211
183
  } finally {
212
- this.reconnectRunning = false;
184
+ this.#reconnectRunning = false;
213
185
  }
214
186
  }
215
187
 
216
- /** 刷新好友/群列表缓存 */
217
- async refreshLists(): Promise<void> {
218
- const [flResp, glResp] = await Promise.all([
219
- this.ipc.request(Actions.LIST_FRIENDS),
220
- this.ipc.request(Actions.LIST_GROUPS),
221
- ]);
222
-
223
- this.friends.clear();
224
- if (flResp.ok && Array.isArray(flResp.data)) {
225
- for (const f of flResp.data as IpcFriendInfo[]) {
226
- this.friends.set(f.user_id, f);
227
- }
228
- }
229
-
230
- this.groups.clear();
231
- if (glResp.ok && Array.isArray(glResp.data)) {
232
- for (const g of glResp.data as IpcGroupInfo[]) {
233
- this.groups.set(g.group_id, g);
234
- }
235
- }
236
-
237
- await this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
238
- this.logger.warn(formatCompact({
239
- op: "sync_guilds",
240
- endpoint: this.$id,
241
- ok: false,
242
- error: e instanceof Error ? e.message : String(e),
243
- }));
244
- });
188
+ open(): void {
189
+ this.#open = true;
245
190
  }
246
191
 
247
- /** Console:列出 guild 子频道(含 parent.guild) */
248
- getGuildChannelList(): ReturnType<IcqqGuildCatalog["getGuildChannelList"]> {
249
- return this.guildCatalog.getGuildChannelList();
192
+ close(): void {
193
+ this.#open = false;
250
194
  }
251
195
 
252
- resolveConsoleChannelNames(
253
- channelId: string,
254
- guildId?: string,
255
- ): { channelName?: string; parentName?: string } {
256
- return this.guildCatalog.resolveConsoleChannelNames(channelId, guildId);
257
- }
258
-
259
- // ── 断开 ───────────────────────────────────────────────────────────
260
-
261
- async $disconnect(): Promise<void> {
262
- this.intentionalDisconnect = true;
263
-
196
+ async stop(): Promise<void> {
197
+ this.#intentionalDisconnect = true;
198
+ this.#open = false;
264
199
  this.ipc?.setOnRemoteDisconnect(null);
265
- for (const sub of this.subscriptions) {
266
- await sub.unsubscribe().catch(() => {});
267
- }
268
- this.subscriptions = [];
269
- this.inboundDeduper.clear();
270
- disposeIcqqLoginAssistBridge(this.$id);
271
- this.ipc?.close();
272
- this.$connected = false;
273
- this.logger.info(formatCompact( { op: "disconnect", endpoint: this.$id }));
274
- }
275
-
276
- // ── 消息处理 ───────────────────────────────────────────────────────
277
-
278
- private handleEvent(event: IpcEvent): void {
279
- if (event.event?.startsWith("system.login.")) {
280
- handleIcqqLoginIpcEvent(this, event.event, event.data);
281
- return;
282
- }
283
-
284
- if (event.event === Actions.RELOAD_GUILDS) {
285
- void this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
286
- this.logger.warn(formatCompact({
287
- op: "reload_guilds",
288
- endpoint: this.$id,
289
- ok: false,
290
- error: e instanceof Error ? e.message : String(e),
291
- }));
292
- });
293
- return;
294
- }
295
-
296
- if (isIcqqGuildIpcEvent(event.event)) {
297
- this.handleGuildMessageEvent(event);
298
- return;
299
- }
300
-
301
- const payload = unwrapIcqqIpcEventPayload(event);
302
- if (!payload || typeof payload !== "object") {
303
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
304
- this.logger.info(
305
- formatCompact({
306
- ipc_skip: "no_payload",
307
- ipc_event: event.event,
308
- }),
309
- );
310
- }
311
- return;
312
- }
313
-
314
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
315
- this.logger.info(
316
- formatCompact({
317
- ipc_raw: JSON.stringify(payload).slice(0, 800),
318
- }),
319
- );
320
- }
321
-
322
- if (isIcqqNoticePayload(payload)) {
323
- this.handleNoticeEvent(payload);
324
- return;
325
- }
326
- if (isIcqqRequestPayload(payload)) {
327
- this.handleRequestEvent(payload);
328
- return;
329
- }
330
- if (isIcqqMetaPayload(payload)) {
331
- this.handleMetaEvent(payload);
332
- return;
333
- }
334
-
335
- if (!isIcqqMessagePostType(payload)) {
336
- const postType = resolveIcqqEventPostType(
337
- payload as Record<string, unknown>,
338
- );
339
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
340
- this.logger.info(
341
- formatCompact({ ipc_skip: postType ?? "unknown_event" }),
342
- );
343
- }
344
- return;
345
- }
346
-
347
- const data = payload;
348
-
349
- if (shouldSkipSelfInboundMessage(data)) {
350
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
351
- this.logger.info(formatCompact({ ipc_skip: "self_message" }));
352
- }
353
- return;
354
- }
355
-
356
- const normalized = normalizeIcqqInboundMessage(data);
357
- if (!normalized) {
358
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
359
- this.logger.info(formatCompact({ ipc_skip: "normalize_failed" }));
360
- }
361
- return;
362
- }
363
-
364
- if (!this.inboundDeduper.shouldProcess(normalized.messageId)) {
365
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
366
- this.logger.info(
367
- formatCompact({ ipc_dedupe: normalized.messageId }),
368
- );
369
- }
370
- return;
371
- }
372
-
373
- void this.dispatchInboundMessage(data, normalized);
374
- }
375
-
376
- private async dispatchInboundMessage(
377
- data: IcqqIpcMessageEvent,
378
- normalized: NormalizedIcqqInbound,
379
- ): Promise<void> {
380
- this.logIpcInboundPayload(data, normalized);
381
-
382
- await this.primeQuotedSourceCache(
383
- data.source ?? findIcqqNestedMessageSource(data),
384
- );
385
-
386
- const message = this.$formatMessage(normalized);
387
- this.adapter.emit("message.receive", message);
388
- this.logger.debug(
389
- `${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}${message.$quote_id ? ` quote_id=${message.$quote_id}` : ""}`,
390
- );
391
- }
392
-
393
- private handleGuildMessageEvent(event: IpcEvent): void {
394
- const payload = event.data;
395
- if (!payload || typeof payload !== "object") return;
396
- const data = payload as IpcGuildMessageEventData & Record<string, unknown>;
397
- const normalized = normalizeIcqqGuildInboundMessage(data);
398
- if (!normalized) return;
399
-
400
- this.guildCatalog.upsertFromInbound(data);
401
-
402
- if (!this.inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
403
- return;
404
- }
405
-
406
- void this.dispatchGuildInboundMessage(normalized);
407
- }
408
-
409
- private async dispatchGuildInboundMessage(
410
- normalized: NormalizedIcqqGuildInbound,
411
- ): Promise<void> {
412
- const message = this.$formatGuildMessage(normalized);
413
- this.adapter.emit("message.receive", message);
414
- this.logger.debug(
415
- `${this.$id} recv channel(${message.$channel.id}):${segment.raw(message.$content)}`,
416
- );
417
- }
418
-
419
- private $formatGuildMessage(
420
- normalized: NormalizedIcqqGuildInbound,
421
- ): ReturnType<typeof Message.from<IpcGuildMessageEventData>> {
422
- const senderInfo: IcqqSenderInfo = {
423
- id: normalized.userId,
424
- name: normalized.nickname,
425
- };
426
-
427
- const result = Message.from(normalized.raw, {
428
- $id: normalized.messageId,
429
- $adapter: "icqq" as const,
430
- $endpoint: this.$config.name,
431
- $sender: senderInfo,
432
- $channel: {
433
- id: normalized.channelId,
434
- type: "channel",
435
- parent: { type: "guild", id: normalized.guildId },
436
- },
437
- $content: normalized.content,
438
- $raw: normalized.rawMessage,
439
- $timestamp: normalized.timestampMs,
440
- $recall: async () => {
441
- await this.$recallMessage(result.$id);
442
- },
443
- $reply: async (
444
- content: SendContent,
445
- quote?: boolean | string,
446
- ): Promise<string> => {
447
- if (!Array.isArray(content)) content = [content];
448
- if (quote) {
449
- content.unshift({
450
- type: "reply",
451
- data: { id: typeof quote === "boolean" ? result.$id : quote },
452
- });
453
- }
454
- return await this.adapter.sendMessage({
455
- context: "icqq",
456
- endpoint: this.$config.name,
457
- id: normalized.channelId,
458
- type: "channel",
459
- parent: { type: "guild", id: normalized.guildId },
460
- content,
461
- });
462
- },
463
- });
464
- return result;
465
- }
466
-
467
- private handleNoticeEvent(event: IcqqIpcRawEvent): void {
468
- const dedupeKey = resolveSideEventDedupeKey(event, "notice");
469
- if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
470
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
471
- this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
472
- }
473
- return;
474
- }
475
- const notice = formatIcqqNotice(event, this.$config.name);
476
- this.adapter.emit("notice.receive", notice);
477
- this.logger.info(
478
- formatCompact({
479
- notice: notice.$type,
480
- channel: `${notice.$channel.type}(${notice.$channel.id})`,
481
- endpoint: this.$id,
482
- sub_type: notice.$subType,
483
- }),
484
- );
485
- }
486
-
487
- private handleRequestEvent(event: IcqqIpcRawEvent): void {
488
- const dedupeKey = resolveSideEventDedupeKey(event, "request");
489
- if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
490
- if (process.env.ICQQ_IPC_LOG_RAW === "1") {
491
- this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
492
- }
493
- return;
494
- }
495
- const request = formatIcqqRequest(event, this.$config.name, this.ipc);
496
- this.adapter.emit("request.receive", request);
497
- this.logger.info(
498
- formatCompact({
499
- request: request.$type,
500
- channel: `${request.$channel.type}(${request.$channel.id})`,
501
- endpoint: this.$id,
502
- from: request.$sender.id,
503
- }),
504
- );
505
- }
506
-
507
- private handleMetaEvent(event: IcqqIpcRawEvent): void {
508
- const dedupeKey = resolveSideEventDedupeKey(event, "meta");
509
- if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
510
-
511
- this.logger.debug(formatIcqqMetaLog(event));
512
- if (shouldRefreshListsOnMeta(event)) {
513
- void this.refreshLists().catch((e: unknown) => {
514
- this.logger.warn(
515
- formatCompact({
516
- op: "refresh_lists",
517
- ok: false,
518
- error: e instanceof Error ? e.message : String(e),
519
- }),
520
- );
521
- });
522
- }
523
- }
524
-
525
- /** 调试 IPC 入站字段:默认 debug;设 ICQQ_IPC_LOG_RAW=1 则 info 打完整 payload */
526
- private logIpcInboundPayload(
527
- data: IcqqIpcMessageEvent,
528
- normalized: NormalizedIcqqInbound,
529
- ): void {
530
- const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
531
- const keys = Object.keys(ext);
532
- const idHints: Record<string, unknown> = {};
533
- for (const key of keys) {
534
- if (/message|msg|seq|id|source/i.test(key)) {
535
- idHints[key] = ext[key];
536
- }
537
- }
538
- const preview = JSON.stringify(data);
539
- const compact = formatCompact({
540
- post_type: data.post_type ?? "legacy",
541
- message_type: data.message_type ?? data.type,
542
- ipc_message_id: normalized.messageId,
543
- id_source: normalized.idSource,
544
- ipc_keys: keys.join(","),
545
- ...(Object.keys(idHints).length ? { ipc_id_hints: JSON.stringify(idHints) } : {}),
546
- });
547
- this.logger.debug(compact);
548
- this.logger.debug(formatCompact({ ipc_payload: preview.slice(0, 1200) }));
549
- }
550
-
551
- $formatMessage(
552
- input: NormalizedIcqqInbound | IcqqIpcMessageEvent,
553
- ): ReturnType<typeof Message.from<IcqqIpcMessageEvent>> {
554
- const normalized =
555
- "messageId" in input
556
- ? input
557
- : normalizeIcqqInboundMessage(input);
558
- if (!normalized) {
559
- throw new Error("无法解析 icqq 入站消息");
200
+ for (const sub of this.#subscriptions.splice(0)) {
201
+ await sub.unsubscribe().catch(() => { /* ignore */ });
560
202
  }
561
- const raw = normalized.raw;
562
- const senderInfo: IcqqSenderInfo = {
563
- id: normalized.userId,
564
- name: normalized.nickname,
565
- };
566
- if (normalized.senderRole && normalized.senderRole !== "member") {
567
- senderInfo.role = normalized.senderRole;
568
- if (normalized.senderRole === "owner") {
569
- senderInfo.isOwner = true;
570
- senderInfo.permissions = ["owner"];
571
- } else if (normalized.senderRole === "admin") {
572
- senderInfo.isAdmin = true;
573
- senderInfo.permissions = ["admin"];
574
- }
575
- }
576
-
577
- const quoteId =
578
- Message.quoteIdFromContent(normalized.content) ??
579
- resolveIcqqQuoteIdFromEvent(normalized.raw);
580
- Message.alignReplySegments(normalized.content, quoteId);
581
-
582
- const result = Message.from(raw, {
583
- $id: normalized.messageId,
584
- $adapter: "icqq" as const,
585
- $endpoint: this.$config.name,
586
- $sender: senderInfo,
587
- $channel: {
588
- id: normalized.channelId,
589
- type: normalized.channelType,
590
- ...(normalized.channelParentGroupId
591
- ? { parent: { type: 'group' as const, id: normalized.channelParentGroupId } }
592
- : {}),
593
- },
594
- $content: normalized.content,
595
- $quote_id: quoteId,
596
- $raw: normalized.rawMessage,
597
- $timestamp: normalized.timestampMs,
598
- $recall: async () => {
599
- if (normalized.idSource === "synthetic") {
600
- this.logger.warn(formatCompact( {
601
- op: "recall",
602
- endpoint: this.$id,
603
- ok: false,
604
- error: "no message_id in push",
605
- }));
606
- return;
607
- }
608
- await this.$recallMessage(result.$id);
609
- },
610
- $reply: async (
611
- content: SendContent,
612
- quote?: boolean | string,
613
- ): Promise<string> => {
614
- if (!Array.isArray(content)) content = [content];
615
- if (quote) {
616
- content.unshift({
617
- type: "reply",
618
- data: { id: typeof quote === "boolean" ? result.$id : quote },
619
- });
620
- }
621
- return await this.adapter.sendMessage({
622
- ...result.$channel,
623
- context: "icqq",
624
- endpoint: this.$config.name,
625
- content,
626
- });
627
- },
628
- });
629
- return result;
630
- }
631
-
632
- /**
633
- * 有 source.message_id 时用 get_msg 拉全量正文;否则仅用 source 内联摘要。
634
- */
635
- private async primeQuotedSourceCache(source: unknown): Promise<void> {
636
- if (!source) return;
637
- const quoteId = resolveQuoteIdFromIcqqSource(source);
638
- const s = source as { message_id?: unknown };
639
- const hasCanonicalId =
640
- quoteId &&
641
- s.message_id != null &&
642
- String(s.message_id).trim() === quoteId;
643
-
644
- if (hasCanonicalId) {
645
- try {
646
- await this.fetchQuotedMessagePayload(quoteId);
647
- return;
648
- } catch (e: unknown) {
649
- this.logger.debug(
650
- formatCompact({
651
- op: "quote_get_msg",
652
- message_id: quoteId,
653
- ok: false,
654
- error: e instanceof Error ? e.message : String(e),
655
- }),
656
- );
657
- }
658
- }
659
-
660
- const sourcePayload = quotedPayloadFromIcqqSource(source);
661
- if (!sourcePayload) return;
662
- const enriched = await enrichQuotedPayloadWithForward(
663
- this.ipc,
664
- sourcePayload,
665
- );
666
- this.quotedSourceCache.set(enriched.messageId, enriched);
667
- }
668
-
669
- private async fetchQuotedMessagePayload(
670
- messageId: string,
671
- ): Promise<QuotedMessagePayload> {
672
- const resp = await this.ipc.request(Actions.GET_MSG, {
673
- message_id: messageId,
674
- });
675
- if (!resp.ok) {
676
- throw new Error(resp.error ?? "get_msg failed");
677
- }
678
- const payload = parseIcqqGetMsgResponse(messageId, resp.data);
679
- const enriched = await enrichQuotedPayloadWithForward(
680
- this.ipc,
681
- payload,
682
- resp.data,
683
- );
684
- if (
685
- isForwardPlaceholderPayload(enriched) &&
686
- !String(
687
- Array.isArray(enriched.content)
688
- ? segment.raw(enriched.content)
689
- : enriched.content ?? "",
690
- ).includes("[Merged chat history")
691
- ) {
692
- this.logger.debug(
693
- formatCompact({
694
- op: "forward_unresolved",
695
- message_id: messageId,
696
- }),
697
- );
698
- }
699
- this.quotedSourceCache.set(messageId, enriched);
700
- return enriched;
203
+ this.#unregisterAgent?.();
204
+ this.#unregisterAgent = undefined;
205
+ this.#inboundDeduper.clear();
206
+ this.friends.clear();
207
+ this.groups.clear();
208
+ try {
209
+ this.ipc?.close();
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ // 置空 ipc:start 前 / stop 后的 request() 走防御性报错而非 TypeError
214
+ this.ipc = undefined as unknown as IcqqIpcTransport;
215
+ this.#started = false;
216
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.name }));
701
217
  }
702
218
 
703
- async $getMsg(messageId: string): Promise<QuotedMessagePayload> {
704
- const cached = this.quotedSourceCache.get(messageId);
705
- if (cached) {
706
- return enrichQuotedPayloadWithForward(this.ipc, cached);
219
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
220
+ if (!this.ipc) {
221
+ throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发送消息`);
707
222
  }
708
- return this.fetchQuotedMessagePayload(messageId);
709
- }
710
-
711
- // ── 撤回 ───────────────────────────────────────────────────────────
712
-
713
- async $recallMessage(id: string): Promise<void> {
714
- const resp = await this.ipc.request(Actions.RECALL_MSG, {
715
- message_id: id,
716
- });
717
- if (!resp.ok) {
718
- this.logger.warn(formatCompact( {
719
- op: "recall",
720
- endpoint: this.$id,
721
- ok: false,
722
- error: resp.error,
723
- }));
724
- }
725
- }
726
-
727
- // ── 发送消息 ───────────────────────────────────────────────────────
728
-
729
- async $sendMessage(options: SendOptions): Promise<string> {
730
- const outboundMedia = resolveIcqqOutboundMediaMode(this.$config);
731
- const expanded = expandInteractiveSegmentsInContent(options.content);
732
- const qrcodeContent = expanded;
733
- const content = materializeOutboundBase64(qrcodeContent, outboundMedia);
734
- const message = buildIcqqIpcMessageImpl(content);
735
-
223
+ const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
224
+ const content = Array.isArray(payload)
225
+ ? materializeOutboundBase64(payload, mediaMode)
226
+ : payload;
227
+ const message = formatOutboundBody(content);
228
+ const parsed = parseSendTarget(target);
736
229
  let action: string;
737
230
  let params: Record<string, unknown>;
738
-
739
- switch (options.type) {
740
- case "private": {
741
- const groupParent =
742
- options.parent?.type === "group" ? options.parent.id : undefined;
743
- if (groupParent) {
744
- action = Actions.SEND_TEMP_MSG;
745
- params = {
746
- group_id: Number(groupParent),
747
- user_id: Number(options.id),
748
- message,
749
- };
750
- } else {
751
- action = Actions.SEND_PRIVATE_MSG;
752
- params = { user_id: Number(options.id), message };
753
- }
231
+ switch (parsed.kind) {
232
+ case 'private':
233
+ action = Actions.SEND_PRIVATE_MSG;
234
+ params = { user_id: parsed.userId, message };
754
235
  break;
755
- }
756
- case "group":
236
+ case 'group':
757
237
  action = Actions.SEND_GROUP_MSG;
758
- params = { group_id: Number(options.id), message };
238
+ params = { group_id: parsed.groupId, message };
759
239
  break;
760
- case "channel": {
761
- const guildId =
762
- options.parent?.type === "guild" ? options.parent.id : undefined;
763
- if (!guildId) {
764
- throw new Error(
765
- 'channel 发送需要 parent: { type: "guild", id: guild_id }',
766
- );
767
- }
240
+ case 'temp':
241
+ action = Actions.SEND_TEMP_MSG;
242
+ params = {
243
+ group_id: parsed.groupId,
244
+ user_id: parsed.userId,
245
+ message,
246
+ };
247
+ break;
248
+ case 'channel':
768
249
  action = Actions.GUILD_SEND_MSG;
769
250
  params = {
770
- guild_id: guildId,
771
- channel_id: options.id,
251
+ guild_id: parsed.guildId,
252
+ channel_id: parsed.channelId,
772
253
  message,
773
254
  };
774
255
  break;
775
- }
776
- default:
777
- throw new Error(`不支持的频道类型: ${options.type}`);
778
256
  }
779
-
780
257
  const resp = await this.ipc.request(action, params);
781
258
  if (!resp.ok) {
782
- this.logger.debug(formatCompact({
783
- op: action,
784
- ...params,
785
- }));
786
259
  throw new Error(`发送消息失败: ${resp.error}`);
787
260
  }
788
-
789
261
  const messageId = String(
790
- (resp.data as any)?.message_id ?? `sent_${Date.now()}`,
791
- );
792
- this.logger.debug(
793
- `${this.$id} send ${options.type}(${options.id}):${segment.raw(options.content)}`,
262
+ (resp.data as { message_id?: unknown } | undefined)?.message_id ?? `sent_${Date.now()}`,
794
263
  );
264
+ logger.debug(formatCompact({
265
+ op: 'icqq_send',
266
+ endpoint: this.name,
267
+ target,
268
+ messageId,
269
+ }));
795
270
  return messageId;
796
271
  }
797
272
 
798
- // ── 消息回应 ───────────────────────────────────────────────────────
273
+ // ── 消息回应(activity-feedback / typing reaction)────────────────────
799
274
 
800
- /**
801
- * 表情符号到 reaction id 的映射
802
- * QQ 使用数字 ID 来标识表情,而不是 Unicode 字符
803
- */
804
275
  private static readonly EMOJI_MAP: Record<string, string> = {
805
- '⏳': '1468368274', // 沙漏
806
- '👍': '128077', // 竖起大拇指
807
- '❤️': '10084', // 红心
808
- '😊': '128522', // 微笑
809
- '🎉': '127881', // 派对
810
- '🔥': '128293', // 火
811
- '✅': '9989', // 勾选
812
- '❌': '10060', // 叉号
813
- '⭐': '11088', // 星星
814
- '💯': '128175', // 一百分
276
+ '⏳': '1468368274',
277
+ '👍': '128077',
278
+ '❤️': '10084',
279
+ '😊': '128522',
280
+ '🎉': '127881',
281
+ '🔥': '128293',
282
+ '✅': '9989',
283
+ '❌': '10060',
284
+ '⭐': '11088',
285
+ '💯': '128175',
815
286
  };
816
287
 
817
- /**
818
- * 将表情符号转换为 reaction id
819
- */
820
288
  private getEmojiId(emoji: string): string {
821
- // 如果已经是数字 ID,直接返回
822
- if (/^\d+$/.test(emoji)) {
823
- return emoji;
824
- }
825
-
826
- // 从映射中查找
827
- const id = IcqqEndpoint.EMOJI_MAP[emoji];
828
- if (id) {
829
- return id;
830
- }
831
-
832
- // 默认返回 Unicode 码点
833
- return String(emoji.codePointAt(0) || 0);
289
+ if (/^\d+$/u.test(emoji)) return emoji;
290
+ return IcqqIpcEndpoint.EMOJI_MAP[emoji] ?? String(emoji.codePointAt(0) || 0);
834
291
  }
835
292
 
836
- /**
837
- * 添加消息回应(表情)
838
- *
839
- * @param messageId - 消息 ID
840
- * @param emoji - 表情符号或 reaction id
841
- * @returns 反应 ID,可用于后续移除
842
- */
843
- async $addReaction(messageId: string, emoji: string): Promise<string | null> {
293
+ /** Activity-feedback / OutboundHost reaction surface. */
294
+ async addReaction(messageId: string, emoji: string): Promise<string | null> {
844
295
  try {
845
296
  const emojiId = this.getEmojiId(emoji);
846
-
847
- const resp = await this.ipc.request(Actions.GROUP_SET_REACTION, {
297
+ const resp = await this.request(Actions.GROUP_SET_REACTION, {
848
298
  message_id: messageId,
849
299
  id: emojiId,
850
300
  });
851
-
852
301
  if (!resp.ok) {
853
- this.logger.warn(formatCompact({
854
- op: "add_reaction",
855
- endpoint: this.$id,
302
+ logger.warn(formatCompact({
303
+ op: 'add_reaction',
304
+ endpoint: this.name,
856
305
  message_id: messageId,
857
306
  emoji,
858
307
  id: emojiId,
@@ -861,22 +310,19 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
861
310
  }));
862
311
  return null;
863
312
  }
864
-
865
- this.logger.debug(formatCompact({
866
- op: "add_reaction",
867
- endpoint: this.$id,
313
+ logger.debug(formatCompact({
314
+ op: 'add_reaction',
315
+ endpoint: this.name,
868
316
  message_id: messageId,
869
317
  emoji,
870
318
  id: emojiId,
871
319
  ok: true,
872
320
  }));
873
-
874
- // 返回 reaction id,供 $removeReaction 直接使用
875
321
  return emojiId;
876
322
  } catch (error) {
877
- this.logger.warn(formatCompact({
878
- op: "add_reaction",
879
- endpoint: this.$id,
323
+ logger.warn(formatCompact({
324
+ op: 'add_reaction',
325
+ endpoint: this.name,
880
326
  message_id: messageId,
881
327
  emoji,
882
328
  ok: false,
@@ -886,61 +332,214 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
886
332
  }
887
333
  }
888
334
 
889
- /**
890
- * 移除消息回应
891
- *
892
- * @param messageId - 消息 ID
893
- * @param reactionId - 反应 ID(由 $addReaction 返回)
894
- */
895
- async $removeReaction(messageId: string, reactionId: string): Promise<void> {
335
+ async removeReaction(messageId: string, reactionId: string): Promise<void> {
896
336
  try {
897
- // 兼容两种格式:
898
- // 1) 旧格式 reaction_{id}_{timestamp}
899
- // 2) 新格式直接为 id
900
337
  const parts = reactionId.split('_');
901
- const emojiId = parts.length >= 2 ? parts[1] : reactionId;
902
-
903
- const resp = await this.ipc.request(Actions.GROUP_DEL_REACTION, {
338
+ const emojiId = parts.length >= 2 ? parts[1]! : reactionId;
339
+ const resp = await this.request(Actions.GROUP_DEL_REACTION, {
904
340
  message_id: messageId,
905
341
  id: emojiId,
906
342
  });
907
-
908
343
  if (!resp.ok) {
909
- this.logger.warn(formatCompact({
910
- op: "remove_reaction",
911
- endpoint: this.$id,
344
+ logger.warn(formatCompact({
345
+ op: 'remove_reaction',
346
+ endpoint: this.name,
912
347
  message_id: messageId,
913
348
  reaction_id: reactionId,
914
349
  id: emojiId,
915
350
  ok: false,
916
351
  error: resp.error,
917
352
  }));
918
- } else {
919
- this.logger.debug(formatCompact({
920
- op: "remove_reaction",
921
- endpoint: this.$id,
353
+ return;
354
+ }
355
+ logger.debug(formatCompact({
356
+ op: 'remove_reaction',
357
+ endpoint: this.name,
358
+ message_id: messageId,
359
+ reaction_id: reactionId,
360
+ id: emojiId,
361
+ ok: true,
362
+ }));
363
+ } catch (error) {
364
+ logger.warn(formatCompact({
365
+ op: 'remove_reaction',
366
+ endpoint: this.name,
367
+ message_id: messageId,
368
+ reaction_id: reactionId,
369
+ ok: false,
370
+ error: error instanceof Error ? error.message : String(error),
371
+ }));
372
+ }
373
+ }
374
+
375
+ /** Activity-feedback autoRemove / Console recall. */
376
+ async recallMessage(messageId: string): Promise<void> {
377
+ if (!messageId || messageId.startsWith('outbound:')) return;
378
+ try {
379
+ const resp = await this.request(Actions.RECALL_MSG, { message_id: messageId });
380
+ if (!resp.ok) {
381
+ logger.warn(formatCompact({
382
+ op: 'recall_message',
383
+ endpoint: this.name,
922
384
  message_id: messageId,
923
- reaction_id: reactionId,
924
- id: emojiId,
925
- ok: true,
385
+ ok: false,
386
+ error: resp.error,
926
387
  }));
388
+ return;
927
389
  }
390
+ logger.debug(formatCompact({
391
+ op: 'recall_message',
392
+ endpoint: this.name,
393
+ message_id: messageId,
394
+ ok: true,
395
+ }));
928
396
  } catch (error) {
929
- this.logger.warn(formatCompact({
930
- op: "remove_reaction",
931
- endpoint: this.$id,
397
+ logger.warn(formatCompact({
398
+ op: 'recall_message',
399
+ endpoint: this.name,
932
400
  message_id: messageId,
933
- reaction_id: reactionId,
934
401
  ok: false,
935
402
  error: error instanceof Error ? error.message : String(error),
936
403
  }));
937
404
  }
938
405
  }
406
+
407
+ /** Test / internal: admit when open. */
408
+ admit(msg: IcqqInboundMessage): void {
409
+ if (!this.#open) return;
410
+ // 新 Runtime Message.content 为纯文本:@ 本机(uin = name)只能经 metadata 传递
411
+ const mentioned = isIcqqBotMentioned({ uin: this.name, rawMessage: msg.content });
412
+ void this.#options.gateway.receive({
413
+ adapter: this.#options.id,
414
+ target: msg.target,
415
+ content: msg.content,
416
+ sender: msg.sender,
417
+ id: msg.id,
418
+ metadata: Object.freeze({
419
+ endpoint: this.name,
420
+ channelType: msg.channelType,
421
+ ...msg.metadata,
422
+ ...(mentioned ? { mentioned: true } : {}),
423
+ }),
424
+ }).catch((err) => {
425
+ logger.warn(formatCompact({
426
+ op: 'icqq_gateway_receive_failed',
427
+ target: msg.target,
428
+ error: err instanceof Error ? err.message : String(err),
429
+ }));
430
+ });
431
+ }
432
+
433
+ async #refreshLists(): Promise<void> {
434
+ const [flResp, glResp] = await Promise.all([
435
+ this.ipc.request(Actions.LIST_FRIENDS),
436
+ this.ipc.request(Actions.LIST_GROUPS),
437
+ ]);
438
+ this.friends.clear();
439
+ if (flResp.ok && Array.isArray(flResp.data)) {
440
+ for (const f of flResp.data as IpcFriendInfo[]) {
441
+ this.friends.set(f.user_id, f);
442
+ }
443
+ }
444
+ this.groups.clear();
445
+ if (glResp.ok && Array.isArray(glResp.data)) {
446
+ for (const g of glResp.data as IpcGroupInfo[]) {
447
+ this.groups.set(g.group_id, g);
448
+ }
449
+ }
450
+ }
451
+
452
+ #handleEvent(event: IpcEvent): void {
453
+ if (isIcqqGuildIpcEvent(event.event)) {
454
+ this.#handleGuildEvent(event);
455
+ return;
456
+ }
457
+ const payload = unwrapIcqqIpcEventPayload(event);
458
+ if (!payload || typeof payload !== 'object') return;
459
+ if (!isIcqqMessagePostType(payload)) return;
460
+ const data = payload as IcqqIpcMessageEvent;
461
+ if (shouldSkipSelfInboundMessage(data)) return;
462
+ const normalized = normalizeIcqqInboundMessage(data);
463
+ if (!normalized) return;
464
+ if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) return;
465
+ this.admit({
466
+ id: normalized.messageId,
467
+ target: formatInboundTarget({
468
+ channelType: normalized.channelType,
469
+ channelId: normalized.channelId,
470
+ channelParentGroupId: normalized.channelParentGroupId,
471
+ }),
472
+ content: formatInboundContent(normalized.rawMessage),
473
+ sender: normalized.userId,
474
+ channelType: normalized.channelType,
475
+ metadata: buildIcqqQuoteMetadata(data, {
476
+ nickname: normalized.nickname,
477
+ senderRole: normalized.senderRole,
478
+ }),
479
+ });
480
+ }
481
+
482
+ #handleGuildEvent(event: IpcEvent): void {
483
+ const payload = event.data;
484
+ if (!payload || typeof payload !== 'object') return;
485
+ const normalized = normalizeIcqqGuildInboundMessage(
486
+ payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
487
+ );
488
+ if (!normalized) return;
489
+ if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) return;
490
+ this.admit({
491
+ id: normalized.messageId,
492
+ target: formatInboundTarget({
493
+ channelType: 'channel',
494
+ channelId: normalized.channelId,
495
+ guildId: normalized.guildId,
496
+ }),
497
+ content: formatInboundContent(normalized.rawMessage),
498
+ sender: normalized.userId,
499
+ channelType: 'channel',
500
+ metadata: {
501
+ guildId: normalized.guildId,
502
+ nickname: normalized.nickname,
503
+ },
504
+ });
505
+ }
939
506
  }
940
507
 
941
- /** @deprecated 使用 `./cq-message.js` 导出 */
942
- export namespace IcqqEndpoint {
943
- export const parseCqMessage = parseCqMessageImpl;
944
- export const buildIcqqIpcMessage = buildIcqqIpcMessageImpl;
945
- export const toCqString = toCqStringImpl;
508
+ /**
509
+ * 组装入站 metadata:在基础字段上补充 quote 链路信息。
510
+ * - quote_id:被引用消息 id(resolveIcqqQuoteIdFromEvent,无则不写)
511
+ * - quote_sender_id / quote_sender_name / quote_content:source 可用时的平铺摘要
512
+ */
513
+ function buildIcqqQuoteMetadata(
514
+ data: IcqqIpcMessageEvent,
515
+ base: Record<string, unknown>,
516
+ ): Record<string, unknown> {
517
+ const metadata: Record<string, unknown> = { ...base };
518
+ const quoteId = resolveIcqqQuoteIdFromEvent(data);
519
+ if (quoteId) metadata.quote_id = quoteId;
520
+ const quoted = quotedPayloadFromIcqqSource(findIcqqNestedMessageSource(data));
521
+ if (quoted?.sender?.id) metadata.quote_sender_id = quoted.sender.id;
522
+ if (quoted?.sender?.name) metadata.quote_sender_name = quoted.sender.name;
523
+ const quoteText = quoted
524
+ ? (typeof quoted.content === 'string'
525
+ ? quoted.content
526
+ : quoted.content
527
+ .map((seg) =>
528
+ seg && typeof seg === 'object' && seg.type === 'text'
529
+ ? String((seg.data as { text?: unknown } | undefined)?.text ?? '')
530
+ : '',
531
+ )
532
+ .join('')
533
+ ).trim() || quoted.raw || ''
534
+ : '';
535
+ if (quoteText) metadata.quote_content = quoteText;
536
+ return metadata;
537
+ }
538
+
539
+ async function defaultCreateIpc(config: ResolvedIcqqConfig): Promise<IcqqIpcTransport> {
540
+ const uin = Number(config.name);
541
+ if (config.rpc) {
542
+ return IpcClient.connectRpc(config.rpc);
543
+ }
544
+ return IpcClient.connect(uin);
946
545
  }