@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/lib/bot.js CHANGED
@@ -6,7 +6,13 @@
6
6
  */
7
7
  import { formatCompact, Message, segment } from 'zhin.js';
8
8
  import { IpcClient } from "./ipc-client.js";
9
- import { Actions, } from "./protocol.js";
9
+ import { Actions } from "./protocol.js";
10
+ import { findIcqqNestedMessageSource, InboundMessageDeduper, isIcqqMessagePostType, normalizeIcqqInboundMessage, quotedPayloadFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveQuoteIdFromIcqqSource, shouldSkipSelfInboundMessage, unwrapIcqqIpcEventPayload, } from "./icqq-inbound.js";
11
+ import { buildIcqqIpcMessage as buildIcqqIpcMessageImpl, parseCqMessage as parseCqMessageImpl, toCqString as toCqStringImpl, } from "./cq-message.js";
12
+ import { formatIcqqMetaLog, formatIcqqNotice, formatIcqqRequest, isIcqqMetaPayload, isIcqqNoticePayload, isIcqqRequestPayload, resolveIcqqEventPostType, resolveSideEventDedupeKey, shouldRefreshListsOnMeta, } from "./icqq-side-events.js";
13
+ import { enableTypingIndicator } from "./typing-indicator.js";
14
+ import { parseIcqqGetMsgResponse } from "./get-msg.js";
15
+ import { enrichQuotedPayloadWithForward, isForwardPlaceholderPayload } from "./forward-msg.js";
10
16
  export class IcqqBot {
11
17
  adapter;
12
18
  $connected = false;
@@ -17,10 +23,16 @@ export class IcqqBot {
17
23
  /** 缓存的群列表 */
18
24
  groups = new Map();
19
25
  subscriptions = [];
26
+ /** 事件去重:覆盖多端回流/服务端重复推送等场景 */
27
+ inboundDeduper = new InboundMessageDeduper();
28
+ /** MessageEvent.source 解析结果,供 $getMsg 优先命中 */
29
+ quotedSourceCache = new Map();
20
30
  /** 用户主动断开时为 true,阻止自动重连 */
21
31
  intentionalDisconnect = false;
22
32
  /** 是否已有重连循环在跑(避免多次 schedule 叠套) */
23
33
  reconnectRunning = false;
34
+ /** Typing Indicator 管理器 */
35
+ $typingIndicator;
24
36
  get $id() {
25
37
  return this.$config.name;
26
38
  }
@@ -42,12 +54,46 @@ export class IcqqBot {
42
54
  port: rpc?.port,
43
55
  }));
44
56
  await this.rebindIpcSession();
57
+ // 根据配置自动启用 Typing Indicator
58
+ this.initTypingIndicator();
45
59
  this.logger.info(formatCompact({
46
- bot: this.$id,
47
- friends: this.friends.size,
48
- groups: this.groups.size,
60
+ 机器人: this.$id,
61
+ 好友数: this.friends.size,
62
+ 群组数: this.groups.size,
63
+ 思考提示: this.$typingIndicator ? '已启用' : '未启用',
49
64
  }));
50
65
  }
