@zhin.js/adapter-icqq 3.0.4 → 3.0.6

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 (67) hide show
  1. package/CHANGELOG.md +106 -78
  2. package/README.md +3 -3
  3. package/client/index.tsx +1 -1
  4. package/client/tsconfig.json +1 -1
  5. package/dist/index.js +1 -1
  6. package/lib/bot.d.ts +56 -12
  7. package/lib/bot.d.ts.map +1 -1
  8. package/lib/bot.js +416 -136
  9. package/lib/bot.js.map +1 -1
  10. package/lib/cq-message.d.ts +10 -0
  11. package/lib/cq-message.d.ts.map +1 -0
  12. package/lib/cq-message.js +119 -0
  13. package/lib/cq-message.js.map +1 -0
  14. package/lib/forward-msg.d.ts +27 -0
  15. package/lib/forward-msg.d.ts.map +1 -0
  16. package/lib/forward-msg.js +387 -0
  17. package/lib/forward-msg.js.map +1 -0
  18. package/lib/get-msg.d.ts +3 -0
  19. package/lib/get-msg.d.ts.map +1 -0
  20. package/lib/get-msg.js +46 -0
  21. package/lib/get-msg.js.map +1 -0
  22. package/lib/icqq-inbound.d.ts +114 -0
  23. package/lib/icqq-inbound.d.ts.map +1 -0
  24. package/lib/icqq-inbound.js +495 -0
  25. package/lib/icqq-inbound.js.map +1 -0
  26. package/lib/icqq-side-events.d.ts +34 -0
  27. package/lib/icqq-side-events.d.ts.map +1 -0
  28. package/lib/icqq-side-events.js +194 -0
  29. package/lib/icqq-side-events.js.map +1 -0
  30. package/lib/index.d.ts +4 -2
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +1 -0
  33. package/lib/index.js.map +1 -1
  34. package/lib/ipc-client.d.ts +7 -2
  35. package/lib/ipc-client.d.ts.map +1 -1
  36. package/lib/ipc-client.js +74 -16
  37. package/lib/ipc-client.js.map +1 -1
  38. package/lib/protocol.d.ts +3 -10
  39. package/lib/protocol.d.ts.map +1 -1
  40. package/lib/protocol.js +2 -0
  41. package/lib/protocol.js.map +1 -1
  42. package/lib/routes.d.ts +1 -1
  43. package/lib/routes.d.ts.map +1 -1
  44. package/lib/types.d.ts +44 -0
  45. package/lib/types.d.ts.map +1 -1
  46. package/lib/typing-indicator-example.d.ts +108 -0
  47. package/lib/typing-indicator-example.d.ts.map +1 -0
  48. package/lib/typing-indicator-example.js +220 -0
  49. package/lib/typing-indicator-example.js.map +1 -0
  50. package/lib/typing-indicator.d.ts +87 -0
  51. package/lib/typing-indicator.d.ts.map +1 -0
  52. package/lib/typing-indicator.js +225 -0
  53. package/lib/typing-indicator.js.map +1 -0
  54. package/package.json +18 -12
  55. package/src/bot.ts +524 -149
  56. package/src/cq-message.ts +120 -0
  57. package/src/forward-msg.ts +433 -0
  58. package/src/get-msg.ts +56 -0
  59. package/src/icqq-inbound.ts +616 -0
  60. package/src/icqq-side-events.ts +228 -0
  61. package/src/index.ts +10 -2
  62. package/src/ipc-client.ts +76 -16
  63. package/src/protocol.ts +4 -10
  64. package/src/routes.ts +1 -1
  65. package/src/types.ts +45 -0
  66. package/src/typing-indicator-example.ts +269 -0
  67. package/src/typing-indicator.ts +312 -0
package/src/bot.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 不再直接依赖 @icqqjs/icqq 协议库。
5
5
  * 登录由 `icqq login` 完成,本 Bot 只负责连接守护进程并收发消息。
6
6
  */
