@dsh-plugin/dsh-synapse 0.0.0-stage → 0.1.0-dev.41044228975
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 -0
- package/NOTICE.md +18 -0
- package/README.md +136 -2
- package/cordis.patch.yml +3 -0
- package/lib/bot.cjs +8314 -0
- package/lib/bot.cjs.map +7 -0
- package/lib/build.json +1 -0
- package/lib/client.js +46 -0
- package/lib/host/agent.d.ts +21 -0
- package/lib/host/agent.js +151 -0
- package/lib/host/agent.js.map +1 -0
- package/lib/host/bot.d.ts +17 -0
- package/lib/host/bot.js +158 -0
- package/lib/host/bot.js.map +1 -0
- package/lib/host/client.d.ts +2 -0
- package/lib/host/client.js +19 -0
- package/lib/host/client.js.map +1 -0
- package/lib/host/config.d.ts +24 -0
- package/lib/host/config.js +192 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/files.d.ts +29 -0
- package/lib/host/files.js +188 -0
- package/lib/host/files.js.map +1 -0
- package/lib/host/gateway.d.ts +33 -0
- package/lib/host/gateway.js +237 -0
- package/lib/host/gateway.js.map +1 -0
- package/lib/host/github.d.ts +47 -0
- package/lib/host/github.js +219 -0
- package/lib/host/github.js.map +1 -0
- package/lib/host/http.d.ts +4 -0
- package/lib/host/http.js +41 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.d.ts +32 -0
- package/lib/host/index.js +192 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/inference-worker.d.ts +1 -0
- package/lib/host/inference-worker.js +72 -0
- package/lib/host/inference-worker.js.map +1 -0
- package/lib/host/mcp.d.ts +138 -0
- package/lib/host/mcp.js +104 -0
- package/lib/host/mcp.js.map +1 -0
- package/lib/host/memory.d.ts +26 -0
- package/lib/host/memory.js +155 -0
- package/lib/host/memory.js.map +1 -0
- package/lib/host/panel.d.ts +1 -0
- package/lib/host/panel.html +174 -0
- package/lib/host/panel.js +388 -0
- package/lib/host/panel.js.map +1 -0
- package/lib/host/runtime.d.ts +21 -0
- package/lib/host/runtime.js +145 -0
- package/lib/host/runtime.js.map +1 -0
- package/lib/host/types.d.ts +112 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/package.json +94 -4
- package/scripts/build.mjs +90 -0
- package/scripts/check.mjs +13 -0
- package/scripts/dump-npm-latest.mjs +30 -0
- package/scripts/model-smoke.mjs +25 -0
- package/src/agent.ts +203 -0
- package/src/bot.ts +173 -0
- package/src/client.ts +20 -0
- package/src/config.ts +217 -0
- package/src/files.ts +206 -0
- package/src/gateway.ts +276 -0
- package/src/github.ts +263 -0
- package/src/http.ts +41 -0
- package/src/index.ts +227 -0
- package/src/inference-worker.ts +89 -0
- package/src/mcp.ts +112 -0
- package/src/memory.ts +178 -0
- package/src/panel.html +174 -0
- package/src/panel.ts +440 -0
- package/src/runtime.ts +164 -0
- package/src/types.ts +106 -0
- package/test/core.test.mjs +279 -0
- package/test/fixtures/mcp-server.mjs +14 -0
- package/test/integration.test.mjs +253 -0
- package/test/panel.test.mjs +73 -0
- package/tsconfig.json +18 -0
- package/vendor/gugle-chat-bot/LICENSE +661 -0
- package/vendor/gugle-chat-bot/package.json +57 -0
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -0
- package/vendor/gugle-chat-bot/src/api.json +4285 -0
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -0
- package/vendor/gugle-chat-bot/src/config/features.ts +548 -0
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -0
- package/vendor/gugle-chat-bot/src/config/manager.ts +357 -0
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -0
- package/vendor/gugle-chat-bot/src/custom.ts +548 -0
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -0
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +57 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -0
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -0
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -0
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -0
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -0
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -0
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -0
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +469 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -0
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -0
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -0
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -0
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -0
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -0
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -0
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -0
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -0
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -0
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -0
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -0
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -0
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +452 -0
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -0
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +78 -0
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +58 -0
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -0
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -0
- package/vendor/gugle-chat-bot/src/index.ts +695 -0
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -0
- package/vendor/gugle-chat-bot/src/result.json +359 -0
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -0
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -0
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -0
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -0
- package/vendor/gugle-chat-bot/tsconfig.json +46 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { QQBot } from '@/index';
|
|
4
|
+
import { getHermesConfig } from '@/features/hermes/config';
|
|
5
|
+
import { writeConfigFile } from '@/config/manager';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AI「处理中」表情回应管理。
|
|
9
|
+
*
|
|
10
|
+
* 用户消息触发 Hermes 运行时,给触发消息贴上表情回应(QQ 走 NapCat
|
|
11
|
+
* set_msg_emoji_like,Discord 走 message.react);完整回复完成(含查询技能的
|
|
12
|
+
* 回喂轮)后摘除。
|
|
13
|
+
*
|
|
14
|
+
* 记录立即持久化到 data/hermes-reactions.json:若回应期间进程重启/宕机,
|
|
15
|
+
* 下次启动时按残留记录逐条摘除并清空(cleanupQQ 由 after-start 钩子调用,
|
|
16
|
+
* Discord 残留由 DiscordBridge 在 clientReady 后调用 listDiscord/drop 处理),
|
|
17
|
+
* 避免「处理中」表情永久残留。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 一条待摘除的表情回应记录 */
|
|
21
|
+
export interface PendingReaction {
|
|
22
|
+
/** 唯一键:`qq:{messageId}` / `discord:{channelId}:{messageId}` */
|
|
23
|
+
key: string;
|
|
24
|
+
platform: 'qq' | 'discord';
|
|
25
|
+
/** QQ: OneBot message_id 字符串;Discord: 消息 snowflake */
|
|
26
|
+
messageId: string;
|
|
27
|
+
/** Discord 频道 snowflake(QQ 记录无此字段) */
|
|
28
|
+
channelId?: string;
|
|
29
|
+
/** QQ: emoji_id;Discord: unicode emoji */
|
|
30
|
+
emoji: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ReactionStoreFile {
|
|
35
|
+
version: number;
|
|
36
|
+
reactions: PendingReaction[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ReactionManager {
|
|
40
|
+
private static instance?: ReactionManager;
|
|
41
|
+
|
|
42
|
+
public static getInstance(): ReactionManager {
|
|
43
|
+
if (!ReactionManager.instance) ReactionManager.instance = new ReactionManager();
|
|
44
|
+
return ReactionManager.instance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private readonly file = path.resolve(process.cwd(), 'data', 'hermes-reactions.json');
|
|
48
|
+
private readonly records = new Map<string, PendingReaction>();
|
|
49
|
+
private loaded = false;
|
|
50
|
+
|
|
51
|
+
private constructor() {}
|
|
52
|
+
|
|
53
|
+
/** 懒加载持久化记录(损坏按空处理) */
|
|
54
|
+
private load(): void {
|
|
55
|
+
if (this.loaded) return;
|
|
56
|
+
this.loaded = true;
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(this.file)) return;
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8')) as ReactionStoreFile;
|
|
60
|
+
if (!Array.isArray(raw?.reactions)) return;
|
|
61
|
+
for (const record of raw.reactions) {
|
|
62
|
+
if (record && typeof record.key === 'string' && typeof record.messageId === 'string') {
|
|
63
|
+
this.records.set(record.key, record);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// 文件损坏或格式错误,按空记录处理
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 立即落盘(原子写,复用 config/manager 的 writeConfigFile) */
|
|
72
|
+
private persist(): void {
|
|
73
|
+
try {
|
|
74
|
+
writeConfigFile(this.file, { version: 1, reactions: [...this.records.values()] } satisfies ReactionStoreFile);
|
|
75
|
+
} catch {
|
|
76
|
+
// 持久化失败不阻塞主流程
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* QQ:给消息贴「处理中」表情,成功后立即持久化记录并返回记录键。
|
|
82
|
+
* 返回 null 表示没贴上(功能关闭或 NapCat 拒绝,例如私聊消息通常不支持表情回应),
|
|
83
|
+
* 调用方据此决定是否改用文字回执;具体原因写入日志便于排查。
|
|
84
|
+
*/
|
|
85
|
+
public async addQQ(bot: QQBot, messageId: number): Promise<string | null> {
|
|
86
|
+
const config = getHermesConfig();
|
|
87
|
+
if (!config.reactionEnabled) {
|
|
88
|
+
bot.logger?.debug(`[Hermes] 表情回应已关闭,跳过(message ${messageId})`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await bot.axiosInstance.post('/set_msg_emoji_like', {
|
|
93
|
+
message_id: messageId,
|
|
94
|
+
emoji_id: config.reactionEmojiQq,
|
|
95
|
+
set: true
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
bot.logger?.warn(
|
|
99
|
+
`[Hermes] 表情回应失败(message ${messageId}): ${(error as Error).message}` +
|
|
100
|
+
`;NapCat 对私聊消息通常不支持 set_msg_emoji_like`
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const key = `qq:${messageId}`;
|
|
105
|
+
this.load();
|
|
106
|
+
this.records.set(key, {
|
|
107
|
+
key,
|
|
108
|
+
platform: 'qq',
|
|
109
|
+
messageId: String(messageId),
|
|
110
|
+
emoji: config.reactionEmojiQq,
|
|
111
|
+
createdAt: Date.now()
|
|
112
|
+
});
|
|
113
|
+
this.persist();
|
|
114
|
+
return key;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Discord:message.react 成功后调用,立即持久化记录并返回记录键。
|
|
119
|
+
*/
|
|
120
|
+
public persistDiscord(channelId: string, messageId: string, emoji: string): string {
|
|
121
|
+
const key = `discord:${channelId}:${messageId}`;
|
|
122
|
+
this.load();
|
|
123
|
+
this.records.set(key, { key, platform: 'discord', messageId, channelId, emoji, createdAt: Date.now() });
|
|
124
|
+
this.persist();
|
|
125
|
+
return key;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* QQ:摘除表情(set=false)。无论摘除成败都移除持久化记录
|
|
130
|
+
* (失败多为消息已被撤回等不可重试原因,残留记录只会在下次启动时再次失败)。
|
|
131
|
+
*/
|
|
132
|
+
public async removeQQ(bot: QQBot | undefined, key: string): Promise<void> {
|
|
133
|
+
this.load();
|
|
134
|
+
const record = this.records.get(key);
|
|
135
|
+
if (this.records.delete(key)) this.persist();
|
|
136
|
+
if (!record || !bot) return;
|
|
137
|
+
try {
|
|
138
|
+
await bot.axiosInstance.post('/set_msg_emoji_like', {
|
|
139
|
+
message_id: Number(record.messageId),
|
|
140
|
+
emoji_id: record.emoji,
|
|
141
|
+
set: false
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
bot.logger?.warn(`[Hermes] 摘除表情回应失败(${key}): ${(error as Error).message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 移除一条记录(Discord 摘除动作由调用方完成后调用) */
|
|
149
|
+
public drop(key: string): void {
|
|
150
|
+
this.load();
|
|
151
|
+
if (this.records.delete(key)) this.persist();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 全部残留的 Discord 记录(启动清理用,摘除后逐条 drop) */
|
|
155
|
+
public listDiscord(): PendingReaction[] {
|
|
156
|
+
this.load();
|
|
157
|
+
return [...this.records.values()].filter(record => record.platform === 'discord');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 启动清理:摘除全部残留的 QQ 表情回应并清空对应记录 */
|
|
161
|
+
public async cleanupQQ(bot: QQBot): Promise<void> {
|
|
162
|
+
this.load();
|
|
163
|
+
const stale = [...this.records.values()].filter(record => record.platform === 'qq');
|
|
164
|
+
for (const record of stale) {
|
|
165
|
+
try {
|
|
166
|
+
await bot.axiosInstance.post('/set_msg_emoji_like', {
|
|
167
|
+
message_id: Number(record.messageId),
|
|
168
|
+
emoji_id: record.emoji,
|
|
169
|
+
set: false
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// 消息已撤回/不存在等:放弃摘除,仅清理记录
|
|
173
|
+
}
|
|
174
|
+
this.records.delete(record.key);
|
|
175
|
+
}
|
|
176
|
+
if (stale.length > 0) {
|
|
177
|
+
this.persist();
|
|
178
|
+
bot.logger?.info(`[Hermes] 已清理 ${stale.length} 条残留的 QQ 表情回应`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Template, tryGenerateImage } from '@/image';
|
|
2
|
+
import { getHermesConfig } from '@/features/hermes/config';
|
|
3
|
+
import { RunCurrentTool, RunToolRecord } from '@/features/hermes/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hermes 进度/审批卡片渲染(移植自 qq-hermes-bridge src/renderer.ts,MIT 协议,原作者 Amorter)。
|
|
7
|
+
* HTML/CSS 拆为模板文件,渲染改用 src/image 的共享 Chrome + 串行截图队列。
|
|
8
|
+
* 渲染失败返回 null,调用方降级为纯文本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 进度卡片渲染数据 */
|
|
12
|
+
export interface ProgressCardData {
|
|
13
|
+
tools: RunToolRecord[];
|
|
14
|
+
currentTool: RunCurrentTool | null;
|
|
15
|
+
messageDelta?: string;
|
|
16
|
+
elapsed: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 审批卡片渲染数据 */
|
|
20
|
+
export interface ApprovalCardData {
|
|
21
|
+
command: string;
|
|
22
|
+
riskLevel: string;
|
|
23
|
+
toolName?: string;
|
|
24
|
+
runId?: string;
|
|
25
|
+
preview?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeHtml(text: unknown): string {
|
|
29
|
+
return String(text || '')
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatDuration(ms: number): string {
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
39
|
+
return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function render(templateName: string, template: Template): Promise<string> {
|
|
43
|
+
return new Promise<string>((resolve, reject) => {
|
|
44
|
+
const templateFile = template.file();
|
|
45
|
+
template
|
|
46
|
+
.handler()
|
|
47
|
+
.then(html => tryGenerateImage(resolve, reject, html, 500, templateFile))
|
|
48
|
+
.catch(reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 渲染进度卡片为 base64 图片;progressAsImage=false 或渲染失败时返回 null */
|
|
53
|
+
export async function renderProgressImage(data: ProgressCardData): Promise<string | null> {
|
|
54
|
+
const config = getHermesConfig();
|
|
55
|
+
if (!config.progressAsImage) return null;
|
|
56
|
+
try {
|
|
57
|
+
const toolRows = data.tools
|
|
58
|
+
.slice(-config.progressMaxTools)
|
|
59
|
+
.map(tool => {
|
|
60
|
+
const icon = tool.error ? '❌' : '✅';
|
|
61
|
+
const duration = tool.duration ? ` (${formatDuration(tool.duration)})` : '';
|
|
62
|
+
return `<div class="tool-row">
|
|
63
|
+
<span class="icon">${icon}</span>
|
|
64
|
+
<span class="name">${escapeHtml(tool.name)}</span>
|
|
65
|
+
<span class="dur">${duration}</span>
|
|
66
|
+
</div>
|
|
67
|
+
${tool.preview ? `<div class="preview">${escapeHtml(tool.preview).slice(0, 120)}</div>` : ''}`;
|
|
68
|
+
})
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
const currentHtml = data.currentTool
|
|
72
|
+
? `<div class="current-tool">
|
|
73
|
+
<span class="spinner">⏳</span>
|
|
74
|
+
<span class="name">${escapeHtml(data.currentTool.name)}</span>
|
|
75
|
+
${data.currentTool.preview ? `<div class="preview">${escapeHtml(data.currentTool.preview).slice(0, 120)}</div>` : ''}
|
|
76
|
+
</div>`
|
|
77
|
+
: '';
|
|
78
|
+
|
|
79
|
+
const previewHtml = data.messageDelta
|
|
80
|
+
? `<div class="response-preview">${escapeHtml(data.messageDelta.slice(-300))}</div>`
|
|
81
|
+
: '';
|
|
82
|
+
|
|
83
|
+
const template = Template.load('progress', 'src/features/hermes/template')
|
|
84
|
+
.arg('elapsed', escapeHtml(data.elapsed))
|
|
85
|
+
.arg('tools', toolRows)
|
|
86
|
+
.arg('current', currentHtml)
|
|
87
|
+
.arg('preview', previewHtml)
|
|
88
|
+
.arg('botName', escapeHtml(config.botName));
|
|
89
|
+
return await render('progress', template);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 渲染审批卡片为 base64 图片;渲染失败时返回 null */
|
|
96
|
+
export async function renderApprovalImage(data: ApprovalCardData): Promise<string | null> {
|
|
97
|
+
try {
|
|
98
|
+
const riskColors: Record<string, string> = { high: '#E94560', medium: '#F5C842', low: '#53D8FB' };
|
|
99
|
+
const riskColor = riskColors[data.riskLevel] || riskColors.high!;
|
|
100
|
+
|
|
101
|
+
const toolSection = data.toolName
|
|
102
|
+
? `<div class="section"><div class="label">工具</div><div class="value">${escapeHtml(data.toolName)}</div></div>`
|
|
103
|
+
: '';
|
|
104
|
+
const previewSection = data.preview
|
|
105
|
+
? `<div class="section"><div class="label">说明</div><div class="value">${escapeHtml(data.preview).slice(0, 200)}</div></div>`
|
|
106
|
+
: '';
|
|
107
|
+
|
|
108
|
+
const template = Template.load('approval', 'src/features/hermes/template')
|
|
109
|
+
.arg('riskColor', riskColor)
|
|
110
|
+
.arg('toolSection', toolSection)
|
|
111
|
+
.arg('command', escapeHtml(data.command))
|
|
112
|
+
.arg('previewSection', previewSection)
|
|
113
|
+
.arg('runId', escapeHtml(data.runId?.slice(-8) ?? ''));
|
|
114
|
+
return await render('approval', template);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|