66
+ /**
67
+ * 初始化 Typing Indicator
68
+ * 根据配置自动启用或禁用
69
+ */
70
+ initTypingIndicator() {
71
+ const typingConfig = this.$config.typingIndicator;
72
+ // 如果配置中明确设置为 false,则不启用
73
+ if (typingConfig?.enabled === false) {
74
+ this.logger.debug(formatCompact({
75
+ bot: this.$id,
76
+ typingIndicator: 'disabled_by_config',
77
+ }));
78
+ return;
79
+ }
80
+ // 启用 Typing Indicator
81
+ try {
82
+ this.$typingIndicator = enableTypingIndicator(this, typingConfig);
83
+ this.logger.debug(formatCompact({
84
+ bot: this.$id,
85
+ typingIndicator: 'enabled',
86
+ emoji: typingConfig?.defaultEmoji || '⏳',
87
+ }));
88
+ }
89
+ catch (error) {
90
+ this.logger.warn(formatCompact({
91
+ bot: this.$id,
92
+ typingIndicator: 'failed',
93
+ error: error instanceof Error ? error.message : String(error),
94
+ }));
95
+ }
96
+ }
51
97
  /** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
52
98
  async rebindIpcSession() {
53
99
  const uin = Number(this.$config.name);
@@ -73,14 +119,9 @@ export class IcqqBot {
73
119
  this.ipc.setOnRemoteDisconnect(null);
74
120
  }
75
121
  await this.refreshLists();
76
- for (const [uid] of this.friends) {
77
- const sub = this.ipc.subscribe(Actions.SUBSCRIBE, { type: "private", id: uid }, (event) => this.handleEvent(event));
78
- this.subscriptions.push(sub);
79
- }
80
- for (const [gid] of this.groups) {
81
- const sub = this.ipc.subscribe(Actions.SUBSCRIBE, { type: "group", id: gid }, (event) => this.handleEvent(event));
82
- this.subscriptions.push(sub);
83
- }
122
+ // 新版 icqq cli 在认证后自动广播事件,订阅过滤改为客户端侧完成。
123
+ const sub = this.ipc.subscribe(Actions.SUBSCRIBE, {}, (event) => this.handleEvent(event));
124
+ this.subscriptions.push(sub);
84
125
  this.$connected = true;
85
126
  }
86
127
  /** IPC/RPC 意外断开时调度重连(指数退避) */
@@ -157,52 +198,196 @@ export class IcqqBot {
157
198
  // ── 断开 ───────────────────────────────────────────────────────────
158
199
  async $disconnect() {
159
200
  this.intentionalDisconnect = true;
201
+ // 停止所有 Typing Indicator
202
+ if (this.$typingIndicator) {
203
+ await this.$typingIndicator.stopAll().catch(() => { });
204
+ this.$typingIndicator = undefined;
205
+ }
160
206
  this.ipc?.setOnRemoteDisconnect(null);
161
207
  for (const sub of this.subscriptions) {
162
208
  await sub.unsubscribe().catch(() => { });
163
209
  }
164
210
  this.subscriptions = [];
211
+ this.inboundDeduper.clear();
165
212
  this.ipc?.close();
166
213
  this.$connected = false;
167
214
  this.logger.info(formatCompact({ op: "disconnect", bot: this.$id }));
168
215
  }
169
216
  // ── 消息处理 ───────────────────────────────────────────────────────
170
217
  handleEvent(event) {
171
- const data = event.data;
172
- if (!data || !data.raw_message)
218
+ const payload = unwrapIcqqIpcEventPayload(event);
219
+ if (!payload || typeof payload !== "object") {
220
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
221
+ this.logger.info(formatCompact({
222
+ ipc_skip: "no_payload",
223
+ ipc_event: event.event,
224
+ }));
225
+ }
226
+ return;
227
+ }
228
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
229
+ this.logger.info(formatCompact({
230
+ ipc_raw: JSON.stringify(payload).slice(0, 800),
231
+ }));
232
+ }
233
+ if (isIcqqNoticePayload(payload)) {
234
+ this.handleNoticeEvent(payload);
235
+ return;
236
+ }
237
+ if (isIcqqRequestPayload(payload)) {
238
+ this.handleRequestEvent(payload);
239
+ return;
240
+ }
241
+ if (isIcqqMetaPayload(payload)) {
242
+ this.handleMetaEvent(payload);
243
+ return;
244
+ }
245
+ if (!isIcqqMessagePostType(payload)) {
246
+ const postType = resolveIcqqEventPostType(payload);
247
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
248
+ this.logger.info(formatCompact({ ipc_skip: postType ?? "unknown_event" }));
249
+ }
250
+ return;
251
+ }
252
+ const data = payload;
253
+ if (shouldSkipSelfInboundMessage(data)) {
254
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
255
+ this.logger.info(formatCompact({ ipc_skip: "self_message" }));
256
+ }
257
+ return;
258
+ }
259
+ const normalized = normalizeIcqqInboundMessage(data);
260
+ if (!normalized) {
261
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
262
+ this.logger.info(formatCompact({ ipc_skip: "normalize_failed" }));
263
+ }
264
+ return;
265
+ }
266
+ if (!this.inboundDeduper.shouldProcess(normalized.messageId)) {
267
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
268
+ this.logger.info(formatCompact({ ipc_dedupe: normalized.messageId }));
269
+ }
173
270
  return;