7
- import { formatCompact, Bot, Message, MessageSegment, segment, SendContent, SendOptions } from 'zhin.js';
7
+ import { formatCompact, Bot, Message, segment, SendContent, SendOptions, type QuotedMessagePayload } from 'zhin.js';
8
8
  import type {
9
9
  IcqqBotConfig,
10
10
  IcqqSenderInfo,
@@ -14,13 +14,42 @@ import type {
14
14
  } from "./types.js";
15
15
  import type { IcqqAdapter } from "./adapter.js";
16
16
  import { IpcClient } from "./ipc-client.js";
17
+ import { Actions, type IpcEvent } from "./protocol.js";
18
+ import type { IcqqIpcMessageEvent } from "./icqq-inbound.js";
17
19
  import {
18
- Actions,
19
- type IpcMessageEventData,
20
- type IpcEvent,
21
- } from "./protocol.js";
22
-
23
- export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
20
+ findIcqqNestedMessageSource,
21
+ InboundMessageDeduper,
22
+ isIcqqMessagePostType,
23
+ normalizeIcqqInboundMessage,
24
+ quotedPayloadFromIcqqSource,
25
+ resolveIcqqQuoteIdFromEvent,
26
+ resolveQuoteIdFromIcqqSource,
27
+ shouldSkipSelfInboundMessage,
28
+ unwrapIcqqIpcEventPayload,
29
+ type NormalizedIcqqInbound,
30
+ } from "./icqq-inbound.js";
31
+ import {
32
+ buildIcqqIpcMessage as buildIcqqIpcMessageImpl,
33
+ parseCqMessage as parseCqMessageImpl,
34
+ toCqString as toCqStringImpl,
35
+ } from "./cq-message.js";
36
+ import {
37
+ formatIcqqMetaLog,
38
+ formatIcqqNotice,
39
+ formatIcqqRequest,
40
+ isIcqqMetaPayload,
41
+ isIcqqNoticePayload,
42
+ isIcqqRequestPayload,
43
+ resolveIcqqEventPostType,
44
+ resolveSideEventDedupeKey,
45
+ shouldRefreshListsOnMeta,
46
+ type IcqqIpcRawEvent,
47
+ } from "./icqq-side-events.js";
48
+ import { enableTypingIndicator, type ICQQTypingIndicatorManager } from "./typing-indicator.js";
49
+ import { parseIcqqGetMsgResponse } from "./get-msg.js";
50
+ import { enrichQuotedPayloadWithForward, isForwardPlaceholderPayload } from "./forward-msg.js";
51
+
52
+ export class IcqqBot implements Bot<IcqqBotConfig, IcqqIpcMessageEvent> {
24
53
  $connected = false;
25
54
  $config: IcqqBotConfig;
26
55
  ipc!: IpcClient;
@@ -31,11 +60,18 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
31
60
  groups = new Map<number, IpcGroupInfo>();
32
61
 
33
62
  private subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
63
+ /** 事件去重:覆盖多端回流/服务端重复推送等场景 */
64
+ private readonly inboundDeduper = new InboundMessageDeduper();
65
+ /** MessageEvent.source 解析结果,供 $getMsg 优先命中 */
66
+ private readonly quotedSourceCache = new Map<string, QuotedMessagePayload>();
34
67
  /** 用户主动断开时为 true,阻止自动重连 */
35
68
  private intentionalDisconnect = false;
36
69
  /** 是否已有重连循环在跑(避免多次 schedule 叠套) */
37
70
  private reconnectRunning = false;
38
71
 
72
+ /** Typing Indicator 管理器 */
73
+ $typingIndicator?: ICQQTypingIndicatorManager;
74
+
39
75
  get $id() {
40
76
  return this.$config.name;
41
77
  }
@@ -66,13 +102,50 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
66
102
 
67
103
  await this.rebindIpcSession();
68
104
 
105
+ // 根据配置自动启用 Typing Indicator
106
+ this.initTypingIndicator();
107
+
69
108
  this.logger.info(formatCompact( {
70
- bot: this.$id,
71
- friends: this.friends.size,
72
- groups: this.groups.size,
109
+ 机器人: this.$id,
110
+ 好友数: this.friends.size,
111
+ 群组数: this.groups.size,
112
+ 思考提示: this.$typingIndicator ? '已启用' : '未启用',
73
113
  }));
74
114
  }
75
115
 
