@zhin.js/adapter-icqq 1.0.71 → 1.1.2
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 +1216 -52
- package/PERMITS.md +24 -0
- package/README.md +123 -429
- package/adapters/icqq/index.js +31 -0
- package/adapters/icqq/index.ts +42 -0
- package/agents/icqq/agent.json +20 -0
- package/agents/icqq/boundaries.md +3 -0
- package/agents/icqq/conventions.md +3 -0
- package/agents/icqq/skills/icqq-directory/SKILL.md +20 -0
- package/agents/icqq/skills/icqq-directory/tools/friend_list/index.js +19 -0
- package/agents/icqq/skills/icqq-directory/tools/friend_list/index.ts +19 -0
- package/agents/icqq/skills/icqq-directory/tools/group_list/index.js +20 -0
- package/agents/icqq/skills/icqq-directory/tools/group_list/index.ts +20 -0
- package/agents/icqq/skills/icqq-directory/tools/group_member_info/index.js +26 -0
- package/agents/icqq/skills/icqq-directory/tools/group_member_info/index.ts +28 -0
- package/agents/icqq/skills/icqq-directory/tools/user_info/index.js +15 -0
- package/agents/icqq/skills/icqq-directory/tools/user_info/index.ts +14 -0
- package/agents/icqq/skills/icqq-group-admin/SKILL.md +25 -0
- package/agents/icqq/skills/icqq-group-admin/tools/announce/index.js +18 -0
- package/agents/icqq/skills/icqq-group-admin/tools/announce/index.ts +17 -0
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +24 -0
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +22 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.js +23 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.ts +20 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.js +27 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.ts +23 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.js +23 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.ts +21 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +25 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +22 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +26 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +23 -0
- package/agents/icqq/skills/icqq-group-state/SKILL.md +17 -0
- package/agents/icqq/skills/icqq-group-state/tools/group_files/index.js +24 -0
- package/agents/icqq/skills/icqq-group-state/tools/group_files/index.ts +22 -0
- package/agents/icqq/skills/icqq-group-state/tools/list_muted/index.js +18 -0
- package/agents/icqq/skills/icqq-group-state/tools/list_muted/index.ts +17 -0
- package/agents/icqq/skills/icqq-interaction/SKILL.md +19 -0
- package/agents/icqq/skills/icqq-interaction/tools/group_sign/index.js +20 -0
- package/agents/icqq/skills/icqq-interaction/tools/group_sign/index.ts +18 -0
- package/agents/icqq/skills/icqq-interaction/tools/poke/index.js +17 -0
- package/agents/icqq/skills/icqq-interaction/tools/poke/index.ts +16 -0
- package/agents/icqq/skills/icqq-interaction/tools/send_user_like/index.js +26 -0
- package/agents/icqq/skills/icqq-interaction/tools/send_user_like/index.ts +22 -0
- package/agents/icqq/system.md +3 -0
- package/commands/icqq/endpoint/add/[[id]]/index.js +3 -0
- package/commands/icqq/endpoint/add/[[id]]/index.ts +3 -0
- package/commands/icqq/endpoint/definition.js +31 -0
- package/commands/icqq/endpoint/definition.ts +32 -0
- package/commands/icqq/endpoint/list/index.js +3 -0
- package/commands/icqq/endpoint/list/index.ts +3 -0
- package/commands/icqq/endpoint/remove/[id]/index.js +3 -0
- package/commands/icqq/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +19 -0
- package/lib/client.js +3 -0
- package/lib/content-resolver.d.ts +10 -0
- package/lib/content-resolver.js +98 -0
- package/lib/cq-message.d.ts +14 -0
- package/lib/cq-message.js +106 -0
- package/lib/endpoint.d.ts +30 -0
- package/lib/endpoint.js +1003 -0
- package/lib/forward-msg.d.ts +6 -0
- package/lib/forward-msg.js +107 -0
- package/lib/icqq-guild.d.ts +57 -0
- package/lib/icqq-guild.js +109 -0
- package/lib/icqq-inbound.d.ts +165 -0
- package/lib/icqq-inbound.js +651 -0
- package/lib/icqq-inbox.d.ts +25 -0
- package/lib/icqq-inbox.js +129 -0
- package/lib/icqq-login-assist.d.ts +27 -0
- package/lib/icqq-login-assist.js +109 -0
- package/lib/icqq-runtime-state.d.ts +1 -0
- package/lib/icqq-runtime-state.js +6 -0
- package/lib/index.d.ts +5 -18
- package/lib/index.js +5 -124
- package/lib/outbound-media.d.ts +14 -0
- package/lib/outbound-media.js +76 -0
- package/lib/protocol.d.ts +258 -0
- package/lib/protocol.js +422 -0
- package/lib/types.d.ts +33 -13
- package/lib/types.js +0 -1
- package/package.json +86 -32
- package/plugin.js +19 -0
- package/prompt-sections/platform/index.js +16 -0
- package/prompt-sections/platform/index.ts +16 -0
- package/schema.json +168 -0
- package/src/client.ts +29 -0
- package/src/content-resolver.ts +131 -0
- package/src/cq-message.ts +113 -0
- package/src/endpoint.ts +1084 -0
- package/src/forward-msg.ts +112 -0
- package/src/icqq-guild.ts +154 -0
- package/src/icqq-inbound.ts +814 -0
- package/src/icqq-inbox.ts +147 -0
- package/src/icqq-login-assist.ts +132 -0
- package/src/icqq-runtime-state.ts +7 -0
- package/src/index.ts +30 -149
- package/src/outbound-media.ts +103 -0
- package/src/protocol.ts +551 -0
- package/src/types.ts +35 -13
- package/LICENSE +0 -21
- package/client/ICQQManagement.tsx +0 -139
- package/client/LoginAssistPanel.tsx +0 -293
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -20
- package/dist/index.js +0 -1
- package/lib/adapter.d.ts +0 -34
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -396
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -46
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -457
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js.map +0 -1
- package/skills/icqq/SKILL.md +0 -22
- package/src/adapter.ts +0 -448
- package/src/bot.ts +0 -521
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* icqq 入站事件归一化(post_type / message_type / message 段)
|
|
3
|
+
*/
|
|
4
|
+
import { toCanonicalSegments, type MessageSegment } from "zhin.js";
|
|
5
|
+
import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
|
|
6
|
+
import { extractForwardResidFromJsonElement } from "./forward-msg.js";
|
|
7
|
+
import type { GroupRole } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** 守护进程推送的通用事件壳 */
|
|
10
|
+
export interface IcqqEventBase {
|
|
11
|
+
post_type?: string;
|
|
12
|
+
self_id?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** post_type=message 时的消息体(字段随 icqqjs/cli 演进,未知字段保留) */
|
|
16
|
+
export interface IcqqMessageEvent extends IcqqEventBase {
|
|
17
|
+
post_type: "message";
|
|
18
|
+
message_id?: string;
|
|
19
|
+
msg_id?: string;
|
|
20
|
+
user_id: number;
|
|
21
|
+
user_uid?: string;
|
|
22
|
+
time: number;
|
|
23
|
+
seq?: number;
|
|
24
|
+
message_type: string;
|
|
25
|
+
sub_type?: string;
|
|
26
|
+
raw_message?: string;
|
|
27
|
+
message?: IcqqMessageElement[];
|
|
28
|
+
sender?: {
|
|
29
|
+
user_id?: number;
|
|
30
|
+
user_uid?: string;
|
|
31
|
+
nickname?: string;
|
|
32
|
+
card?: string;
|
|
33
|
+
/** 群临时会话来源群号 */
|
|
34
|
+
group_id?: number;
|
|
35
|
+
/** 群成员角色:owner | admin | member */
|
|
36
|
+
role?: GroupRole | string;
|
|
37
|
+
};
|
|
38
|
+
from_id?: number;
|
|
39
|
+
from_uid?: string;
|
|
40
|
+
group_id?: number;
|
|
41
|
+
to_id?: number;
|
|
42
|
+
nickname?: string;
|
|
43
|
+
/** 旧版仅有 type 无 message_type */
|
|
44
|
+
type?: "group" | "private";
|
|
45
|
+
/** oicq Message#source:被引用回复的源消息 */
|
|
46
|
+
source?: IcqqMessageSource;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** oicq 引用源消息(与 MessageEvent.source 对齐) */
|
|
50
|
+
export interface IcqqMessageSource {
|
|
51
|
+
message_id?: string;
|
|
52
|
+
msg_id?: string;
|
|
53
|
+
seq?: number;
|
|
54
|
+
rand?: number;
|
|
55
|
+
time?: number;
|
|
56
|
+
user_id?: number;
|
|
57
|
+
group_id?: number;
|
|
58
|
+
raw_message?: string;
|
|
59
|
+
/** 元素数组,或 NT 推送的纯文本摘要 */
|
|
60
|
+
message?: IcqqMessageElement[] | string;
|
|
61
|
+
sender?: {
|
|
62
|
+
user_id?: number;
|
|
63
|
+
nickname?: string;
|
|
64
|
+
card?: string;
|
|
65
|
+
};
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface IcqqMessageLookup {
|
|
70
|
+
readonly messageId: string;
|
|
71
|
+
readonly sender?: Readonly<{ id?: string; name?: string }>;
|
|
72
|
+
readonly content: readonly MessageSegment[] | string;
|
|
73
|
+
readonly raw?: string;
|
|
74
|
+
readonly time?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type IcqqMessageElement = {
|
|
78
|
+
type: string;
|
|
79
|
+
text?: string;
|
|
80
|
+
qq?: string | number;
|
|
81
|
+
user_id?: string | number;
|
|
82
|
+
id?: string | number;
|
|
83
|
+
url?: string;
|
|
84
|
+
file?: string;
|
|
85
|
+
fid?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
size?: number;
|
|
88
|
+
seconds?: number;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface IcqqInboundMediaResolver {
|
|
93
|
+
getPttUrl(element: IcqqMessageElement): Promise<string | null | undefined>;
|
|
94
|
+
getVideoUrl(element: IcqqMessageElement): Promise<string | null | undefined>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve ICQQ's opaque protobuf media references while the endpoint-owned
|
|
99
|
+
* native client is still available. Resolution failure preserves the opaque
|
|
100
|
+
* reference, so canonical ingress stays truthful and can report unsupported
|
|
101
|
+
* media instead of inventing a URL.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveIcqqInboundMedia(
|
|
104
|
+
elements: IcqqMessageElement[] | undefined,
|
|
105
|
+
resolver: IcqqInboundMediaResolver,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
if (!elements?.length) return;
|
|
108
|
+
await Promise.all(elements.map(async (element) => {
|
|
109
|
+
if (!element || typeof element !== "object") return;
|
|
110
|
+
if (typeof element.url === "string" && element.url) return;
|
|
111
|
+
try {
|
|
112
|
+
const url = element.type === "record" || element.type === "audio"
|
|
113
|
+
? await resolver.getPttUrl(element)
|
|
114
|
+
: element.type === "video"
|
|
115
|
+
? await resolver.getVideoUrl(element)
|
|
116
|
+
: undefined;
|
|
117
|
+
if (typeof url === "string" && url) element.url = url;
|
|
118
|
+
} catch {
|
|
119
|
+
// The original platform reference remains the canonical fallback.
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 归一化后供适配器使用的入站消息 */
|
|
125
|
+
export interface NormalizedIcqqInbound {
|
|
126
|
+
messageId: string;
|
|
127
|
+
idSource: "message_id" | "msg_id" | "seq" | "synthetic";
|
|
128
|
+
channelType: "group" | "private";
|
|
129
|
+
channelId: string;
|
|
130
|
+
/** 群临时会话:来源群号 */
|
|
131
|
+
channelParentGroupId?: string;
|
|
132
|
+
userId: string;
|
|
133
|
+
nickname: string;
|
|
134
|
+
/** 群聊时来自 sender.role(私聊无) */
|
|
135
|
+
senderRole?: GroupRole;
|
|
136
|
+
content: MessageSegment[];
|
|
137
|
+
rawMessage: string;
|
|
138
|
+
timestampMs: number;
|
|
139
|
+
raw: IcqqMessageEvent;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DEDUPE_TTL_MS = 120_000;
|
|
143
|
+
|
|
144
|
+
const ICQQ_GROUP_ROLES = new Set<GroupRole>(["owner", "admin", "member"]);
|
|
145
|
+
|
|
146
|
+
/** 从 sender.role 解析群成员角色(供 Message.$sender → resolveSenderRoles) */
|
|
147
|
+
export function parseIcqqSenderGroupRole(
|
|
148
|
+
sender: { role?: unknown } | undefined,
|
|
149
|
+
): GroupRole | undefined {
|
|
150
|
+
const raw = sender?.role;
|
|
151
|
+
if (typeof raw !== "string" || !raw.trim()) return undefined;
|
|
152
|
+
const role = raw.trim().toLowerCase() as GroupRole;
|
|
153
|
+
return ICQQ_GROUP_ROLES.has(role) ? role : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 判断入站消息是否 @ 了本机账号(uin = endpoint 配置 name,即 QQ 号)。
|
|
158
|
+
* 新 Plugin Runtime 的 Message.content 为纯文本,at 信息只能经 metadata 传递,
|
|
159
|
+
* endpoint 在 admit 时用本函数标注 `metadata.mentioned`(供 Agent Host 的 @ 触发)。
|
|
160
|
+
*/
|
|
161
|
+
export function isIcqqBotMentioned(input: {
|
|
162
|
+
readonly uin: string;
|
|
163
|
+
readonly content?: readonly MessageSegment[] | null;
|
|
164
|
+
readonly rawMessage?: string;
|
|
165
|
+
}): boolean {
|
|
166
|
+
const uin = String(input.uin ?? "").trim();
|
|
167
|
+
if (!uin) return false;
|
|
168
|
+
for (const seg of input.content ?? []) {
|
|
169
|
+
if (!seg || (seg.type !== "at" && seg.type !== "mention")) continue;
|
|
170
|
+
const data = (seg.data ?? {}) as Record<string, unknown>;
|
|
171
|
+
const target = String(
|
|
172
|
+
data.qq ?? data.user_id ?? data.id ?? data.target ?? "",
|
|
173
|
+
).trim();
|
|
174
|
+
if (target && target !== "all" && Number(target) === Number(uin)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const raw = input.rawMessage ?? "";
|
|
179
|
+
if (!raw) return false;
|
|
180
|
+
const escaped = uin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
181
|
+
// OneBot CQ 码 `[CQ:at,qq=uin]`;QQ 频道 markdown `<@!uin>`
|
|
182
|
+
return new RegExp(`\\[CQ:at,qq=${escaped}\\]`, "u").test(raw)
|
|
183
|
+
|| new RegExp(`<@!?${escaped}>`, "u").test(raw);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const MESSAGE_POST_TYPES = new Set(["message"]);
|
|
187
|
+
|
|
188
|
+
function isMessagePostType(postType: unknown): boolean {
|
|
189
|
+
if (typeof postType !== "string" || postType === "") return false;
|
|
190
|
+
if (MESSAGE_POST_TYPES.has(postType)) return true;
|
|
191
|
+
// 如 message.private / message.group
|
|
192
|
+
return postType.startsWith("message.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 从事件壳中取出 OneBot/icqq 载荷(兼容 data 嵌套或字段在根级) */
|
|
196
|
+
export function unwrapIcqqEventPayload(event: {
|
|
197
|
+
data?: unknown;
|
|
198
|
+
[key: string]: unknown;
|
|
199
|
+
}): unknown {
|
|
200
|
+
const nested = event.data;
|
|
201
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
202
|
+
const d = nested as Record<string, unknown>;
|
|
203
|
+
if (isRecognizedIcqqPayload(d)) {
|
|
204
|
+
return nested;
|
|
205
|
+
}
|
|
206
|
+
for (const key of ["data", "detail", "payload", "event"]) {
|
|
207
|
+
const inner = d[key];
|
|
208
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
209
|
+
return inner;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { id: _id, event: _ev, data: _data, ok: _ok, ...rest } = event;
|
|
215
|
+
if (isRecognizedIcqqPayload(rest)) {
|
|
216
|
+
return rest;
|
|
217
|
+
}
|
|
218
|
+
return nested;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isRecognizedIcqqPayload(d: Record<string, unknown>): boolean {
|
|
222
|
+
const pt = d.post_type;
|
|
223
|
+
if (typeof pt === "string") {
|
|
224
|
+
if (
|
|
225
|
+
isMessagePostType(pt) ||
|
|
226
|
+
pt === "notice" ||
|
|
227
|
+
pt === "request" ||
|
|
228
|
+
pt === "meta_event" ||
|
|
229
|
+
pt.startsWith("notice.") ||
|
|
230
|
+
pt.startsWith("request.") ||
|
|
231
|
+
pt.startsWith("system.")
|
|
232
|
+
) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return (
|
|
237
|
+
d.message_type != null ||
|
|
238
|
+
d.raw_message != null ||
|
|
239
|
+
d.message != null ||
|
|
240
|
+
d.type === "group" ||
|
|
241
|
+
d.type === "private" ||
|
|
242
|
+
d.notice_type != null ||
|
|
243
|
+
d.request_type != null ||
|
|
244
|
+
d.meta_event_type != null
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resolveIcqqInboundUserId(
|
|
249
|
+
data: Record<string, unknown>,
|
|
250
|
+
): number | undefined {
|
|
251
|
+
const sender = data.sender;
|
|
252
|
+
if (sender && typeof sender === "object" && !Array.isArray(sender)) {
|
|
253
|
+
const suid = (sender as { user_id?: unknown }).user_id;
|
|
254
|
+
if (suid != null) return Number(suid);
|
|
255
|
+
}
|
|
256
|
+
if (data.user_id != null) return Number(data.user_id);
|
|
257
|
+
if (data.from_id != null) return Number(data.from_id);
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function isIcqqMessagePostType(
|
|
262
|
+
data: unknown,
|
|
263
|
+
): data is IcqqMessageEvent {
|
|
264
|
+
if (!data || typeof data !== "object") return false;
|
|
265
|
+
const d = data as Record<string, unknown>;
|
|
266
|
+
if (isMessagePostType(d.post_type)) return true;
|
|
267
|
+
// 其它 post_type(notice/request 等)明确排除
|
|
268
|
+
if (d.post_type != null && d.post_type !== "") return false;
|
|
269
|
+
const hasBody =
|
|
270
|
+
typeof d.raw_message === "string" ||
|
|
271
|
+
Array.isArray(d.message) ||
|
|
272
|
+
typeof d.message_type === "string" ||
|
|
273
|
+
d.type === "group" ||
|
|
274
|
+
d.type === "private";
|
|
275
|
+
return hasBody && resolveIcqqInboundUserId(d) != null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function shouldSkipSelfInboundMessage(
|
|
279
|
+
data: IcqqMessageEvent,
|
|
280
|
+
): boolean {
|
|
281
|
+
if (data.self_id == null || data.user_id == null) return false;
|
|
282
|
+
return Number(data.self_id) === Number(data.user_id);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 群临时会话私聊:icqq message_type=private & sub_type=group */
|
|
286
|
+
export function isIcqqGroupTempPrivateMessage(data: IcqqMessageEvent): boolean {
|
|
287
|
+
const messageType =
|
|
288
|
+
data.message_type ??
|
|
289
|
+
(data.type === "group" || data.type === "private" ? data.type : undefined);
|
|
290
|
+
if (messageType !== "private") return false;
|
|
291
|
+
if (data.sub_type === "group") return true;
|
|
292
|
+
const groupId = data.sender?.group_id;
|
|
293
|
+
return typeof groupId === "number" && groupId > 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function resolveChannelFromIcqqMessage(
|
|
297
|
+
data: IcqqMessageEvent,
|
|
298
|
+
): {
|
|
299
|
+
channelType: "group" | "private";
|
|
300
|
+
channelId: string;
|
|
301
|
+
channelParentGroupId?: string;
|
|
302
|
+
} {
|
|
303
|
+
if (isIcqqGroupTempPrivateMessage(data)) {
|
|
304
|
+
const groupId = data.sender?.group_id ?? data.group_id;
|
|
305
|
+
const userId = resolveIcqqInboundUserId(
|
|
306
|
+
data as unknown as Record<string, unknown>,
|
|
307
|
+
);
|
|
308
|
+
if (groupId != null && userId != null) {
|
|
309
|
+
return {
|
|
310
|
+
channelType: "private",
|
|
311
|
+
channelId: String(userId),
|
|
312
|
+
channelParentGroupId: String(groupId),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const messageType =
|
|
318
|
+
data.message_type ??
|
|
319
|
+
(data.type === "group" || data.type === "private" ? data.type : undefined);
|
|
320
|
+
|
|
321
|
+
if (messageType === "group" || data.group_id != null) {
|
|
322
|
+
return {
|
|
323
|
+
channelType: "group",
|
|
324
|
+
channelId: String(data.group_id ?? data.from_id ?? data.user_id),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
channelType: "private",
|
|
329
|
+
channelId: String(data.from_id ?? data.user_id),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function resolveIcqqInboundMessageId(
|
|
334
|
+
data: IcqqMessageEvent,
|
|
335
|
+
channelId: string,
|
|
336
|
+
): { id: string; source: "message_id" | "msg_id" | "seq" | "synthetic" } {
|
|
337
|
+
if (data.message_id != null && String(data.message_id) !== "") {
|
|
338
|
+
return { id: String(data.message_id), source: "message_id" };
|
|
339
|
+
}
|
|
340
|
+
if (data.msg_id != null && String(data.msg_id) !== "") {
|
|
341
|
+
return { id: String(data.msg_id), source: "msg_id" };
|
|
342
|
+
}
|
|
343
|
+
if (data.seq != null && String(data.seq) !== "") {
|
|
344
|
+
return { id: String(data.seq), source: "seq" };
|
|
345
|
+
}
|
|
346
|
+
const uid =
|
|
347
|
+
resolveIcqqInboundUserId(data as unknown as Record<string, unknown>) ?? 0;
|
|
348
|
+
return {
|
|
349
|
+
id: `${data.time}_${uid}_${channelId}`,
|
|
350
|
+
source: "synthetic",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function icqqElementsToSegments(
|
|
355
|
+
elements: IcqqMessageElement[] | undefined,
|
|
356
|
+
): MessageSegment[] | null {
|
|
357
|
+
if (!elements?.length) return null;
|
|
358
|
+
const out: MessageSegment[] = [];
|
|
359
|
+
|
|
360
|
+
for (const el of elements) {
|
|
361
|
+
if (!el || typeof el !== "object") continue;
|
|
362
|
+
const type = el.type;
|
|
363
|
+
switch (type) {
|
|
364
|
+
case "text":
|
|
365
|
+
if (el.text != null && el.text !== "") {
|
|
366
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
367
|
+
}
|
|
368
|
+
break;
|
|
369
|
+
case "at": {
|
|
370
|
+
const atId = String(el.qq ?? el.user_id ?? "");
|
|
371
|
+
const name =
|
|
372
|
+
typeof el.name === "string"
|
|
373
|
+
? el.name
|
|
374
|
+
: typeof el.nickname === "string"
|
|
375
|
+
? el.nickname
|
|
376
|
+
: undefined;
|
|
377
|
+
out.push({
|
|
378
|
+
type: "at",
|
|
379
|
+
data: name
|
|
380
|
+
? { qq: atId, user_id: atId, id: atId, name }
|
|
381
|
+
: { qq: atId, user_id: atId, id: atId },
|
|
382
|
+
});
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "face":
|
|
386
|
+
out.push({ type: "face", data: { id: Number(el.id) } });
|
|
387
|
+
break;
|
|
388
|
+
case "image": {
|
|
389
|
+
const value = String(el.url ?? el.file ?? "");
|
|
390
|
+
if (!value) break;
|
|
391
|
+
out.push({
|
|
392
|
+
type: "image",
|
|
393
|
+
data: { media: icqqMediaRefFromElement(value, el) },
|
|
394
|
+
});
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "flash": {
|
|
398
|
+
const value = String(el.url ?? el.file ?? "");
|
|
399
|
+
if (!value) break;
|
|
400
|
+
out.push({
|
|
401
|
+
type: "image",
|
|
402
|
+
data: { media: icqqMediaRefFromElement(value, el), flash: true },
|
|
403
|
+
});
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case "forward": {
|
|
407
|
+
const forwardResid = String(
|
|
408
|
+
el.id ?? el.resid ?? el.res_id ?? el.file ?? "",
|
|
409
|
+
).trim();
|
|
410
|
+
if (forwardResid) {
|
|
411
|
+
out.push({
|
|
412
|
+
type: "forward",
|
|
413
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
414
|
+
});
|
|
415
|
+
} else {
|
|
416
|
+
out.push({ type: "text", data: { text: "[聊天记录]" } });
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "light_app":
|
|
421
|
+
case "xml": {
|
|
422
|
+
const forwardResid = extractForwardResidFromJsonElement(el);
|
|
423
|
+
if (forwardResid) {
|
|
424
|
+
out.push({
|
|
425
|
+
type: "forward",
|
|
426
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
427
|
+
});
|
|
428
|
+
} else if (el.text != null && el.text !== "") {
|
|
429
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
430
|
+
} else {
|
|
431
|
+
out.push({ type: "text", data: { text: "[聊天记录]" } });
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "reply": {
|
|
436
|
+
const replyId = String(el.id ?? el.message_id ?? "");
|
|
437
|
+
if (replyId) {
|
|
438
|
+
out.push({
|
|
439
|
+
type: "reply",
|
|
440
|
+
data: { id: replyId, message_id: replyId },
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case "json": {
|
|
446
|
+
const forwardResid = extractForwardResidFromJsonElement(el);
|
|
447
|
+
if (forwardResid) {
|
|
448
|
+
out.push({
|
|
449
|
+
type: "forward",
|
|
450
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
const replyFromJson = resolveReplyIdFromJsonElement(el);
|
|
455
|
+
if (replyFromJson) {
|
|
456
|
+
out.push({
|
|
457
|
+
type: "reply",
|
|
458
|
+
data: { id: replyFromJson, message_id: replyFromJson },
|
|
459
|
+
});
|
|
460
|
+
} else if (el.text != null && el.text !== "") {
|
|
461
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case "record":
|
|
466
|
+
case "audio": {
|
|
467
|
+
const value = String(el.url ?? el.file ?? "");
|
|
468
|
+
if (!value) break;
|
|
469
|
+
out.push({
|
|
470
|
+
type: "audio",
|
|
471
|
+
data: {
|
|
472
|
+
media: icqqMediaRefFromElement(value, el),
|
|
473
|
+
...(typeof el.seconds === "number" ? { duration: el.seconds } : {}),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "video": {
|
|
479
|
+
const value = String(el.url ?? el.file ?? "");
|
|
480
|
+
if (!value) break;
|
|
481
|
+
out.push({
|
|
482
|
+
type: "video",
|
|
483
|
+
data: {
|
|
484
|
+
media: icqqMediaRefFromElement(value, el),
|
|
485
|
+
...(typeof el.seconds === "number" ? { duration: el.seconds } : {}),
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case "file": {
|
|
491
|
+
const value = String(el.url ?? el.file ?? el.fid ?? "");
|
|
492
|
+
if (!value) break;
|
|
493
|
+
const name = typeof el.name === "string" && el.name ? el.name : undefined;
|
|
494
|
+
out.push({
|
|
495
|
+
type: "file",
|
|
496
|
+
data: {
|
|
497
|
+
media: icqqMediaRefFromElement(value, el),
|
|
498
|
+
...(name ? { name } : {}),
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
default:
|
|
504
|
+
if (el.text != null && el.text !== "") {
|
|
505
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return out.length ? out : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function icqqMediaRefFromElement(
|
|
514
|
+
value: string,
|
|
515
|
+
element: IcqqMessageElement,
|
|
516
|
+
): ReturnType<typeof icqqMediaRefFromString> {
|
|
517
|
+
const media = icqqMediaRefFromString(value);
|
|
518
|
+
const fileName = typeof element.name === "string" && element.name
|
|
519
|
+
? element.name
|
|
520
|
+
: undefined;
|
|
521
|
+
const size = typeof element.size === "number" && Number.isFinite(element.size)
|
|
522
|
+
? element.size
|
|
523
|
+
: undefined;
|
|
524
|
+
return {
|
|
525
|
+
...media,
|
|
526
|
+
...(fileName ? { file_name: fileName } : {}),
|
|
527
|
+
...(size != null ? { size } : {}),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function resolveReplyIdFromJsonElement(el: IcqqMessageElement): string | undefined {
|
|
532
|
+
let payload: unknown = el.data ?? el;
|
|
533
|
+
if (typeof payload === "string") {
|
|
534
|
+
try {
|
|
535
|
+
payload = JSON.parse(payload) as unknown;
|
|
536
|
+
} catch {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
541
|
+
const record = payload as Record<string, unknown>;
|
|
542
|
+
const refer =
|
|
543
|
+
record.msgRefer ??
|
|
544
|
+
record.reply ??
|
|
545
|
+
record.resid ??
|
|
546
|
+
record.message_id ??
|
|
547
|
+
record.id;
|
|
548
|
+
if (refer && typeof refer === "object") {
|
|
549
|
+
const r = refer as Record<string, unknown>;
|
|
550
|
+
const id = r.msgId ?? r.message_id ?? r.id ?? r.msg_id;
|
|
551
|
+
if (id != null && String(id)) return String(id);
|
|
552
|
+
}
|
|
553
|
+
if (typeof refer === "string" && refer) return refer;
|
|
554
|
+
if (typeof refer === "number") return String(refer);
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const MAX_SOURCE_WALK_DEPTH = 10;
|
|
559
|
+
|
|
560
|
+
function icqqSourceMessageBody(s: IcqqMessageSource): unknown {
|
|
561
|
+
return (s as Record<string, unknown>).message;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function icqqSourceHasBody(s: IcqqMessageSource): boolean {
|
|
565
|
+
const body = icqqSourceMessageBody(s);
|
|
566
|
+
if (Array.isArray(body) && body.length > 0) return true;
|
|
567
|
+
if (typeof body === "string" && body.trim().length > 0) return true;
|
|
568
|
+
return typeof s.raw_message === "string" && s.raw_message.length > 0;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function looksLikeIcqqMessageSource(value: unknown): value is IcqqMessageSource {
|
|
572
|
+
if (!value || typeof value !== "object") return false;
|
|
573
|
+
const r = value as Record<string, unknown>;
|
|
574
|
+
// 整条入站事件也有 message_id,不能与 oicq Message#source 混淆
|
|
575
|
+
if (r.post_type != null || r.message_type != null) return false;
|
|
576
|
+
const s = value as IcqqMessageSource;
|
|
577
|
+
if (!icqqSourceHasBody(s)) return false;
|
|
578
|
+
return !!resolveQuoteIdFromIcqqSource(value);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** 载荷可能把 source 嵌在 data / event 等字段,深度查找 */
|
|
582
|
+
export function findIcqqNestedMessageSource(
|
|
583
|
+
root: unknown,
|
|
584
|
+
maxDepth = MAX_SOURCE_WALK_DEPTH,
|
|
585
|
+
): IcqqMessageSource | undefined {
|
|
586
|
+
const seen = new Set<unknown>();
|
|
587
|
+
function walk(node: unknown, depth: number): IcqqMessageSource | undefined {
|
|
588
|
+
if (depth > maxDepth || node == null) return undefined;
|
|
589
|
+
if (typeof node !== "object") return undefined;
|
|
590
|
+
if (seen.has(node)) return undefined;
|
|
591
|
+
seen.add(node);
|
|
592
|
+
|
|
593
|
+
if (looksLikeIcqqMessageSource(node)) {
|
|
594
|
+
return node as IcqqMessageSource;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const record = node as Record<string, unknown>;
|
|
598
|
+
if (record.source && looksLikeIcqqMessageSource(record.source)) {
|
|
599
|
+
return record.source as IcqqMessageSource;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
for (const value of Object.values(record)) {
|
|
603
|
+
if (value && typeof value === "object") {
|
|
604
|
+
const hit = walk(value, depth + 1);
|
|
605
|
+
if (hit) return hit;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
return walk(root, 0);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** 从 oicq MessageEvent.source 解析被引用消息的 message_id */
|
|
614
|
+
export function resolveQuoteIdFromIcqqSource(
|
|
615
|
+
source: unknown,
|
|
616
|
+
): string | undefined {
|
|
617
|
+
if (!source || typeof source !== "object") return undefined;
|
|
618
|
+
const s = source as IcqqMessageSource;
|
|
619
|
+
if (s.message_id != null && String(s.message_id).trim()) {
|
|
620
|
+
return String(s.message_id).trim();
|
|
621
|
+
}
|
|
622
|
+
if (s.msg_id != null && String(s.msg_id).trim()) {
|
|
623
|
+
return String(s.msg_id).trim();
|
|
624
|
+
}
|
|
625
|
+
// NT 引用源常仅有 seq/rand,无 message_id
|
|
626
|
+
if (s.seq != null && String(s.seq) !== "") {
|
|
627
|
+
const seq = String(s.seq);
|
|
628
|
+
if (s.rand != null && String(s.rand) !== "") {
|
|
629
|
+
return `${seq}:${s.rand}`;
|
|
630
|
+
}
|
|
631
|
+
return seq;
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function icqqSourceToContentSegments(
|
|
637
|
+
s: IcqqMessageSource,
|
|
638
|
+
): MessageSegment[] {
|
|
639
|
+
const body = icqqSourceMessageBody(s);
|
|
640
|
+
if (Array.isArray(body) && body.length) {
|
|
641
|
+
return icqqElementsToSegments(body as IcqqMessageElement[]) ?? [];
|
|
642
|
+
}
|
|
643
|
+
if (typeof body === "string" && body.trim()) {
|
|
644
|
+
return [{ type: "text", data: { text: body.trim() } }];
|
|
645
|
+
}
|
|
646
|
+
if (typeof s.raw_message === "string" && s.raw_message) {
|
|
647
|
+
return parseCqMessage(s.raw_message);
|
|
648
|
+
}
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Convert an ICQQ source into the adapter-local lookup payload. */
|
|
653
|
+
export function messageLookupFromIcqqSource(
|
|
654
|
+
source: unknown,
|
|
655
|
+
): IcqqMessageLookup | null {
|
|
656
|
+
if (!source || typeof source !== "object") return null;
|
|
657
|
+
const s = source as IcqqMessageSource;
|
|
658
|
+
const body = icqqSourceMessageBody(s);
|
|
659
|
+
const content = icqqSourceToContentSegments(s);
|
|
660
|
+
const messageId = resolveQuoteIdFromIcqqSource(source);
|
|
661
|
+
if (!messageId && !content.length) return null;
|
|
662
|
+
const senderRaw = s.sender;
|
|
663
|
+
const senderFromUserId =
|
|
664
|
+
s.user_id != null
|
|
665
|
+
? { id: String(s.user_id), name: "" }
|
|
666
|
+
: undefined;
|
|
667
|
+
return {
|
|
668
|
+
messageId: messageId ?? `seq:${s.seq ?? 0}:${s.rand ?? 0}`,
|
|
669
|
+
sender: senderRaw
|
|
670
|
+
? {
|
|
671
|
+
id: String(senderRaw.user_id ?? ""),
|
|
672
|
+
name: String(senderRaw.nickname ?? senderRaw.card ?? ""),
|
|
673
|
+
}
|
|
674
|
+
: senderFromUserId,
|
|
675
|
+
content,
|
|
676
|
+
raw:
|
|
677
|
+
typeof s.raw_message === "string"
|
|
678
|
+
? s.raw_message
|
|
679
|
+
: typeof body === "string"
|
|
680
|
+
? body
|
|
681
|
+
: undefined,
|
|
682
|
+
time: typeof s.time === "number" ? s.time : undefined,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 从事件顶层字段解析引用 message_id(与 message 段互补) */
|
|
687
|
+
export function resolveIcqqQuoteIdFromEvent(
|
|
688
|
+
data: IcqqMessageEvent,
|
|
689
|
+
): string | undefined {
|
|
690
|
+
const fromSource =
|
|
691
|
+
resolveQuoteIdFromIcqqSource(data.source) ??
|
|
692
|
+
resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
693
|
+
if (fromSource) return fromSource;
|
|
694
|
+
|
|
695
|
+
const ext = data as IcqqMessageEvent & Record<string, unknown>;
|
|
696
|
+
const candidates = [
|
|
697
|
+
ext.reply,
|
|
698
|
+
ext.quoted_message_id,
|
|
699
|
+
ext.quote_message_id,
|
|
700
|
+
ext.source_message_id,
|
|
701
|
+
];
|
|
702
|
+
for (const c of candidates) {
|
|
703
|
+
if (typeof c === "string" && c.trim()) return c.trim();
|
|
704
|
+
if (typeof c === "number") return String(c);
|
|
705
|
+
if (c && typeof c === "object") {
|
|
706
|
+
const r = c as Record<string, unknown>;
|
|
707
|
+
const id = r.id ?? r.message_id ?? r.msg_id ?? r.msgId;
|
|
708
|
+
if (id != null && String(id)) return String(id);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function mergeReplyFromRawMessage(
|
|
715
|
+
content: MessageSegment[],
|
|
716
|
+
rawMessage: string,
|
|
717
|
+
): MessageSegment[] {
|
|
718
|
+
if (!rawMessage || quoteIdFromSegments(content)) return content;
|
|
719
|
+
const fromRaw = parseCqMessage(rawMessage);
|
|
720
|
+
const quoteFromRaw = quoteIdFromSegments(fromRaw);
|
|
721
|
+
if (!quoteFromRaw) return content;
|
|
722
|
+
return [
|
|
723
|
+
{ type: "reply", data: { message_id: quoteFromRaw } },
|
|
724
|
+
...content.filter((s) => s.type !== "reply"),
|
|
725
|
+
];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function mergeReplyFromSource(
|
|
729
|
+
content: MessageSegment[],
|
|
730
|
+
data: IcqqMessageEvent,
|
|
731
|
+
): MessageSegment[] {
|
|
732
|
+
if (quoteIdFromSegments(content)) return content;
|
|
733
|
+
const quoteId =
|
|
734
|
+
resolveQuoteIdFromIcqqSource(data.source) ??
|
|
735
|
+
resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
736
|
+
if (!quoteId) return content;
|
|
737
|
+
return [
|
|
738
|
+
{ type: "reply", data: { message_id: quoteId } },
|
|
739
|
+
...content.filter((s) => s.type !== "reply"),
|
|
740
|
+
];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function quoteIdFromSegments(content: readonly MessageSegment[]): string | undefined {
|
|
744
|
+
const reply = content.find((item) => item.type === 'reply');
|
|
745
|
+
const value = reply?.data?.message_id ?? reply?.data?.id;
|
|
746
|
+
return value == null || !String(value).trim() ? undefined : String(value).trim();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function resolveInboundContent(
|
|
750
|
+
data: IcqqMessageEvent,
|
|
751
|
+
): MessageSegment[] {
|
|
752
|
+
const fromElements = icqqElementsToSegments(data.message);
|
|
753
|
+
const raw = data.raw_message ?? "";
|
|
754
|
+
let content: MessageSegment[];
|
|
755
|
+
if (fromElements?.length) {
|
|
756
|
+
content = mergeReplyFromRawMessage(fromElements, raw);
|
|
757
|
+
} else if (raw) {
|
|
758
|
+
content = parseCqMessage(raw);
|
|
759
|
+
} else {
|
|
760
|
+
content = [{ type: "text", data: { text: "" } }];
|
|
761
|
+
}
|
|
762
|
+
return toCanonicalSegments(mergeReplyFromSource(content, data));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function normalizeIcqqInboundMessage(
|
|
766
|
+
data: IcqqMessageEvent,
|
|
767
|
+
): NormalizedIcqqInbound | null {
|
|
768
|
+
if (!isIcqqMessagePostType(data)) return null;
|
|
769
|
+
const ext = data as IcqqMessageEvent & Record<string, unknown>;
|
|
770
|
+
const userIdNum = resolveIcqqInboundUserId(ext);
|
|
771
|
+
if (userIdNum == null) return null;
|
|
772
|
+
const { channelType, channelId, channelParentGroupId } = resolveChannelFromIcqqMessage(data);
|
|
773
|
+
const resolved = resolveIcqqInboundMessageId(data, channelId);
|
|
774
|
+
const nickname =
|
|
775
|
+
data.sender?.nickname ?? data.nickname ?? String(userIdNum);
|
|
776
|
+
const senderRole =
|
|
777
|
+
channelType === "group"
|
|
778
|
+
? parseIcqqSenderGroupRole(data.sender)
|
|
779
|
+
: undefined;
|
|
780
|
+
const rawMessage = data.raw_message ?? "";
|
|
781
|
+
return {
|
|
782
|
+
messageId: resolved.id,
|
|
783
|
+
idSource: resolved.source,
|
|
784
|
+
channelType,
|
|
785
|
+
channelId,
|
|
786
|
+
channelParentGroupId,
|
|
787
|
+
userId: String(userIdNum),
|
|
788
|
+
nickname,
|
|
789
|
+
senderRole,
|
|
790
|
+
content: resolveInboundContent(data),
|
|
791
|
+
rawMessage,
|
|
792
|
+
timestampMs: data.time * 1000,
|
|
793
|
+
raw: data,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** 按 message_id 去重(多路 subscribe 会收到同一消息多次) */
|
|
798
|
+
export class InboundMessageDeduper {
|
|
799
|
+
private readonly seen = new Map<string, number>();
|
|
800
|
+
|
|
801
|
+
shouldProcess(messageId: string): boolean {
|
|
802
|
+
const now = Date.now();
|
|
803
|
+
for (const [id, t] of this.seen) {
|
|
804
|
+
if (now - t > DEDUPE_TTL_MS) this.seen.delete(id);
|
|
805
|
+
}
|
|
806
|
+
if (this.seen.has(messageId)) return false;
|
|
807
|
+
this.seen.set(messageId, now);
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
clear(): void {
|
|
812
|
+
this.seen.clear();
|
|
813
|
+
}
|
|
814
|
+
}
|