174
- const message = this.$formatMessage(data);
271
+ }
272
+ void this.dispatchInboundMessage(data, normalized);
273
+ }
274
+ async dispatchInboundMessage(data, normalized) {
275
+ this.logIpcInboundPayload(data, normalized);
276
+ await this.primeQuotedSourceCache(data.source ?? findIcqqNestedMessageSource(data));
277
+ const message = this.$formatMessage(normalized);
175
278
  this.adapter.emit("message.receive", message);
176
- this.logger.debug(`${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`);
279
+ this.logger.debug(`${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}${message.$quote_id ? ` quote_id=${message.$quote_id}` : ""}`);
280
+ }
281
+ handleNoticeEvent(event) {
282
+ const dedupeKey = resolveSideEventDedupeKey(event, "notice");
283
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
284
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
285
+ this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
286
+ }
287
+ return;
288
+ }
289
+ const notice = formatIcqqNotice(event, this.$config.name);
290
+ this.adapter.emit("notice.receive", notice);
291
+ this.logger.info(formatCompact({
292
+ notice: notice.$type,
293
+ channel: `${notice.$channel.type}(${notice.$channel.id})`,
294
+ bot: this.$id,
295
+ sub_type: notice.$subType,
296
+ }));
297
+ }
298
+ handleRequestEvent(event) {
299
+ const dedupeKey = resolveSideEventDedupeKey(event, "request");
300
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
301
+ if (process.env.ICQQ_IPC_LOG_RAW === "1") {
302
+ this.logger.info(formatCompact({ ipc_dedupe: dedupeKey }));
303
+ }
304
+ return;
305
+ }
306
+ const request = formatIcqqRequest(event, this.$config.name, this.ipc);
307
+ this.adapter.emit("request.receive", request);
308
+ this.logger.info(formatCompact({
309
+ request: request.$type,
310
+ channel: `${request.$channel.type}(${request.$channel.id})`,
311
+ bot: this.$id,
312
+ from: request.$sender.id,
313
+ }));
314
+ }
315
+ handleMetaEvent(event) {
316
+ const dedupeKey = resolveSideEventDedupeKey(event, "meta");
317
+ if (!this.inboundDeduper.shouldProcess(dedupeKey))
318
+ return;
319
+ this.logger.debug(formatIcqqMetaLog(event));
320
+ if (shouldRefreshListsOnMeta(event)) {
321
+ void this.refreshLists().catch((e) => {
322
+ this.logger.warn(formatCompact({
323
+ op: "refresh_lists",
324
+ ok: false,
325
+ error: e instanceof Error ? e.message : String(e),
326
+ }));
327
+ });
328
+ }
177
329
  }