116
+ /**
117
+ * 初始化 Typing Indicator
118
+ * 根据配置自动启用或禁用
119
+ */
120
+ private initTypingIndicator(): void {
121
+ const typingConfig = this.$config.typingIndicator;
122
+
123
+ // 如果配置中明确设置为 false,则不启用
124
+ if (typingConfig?.enabled === false) {
125
+ this.logger.debug(formatCompact({
126
+ bot: this.$id,
127
+ typingIndicator: 'disabled_by_config',
128
+ }));
129
+ return;
130
+ }
131
+
132
+ // 启用 Typing Indicator
133
+ try {
134
+ this.$typingIndicator = enableTypingIndicator(this, typingConfig);
135
+ this.logger.debug(formatCompact({
136
+ bot: this.$id,
137
+ typingIndicator: 'enabled',
138
+ emoji: typingConfig?.defaultEmoji || '⏳',
139
+ }));
140
+ } catch (error) {
141
+ this.logger.warn(formatCompact({
142
+ bot: this.$id,
143
+ typingIndicator: 'failed',
144
+ error: error instanceof Error ? error.message : String(error),
145
+ }));
146
+ }
147
+ }
148
+
76
149
  /** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
77
150
  private async rebindIpcSession(): Promise<void> {
78
151
  const uin = Number(this.$config.name);
@@ -102,23 +175,13 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
102
175
 
103
176
  await this.refreshLists();
104
177
 
105
- for (const [uid] of this.friends) {
106
- const sub = this.ipc.subscribe(
107
- Actions.SUBSCRIBE,
108
- { type: "private", id: uid },
109
- (event) => this.handleEvent(event),
110
- );
111
- this.subscriptions.push(sub);
112
- }
113
-
114
- for (const [gid] of this.groups) {
115
- const sub = this.ipc.subscribe(
116
- Actions.SUBSCRIBE,
117
- { type: "group", id: gid },
118
- (event) => this.handleEvent(event),
119
- );
120
- this.subscriptions.push(sub);
121
- }
178
+ // 新版 icqq cli 在认证后自动广播事件,订阅过滤改为客户端侧完成。
179
+ const sub = this.ipc.subscribe(
180
+ Actions.SUBSCRIBE,
181
+ {},
182
+ (event) => this.handleEvent(event),
183
+ );
184
+ this.subscriptions.push(sub);
122
185
 
123
186
  this.$connected = true;
124
187
  }
@@ -200,11 +263,19 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
200
263
 
201
264
  async $disconnect(): Promise<void> {
202
265
  this.intentionalDisconnect = true;
266
+
267
+ // 停止所有 Typing Indicator
268
+ if (this.$typingIndicator) {
269
+ await this.$typingIndicator.stopAll().catch(() => {});
270
+ this.$typingIndicator = undefined;
271
+ }
272
+
203
273
  this.ipc?.setOnRemoteDisconnect(null);
204
274
  for (const sub of this.subscriptions) {
205
275
  await sub.unsubscribe().catch(() => {});
206
276
  }
207
277
  this.subscriptions = [];
278
+ this.inboundDeduper.clear();
208
279
  this.ipc?.close();
209
280
  this.$connected = false;
210
281
  this.logger.info(formatCompact( { op: "disconnect", bot: this.$id }));
@@ -213,47 +284,227 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
213
284
  // ── 消息处理 ───────────────────────────────────────────────────────
214
285
 
215
286
  private handleEvent(event: IpcEvent): void {
216
- const data = event.data as IpcMessageEventData;
217
- if (!data || !data.raw_message) return;
287
+ const payload = unwrapIcqqIpcEventPayload(event);
288
+ if (!payload || typeof payload !== "object") {
289
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
290
+ this.logger.info(
291
+ formatCompact({
292
+ ipc_skip: "no_payload",
293
+ ipc_event: event.event,
294
+ }),
295
+ );
296
+ }
297
+ return;
298
+ }
299
+
300
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
301
+ this.logger.info(
302
+ formatCompact({
303
+ ipc_raw: JSON.stringify(payload).slice(0, 800),
304
+ }),
305
+ );
306
+ }
307
+
308
+ if (isIcqqNoticePayload(payload)) {
309
+ this.handleNoticeEvent(payload);
310
+ return;
311
+ }
312
+ if (isIcqqRequestPayload(payload)) {
313
+ this.handleRequestEvent(payload);
314
+ return;
315
+ }
316
+ if (isIcqqMetaPayload(payload)) {
317
+ this.handleMetaEvent(payload);
318
+ return;
319
+ }
320
+
321
+ if (!isIcqqMessagePostType(payload)) {
322
+ const postType = resolveIcqqEventPostType(
323
+ payload as Record<string, unknown>,
324
+ );
325
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
326
+ this.logger.info(
327
+ formatCompact({ ipc_skip: postType ?? "unknown_event" }),
328
+ );
329
+ }
330
+ return;
331
+ }
332
+
333
+ const data = payload;
334
+
335
+ if (shouldSkipSelfInboundMessage(data)) {
336
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
337
+ this.logger.info(formatCompact({ ipc_skip: "self_message" }));
338
+ }
339
+ return;
340
+ }
341
+
342
+ const normalized = normalizeIcqqInboundMessage(data);
343
+ if (!normalized) {
344
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
345
+ this.logger.info(formatCompact({ ipc_skip: "normalize_failed" }));
346
+ }
347
+ return;
348
+ }
349
+
350
+ if (!this.inboundDeduper.shouldProcess(normalized.messageId)) {
351
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
352
+ this.logger.info(
353
+ formatCompact({ ipc_dedupe: normalized.messageId }),
354
+ );
355
+ }
356
+ return;
357
+ }
358
+
359
+ void this.dispatchInboundMessage(data, normalized);
360
+ }
361
+
362
+ private async dispatchInboundMessage(
363
+ data: IcqqIpcMessageEvent,
364
+ normalized: NormalizedIcqqInbound,
365
+ ): Promise<void> {
366
+ this.logIpcInboundPayload(data, normalized);
218
367
 
219
- const message = this.$formatMessage(data);
368
+ await this.primeQuotedSourceCache(
369
+ data.source ?? findIcqqNestedMessageSource(data),
370
+ );
371
+
372
+ const message = this.$formatMessage(normalized);
220
373
  this.adapter.emit("message.receive", message);
221
374
  this.logger.debug(
222
- `${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
375
+ `${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}${message.$quote_id ? ` quote_id=${message.$quote_id}` : ""}`,
376
+ );
377
+ }
378
+
379
+ private handleNoticeEvent(event: IcqqIpcRawEvent): void {
380
+ const dedupeKey = resolveSideEventDedupeKey(event, "notice");
381
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
382
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
383
+ this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
384
+ }
385
+ return;
386
+ }
387
+ const notice = formatIcqqNotice(event, this.$config.name);
388
+ this.adapter.emit("notice.receive", notice);
389
+ this.logger.info(
390
+ formatCompact({
391
+ notice: notice.$type,
392
+ channel: `${notice.$channel.type}(${notice.$channel.id})`,
393
+ bot: this.$id,
394
+ sub_type: notice.$subType,
395
+ }),
396
+ );
397
+ }
398
+
399
+ private handleRequestEvent(event: IcqqIpcRawEvent): void {
400
+ const dedupeKey = resolveSideEventDedupeKey(event, "request");
401
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
402
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
403
+ this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
404
+ }
405
+ return;
406
+ }
407
+ const request = formatIcqqRequest(event, this.$config.name, this.ipc);
408
+ this.adapter.emit("request.receive", request);
409
+ this.logger.info(
410
+ formatCompact({
411
+ request: request.$type,
412
+ channel: `${request.$channel.type}(${request.$channel.id})`,
413
+ bot: this.$id,
414
+ from: request.$sender.id,
415
+ }),
223
416
  );
224
417
  }
225
418
 
226
- $formatMessage(raw: IpcMessageEventData) {
227
- const channelId =
228
- raw.type === "group"
229
- ? String(raw.group_id ?? raw.from_id)
230
- : String(raw.from_id);
231
- const channelType = raw.type === "group" ? "group" : "private";
232
- // 守护进程推送无 message_id,合成一个
233
- const syntheticId = `${raw.time}_${raw.user_id}_${channelId}`;
419
+ private handleMetaEvent(event: IcqqIpcRawEvent): void {
420
+ const dedupeKey = resolveSideEventDedupeKey(event, "meta");
421
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
422
+
423
+ this.logger.debug(formatIcqqMetaLog(event));
424
+ if (shouldRefreshListsOnMeta(event)) {
425
+ void this.refreshLists().catch((e: unknown) => {
426
+ this.logger.warn(
427
+ formatCompact({
428
+ op: "refresh_lists",
429
+ ok: false,
430
+ error: e instanceof Error ? e.message : String(e),
431
+ }),
432
+ );
433
+ });
434
+ }
435
+ }
436
+
437
+ /** 调试 IPC 入站字段:默认 debug;设 ICQQ_IPC_LOG_RAW=1 则 info 打完整 payload */
438
+ private logIpcInboundPayload(
439
+ data: IcqqIpcMessageEvent,
440
+ normalized: NormalizedIcqqInbound,
441
+ ): void {
442
+ const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
443
+ const keys = Object.keys(ext);
444
+ const idHints: Record<string, unknown> = {};
445
+ for (const key of keys) {
446
+ if (/message|msg|seq|id|source/i.test(key)) {
447
+ idHints[key] = ext[key];
448
+ }
449
+ }
450
+ const preview = JSON.stringify(data);
451
+ const compact = formatCompact({
452
+ post_type: data.post_type ?? "legacy",
453
+ message_type: data.message_type ?? data.type,
454
+ ipc_message_id: normalized.messageId,
455
+ id_source: normalized.idSource,
456
+ ipc_keys: keys.join(","),
457
+ ...(Object.keys(idHints).length ? { ipc_id_hints: JSON.stringify(idHints) } : {}),
458
+ });
459
+ this.logger.debug(compact);
460
+ this.logger.debug(formatCompact({ ipc_payload: preview.slice(0, 1200) }));
461
+ }
234
462
 
