@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.
- package/CHANGELOG.md +106 -78
- package/README.md +3 -3
- package/client/index.tsx +1 -1
- package/client/tsconfig.json +1 -1
- package/dist/index.js +1 -1
- package/lib/bot.d.ts +56 -12
- package/lib/bot.d.ts.map +1 -1
- package/lib/bot.js +416 -136
- package/lib/bot.js.map +1 -1
- package/lib/cq-message.d.ts +10 -0
- package/lib/cq-message.d.ts.map +1 -0
- package/lib/cq-message.js +119 -0
- package/lib/cq-message.js.map +1 -0
- package/lib/forward-msg.d.ts +27 -0
- package/lib/forward-msg.d.ts.map +1 -0
- package/lib/forward-msg.js +387 -0
- package/lib/forward-msg.js.map +1 -0
- package/lib/get-msg.d.ts +3 -0
- package/lib/get-msg.d.ts.map +1 -0
- package/lib/get-msg.js +46 -0
- package/lib/get-msg.js.map +1 -0
- package/lib/icqq-inbound.d.ts +114 -0
- package/lib/icqq-inbound.d.ts.map +1 -0
- package/lib/icqq-inbound.js +495 -0
- package/lib/icqq-inbound.js.map +1 -0
- package/lib/icqq-side-events.d.ts +34 -0
- package/lib/icqq-side-events.d.ts.map +1 -0
- package/lib/icqq-side-events.js +194 -0
- package/lib/icqq-side-events.js.map +1 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/ipc-client.d.ts +7 -2
- package/lib/ipc-client.d.ts.map +1 -1
- package/lib/ipc-client.js +74 -16
- package/lib/ipc-client.js.map +1 -1
- package/lib/protocol.d.ts +3 -10
- package/lib/protocol.d.ts.map +1 -1
- package/lib/protocol.js +2 -0
- package/lib/protocol.js.map +1 -1
- package/lib/routes.d.ts +1 -1
- package/lib/routes.d.ts.map +1 -1
- package/lib/types.d.ts +44 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/typing-indicator-example.d.ts +108 -0
- package/lib/typing-indicator-example.d.ts.map +1 -0
- package/lib/typing-indicator-example.js +220 -0
- package/lib/typing-indicator-example.js.map +1 -0
- package/lib/typing-indicator.d.ts +87 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +225 -0
- package/lib/typing-indicator.js.map +1 -0
- package/package.json +18 -12
- package/src/bot.ts +524 -149
- package/src/cq-message.ts +120 -0
- package/src/forward-msg.ts +433 -0
- package/src/get-msg.ts +56 -0
- package/src/icqq-inbound.ts +616 -0
- package/src/icqq-side-events.ts +228 -0
- package/src/index.ts +10 -2
- package/src/ipc-client.ts +76 -16
- package/src/protocol.ts +4 -10
- package/src/routes.ts +1 -1
- package/src/types.ts +45 -0
- package/src/typing-indicator-example.ts +269 -0
- 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,
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
217
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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:
|
|
237
|
-
name:
|
|
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:
|
|
485
|
+
$id: normalized.messageId,
|
|
242
486
|
$adapter: "icqq" as const,
|
|
243
487
|
$bot: this.$config.name,
|
|
244
488
|
$sender: senderInfo,
|
|
245
|
-
$channel: {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
665
|
+
// ── 消息回应 ───────────────────────────────────────────────────────
|
|
333
666
|
|
|
334
|
-
export namespace IcqqBot {
|
|
335
667
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
668
|
+
* 表情符号到 reaction id 的映射
|
|
669
|
+
* QQ 使用数字 ID 来标识表情,而不是 Unicode 字符
|
|
338
670
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
*
|
|
404
|
-
*
|
|
757
|
+
* 移除消息回应
|
|
758
|
+
*
|
|
759
|
+
* @param messageId - 消息 ID
|
|
760
|
+
* @param reactionId - 反应 ID(由 $addReaction 返回)
|
|
405
761
|
*/
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
+
}
|