178
- $formatMessage(raw) {
179
- const channelId = raw.type === "group"
180
- ? String(raw.group_id ?? raw.from_id)
181
- : String(raw.from_id);
182
- const channelType = raw.type === "group" ? "group" : "private";
183
- // 守护进程推送无 message_id,合成一个
184
- const syntheticId = `${raw.time}_${raw.user_id}_${channelId}`;
330
+ /** 调试 IPC 入站字段:默认 debug;设 ICQQ_IPC_LOG_RAW=1 则 info 打完整 payload */
331
+ logIpcInboundPayload(data, normalized) {
332
+ const ext = data;
333
+ const keys = Object.keys(ext);
334
+ const idHints = {};
335
+ for (const key of keys) {
336
+ if (/message|msg|seq|id|source/i.test(key)) {
337
+ idHints[key] = ext[key];
338
+ }
339
+ }
340
+ const preview = JSON.stringify(data);
341
+ const compact = formatCompact({
342
+ post_type: data.post_type ?? "legacy",
343
+ message_type: data.message_type ?? data.type,
344
+ ipc_message_id: normalized.messageId,
345
+ id_source: normalized.idSource,
346
+ ipc_keys: keys.join(","),
347
+ ...(Object.keys(idHints).length ? { ipc_id_hints: JSON.stringify(idHints) } : {}),
348
+ });
349
+ this.logger.debug(compact);
350
+ this.logger.debug(formatCompact({ ipc_payload: preview.slice(0, 1200) }));
351
+ }
352
+ $formatMessage(input) {
353
+ const normalized = "messageId" in input
354
+ ? input
355
+ : normalizeIcqqInboundMessage(input);
356
+ if (!normalized) {
357
+ throw new Error("无法解析 icqq 入站消息");
358
+ }
359
+ const raw = normalized.raw;
185
360
  const senderInfo = {
186
- id: String(raw.user_id),
187
- name: raw.nickname,
361
+ id: normalized.userId,
362
+ name: normalized.nickname,
188
363
  };
364
+ const quoteId = Message.quoteIdFromContent(normalized.content) ??
365
+ resolveIcqqQuoteIdFromEvent(normalized.raw);
366
+ Message.alignReplySegments(normalized.content, quoteId);
189
367
  const result = Message.from(raw, {
190
- $id: syntheticId,
368
+ $id: normalized.messageId,
191
369
  $adapter: "icqq",
192
370
  $bot: this.$config.name,
193
371
  $sender: senderInfo,
194
- $channel: { id: channelId, type: channelType },
195
- $content: IcqqBot.parseCqMessage(raw.raw_message),
196
- $raw: raw.raw_message,
197
- $timestamp: raw.time * 1000,
372
+ $channel: {
373
+ id: normalized.channelId,
374
+ type: normalized.channelType,
375
+ },
376
+ $content: normalized.content,
377
+ $quote_id: quoteId,
378
+ $raw: normalized.rawMessage,
379
+ $timestamp: normalized.timestampMs,
198
380
  $recall: async () => {
199
- // 合成 id 无法撤回
200
- this.logger.warn(formatCompact({
201
- op: "recall",
202
- bot: this.$id,
203
- ok: false,
204
- error: "no message_id in push",
205
- }));
381
+ if (normalized.idSource === "synthetic") {
382
+ this.logger.warn(formatCompact({
383
+ op: "recall",
384
+ bot: this.$id,
385
+ ok: false,
386
+ error: "no message_id in push",
387
+ }));
388
+ return;
389
+ }
390
+ await this.$recallMessage(result.$id);
206
391
  },
207
392
  $reply: async (content, quote) => {
208
393
  if (!Array.isArray(content))
@@ -223,6 +408,65 @@ export class IcqqBot {
223
408
  });
224
409
  return result;
225
410
  }
411
+ /**
412
+ * 有 source.message_id 时用 get_msg 拉全量正文;否则仅用 source 内联摘要。
413
+ */
414
+ async primeQuotedSourceCache(source) {
415
+ if (!source)
416
+ return;
417
+ const quoteId = resolveQuoteIdFromIcqqSource(source);
418
+ const s = source;
419
+ const hasCanonicalId = quoteId &&
420
+ s.message_id != null &&
421
+ String(s.message_id).trim() === quoteId;
422
+ if (hasCanonicalId) {
423
+ try {
424
+ await this.fetchQuotedMessagePayload(quoteId);
425
+ return;
426
+ }
427
+ catch (e) {
428
+ this.logger.debug(formatCompact({
429
+ op: "quote_get_msg",
430
+ message_id: quoteId,
431
+ ok: false,
432
+ error: e instanceof Error ? e.message : String(e),
433
+ }));
434
+ }
435
+ }
436
+ const sourcePayload = quotedPayloadFromIcqqSource(source);
437
+ if (!sourcePayload)
438
+ return;
439
+ const enriched = await enrichQuotedPayloadWithForward(this.ipc, sourcePayload);
440
+ this.quotedSourceCache.set(enriched.messageId, enriched);
441
+ }
442
+ async fetchQuotedMessagePayload(messageId) {
443
+ const resp = await this.ipc.request(Actions.GET_MSG, {
444
+ message_id: messageId,
445
+ });
446
+ if (!resp.ok) {
447
+ throw new Error(resp.error ?? "get_msg failed");
448
+ }
449
+ const payload = parseIcqqGetMsgResponse(messageId, resp.data);
450
+ const enriched = await enrichQuotedPayloadWithForward(this.ipc, payload, resp.data);
451
+ if (isForwardPlaceholderPayload(enriched) &&
452
+ !String(Array.isArray(enriched.content)
453
+ ? segment.raw(enriched.content)
454
+ : enriched.content ?? "").includes("[Merged chat history")) {
455
+ this.logger.debug(formatCompact({
456
+ op: "forward_unresolved",
457
+ message_id: messageId,
458
+ }));
459
+ }
460
+ this.quotedSourceCache.set(messageId, enriched);
461
+ return enriched;
462
+ }
463
+ async $getMsg(messageId) {
464
+ const cached = this.quotedSourceCache.get(messageId);
465
+ if (cached) {
466
+ return enrichQuotedPayloadWithForward(this.ipc, cached);
467
+ }
468
+ return this.fetchQuotedMessagePayload(messageId);
469
+ }
226
470
  // ── 撤回 ───────────────────────────────────────────────────────────
227
471
  async $recallMessage(id) {
228
472
  const resp = await this.ipc.request(Actions.RECALL_MSG, {
@@ -239,134 +483,170 @@ export class IcqqBot {
239
483
  }
240
484
  // ── 发送消息 ───────────────────────────────────────────────────────
241
485
  async $sendMessage(options) {
242
- const content = IcqqBot.toCqString(options.content);
486
+ const message = buildIcqqIpcMessageImpl(options.content);
243
487
  let action;
244
488
  let params;
245
489
  switch (options.type) {
246
490
  case "private":
247
491
  action = Actions.SEND_PRIVATE_MSG;
248
- params = { user_id: Number(options.id), message: content };
492
+ params = { user_id: Number(options.id), message };
249
493
  break;
250
494
  case "group":
251
495
  action = Actions.SEND_GROUP_MSG;
252
- params = { group_id: Number(options.id), message: content };
496
+ params = { group_id: Number(options.id), message };
253
497
  break;
254
498
  default:
255
499
  throw new Error(`不支持的频道类型: ${options.type}`);
256
500
  }
257
501
  const resp = await this.ipc.request(action, params);
258
502
  if (!resp.ok) {
503
+ this.logger.debug(formatCompact({
504
+ op: action,
505
+ ...params,
506
+ }));
259
507
  throw new Error(`发送消息失败: ${resp.error}`);
260
508
  }
261
509
  const messageId = String(resp.data?.message_id ?? `sent_${Date.now()}`);
262
510
  this.logger.debug(`${this.$id} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
263
511
  return messageId;
264
512
  }
265
- }
266
- // ── CQ 码解析工具 ──────────────────────────────────────────────────
267
- (function (IcqqBot) {
513
+ // ── 消息回应 ───────────────────────────────────────────────────────
268
514
  /**
269
- * CQ 码原始消息字符串解析为 MessageSegment 数组。
270
- * 格式: `[type:value]` 或纯文本
515
+ * 表情符号到 reaction id 的映射
516
+ * QQ 使用数字 ID 来标识表情,而不是 Unicode 字符
271
517
  */
272
- function parseCqMessage(raw) {
273
- const segments = [];
274
- // 匹配 [type:arg] 或 [type:arg1,arg2=val] CQ 码
275
- const cqRegex = /\[([a-z_]+)(?::([^\]]*))?\]/g;
276
- let lastIndex = 0;
277
- for (const match of raw.matchAll(cqRegex)) {
278
- // 前面的纯文本
279
- if (match.index > lastIndex) {
280
- const text = raw.slice(lastIndex, match.index);
281
- if (text)
282
- segments.push({ type: "text", data: { text } });
283
- }
284
- const type = match[1];
285
- const arg = match[2] ?? "";
286
- switch (type) {
287
- case "face":
288
- segments.push({ type: "face", data: { id: Number(arg) } });
289
- break;
290
- case "image":
291
- segments.push({ type: "image", data: { url: arg, file: arg } });
292
- break;
293
- case "at":
294
- if (arg === "all") {
295
- segments.push({ type: "at", data: { qq: "all" } });
296
- }
297
- else {
298
- segments.push({ type: "at", data: { qq: arg } });
299
- }
300
- break;
301
- case "dice":
302
- segments.push({ type: "dice", data: {} });
303
- break;
304
- case "rps":
305
- segments.push({ type: "rps", data: {} });
306
- break;
307
- case "record":
308
- case "audio":
309
- segments.push({ type: "record", data: { file: arg } });
310
- break;
311
- case "video":
312
- segments.push({ type: "video", data: { file: arg } });
313
- break;
314
- case "reply":
315
- segments.push({ type: "reply", data: { id: arg } });
316
- break;
317
- default:
318
- segments.push({ type, data: { text: `[${type}:${arg}]` } });
319
- break;
518
+ static EMOJI_MAP = {
519
+ '⏳': '1468368274', // 沙漏
520
+ '👍': '128077', // 竖起大拇指
521
+ '❤️': '10084', // 红心
522
+ '😊': '128522', // 微笑
523
+ '🎉': '127881', // 派对
524
+ '🔥': '128293', //
525
+ '✅': '9989', // 勾选
526
+ '❌': '10060', // 叉号
527
+ '⭐': '11088', // 星星
528
+ '💯': '128175', // 一百分
529
+ };
530
+ /**
531
+ * 将表情符号转换为 reaction id
532
+ */
533
+ getEmojiId(emoji) {
534
+ // 如果已经是数字 ID,直接返回
535
+ if (/^\d+$/.test(emoji)) {
536
+ return emoji;
537
+ }
538
+ // 从映射中查找
539
+ const id = IcqqBot.EMOJI_MAP[emoji];
540
+ if (id) {
541
+ return id;
542
+ }
543
+ // 默认返回 Unicode 码点
544
+ return String(emoji.codePointAt(0) || 0);
545
+ }
546
+ /**
547
+ * 添加消息回应(表情)
548
+ *
549
+ * @param messageId - 消息 ID
550
+ * @param emoji - 表情符号或 reaction id
551
+ * @returns 反应 ID,可用于后续移除
552
+ */
553
+ async $addReaction(messageId, emoji) {
554
+ try {
555
+ const emojiId = this.getEmojiId(emoji);
556
+ const resp = await this.ipc.request(Actions.GROUP_SET_REACTION, {
557
+ message_id: messageId,
558
+ id: emojiId,
559
+ });
560
+ if (!resp.ok) {
561
+ this.logger.warn(formatCompact({
562
+ op: "add_reaction",
563
+ bot: this.$id,
564
+ message_id: messageId,
565
+ emoji,
566
+ id: emojiId,
567
+ ok: false,
568
+ error: resp.error,
569
+ }));
570
+ return null;
320
571
  }
321
- lastIndex = match.index + match[0].length;
572
+ this.logger.debug(formatCompact({
573
+ op: "add_reaction",
574
+ bot: this.$id,
575
+ message_id: messageId,
576
+ emoji,
577
+ id: emojiId,
578
+ ok: true,
579
+ }));
580
+ // 返回 reaction id,供 $removeReaction 直接使用
581
+ return emojiId;
322
582
  }
323
- // 尾部文本
324
- if (lastIndex < raw.length) {
325
- const text = raw.slice(lastIndex);
326
- if (text)
327
- segments.push({ type: "text", data: { text } });
583
+ catch (error) {
584
+ this.logger.warn(formatCompact({
585
+ op: "add_reaction",
586
+ bot: this.$id,
587
+ message_id: messageId,
588
+ emoji,
589
+ ok: false,
590
+ error: error instanceof Error ? error.message : String(error),
591
+ }));
592
+ return null;
328
593
  }
329
- return segments.length ? segments : [{ type: "text", data: { text: raw } }];
330
594
  }
331
- IcqqBot.parseCqMessage = parseCqMessage;
332
595
  /**
333
- * 将 SendContent(MessageSegment[] 或字符串)转为 CQ 码字符串。
334
- * 守护进程使用 CQ 码字符串格式收发消息。
596
+ * 移除消息回应
597
+ *
598
+ * @param messageId - 消息 ID
599
+ * @param reactionId - 反应 ID(由 $addReaction 返回)
335
600
  */
336
- function toCqString(content) {
337
- if (!Array.isArray(content))
338
- content = [content];
339
- return content
340
- .map((seg) => {
341
- if (typeof seg === "string")
342
- return seg;
343
- const { type, data } = seg;
344
- switch (type) {
345
- case "text":
346
- return data.text ?? "";
347
- case "face":
348
- return `[face:${data.id}]`;
349
- case "image":
350
- return `[image:${data.file || data.url || data.src}]`;
351
- case "at":
352
- return `[at:${data.qq ?? data.id}]`;
353
- case "dice":
354
- return "[dice]";
355
- case "rps":
356
- return "[rps]";
357
- case "record":
358
- case "audio":
359
- return `[record:${data.file || data.url}]`;
360
- case "video":
361
- return `[video:${data.file || data.url}]`;
362
- case "reply":
363
- return `[reply:${data.id}]`;
364
- default:
365
- return segment.toString(seg);
601
+ async $removeReaction(messageId, reactionId) {
602
+ try {
603
+ // 兼容两种格式:
604
+ // 1) 旧格式 reaction_{id}_{timestamp}
605
+ // 2) 新格式直接为 id
606
+ const parts = reactionId.split('_');
607
+ const emojiId = parts.length >= 2 ? parts[1] : reactionId;
608
+ const resp = await this.ipc.request(Actions.GROUP_DEL_REACTION, {
609
+ message_id: messageId,
610
+ id: emojiId,
611
+ });
612
+ if (!resp.ok) {
613
+ this.logger.warn(formatCompact({
614
+ op: "remove_reaction",
615
+ bot: this.$id,
616
+ message_id: messageId,
617
+ reaction_id: reactionId,
618
+ id: emojiId,
619
+ ok: false,
620
+ error: resp.error,
621
+ }));
622
+ }
623
+ else {
624
+ this.logger.debug(formatCompact({
625
+ op: "remove_reaction",
626
+ bot: this.$id,
627
+ message_id: messageId,
628
+ reaction_id: reactionId,
629
+ id: emojiId,
630
+ ok: true,
631
+ }));
366
632
  }
367
- })
368
- .join("");
633
+ }
634
+ catch (error) {
635
+ this.logger.warn(formatCompact({
636
+ op: "remove_reaction",
637
+ bot: this.$id,
638
+ message_id: messageId,
639
+ reaction_id: reactionId,
640
+ ok: false,
641
+ error: error instanceof Error ? error.message : String(error),
642
+ }));
643
+ }
369
644
  }
370
- IcqqBot.toCqString = toCqString;
645
+ }
646
+ /** @deprecated 使用 `./cq-message.js` 导出 */
647
+ (function (IcqqBot) {
648
+ IcqqBot.parseCqMessage = parseCqMessageImpl;
649
+ IcqqBot.buildIcqqIpcMessage = buildIcqqIpcMessageImpl;
650
+ IcqqBot.toCqString = toCqStringImpl;
371
651
  })(IcqqBot || (IcqqBot = {}));
372
652
  //# sourceMappingURL=bot.js.map