463
+ $formatMessage(
464
+ input: NormalizedIcqqInbound | IcqqIpcMessageEvent,
465
+ ): ReturnType<typeof Message.from<IcqqIpcMessageEvent>> {
466
+ const normalized =
467
+ "messageId" in input
468
+ ? input
469
+ : normalizeIcqqInboundMessage(input);
470
+ if (!normalized) {
471
+ throw new Error("无法解析 icqq 入站消息");
472
+ }
473
+ const raw = normalized.raw;
235
474
  const senderInfo: IcqqSenderInfo = {
236
- id: String(raw.user_id),
237
- name: raw.nickname,
475
+ id: normalized.userId,
476
+ name: normalized.nickname,
238
477
  };
239
478
 
479
+ const quoteId =
480
+ Message.quoteIdFromContent(normalized.content) ??
481
+ resolveIcqqQuoteIdFromEvent(normalized.raw);
482
+ Message.alignReplySegments(normalized.content, quoteId);
483
+
240
484
  const result = Message.from(raw, {
241
- $id: syntheticId,
485
+ $id: normalized.messageId,
242
486
  $adapter: "icqq" as const,
243
487
  $bot: this.$config.name,
244
488
  $sender: senderInfo,
245
- $channel: { id: channelId, type: channelType },
246
- $content: IcqqBot.parseCqMessage(raw.raw_message),
247
- $raw: raw.raw_message,
248
- $timestamp: raw.time * 1000,
489
+ $channel: {
490
+ id: normalized.channelId,
491
+ type: normalized.channelType,
492
+ },
493
+ $content: normalized.content,
494
+ $quote_id: quoteId,
495
+ $raw: normalized.rawMessage,
496
+ $timestamp: normalized.timestampMs,
249
497
  $recall: async () => {
250
- // 合成 id 无法撤回
251
- this.logger.warn(formatCompact( {
252
- op: "recall",
253
- bot: this.$id,
254
- ok: false,
255
- error: "no message_id in push",
256
- }));
498
+ if (normalized.idSource === "synthetic") {
499
+ this.logger.warn(formatCompact( {
500
+ op: "recall",
501
+ bot: this.$id,
502
+ ok: false,
503
+ error: "no message_id in push",
504
+ }));
505
+ return;
506
+ }
507
+ await this.$recallMessage(result.$id);
257
508
  },
258
509
  $reply: async (
259
510
  content: SendContent,
@@ -277,6 +528,85 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
277
528
  return result;
278
529
  }
279
530
 
531
+ /**
532
+ * 有 source.message_id 时用 get_msg 拉全量正文;否则仅用 source 内联摘要。
533
+ */
534
+ private async primeQuotedSourceCache(source: unknown): Promise<void> {
535
+ if (!source) return;
536
+ const quoteId = resolveQuoteIdFromIcqqSource(source);
537
+ const s = source as { message_id?: unknown };
538
+ const hasCanonicalId =
539
+ quoteId &&
540
+ s.message_id != null &&
541
+ String(s.message_id).trim() === quoteId;
542
+
543
+ if (hasCanonicalId) {
544
+ try {
545
+ await this.fetchQuotedMessagePayload(quoteId);
546
+ return;
547
+ } catch (e: unknown) {
548
+ this.logger.debug(
549
+ formatCompact({
550
+ op: "quote_get_msg",
551
+ message_id: quoteId,
552
+ ok: false,
553
+ error: e instanceof Error ? e.message : String(e),
554
+ }),
555
+ );
556
+ }
557
+ }
558
+
559
+ const sourcePayload = quotedPayloadFromIcqqSource(source);
560
+ if (!sourcePayload) return;
561
+ const enriched = await enrichQuotedPayloadWithForward(
562
+ this.ipc,
563
+ sourcePayload,
564
+ );
565
+ this.quotedSourceCache.set(enriched.messageId, enriched);
566
+ }
567
+
568
+ private async fetchQuotedMessagePayload(
569
+ messageId: string,
570
+ ): Promise<QuotedMessagePayload> {
571
+ const resp = await this.ipc.request(Actions.GET_MSG, {
572
+ message_id: messageId,
573
+ });
574
+ if (!resp.ok) {
575
+ throw new Error(resp.error ?? "get_msg failed");
576
+ }
577
+ const payload = parseIcqqGetMsgResponse(messageId, resp.data);
578
+ const enriched = await enrichQuotedPayloadWithForward(
579
+ this.ipc,
580
+ payload,
581
+ resp.data,
582
+ );
583
+ if (
584
+ isForwardPlaceholderPayload(enriched) &&
585
+ !String(
586
+ Array.isArray(enriched.content)
587
+ ? segment.raw(enriched.content)
588
+ : enriched.content ?? "",
589
+ ).includes("[Merged chat history")
590
+ ) {
591
+ this.logger.debug(
592
+ formatCompact({
593
+ op: "forward_unresolved",
594
+ message_id: messageId,
595
+ }),
596
+ );
597
+ }
598
+ this.quotedSourceCache.set(messageId, enriched);
599
+ return enriched;
600
+ }
601
+
602
+ async $getMsg(messageId: string): Promise<QuotedMessagePayload> {
603
+ const cached = this.quotedSourceCache.get(messageId);
604
+ if (cached) {
605
+ return enrichQuotedPayloadWithForward(this.ipc, cached);
606
+ }
607
+ return this.fetchQuotedMessagePayload(messageId);
608
+ }
609
+
280
610
  // ── 撤回 ───────────────────────────────────────────────────────────
281
611
 
282
612
  async $recallMessage(id: string): Promise<void> {
@@ -296,7 +626,7 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
296
626
  // ── 发送消息 ───────────────────────────────────────────────────────
297
627
 
298
628
  async $sendMessage(options: SendOptions): Promise<string> {
299
- const content = IcqqBot.toCqString(options.content);
629
+ const message = buildIcqqIpcMessageImpl(options.content);
300
630
 
301
631
  let action: string;
302
632
  let params: Record<string, unknown>;
@@ -304,11 +634,11 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
304
634
  switch (options.type) {
305
635
  case "private":
306
636
  action = Actions.SEND_PRIVATE_MSG;
307
- params = { user_id: Number(options.id), message: content };
637
+ params = { user_id: Number(options.id), message };
308
638
  break;
309
639
  case "group":
310
640
  action = Actions.SEND_GROUP_MSG;
311
- params = { group_id: Number(options.id), message: content };
641
+ params = { group_id: Number(options.id), message };
312
642
  break;
313
643
  default:
314
644
  throw new Error(`不支持的频道类型: ${options.type}`);
@@ -316,6 +646,10 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
316
646
 
317
647
  const resp = await this.ipc.request(action, params);
318
648
  if (!resp.ok) {
649
+ this.logger.debug(formatCompact({
650
+ op: action,
651
+ ...params,
652
+ }));
319
653
  throw new Error(`发送消息失败: ${resp.error}`);
320
654
  }
321
655
 
@@ -327,112 +661,153 @@ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
327
661
  );
328
662
  return messageId;
329
663
  }
330
- }
331
664
 
332
- // ── CQ 码解析工具 ──────────────────────────────────────────────────
665
+ // ── 消息回应 ───────────────────────────────────────────────────────
333
666
 
334
- export namespace IcqqBot {
335
667
  /**
336
- * CQ 码原始消息字符串解析为 MessageSegment 数组。
337
- * 格式: `[type:value]` 或纯文本
668
+ * 表情符号到 reaction id 的映射
669
+ * QQ 使用数字 ID 来标识表情,而不是 Unicode 字符
338
670
  */
339
- export function parseCqMessage(raw: string): MessageSegment[] {
340
- const segments: MessageSegment[] = [];
341
- // 匹配 [type:arg] 或 [type:arg1,arg2=val] 等 CQ 码
342
- const cqRegex = /\[([a-z_]+)(?::([^\]]*))?\]/g;
343
- let lastIndex = 0;
344
-
345
- for (const match of raw.matchAll(cqRegex)) {
346
- // 前面的纯文本
347
- if (match.index! > lastIndex) {
348
- const text = raw.slice(lastIndex, match.index!);
349
- if (text) segments.push({ type: "text", data: { text } });
350
- }
671
+ private static readonly EMOJI_MAP: Record<string, string> = {
672
+ '⏳': '1468368274', // 沙漏
673
+ '👍': '128077', // 竖起大拇指
674
+ '❤️': '10084', // 红心
675
+ '😊': '128522', // 微笑
676
+ '🎉': '127881', // 派对
677
+ '🔥': '128293', //
678
+ '✅': '9989', // 勾选
679
+ '❌': '10060', // 叉号
680
+ '⭐': '11088', // 星星
681
+ '💯': '128175', // 一百分
682
+ };
351
683
 
352
- const type = match[1];
353
- const arg = match[2] ?? "";
684
+ /**
685
+ * 将表情符号转换为 reaction id
686
+ */
687
+ private getEmojiId(emoji: string): string {
688
+ // 如果已经是数字 ID,直接返回
689
+ if (/^\d+$/.test(emoji)) {
690
+ return emoji;
691
+ }
354
692
 
355
- switch (type) {
356
- case "face":
357
- segments.push({ type: "face", data: { id: Number(arg) } });
358
- break;
359
- case "image":
360
- segments.push({ type: "image", data: { url: arg, file: arg } });
361
- break;
362
- case "at":
363
- if (arg === "all") {
364
- segments.push({ type: "at", data: { qq: "all" } });
365
- } else {
366
- segments.push({ type: "at", data: { qq: arg } });
367
- }
368
- break;
369
- case "dice":
370
- segments.push({ type: "dice", data: {} });
371
- break;
372
- case "rps":
373
- segments.push({ type: "rps", data: {} });
374
- break;
375
- case "record":
376
- case "audio":
377
- segments.push({ type: "record", data: { file: arg } });
378
- break;
379
- case "video":
380
- segments.push({ type: "video", data: { file: arg } });
381
- break;
382
- case "reply":
383
- segments.push({ type: "reply", data: { id: arg } });
384
- break;
385
- default:
386
- segments.push({ type, data: { text: `[${type}:${arg}]` } });
387
- break;
693
+ // 从映射中查找
694
+ const id = IcqqBot.EMOJI_MAP[emoji];
695
+ if (id) {
696
+ return id;
697
+ }
698
+
699
+ // 默认返回 Unicode 码点
700
+ return String(emoji.codePointAt(0) || 0);
701
+ }
702
+
703
+ /**
704
+ * 添加消息回应(表情)
705
+ *
706
+ * @param messageId - 消息 ID
707
+ * @param emoji - 表情符号或 reaction id
708
+ * @returns 反应 ID,可用于后续移除
709
+ */
710
+ async $addReaction(messageId: string, emoji: string): Promise<string | null> {
711
+ try {
712
+ const emojiId = this.getEmojiId(emoji);
713
+
714
+ const resp = await this.ipc.request(Actions.GROUP_SET_REACTION, {
715
+ message_id: messageId,
716
+ id: emojiId,
717
+ });
718
+
719
+ if (!resp.ok) {
720
+ this.logger.warn(formatCompact({
721
+ op: "add_reaction",
722
+ bot: this.$id,
723
+ message_id: messageId,
724
+ emoji,
725
+ id: emojiId,
726
+ ok: false,
727
+ error: resp.error,
728
+ }));
729
+ return null;
388
730
  }
389
731
 
390
- lastIndex = match.index! + match[0].length;
391
- }
732
+ this.logger.debug(formatCompact({
733
+ op: "add_reaction",
734
+ bot: this.$id,
735
+ message_id: messageId,
736
+ emoji,
737
+ id: emojiId,
738
+ ok: true,
739
+ }));
392
740
 
393
- // 尾部文本
394
- if (lastIndex < raw.length) {
395
- const text = raw.slice(lastIndex);
396
- if (text) segments.push({ type: "text", data: { text } });
741
+ // 返回 reaction id,供 $removeReaction 直接使用
742
+ return emojiId;
743
+ } catch (error) {
744
+ this.logger.warn(formatCompact({
745
+ op: "add_reaction",
746
+ bot: this.$id,
747
+ message_id: messageId,
748
+ emoji,
749
+ ok: false,
750
+ error: error instanceof Error ? error.message : String(error),
751
+ }));
752
+ return null;
397
753
  }
398
-
399
- return segments.length ? segments : [{ type: "text", data: { text: raw } }];
400
754
  }
401
755
 
402
756
  /**
403
- * 将 SendContent(MessageSegment[] 或字符串)转为 CQ 码字符串。
404
- * 守护进程使用 CQ 码字符串格式收发消息。
757
+ * 移除消息回应
758
+ *
759
+ * @param messageId - 消息 ID
760
+ * @param reactionId - 反应 ID(由 $addReaction 返回)
405
761
  */
406
- export function toCqString(content: SendContent): string {
407
- if (!Array.isArray(content)) content = [content];
408
- return content
409
- .map((seg) => {
410
- if (typeof seg === "string") return seg;
411
- const { type, data } = seg as MessageSegment;
412
- switch (type) {
413
- case "text":
414
- return data.text ?? "";
415
- case "face":
416
- return `[face:${data.id}]`;
417
- case "image":
418
- return `[image:${data.file || data.url || data.src}]`;
419
- case "at":
420
- return `[at:${data.qq ?? data.id}]`;
421
- case "dice":
422
- return "[dice]";
423
- case "rps":
424
- return "[rps]";
425
- case "record":
426
- case "audio":
427
- return `[record:${data.file || data.url}]`;
428
- case "video":
429
- return `[video:${data.file || data.url}]`;
430
- case "reply":
431
- return `[reply:${data.id}]`;
432
- default:
433
- return segment.toString(seg);
434
- }
435
- })
436
- .join("");
762
+ async $removeReaction(messageId: string, reactionId: string): Promise<void> {
763
+ try {
764
+ // 兼容两种格式:
765
+ // 1) 旧格式 reaction_{id}_{timestamp}
766
+ // 2) 新格式直接为 id
767
+ const parts = reactionId.split('_');
768
+ const emojiId = parts.length >= 2 ? parts[1] : reactionId;
769
+
770
+ const resp = await this.ipc.request(Actions.GROUP_DEL_REACTION, {
771
+ message_id: messageId,
772
+ id: emojiId,
773
+ });
774
+
775
+ if (!resp.ok) {
776
+ this.logger.warn(formatCompact({
777
+ op: "remove_reaction",
778
+ bot: this.$id,
779
+ message_id: messageId,
780
+ reaction_id: reactionId,
781
+ id: emojiId,
782
+ ok: false,
783
+ error: resp.error,
784
+ }));
785
+ } else {
786
+ this.logger.debug(formatCompact({
787
+ op: "remove_reaction",
788
+ bot: this.$id,
789
+ message_id: messageId,
790
+ reaction_id: reactionId,
791
+ id: emojiId,
792
+ ok: true,
793
+ }));
794
+ }
795
+ } catch (error) {
796
+ this.logger.warn(formatCompact({
797
+ op: "remove_reaction",
798
+ bot: this.$id,
799
+ message_id: messageId,
800
+ reaction_id: reactionId,
801
+ ok: false,
802
+ error: error instanceof Error ? error.message : String(error),
803
+ }));
804
+ }
437
805
  }
438
806
  }
807
+
808
+ /** @deprecated 使用 `./cq-message.js` 导出 */
809
+ export namespace IcqqBot {
810
+ export const parseCqMessage = parseCqMessageImpl;
811
+ export const buildIcqqIpcMessage = buildIcqqIpcMessageImpl;
812
+ export const toCqString = toCqStringImpl;
813
+ }