@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0
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/LICENSE +661 -661
- package/README.md +0 -14
- package/package.json +1 -1
- package/vendor/gugle-chat-bot/LICENSE +661 -661
- package/vendor/gugle-chat-bot/package.json +57 -57
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
- package/vendor/gugle-chat-bot/src/api.json +4284 -4284
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
- package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
- package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
- package/vendor/gugle-chat-bot/src/custom.ts +548 -548
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
- package/vendor/gugle-chat-bot/src/index.ts +695 -695
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
- package/vendor/gugle-chat-bot/src/result.json +359 -359
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
- package/vendor/gugle-chat-bot/tsconfig.json +46 -46
- package/scripts/dump-npm-latest.mjs +0 -30
|
@@ -1,319 +1,319 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ 消息段的深度解析(纯函数 + 注入式 resolver,抽离自 HermesBridge 以便冒烟测试)。
|
|
3
|
-
*
|
|
4
|
-
* 深度表(depth = 正在解析内容的层级,触发消息本身为 0):
|
|
5
|
-
* | 段类型 | depth 0 | depth 1 | depth 2 | depth ≥3 |
|
|
6
|
-
* | 图片 | `[图片]` + 原图入 images | 同左 | `[图片]`,不下载 | `[图片]`,不下载 |
|
|
7
|
-
* | reply / forward | 拉取并解析(depth+1) | 同左 | 拉取但仅文字(不下载图片) | 不拉取,输出 `[聊天记录]` |
|
|
8
|
-
* | text / at / video / record | 原样(at:群名片优先,缺失回退 `@QQ号`) | 同左 | 同左 | 同左 |
|
|
9
|
-
*
|
|
10
|
-
* 有意的取舍:交付物 5 中「引用里含聊天记录 → 节点内图片入 images」按深度表解释为
|
|
11
|
-
* forward 处于 depth 0 时其节点图片入 images;若 forward 落在 depth 1,则节点文字展开、
|
|
12
|
-
* 节点图片在 depth 2 不下载(深度表为准)。
|
|
13
|
-
*
|
|
14
|
-
* 本模块不产生任何 IO:拉取、图片下载、昵称与时间格式化全部由调用方经 MessageResolvers 注入。
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** 聊天记录(合并转发)在文本中的占位符 */
|
|
18
|
-
export const FORWARD_PLACEHOLDER = '[聊天记录]';
|
|
19
|
-
|
|
20
|
-
/** 解析护栏:单次解析的段/节点/图片总量与深度上限,防超长消息与自环拖垮调用方 */
|
|
21
|
-
export const PARSE_LIMITS = {
|
|
22
|
-
/** 单次解析处理的段总量上限(跨层级累计) */
|
|
23
|
-
maxSegments: 500,
|
|
24
|
-
/** 单个聊天记录展开的节点上限 */
|
|
25
|
-
maxNodes: 50,
|
|
26
|
-
/** 单次解析收集的图片上限 */
|
|
27
|
-
maxImages: 10,
|
|
28
|
-
/** 深度上限:处于该深度的 reply/forward 不再拉取,只留 `[聊天记录]` 占位 */
|
|
29
|
-
maxDepth: 3,
|
|
30
|
-
/** 下载图片的最大深度(depth 2 起只保留 `[图片]` 占位) */
|
|
31
|
-
maxImageDepth: 1
|
|
32
|
-
} as const;
|
|
33
|
-
|
|
34
|
-
export interface MessageResolvers {
|
|
35
|
-
/** get_msg,失败抛错 */
|
|
36
|
-
getMsg(
|
|
37
|
-
messageId: number
|
|
38
|
-
): Promise<{ sender?: { nickname?: string; user_id?: number }; time?: number; message: unknown }>;
|
|
39
|
-
/** get_forward_msg,失败抛错 */
|
|
40
|
-
getForward(forwardId: string): Promise<{ messages: unknown[] }>;
|
|
41
|
-
/** 图片段 → base64 data URL;失败/不需要时返回 null */
|
|
42
|
-
resolveImage(segment: { data: { file: string; url?: string } }): Promise<string | null>;
|
|
43
|
-
/** @提及昵称解析(可选;缺省用 QQ 号) */
|
|
44
|
-
resolveName?(qq: string): Promise<string | null>;
|
|
45
|
-
/** 时间戳格式化(注入以保持纯净) */
|
|
46
|
-
formatTime(unixTs: number): string;
|
|
47
|
-
/** 是否下载/转发图片(config.forwardImages) */
|
|
48
|
-
forwardImages: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface ParsedMessage {
|
|
52
|
-
text: string;
|
|
53
|
-
images: string[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 单次解析的共享状态:护栏计数、去重集合与 @提及昵称缓存。
|
|
58
|
-
* 由 parseMessage 建立并传给各层 parseSegments;直接调用 parseSegments 时可省略。
|
|
59
|
-
*/
|
|
60
|
-
export interface ParseState {
|
|
61
|
-
/** 已展开的引用/转发 id,防自环与重复展开 */
|
|
62
|
-
visitedIds: Set<string>;
|
|
63
|
-
/** 跨层级累计的段计数(护栏) */
|
|
64
|
-
segmentCount: number;
|
|
65
|
-
/** 已展开的转发节点计数(护栏) */
|
|
66
|
-
nodeCount: number;
|
|
67
|
-
/** 是否解析 @提及昵称(群聊上下文才解析,私聊直接回退 QQ 号) */
|
|
68
|
-
resolveNames: boolean;
|
|
69
|
-
/** @提及昵称缓存(qq → 昵称,空串表示解析失败) */
|
|
70
|
-
nameCache: Map<string, string>;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** 建立解析状态;resolveNames=false 时 @提及 一律回退 QQ 号 */
|
|
74
|
-
export function createParseState(resolveNames: boolean): ParseState {
|
|
75
|
-
return { visitedIds: new Set<string>(), segmentCount: 0, nodeCount: 0, resolveNames, nameCache: new Map() };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 取消息段的数据体(非对象按空对象处理) */
|
|
79
|
-
function segmentData(segment: unknown): Record<string, unknown> {
|
|
80
|
-
if (!segment || typeof segment !== 'object') return {};
|
|
81
|
-
const data = (segment as { data?: unknown }).data;
|
|
82
|
-
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** 取消息段类型(缺失/非字符串返回空串) */
|
|
86
|
-
function segmentType(segment: unknown): string {
|
|
87
|
-
if (!segment || typeof segment !== 'object') return '';
|
|
88
|
-
const type = (segment as { type?: unknown }).type;
|
|
89
|
-
return typeof type === 'string' ? type : '';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** id 字段归一化为字符串(number/string 皆可,0 与空串视为无 id) */
|
|
93
|
-
function readId(raw: unknown): string {
|
|
94
|
-
if (typeof raw === 'number' && Number.isFinite(raw) && raw !== 0) return String(raw);
|
|
95
|
-
if (typeof raw === 'string' && raw.length > 0) return raw;
|
|
96
|
-
return '';
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** 取首个非空字符串 */
|
|
100
|
-
function pickString(values: unknown[]): string {
|
|
101
|
-
for (const value of values) {
|
|
102
|
-
if (typeof value === 'string' && value.length > 0) return value;
|
|
103
|
-
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
104
|
-
}
|
|
105
|
-
return '';
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** 引用块 / 转发节点行的统一格式:`> {昵称}({id}) {时间}\n> {内容}\n` */
|
|
109
|
-
function quoteBlock(name: string, id: string, time: string, content: string): string {
|
|
110
|
-
return `${`> ${name}(${id}) ${time}`.trimEnd()}\n> ${content}\n`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 解析 @提及 昵称:群聊上下文经 resolver 查询并缓存,失败回退 null(调用方用 QQ 号) */
|
|
114
|
-
async function resolveAtName(qq: string, resolvers: MessageResolvers, state: ParseState): Promise<string | null> {
|
|
115
|
-
if (!state.resolveNames || !resolvers.resolveName) return null;
|
|
116
|
-
const cached = state.nameCache.get(qq);
|
|
117
|
-
if (cached !== undefined) return cached || null;
|
|
118
|
-
try {
|
|
119
|
-
const name = await resolvers.resolveName(qq);
|
|
120
|
-
state.nameCache.set(qq, name ?? '');
|
|
121
|
-
return name ?? null;
|
|
122
|
-
} catch {
|
|
123
|
-
state.nameCache.set(qq, '');
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** 解析图片段为 base64 data URL;失败/不需要时返回 null(调用方保留 `[图片]` 占位) */
|
|
129
|
-
async function resolveImageSafe(segment: unknown, resolvers: MessageResolvers): Promise<string | null> {
|
|
130
|
-
const data = segmentData(segment);
|
|
131
|
-
const file = typeof data.file === 'string' ? data.file : '';
|
|
132
|
-
const url = typeof data.url === 'string' ? data.url : '';
|
|
133
|
-
if (!file && !url) return null;
|
|
134
|
-
const target: { data: { file: string; url?: string } } = { data: { file } };
|
|
135
|
-
if (url) target.data.url = url;
|
|
136
|
-
try {
|
|
137
|
-
return await resolvers.resolveImage(target);
|
|
138
|
-
} catch {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** 转发节点归一化结果(兼容 NapCat node 格式与 go-cqhttp 旧格式) */
|
|
144
|
-
interface ForwardNodeContent {
|
|
145
|
-
segments: unknown;
|
|
146
|
-
name: string;
|
|
147
|
-
id: string;
|
|
148
|
-
time: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** 节点正文:message 非空取 message,否则取 content(content 可能是字符串) */
|
|
152
|
-
function pickNodeContent(data: Record<string, unknown>): unknown {
|
|
153
|
-
const message = data.message;
|
|
154
|
-
if (Array.isArray(message) && message.length > 0) return message;
|
|
155
|
-
if (typeof message === 'string' && message.length > 0) return message;
|
|
156
|
-
return data.content ?? [];
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* 归一化转发节点:
|
|
161
|
-
* - NapCat:`{ type: 'node', data: { user_id, nickname, card, time, message, content } }`
|
|
162
|
-
* - go-cqhttp 旧格式:`{ sender, time, content }`
|
|
163
|
-
*/
|
|
164
|
-
function normalizeForwardNode(raw: unknown, resolvers: MessageResolvers): ForwardNodeContent {
|
|
165
|
-
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
166
|
-
const data =
|
|
167
|
-
record.type === 'node' && record.data && typeof record.data === 'object'
|
|
168
|
-
? (record.data as Record<string, unknown>)
|
|
169
|
-
: record;
|
|
170
|
-
const sender = data.sender && typeof data.sender === 'object' ? (data.sender as Record<string, unknown>) : {};
|
|
171
|
-
const rawTime = data.time ?? record.time;
|
|
172
|
-
return {
|
|
173
|
-
segments: pickNodeContent(data),
|
|
174
|
-
name: pickString([data.nickname, data.card, sender.nickname, sender.card]) || '未知',
|
|
175
|
-
id: pickString([data.user_id, sender.user_id]),
|
|
176
|
-
time: typeof rawTime === 'number' && rawTime > 0 ? resolvers.formatTime(rawTime) : ''
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** 展开转发(聊天记录):逐节点输出引用块,节点正文按 depth+1 继续解析 */
|
|
181
|
-
async function parseForward(
|
|
182
|
-
forwardId: string,
|
|
183
|
-
depth: number,
|
|
184
|
-
resolvers: MessageResolvers,
|
|
185
|
-
images: string[],
|
|
186
|
-
state: ParseState
|
|
187
|
-
): Promise<string> {
|
|
188
|
-
try {
|
|
189
|
-
const forward = await resolvers.getForward(forwardId);
|
|
190
|
-
const parts: string[] = [];
|
|
191
|
-
for (const raw of forward?.messages ?? []) {
|
|
192
|
-
if (state.nodeCount >= PARSE_LIMITS.maxNodes) break;
|
|
193
|
-
state.nodeCount++;
|
|
194
|
-
const node = normalizeForwardNode(raw, resolvers);
|
|
195
|
-
const content = await parseSegments(node.segments, depth + 1, resolvers, images, state);
|
|
196
|
-
parts.push(quoteBlock(node.name, node.id, node.time, content));
|
|
197
|
-
}
|
|
198
|
-
return parts.join('');
|
|
199
|
-
} catch {
|
|
200
|
-
return '> [转发消息获取失败]\n';
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** 展开引用:输出引用块,被引用消息按 depth+1 继续解析 */
|
|
205
|
-
async function parseReply(
|
|
206
|
-
repliedId: string,
|
|
207
|
-
depth: number,
|
|
208
|
-
resolvers: MessageResolvers,
|
|
209
|
-
images: string[],
|
|
210
|
-
state: ParseState
|
|
211
|
-
): Promise<string> {
|
|
212
|
-
try {
|
|
213
|
-
const numericId = Number(repliedId);
|
|
214
|
-
const original = await resolvers.getMsg(Number.isFinite(numericId) ? numericId : 0);
|
|
215
|
-
const name = pickString([original?.sender?.nickname]) || '未知';
|
|
216
|
-
const id = pickString([original?.sender?.user_id]);
|
|
217
|
-
const time = typeof original?.time === 'number' && original.time > 0 ? resolvers.formatTime(original.time) : '';
|
|
218
|
-
const content = await parseSegments(original?.message, depth + 1, resolvers, images, state);
|
|
219
|
-
return quoteBlock(name, id, time, content);
|
|
220
|
-
} catch {
|
|
221
|
-
return '> [引用消息获取失败]\n';
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* 解析消息段数组为可读文本:
|
|
227
|
-
* 文本 → 原文;@提及 → `@昵称`(群聊上下文,缺失回退 `@QQ号`,`all` → `@全体成员`);
|
|
228
|
-
* 图片 → `[图片]`(depth ≤ 1 且开启 forwardImages 时原图入 images);
|
|
229
|
-
* 视频/语音 → `[视频]` / `[语音]`;引用/转发 → 拉取并逐块展开(depth+1,见文件头深度表)。
|
|
230
|
-
*
|
|
231
|
-
* `images` 为调用方持有的收集数组(函数只追加);`state` 省略时按单次解析新建(昵称解析开启)。
|
|
232
|
-
*/
|
|
233
|
-
export async function parseSegments(
|
|
234
|
-
segments: unknown,
|
|
235
|
-
depth: number,
|
|
236
|
-
resolvers: MessageResolvers,
|
|
237
|
-
images: string[],
|
|
238
|
-
state?: ParseState
|
|
239
|
-
): Promise<string> {
|
|
240
|
-
if (typeof segments === 'string') return segments;
|
|
241
|
-
if (!Array.isArray(segments)) return '';
|
|
242
|
-
const current = state ?? createParseState(true);
|
|
243
|
-
const parts: string[] = [];
|
|
244
|
-
|
|
245
|
-
for (const segment of segments) {
|
|
246
|
-
if (current.segmentCount >= PARSE_LIMITS.maxSegments) break;
|
|
247
|
-
current.segmentCount++;
|
|
248
|
-
const data = segmentData(segment);
|
|
249
|
-
|
|
250
|
-
switch (segmentType(segment)) {
|
|
251
|
-
case 'text': {
|
|
252
|
-
const text = data.text;
|
|
253
|
-
if (typeof text === 'string') parts.push(text);
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
case 'at': {
|
|
257
|
-
const qq = pickString([data.qq]);
|
|
258
|
-
if (qq === 'all') parts.push('@全体成员');
|
|
259
|
-
else if (qq) parts.push(`@${(await resolveAtName(qq, resolvers, current)) ?? qq}`);
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
case 'image': {
|
|
263
|
-
parts.push('[图片]');
|
|
264
|
-
if (!resolvers.forwardImages || depth > PARSE_LIMITS.maxImageDepth) break;
|
|
265
|
-
if (images.length >= PARSE_LIMITS.maxImages) break;
|
|
266
|
-
const url = await resolveImageSafe(segment, resolvers);
|
|
267
|
-
if (url) images.push(url);
|
|
268
|
-
break;
|
|
269
|
-
}
|
|
270
|
-
case 'video':
|
|
271
|
-
parts.push('[视频]');
|
|
272
|
-
break;
|
|
273
|
-
case 'record':
|
|
274
|
-
parts.push('[语音]');
|
|
275
|
-
break;
|
|
276
|
-
case 'reply':
|
|
277
|
-
case 'forward': {
|
|
278
|
-
const id = readId(data.id);
|
|
279
|
-
if (!id) break;
|
|
280
|
-
const key = `${segmentType(segment)}:${id}`;
|
|
281
|
-
// depth ≥ 3 不拉取;已展开过的 id(含自环)不再进入,统一留占位
|
|
282
|
-
if (depth >= PARSE_LIMITS.maxDepth || current.visitedIds.has(key)) {
|
|
283
|
-
parts.push(FORWARD_PLACEHOLDER);
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
current.visitedIds.add(key);
|
|
287
|
-
parts.push(
|
|
288
|
-
segmentType(segment) === 'reply'
|
|
289
|
-
? await parseReply(id, depth, resolvers, images, current)
|
|
290
|
-
: await parseForward(id, depth, resolvers, images, current)
|
|
291
|
-
);
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
294
|
-
default:
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return parts.join('').trim();
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* 解析整条消息(触发消息本身为 depth 0):
|
|
304
|
-
* - 非数组输入(string/undefined 等)→ text 为该字符串或 '',images 为 [];
|
|
305
|
-
* - `groupId` 为 undefined(私聊)时 @提及 不查昵称,直接回退 `@QQ号`;
|
|
306
|
-
* - 图片收集上限、段/节点上限与 visitedIds 去重见 PARSE_LIMITS。
|
|
307
|
-
*/
|
|
308
|
-
export async function parseMessage(
|
|
309
|
-
message: unknown,
|
|
310
|
-
groupId: number | undefined,
|
|
311
|
-
resolvers: MessageResolvers
|
|
312
|
-
): Promise<ParsedMessage> {
|
|
313
|
-
const images: string[] = [];
|
|
314
|
-
if (typeof message === 'string') return { text: message, images };
|
|
315
|
-
if (!Array.isArray(message)) return { text: '', images };
|
|
316
|
-
const state = createParseState(groupId !== undefined);
|
|
317
|
-
const text = await parseSegments(message, 0, resolvers, images, state);
|
|
318
|
-
return { text, images };
|
|
319
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* QQ 消息段的深度解析(纯函数 + 注入式 resolver,抽离自 HermesBridge 以便冒烟测试)。
|
|
3
|
+
*
|
|
4
|
+
* 深度表(depth = 正在解析内容的层级,触发消息本身为 0):
|
|
5
|
+
* | 段类型 | depth 0 | depth 1 | depth 2 | depth ≥3 |
|
|
6
|
+
* | 图片 | `[图片]` + 原图入 images | 同左 | `[图片]`,不下载 | `[图片]`,不下载 |
|
|
7
|
+
* | reply / forward | 拉取并解析(depth+1) | 同左 | 拉取但仅文字(不下载图片) | 不拉取,输出 `[聊天记录]` |
|
|
8
|
+
* | text / at / video / record | 原样(at:群名片优先,缺失回退 `@QQ号`) | 同左 | 同左 | 同左 |
|
|
9
|
+
*
|
|
10
|
+
* 有意的取舍:交付物 5 中「引用里含聊天记录 → 节点内图片入 images」按深度表解释为
|
|
11
|
+
* forward 处于 depth 0 时其节点图片入 images;若 forward 落在 depth 1,则节点文字展开、
|
|
12
|
+
* 节点图片在 depth 2 不下载(深度表为准)。
|
|
13
|
+
*
|
|
14
|
+
* 本模块不产生任何 IO:拉取、图片下载、昵称与时间格式化全部由调用方经 MessageResolvers 注入。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 聊天记录(合并转发)在文本中的占位符 */
|
|
18
|
+
export const FORWARD_PLACEHOLDER = '[聊天记录]';
|
|
19
|
+
|
|
20
|
+
/** 解析护栏:单次解析的段/节点/图片总量与深度上限,防超长消息与自环拖垮调用方 */
|
|
21
|
+
export const PARSE_LIMITS = {
|
|
22
|
+
/** 单次解析处理的段总量上限(跨层级累计) */
|
|
23
|
+
maxSegments: 500,
|
|
24
|
+
/** 单个聊天记录展开的节点上限 */
|
|
25
|
+
maxNodes: 50,
|
|
26
|
+
/** 单次解析收集的图片上限 */
|
|
27
|
+
maxImages: 10,
|
|
28
|
+
/** 深度上限:处于该深度的 reply/forward 不再拉取,只留 `[聊天记录]` 占位 */
|
|
29
|
+
maxDepth: 3,
|
|
30
|
+
/** 下载图片的最大深度(depth 2 起只保留 `[图片]` 占位) */
|
|
31
|
+
maxImageDepth: 1
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export interface MessageResolvers {
|
|
35
|
+
/** get_msg,失败抛错 */
|
|
36
|
+
getMsg(
|
|
37
|
+
messageId: number
|
|
38
|
+
): Promise<{ sender?: { nickname?: string; user_id?: number }; time?: number; message: unknown }>;
|
|
39
|
+
/** get_forward_msg,失败抛错 */
|
|
40
|
+
getForward(forwardId: string): Promise<{ messages: unknown[] }>;
|
|
41
|
+
/** 图片段 → base64 data URL;失败/不需要时返回 null */
|
|
42
|
+
resolveImage(segment: { data: { file: string; url?: string } }): Promise<string | null>;
|
|
43
|
+
/** @提及昵称解析(可选;缺省用 QQ 号) */
|
|
44
|
+
resolveName?(qq: string): Promise<string | null>;
|
|
45
|
+
/** 时间戳格式化(注入以保持纯净) */
|
|
46
|
+
formatTime(unixTs: number): string;
|
|
47
|
+
/** 是否下载/转发图片(config.forwardImages) */
|
|
48
|
+
forwardImages: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ParsedMessage {
|
|
52
|
+
text: string;
|
|
53
|
+
images: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 单次解析的共享状态:护栏计数、去重集合与 @提及昵称缓存。
|
|
58
|
+
* 由 parseMessage 建立并传给各层 parseSegments;直接调用 parseSegments 时可省略。
|
|
59
|
+
*/
|
|
60
|
+
export interface ParseState {
|
|
61
|
+
/** 已展开的引用/转发 id,防自环与重复展开 */
|
|
62
|
+
visitedIds: Set<string>;
|
|
63
|
+
/** 跨层级累计的段计数(护栏) */
|
|
64
|
+
segmentCount: number;
|
|
65
|
+
/** 已展开的转发节点计数(护栏) */
|
|
66
|
+
nodeCount: number;
|
|
67
|
+
/** 是否解析 @提及昵称(群聊上下文才解析,私聊直接回退 QQ 号) */
|
|
68
|
+
resolveNames: boolean;
|
|
69
|
+
/** @提及昵称缓存(qq → 昵称,空串表示解析失败) */
|
|
70
|
+
nameCache: Map<string, string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 建立解析状态;resolveNames=false 时 @提及 一律回退 QQ 号 */
|
|
74
|
+
export function createParseState(resolveNames: boolean): ParseState {
|
|
75
|
+
return { visitedIds: new Set<string>(), segmentCount: 0, nodeCount: 0, resolveNames, nameCache: new Map() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 取消息段的数据体(非对象按空对象处理) */
|
|
79
|
+
function segmentData(segment: unknown): Record<string, unknown> {
|
|
80
|
+
if (!segment || typeof segment !== 'object') return {};
|
|
81
|
+
const data = (segment as { data?: unknown }).data;
|
|
82
|
+
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 取消息段类型(缺失/非字符串返回空串) */
|
|
86
|
+
function segmentType(segment: unknown): string {
|
|
87
|
+
if (!segment || typeof segment !== 'object') return '';
|
|
88
|
+
const type = (segment as { type?: unknown }).type;
|
|
89
|
+
return typeof type === 'string' ? type : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** id 字段归一化为字符串(number/string 皆可,0 与空串视为无 id) */
|
|
93
|
+
function readId(raw: unknown): string {
|
|
94
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw !== 0) return String(raw);
|
|
95
|
+
if (typeof raw === 'string' && raw.length > 0) return raw;
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 取首个非空字符串 */
|
|
100
|
+
function pickString(values: unknown[]): string {
|
|
101
|
+
for (const value of values) {
|
|
102
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
103
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 引用块 / 转发节点行的统一格式:`> {昵称}({id}) {时间}\n> {内容}\n` */
|
|
109
|
+
function quoteBlock(name: string, id: string, time: string, content: string): string {
|
|
110
|
+
return `${`> ${name}(${id}) ${time}`.trimEnd()}\n> ${content}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 解析 @提及 昵称:群聊上下文经 resolver 查询并缓存,失败回退 null(调用方用 QQ 号) */
|
|
114
|
+
async function resolveAtName(qq: string, resolvers: MessageResolvers, state: ParseState): Promise<string | null> {
|
|
115
|
+
if (!state.resolveNames || !resolvers.resolveName) return null;
|
|
116
|
+
const cached = state.nameCache.get(qq);
|
|
117
|
+
if (cached !== undefined) return cached || null;
|
|
118
|
+
try {
|
|
119
|
+
const name = await resolvers.resolveName(qq);
|
|
120
|
+
state.nameCache.set(qq, name ?? '');
|
|
121
|
+
return name ?? null;
|
|
122
|
+
} catch {
|
|
123
|
+
state.nameCache.set(qq, '');
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 解析图片段为 base64 data URL;失败/不需要时返回 null(调用方保留 `[图片]` 占位) */
|
|
129
|
+
async function resolveImageSafe(segment: unknown, resolvers: MessageResolvers): Promise<string | null> {
|
|
130
|
+
const data = segmentData(segment);
|
|
131
|
+
const file = typeof data.file === 'string' ? data.file : '';
|
|
132
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
133
|
+
if (!file && !url) return null;
|
|
134
|
+
const target: { data: { file: string; url?: string } } = { data: { file } };
|
|
135
|
+
if (url) target.data.url = url;
|
|
136
|
+
try {
|
|
137
|
+
return await resolvers.resolveImage(target);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 转发节点归一化结果(兼容 NapCat node 格式与 go-cqhttp 旧格式) */
|
|
144
|
+
interface ForwardNodeContent {
|
|
145
|
+
segments: unknown;
|
|
146
|
+
name: string;
|
|
147
|
+
id: string;
|
|
148
|
+
time: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 节点正文:message 非空取 message,否则取 content(content 可能是字符串) */
|
|
152
|
+
function pickNodeContent(data: Record<string, unknown>): unknown {
|
|
153
|
+
const message = data.message;
|
|
154
|
+
if (Array.isArray(message) && message.length > 0) return message;
|
|
155
|
+
if (typeof message === 'string' && message.length > 0) return message;
|
|
156
|
+
return data.content ?? [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 归一化转发节点:
|
|
161
|
+
* - NapCat:`{ type: 'node', data: { user_id, nickname, card, time, message, content } }`
|
|
162
|
+
* - go-cqhttp 旧格式:`{ sender, time, content }`
|
|
163
|
+
*/
|
|
164
|
+
function normalizeForwardNode(raw: unknown, resolvers: MessageResolvers): ForwardNodeContent {
|
|
165
|
+
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
166
|
+
const data =
|
|
167
|
+
record.type === 'node' && record.data && typeof record.data === 'object'
|
|
168
|
+
? (record.data as Record<string, unknown>)
|
|
169
|
+
: record;
|
|
170
|
+
const sender = data.sender && typeof data.sender === 'object' ? (data.sender as Record<string, unknown>) : {};
|
|
171
|
+
const rawTime = data.time ?? record.time;
|
|
172
|
+
return {
|
|
173
|
+
segments: pickNodeContent(data),
|
|
174
|
+
name: pickString([data.nickname, data.card, sender.nickname, sender.card]) || '未知',
|
|
175
|
+
id: pickString([data.user_id, sender.user_id]),
|
|
176
|
+
time: typeof rawTime === 'number' && rawTime > 0 ? resolvers.formatTime(rawTime) : ''
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 展开转发(聊天记录):逐节点输出引用块,节点正文按 depth+1 继续解析 */
|
|
181
|
+
async function parseForward(
|
|
182
|
+
forwardId: string,
|
|
183
|
+
depth: number,
|
|
184
|
+
resolvers: MessageResolvers,
|
|
185
|
+
images: string[],
|
|
186
|
+
state: ParseState
|
|
187
|
+
): Promise<string> {
|
|
188
|
+
try {
|
|
189
|
+
const forward = await resolvers.getForward(forwardId);
|
|
190
|
+
const parts: string[] = [];
|
|
191
|
+
for (const raw of forward?.messages ?? []) {
|
|
192
|
+
if (state.nodeCount >= PARSE_LIMITS.maxNodes) break;
|
|
193
|
+
state.nodeCount++;
|
|
194
|
+
const node = normalizeForwardNode(raw, resolvers);
|
|
195
|
+
const content = await parseSegments(node.segments, depth + 1, resolvers, images, state);
|
|
196
|
+
parts.push(quoteBlock(node.name, node.id, node.time, content));
|
|
197
|
+
}
|
|
198
|
+
return parts.join('');
|
|
199
|
+
} catch {
|
|
200
|
+
return '> [转发消息获取失败]\n';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 展开引用:输出引用块,被引用消息按 depth+1 继续解析 */
|
|
205
|
+
async function parseReply(
|
|
206
|
+
repliedId: string,
|
|
207
|
+
depth: number,
|
|
208
|
+
resolvers: MessageResolvers,
|
|
209
|
+
images: string[],
|
|
210
|
+
state: ParseState
|
|
211
|
+
): Promise<string> {
|
|
212
|
+
try {
|
|
213
|
+
const numericId = Number(repliedId);
|
|
214
|
+
const original = await resolvers.getMsg(Number.isFinite(numericId) ? numericId : 0);
|
|
215
|
+
const name = pickString([original?.sender?.nickname]) || '未知';
|
|
216
|
+
const id = pickString([original?.sender?.user_id]);
|
|
217
|
+
const time = typeof original?.time === 'number' && original.time > 0 ? resolvers.formatTime(original.time) : '';
|
|
218
|
+
const content = await parseSegments(original?.message, depth + 1, resolvers, images, state);
|
|
219
|
+
return quoteBlock(name, id, time, content);
|
|
220
|
+
} catch {
|
|
221
|
+
return '> [引用消息获取失败]\n';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 解析消息段数组为可读文本:
|
|
227
|
+
* 文本 → 原文;@提及 → `@昵称`(群聊上下文,缺失回退 `@QQ号`,`all` → `@全体成员`);
|
|
228
|
+
* 图片 → `[图片]`(depth ≤ 1 且开启 forwardImages 时原图入 images);
|
|
229
|
+
* 视频/语音 → `[视频]` / `[语音]`;引用/转发 → 拉取并逐块展开(depth+1,见文件头深度表)。
|
|
230
|
+
*
|
|
231
|
+
* `images` 为调用方持有的收集数组(函数只追加);`state` 省略时按单次解析新建(昵称解析开启)。
|
|
232
|
+
*/
|
|
233
|
+
export async function parseSegments(
|
|
234
|
+
segments: unknown,
|
|
235
|
+
depth: number,
|
|
236
|
+
resolvers: MessageResolvers,
|
|
237
|
+
images: string[],
|
|
238
|
+
state?: ParseState
|
|
239
|
+
): Promise<string> {
|
|
240
|
+
if (typeof segments === 'string') return segments;
|
|
241
|
+
if (!Array.isArray(segments)) return '';
|
|
242
|
+
const current = state ?? createParseState(true);
|
|
243
|
+
const parts: string[] = [];
|
|
244
|
+
|
|
245
|
+
for (const segment of segments) {
|
|
246
|
+
if (current.segmentCount >= PARSE_LIMITS.maxSegments) break;
|
|
247
|
+
current.segmentCount++;
|
|
248
|
+
const data = segmentData(segment);
|
|
249
|
+
|
|
250
|
+
switch (segmentType(segment)) {
|
|
251
|
+
case 'text': {
|
|
252
|
+
const text = data.text;
|
|
253
|
+
if (typeof text === 'string') parts.push(text);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'at': {
|
|
257
|
+
const qq = pickString([data.qq]);
|
|
258
|
+
if (qq === 'all') parts.push('@全体成员');
|
|
259
|
+
else if (qq) parts.push(`@${(await resolveAtName(qq, resolvers, current)) ?? qq}`);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'image': {
|
|
263
|
+
parts.push('[图片]');
|
|
264
|
+
if (!resolvers.forwardImages || depth > PARSE_LIMITS.maxImageDepth) break;
|
|
265
|
+
if (images.length >= PARSE_LIMITS.maxImages) break;
|
|
266
|
+
const url = await resolveImageSafe(segment, resolvers);
|
|
267
|
+
if (url) images.push(url);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case 'video':
|
|
271
|
+
parts.push('[视频]');
|
|
272
|
+
break;
|
|
273
|
+
case 'record':
|
|
274
|
+
parts.push('[语音]');
|
|
275
|
+
break;
|
|
276
|
+
case 'reply':
|
|
277
|
+
case 'forward': {
|
|
278
|
+
const id = readId(data.id);
|
|
279
|
+
if (!id) break;
|
|
280
|
+
const key = `${segmentType(segment)}:${id}`;
|
|
281
|
+
// depth ≥ 3 不拉取;已展开过的 id(含自环)不再进入,统一留占位
|
|
282
|
+
if (depth >= PARSE_LIMITS.maxDepth || current.visitedIds.has(key)) {
|
|
283
|
+
parts.push(FORWARD_PLACEHOLDER);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
current.visitedIds.add(key);
|
|
287
|
+
parts.push(
|
|
288
|
+
segmentType(segment) === 'reply'
|
|
289
|
+
? await parseReply(id, depth, resolvers, images, current)
|
|
290
|
+
: await parseForward(id, depth, resolvers, images, current)
|
|
291
|
+
);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return parts.join('').trim();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 解析整条消息(触发消息本身为 depth 0):
|
|
304
|
+
* - 非数组输入(string/undefined 等)→ text 为该字符串或 '',images 为 [];
|
|
305
|
+
* - `groupId` 为 undefined(私聊)时 @提及 不查昵称,直接回退 `@QQ号`;
|
|
306
|
+
* - 图片收集上限、段/节点上限与 visitedIds 去重见 PARSE_LIMITS。
|
|
307
|
+
*/
|
|
308
|
+
export async function parseMessage(
|
|
309
|
+
message: unknown,
|
|
310
|
+
groupId: number | undefined,
|
|
311
|
+
resolvers: MessageResolvers
|
|
312
|
+
): Promise<ParsedMessage> {
|
|
313
|
+
const images: string[] = [];
|
|
314
|
+
if (typeof message === 'string') return { text: message, images };
|
|
315
|
+
if (!Array.isArray(message)) return { text: '', images };
|
|
316
|
+
const state = createParseState(groupId !== undefined);
|
|
317
|
+
const text = await parseSegments(message, 0, resolvers, images, state);
|
|
318
|
+
return { text, images };
|
|
319
|
+
}
|