@cxyhhhhh/qqbot-cli 0.1.0-dev.202606011703
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/README.md +239 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +25 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/chunk-WRKDI4MF.js +407 -0
- package/dist/chunk-WRKDI4MF.js.map +1 -0
- package/dist/chunk-XIJ6OSLY.js +3654 -0
- package/dist/chunk-XIJ6OSLY.js.map +1 -0
- package/dist/cli-TUC3HG75.js +14 -0
- package/dist/cli-TUC3HG75.js.map +1 -0
- package/dist/src/index.d.ts +1540 -0
- package/dist/src/index.js +11 -0
- package/dist/src/index.js.map +1 -0
- package/package.json +73 -0
- package/templates/bot.cloudagent.yaml +131 -0
- package/templates/bot.echo.yaml +55 -0
- package/templates/bot.galileo.yaml +63 -0
- package/templates/bot.openai.yaml +63 -0
|
@@ -0,0 +1,3654 @@
|
|
|
1
|
+
// src/config/loader.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
6
|
+
// src/config/schema.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
var coerceString = z.preprocess(
|
|
9
|
+
(val) => val === void 0 || val === null ? val : String(val),
|
|
10
|
+
z.string()
|
|
11
|
+
);
|
|
12
|
+
var qqSchema = z.object({
|
|
13
|
+
appId: coerceString.pipe(z.string().min(1, "qq.appId \u5FC5\u586B")),
|
|
14
|
+
appSecret: coerceString.pipe(z.string().min(1, "qq.appSecret \u5FC5\u586B")),
|
|
15
|
+
markdown: z.boolean().default(false),
|
|
16
|
+
/** 自定义 User-Agent(默认 qqbot-cli/<version>) */
|
|
17
|
+
userAgent: z.string().optional(),
|
|
18
|
+
/** QQ Open Platform API 基址(默认 https://api.sgroup.qq.com) */
|
|
19
|
+
baseUrl: z.string().optional(),
|
|
20
|
+
/** Token 接口基址(默认 https://bots.qq.com) */
|
|
21
|
+
tokenBaseUrl: z.string().optional(),
|
|
22
|
+
/**
|
|
23
|
+
* 启动时 Token 获取策略。
|
|
24
|
+
* - "sync" (默认): 阻塞等待 token 获取成功后再接受流量,凭证错误立即暴露。
|
|
25
|
+
* - "async": 后台异步获取 token,服务更快启动,但首次请求可能失败。
|
|
26
|
+
*/
|
|
27
|
+
tokenPrefetch: z.enum(["sync", "async"]).default("sync"),
|
|
28
|
+
/**
|
|
29
|
+
* 事件传输模式。
|
|
30
|
+
* - "websocket" (默认): WS 长连(心跳/重连/RESUME)
|
|
31
|
+
* - "webhook": QQ 平台 POST 回调到你的 HTTPS 端点
|
|
32
|
+
*/
|
|
33
|
+
transport: z.enum(["websocket", "webhook"]).default("websocket"),
|
|
34
|
+
/** Webhook 模式配置(仅 transport=webhook 时生效) */
|
|
35
|
+
webhook: z.object({
|
|
36
|
+
/** 监听端口,默认 8080 */
|
|
37
|
+
port: z.number().default(8080),
|
|
38
|
+
/** 监听路径,默认 "/" */
|
|
39
|
+
path: z.string().default("/callback")
|
|
40
|
+
}).default({})
|
|
41
|
+
});
|
|
42
|
+
var cloudagentSchema = z.object({
|
|
43
|
+
apiKey: z.string().optional(),
|
|
44
|
+
endpoint: z.string().default("https://www.codebuddy.cn/v2"),
|
|
45
|
+
sandbox: z.object({
|
|
46
|
+
/** auto: 自动创建 Runtime | manual: 指定 runtimeId | direct: 直连 ACP 端点 */
|
|
47
|
+
mode: z.enum(["auto", "manual", "direct"]).default("auto"),
|
|
48
|
+
runtimeName: z.string().optional(),
|
|
49
|
+
runtimeId: z.string().optional(),
|
|
50
|
+
/** direct 模式:ACP 端点 URL(如 http://localhost:65225/acp) */
|
|
51
|
+
acpEndpoint: z.string().optional(),
|
|
52
|
+
/** direct 模式:ACP 认证 token */
|
|
53
|
+
acpToken: z.string().optional()
|
|
54
|
+
}).default({}),
|
|
55
|
+
manifest: z.object({
|
|
56
|
+
systemPrompt: z.string().optional(),
|
|
57
|
+
systemPromptFile: z.string().optional()
|
|
58
|
+
}).default({}),
|
|
59
|
+
acp: z.object({
|
|
60
|
+
lazyConnect: z.boolean().default(true),
|
|
61
|
+
maxRetries: z.number().default(20),
|
|
62
|
+
retryIntervalMs: z.number().default(3e3),
|
|
63
|
+
/** 等待下一个 ACP 事件的超时时间(ms),工具调用可能耗时较长。默认 180s */
|
|
64
|
+
eventTimeoutMs: z.number().default(18e4)
|
|
65
|
+
}).default({}),
|
|
66
|
+
/** 多用户 Session 连接池配置 */
|
|
67
|
+
session: z.object({
|
|
68
|
+
/** 最大并发连接数。默认 20 */
|
|
69
|
+
maxConnections: z.number().default(20),
|
|
70
|
+
/** 是否启用空闲清理。默认 false */
|
|
71
|
+
enableCleanup: z.boolean().default(false),
|
|
72
|
+
/** 连接空闲超时(ms)。默认 30min */
|
|
73
|
+
idleTimeoutMs: z.number().default(18e5),
|
|
74
|
+
/** 清理扫描间隔(ms)。默认 60s */
|
|
75
|
+
cleanupIntervalMs: z.number().default(6e4),
|
|
76
|
+
/** 淘汰时保留服务端 session(下次自动恢复上下文)。默认 true */
|
|
77
|
+
preserveOnEvict: z.boolean().default(true)
|
|
78
|
+
}).default({}),
|
|
79
|
+
/** 额外的 MCP Server,和内置的一起传给 ACP session */
|
|
80
|
+
mcpServers: z.array(z.object({
|
|
81
|
+
type: z.enum(["http", "sse"]).default("http"),
|
|
82
|
+
name: z.string(),
|
|
83
|
+
url: z.string(),
|
|
84
|
+
headers: z.record(z.string()).default({})
|
|
85
|
+
})).default([])
|
|
86
|
+
});
|
|
87
|
+
var openaiSchema = z.object({
|
|
88
|
+
apiKey: z.string().min(1, "backend.openai.apiKey \u5FC5\u586B"),
|
|
89
|
+
baseUrl: z.string().default("https://api.openai.com/v1"),
|
|
90
|
+
model: z.string().default("gpt-4o"),
|
|
91
|
+
systemPrompt: z.string().default("\u4F60\u662F\u4E00\u4E2A QQ \u673A\u5668\u4EBA\u52A9\u624B\u3002"),
|
|
92
|
+
maxTokens: z.number().default(2048),
|
|
93
|
+
temperature: z.number().default(0.7)
|
|
94
|
+
});
|
|
95
|
+
var backendSchema = z.object({
|
|
96
|
+
type: z.enum(["echo", "openai", "cloudagent"]).default("echo"),
|
|
97
|
+
cloudagent: cloudagentSchema.optional(),
|
|
98
|
+
openai: openaiSchema.optional()
|
|
99
|
+
});
|
|
100
|
+
var middlewareSchema = z.object({
|
|
101
|
+
messageFilter: z.object({
|
|
102
|
+
skipSelfEcho: z.boolean().default(true),
|
|
103
|
+
dedup: z.object({ windowMs: z.number().default(5e3) }).default({})
|
|
104
|
+
}).default({}),
|
|
105
|
+
contentSanitizer: z.object({
|
|
106
|
+
stripBotMention: z.boolean().default(true),
|
|
107
|
+
collapseWhitespace: z.boolean().default(true)
|
|
108
|
+
}).default({}),
|
|
109
|
+
mentionGate: z.object({
|
|
110
|
+
requireMentionInGroup: z.boolean().default(true),
|
|
111
|
+
alwaysAnswerC2C: z.boolean().default(true)
|
|
112
|
+
}).default({}),
|
|
113
|
+
rateLimiter: z.object({
|
|
114
|
+
enabled: z.boolean().default(false),
|
|
115
|
+
perSender: z.object({ max: z.number().default(5), windowMs: z.number().default(1e4) }).default({}),
|
|
116
|
+
global: z.object({ max: z.number().default(50), windowMs: z.number().default(6e4) }).default({})
|
|
117
|
+
}).default({}),
|
|
118
|
+
typingIndicator: z.object({
|
|
119
|
+
enabled: z.boolean().default(true),
|
|
120
|
+
/** typing 指示器持续时间(秒)。默认 15 */
|
|
121
|
+
durationSec: z.number().default(15),
|
|
122
|
+
/** keepAlive 重发间隔(ms)。默认根据 durationSec 自动计算 */
|
|
123
|
+
keepAliveIntervalMs: z.number().optional()
|
|
124
|
+
}).default({}).transform((raw) => ({
|
|
125
|
+
...raw,
|
|
126
|
+
// 自动计算:比 durationSec 提前 5s 重发,确保 typing 状态不中断
|
|
127
|
+
keepAliveIntervalMs: raw.keepAliveIntervalMs ?? Math.max((raw.durationSec - 5) * 1e3, 5e3)
|
|
128
|
+
})),
|
|
129
|
+
slashCommands: z.object({
|
|
130
|
+
enabled: z.boolean().default(true),
|
|
131
|
+
prefixes: z.array(z.string()).default(["/", "!"]),
|
|
132
|
+
/** 指令白名单(用户 OpenID 列表)。为空则所有人可用。 */
|
|
133
|
+
allowFrom: z.array(z.string().nullable()).default([]).transform(
|
|
134
|
+
(arr) => arr.filter((v) => v != null && v !== "")
|
|
135
|
+
)
|
|
136
|
+
}).default({}),
|
|
137
|
+
concurrency: z.object({
|
|
138
|
+
/** Strategy: queue | drop | abort | merge. Default: merge. */
|
|
139
|
+
strategy: z.enum(["queue", "drop", "abort", "merge"]).default("merge"),
|
|
140
|
+
/** Max queued messages per target. Default: 50. */
|
|
141
|
+
maxQueue: z.number().default(50)
|
|
142
|
+
}).default({})
|
|
143
|
+
}).default({});
|
|
144
|
+
var displaySchema = z.object({
|
|
145
|
+
/** Preset: full | compact | minimal | text-only */
|
|
146
|
+
preset: z.enum(["full", "compact", "minimal", "text-only"]).default("minimal"),
|
|
147
|
+
/** Show tool calls */
|
|
148
|
+
tool: z.boolean().optional(),
|
|
149
|
+
/** Show agent thought chunks */
|
|
150
|
+
thought: z.boolean().optional(),
|
|
151
|
+
/** Show execution plan */
|
|
152
|
+
plan: z.boolean().optional(),
|
|
153
|
+
/** Show emoji before tool name */
|
|
154
|
+
toolEmoji: z.boolean().optional(),
|
|
155
|
+
/** Show tool kind label (Search/Fetch/...) */
|
|
156
|
+
toolKind: z.boolean().optional(),
|
|
157
|
+
/** Show tool title (URL, query, command, ...) */
|
|
158
|
+
toolTitle: z.boolean().optional(),
|
|
159
|
+
/** Max display length for tool detail when truncation is enabled. Default: 120 */
|
|
160
|
+
toolDetailMaxLength: z.number().optional(),
|
|
161
|
+
/** Tool kinds whose detail should be truncated. Default: [] (no truncation) */
|
|
162
|
+
toolDetailTruncate: z.array(z.string()).optional()
|
|
163
|
+
}).default({}).transform((raw) => {
|
|
164
|
+
const base = DISPLAY_PRESET_DEFAULTS[raw.preset] ?? DISPLAY_PRESET_DEFAULTS["compact"];
|
|
165
|
+
return {
|
|
166
|
+
preset: raw.preset,
|
|
167
|
+
tool: raw.tool ?? base.tool,
|
|
168
|
+
thought: raw.thought ?? base.thought,
|
|
169
|
+
plan: raw.plan ?? base.plan,
|
|
170
|
+
toolEmoji: raw.toolEmoji ?? base.toolEmoji,
|
|
171
|
+
toolKind: raw.toolKind ?? base.toolKind,
|
|
172
|
+
toolTitle: raw.toolTitle ?? base.toolTitle,
|
|
173
|
+
toolDetailMaxLength: raw.toolDetailMaxLength,
|
|
174
|
+
toolDetailTruncate: raw.toolDetailTruncate
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
var DISPLAY_PRESET_DEFAULTS = {
|
|
178
|
+
"full": { tool: true, thought: true, plan: true, toolEmoji: true, toolKind: true, toolTitle: true },
|
|
179
|
+
"compact": { tool: true, thought: false, plan: true, toolEmoji: true, toolKind: true, toolTitle: true },
|
|
180
|
+
"minimal": { tool: true, thought: false, plan: false, toolEmoji: true, toolKind: true, toolTitle: false },
|
|
181
|
+
"text-only": { tool: false, thought: false, plan: false, toolEmoji: false, toolKind: false, toolTitle: false }
|
|
182
|
+
};
|
|
183
|
+
function expandDisplayPreset(preset) {
|
|
184
|
+
const base = DISPLAY_PRESET_DEFAULTS[preset] ?? DISPLAY_PRESET_DEFAULTS["compact"];
|
|
185
|
+
return { preset, ...base, toolDetailMaxLength: void 0, toolDetailTruncate: void 0 };
|
|
186
|
+
}
|
|
187
|
+
var streamingSchema = z.object({
|
|
188
|
+
c2c: z.boolean().default(true),
|
|
189
|
+
throttleMs: z.number().default(500)
|
|
190
|
+
}).default({});
|
|
191
|
+
var messageSchema = z.object({
|
|
192
|
+
/** 展示样式配置 */
|
|
193
|
+
display: displaySchema,
|
|
194
|
+
/** 异常兜底文案配置 */
|
|
195
|
+
errorMessages: z.object({
|
|
196
|
+
/**
|
|
197
|
+
* 错误匹配规则,按顺序匹配。
|
|
198
|
+
* 每条 rule: match 为关键词数组(任一命中即匹配,大小写不敏感),reply 为回复文案。
|
|
199
|
+
* 可选 hint: 追加在 reply 后的排查指引(覆盖全局 troubleshootHint)。
|
|
200
|
+
*/
|
|
201
|
+
rules: z.array(z.object({
|
|
202
|
+
match: z.array(z.string()),
|
|
203
|
+
reply: z.string(),
|
|
204
|
+
hint: z.string().optional()
|
|
205
|
+
})).default([]),
|
|
206
|
+
/** Agent 未生成回复 */
|
|
207
|
+
emptyReply: z.string().default("\u26A0\uFE0F Agent \u672A\u751F\u6210\u56DE\u590D\u5185\u5BB9\uFF0C\u8BF7\u6362\u4E2A\u65B9\u5F0F\u63CF\u8FF0\u6216\u91CD\u8BD5\u3002"),
|
|
208
|
+
/** 所有规则都未命中时的兜底 */
|
|
209
|
+
unknown: z.string().default("\u26A0\uFE0F \u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"),
|
|
210
|
+
/** 全局排查指引,追加在所有错误消息后面(rule 级别 hint 优先) */
|
|
211
|
+
troubleshootHint: z.string().optional(),
|
|
212
|
+
/** 调试模式:错误消息中追加原始 errMsg。默认 false */
|
|
213
|
+
debug: z.boolean().default(false)
|
|
214
|
+
}).default({})
|
|
215
|
+
}).default({});
|
|
216
|
+
var sessionSchema = z.object({
|
|
217
|
+
isolation: z.enum(["per-qualifier", "global"]).default("per-qualifier"),
|
|
218
|
+
persistence: z.object({
|
|
219
|
+
type: z.enum(["file", "memory"]).default("file"),
|
|
220
|
+
dir: z.string().default("./.qqbot-data")
|
|
221
|
+
}).default({})
|
|
222
|
+
}).default({});
|
|
223
|
+
var logSchema = z.object({
|
|
224
|
+
level: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
225
|
+
/** 控制台输出格式:pretty(人类可读)| json(结构化)。默认 pretty */
|
|
226
|
+
console: z.enum(["json", "pretty"]).default("pretty"),
|
|
227
|
+
/** 文件日志(滚动) */
|
|
228
|
+
file: z.object({
|
|
229
|
+
enabled: z.boolean().default(false),
|
|
230
|
+
dir: z.string().default("./logs"),
|
|
231
|
+
/** 单文件最大大小,支持 "10m" "1g" 等 */
|
|
232
|
+
maxSize: z.string().default("10m"),
|
|
233
|
+
/** 保留文件数 */
|
|
234
|
+
maxFiles: z.number().default(7),
|
|
235
|
+
/** 按时间滚动:daily | hourly | weekly */
|
|
236
|
+
frequency: z.enum(["daily", "hourly", "weekly"]).optional(),
|
|
237
|
+
/** 文件名日期格式(需配合 frequency),如 "yyyy-MM-dd" */
|
|
238
|
+
dateFormat: z.string().optional(),
|
|
239
|
+
/** 创建 current.log 符号链接指向当前活跃文件 */
|
|
240
|
+
symlink: z.boolean().default(false)
|
|
241
|
+
}).default({})
|
|
242
|
+
}).default({});
|
|
243
|
+
var mcpSchema = z.object({
|
|
244
|
+
enabled: z.boolean().default(false),
|
|
245
|
+
/** MCP Server 监听地址,默认 127.0.0.1;Docker 容器内需设为 0.0.0.0 */
|
|
246
|
+
host: z.string().default("127.0.0.1"),
|
|
247
|
+
/** MCP Server 监听端口,0 为随机分配 */
|
|
248
|
+
port: z.number().int().min(0).default(0),
|
|
249
|
+
/** 文件路径前缀,拼接在工具接收到的 file_path 之前,用于容器卷映射 */
|
|
250
|
+
pathPrefix: z.string().default("")
|
|
251
|
+
}).default({ enabled: false, host: "127.0.0.1", port: 0, pathPrefix: "" });
|
|
252
|
+
var openApiParamSchema = z.object({
|
|
253
|
+
name: z.string(),
|
|
254
|
+
type: z.enum(["string", "number", "boolean", "string[]", "number[]"]).default("string"),
|
|
255
|
+
desc: z.string(),
|
|
256
|
+
required: z.boolean().default(false),
|
|
257
|
+
default: z.unknown().optional()
|
|
258
|
+
});
|
|
259
|
+
var openApiItemSchema = z.object({
|
|
260
|
+
name: z.string(),
|
|
261
|
+
desc: z.string(),
|
|
262
|
+
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).default("POST"),
|
|
263
|
+
path: z.string(),
|
|
264
|
+
fixed_body: z.record(z.unknown()).optional(),
|
|
265
|
+
params: z.array(openApiParamSchema).default([]),
|
|
266
|
+
param_mapping: z.record(z.string()).optional()
|
|
267
|
+
});
|
|
268
|
+
var openApiSchema = z.object({
|
|
269
|
+
/** 全局超时(ms),默认 10000 */
|
|
270
|
+
timeoutMs: z.number().default(1e4),
|
|
271
|
+
/** 接口声明列表 */
|
|
272
|
+
apis: z.array(openApiItemSchema).default([])
|
|
273
|
+
}).optional();
|
|
274
|
+
var telemetrySchema = z.object({
|
|
275
|
+
enabled: z.boolean().default(false),
|
|
276
|
+
/** 开启 OTEL 内部诊断日志和控制台指标输出 */
|
|
277
|
+
debug: z.boolean().default(false),
|
|
278
|
+
serviceName: z.string().default("qqbot-cli"),
|
|
279
|
+
endpoint: z.string().default("http://localhost:4318"),
|
|
280
|
+
protocol: z.enum(["http", "grpc"]).default("http"),
|
|
281
|
+
sampleRate: z.number().min(0).max(1).default(1),
|
|
282
|
+
exportIntervalMs: z.number().default(3e4),
|
|
283
|
+
attributes: z.record(z.string()).default({}),
|
|
284
|
+
/** 平台扩展配置 */
|
|
285
|
+
galileo: z.object({
|
|
286
|
+
platform: z.string(),
|
|
287
|
+
app: z.string(),
|
|
288
|
+
server: z.string(),
|
|
289
|
+
namespace: z.string().default("Production"),
|
|
290
|
+
envName: z.string().default("formal")
|
|
291
|
+
}).optional()
|
|
292
|
+
}).default({});
|
|
293
|
+
var botConfigSchema = z.object({
|
|
294
|
+
qq: qqSchema,
|
|
295
|
+
backend: backendSchema,
|
|
296
|
+
middleware: middlewareSchema,
|
|
297
|
+
streaming: streamingSchema,
|
|
298
|
+
message: messageSchema,
|
|
299
|
+
session: sessionSchema,
|
|
300
|
+
log: logSchema,
|
|
301
|
+
mcp: mcpSchema,
|
|
302
|
+
openapi: openApiSchema,
|
|
303
|
+
telemetry: telemetrySchema
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// src/config/loader.ts
|
|
307
|
+
function interpolateEnv(text) {
|
|
308
|
+
return text.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
309
|
+
const match = expr.match(/^([^:}-]+)(?::-([\s\S]*))?$/);
|
|
310
|
+
if (!match) return "";
|
|
311
|
+
const varName = match[1].trim();
|
|
312
|
+
const defaultVal = match[2] ?? "";
|
|
313
|
+
const val = process.env[varName];
|
|
314
|
+
if (val === void 0 || val === "") {
|
|
315
|
+
return defaultVal;
|
|
316
|
+
}
|
|
317
|
+
return val;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async function loadConfig(configPath) {
|
|
321
|
+
const resolved = path.resolve(configPath);
|
|
322
|
+
if (!fs.existsSync(resolved)) {
|
|
323
|
+
throw new Error(`\u914D\u7F6E\u6587\u4EF6\u4E0D\u5B58\u5728: ${resolved}
|
|
324
|
+
\u63D0\u793A: \u8FD0\u884C qqbot-cli init \u751F\u6210\u6A21\u677F\u914D\u7F6E`);
|
|
325
|
+
}
|
|
326
|
+
const raw = fs.readFileSync(resolved, "utf-8");
|
|
327
|
+
const interpolated = interpolateEnv(raw);
|
|
328
|
+
let data;
|
|
329
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
330
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
331
|
+
data = parseYaml(interpolated);
|
|
332
|
+
} else if (ext === ".json" || ext === ".jsonc") {
|
|
333
|
+
data = JSON.parse(interpolated);
|
|
334
|
+
} else {
|
|
335
|
+
try {
|
|
336
|
+
data = parseYaml(interpolated);
|
|
337
|
+
} catch {
|
|
338
|
+
data = JSON.parse(interpolated);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const result = botConfigSchema.safeParse(data);
|
|
342
|
+
if (!result.success) {
|
|
343
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
344
|
+
throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25:
|
|
345
|
+
${issues}`);
|
|
346
|
+
}
|
|
347
|
+
const config = result.data;
|
|
348
|
+
if (config.backend.type === "cloudagent" && !config.backend.cloudagent) {
|
|
349
|
+
throw new Error("backend.type=cloudagent \u65F6\uFF0Cbackend.cloudagent \u914D\u7F6E\u5757\u5FC5\u586B");
|
|
350
|
+
}
|
|
351
|
+
if (config.backend.type === "openai" && !config.backend.openai) {
|
|
352
|
+
throw new Error("backend.type=openai \u65F6\uFF0Cbackend.openai \u914D\u7F6E\u5757\u5FC5\u586B");
|
|
353
|
+
}
|
|
354
|
+
return config;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/config/watcher.ts
|
|
358
|
+
import * as fs2 from "fs";
|
|
359
|
+
import { parseDocument } from "yaml";
|
|
360
|
+
var ConfigWatcher = class {
|
|
361
|
+
watcher = null;
|
|
362
|
+
debounceTimer = null;
|
|
363
|
+
currentConfig;
|
|
364
|
+
opts;
|
|
365
|
+
paused = false;
|
|
366
|
+
constructor(opts) {
|
|
367
|
+
this.opts = opts;
|
|
368
|
+
this.currentConfig = opts.currentConfig;
|
|
369
|
+
}
|
|
370
|
+
start() {
|
|
371
|
+
const { configPath, logger } = this.opts;
|
|
372
|
+
const debounceMs = this.opts.debounceMs ?? 500;
|
|
373
|
+
try {
|
|
374
|
+
this.watcher = fs2.watch(configPath, () => {
|
|
375
|
+
if (this.paused) return;
|
|
376
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
377
|
+
this.debounceTimer = setTimeout(() => this.reload(), debounceMs);
|
|
378
|
+
});
|
|
379
|
+
logger.info(`[config] watching ${configPath} for changes`);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.warn?.(
|
|
382
|
+
`[config] failed to watch ${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
stop() {
|
|
387
|
+
if (this.debounceTimer) {
|
|
388
|
+
clearTimeout(this.debounceTimer);
|
|
389
|
+
this.debounceTimer = null;
|
|
390
|
+
}
|
|
391
|
+
if (this.watcher) {
|
|
392
|
+
this.watcher.close();
|
|
393
|
+
this.watcher = null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* 将指定的键值对写回 YAML 配置文件。
|
|
398
|
+
*
|
|
399
|
+
* 使用 yaml `parseDocument` 保留原始注释和格式,
|
|
400
|
+
* 仅修改目标字段,然后重新序列化写入。
|
|
401
|
+
*
|
|
402
|
+
* 写入期间自动暂停文件监听,避免触发循环重载。
|
|
403
|
+
*
|
|
404
|
+
* @param patches - 要写入的路径-值映射,路径用点号分隔(如 "message.display.preset")
|
|
405
|
+
* @param currentConfig - 当前内存中的最新配置,同步到 watcher 防止 reload 回退
|
|
406
|
+
*/
|
|
407
|
+
writeBack(patches, currentConfig) {
|
|
408
|
+
const { configPath, logger } = this.opts;
|
|
409
|
+
try {
|
|
410
|
+
const raw = fs2.readFileSync(configPath, "utf-8");
|
|
411
|
+
const doc = parseDocument(raw);
|
|
412
|
+
for (const [dotPath, value] of Object.entries(patches)) {
|
|
413
|
+
const keys = dotPath.split(".");
|
|
414
|
+
doc.setIn(keys, value);
|
|
415
|
+
}
|
|
416
|
+
this.paused = true;
|
|
417
|
+
fs2.writeFileSync(configPath, doc.toString(), "utf-8");
|
|
418
|
+
logger.info(`[config] wrote back: ${Object.keys(patches).join(", ")}`);
|
|
419
|
+
if (currentConfig) {
|
|
420
|
+
this.currentConfig = currentConfig;
|
|
421
|
+
}
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
this.paused = false;
|
|
424
|
+
}, 1500);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
this.paused = false;
|
|
427
|
+
logger.error(
|
|
428
|
+
`[config] writeBack failed: ${err instanceof Error ? err.message : String(err)}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async reload() {
|
|
433
|
+
const { configPath, logger, onChange } = this.opts;
|
|
434
|
+
try {
|
|
435
|
+
const newConfig = await loadConfig(configPath);
|
|
436
|
+
const oldConfig = this.currentConfig;
|
|
437
|
+
this.currentConfig = newConfig;
|
|
438
|
+
logger.info("[config] reloaded successfully");
|
|
439
|
+
onChange(newConfig, oldConfig);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
logger.error(
|
|
442
|
+
`[config] reload failed, keeping old config: ${err instanceof Error ? err.message : String(err)}`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/runner.ts
|
|
449
|
+
import { createRequire } from "module";
|
|
450
|
+
import {
|
|
451
|
+
QQBot,
|
|
452
|
+
messageFilter,
|
|
453
|
+
contentSanitizer,
|
|
454
|
+
mentionGate,
|
|
455
|
+
rateLimiter,
|
|
456
|
+
concurrencyGuard,
|
|
457
|
+
quoteRef,
|
|
458
|
+
typingIndicator,
|
|
459
|
+
envelopeFormatter,
|
|
460
|
+
errorHandler
|
|
461
|
+
} from "@tencent/qqbot-nodejs";
|
|
462
|
+
|
|
463
|
+
// src/backend/echo.ts
|
|
464
|
+
var EchoBackend = class {
|
|
465
|
+
name = "echo";
|
|
466
|
+
async init() {
|
|
467
|
+
}
|
|
468
|
+
async getOrCreateSession(qualifier) {
|
|
469
|
+
return `echo-${qualifier}`;
|
|
470
|
+
}
|
|
471
|
+
async *chat(params) {
|
|
472
|
+
const reply = params.text ? `Echo: ${params.text}` : "(\u7A7A\u6D88\u606F)";
|
|
473
|
+
let buffer = "";
|
|
474
|
+
for (const char of reply) {
|
|
475
|
+
buffer += char;
|
|
476
|
+
yield { type: "text", content: char };
|
|
477
|
+
await sleep(50);
|
|
478
|
+
}
|
|
479
|
+
yield { type: "done", content: "", stopReason: "end" };
|
|
480
|
+
}
|
|
481
|
+
async shutdown() {
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
function sleep(ms) {
|
|
485
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/telemetry/metrics.ts
|
|
489
|
+
import { metrics, ValueType } from "@opentelemetry/api";
|
|
490
|
+
|
|
491
|
+
// src/telemetry/constants.ts
|
|
492
|
+
var GALILEO_RESOURCE = {
|
|
493
|
+
TARGET: "target",
|
|
494
|
+
SERVICE_NAME: "service_name",
|
|
495
|
+
NAMESPACE: "namespace",
|
|
496
|
+
ENV_NAME: "env_name",
|
|
497
|
+
INSTANCE: "instance",
|
|
498
|
+
CONTAINER_NAME: "container_name",
|
|
499
|
+
VERSION: "version",
|
|
500
|
+
CON_SETID: "con_setid",
|
|
501
|
+
APP_ID: "app_id",
|
|
502
|
+
SDK_LANGUAGE: "telemetry.sdk.language",
|
|
503
|
+
SDK_NAME: "telemetry.sdk.name"
|
|
504
|
+
};
|
|
505
|
+
var GALILEO_METRICS = {
|
|
506
|
+
/** 被调处理耗时 */
|
|
507
|
+
SERVER_HANDLED_SECONDS: "rpc_server_handled_seconds",
|
|
508
|
+
/** 被调请求计数 */
|
|
509
|
+
SERVER_STARTED_TOTAL: "rpc_server_started_total",
|
|
510
|
+
/** 被调完成计数 */
|
|
511
|
+
SERVER_HANDLED_TOTAL: "rpc_server_handled_total",
|
|
512
|
+
/** 主调耗时 */
|
|
513
|
+
CLIENT_HANDLED_SECONDS: "rpc_client_handled_seconds",
|
|
514
|
+
/** 主调请求计数 */
|
|
515
|
+
CLIENT_STARTED_TOTAL: "rpc_client_started_total",
|
|
516
|
+
/** 主调完成计数 */
|
|
517
|
+
CLIENT_HANDLED_TOTAL: "rpc_client_handled_total"
|
|
518
|
+
};
|
|
519
|
+
var METRIC_ATTRS = {
|
|
520
|
+
CALLER_SERVICE: "caller_service",
|
|
521
|
+
CALLER_METHOD: "caller_method",
|
|
522
|
+
CALLER_CON_SETID: "caller_con_setid",
|
|
523
|
+
CALLEE_SERVICE: "callee_service",
|
|
524
|
+
CALLEE_METHOD: "callee_method",
|
|
525
|
+
CALLEE_CON_SETID: "callee_con_setid",
|
|
526
|
+
CALLEE_IP: "callee_ip",
|
|
527
|
+
CALLEE_CONTAINER: "callee_container",
|
|
528
|
+
CALLER_SERVER: "caller_server",
|
|
529
|
+
CALLEE_SERVER: "callee_server",
|
|
530
|
+
CODE: "code",
|
|
531
|
+
CODE_TYPE: "code_type",
|
|
532
|
+
CALLER_GROUP: "caller_group",
|
|
533
|
+
USER_EXT1: "user_ext1",
|
|
534
|
+
USER_EXT2: "user_ext2",
|
|
535
|
+
USER_EXT3: "user_ext3"
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/telemetry/metrics.ts
|
|
539
|
+
var bizDefaultAttrs = {};
|
|
540
|
+
function setBizDefaultAttrs(attrs) {
|
|
541
|
+
bizDefaultAttrs = attrs;
|
|
542
|
+
}
|
|
543
|
+
function fillBizAttrs(attrs) {
|
|
544
|
+
return { ...bizDefaultAttrs, ...attrs };
|
|
545
|
+
}
|
|
546
|
+
function getServerMeter() {
|
|
547
|
+
return metrics.getMeter("server_metrics");
|
|
548
|
+
}
|
|
549
|
+
function getClientMeter() {
|
|
550
|
+
return metrics.getMeter("client_metrics");
|
|
551
|
+
}
|
|
552
|
+
function getBizMeter() {
|
|
553
|
+
return metrics.getMeter("qqbot_biz");
|
|
554
|
+
}
|
|
555
|
+
function fillClientAttrs(attrs) {
|
|
556
|
+
return {
|
|
557
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "",
|
|
558
|
+
[METRIC_ATTRS.CALLER_METHOD]: "",
|
|
559
|
+
[METRIC_ATTRS.CALLER_CON_SETID]: "",
|
|
560
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: "",
|
|
561
|
+
[METRIC_ATTRS.CALLEE_METHOD]: "",
|
|
562
|
+
[METRIC_ATTRS.CALLEE_CON_SETID]: "",
|
|
563
|
+
[METRIC_ATTRS.CALLEE_IP]: "",
|
|
564
|
+
[METRIC_ATTRS.CALLEE_CONTAINER]: "",
|
|
565
|
+
[METRIC_ATTRS.CODE]: 0,
|
|
566
|
+
[METRIC_ATTRS.CODE_TYPE]: "",
|
|
567
|
+
[METRIC_ATTRS.CALLER_GROUP]: "",
|
|
568
|
+
[METRIC_ATTRS.USER_EXT1]: "",
|
|
569
|
+
[METRIC_ATTRS.USER_EXT2]: "",
|
|
570
|
+
[METRIC_ATTRS.USER_EXT3]: "",
|
|
571
|
+
[METRIC_ATTRS.CALLER_SERVER]: "",
|
|
572
|
+
[METRIC_ATTRS.CALLEE_SERVER]: "",
|
|
573
|
+
...attrs
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function fillServerAttrs(attrs) {
|
|
577
|
+
return {
|
|
578
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "",
|
|
579
|
+
[METRIC_ATTRS.CALLER_METHOD]: "",
|
|
580
|
+
[METRIC_ATTRS.CALLER_CON_SETID]: "",
|
|
581
|
+
caller_ip: "",
|
|
582
|
+
caller_container: "",
|
|
583
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: "",
|
|
584
|
+
[METRIC_ATTRS.CALLEE_METHOD]: "",
|
|
585
|
+
[METRIC_ATTRS.CALLEE_CON_SETID]: "",
|
|
586
|
+
[METRIC_ATTRS.CODE]: 0,
|
|
587
|
+
[METRIC_ATTRS.CODE_TYPE]: "",
|
|
588
|
+
[METRIC_ATTRS.CALLER_GROUP]: "",
|
|
589
|
+
[METRIC_ATTRS.USER_EXT1]: "",
|
|
590
|
+
[METRIC_ATTRS.USER_EXT2]: "",
|
|
591
|
+
[METRIC_ATTRS.USER_EXT3]: "",
|
|
592
|
+
[METRIC_ATTRS.CALLER_SERVER]: "",
|
|
593
|
+
[METRIC_ATTRS.CALLEE_SERVER]: "",
|
|
594
|
+
...attrs
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
var serverHandledSeconds = {
|
|
598
|
+
record(value, attrs) {
|
|
599
|
+
getServerMeter().createHistogram(GALILEO_METRICS.SERVER_HANDLED_SECONDS, {
|
|
600
|
+
unit: "s",
|
|
601
|
+
valueType: ValueType.DOUBLE
|
|
602
|
+
}).record(value, fillServerAttrs(attrs ?? {}));
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
var serverStartedTotal = {
|
|
606
|
+
add(value, attrs) {
|
|
607
|
+
getServerMeter().createCounter(GALILEO_METRICS.SERVER_STARTED_TOTAL, {
|
|
608
|
+
valueType: ValueType.INT
|
|
609
|
+
}).add(value, fillServerAttrs(attrs ?? {}));
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var serverHandledTotal = {
|
|
613
|
+
add(value, attrs) {
|
|
614
|
+
getServerMeter().createCounter(GALILEO_METRICS.SERVER_HANDLED_TOTAL, {
|
|
615
|
+
valueType: ValueType.INT
|
|
616
|
+
}).add(value, fillServerAttrs(attrs ?? {}));
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
var clientHandledSeconds = {
|
|
620
|
+
record(value, attrs) {
|
|
621
|
+
getClientMeter().createHistogram(GALILEO_METRICS.CLIENT_HANDLED_SECONDS, {
|
|
622
|
+
unit: "s",
|
|
623
|
+
valueType: ValueType.DOUBLE
|
|
624
|
+
}).record(value, fillClientAttrs(attrs ?? {}));
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
var clientStartedTotal = {
|
|
628
|
+
add(value, attrs) {
|
|
629
|
+
getClientMeter().createCounter(GALILEO_METRICS.CLIENT_STARTED_TOTAL, {
|
|
630
|
+
valueType: ValueType.INT
|
|
631
|
+
}).add(value, fillClientAttrs(attrs ?? {}));
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
var clientHandledTotal = {
|
|
635
|
+
add(value, attrs) {
|
|
636
|
+
getClientMeter().createCounter(GALILEO_METRICS.CLIENT_HANDLED_TOTAL, {
|
|
637
|
+
valueType: ValueType.INT
|
|
638
|
+
}).add(value, fillClientAttrs(attrs ?? {}));
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
var mergeTotal = {
|
|
642
|
+
add(value, attrs) {
|
|
643
|
+
getBizMeter().createCounter("qqbot.concurrency.merged").add(value, fillBizAttrs(attrs));
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
var mergeEventsTotal = {
|
|
647
|
+
add(value, attrs) {
|
|
648
|
+
getBizMeter().createCounter("qqbot.concurrency.merge_events").add(value, fillBizAttrs(attrs));
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var dropTotal = {
|
|
652
|
+
add(value, attrs) {
|
|
653
|
+
getBizMeter().createCounter("qqbot.concurrency.dropped").add(value, fillBizAttrs(attrs));
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
var toolCallsTotal = {
|
|
657
|
+
add(value, attrs) {
|
|
658
|
+
getBizMeter().createCounter("qqbot.acp.tool_calls.total").add(value, fillBizAttrs(attrs));
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
var acpTtfbSeconds = {
|
|
662
|
+
record(value, attrs) {
|
|
663
|
+
getBizMeter().createHistogram("qqbot.acp.ttfb_seconds", {
|
|
664
|
+
unit: "s",
|
|
665
|
+
valueType: ValueType.DOUBLE
|
|
666
|
+
}).record(value, fillBizAttrs(attrs));
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
var acpConnectSeconds = {
|
|
670
|
+
record(value, attrs) {
|
|
671
|
+
getBizMeter().createHistogram("qqbot.acp.connect_seconds", {
|
|
672
|
+
unit: "s",
|
|
673
|
+
valueType: ValueType.DOUBLE
|
|
674
|
+
}).record(value, fillBizAttrs(attrs));
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
var acpConnectRetries = {
|
|
678
|
+
add(value, attrs) {
|
|
679
|
+
getBizMeter().createCounter("qqbot.acp.connect_retries").add(value, fillBizAttrs(attrs));
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
var chatRetryTotal = {
|
|
683
|
+
add(value, attrs) {
|
|
684
|
+
getBizMeter().createCounter("qqbot.acp.chat_retry_total").add(value, fillBizAttrs(attrs));
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
var poolSize = {
|
|
688
|
+
record(value) {
|
|
689
|
+
getBizMeter().createHistogram("qqbot.pool.size", {
|
|
690
|
+
valueType: ValueType.INT
|
|
691
|
+
}).record(value, fillBizAttrs());
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
var poolEvictTotal = {
|
|
695
|
+
add(value, attrs) {
|
|
696
|
+
getBizMeter().createCounter("qqbot.pool.evict_total").add(value, fillBizAttrs(attrs));
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
var poolRestoreTotal = {
|
|
700
|
+
add(value, attrs) {
|
|
701
|
+
getBizMeter().createCounter("qqbot.pool.restore_total").add(value, fillBizAttrs(attrs));
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
var poolCreateSeconds = {
|
|
705
|
+
record(value, attrs) {
|
|
706
|
+
getBizMeter().createHistogram("qqbot.pool.create_seconds", {
|
|
707
|
+
unit: "s",
|
|
708
|
+
valueType: ValueType.DOUBLE
|
|
709
|
+
}).record(value, fillBizAttrs(attrs));
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
var sandboxStartSeconds = {
|
|
713
|
+
record(value, attrs) {
|
|
714
|
+
getBizMeter().createHistogram("qqbot.sandbox.start_seconds", {
|
|
715
|
+
unit: "s",
|
|
716
|
+
valueType: ValueType.DOUBLE
|
|
717
|
+
}).record(value, fillBizAttrs(attrs));
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
var sessionCreateSeconds = {
|
|
721
|
+
record(value, attrs) {
|
|
722
|
+
getBizMeter().createHistogram("qqbot.session.create_seconds", {
|
|
723
|
+
unit: "s",
|
|
724
|
+
valueType: ValueType.DOUBLE
|
|
725
|
+
}).record(value, fillBizAttrs(attrs));
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
var replyEmptyTotal = {
|
|
729
|
+
add(value, attrs) {
|
|
730
|
+
getBizMeter().createCounter("qqbot.reply.empty_total").add(value, fillBizAttrs(attrs));
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var replyFallbackTotal = {
|
|
734
|
+
add(value, attrs) {
|
|
735
|
+
getBizMeter().createCounter("qqbot.reply.fallback_total").add(value, fillBizAttrs(attrs));
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
var replyChunksTotal = {
|
|
739
|
+
add(value, attrs) {
|
|
740
|
+
getBizMeter().createCounter("qqbot.reply.chunks_total").add(value, fillBizAttrs(attrs));
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
var replyLength = {
|
|
744
|
+
record(value, attrs) {
|
|
745
|
+
getBizMeter().createHistogram("qqbot.reply.length", {
|
|
746
|
+
valueType: ValueType.INT
|
|
747
|
+
}).record(value, fillBizAttrs(attrs));
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var slashCommandTotal = {
|
|
751
|
+
add(value, attrs) {
|
|
752
|
+
getBizMeter().createCounter("qqbot.slash.total").add(value, fillBizAttrs(attrs));
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
var rateLimitRejectedTotal = {
|
|
756
|
+
add(value, attrs) {
|
|
757
|
+
getBizMeter().createCounter("qqbot.ratelimit.rejected_total").add(value, fillBizAttrs(attrs));
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
var envelopeLength = {
|
|
761
|
+
record(value, attrs) {
|
|
762
|
+
getBizMeter().createHistogram("qqbot.envelope.length", {
|
|
763
|
+
valueType: ValueType.INT
|
|
764
|
+
}).record(value, fillBizAttrs(attrs));
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
var transportDisconnectTotal = {
|
|
768
|
+
add(value, attrs) {
|
|
769
|
+
getBizMeter().createCounter("qqbot.transport.disconnect_total").add(value, fillBizAttrs(attrs));
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// src/backend/openai.ts
|
|
774
|
+
var OpenAIBackend = class {
|
|
775
|
+
name = "openai";
|
|
776
|
+
opts;
|
|
777
|
+
/** qualifier → 对话历史 */
|
|
778
|
+
histories = /* @__PURE__ */ new Map();
|
|
779
|
+
constructor(opts) {
|
|
780
|
+
this.opts = opts;
|
|
781
|
+
}
|
|
782
|
+
async init() {
|
|
783
|
+
this.opts.logger.info(`[openai] \u521D\u59CB\u5316: model=${this.opts.model} baseUrl=${this.opts.baseUrl}`);
|
|
784
|
+
}
|
|
785
|
+
async getOrCreateSession(qualifier) {
|
|
786
|
+
if (!this.histories.has(qualifier)) {
|
|
787
|
+
this.histories.set(qualifier, []);
|
|
788
|
+
}
|
|
789
|
+
return qualifier;
|
|
790
|
+
}
|
|
791
|
+
async *chat(params) {
|
|
792
|
+
const clientAttrs = {
|
|
793
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "qqbot-cli",
|
|
794
|
+
[METRIC_ATTRS.CALLER_METHOD]: "chat",
|
|
795
|
+
[METRIC_ATTRS.CALLER_SERVER]: "qqbot-cli",
|
|
796
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: "openai",
|
|
797
|
+
[METRIC_ATTRS.CALLEE_METHOD]: "chat/completions",
|
|
798
|
+
[METRIC_ATTRS.CALLEE_SERVER]: "openai",
|
|
799
|
+
[METRIC_ATTRS.CODE]: 0,
|
|
800
|
+
[METRIC_ATTRS.CODE_TYPE]: "success"
|
|
801
|
+
};
|
|
802
|
+
clientStartedTotal.add(1, clientAttrs);
|
|
803
|
+
const startTime = Date.now();
|
|
804
|
+
let hasError = false;
|
|
805
|
+
try {
|
|
806
|
+
const history = this.histories.get(params.qualifier) ?? [];
|
|
807
|
+
history.push({ role: "user", content: params.text });
|
|
808
|
+
if (history.length > 20) {
|
|
809
|
+
history.splice(0, history.length - 20);
|
|
810
|
+
}
|
|
811
|
+
const messages = [
|
|
812
|
+
{ role: "system", content: this.opts.systemPrompt },
|
|
813
|
+
...history
|
|
814
|
+
];
|
|
815
|
+
const response = await fetch(`${this.opts.baseUrl}/chat/completions`, {
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers: {
|
|
818
|
+
"Content-Type": "application/json",
|
|
819
|
+
Authorization: `Bearer ${this.opts.apiKey}`
|
|
820
|
+
},
|
|
821
|
+
body: JSON.stringify({
|
|
822
|
+
model: this.opts.model,
|
|
823
|
+
messages,
|
|
824
|
+
max_tokens: this.opts.maxTokens,
|
|
825
|
+
temperature: this.opts.temperature,
|
|
826
|
+
stream: true
|
|
827
|
+
})
|
|
828
|
+
});
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
const text = await response.text();
|
|
831
|
+
throw new Error(`OpenAI API ${response.status}: ${text.slice(0, 200)}`);
|
|
832
|
+
}
|
|
833
|
+
const reader = response.body?.getReader();
|
|
834
|
+
if (!reader) {
|
|
835
|
+
throw new Error("OpenAI API \u65E0\u54CD\u5E94\u4F53");
|
|
836
|
+
}
|
|
837
|
+
const decoder = new TextDecoder();
|
|
838
|
+
let buffer = "";
|
|
839
|
+
let fullReply = "";
|
|
840
|
+
try {
|
|
841
|
+
while (true) {
|
|
842
|
+
const { done, value } = await reader.read();
|
|
843
|
+
if (done) break;
|
|
844
|
+
buffer += decoder.decode(value, { stream: true });
|
|
845
|
+
const lines = buffer.split("\n");
|
|
846
|
+
buffer = lines.pop() ?? "";
|
|
847
|
+
for (const line of lines) {
|
|
848
|
+
const trimmed = line.trim();
|
|
849
|
+
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
|
850
|
+
const data = trimmed.slice(6);
|
|
851
|
+
if (data === "[DONE]") continue;
|
|
852
|
+
try {
|
|
853
|
+
const parsed = JSON.parse(data);
|
|
854
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
855
|
+
if (delta) {
|
|
856
|
+
fullReply += delta;
|
|
857
|
+
yield { type: "text", content: delta };
|
|
858
|
+
}
|
|
859
|
+
} catch {
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} finally {
|
|
864
|
+
reader.releaseLock();
|
|
865
|
+
}
|
|
866
|
+
if (fullReply) {
|
|
867
|
+
history.push({ role: "assistant", content: fullReply });
|
|
868
|
+
}
|
|
869
|
+
yield { type: "done", content: "", stopReason: "stop" };
|
|
870
|
+
} catch (err) {
|
|
871
|
+
hasError = true;
|
|
872
|
+
clientHandledTotal.add(1, { ...clientAttrs, [METRIC_ATTRS.CODE]: 1, [METRIC_ATTRS.CODE_TYPE]: "error" });
|
|
873
|
+
throw err;
|
|
874
|
+
} finally {
|
|
875
|
+
if (!hasError) {
|
|
876
|
+
clientHandledTotal.add(1, clientAttrs);
|
|
877
|
+
}
|
|
878
|
+
clientHandledSeconds.record((Date.now() - startTime) / 1e3, clientAttrs);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async shutdown() {
|
|
882
|
+
this.histories.clear();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// src/cloudagent/acp-client.ts
|
|
887
|
+
import {
|
|
888
|
+
ClientSideConnection,
|
|
889
|
+
PROTOCOL_VERSION
|
|
890
|
+
} from "@agentclientprotocol/sdk";
|
|
891
|
+
|
|
892
|
+
// src/cloudagent/acp-transport.ts
|
|
893
|
+
function createTransport(options) {
|
|
894
|
+
const { endpoint, authToken, logger } = options;
|
|
895
|
+
let connectionId;
|
|
896
|
+
let closed = false;
|
|
897
|
+
const abortController = new AbortController();
|
|
898
|
+
let resolveReady;
|
|
899
|
+
let rejectReady;
|
|
900
|
+
const ready = new Promise((resolve2, reject) => {
|
|
901
|
+
resolveReady = resolve2;
|
|
902
|
+
rejectReady = reject;
|
|
903
|
+
});
|
|
904
|
+
const queue = [];
|
|
905
|
+
const waiters = [];
|
|
906
|
+
const enqueue = (m) => {
|
|
907
|
+
if (waiters.length > 0) waiters.shift()(m);
|
|
908
|
+
else queue.push(m);
|
|
909
|
+
};
|
|
910
|
+
const dequeue = () => {
|
|
911
|
+
if (closed && queue.length === 0) return Promise.resolve(null);
|
|
912
|
+
if (queue.length > 0) return Promise.resolve(queue.shift());
|
|
913
|
+
return new Promise((resolve2) => waiters.push(resolve2));
|
|
914
|
+
};
|
|
915
|
+
const closeAll = () => {
|
|
916
|
+
closed = true;
|
|
917
|
+
while (waiters.length > 0) waiters.shift()(null);
|
|
918
|
+
};
|
|
919
|
+
const baseHeaders = () => ({
|
|
920
|
+
Authorization: `Bearer ${authToken}`
|
|
921
|
+
});
|
|
922
|
+
async function startSSE() {
|
|
923
|
+
try {
|
|
924
|
+
const headers = baseHeaders();
|
|
925
|
+
headers["Accept"] = "text/event-stream";
|
|
926
|
+
const res = await fetch(endpoint, {
|
|
927
|
+
method: "GET",
|
|
928
|
+
headers,
|
|
929
|
+
signal: abortController.signal
|
|
930
|
+
});
|
|
931
|
+
if (!res.ok) {
|
|
932
|
+
throw new Error(`SSE GET failed: HTTP ${res.status} ${res.statusText}`);
|
|
933
|
+
}
|
|
934
|
+
const id = res.headers.get("Acp-Connection-Id");
|
|
935
|
+
if (!id) throw new Error("Missing Acp-Connection-Id header");
|
|
936
|
+
connectionId = id;
|
|
937
|
+
resolveReady();
|
|
938
|
+
logger?.info?.(`[acp:transport] SSE connected, connectionId=${id}`);
|
|
939
|
+
const reader = res.body?.getReader();
|
|
940
|
+
if (!reader) throw new Error("No SSE body reader");
|
|
941
|
+
const decoder = new TextDecoder();
|
|
942
|
+
let buf = "";
|
|
943
|
+
let evtData = "";
|
|
944
|
+
let evtType = "";
|
|
945
|
+
while (!closed) {
|
|
946
|
+
const { value, done } = await reader.read();
|
|
947
|
+
if (done) break;
|
|
948
|
+
buf += decoder.decode(value, { stream: true });
|
|
949
|
+
const lines = buf.split("\n");
|
|
950
|
+
buf = lines.pop() ?? "";
|
|
951
|
+
for (const line of lines) {
|
|
952
|
+
if (line === "") {
|
|
953
|
+
if (evtData && evtType !== "connected") {
|
|
954
|
+
try {
|
|
955
|
+
const msg = JSON.parse(evtData);
|
|
956
|
+
if (msg && typeof msg === "object" && "jsonrpc" in msg) {
|
|
957
|
+
enqueue(msg);
|
|
958
|
+
}
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
evtData = "";
|
|
963
|
+
evtType = "";
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (line.startsWith(":")) continue;
|
|
967
|
+
const idx = line.indexOf(":");
|
|
968
|
+
if (idx === -1) continue;
|
|
969
|
+
const field = line.slice(0, idx);
|
|
970
|
+
let val = line.slice(idx + 1);
|
|
971
|
+
if (val.startsWith(" ")) val = val.slice(1);
|
|
972
|
+
if (field === "event") evtType = val;
|
|
973
|
+
if (field === "data") evtData = (evtData || "") + val;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
if (!closed && err?.name !== "AbortError") {
|
|
978
|
+
transportDisconnectTotal.add(1);
|
|
979
|
+
logger?.error?.(`[acp:transport] SSE error: ${err?.message ?? err}`);
|
|
980
|
+
rejectReady(err instanceof Error ? err : new Error(String(err)));
|
|
981
|
+
}
|
|
982
|
+
} finally {
|
|
983
|
+
closeAll();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
async function sendMessage(message) {
|
|
987
|
+
if (closed) throw new Error("Transport closed");
|
|
988
|
+
await ready;
|
|
989
|
+
const headers = baseHeaders();
|
|
990
|
+
headers["Content-Type"] = "application/json";
|
|
991
|
+
headers["Accept"] = "application/json, text/event-stream";
|
|
992
|
+
headers["Acp-Connection-Id"] = connectionId;
|
|
993
|
+
const res = await fetch(endpoint, {
|
|
994
|
+
method: "POST",
|
|
995
|
+
headers,
|
|
996
|
+
body: JSON.stringify(message)
|
|
997
|
+
});
|
|
998
|
+
if (!res.ok) {
|
|
999
|
+
const t = await res.text().catch(() => "");
|
|
1000
|
+
throw new Error(`POST failed: HTTP ${res.status} ${t.slice(0, 200)}`);
|
|
1001
|
+
}
|
|
1002
|
+
const ct = res.headers.get("Content-Type") ?? "";
|
|
1003
|
+
if (ct.includes("text/event-stream")) {
|
|
1004
|
+
const reader = res.body?.getReader();
|
|
1005
|
+
if (reader) {
|
|
1006
|
+
processPostSSE(reader).catch(
|
|
1007
|
+
(e) => logger?.error?.(`[acp:transport] POST SSE error: ${e?.message ?? e}`)
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
} else if (ct.includes("application/json")) {
|
|
1011
|
+
const data = await res.json();
|
|
1012
|
+
if (data && typeof data === "object" && "jsonrpc" in data) {
|
|
1013
|
+
enqueue(data);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function processPostSSE(reader) {
|
|
1018
|
+
const decoder = new TextDecoder();
|
|
1019
|
+
let buf = "";
|
|
1020
|
+
let data = "";
|
|
1021
|
+
try {
|
|
1022
|
+
while (true) {
|
|
1023
|
+
const { value, done } = await reader.read();
|
|
1024
|
+
if (done) break;
|
|
1025
|
+
buf += decoder.decode(value, { stream: true });
|
|
1026
|
+
const lines = buf.split("\n");
|
|
1027
|
+
buf = lines.pop() ?? "";
|
|
1028
|
+
for (const line of lines) {
|
|
1029
|
+
if (line === "") {
|
|
1030
|
+
if (data) {
|
|
1031
|
+
try {
|
|
1032
|
+
const m = JSON.parse(data);
|
|
1033
|
+
if (m && typeof m === "object" && "jsonrpc" in m) {
|
|
1034
|
+
enqueue(m);
|
|
1035
|
+
}
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
data = "";
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (line.startsWith(":")) continue;
|
|
1043
|
+
const idx = line.indexOf(":");
|
|
1044
|
+
if (idx === -1) continue;
|
|
1045
|
+
const f = line.slice(0, idx);
|
|
1046
|
+
let v = line.slice(idx + 1);
|
|
1047
|
+
if (v.startsWith(" ")) v = v.slice(1);
|
|
1048
|
+
if (f === "data") data = (data || "") + v;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
} finally {
|
|
1052
|
+
reader.releaseLock();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
async function close() {
|
|
1056
|
+
if (closed) return;
|
|
1057
|
+
if (connectionId) {
|
|
1058
|
+
try {
|
|
1059
|
+
const headers = baseHeaders();
|
|
1060
|
+
headers["Acp-Connection-Id"] = connectionId;
|
|
1061
|
+
await fetch(endpoint, {
|
|
1062
|
+
method: "DELETE",
|
|
1063
|
+
headers,
|
|
1064
|
+
signal: AbortSignal.timeout(5e3)
|
|
1065
|
+
});
|
|
1066
|
+
logger?.debug?.(`[acp:transport] DELETE sent`);
|
|
1067
|
+
} catch {
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
abortController.abort();
|
|
1071
|
+
closeAll();
|
|
1072
|
+
}
|
|
1073
|
+
const readable = new ReadableStream({
|
|
1074
|
+
async pull(controller) {
|
|
1075
|
+
const m = await dequeue();
|
|
1076
|
+
if (m === null) controller.close();
|
|
1077
|
+
else controller.enqueue(m);
|
|
1078
|
+
},
|
|
1079
|
+
cancel() {
|
|
1080
|
+
closeAll();
|
|
1081
|
+
abortController.abort();
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
const writable = new WritableStream({
|
|
1085
|
+
async write(m) {
|
|
1086
|
+
await sendMessage(m);
|
|
1087
|
+
},
|
|
1088
|
+
close() {
|
|
1089
|
+
closeAll();
|
|
1090
|
+
},
|
|
1091
|
+
abort() {
|
|
1092
|
+
closeAll();
|
|
1093
|
+
abortController.abort();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
startSSE();
|
|
1097
|
+
return { readable, writable, ready, close };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/cloudagent/acp-client.ts
|
|
1101
|
+
var AcpClient = class {
|
|
1102
|
+
opts;
|
|
1103
|
+
transport;
|
|
1104
|
+
connection;
|
|
1105
|
+
_connected = false;
|
|
1106
|
+
/** Agent 能力集(initialize 后填充) */
|
|
1107
|
+
agentCapabilities = null;
|
|
1108
|
+
/** 内部事件缓冲:sessionUpdate 推送的 chunk 暂存于此 */
|
|
1109
|
+
eventBuffer = [];
|
|
1110
|
+
eventWaiters = [];
|
|
1111
|
+
promptDone = false;
|
|
1112
|
+
/** Only accept chunks when actively prompting (ignore session resume replay). */
|
|
1113
|
+
prompting = false;
|
|
1114
|
+
/** Last time any ACP activity was received (for idle timeout). */
|
|
1115
|
+
lastActivityAt = 0;
|
|
1116
|
+
constructor(opts) {
|
|
1117
|
+
this.opts = opts;
|
|
1118
|
+
}
|
|
1119
|
+
get isConnected() {
|
|
1120
|
+
return this._connected;
|
|
1121
|
+
}
|
|
1122
|
+
// ============ 连接 ============
|
|
1123
|
+
/**
|
|
1124
|
+
* 建立 SSE + initialize 握手。
|
|
1125
|
+
* 自动重试(默认 20 次 × 3s),触发沙箱唤醒。
|
|
1126
|
+
*/
|
|
1127
|
+
async connect() {
|
|
1128
|
+
const { logger, endpoint, authToken, maxRetries, retryIntervalMs } = this.opts;
|
|
1129
|
+
logger.info(`[acp] connecting to ${endpoint} ...`);
|
|
1130
|
+
const connectStart = Date.now();
|
|
1131
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1132
|
+
try {
|
|
1133
|
+
this.transport = createTransport({ endpoint, authToken, logger });
|
|
1134
|
+
this.connection = new ClientSideConnection(
|
|
1135
|
+
() => ({
|
|
1136
|
+
sessionUpdate: async (params) => {
|
|
1137
|
+
try {
|
|
1138
|
+
this.handleSessionUpdate(params);
|
|
1139
|
+
this.opts.onSessionUpdate?.(params);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
logger.error(`[acp] onSessionUpdate error: ${e?.message ?? e}`);
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
requestPermission: async (params) => {
|
|
1145
|
+
if (this.opts.onRequestPermission) {
|
|
1146
|
+
return this.opts.onRequestPermission(params);
|
|
1147
|
+
}
|
|
1148
|
+
const first = params.options?.[0];
|
|
1149
|
+
logger.debug?.(
|
|
1150
|
+
`[acp] auto-approve permission: ${first?.optionId ?? "approve"}`
|
|
1151
|
+
);
|
|
1152
|
+
return {
|
|
1153
|
+
outcome: {
|
|
1154
|
+
outcome: "selected",
|
|
1155
|
+
optionId: first?.optionId ?? "approve"
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
// 处理 SDK 未内置的扩展通知(消除 "Method not found" 警告)
|
|
1160
|
+
extNotification: async (method, params) => {
|
|
1161
|
+
if (!this.prompting) return;
|
|
1162
|
+
switch (method) {
|
|
1163
|
+
case "session/endTurn": {
|
|
1164
|
+
const stopReason = params?.stopReason ?? "end_turn";
|
|
1165
|
+
logger.debug?.(`[acp] endTurn: stopReason=${stopReason}`);
|
|
1166
|
+
this.promptDone = true;
|
|
1167
|
+
const doneEvent = { type: "done", text: "", stopReason };
|
|
1168
|
+
if (this.eventWaiters.length > 0) {
|
|
1169
|
+
this.eventWaiters.shift()(doneEvent);
|
|
1170
|
+
} else {
|
|
1171
|
+
this.eventBuffer.push(doneEvent);
|
|
1172
|
+
}
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
default:
|
|
1176
|
+
logger.debug?.(`[acp] unknown notification: ${method}`);
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}),
|
|
1181
|
+
this.transport
|
|
1182
|
+
);
|
|
1183
|
+
await this.transport.ready;
|
|
1184
|
+
const initResp = await this.connection.initialize({
|
|
1185
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1186
|
+
clientCapabilities: {
|
|
1187
|
+
fs: { readTextFile: false, writeTextFile: false }
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
this.agentCapabilities = initResp.agentCapabilities;
|
|
1191
|
+
this._connected = true;
|
|
1192
|
+
acpConnectSeconds.record((Date.now() - connectStart) / 1e3, { result: "success" });
|
|
1193
|
+
if (attempt > 1) {
|
|
1194
|
+
acpConnectRetries.add(attempt - 1, { result: "success" });
|
|
1195
|
+
}
|
|
1196
|
+
logger.info(
|
|
1197
|
+
`[acp] initialized (attempt=${attempt}, capabilities=${JSON.stringify(initResp.agentCapabilities)})`
|
|
1198
|
+
);
|
|
1199
|
+
return;
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
this.transport = void 0;
|
|
1202
|
+
this.connection = void 0;
|
|
1203
|
+
if (attempt >= maxRetries) {
|
|
1204
|
+
acpConnectSeconds.record((Date.now() - connectStart) / 1e3, { result: "fail" });
|
|
1205
|
+
acpConnectRetries.add(attempt, { result: "fail" });
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`ACP connect failed after ${maxRetries} attempts: ${e?.message ?? e}`
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
logger.info(
|
|
1211
|
+
`[acp] attempt ${attempt}/${maxRetries} failed: ${e?.message ?? e}, retrying in ${retryIntervalMs / 1e3}s`
|
|
1212
|
+
);
|
|
1213
|
+
await new Promise((r) => setTimeout(r, retryIntervalMs));
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
// ============ Session 管理 ============
|
|
1218
|
+
/** 创建新 ACP session */
|
|
1219
|
+
async createSession(cwd = "/workspace") {
|
|
1220
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1221
|
+
const mcpServers = this.opts.extraMcpServers ?? [];
|
|
1222
|
+
const resp = await this.connection.newSession({ cwd, mcpServers });
|
|
1223
|
+
this.opts.logger.info(
|
|
1224
|
+
`[acp] session created: ${resp.sessionId} (cwd=${cwd}, mcp=${mcpServers.length})`
|
|
1225
|
+
);
|
|
1226
|
+
this.opts.logger.debug?.(`[acp] newSession response: ${JSON.stringify(resp)}`);
|
|
1227
|
+
return resp.sessionId;
|
|
1228
|
+
}
|
|
1229
|
+
/** 加载已有 session(含历史回放) */
|
|
1230
|
+
async loadSession(sessionId, cwd = "/workspace") {
|
|
1231
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1232
|
+
const mcpServers = this.opts.extraMcpServers ?? [];
|
|
1233
|
+
const resp = await this.connection.loadSession({ sessionId, cwd, mcpServers });
|
|
1234
|
+
this.opts.logger.info(`[acp] session loaded: ${sessionId}`);
|
|
1235
|
+
this.opts.logger.debug?.(`[acp] loadSession response: ${JSON.stringify(resp)}`);
|
|
1236
|
+
}
|
|
1237
|
+
/** 取消当前正在执行的 prompt */
|
|
1238
|
+
cancelPrompt(sessionId) {
|
|
1239
|
+
if (!this.connection) return;
|
|
1240
|
+
this.connection.cancel({ sessionId }).catch((err) => {
|
|
1241
|
+
this.opts.logger.debug?.(`[acp] cancel error: ${err?.message ?? err}`);
|
|
1242
|
+
});
|
|
1243
|
+
this.opts.logger.info(`[acp] cancel sent for session=${sessionId}`);
|
|
1244
|
+
}
|
|
1245
|
+
/** 切换权限模式 */
|
|
1246
|
+
async setMode(sessionId, modeId) {
|
|
1247
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1248
|
+
await this.connection.request?.("session/set_mode", { sessionId, modeId });
|
|
1249
|
+
this.opts.logger.info(`[acp] mode set to ${modeId} for session=${sessionId}`);
|
|
1250
|
+
}
|
|
1251
|
+
/** 切换模型 */
|
|
1252
|
+
async setModel(sessionId, modelId) {
|
|
1253
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1254
|
+
await this.connection.request?.("session/set_model", { sessionId, modelId });
|
|
1255
|
+
this.opts.logger.info(`[acp] model set to ${modelId} for session=${sessionId}`);
|
|
1256
|
+
}
|
|
1257
|
+
// ============ Prompt(流式输出) ============
|
|
1258
|
+
/**
|
|
1259
|
+
* 发送 prompt 并流式接收回复。
|
|
1260
|
+
*
|
|
1261
|
+
* 同时消费:
|
|
1262
|
+
* - POST SSE(prompt 直接响应流)
|
|
1263
|
+
* - GET SSE(session/update 推送的 agent_message_chunk 等)
|
|
1264
|
+
*
|
|
1265
|
+
* 通过 eventBuffer + waitForStable 实现双流时序补偿。
|
|
1266
|
+
*/
|
|
1267
|
+
async *promptStream(sessionId, blocks) {
|
|
1268
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1269
|
+
this.eventBuffer = [];
|
|
1270
|
+
this.eventWaiters = [];
|
|
1271
|
+
this.promptDone = false;
|
|
1272
|
+
this.prompting = true;
|
|
1273
|
+
this.lastActivityAt = Date.now();
|
|
1274
|
+
const promptPromise = this.connection.prompt({
|
|
1275
|
+
sessionId,
|
|
1276
|
+
prompt: blocks
|
|
1277
|
+
});
|
|
1278
|
+
promptPromise.catch((err) => {
|
|
1279
|
+
this.promptDone = true;
|
|
1280
|
+
const errorEvent = {
|
|
1281
|
+
type: "done",
|
|
1282
|
+
text: err?.message ?? String(err),
|
|
1283
|
+
stopReason: "error"
|
|
1284
|
+
};
|
|
1285
|
+
if (this.eventWaiters.length > 0) {
|
|
1286
|
+
this.eventWaiters.shift()(errorEvent);
|
|
1287
|
+
} else {
|
|
1288
|
+
this.eventBuffer.push(errorEvent);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
try {
|
|
1292
|
+
while (true) {
|
|
1293
|
+
const event = await this.nextEvent();
|
|
1294
|
+
if (event === null) {
|
|
1295
|
+
yield { type: "done", text: "", stopReason: "timeout" };
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
yield event;
|
|
1299
|
+
if (event.type === "done") break;
|
|
1300
|
+
}
|
|
1301
|
+
} finally {
|
|
1302
|
+
try {
|
|
1303
|
+
const result = await promptPromise;
|
|
1304
|
+
await this.waitForStable();
|
|
1305
|
+
while (this.eventBuffer.length > 0) {
|
|
1306
|
+
const ev = this.eventBuffer.shift();
|
|
1307
|
+
if (ev.type !== "done") {
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (!this.promptDone) {
|
|
1311
|
+
this.promptDone = true;
|
|
1312
|
+
}
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
this.opts.logger.error(`[acp] prompt error: ${e?.message ?? e}`);
|
|
1315
|
+
throw e;
|
|
1316
|
+
} finally {
|
|
1317
|
+
this.prompting = false;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* 发送 prompt(非流式,等待完成后返回 stopReason)。
|
|
1323
|
+
* 适合不需要流式输出的场景。
|
|
1324
|
+
*/
|
|
1325
|
+
async prompt(sessionId, input) {
|
|
1326
|
+
if (!this.connection) throw new Error("ACP not connected");
|
|
1327
|
+
const blocks = typeof input === "string" ? [{ type: "text", text: input }] : input;
|
|
1328
|
+
const resp = await this.connection.prompt({
|
|
1329
|
+
sessionId,
|
|
1330
|
+
prompt: blocks
|
|
1331
|
+
});
|
|
1332
|
+
return resp.stopReason;
|
|
1333
|
+
}
|
|
1334
|
+
// ============ 关闭 ============
|
|
1335
|
+
async close() {
|
|
1336
|
+
if (this.transport) {
|
|
1337
|
+
await this.transport.close();
|
|
1338
|
+
this.transport = void 0;
|
|
1339
|
+
}
|
|
1340
|
+
this.connection = void 0;
|
|
1341
|
+
this._connected = false;
|
|
1342
|
+
while (this.eventWaiters.length > 0) {
|
|
1343
|
+
this.eventWaiters.shift()(null);
|
|
1344
|
+
}
|
|
1345
|
+
this.opts.logger.info("[acp] closed");
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* 标记连接已断开(由外部错误处理调用)。
|
|
1349
|
+
* 下次 ensureAcpReady 会触发重连。
|
|
1350
|
+
*/
|
|
1351
|
+
markDisconnected() {
|
|
1352
|
+
this._connected = false;
|
|
1353
|
+
while (this.eventWaiters.length > 0) {
|
|
1354
|
+
this.eventWaiters.shift()(null);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// ============ 内部 ============
|
|
1358
|
+
/**
|
|
1359
|
+
* 处理 GET SSE 推送的 session/update 通知。
|
|
1360
|
+
* 将解析后的事件入队到 eventBuffer,供 promptStream 消费。
|
|
1361
|
+
*/
|
|
1362
|
+
handleSessionUpdate(n) {
|
|
1363
|
+
const update = n.update ?? {};
|
|
1364
|
+
const t = update.sessionUpdate ?? update.type;
|
|
1365
|
+
if (!this.prompting) return;
|
|
1366
|
+
this.lastActivityAt = Date.now();
|
|
1367
|
+
this.opts.logger.debug?.(
|
|
1368
|
+
`[acp:raw] type=${t} ${JSON.stringify(update)}`
|
|
1369
|
+
);
|
|
1370
|
+
let event = null;
|
|
1371
|
+
switch (t) {
|
|
1372
|
+
case "agent_message_chunk": {
|
|
1373
|
+
const txt = update.content?.text ?? "";
|
|
1374
|
+
if (txt) event = { type: "agent_message_chunk", text: txt };
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
case "agent_thought_chunk": {
|
|
1378
|
+
const txt = update.content?.text ?? "";
|
|
1379
|
+
if (txt) event = { type: "agent_thought_chunk", text: txt };
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
case "tool_call": {
|
|
1383
|
+
event = {
|
|
1384
|
+
type: "tool_call",
|
|
1385
|
+
text: "",
|
|
1386
|
+
title: update.title,
|
|
1387
|
+
toolCallId: update.toolCallId,
|
|
1388
|
+
kind: update.kind,
|
|
1389
|
+
status: update.status,
|
|
1390
|
+
rawInput: update.rawInput
|
|
1391
|
+
};
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
case "tool_call_update": {
|
|
1395
|
+
if (update.status === "completed" || update.status === "failed") {
|
|
1396
|
+
event = {
|
|
1397
|
+
type: "tool_call_update",
|
|
1398
|
+
text: "",
|
|
1399
|
+
toolCallId: update.toolCallId,
|
|
1400
|
+
status: update.status
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
case "plan": {
|
|
1406
|
+
event = { type: "plan", text: "", entries: update.entries };
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
default:
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
if (event) {
|
|
1413
|
+
if (this.eventWaiters.length > 0) {
|
|
1414
|
+
this.eventWaiters.shift()(event);
|
|
1415
|
+
} else {
|
|
1416
|
+
this.eventBuffer.push(event);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/** 从 eventBuffer 取下一个事件(阻塞等待) */
|
|
1421
|
+
nextEvent() {
|
|
1422
|
+
if (this.eventBuffer.length > 0) {
|
|
1423
|
+
return Promise.resolve(this.eventBuffer.shift());
|
|
1424
|
+
}
|
|
1425
|
+
if (this.promptDone) {
|
|
1426
|
+
return Promise.resolve(null);
|
|
1427
|
+
}
|
|
1428
|
+
const timeoutMs = this.opts.eventTimeoutMs ?? 18e4;
|
|
1429
|
+
return new Promise((resolve2) => {
|
|
1430
|
+
this.eventWaiters.push(resolve2);
|
|
1431
|
+
const checkInterval = setInterval(() => {
|
|
1432
|
+
const idx = this.eventWaiters.indexOf(resolve2);
|
|
1433
|
+
if (idx < 0) {
|
|
1434
|
+
clearInterval(checkInterval);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const idle = Date.now() - this.lastActivityAt;
|
|
1438
|
+
if (idle >= timeoutMs) {
|
|
1439
|
+
clearInterval(checkInterval);
|
|
1440
|
+
this.eventWaiters.splice(idx, 1);
|
|
1441
|
+
this.opts.logger.debug?.(`[acp] nextEvent idle timeout (${timeoutMs / 1e3}s), returning null`);
|
|
1442
|
+
resolve2(null);
|
|
1443
|
+
}
|
|
1444
|
+
}, 5e3);
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* 双流时序补偿:等待 GET SSE 中可能还在路上的 chunk 落地。
|
|
1449
|
+
* prompt POST SSE 返回后,GET SSE 可能还有 ~80ms 延迟的 chunk。
|
|
1450
|
+
*/
|
|
1451
|
+
async waitForStable(quietMs = 300, maxWaitMs = 1500) {
|
|
1452
|
+
const start = Date.now();
|
|
1453
|
+
let lastEventAt = Date.now();
|
|
1454
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1455
|
+
if (this.eventBuffer.length > 0) {
|
|
1456
|
+
lastEventAt = Date.now();
|
|
1457
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const silent = Date.now() - lastEventAt;
|
|
1461
|
+
if (silent >= quietMs) return;
|
|
1462
|
+
await new Promise((r) => setTimeout(r, quietMs - silent));
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
// src/cloudagent/sandbox.ts
|
|
1468
|
+
import JSONBig from "json-bigint";
|
|
1469
|
+
var JSONB = JSONBig({ storeAsString: true });
|
|
1470
|
+
async function createSandbox(params) {
|
|
1471
|
+
const { runtimeName, apiKey, endpoint, systemPrompt, logger } = params;
|
|
1472
|
+
const url = `${endpoint}/agentos/runtimes`;
|
|
1473
|
+
const body = JSON.stringify({ runtimeName, agentManifest: {
|
|
1474
|
+
id: runtimeName,
|
|
1475
|
+
name: runtimeName,
|
|
1476
|
+
manifestVersion: "1.0",
|
|
1477
|
+
system_prompt: systemPrompt ?? "You are a helpful assistant running inside a QQ Bot.",
|
|
1478
|
+
secrets: [{ key: "CODEBUDDY_API_KEY", value: apiKey }]
|
|
1479
|
+
} });
|
|
1480
|
+
logger?.debug?.(`[agentos] >>> POST ${url}`);
|
|
1481
|
+
const res = await fetch(url, {
|
|
1482
|
+
method: "POST",
|
|
1483
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey, "X-Sandbox-Type": "AGS" },
|
|
1484
|
+
body
|
|
1485
|
+
});
|
|
1486
|
+
return parseApiResponse(res, `POST ${url}`, logger);
|
|
1487
|
+
}
|
|
1488
|
+
async function getRuntime(runtimeId, apiKey, endpoint, logger) {
|
|
1489
|
+
const url = `${endpoint}/agentos/runtimes/${runtimeId}`;
|
|
1490
|
+
logger?.debug?.(`[agentos] >>> GET ${url}`);
|
|
1491
|
+
const res = await fetch(url, {
|
|
1492
|
+
method: "GET",
|
|
1493
|
+
headers: { "x-api-key": apiKey }
|
|
1494
|
+
});
|
|
1495
|
+
return parseApiResponse(res, `GET ${url}`, logger);
|
|
1496
|
+
}
|
|
1497
|
+
async function waitForRunning(runtimeId, apiKey, endpoint, opts = {}) {
|
|
1498
|
+
const maxWaitMs = opts.maxWaitMs ?? 18e4;
|
|
1499
|
+
const intervalMs = opts.intervalMs ?? 3e3;
|
|
1500
|
+
const start = Date.now();
|
|
1501
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1502
|
+
const rt = await getRuntime(runtimeId, apiKey, endpoint, opts.logger);
|
|
1503
|
+
if (rt.status === "RUNNING") {
|
|
1504
|
+
sandboxStartSeconds.record((Date.now() - start) / 1e3);
|
|
1505
|
+
return rt;
|
|
1506
|
+
}
|
|
1507
|
+
if (rt.status === "FAILED") {
|
|
1508
|
+
throw new Error(`Runtime ${runtimeId} FAILED: ${rt.failureReason ?? "unknown"}`);
|
|
1509
|
+
}
|
|
1510
|
+
const elapsed = Math.round((Date.now() - start) / 1e3);
|
|
1511
|
+
opts.logger?.info?.(`[sandbox] status=${rt.status}, waiting... (${elapsed}s)`);
|
|
1512
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1513
|
+
}
|
|
1514
|
+
throw new Error(`Runtime ${runtimeId} not ready within ${maxWaitMs / 1e3}s`);
|
|
1515
|
+
}
|
|
1516
|
+
async function createControlPlaneSession(runtimeId, sessionId, apiKey, endpoint, logger) {
|
|
1517
|
+
const url = `${endpoint}/agentos/runtimes/${runtimeId}/sessions`;
|
|
1518
|
+
const body = JSON.stringify({
|
|
1519
|
+
sessionId,
|
|
1520
|
+
agentManifest: {
|
|
1521
|
+
id: "qqbot-cli",
|
|
1522
|
+
name: "QQBot-CLI",
|
|
1523
|
+
manifestVersion: "1.0"
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
logger?.debug?.(`[agentos] >>> POST ${url} body=${body}`);
|
|
1527
|
+
const res = await fetch(url, {
|
|
1528
|
+
method: "POST",
|
|
1529
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
|
1530
|
+
body
|
|
1531
|
+
});
|
|
1532
|
+
return parseApiResponse(res, `POST ${url}`, logger);
|
|
1533
|
+
}
|
|
1534
|
+
async function getSession(runtimeId, sessionId, apiKey, endpoint, logger) {
|
|
1535
|
+
const url = `${endpoint}/agentos/runtimes/${runtimeId}/sessions/${sessionId}`;
|
|
1536
|
+
logger?.debug?.(`[agentos] >>> GET ${url}`);
|
|
1537
|
+
const res = await fetch(url, {
|
|
1538
|
+
method: "GET",
|
|
1539
|
+
headers: { "x-api-key": apiKey }
|
|
1540
|
+
});
|
|
1541
|
+
if (res.status === 404) {
|
|
1542
|
+
const text = await res.text().catch(() => "");
|
|
1543
|
+
logger?.debug?.(`[agentos] <<< 404 ${text}`);
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
return parseApiResponse(res, `GET ${url}`, logger);
|
|
1547
|
+
}
|
|
1548
|
+
async function waitForSessionReady(runtimeId, sessionId, apiKey, endpoint, opts = {}) {
|
|
1549
|
+
const maxWaitMs = opts.maxWaitMs ?? 3e4;
|
|
1550
|
+
const intervalMs = opts.intervalMs ?? 1e3;
|
|
1551
|
+
const start = Date.now();
|
|
1552
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1553
|
+
const sess = await getSession(runtimeId, sessionId, apiKey, endpoint, opts.logger);
|
|
1554
|
+
if (!sess) {
|
|
1555
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
const status = sess.sessionStatus.toUpperCase();
|
|
1559
|
+
if (status === "RUNNING" || status === "READY" || status === "ACTIVE") {
|
|
1560
|
+
sessionCreateSeconds.record((Date.now() - start) / 1e3);
|
|
1561
|
+
return sess;
|
|
1562
|
+
}
|
|
1563
|
+
if (status === "FAILED" || status === "ERROR") {
|
|
1564
|
+
throw new Error(`Session ${sessionId} failed: status=${sess.sessionStatus}`);
|
|
1565
|
+
}
|
|
1566
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1567
|
+
}
|
|
1568
|
+
throw new Error(`Session ${sessionId} not ready within ${maxWaitMs / 1e3}s`);
|
|
1569
|
+
}
|
|
1570
|
+
async function deleteControlPlaneSession(runtimeId, sessionId, apiKey, endpoint, logger) {
|
|
1571
|
+
const url = `${endpoint}/agentos/runtimes/${runtimeId}/sessions/${sessionId}/delete`;
|
|
1572
|
+
logger?.debug?.(`[agentos] >>> POST ${url}`);
|
|
1573
|
+
const res = await fetch(url, {
|
|
1574
|
+
method: "POST",
|
|
1575
|
+
headers: { "x-api-key": apiKey },
|
|
1576
|
+
signal: AbortSignal.timeout(5e3)
|
|
1577
|
+
});
|
|
1578
|
+
if (!res.ok) {
|
|
1579
|
+
const t = await res.text().catch(() => "");
|
|
1580
|
+
logger?.debug?.(`[agentos] <<< ${res.status} ${t}`);
|
|
1581
|
+
throw new Error(`deleteSession HTTP ${res.status}: ${t}`);
|
|
1582
|
+
}
|
|
1583
|
+
logger?.debug?.(`[agentos] <<< ${res.status} OK`);
|
|
1584
|
+
}
|
|
1585
|
+
async function parseApiResponse(res, path2, logger) {
|
|
1586
|
+
const text = await res.text().catch(() => "");
|
|
1587
|
+
logger?.debug?.(`[agentos] <<< ${res.status} ${text}`);
|
|
1588
|
+
if (!res.ok) {
|
|
1589
|
+
throw new Error(`AgentOS HTTP ${res.status}: ${text} [${path2}]`);
|
|
1590
|
+
}
|
|
1591
|
+
let json;
|
|
1592
|
+
try {
|
|
1593
|
+
json = JSONB.parse(text);
|
|
1594
|
+
} catch {
|
|
1595
|
+
throw new Error(`AgentOS response parse failed [${path2}]`);
|
|
1596
|
+
}
|
|
1597
|
+
if (json.code !== 0) {
|
|
1598
|
+
throw new Error(`AgentOS error [${path2}] code=${json.code}: ${json.msg}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (!json.data) {
|
|
1601
|
+
throw new Error(`AgentOS empty data [${path2}]`);
|
|
1602
|
+
}
|
|
1603
|
+
return json.data;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// src/telemetry/client-interceptor.ts
|
|
1607
|
+
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
|
|
1608
|
+
var tracer = trace.getTracer("qqbot-cli");
|
|
1609
|
+
async function withClientMetrics(opts, fn) {
|
|
1610
|
+
const attrs = buildAttrs(opts);
|
|
1611
|
+
const spanName = `${opts.calleeService}/${opts.calleeMethod}`;
|
|
1612
|
+
return tracer.startActiveSpan(
|
|
1613
|
+
spanName,
|
|
1614
|
+
{
|
|
1615
|
+
kind: SpanKind.CLIENT,
|
|
1616
|
+
attributes: {
|
|
1617
|
+
"rpc.system": "custom",
|
|
1618
|
+
"rpc.service": opts.calleeService,
|
|
1619
|
+
"rpc.method": opts.calleeMethod,
|
|
1620
|
+
"caller.service": "qqbot-cli",
|
|
1621
|
+
"caller.method": opts.callerMethod,
|
|
1622
|
+
...opts.extras
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
async (span) => {
|
|
1626
|
+
clientStartedTotal.add(1, attrs);
|
|
1627
|
+
const startTime = Date.now();
|
|
1628
|
+
try {
|
|
1629
|
+
const result = await fn();
|
|
1630
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1631
|
+
clientHandledTotal.add(1, attrs);
|
|
1632
|
+
return result;
|
|
1633
|
+
} catch (err) {
|
|
1634
|
+
span.setStatus({
|
|
1635
|
+
code: SpanStatusCode.ERROR,
|
|
1636
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1637
|
+
});
|
|
1638
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
1639
|
+
clientHandledTotal.add(1, {
|
|
1640
|
+
...attrs,
|
|
1641
|
+
[METRIC_ATTRS.CODE]: 1,
|
|
1642
|
+
[METRIC_ATTRS.CODE_TYPE]: "error"
|
|
1643
|
+
});
|
|
1644
|
+
throw err;
|
|
1645
|
+
} finally {
|
|
1646
|
+
span.end();
|
|
1647
|
+
clientHandledSeconds.record((Date.now() - startTime) / 1e3, attrs);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
function buildAttrs(opts) {
|
|
1653
|
+
return {
|
|
1654
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "qqbot-cli",
|
|
1655
|
+
[METRIC_ATTRS.CALLER_METHOD]: opts.callerMethod,
|
|
1656
|
+
[METRIC_ATTRS.CALLER_SERVER]: "qqbot-cli",
|
|
1657
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: opts.calleeService,
|
|
1658
|
+
[METRIC_ATTRS.CALLEE_METHOD]: opts.calleeMethod,
|
|
1659
|
+
[METRIC_ATTRS.CALLEE_SERVER]: opts.calleeServer ?? opts.calleeService,
|
|
1660
|
+
[METRIC_ATTRS.CODE]: 0,
|
|
1661
|
+
[METRIC_ATTRS.CODE_TYPE]: "success",
|
|
1662
|
+
...opts.extras
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/cloudagent/connection-pool.ts
|
|
1667
|
+
var AcpConnectionPool = class {
|
|
1668
|
+
entries = /* @__PURE__ */ new Map();
|
|
1669
|
+
/** soft-close 的 session(qualifier → sessionId),恢复时用 */
|
|
1670
|
+
evicted = /* @__PURE__ */ new Map();
|
|
1671
|
+
/** 并发创建去重 */
|
|
1672
|
+
pendingCreates = /* @__PURE__ */ new Map();
|
|
1673
|
+
cleanupTimer = null;
|
|
1674
|
+
cleaning = false;
|
|
1675
|
+
config;
|
|
1676
|
+
constructor(config) {
|
|
1677
|
+
this.config = config;
|
|
1678
|
+
if (config.enableCleanup) {
|
|
1679
|
+
this.startCleanup();
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
/** 当前活跃连接数 */
|
|
1683
|
+
get size() {
|
|
1684
|
+
return this.entries.size;
|
|
1685
|
+
}
|
|
1686
|
+
/** 获取 qualifier 对应的连接和 sessionId */
|
|
1687
|
+
get(qualifier) {
|
|
1688
|
+
return this.entries.get(qualifier);
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* 获取或创建 qualifier 对应的连接。
|
|
1692
|
+
* extSessionId: 外部指定的会话 ID(用于 session/load),undefined 则直接 session/new。
|
|
1693
|
+
*/
|
|
1694
|
+
async getOrCreate(qualifier, extSessionId) {
|
|
1695
|
+
const existing = this.entries.get(qualifier);
|
|
1696
|
+
if (existing && existing.status === "active") {
|
|
1697
|
+
existing.lastActivityAt = Date.now();
|
|
1698
|
+
return { client: existing.client, sessionId: existing.sessionId };
|
|
1699
|
+
}
|
|
1700
|
+
const pending = this.pendingCreates.get(qualifier);
|
|
1701
|
+
if (pending) {
|
|
1702
|
+
const entry = await pending;
|
|
1703
|
+
return { client: entry.client, sessionId: entry.sessionId };
|
|
1704
|
+
}
|
|
1705
|
+
const createPromise = this.ensureSession(qualifier, extSessionId);
|
|
1706
|
+
this.pendingCreates.set(qualifier, createPromise);
|
|
1707
|
+
try {
|
|
1708
|
+
const entry = await createPromise;
|
|
1709
|
+
return { client: entry.client, sessionId: entry.sessionId };
|
|
1710
|
+
} finally {
|
|
1711
|
+
this.pendingCreates.delete(qualifier);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
/** 刷新活跃时间 */
|
|
1715
|
+
touch(qualifier) {
|
|
1716
|
+
const entry = this.entries.get(qualifier);
|
|
1717
|
+
if (entry) {
|
|
1718
|
+
entry.lastActivityAt = Date.now();
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* 恢复断线的连接(外部调用,如 chat 捕获到 connection error)。
|
|
1723
|
+
* 关闭旧连接 → ensureSession 重新建立。
|
|
1724
|
+
*/
|
|
1725
|
+
async restore(qualifier) {
|
|
1726
|
+
const entry = this.entries.get(qualifier);
|
|
1727
|
+
if (entry) {
|
|
1728
|
+
await entry.client.close().catch(() => {
|
|
1729
|
+
});
|
|
1730
|
+
this.entries.delete(qualifier);
|
|
1731
|
+
}
|
|
1732
|
+
try {
|
|
1733
|
+
const restored = await this.ensureSession(qualifier);
|
|
1734
|
+
poolRestoreTotal.add(1, { result: "success" });
|
|
1735
|
+
return { client: restored.client, sessionId: restored.sessionId };
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
poolRestoreTotal.add(1, { result: "fail" });
|
|
1738
|
+
throw err;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
/** 主动关闭某个 qualifier 的连接 */
|
|
1742
|
+
async close(qualifier) {
|
|
1743
|
+
const entry = this.entries.get(qualifier);
|
|
1744
|
+
if (!entry) return;
|
|
1745
|
+
entry.status = "closing";
|
|
1746
|
+
await entry.client.close().catch(() => {
|
|
1747
|
+
});
|
|
1748
|
+
this.entries.delete(qualifier);
|
|
1749
|
+
if (this.config.controlPlane) {
|
|
1750
|
+
const { runtimeId, apiKey, endpoint } = this.config.controlPlane;
|
|
1751
|
+
await deleteControlPlaneSession(runtimeId, entry.sessionId, apiKey, endpoint, this.config.logger).catch((err) => {
|
|
1752
|
+
this.config.logger.debug(
|
|
1753
|
+
`[pool] deleteSession failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1754
|
+
);
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
/** 关闭所有连接(graceful shutdown) */
|
|
1759
|
+
async closeAll() {
|
|
1760
|
+
this.stopCleanup();
|
|
1761
|
+
const qualifiers = [...this.entries.keys()];
|
|
1762
|
+
await Promise.allSettled(qualifiers.map((q) => this.close(q)));
|
|
1763
|
+
this.entries.clear();
|
|
1764
|
+
this.evicted.clear();
|
|
1765
|
+
}
|
|
1766
|
+
// ─── 内部方法 ───
|
|
1767
|
+
/** 创建新连接 + 新 session */
|
|
1768
|
+
async createEntry(qualifier, extSessionId) {
|
|
1769
|
+
await this.ensureCapacity();
|
|
1770
|
+
const now = Date.now();
|
|
1771
|
+
const { logger } = this.config;
|
|
1772
|
+
let sessionId = extSessionId ?? qualifier;
|
|
1773
|
+
const client = this.createClient();
|
|
1774
|
+
await withClientMetrics({
|
|
1775
|
+
callerMethod: "connect",
|
|
1776
|
+
calleeService: "cloudagent-acp",
|
|
1777
|
+
calleeMethod: "connect"
|
|
1778
|
+
}, () => client.connect());
|
|
1779
|
+
if (this.config.controlPlane) {
|
|
1780
|
+
const { runtimeId, apiKey, endpoint } = this.config.controlPlane;
|
|
1781
|
+
let alreadyExists = false;
|
|
1782
|
+
try {
|
|
1783
|
+
await withClientMetrics({
|
|
1784
|
+
callerMethod: "session",
|
|
1785
|
+
calleeService: "agentos",
|
|
1786
|
+
calleeMethod: "createSession"
|
|
1787
|
+
}, () => createControlPlaneSession(runtimeId, sessionId, apiKey, endpoint, logger));
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1790
|
+
if (!msg.includes("already exists")) {
|
|
1791
|
+
throw err;
|
|
1792
|
+
}
|
|
1793
|
+
alreadyExists = true;
|
|
1794
|
+
logger.info(`[pool] session already exists: ${sessionId}, will load`);
|
|
1795
|
+
}
|
|
1796
|
+
if (!alreadyExists) {
|
|
1797
|
+
await waitForSessionReady(runtimeId, sessionId, apiKey, endpoint, { logger });
|
|
1798
|
+
}
|
|
1799
|
+
await withClientMetrics({
|
|
1800
|
+
callerMethod: "session",
|
|
1801
|
+
calleeService: "cloudagent-acp",
|
|
1802
|
+
calleeMethod: "loadSession"
|
|
1803
|
+
}, () => client.loadSession(sessionId));
|
|
1804
|
+
} else {
|
|
1805
|
+
if (extSessionId) {
|
|
1806
|
+
try {
|
|
1807
|
+
await client.loadSession(extSessionId);
|
|
1808
|
+
sessionId = extSessionId;
|
|
1809
|
+
logger.info(`[pool] direct session/load: ${sessionId}`);
|
|
1810
|
+
} catch {
|
|
1811
|
+
sessionId = await client.createSession("/workspace");
|
|
1812
|
+
logger.info(`[pool] direct session/new (load failed): ${sessionId}`);
|
|
1813
|
+
}
|
|
1814
|
+
} else {
|
|
1815
|
+
sessionId = await client.createSession("/workspace");
|
|
1816
|
+
logger.info(`[pool] direct session/new: ${sessionId}`);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
const entry = {
|
|
1820
|
+
qualifier,
|
|
1821
|
+
sessionId,
|
|
1822
|
+
client,
|
|
1823
|
+
status: "active",
|
|
1824
|
+
createdAt: now,
|
|
1825
|
+
lastActivityAt: now
|
|
1826
|
+
};
|
|
1827
|
+
this.entries.set(qualifier, entry);
|
|
1828
|
+
poolCreateSeconds.record((Date.now() - now) / 1e3);
|
|
1829
|
+
poolSize.record(this.size);
|
|
1830
|
+
logger.info(`[pool] created: qualifier=${qualifier} sessionId=${sessionId} (active=${this.size})`);
|
|
1831
|
+
return entry;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* 确保 session 存在并连接。
|
|
1835
|
+
* 1. 控制面 getSession 查是否已存在
|
|
1836
|
+
* 2. 存在 → connect + loadSession
|
|
1837
|
+
* 3. 不存在 → 控制面 create → 等就绪 → connect + loadSession
|
|
1838
|
+
*/
|
|
1839
|
+
async ensureSession(qualifier, extSessionId) {
|
|
1840
|
+
await this.ensureCapacity();
|
|
1841
|
+
const { logger } = this.config;
|
|
1842
|
+
const sessionId = extSessionId ?? qualifier;
|
|
1843
|
+
this.evicted.delete(qualifier);
|
|
1844
|
+
let sessionExists = false;
|
|
1845
|
+
if (this.config.controlPlane) {
|
|
1846
|
+
const { runtimeId, apiKey, endpoint } = this.config.controlPlane;
|
|
1847
|
+
const sess = await getSession(runtimeId, sessionId, apiKey, endpoint, logger);
|
|
1848
|
+
if (sess) {
|
|
1849
|
+
const status = sess.sessionStatus.toUpperCase();
|
|
1850
|
+
sessionExists = status !== "FAILED" && status !== "ERROR";
|
|
1851
|
+
logger.info(`[pool] session exists: ${sessionId} status=${sess.sessionStatus}`);
|
|
1852
|
+
} else {
|
|
1853
|
+
logger.info(`[pool] session not found: ${sessionId}, will create`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (!sessionExists) {
|
|
1857
|
+
return this.createEntry(qualifier, extSessionId);
|
|
1858
|
+
}
|
|
1859
|
+
const client = this.createClient();
|
|
1860
|
+
await withClientMetrics({
|
|
1861
|
+
callerMethod: "connect",
|
|
1862
|
+
calleeService: "cloudagent-acp",
|
|
1863
|
+
calleeMethod: "connect"
|
|
1864
|
+
}, () => client.connect());
|
|
1865
|
+
await withClientMetrics({
|
|
1866
|
+
callerMethod: "session",
|
|
1867
|
+
calleeService: "cloudagent-acp",
|
|
1868
|
+
calleeMethod: "loadSession"
|
|
1869
|
+
}, () => client.loadSession(sessionId));
|
|
1870
|
+
const now = Date.now();
|
|
1871
|
+
const entry = {
|
|
1872
|
+
qualifier,
|
|
1873
|
+
sessionId,
|
|
1874
|
+
client,
|
|
1875
|
+
status: "active",
|
|
1876
|
+
createdAt: now,
|
|
1877
|
+
lastActivityAt: now
|
|
1878
|
+
};
|
|
1879
|
+
this.entries.set(qualifier, entry);
|
|
1880
|
+
logger.info(`[pool] restored: qualifier=${qualifier} sessionId=${sessionId} (active=${this.size})`);
|
|
1881
|
+
return entry;
|
|
1882
|
+
}
|
|
1883
|
+
/** 确保有容量,超限则 LRU 淘汰 */
|
|
1884
|
+
async ensureCapacity() {
|
|
1885
|
+
const activeCount = [...this.entries.values()].filter((e) => e.status === "active").length;
|
|
1886
|
+
if (activeCount >= this.config.maxConnections) {
|
|
1887
|
+
await this.evictLRU();
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
/** LRU 淘汰最久未活跃的连接 */
|
|
1891
|
+
async evictLRU() {
|
|
1892
|
+
let oldest = null;
|
|
1893
|
+
for (const entry of this.entries.values()) {
|
|
1894
|
+
if (entry.status !== "active") continue;
|
|
1895
|
+
if (!oldest || entry.lastActivityAt < oldest.lastActivityAt) {
|
|
1896
|
+
oldest = entry;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (oldest) {
|
|
1900
|
+
poolEvictTotal.add(1);
|
|
1901
|
+
this.config.logger.info(
|
|
1902
|
+
`[pool] evicting LRU: qualifier=${oldest.qualifier} idle=${Date.now() - oldest.lastActivityAt}ms`
|
|
1903
|
+
);
|
|
1904
|
+
if (this.config.preserveOnEvict) {
|
|
1905
|
+
this.softClose(oldest.qualifier);
|
|
1906
|
+
} else {
|
|
1907
|
+
await this.close(oldest.qualifier);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
/** Soft-close:关闭 SSE 连接但保留服务端 session */
|
|
1912
|
+
softClose(qualifier) {
|
|
1913
|
+
const entry = this.entries.get(qualifier);
|
|
1914
|
+
if (!entry) return;
|
|
1915
|
+
this.config.logger.info(
|
|
1916
|
+
`[pool] soft-close: qualifier=${qualifier} sessionId=${entry.sessionId}`
|
|
1917
|
+
);
|
|
1918
|
+
entry.client.close().catch(() => {
|
|
1919
|
+
});
|
|
1920
|
+
this.evicted.set(qualifier, entry.sessionId);
|
|
1921
|
+
this.entries.delete(qualifier);
|
|
1922
|
+
}
|
|
1923
|
+
/** 创建 AcpClient 实例(不连接) */
|
|
1924
|
+
createClient() {
|
|
1925
|
+
return new AcpClient({
|
|
1926
|
+
endpoint: this.config.acpEndpoint,
|
|
1927
|
+
authToken: this.config.acpToken,
|
|
1928
|
+
maxRetries: this.config.acpConnectRetries,
|
|
1929
|
+
retryIntervalMs: this.config.acpRetryIntervalMs,
|
|
1930
|
+
eventTimeoutMs: this.config.eventTimeoutMs,
|
|
1931
|
+
extraMcpServers: this.config.mcpServers,
|
|
1932
|
+
logger: this.config.logger
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
// ─── 定时清理 ───
|
|
1936
|
+
startCleanup() {
|
|
1937
|
+
if (this.cleanupTimer) return;
|
|
1938
|
+
this.cleanupTimer = setInterval(() => {
|
|
1939
|
+
void this.runCleanup();
|
|
1940
|
+
}, this.config.cleanupIntervalMs);
|
|
1941
|
+
}
|
|
1942
|
+
stopCleanup() {
|
|
1943
|
+
if (this.cleanupTimer) {
|
|
1944
|
+
clearInterval(this.cleanupTimer);
|
|
1945
|
+
this.cleanupTimer = null;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
async runCleanup() {
|
|
1949
|
+
if (this.cleaning) return;
|
|
1950
|
+
this.cleaning = true;
|
|
1951
|
+
try {
|
|
1952
|
+
const now = Date.now();
|
|
1953
|
+
const expired = [];
|
|
1954
|
+
for (const [qualifier, entry] of this.entries) {
|
|
1955
|
+
if (entry.status === "active" && now - entry.lastActivityAt > this.config.idleTimeoutMs) {
|
|
1956
|
+
expired.push(qualifier);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (expired.length > 0) {
|
|
1960
|
+
this.config.logger.info(
|
|
1961
|
+
`[pool] cleanup: ${expired.length} idle connections (preserve=${this.config.preserveOnEvict})`
|
|
1962
|
+
);
|
|
1963
|
+
if (this.config.preserveOnEvict) {
|
|
1964
|
+
for (const q of expired) {
|
|
1965
|
+
this.softClose(q);
|
|
1966
|
+
}
|
|
1967
|
+
} else {
|
|
1968
|
+
await Promise.allSettled(expired.map((q) => this.close(q)));
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
} finally {
|
|
1972
|
+
this.cleaning = false;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
// src/backend/cloudagent.ts
|
|
1978
|
+
import { trace as trace2, SpanKind as SpanKind2, SpanStatusCode as SpanStatusCode2 } from "@opentelemetry/api";
|
|
1979
|
+
var tracer2 = trace2.getTracer("qqbot-cli");
|
|
1980
|
+
var CloudAgentBackend = class {
|
|
1981
|
+
name = "cloudagent";
|
|
1982
|
+
opts;
|
|
1983
|
+
pool = null;
|
|
1984
|
+
runtimeId = null;
|
|
1985
|
+
acpEndpoint = null;
|
|
1986
|
+
acpToken = null;
|
|
1987
|
+
constructor(opts) {
|
|
1988
|
+
this.opts = opts;
|
|
1989
|
+
}
|
|
1990
|
+
async init() {
|
|
1991
|
+
const { config, logger } = this.opts;
|
|
1992
|
+
logger.info(`[cloudagent] \u521D\u59CB\u5316: endpoint=${config.endpoint} sandbox.mode=${config.sandbox.mode}`);
|
|
1993
|
+
if (config.sandbox.mode === "direct") {
|
|
1994
|
+
if (!config.sandbox.acpEndpoint) {
|
|
1995
|
+
throw new Error("sandbox.mode=direct \u65F6\uFF0Csandbox.acpEndpoint \u5FC5\u586B");
|
|
1996
|
+
}
|
|
1997
|
+
this.acpEndpoint = config.sandbox.acpEndpoint;
|
|
1998
|
+
this.acpToken = config.sandbox.acpToken ?? config.apiKey ?? "";
|
|
1999
|
+
logger.info(`[cloudagent] \u76F4\u8FDE ACP: ${this.acpEndpoint}`);
|
|
2000
|
+
} else if (config.sandbox.mode === "manual" && config.sandbox.runtimeId) {
|
|
2001
|
+
if (!config.apiKey) {
|
|
2002
|
+
throw new Error("sandbox.mode=manual \u65F6\uFF0Cbackend.cloudagent.apiKey \u5FC5\u586B");
|
|
2003
|
+
}
|
|
2004
|
+
this.runtimeId = config.sandbox.runtimeId;
|
|
2005
|
+
const rt = await getRuntime(config.sandbox.runtimeId, config.apiKey, config.endpoint, logger);
|
|
2006
|
+
this.acpEndpoint = rt.links.acpLink.url;
|
|
2007
|
+
this.acpToken = rt.links.acpLink.token;
|
|
2008
|
+
logger.info(`[cloudagent] \u4F7F\u7528\u5DF2\u6709 runtime: ${this.runtimeId}`);
|
|
2009
|
+
} else {
|
|
2010
|
+
if (!config.apiKey) {
|
|
2011
|
+
throw new Error("sandbox.mode=auto \u65F6\uFF0Cbackend.cloudagent.apiKey \u5FC5\u586B");
|
|
2012
|
+
}
|
|
2013
|
+
const runtimeName = config.sandbox.runtimeName ?? `qqbot-${this.opts.appId}-${Date.now()}`;
|
|
2014
|
+
logger.info(`[cloudagent] \u521B\u5EFA\u6C99\u7BB1: ${runtimeName}`);
|
|
2015
|
+
const rt = await createSandbox({
|
|
2016
|
+
runtimeName,
|
|
2017
|
+
apiKey: config.apiKey,
|
|
2018
|
+
endpoint: config.endpoint,
|
|
2019
|
+
systemPrompt: config.manifest.systemPrompt,
|
|
2020
|
+
logger
|
|
2021
|
+
});
|
|
2022
|
+
this.runtimeId = rt.id;
|
|
2023
|
+
logger.info(`[cloudagent] \u6C99\u7BB1\u5DF2\u521B\u5EFA: id=${rt.id} status=${rt.status}`);
|
|
2024
|
+
if (rt.status !== "RUNNING") {
|
|
2025
|
+
logger.info(`[cloudagent] \u7B49\u5F85 Runtime RUNNING...`);
|
|
2026
|
+
const ready = await waitForRunning(rt.id, config.apiKey, config.endpoint, { logger });
|
|
2027
|
+
this.acpEndpoint = ready.links.acpLink.url;
|
|
2028
|
+
this.acpToken = ready.links.acpLink.token;
|
|
2029
|
+
} else {
|
|
2030
|
+
this.acpEndpoint = rt.links.acpLink.url;
|
|
2031
|
+
this.acpToken = rt.links.acpLink.token;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
const poolConfig = {
|
|
2035
|
+
maxConnections: config.session.maxConnections,
|
|
2036
|
+
enableCleanup: config.session.enableCleanup,
|
|
2037
|
+
idleTimeoutMs: config.session.idleTimeoutMs,
|
|
2038
|
+
cleanupIntervalMs: config.session.cleanupIntervalMs,
|
|
2039
|
+
preserveOnEvict: config.session.preserveOnEvict,
|
|
2040
|
+
acpEndpoint: this.acpEndpoint,
|
|
2041
|
+
acpToken: this.acpToken,
|
|
2042
|
+
acpConnectRetries: config.acp.maxRetries,
|
|
2043
|
+
acpRetryIntervalMs: config.acp.retryIntervalMs,
|
|
2044
|
+
eventTimeoutMs: config.acp.eventTimeoutMs,
|
|
2045
|
+
mcpServers: this.opts.extraMcpServers ?? [],
|
|
2046
|
+
controlPlane: this.runtimeId && config.apiKey ? { runtimeId: this.runtimeId, apiKey: config.apiKey, endpoint: config.endpoint } : void 0,
|
|
2047
|
+
logger
|
|
2048
|
+
};
|
|
2049
|
+
this.pool = new AcpConnectionPool(poolConfig);
|
|
2050
|
+
logger.info(`[cloudagent] \u8FDE\u63A5\u6C60\u5C31\u7EEA (max=${poolConfig.maxConnections})`);
|
|
2051
|
+
}
|
|
2052
|
+
async getOrCreateSession(qualifier, extSessionId) {
|
|
2053
|
+
const { sessionId } = await this.pool.getOrCreate(qualifier, extSessionId);
|
|
2054
|
+
return sessionId;
|
|
2055
|
+
}
|
|
2056
|
+
async *chat(params) {
|
|
2057
|
+
const scopeType = params.qualifier.startsWith("group") ? "group" : "c2c";
|
|
2058
|
+
const hasAttachments = (params.attachments?.length ?? 0) > 0;
|
|
2059
|
+
const clientAttrs = {
|
|
2060
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "qqbot-cli",
|
|
2061
|
+
[METRIC_ATTRS.CALLER_METHOD]: "chat",
|
|
2062
|
+
[METRIC_ATTRS.CALLER_SERVER]: "qqbot-cli",
|
|
2063
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: "cloudagent-acp",
|
|
2064
|
+
[METRIC_ATTRS.CALLEE_METHOD]: "prompt",
|
|
2065
|
+
[METRIC_ATTRS.CALLEE_SERVER]: "cloudagent-acp",
|
|
2066
|
+
[METRIC_ATTRS.CODE]: 0,
|
|
2067
|
+
[METRIC_ATTRS.CODE_TYPE]: "success",
|
|
2068
|
+
[METRIC_ATTRS.USER_EXT1]: scopeType,
|
|
2069
|
+
[METRIC_ATTRS.USER_EXT2]: hasAttachments ? "multimodal" : "text"
|
|
2070
|
+
};
|
|
2071
|
+
const span = tracer2.startSpan("cloudagent-acp/prompt", {
|
|
2072
|
+
kind: SpanKind2.CLIENT,
|
|
2073
|
+
attributes: {
|
|
2074
|
+
"rpc.system": "acp",
|
|
2075
|
+
"rpc.service": "cloudagent-acp",
|
|
2076
|
+
"rpc.method": "prompt",
|
|
2077
|
+
"caller.method": "chat",
|
|
2078
|
+
"msg.scope": scopeType,
|
|
2079
|
+
"msg.input_type": hasAttachments ? "multimodal" : "text"
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
clientStartedTotal.add(1, clientAttrs);
|
|
2083
|
+
const startTime = Date.now();
|
|
2084
|
+
let hasError = false;
|
|
2085
|
+
let firstChunkReceived = false;
|
|
2086
|
+
try {
|
|
2087
|
+
for await (const chunk of this.chatWithRetry(params, 1)) {
|
|
2088
|
+
if (!firstChunkReceived) {
|
|
2089
|
+
firstChunkReceived = true;
|
|
2090
|
+
acpTtfbSeconds.record((Date.now() - startTime) / 1e3, { scope: scopeType });
|
|
2091
|
+
span.addEvent("first_chunk", { ttfb_ms: Date.now() - startTime });
|
|
2092
|
+
}
|
|
2093
|
+
if (chunk.type === "tool" && chunk.status === "in_progress") {
|
|
2094
|
+
toolCallsTotal.add(1, { "tool.kind": chunk.kind ?? "other", scope: scopeType });
|
|
2095
|
+
}
|
|
2096
|
+
yield chunk;
|
|
2097
|
+
}
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
hasError = true;
|
|
2100
|
+
span.setStatus({
|
|
2101
|
+
code: SpanStatusCode2.ERROR,
|
|
2102
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2103
|
+
});
|
|
2104
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
2105
|
+
clientHandledTotal.add(1, {
|
|
2106
|
+
...clientAttrs,
|
|
2107
|
+
[METRIC_ATTRS.CODE]: 1,
|
|
2108
|
+
[METRIC_ATTRS.CODE_TYPE]: "error"
|
|
2109
|
+
});
|
|
2110
|
+
throw err;
|
|
2111
|
+
} finally {
|
|
2112
|
+
if (!hasError) {
|
|
2113
|
+
span.setStatus({ code: SpanStatusCode2.OK });
|
|
2114
|
+
clientHandledTotal.add(1, clientAttrs);
|
|
2115
|
+
}
|
|
2116
|
+
span.end();
|
|
2117
|
+
clientHandledSeconds.record((Date.now() - startTime) / 1e3, clientAttrs);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
async *chatWithRetry(params, retriesLeft) {
|
|
2121
|
+
const { qualifier, text, attachments } = params;
|
|
2122
|
+
try {
|
|
2123
|
+
const { client, sessionId } = await this.pool.getOrCreate(qualifier);
|
|
2124
|
+
const blocks = this.buildPromptBlocks(qualifier, text, attachments);
|
|
2125
|
+
this.opts.logger.debug?.(`[acp:prompt] session=${sessionId} ${JSON.stringify(blocks)}`);
|
|
2126
|
+
for await (const event of client.promptStream(sessionId, blocks)) {
|
|
2127
|
+
switch (event.type) {
|
|
2128
|
+
case "agent_message_chunk":
|
|
2129
|
+
yield { type: "text", content: event.text };
|
|
2130
|
+
break;
|
|
2131
|
+
case "agent_thought_chunk":
|
|
2132
|
+
yield { type: "thought", content: event.text };
|
|
2133
|
+
break;
|
|
2134
|
+
case "tool_call": {
|
|
2135
|
+
const kind = event.kind ?? "other";
|
|
2136
|
+
const title = event.title ?? "";
|
|
2137
|
+
if (event.status === "in_progress" || event.status === "completed" || event.status === "failed") {
|
|
2138
|
+
yield {
|
|
2139
|
+
type: "tool",
|
|
2140
|
+
content: title,
|
|
2141
|
+
status: event.status,
|
|
2142
|
+
toolCallId: event.toolCallId,
|
|
2143
|
+
kind,
|
|
2144
|
+
detail: summarizeRawInput(kind, title, event.rawInput)
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
case "tool_call_update":
|
|
2150
|
+
if (event.status === "completed" || event.status === "failed") {
|
|
2151
|
+
yield {
|
|
2152
|
+
type: "tool",
|
|
2153
|
+
content: "",
|
|
2154
|
+
status: event.status,
|
|
2155
|
+
toolCallId: event.toolCallId
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
break;
|
|
2159
|
+
case "plan":
|
|
2160
|
+
yield { type: "plan", content: this.formatPlan(event.entries) };
|
|
2161
|
+
break;
|
|
2162
|
+
case "done": {
|
|
2163
|
+
if (event.stopReason === "error" && this.isConnectionError(new Error(event.text))) {
|
|
2164
|
+
throw new Error(event.text);
|
|
2165
|
+
}
|
|
2166
|
+
yield { type: "done", content: "", stopReason: event.stopReason };
|
|
2167
|
+
break;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
this.pool.touch(qualifier);
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
if (retriesLeft > 0 && this.isConnectionError(err)) {
|
|
2174
|
+
chatRetryTotal.add(1, { result: "retry" });
|
|
2175
|
+
this.opts.logger.warn(
|
|
2176
|
+
`[cloudagent] \u8FDE\u63A5\u9519\u8BEF\uFF0C\u6062\u590D\u540E\u91CD\u8BD5: ${err instanceof Error ? err.message : String(err)}`
|
|
2177
|
+
);
|
|
2178
|
+
const { sessionId: newSessionId } = await this.pool.restore(qualifier);
|
|
2179
|
+
yield* this.chatWithRetry(
|
|
2180
|
+
{ ...params, sessionId: newSessionId },
|
|
2181
|
+
retriesLeft - 1
|
|
2182
|
+
);
|
|
2183
|
+
} else {
|
|
2184
|
+
if (retriesLeft <= 0 && this.isConnectionError(err)) {
|
|
2185
|
+
chatRetryTotal.add(1, { result: "exhausted" });
|
|
2186
|
+
}
|
|
2187
|
+
throw err;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
isConnectionError(err) {
|
|
2192
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2193
|
+
return msg.includes("agent not alive") || msg.includes("connection closed") || msg.includes("ACP connection closed") || msg.includes("Transport closed") || msg.includes("session not bound") || msg.includes("session not found") || msg.includes("session expired");
|
|
2194
|
+
}
|
|
2195
|
+
/** 取消当前 prompt */
|
|
2196
|
+
cancel(qualifier) {
|
|
2197
|
+
const entry = this.pool?.get(qualifier);
|
|
2198
|
+
if (entry) {
|
|
2199
|
+
entry.client.cancelPrompt(entry.sessionId);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
/** 切换权限模式 */
|
|
2203
|
+
async setMode(qualifier, mode) {
|
|
2204
|
+
const entry = this.pool?.get(qualifier);
|
|
2205
|
+
if (entry) {
|
|
2206
|
+
await entry.client.setMode(entry.sessionId, mode);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/** 切换模型 */
|
|
2210
|
+
async setModel(qualifier, modelId) {
|
|
2211
|
+
const entry = this.pool?.get(qualifier);
|
|
2212
|
+
if (entry) {
|
|
2213
|
+
await entry.client.setModel(entry.sessionId, modelId);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
async shutdown() {
|
|
2217
|
+
if (this.pool) {
|
|
2218
|
+
await this.pool.closeAll();
|
|
2219
|
+
this.pool = null;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
formatPlan(entries) {
|
|
2223
|
+
if (!entries || entries.length === 0) return "";
|
|
2224
|
+
return entries.map((e, i) => `${i + 1}. ${e.content ?? e.title ?? `step ${i + 1}`}`).join("\n");
|
|
2225
|
+
}
|
|
2226
|
+
buildPromptBlocks(_qualifier, text, attachments) {
|
|
2227
|
+
const blocks = [];
|
|
2228
|
+
if (text) {
|
|
2229
|
+
blocks.push({ type: "text", text });
|
|
2230
|
+
}
|
|
2231
|
+
if (attachments) {
|
|
2232
|
+
for (const att of attachments) {
|
|
2233
|
+
blocks.push({
|
|
2234
|
+
type: "resource_link",
|
|
2235
|
+
uri: att.url,
|
|
2236
|
+
name: att.name,
|
|
2237
|
+
mimeType: att.mimeType,
|
|
2238
|
+
...att.size ? { size: att.size } : {}
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
if (blocks.length === 0) {
|
|
2243
|
+
blocks.push({ type: "text", text: "(\u7A7A\u6D88\u606F)" });
|
|
2244
|
+
}
|
|
2245
|
+
return blocks;
|
|
2246
|
+
}
|
|
2247
|
+
};
|
|
2248
|
+
function summarizeRawInput(kind, title, rawInput) {
|
|
2249
|
+
if (!rawInput) return "";
|
|
2250
|
+
switch (kind) {
|
|
2251
|
+
case "search":
|
|
2252
|
+
return str(rawInput.query ?? rawInput.pattern ?? rawInput.regex);
|
|
2253
|
+
case "read":
|
|
2254
|
+
return str(rawInput.filePath ?? rawInput.path ?? rawInput.file);
|
|
2255
|
+
case "edit":
|
|
2256
|
+
case "write":
|
|
2257
|
+
return str(rawInput.filePath ?? rawInput.path ?? rawInput.file);
|
|
2258
|
+
case "delete":
|
|
2259
|
+
return str(rawInput.filePath ?? rawInput.path);
|
|
2260
|
+
case "fetch":
|
|
2261
|
+
return str(rawInput.url ?? rawInput.uri);
|
|
2262
|
+
case "execute":
|
|
2263
|
+
return str(rawInput.command ?? rawInput.cmd);
|
|
2264
|
+
case "think":
|
|
2265
|
+
return "";
|
|
2266
|
+
default:
|
|
2267
|
+
break;
|
|
2268
|
+
}
|
|
2269
|
+
const s = rawInput.skill ?? rawInput.command ?? rawInput.tool;
|
|
2270
|
+
const args = rawInput.args ?? rawInput.arguments ?? rawInput.input;
|
|
2271
|
+
if (s) {
|
|
2272
|
+
return args ? truncate(`${s}: ${typeof args === "string" ? args : JSON.stringify(args)}`) : str(s);
|
|
2273
|
+
}
|
|
2274
|
+
for (const v of Object.values(rawInput)) {
|
|
2275
|
+
if (typeof v === "string" && v.length > 0 && v.length < 120) {
|
|
2276
|
+
return truncate(v);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
return "";
|
|
2280
|
+
}
|
|
2281
|
+
function str(v) {
|
|
2282
|
+
return typeof v === "string" ? truncate(v) : "";
|
|
2283
|
+
}
|
|
2284
|
+
function truncate(s) {
|
|
2285
|
+
return s;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// src/mcp/server.ts
|
|
2289
|
+
import { McpServer as MCP } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2290
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2291
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
2292
|
+
import { createServer } from "http";
|
|
2293
|
+
import { randomBytes } from "crypto";
|
|
2294
|
+
import { existsSync as existsSync2 } from "fs";
|
|
2295
|
+
import { stat } from "fs/promises";
|
|
2296
|
+
import { basename, join } from "path";
|
|
2297
|
+
import { URL as URL2 } from "url";
|
|
2298
|
+
import { z as z3 } from "zod";
|
|
2299
|
+
|
|
2300
|
+
// src/mcp/openapi-tools.ts
|
|
2301
|
+
import { z as z2 } from "zod";
|
|
2302
|
+
function buildParamSchema(param) {
|
|
2303
|
+
let schema;
|
|
2304
|
+
switch (param.type) {
|
|
2305
|
+
case "string":
|
|
2306
|
+
schema = z2.string();
|
|
2307
|
+
break;
|
|
2308
|
+
case "number":
|
|
2309
|
+
schema = z2.number();
|
|
2310
|
+
break;
|
|
2311
|
+
case "boolean":
|
|
2312
|
+
schema = z2.boolean();
|
|
2313
|
+
break;
|
|
2314
|
+
case "string[]":
|
|
2315
|
+
schema = z2.array(z2.string());
|
|
2316
|
+
break;
|
|
2317
|
+
case "number[]":
|
|
2318
|
+
schema = z2.array(z2.number());
|
|
2319
|
+
break;
|
|
2320
|
+
default:
|
|
2321
|
+
schema = z2.string();
|
|
2322
|
+
}
|
|
2323
|
+
schema = schema.describe(param.desc);
|
|
2324
|
+
if (!param.required) {
|
|
2325
|
+
schema = schema.optional();
|
|
2326
|
+
}
|
|
2327
|
+
return schema;
|
|
2328
|
+
}
|
|
2329
|
+
function buildInputSchema(params) {
|
|
2330
|
+
const shape = {};
|
|
2331
|
+
for (const param of params) {
|
|
2332
|
+
shape[param.name] = buildParamSchema(param);
|
|
2333
|
+
}
|
|
2334
|
+
return shape;
|
|
2335
|
+
}
|
|
2336
|
+
function buildRequestBody(args, config) {
|
|
2337
|
+
const body = { ...config.fixed_body };
|
|
2338
|
+
const params = config.params ?? [];
|
|
2339
|
+
const mapping = config.param_mapping ?? {};
|
|
2340
|
+
for (const param of params) {
|
|
2341
|
+
const value = args[param.name] ?? param.default;
|
|
2342
|
+
if (value === void 0) continue;
|
|
2343
|
+
const fieldName = mapping[param.name] ?? param.name;
|
|
2344
|
+
body[fieldName] = value;
|
|
2345
|
+
}
|
|
2346
|
+
return body;
|
|
2347
|
+
}
|
|
2348
|
+
function buildOpenApiTools(config) {
|
|
2349
|
+
const tools = {};
|
|
2350
|
+
const globalTimeout = config.timeoutMs ?? 1e4;
|
|
2351
|
+
for (const api of config.apis) {
|
|
2352
|
+
const toolName = api.name;
|
|
2353
|
+
const method = (api.method ?? "POST").toUpperCase();
|
|
2354
|
+
tools[toolName] = {
|
|
2355
|
+
description: api.desc,
|
|
2356
|
+
inputSchema: buildInputSchema(api.params ?? []),
|
|
2357
|
+
timeoutMs: globalTimeout,
|
|
2358
|
+
handler: async (args, deps) => {
|
|
2359
|
+
const { bot, logger } = deps;
|
|
2360
|
+
const body = buildRequestBody(args, api);
|
|
2361
|
+
logger.info(`[mcp/openapi] ${toolName} \u2192 ${method} ${api.path} body=${JSON.stringify(body)}`);
|
|
2362
|
+
return withClientMetrics({
|
|
2363
|
+
callerMethod: `mcp_${toolName}`,
|
|
2364
|
+
calleeService: "qq-openapi",
|
|
2365
|
+
calleeMethod: api.path
|
|
2366
|
+
}, async () => {
|
|
2367
|
+
switch (method) {
|
|
2368
|
+
case "GET": {
|
|
2369
|
+
const query = {};
|
|
2370
|
+
for (const [k, v] of Object.entries(body)) {
|
|
2371
|
+
if (v !== void 0 && v !== null && typeof v !== "object") {
|
|
2372
|
+
query[k] = v;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return bot.api.get(api.path, Object.keys(query).length > 0 ? query : void 0);
|
|
2376
|
+
}
|
|
2377
|
+
case "POST":
|
|
2378
|
+
return bot.api.post(api.path, Object.keys(body).length > 0 ? body : void 0);
|
|
2379
|
+
case "PUT":
|
|
2380
|
+
return bot.api.put(api.path, Object.keys(body).length > 0 ? body : void 0);
|
|
2381
|
+
case "PATCH":
|
|
2382
|
+
return bot.api.patch(api.path, Object.keys(body).length > 0 ? body : void 0);
|
|
2383
|
+
case "DELETE":
|
|
2384
|
+
return bot.api.delete(api.path);
|
|
2385
|
+
default:
|
|
2386
|
+
throw new Error(`\u4E0D\u652F\u6301\u7684 HTTP \u65B9\u6CD5: ${method}`);
|
|
2387
|
+
}
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
return tools;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// src/mcp/server.ts
|
|
2396
|
+
var MSG_ID_CACHE_SIZE = 10;
|
|
2397
|
+
var MSG_ID_TTL_MS = 60 * 60 * 1e3;
|
|
2398
|
+
var MSG_ID_MAX_TARGETS = 200;
|
|
2399
|
+
var MsgIdCache = class {
|
|
2400
|
+
cache = /* @__PURE__ */ new Map();
|
|
2401
|
+
/** 记录一条消息 ID(LRU:访问即提升到最新) */
|
|
2402
|
+
push(targetKey, msgId) {
|
|
2403
|
+
const existing = this.cache.get(targetKey);
|
|
2404
|
+
if (existing) {
|
|
2405
|
+
this.cache.delete(targetKey);
|
|
2406
|
+
existing.push({ msgId, timestamp: Date.now() });
|
|
2407
|
+
if (existing.length > MSG_ID_CACHE_SIZE) {
|
|
2408
|
+
existing.shift();
|
|
2409
|
+
}
|
|
2410
|
+
this.cache.set(targetKey, existing);
|
|
2411
|
+
} else {
|
|
2412
|
+
this.cache.set(targetKey, [{ msgId, timestamp: Date.now() }]);
|
|
2413
|
+
if (this.cache.size > MSG_ID_MAX_TARGETS) {
|
|
2414
|
+
const oldest = this.cache.keys().next().value;
|
|
2415
|
+
if (oldest) this.cache.delete(oldest);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/** 获取最近的有效 msgId(未过期),过期返回 undefined */
|
|
2420
|
+
getLatest(targetKey) {
|
|
2421
|
+
const list = this.cache.get(targetKey);
|
|
2422
|
+
if (!list || list.length === 0) return void 0;
|
|
2423
|
+
const now = Date.now();
|
|
2424
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
2425
|
+
if (now - list[i].timestamp < MSG_ID_TTL_MS) {
|
|
2426
|
+
return list[i].msgId;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
return void 0;
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
var TOOLS = {
|
|
2433
|
+
send_file: {
|
|
2434
|
+
description: "\u5C06\u672C\u5730\u6587\u4EF6\u53D1\u9001\u7ED9 QQ \u7528\u6237\u6216\u7FA4\u3002\u652F\u6301\u56FE\u7247\u3001\u89C6\u9891\u3001\u8BED\u97F3\u548C\u901A\u7528\u6587\u4EF6\u3002",
|
|
2435
|
+
inputSchema: {
|
|
2436
|
+
file_path: z3.string().describe("\u672C\u5730\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u4F8B\u5982 /tmp/report.pdf"),
|
|
2437
|
+
target: z3.string().describe("\u53D1\u9001\u76EE\u6807\uFF0C\u683C\u5F0F\u4E3A scope:openid\uFF0C\u4F8B\u5982 c2c:xxx \u6216 group:xxx")
|
|
2438
|
+
},
|
|
2439
|
+
timeoutMs: 12e4,
|
|
2440
|
+
handler: async (args, deps) => {
|
|
2441
|
+
const { bot, logger, pathPrefix, msgIdCache } = deps;
|
|
2442
|
+
const resolvedPath = pathPrefix ? join(pathPrefix, args.file_path) : args.file_path;
|
|
2443
|
+
const [scope, targetId] = args.target.split(":", 2);
|
|
2444
|
+
if (!targetId || scope !== "c2c" && scope !== "group") {
|
|
2445
|
+
throw new Error(`target \u683C\u5F0F\u9519\u8BEF\uFF0C\u9700\u8981 c2c:openid \u6216 group:openid\uFF0C\u6536\u5230: ${args.target}`);
|
|
2446
|
+
}
|
|
2447
|
+
const msgId = msgIdCache.getLatest(args.target);
|
|
2448
|
+
const target = { scope, targetId, msgId };
|
|
2449
|
+
logger.debug?.(`[mcp/send_file] target=${args.target} msgId=${msgId ?? "(\u4E3B\u52A8\u6D88\u606F)"}`);
|
|
2450
|
+
if (!existsSync2(resolvedPath)) {
|
|
2451
|
+
throw new Error(`\u6587\u4EF6\u4E0D\u5B58\u5728: ${resolvedPath}`);
|
|
2452
|
+
}
|
|
2453
|
+
const fileStat = await stat(resolvedPath);
|
|
2454
|
+
if (!fileStat.isFile()) {
|
|
2455
|
+
throw new Error(`\u8DEF\u5F84\u4E0D\u662F\u666E\u901A\u6587\u4EF6: ${resolvedPath}`);
|
|
2456
|
+
}
|
|
2457
|
+
const fileName = basename(resolvedPath);
|
|
2458
|
+
const ext = resolvedPath.slice(resolvedPath.lastIndexOf(".")).toLowerCase();
|
|
2459
|
+
const sendMethod = IMAGE_EXTS.has(ext) ? "image" : VIDEO_EXTS.has(ext) ? "video" : VOICE_EXTS.has(ext) ? "voice" : "file";
|
|
2460
|
+
logger.info(
|
|
2461
|
+
`[mcp/send_file] \u53D1\u9001${sendMethod}: ${fileName} (${fileStat.size} bytes) \u2192 ${target.scope}:${target.targetId}`
|
|
2462
|
+
);
|
|
2463
|
+
const source = { localPath: resolvedPath };
|
|
2464
|
+
const result = await withClientMetrics({
|
|
2465
|
+
callerMethod: "mcp_send_file",
|
|
2466
|
+
calleeService: "qq-platform",
|
|
2467
|
+
calleeMethod: `send${sendMethod.charAt(0).toUpperCase()}${sendMethod.slice(1)}`
|
|
2468
|
+
}, async () => {
|
|
2469
|
+
return sendMethod === "image" ? await bot.sendImage(target, source) : sendMethod === "video" ? await bot.sendVideo(target, source) : sendMethod === "voice" ? await bot.sendVoice(target, source) : await bot.sendFile(target, source, { fileName });
|
|
2470
|
+
});
|
|
2471
|
+
return {
|
|
2472
|
+
success: true,
|
|
2473
|
+
fileName,
|
|
2474
|
+
fileSize: fileStat.size,
|
|
2475
|
+
target: `${target.scope}:${target.targetId}`,
|
|
2476
|
+
fileUuid: result.upload.file_uuid,
|
|
2477
|
+
messageId: result.message?.id
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
};
|
|
2482
|
+
var MCPServerHost = class {
|
|
2483
|
+
http;
|
|
2484
|
+
deps;
|
|
2485
|
+
/** Streamable HTTP sessions: sessionId → transport */
|
|
2486
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2487
|
+
/** Legacy SSE sessions: sessionId → transport */
|
|
2488
|
+
sseSessions = /* @__PURE__ */ new Map();
|
|
2489
|
+
/** 动态生成的 OpenAPI tools(从配置加载) */
|
|
2490
|
+
dynamicTools = {};
|
|
2491
|
+
port = 0;
|
|
2492
|
+
host = "127.0.0.1";
|
|
2493
|
+
async start(deps, listenPort = 0, listenHost = "127.0.0.1", openApiConfig) {
|
|
2494
|
+
this.deps = deps;
|
|
2495
|
+
this.host = listenHost;
|
|
2496
|
+
if (openApiConfig?.apis?.length) {
|
|
2497
|
+
this.dynamicTools = buildOpenApiTools(openApiConfig);
|
|
2498
|
+
deps.logger.info(`[mcp] \u5DF2\u52A0\u8F7D ${Object.keys(this.dynamicTools).length} \u4E2A OpenAPI tools: ${Object.keys(this.dynamicTools).join(", ")}`);
|
|
2499
|
+
}
|
|
2500
|
+
this.http = createServer(async (req, res) => {
|
|
2501
|
+
const url = new URL2(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
2502
|
+
const path2 = url.pathname;
|
|
2503
|
+
try {
|
|
2504
|
+
if (path2 === "/mcp") {
|
|
2505
|
+
await this.handleStreamableHTTP(req, res);
|
|
2506
|
+
} else if (path2 === "/sse") {
|
|
2507
|
+
await this.handleSSEConnect(req, res);
|
|
2508
|
+
} else if (path2 === "/messages") {
|
|
2509
|
+
await this.handleSSEMessage(req, res);
|
|
2510
|
+
} else {
|
|
2511
|
+
res.writeHead(404).end("Not Found");
|
|
2512
|
+
}
|
|
2513
|
+
} catch (err) {
|
|
2514
|
+
deps.logger.error(`[mcp] request error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2515
|
+
if (!res.headersSent) {
|
|
2516
|
+
res.writeHead(500).end("Internal Server Error");
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
});
|
|
2520
|
+
await new Promise(
|
|
2521
|
+
(resolve2) => this.http.listen(listenPort, listenHost, () => resolve2())
|
|
2522
|
+
);
|
|
2523
|
+
this.port = this.http.address().port;
|
|
2524
|
+
deps.logger.info(`[mcp] listening ${listenHost}:${this.port}`);
|
|
2525
|
+
}
|
|
2526
|
+
// ── Streamable HTTP:每个 initialize 创建新 session ──
|
|
2527
|
+
async handleStreamableHTTP(req, res) {
|
|
2528
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
2529
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
2530
|
+
const transport = this.sessions.get(sessionId);
|
|
2531
|
+
await transport.handleRequest(req, res);
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (req.method === "POST") {
|
|
2535
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2536
|
+
sessionIdGenerator: () => randomBytes(16).toString("hex"),
|
|
2537
|
+
onsessioninitialized: (id) => {
|
|
2538
|
+
this.sessions.set(id, transport);
|
|
2539
|
+
this.deps.logger.info(`[mcp] streamable session created: ${id}`);
|
|
2540
|
+
}
|
|
2541
|
+
});
|
|
2542
|
+
transport.onclose = () => {
|
|
2543
|
+
const id = transport.sessionId;
|
|
2544
|
+
if (id) {
|
|
2545
|
+
this.sessions.delete(id);
|
|
2546
|
+
this.deps.logger.info(`[mcp] streamable session closed: ${id}`);
|
|
2547
|
+
}
|
|
2548
|
+
};
|
|
2549
|
+
const mcp = this.createMcpServer();
|
|
2550
|
+
await mcp.connect(transport);
|
|
2551
|
+
await transport.handleRequest(req, res);
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2555
|
+
res.end(JSON.stringify({
|
|
2556
|
+
jsonrpc: "2.0",
|
|
2557
|
+
error: { code: -32e3, message: "Bad Request: No valid session. Send an initialize request first." },
|
|
2558
|
+
id: null
|
|
2559
|
+
}));
|
|
2560
|
+
}
|
|
2561
|
+
// ── Legacy SSE:GET /sse 建立流,POST /messages 发消息 ──
|
|
2562
|
+
async handleSSEConnect(req, res) {
|
|
2563
|
+
if (req.method !== "GET") {
|
|
2564
|
+
res.writeHead(405).end("Method Not Allowed");
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
2568
|
+
const sessionId = transport.sessionId;
|
|
2569
|
+
this.sseSessions.set(sessionId, transport);
|
|
2570
|
+
this.deps.logger.info(`[mcp] SSE session created: ${sessionId}`);
|
|
2571
|
+
transport.onclose = () => {
|
|
2572
|
+
this.sseSessions.delete(sessionId);
|
|
2573
|
+
this.deps.logger.info(`[mcp] SSE session closed: ${sessionId}`);
|
|
2574
|
+
};
|
|
2575
|
+
const mcp = this.createMcpServer();
|
|
2576
|
+
await mcp.connect(transport);
|
|
2577
|
+
}
|
|
2578
|
+
async handleSSEMessage(req, res) {
|
|
2579
|
+
if (req.method !== "POST") {
|
|
2580
|
+
res.writeHead(405).end("Method Not Allowed");
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
const url = new URL2(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
2584
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
2585
|
+
if (!sessionId || !this.sseSessions.has(sessionId)) {
|
|
2586
|
+
res.writeHead(400).end("Invalid or missing sessionId");
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
const transport = this.sseSessions.get(sessionId);
|
|
2590
|
+
await transport.handlePostMessage(req, res);
|
|
2591
|
+
}
|
|
2592
|
+
// ── 创建带工具注册的 MCP Server 实例 ──
|
|
2593
|
+
createMcpServer() {
|
|
2594
|
+
const mcp = new MCP({ name: "qqbot-tools", version: "0.1.0" });
|
|
2595
|
+
const allTools = { ...TOOLS, ...this.dynamicTools };
|
|
2596
|
+
for (const [name, tool] of Object.entries(allTools)) {
|
|
2597
|
+
const toolDeps = this.deps;
|
|
2598
|
+
const toolDef = tool;
|
|
2599
|
+
mcp.registerTool(
|
|
2600
|
+
name,
|
|
2601
|
+
{
|
|
2602
|
+
description: tool.description,
|
|
2603
|
+
inputSchema: tool.inputSchema
|
|
2604
|
+
},
|
|
2605
|
+
async (args) => {
|
|
2606
|
+
const t0 = Date.now();
|
|
2607
|
+
try {
|
|
2608
|
+
const result = await withTimeout(
|
|
2609
|
+
toolDef.handler(args, toolDeps),
|
|
2610
|
+
toolDef.timeoutMs
|
|
2611
|
+
);
|
|
2612
|
+
toolDeps.logger.info(`[mcp] ${name} ok ${Date.now() - t0}ms`);
|
|
2613
|
+
return {
|
|
2614
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
2615
|
+
};
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
toolDeps.logger.error(`[mcp] ${name} fail ${Date.now() - t0}ms: ${e?.message ?? e}`);
|
|
2618
|
+
return {
|
|
2619
|
+
isError: true,
|
|
2620
|
+
content: [{ type: "text", text: e?.message ?? String(e) }]
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
return mcp;
|
|
2627
|
+
}
|
|
2628
|
+
/** 给 ACP 用的 mcpServers 配置项 */
|
|
2629
|
+
getAcpEntry() {
|
|
2630
|
+
const callbackHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
|
|
2631
|
+
return {
|
|
2632
|
+
type: "http",
|
|
2633
|
+
name: "qqbot-tools",
|
|
2634
|
+
url: `http://${callbackHost}:${this.port}/mcp`,
|
|
2635
|
+
headers: []
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
async stop() {
|
|
2639
|
+
for (const transport of this.sessions.values()) {
|
|
2640
|
+
await transport.close?.().catch(() => {
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
for (const transport of this.sseSessions.values()) {
|
|
2644
|
+
await transport.close?.().catch(() => {
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
this.sessions.clear();
|
|
2648
|
+
this.sseSessions.clear();
|
|
2649
|
+
await new Promise(
|
|
2650
|
+
(resolve2) => this.http?.close(() => resolve2()) ?? resolve2()
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
function withTimeout(p, ms) {
|
|
2655
|
+
return new Promise((resolve2, reject) => {
|
|
2656
|
+
const timer = setTimeout(() => reject(new Error(`timeout ${ms}ms`)), ms);
|
|
2657
|
+
p.then(
|
|
2658
|
+
(v) => {
|
|
2659
|
+
clearTimeout(timer);
|
|
2660
|
+
resolve2(v);
|
|
2661
|
+
},
|
|
2662
|
+
(e) => {
|
|
2663
|
+
clearTimeout(timer);
|
|
2664
|
+
reject(e);
|
|
2665
|
+
}
|
|
2666
|
+
);
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
|
|
2670
|
+
var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".avi", ".mkv", ".webm"]);
|
|
2671
|
+
var VOICE_EXTS = /* @__PURE__ */ new Set([".mp3", ".wav", ".ogg", ".aac", ".silk", ".amr"]);
|
|
2672
|
+
|
|
2673
|
+
// src/slash/index.ts
|
|
2674
|
+
import {
|
|
2675
|
+
slashCommand
|
|
2676
|
+
} from "@tencent/qqbot-nodejs";
|
|
2677
|
+
function createSlashMiddleware(opts) {
|
|
2678
|
+
const slash = slashCommand({
|
|
2679
|
+
prefixes: opts.prefixes,
|
|
2680
|
+
autoHelp: false,
|
|
2681
|
+
allowFrom: opts.allowFrom
|
|
2682
|
+
});
|
|
2683
|
+
for (const cmd of buildCommands(opts.ctx, slash.list)) {
|
|
2684
|
+
const originalHandler = cmd.handler;
|
|
2685
|
+
cmd.handler = (ctx) => {
|
|
2686
|
+
slashCommandTotal.add(1, { command: cmd.name });
|
|
2687
|
+
return originalHandler(ctx);
|
|
2688
|
+
};
|
|
2689
|
+
slash.register(cmd);
|
|
2690
|
+
}
|
|
2691
|
+
return slash.middleware;
|
|
2692
|
+
}
|
|
2693
|
+
var DISPLAY_PRESETS = ["full", "compact", "minimal", "text-only"];
|
|
2694
|
+
function buildCommands(ctx, listCommands) {
|
|
2695
|
+
return [
|
|
2696
|
+
{
|
|
2697
|
+
name: "bot-help",
|
|
2698
|
+
description: "QQ Bot \u5E2E\u52A9\u6307\u4EE4",
|
|
2699
|
+
scope: "c2c",
|
|
2700
|
+
usage: [
|
|
2701
|
+
`/bot-help`,
|
|
2702
|
+
``,
|
|
2703
|
+
`\u67E5\u770B\u6240\u6709\u53EF\u7528\u7684\u5185\u7F6E\u547D\u4EE4\u53CA\u5176\u7B80\u8981\u8BF4\u660E\u3002`
|
|
2704
|
+
].join("\n"),
|
|
2705
|
+
handler: ({ message }) => {
|
|
2706
|
+
const msgKind = message.kind;
|
|
2707
|
+
const isC2C = msgKind === "c2c";
|
|
2708
|
+
const isGroup = msgKind === "group";
|
|
2709
|
+
const lines = [`### \u{1F916} QQ Bot \u5E2E\u52A9\u6307\u4EE4`, ``];
|
|
2710
|
+
for (const cmd of listCommands()) {
|
|
2711
|
+
if (cmd.hidden) continue;
|
|
2712
|
+
const cmdScope = cmd.scope ?? "all";
|
|
2713
|
+
if (cmdScope === "c2c" && !isC2C) continue;
|
|
2714
|
+
if (cmdScope === "group" && !isGroup) continue;
|
|
2715
|
+
const name = Array.isArray(cmd.name) ? cmd.name[0] : cmd.name;
|
|
2716
|
+
const desc = cmd.description ?? "";
|
|
2717
|
+
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${desc}`);
|
|
2718
|
+
}
|
|
2719
|
+
return lines.join("\n");
|
|
2720
|
+
}
|
|
2721
|
+
},
|
|
2722
|
+
{
|
|
2723
|
+
name: "bot-me",
|
|
2724
|
+
description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u7684 OpenID",
|
|
2725
|
+
scope: "c2c",
|
|
2726
|
+
usage: [
|
|
2727
|
+
`/bot-me`,
|
|
2728
|
+
``,
|
|
2729
|
+
`\u663E\u793A\u5F53\u524D\u7528\u6237\u7684 OpenID\u3002`
|
|
2730
|
+
].join("\n"),
|
|
2731
|
+
handler: ({ message }) => {
|
|
2732
|
+
const lines = [`\u{1F464} **${message.senderId}**`];
|
|
2733
|
+
if (message.groupOpenid) {
|
|
2734
|
+
lines.push(`\u{1F465} \u7FA4: **${message.groupOpenid}**`);
|
|
2735
|
+
}
|
|
2736
|
+
return lines.join("\n");
|
|
2737
|
+
}
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
name: "bot-ping",
|
|
2741
|
+
description: "\u6D4B\u8BD5 Agent \u670D\u52A1\u4E0E QQ \u95F4\u7F51\u7EDC\u5EF6\u8FDF",
|
|
2742
|
+
scope: "c2c",
|
|
2743
|
+
usage: [
|
|
2744
|
+
`/bot-ping`,
|
|
2745
|
+
``,
|
|
2746
|
+
`\u6D4B\u8BD5 Agent \u670D\u52A1\u4E0E QQ \u670D\u52A1\u5668\u4E4B\u95F4\u7684\u7F51\u7EDC\u5EF6\u8FDF\u3002`,
|
|
2747
|
+
`\u8FD4\u56DE\u7F51\u7EDC\u4F20\u8F93\u8017\u65F6\u548C\u5904\u7406\u8017\u65F6\u3002`
|
|
2748
|
+
].join("\n"),
|
|
2749
|
+
handler: (ctx2) => {
|
|
2750
|
+
const now = Date.now();
|
|
2751
|
+
const eventTime = new Date(ctx2.message.timestamp).getTime();
|
|
2752
|
+
if (Number.isNaN(eventTime)) {
|
|
2753
|
+
return `\u2705 pong!`;
|
|
2754
|
+
}
|
|
2755
|
+
const totalMs = now - eventTime;
|
|
2756
|
+
const qqToSdk = ctx2.receivedAt - eventTime;
|
|
2757
|
+
const sdkProcess = now - ctx2.receivedAt;
|
|
2758
|
+
return [
|
|
2759
|
+
`\u2705 pong!`,
|
|
2760
|
+
``,
|
|
2761
|
+
`\u23F1 \u5EF6\u8FDF\uFF1A${totalMs}ms`,
|
|
2762
|
+
` \u251C \u7F51\u7EDC\u4F20\u8F93\uFF1A${qqToSdk}ms`,
|
|
2763
|
+
` \u2514 \u5904\u7406\u8017\u65F6\uFF1A${sdkProcess}ms`
|
|
2764
|
+
].join("\n");
|
|
2765
|
+
}
|
|
2766
|
+
},
|
|
2767
|
+
{
|
|
2768
|
+
name: "bot-msg-mode",
|
|
2769
|
+
description: "\u5207\u6362\u6D88\u606F\u5C55\u793A\u6A21\u5F0F",
|
|
2770
|
+
scope: "c2c",
|
|
2771
|
+
usage: [
|
|
2772
|
+
`/bot-msg-mode <mode> \u5207\u6362\u5C55\u793A\u6A21\u5F0F`,
|
|
2773
|
+
`/bot-msg-mode \u67E5\u770B\u5F53\u524D\u6A21\u5F0F`,
|
|
2774
|
+
``,
|
|
2775
|
+
`\u53EF\u9009\u6A21\u5F0F: ${DISPLAY_PRESETS.join(" | ")}`
|
|
2776
|
+
].join("\n"),
|
|
2777
|
+
handler: ({ command }) => {
|
|
2778
|
+
const preset = command.args[0];
|
|
2779
|
+
if (!preset) {
|
|
2780
|
+
const cur = ctx.getConfig().message.display.preset;
|
|
2781
|
+
return [
|
|
2782
|
+
`\u5F53\u524D\u6A21\u5F0F\uFF1A**${cur}**`,
|
|
2783
|
+
``,
|
|
2784
|
+
`<qqbot-cmd-input text="/bot-msg-mode full" show="full"/> \u{1F50D} \u8BE6\u7EC6\u6A21\u5F0F \u2014 \u5C55\u793A AI \u7684\u6BCF\u4E00\u6B65\u601D\u8003\u548C\u64CD\u4F5C\u8FC7\u7A0B`,
|
|
2785
|
+
`<qqbot-cmd-input text="/bot-msg-mode compact" show="compact"/> \u{1F4CB} \u666E\u901A\u6A21\u5F0F \u2014 \u53EA\u5C55\u793A\u5173\u952E\u64CD\u4F5C\u548C\u7ED3\u679C`,
|
|
2786
|
+
`<qqbot-cmd-input text="/bot-msg-mode minimal" show="minimal"/> \u{1F4AC} \u7B80\u6D01\u6A21\u5F0F \u2014 \u53EA\u663E\u793A\u64CD\u4F5C\u540D\u79F0\uFF0C\u5185\u5BB9\u6298\u53E0`,
|
|
2787
|
+
`<qqbot-cmd-input text="/bot-msg-mode text-only" show="text-only"/> \u2728 \u7EAF\u51C0\u6A21\u5F0F \u2014 \u53EA\u770B\u5BF9\u8BDD\uFF0C\u9690\u85CF\u6240\u6709\u8FC7\u7A0B`
|
|
2788
|
+
].join("\n");
|
|
2789
|
+
}
|
|
2790
|
+
if (!DISPLAY_PRESETS.includes(preset)) {
|
|
2791
|
+
return [
|
|
2792
|
+
`\u274C \u65E0\u6548\u7684\u6A21\u5F0F: ${preset}`,
|
|
2793
|
+
``,
|
|
2794
|
+
`\u53EF\u9009: ${DISPLAY_PRESETS.join(" | ")}`
|
|
2795
|
+
].join("\n");
|
|
2796
|
+
}
|
|
2797
|
+
ctx.updateConfig({ display: expandDisplayPreset(preset) });
|
|
2798
|
+
return `\u2705 \u5DF2\u5207\u6362\u4E3A **${preset}** \u6A21\u5F0F`;
|
|
2799
|
+
}
|
|
2800
|
+
},
|
|
2801
|
+
{
|
|
2802
|
+
name: "bot-streaming",
|
|
2803
|
+
description: "\u5F00\u5173\u6D41\u5F0F\u6D88\u606F",
|
|
2804
|
+
scope: "c2c",
|
|
2805
|
+
usage: [
|
|
2806
|
+
`/bot-streaming on \u5F00\u542F\u6D41\u5F0F\u6D88\u606F`,
|
|
2807
|
+
`/bot-streaming off \u5173\u95ED\u6D41\u5F0F\u6D88\u606F`,
|
|
2808
|
+
`/bot-streaming \u67E5\u770B\u5F53\u524D\u72B6\u6001`,
|
|
2809
|
+
``,
|
|
2810
|
+
`\u5F00\u542F\u540E\uFF0CAI \u56DE\u590D\u4EE5\u6D41\u5F0F\u5F62\u5F0F\u9010\u6B65\u663E\u793A\u3002`
|
|
2811
|
+
].join("\n"),
|
|
2812
|
+
handler: ({ command }) => {
|
|
2813
|
+
const val = command.args[0];
|
|
2814
|
+
const currentOn = ctx.getConfig().streaming.c2c;
|
|
2815
|
+
if (!val) {
|
|
2816
|
+
return [
|
|
2817
|
+
`\u{1F4E1} \u6D41\u5F0F\u6D88\u606F\u72B6\u6001\uFF1A${currentOn ? "\u2705 \u5DF2\u5F00\u542F" : "\u274C \u5DF2\u5173\u95ED"}`,
|
|
2818
|
+
``,
|
|
2819
|
+
`<qqbot-cmd-input text="/bot-streaming on" show="on"/> \u5F00\u542F`,
|
|
2820
|
+
`<qqbot-cmd-input text="/bot-streaming off" show="off"/> \u5173\u95ED`
|
|
2821
|
+
].join("\n");
|
|
2822
|
+
}
|
|
2823
|
+
if (val !== "on" && val !== "off") {
|
|
2824
|
+
return [
|
|
2825
|
+
`\u274C \u53C2\u6570\u9519\u8BEF\uFF0C\u8BF7\u4F7F\u7528 on \u6216 off`,
|
|
2826
|
+
``,
|
|
2827
|
+
`\u793A\u4F8B\uFF1A/bot-streaming on`
|
|
2828
|
+
].join("\n");
|
|
2829
|
+
}
|
|
2830
|
+
const wantOn = val === "on";
|
|
2831
|
+
if (wantOn === currentOn) {
|
|
2832
|
+
return `\u{1F4E1} \u6D41\u5F0F\u6D88\u606F\u5DF2\u7ECF\u662F${wantOn ? "\u5F00\u542F" : "\u5173\u95ED"}\u72B6\u6001\uFF0C\u65E0\u9700\u64CD\u4F5C`;
|
|
2833
|
+
}
|
|
2834
|
+
ctx.updateConfig({ streamingC2c: wantOn });
|
|
2835
|
+
return [
|
|
2836
|
+
`\u2705 \u6D41\u5F0F\u6D88\u606F\u5DF2${wantOn ? "\u5F00\u542F" : "\u5173\u95ED"}`,
|
|
2837
|
+
``,
|
|
2838
|
+
wantOn ? `AI \u7684\u56DE\u590D\u5C06\u4EE5\u6D41\u5F0F\u5F62\u5F0F\u9010\u6B65\u663E\u793A\u3002` : `AI \u7684\u56DE\u590D\u5C06\u6062\u590D\u4E3A\u5B8C\u6574\u53D1\u9001\u3002`
|
|
2839
|
+
].join("\n");
|
|
2840
|
+
}
|
|
2841
|
+
},
|
|
2842
|
+
{
|
|
2843
|
+
name: "bot-config",
|
|
2844
|
+
description: "\u67E5\u770B\u5F53\u524D\u8FD0\u884C\u914D\u7F6E",
|
|
2845
|
+
scope: "c2c",
|
|
2846
|
+
usage: [
|
|
2847
|
+
`/bot-config`,
|
|
2848
|
+
``,
|
|
2849
|
+
`\u67E5\u770B\u5F53\u524D\u8FD0\u884C\u65F6\u7684\u5173\u952E\u914D\u7F6E\u9879\u3002`
|
|
2850
|
+
].join("\n"),
|
|
2851
|
+
handler: () => {
|
|
2852
|
+
const c = ctx.getConfig();
|
|
2853
|
+
const presetLabels = {
|
|
2854
|
+
full: "\u8BE6\u7EC6\u6A21\u5F0F",
|
|
2855
|
+
compact: "\u666E\u901A\u6A21\u5F0F",
|
|
2856
|
+
minimal: "\u7B80\u6D01\u6A21\u5F0F",
|
|
2857
|
+
"text-only": "\u7EAF\u51C0\u6A21\u5F0F"
|
|
2858
|
+
};
|
|
2859
|
+
const presetLabel = `${presetLabels[c.message.display.preset] ?? c.message.display.preset} (${c.message.display.preset})`;
|
|
2860
|
+
return [
|
|
2861
|
+
`### \u2699\uFE0F \u5F53\u524D\u8FD0\u884C\u914D\u7F6E`,
|
|
2862
|
+
``,
|
|
2863
|
+
`| \u914D\u7F6E\u9879 | \u503C |`,
|
|
2864
|
+
`| --- | --- |`,
|
|
2865
|
+
`| \u6D88\u606F\u6A21\u5F0F | ${presetLabel} |`,
|
|
2866
|
+
`| \u6D41\u5F0F\u6D88\u606F | ${c.streaming.c2c ? "\u2705 \u5F00\u542F" : "\u274C \u5173\u95ED"} |`
|
|
2867
|
+
].join("\n");
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
];
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// src/envelope.ts
|
|
2874
|
+
function formatQQBotEnvelope(ctx) {
|
|
2875
|
+
const parts = [];
|
|
2876
|
+
const msg = ctx.message;
|
|
2877
|
+
const content = (msg.content ?? "").trim();
|
|
2878
|
+
const isCommand = content.startsWith("/");
|
|
2879
|
+
if (!isCommand) {
|
|
2880
|
+
if (msg.kind === "group") {
|
|
2881
|
+
parts.push(`<routing target="group:${msg.groupOpenid ?? ""}" />`);
|
|
2882
|
+
} else {
|
|
2883
|
+
parts.push(`<routing target="c2c:${msg.senderId}" />`);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
const quote = ctx.state.quote;
|
|
2887
|
+
if (quote?.text) {
|
|
2888
|
+
parts.push(`[\u5F15\u7528] ${quote.text}`);
|
|
2889
|
+
}
|
|
2890
|
+
if (msg.kind === "group" && !isCommand) {
|
|
2891
|
+
const sender = msg.senderName || msg.senderId;
|
|
2892
|
+
parts.push(`<sender name="${sender}" />`);
|
|
2893
|
+
}
|
|
2894
|
+
if (content) {
|
|
2895
|
+
parts.push(content);
|
|
2896
|
+
}
|
|
2897
|
+
if (msg.attachments && msg.attachments.length > 0) {
|
|
2898
|
+
for (const att of msg.attachments) {
|
|
2899
|
+
if (att.asr_refer_text) {
|
|
2900
|
+
parts.push(`[\u8BED\u97F3\u8F6C\u6587\u5B57] ${att.asr_refer_text}`);
|
|
2901
|
+
} else {
|
|
2902
|
+
const name = att.filename ?? att.content_type ?? "file";
|
|
2903
|
+
parts.push(`[\u9644\u4EF6] ${name}`);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
if (msg.kind === "group") {
|
|
2908
|
+
const mentioned = Array.isArray(msg.mentions) && msg.mentions.some((m) => m?.is_you === true);
|
|
2909
|
+
if (mentioned) {
|
|
2910
|
+
parts.push("(@you)");
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
return parts.join("\n");
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
// src/display.ts
|
|
2917
|
+
var TOOL_EMOJI = {
|
|
2918
|
+
search: "\u{1F50D}",
|
|
2919
|
+
fetch: "\u{1F310}",
|
|
2920
|
+
execute: "\u26A1",
|
|
2921
|
+
read: "\u{1F4D6}",
|
|
2922
|
+
edit: "\u270F\uFE0F",
|
|
2923
|
+
think: "\u{1F4AD}",
|
|
2924
|
+
delete: "\u{1F5D1}\uFE0F",
|
|
2925
|
+
other: "\u{1F527}"
|
|
2926
|
+
};
|
|
2927
|
+
var TITLE_EMOJI = [
|
|
2928
|
+
["skill", "\u{1F9E9}"],
|
|
2929
|
+
["mcp", "\u{1F50C}"],
|
|
2930
|
+
["bash", "\u26A1"],
|
|
2931
|
+
["terminal", "\u26A1"],
|
|
2932
|
+
["shell", "\u26A1"],
|
|
2933
|
+
["browser", "\u{1F310}"],
|
|
2934
|
+
["web", "\u{1F310}"],
|
|
2935
|
+
["file", "\u{1F4C4}"],
|
|
2936
|
+
["write", "\u270F\uFE0F"],
|
|
2937
|
+
["create", "\u270F\uFE0F"],
|
|
2938
|
+
["list", "\u{1F4CB}"],
|
|
2939
|
+
["analyze", "\u{1F52C}"],
|
|
2940
|
+
["deploy", "\u{1F680}"],
|
|
2941
|
+
["install", "\u{1F4E6}"],
|
|
2942
|
+
["test", "\u{1F9EA}"],
|
|
2943
|
+
["git", "\u{1F4CC}"],
|
|
2944
|
+
["image", "\u{1F5BC}\uFE0F"],
|
|
2945
|
+
["upload", "\u{1F4E4}"],
|
|
2946
|
+
["download", "\u{1F4E5}"],
|
|
2947
|
+
["database", "\u{1F5C4}\uFE0F"],
|
|
2948
|
+
["sql", "\u{1F5C4}\uFE0F"],
|
|
2949
|
+
["api", "\u{1F50C}"]
|
|
2950
|
+
];
|
|
2951
|
+
function resolveEmoji(kind, title) {
|
|
2952
|
+
if (kind !== "other" || !title) return TOOL_EMOJI[kind] ?? "\u{1F527}";
|
|
2953
|
+
const lower = title.toLowerCase();
|
|
2954
|
+
for (const [prefix, emoji] of TITLE_EMOJI) {
|
|
2955
|
+
if (lower.startsWith(prefix) || lower.includes(prefix)) return emoji;
|
|
2956
|
+
}
|
|
2957
|
+
return "\u{1F527}";
|
|
2958
|
+
}
|
|
2959
|
+
function extractUrl(title) {
|
|
2960
|
+
const match = title.match(/https?:\/\/\S+/);
|
|
2961
|
+
return match?.[0];
|
|
2962
|
+
}
|
|
2963
|
+
function shortenUrl(url) {
|
|
2964
|
+
try {
|
|
2965
|
+
const parsed = new URL(url);
|
|
2966
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
2967
|
+
const pathname = parsed.pathname;
|
|
2968
|
+
if (pathname.length <= 1 && !parsed.search) {
|
|
2969
|
+
return host;
|
|
2970
|
+
}
|
|
2971
|
+
const shortPath = pathname.length > 20 ? `${pathname.slice(0, 20)}\u2026` : pathname;
|
|
2972
|
+
return `${host}${shortPath}`;
|
|
2973
|
+
} catch {
|
|
2974
|
+
return url.length > 40 ? `${url.slice(0, 40)}\u2026` : url;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
function formatTool(display, kind, title, detail) {
|
|
2978
|
+
const parts = [];
|
|
2979
|
+
const k = kind ?? "other";
|
|
2980
|
+
if (display.toolEmoji) {
|
|
2981
|
+
parts.push(resolveEmoji(k, title));
|
|
2982
|
+
}
|
|
2983
|
+
if (display.toolKind) {
|
|
2984
|
+
const label = k === "other" && title ? title : k.charAt(0).toUpperCase() + k.slice(1);
|
|
2985
|
+
parts.push(label);
|
|
2986
|
+
}
|
|
2987
|
+
if (display.toolTitle && title) {
|
|
2988
|
+
if (k === "execute" && detail) {
|
|
2989
|
+
const shouldTruncate = display.toolDetailTruncate?.includes(k);
|
|
2990
|
+
const maxLen = shouldTruncate ? display.toolDetailMaxLength ?? 120 : 0;
|
|
2991
|
+
const cmd = truncateDetail(detail, maxLen);
|
|
2992
|
+
const header = parts.join(" ");
|
|
2993
|
+
return `${header}
|
|
2994
|
+
\`\`\`
|
|
2995
|
+
${cmd}
|
|
2996
|
+
\`\`\``;
|
|
2997
|
+
}
|
|
2998
|
+
if (k === "fetch") {
|
|
2999
|
+
const url = extractUrl(title);
|
|
3000
|
+
if (url) {
|
|
3001
|
+
parts.push(`[${shortenUrl(url)}](${url})`);
|
|
3002
|
+
return parts.join(" ");
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
const labelUsed = k === "other" && display.toolKind;
|
|
3006
|
+
if (!labelUsed) {
|
|
3007
|
+
const t = title.toLowerCase().startsWith(k.toLowerCase()) ? display.toolKind ? title.slice(k.length).trim() : title : title;
|
|
3008
|
+
if (t) parts.push(t);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
if (display.toolTitle && detail && detail.trim()) {
|
|
3012
|
+
const titleLower = (title ?? "").toLowerCase().trim();
|
|
3013
|
+
const detailLower = detail.toLowerCase().trim();
|
|
3014
|
+
const titleNorm = titleLower.replace(/["""''`]/g, "").trim();
|
|
3015
|
+
const detailNorm = detailLower.replace(/["""''`]/g, "").trim();
|
|
3016
|
+
const isRedundant = titleNorm === detailNorm || titleNorm.includes(detailNorm) || detailNorm.includes(titleNorm);
|
|
3017
|
+
if (!isRedundant) {
|
|
3018
|
+
const shouldTruncate = display.toolDetailTruncate?.includes(k);
|
|
3019
|
+
const maxLen = shouldTruncate ? display.toolDetailMaxLength ?? 120 : 0;
|
|
3020
|
+
const truncated = truncateDetail(detail, maxLen);
|
|
3021
|
+
parts.push(`\`${truncated}\``);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
return parts.join(" ");
|
|
3025
|
+
}
|
|
3026
|
+
function truncateDetail(detail, maxLen) {
|
|
3027
|
+
if (maxLen <= 0 || detail.length <= maxLen) return detail;
|
|
3028
|
+
return `${detail.slice(0, maxLen - 1)}\u2026`;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
// src/reply.ts
|
|
3032
|
+
async function streamReply(rctx, ctx, msg, sessionId, qualifier, text, attachments) {
|
|
3033
|
+
const { bot, backend, config, logger } = rctx;
|
|
3034
|
+
const emptyReplyMsg = config.message.errorMessages.emptyReply;
|
|
3035
|
+
const stream = bot.openStream({
|
|
3036
|
+
target: msg.replyTarget,
|
|
3037
|
+
throttleMs: config.streaming.throttleMs
|
|
3038
|
+
});
|
|
3039
|
+
let replyText = "";
|
|
3040
|
+
const seenTools = /* @__PURE__ */ new Set();
|
|
3041
|
+
let planShown = false;
|
|
3042
|
+
let chunkCount = 0;
|
|
3043
|
+
const display = config.message.display;
|
|
3044
|
+
const scopeType = msg.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3045
|
+
try {
|
|
3046
|
+
for await (const chunk of backend.chat({ sessionId, qualifier, text, attachments })) {
|
|
3047
|
+
if (ctx.signal.aborted) {
|
|
3048
|
+
logger.debug(`[stream] ${qualifier} aborted, stopping`);
|
|
3049
|
+
backend.cancel?.(qualifier);
|
|
3050
|
+
stream.cancel();
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
switch (chunk.type) {
|
|
3054
|
+
case "text":
|
|
3055
|
+
chunkCount++;
|
|
3056
|
+
replyText += chunk.content;
|
|
3057
|
+
await stream.update(replyText);
|
|
3058
|
+
break;
|
|
3059
|
+
case "thought":
|
|
3060
|
+
if (display.thought) {
|
|
3061
|
+
replyText += chunk.content;
|
|
3062
|
+
await stream.update(replyText);
|
|
3063
|
+
}
|
|
3064
|
+
break;
|
|
3065
|
+
case "tool": {
|
|
3066
|
+
if (!display.tool) break;
|
|
3067
|
+
const tid = chunk.toolCallId ?? chunk.content;
|
|
3068
|
+
if (chunk.status === "in_progress" && !seenTools.has(tid)) {
|
|
3069
|
+
seenTools.add(tid);
|
|
3070
|
+
replyText += `
|
|
3071
|
+
${formatTool(display, chunk.kind, chunk.content, chunk.detail)}
|
|
3072
|
+
`;
|
|
3073
|
+
await stream.update(replyText);
|
|
3074
|
+
}
|
|
3075
|
+
break;
|
|
3076
|
+
}
|
|
3077
|
+
case "plan":
|
|
3078
|
+
if (display.plan && !planShown && chunk.content) {
|
|
3079
|
+
planShown = true;
|
|
3080
|
+
replyText += `
|
|
3081
|
+
\u{1F4CB} \u6267\u884C\u8BA1\u5212\uFF1A
|
|
3082
|
+
${chunk.content}
|
|
3083
|
+
`;
|
|
3084
|
+
await stream.update(replyText);
|
|
3085
|
+
}
|
|
3086
|
+
break;
|
|
3087
|
+
case "done": {
|
|
3088
|
+
const stopReason = chunk.stopReason ?? "end_turn";
|
|
3089
|
+
if (stopReason !== "end_turn" && stopReason !== "cancelled") {
|
|
3090
|
+
if (replyText) {
|
|
3091
|
+
replyChunksTotal.add(chunkCount, { scope: scopeType });
|
|
3092
|
+
replyLength.record(replyText.length, { scope: scopeType });
|
|
3093
|
+
await stream.complete();
|
|
3094
|
+
logger.info(`[stream] ${qualifier} \u5B8C\u6210(${stopReason}) (len=${replyText.length})`);
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
stream.cancel();
|
|
3098
|
+
throw new Error(`[${stopReason}] ${chunk.content || ""}`);
|
|
3099
|
+
}
|
|
3100
|
+
replyChunksTotal.add(chunkCount, { scope: scopeType });
|
|
3101
|
+
if (replyText) {
|
|
3102
|
+
replyLength.record(replyText.length, { scope: scopeType });
|
|
3103
|
+
await stream.complete();
|
|
3104
|
+
} else {
|
|
3105
|
+
replyEmptyTotal.add(1, { scope: scopeType });
|
|
3106
|
+
stream.cancel();
|
|
3107
|
+
await bot.sendText(msg.replyTarget, emptyReplyMsg);
|
|
3108
|
+
}
|
|
3109
|
+
logger.info(`[stream] ${qualifier} \u5B8C\u6210 (len=${replyText.length})`);
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
if (!ctx.signal.aborted) {
|
|
3115
|
+
replyChunksTotal.add(chunkCount, { scope: scopeType });
|
|
3116
|
+
if (replyText) {
|
|
3117
|
+
replyLength.record(replyText.length, { scope: scopeType });
|
|
3118
|
+
await stream.complete();
|
|
3119
|
+
} else {
|
|
3120
|
+
replyEmptyTotal.add(1, { scope: scopeType });
|
|
3121
|
+
stream.cancel();
|
|
3122
|
+
await bot.sendText(msg.replyTarget, emptyReplyMsg);
|
|
3123
|
+
}
|
|
3124
|
+
logger.info(`[stream] ${qualifier} \u5B8C\u6210 (len=${replyText.length})`);
|
|
3125
|
+
} else {
|
|
3126
|
+
backend.cancel?.(qualifier);
|
|
3127
|
+
stream.cancel();
|
|
3128
|
+
logger.debug(`[stream] ${qualifier} aborted after loop`);
|
|
3129
|
+
}
|
|
3130
|
+
} catch (err) {
|
|
3131
|
+
stream.cancel();
|
|
3132
|
+
if (replyText && !ctx.signal.aborted) {
|
|
3133
|
+
replyFallbackTotal.add(1, { scope: scopeType });
|
|
3134
|
+
await bot.sendText(msg.replyTarget, replyText);
|
|
3135
|
+
}
|
|
3136
|
+
throw err;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
async function batchReply(rctx, ctx, msg, sessionId, qualifier, text, attachments) {
|
|
3140
|
+
const { bot, backend, config, logger } = rctx;
|
|
3141
|
+
const emptyReplyMsg = config.message.errorMessages.emptyReply;
|
|
3142
|
+
let replyText = "";
|
|
3143
|
+
const seenTools = /* @__PURE__ */ new Set();
|
|
3144
|
+
let planShown = false;
|
|
3145
|
+
let chunkCount = 0;
|
|
3146
|
+
const display = config.message.display;
|
|
3147
|
+
const scopeType = msg.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3148
|
+
for await (const chunk of backend.chat({ sessionId, qualifier, text, attachments })) {
|
|
3149
|
+
if (ctx.signal.aborted) {
|
|
3150
|
+
logger.debug(`[batch] ${qualifier} aborted, stopping`);
|
|
3151
|
+
backend.cancel?.(qualifier);
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
switch (chunk.type) {
|
|
3155
|
+
case "text":
|
|
3156
|
+
chunkCount++;
|
|
3157
|
+
replyText += chunk.content;
|
|
3158
|
+
break;
|
|
3159
|
+
case "thought":
|
|
3160
|
+
if (display.thought) {
|
|
3161
|
+
replyText += chunk.content;
|
|
3162
|
+
}
|
|
3163
|
+
break;
|
|
3164
|
+
case "tool": {
|
|
3165
|
+
if (!display.tool) break;
|
|
3166
|
+
const tid = chunk.toolCallId ?? chunk.content;
|
|
3167
|
+
if (chunk.status === "in_progress" && !seenTools.has(tid)) {
|
|
3168
|
+
seenTools.add(tid);
|
|
3169
|
+
replyText += `
|
|
3170
|
+
${formatTool(display, chunk.kind, chunk.content, chunk.detail)}
|
|
3171
|
+
`;
|
|
3172
|
+
}
|
|
3173
|
+
break;
|
|
3174
|
+
}
|
|
3175
|
+
case "plan":
|
|
3176
|
+
if (display.plan && !planShown && chunk.content) {
|
|
3177
|
+
planShown = true;
|
|
3178
|
+
replyText += `
|
|
3179
|
+
\u{1F4CB} \u6267\u884C\u8BA1\u5212\uFF1A
|
|
3180
|
+
${chunk.content}
|
|
3181
|
+
`;
|
|
3182
|
+
}
|
|
3183
|
+
break;
|
|
3184
|
+
case "done": {
|
|
3185
|
+
const stopReason = chunk.stopReason ?? "end_turn";
|
|
3186
|
+
if (stopReason !== "end_turn" && stopReason !== "cancelled") {
|
|
3187
|
+
if (replyText) break;
|
|
3188
|
+
throw new Error(`[${stopReason}] ${chunk.content || ""}`);
|
|
3189
|
+
}
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
if (ctx.signal.aborted) {
|
|
3195
|
+
backend.cancel?.(qualifier);
|
|
3196
|
+
logger.debug(`[batch] ${qualifier} aborted after loop`);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
const reply = replyText.trim() || emptyReplyMsg;
|
|
3200
|
+
replyChunksTotal.add(chunkCount, { scope: scopeType });
|
|
3201
|
+
if (!replyText.trim()) {
|
|
3202
|
+
replyEmptyTotal.add(1, { scope: scopeType });
|
|
3203
|
+
} else {
|
|
3204
|
+
replyLength.record(reply.length, { scope: scopeType });
|
|
3205
|
+
}
|
|
3206
|
+
await bot.sendText(msg.replyTarget, reply);
|
|
3207
|
+
logger.info(`[batch] ${qualifier} \u53D1\u9001 (len=${reply.length})`);
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// src/telemetry/middleware.ts
|
|
3211
|
+
import { trace as trace3, SpanStatusCode as SpanStatusCode3 } from "@opentelemetry/api";
|
|
3212
|
+
var tracer3 = trace3.getTracer("qqbot-cli");
|
|
3213
|
+
function telemetryMiddleware(opts) {
|
|
3214
|
+
return async (ctx, next) => {
|
|
3215
|
+
const msg = ctx.message;
|
|
3216
|
+
const scope = msg.replyTarget.scope;
|
|
3217
|
+
const kind = msg.kind ?? "unknown";
|
|
3218
|
+
const scopeType = scope.startsWith("group") ? "group" : "c2c";
|
|
3219
|
+
const attrs = {
|
|
3220
|
+
[METRIC_ATTRS.CALLER_SERVICE]: "qq-platform",
|
|
3221
|
+
[METRIC_ATTRS.CALLER_METHOD]: kind,
|
|
3222
|
+
[METRIC_ATTRS.CALLER_SERVER]: "qq-platform",
|
|
3223
|
+
[METRIC_ATTRS.CALLEE_SERVICE]: "qqbot-cli",
|
|
3224
|
+
[METRIC_ATTRS.CALLEE_METHOD]: "handleMessage",
|
|
3225
|
+
[METRIC_ATTRS.CALLEE_SERVER]: "qqbot-cli",
|
|
3226
|
+
[METRIC_ATTRS.USER_EXT1]: scopeType
|
|
3227
|
+
};
|
|
3228
|
+
const startTime = Date.now();
|
|
3229
|
+
await tracer3.startActiveSpan(
|
|
3230
|
+
"message",
|
|
3231
|
+
{
|
|
3232
|
+
attributes: {
|
|
3233
|
+
"bot.app_id": opts.appId,
|
|
3234
|
+
"msg.kind": kind,
|
|
3235
|
+
"msg.scope": scope,
|
|
3236
|
+
"msg.sender": msg.senderId ?? "",
|
|
3237
|
+
"msg.content.length": (msg.content ?? "").length
|
|
3238
|
+
}
|
|
3239
|
+
},
|
|
3240
|
+
async (span) => {
|
|
3241
|
+
let hasError = false;
|
|
3242
|
+
try {
|
|
3243
|
+
await next();
|
|
3244
|
+
span.setStatus({ code: SpanStatusCode3.OK });
|
|
3245
|
+
} catch (err) {
|
|
3246
|
+
hasError = true;
|
|
3247
|
+
span.setStatus({
|
|
3248
|
+
code: SpanStatusCode3.ERROR,
|
|
3249
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3250
|
+
});
|
|
3251
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
3252
|
+
throw err;
|
|
3253
|
+
} finally {
|
|
3254
|
+
span.end();
|
|
3255
|
+
const handleType = ctx.state.handled ? "handled" : "buffered";
|
|
3256
|
+
const baseAttrs = {
|
|
3257
|
+
...attrs,
|
|
3258
|
+
[METRIC_ATTRS.USER_EXT2]: handleType
|
|
3259
|
+
};
|
|
3260
|
+
serverStartedTotal.add(1, { ...baseAttrs, [METRIC_ATTRS.CODE]: 0, [METRIC_ATTRS.CODE_TYPE]: "success" });
|
|
3261
|
+
const resultAttrs = { ...baseAttrs, [METRIC_ATTRS.CODE]: hasError ? 1 : 0, [METRIC_ATTRS.CODE_TYPE]: hasError ? "error" : "success" };
|
|
3262
|
+
serverHandledTotal.add(1, resultAttrs);
|
|
3263
|
+
serverHandledSeconds.record((Date.now() - startTime) / 1e3, resultAttrs);
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
);
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// src/runner.ts
|
|
3271
|
+
var require2 = createRequire(import.meta.url);
|
|
3272
|
+
var { version: CLI_VERSION } = require2("../package.json");
|
|
3273
|
+
var BotRunner = class {
|
|
3274
|
+
bot;
|
|
3275
|
+
backend;
|
|
3276
|
+
config;
|
|
3277
|
+
logger;
|
|
3278
|
+
mcpHost;
|
|
3279
|
+
msgIdCache = new MsgIdCache();
|
|
3280
|
+
/** 写回配置文件的回调,由外部(cli.ts)注入 */
|
|
3281
|
+
configWriter;
|
|
3282
|
+
constructor(config, logger) {
|
|
3283
|
+
this.config = config;
|
|
3284
|
+
this.logger = logger;
|
|
3285
|
+
this.bot = new QQBot({
|
|
3286
|
+
appId: config.qq.appId,
|
|
3287
|
+
appSecret: config.qq.appSecret,
|
|
3288
|
+
markdownSupport: config.qq.markdown,
|
|
3289
|
+
baseUrl: config.qq.baseUrl,
|
|
3290
|
+
tokenBaseUrl: config.qq.tokenBaseUrl,
|
|
3291
|
+
tokenPrefetch: config.qq.tokenPrefetch,
|
|
3292
|
+
userAgent: `${config.qq.userAgent ?? `qqbot-cli/${CLI_VERSION}`} (Node/${process.versions.node})`,
|
|
3293
|
+
transport: config.qq.transport,
|
|
3294
|
+
...config.qq.transport === "webhook" ? {
|
|
3295
|
+
webhook: {
|
|
3296
|
+
port: config.qq.webhook.port,
|
|
3297
|
+
path: config.qq.webhook.path
|
|
3298
|
+
}
|
|
3299
|
+
} : {},
|
|
3300
|
+
logger
|
|
3301
|
+
});
|
|
3302
|
+
this.backend = this.createBackend(config);
|
|
3303
|
+
this.setupMiddleware(config);
|
|
3304
|
+
this.bot.on("ready", () => {
|
|
3305
|
+
logger.info("\u{1F7E2} Bot online");
|
|
3306
|
+
});
|
|
3307
|
+
this.bot.on("resumed", () => {
|
|
3308
|
+
logger.info("\u{1F7E2} Bot resumed");
|
|
3309
|
+
});
|
|
3310
|
+
this.bot.on("error", (err) => {
|
|
3311
|
+
logger.error(`\u{1F534} ${err.message}`);
|
|
3312
|
+
});
|
|
3313
|
+
this.bot.on("message", (ctx, msg) => this.handleMessage(ctx, msg));
|
|
3314
|
+
}
|
|
3315
|
+
// ============ 生命周期 ============
|
|
3316
|
+
async start(signal) {
|
|
3317
|
+
const mcpServers = [];
|
|
3318
|
+
if (this.config.mcp.enabled) {
|
|
3319
|
+
this.mcpHost = new MCPServerHost();
|
|
3320
|
+
await this.mcpHost.start(
|
|
3321
|
+
{
|
|
3322
|
+
logger: this.logger,
|
|
3323
|
+
bot: this.bot,
|
|
3324
|
+
pathPrefix: this.config.mcp.pathPrefix,
|
|
3325
|
+
msgIdCache: this.msgIdCache
|
|
3326
|
+
},
|
|
3327
|
+
this.config.mcp.port,
|
|
3328
|
+
this.config.mcp.host,
|
|
3329
|
+
this.config.openapi
|
|
3330
|
+
);
|
|
3331
|
+
mcpServers.push(this.mcpHost.getAcpEntry());
|
|
3332
|
+
this.logger.info(`[runner] MCP Server \u5DF2\u542F\u52A8 (${this.config.mcp.host}:${this.mcpHost.port})`);
|
|
3333
|
+
}
|
|
3334
|
+
const cloudagentCfg = this.config.backend.cloudagent;
|
|
3335
|
+
if (cloudagentCfg?.mcpServers) {
|
|
3336
|
+
for (const srv of cloudagentCfg.mcpServers) {
|
|
3337
|
+
const headers = Object.entries(srv.headers).map(([name, value]) => ({ name, value }));
|
|
3338
|
+
mcpServers.push({ type: srv.type, name: srv.name, url: srv.url, headers });
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
if (mcpServers.length > 0 && this.backend instanceof CloudAgentBackend) {
|
|
3342
|
+
this.backend.opts.extraMcpServers = mcpServers;
|
|
3343
|
+
this.logger.info(`[runner] MCP Servers: ${mcpServers.map((s) => s.name).join(", ")}`);
|
|
3344
|
+
}
|
|
3345
|
+
this.logger.info(`[runner] \u521D\u59CB\u5316\u540E\u7AEF: ${this.backend.name}`);
|
|
3346
|
+
await this.backend.init();
|
|
3347
|
+
const transport = this.config.qq.transport;
|
|
3348
|
+
if (transport === "webhook") {
|
|
3349
|
+
this.logger.info(`[runner] \u540E\u7AEF\u5C31\u7EEA\uFF0C\u542F\u52A8 Webhook \u670D\u52A1 (port=${this.config.qq.webhook.port}, path=${this.config.qq.webhook.path})...`);
|
|
3350
|
+
} else {
|
|
3351
|
+
this.logger.info(`[runner] \u540E\u7AEF\u5C31\u7EEA\uFF0C\u542F\u52A8 QQ WebSocket...`);
|
|
3352
|
+
}
|
|
3353
|
+
await this.bot.start(signal);
|
|
3354
|
+
}
|
|
3355
|
+
async stop() {
|
|
3356
|
+
this.bot.stop();
|
|
3357
|
+
await this.backend.shutdown();
|
|
3358
|
+
await this.mcpHost?.stop().catch(() => {
|
|
3359
|
+
});
|
|
3360
|
+
this.logger.info(`[runner] \u5DF2\u505C\u6B62`);
|
|
3361
|
+
}
|
|
3362
|
+
// ============ 配置管理 ============
|
|
3363
|
+
/** 注入配置文件写回能力(由 cli.ts 在创建 ConfigWatcher 后调用) */
|
|
3364
|
+
setConfigWriter(writer) {
|
|
3365
|
+
this.configWriter = writer;
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* 热更新配置。校验通过后立即生效的字段:
|
|
3369
|
+
* - middleware(mentionGate/rateLimiter/concurrency/typingIndicator 等)
|
|
3370
|
+
* - message.display(展示样式)
|
|
3371
|
+
* - streaming.throttleMs
|
|
3372
|
+
* - log.level
|
|
3373
|
+
* - backend.cloudagent.acp.eventTimeoutMs
|
|
3374
|
+
* - backend.cloudagent.manifest.systemPrompt
|
|
3375
|
+
*
|
|
3376
|
+
* 需要重启才能生效的字段(仅日志提示):
|
|
3377
|
+
* - qq.appId/appSecret/transport
|
|
3378
|
+
* - backend.type
|
|
3379
|
+
* - backend.cloudagent.sandbox
|
|
3380
|
+
*/
|
|
3381
|
+
applyConfigUpdate(newConfig) {
|
|
3382
|
+
const old = this.config;
|
|
3383
|
+
const restartNeeded = [];
|
|
3384
|
+
if (old.qq.appId !== newConfig.qq.appId) restartNeeded.push("qq.appId");
|
|
3385
|
+
if (old.qq.appSecret !== newConfig.qq.appSecret) restartNeeded.push("qq.appSecret");
|
|
3386
|
+
if (old.qq.transport !== newConfig.qq.transport) restartNeeded.push("qq.transport");
|
|
3387
|
+
if (old.backend.type !== newConfig.backend.type) restartNeeded.push("backend.type");
|
|
3388
|
+
if (JSON.stringify(old.backend.cloudagent?.sandbox) !== JSON.stringify(newConfig.backend.cloudagent?.sandbox)) {
|
|
3389
|
+
restartNeeded.push("backend.cloudagent.sandbox");
|
|
3390
|
+
}
|
|
3391
|
+
if (restartNeeded.length > 0) {
|
|
3392
|
+
this.logger.warn(
|
|
3393
|
+
`[config] \u4EE5\u4E0B\u914D\u7F6E\u53D8\u66F4\u9700\u8981\u91CD\u542F\u624D\u80FD\u751F\u6548: ${restartNeeded.join(", ")}`
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
3396
|
+
this.config = newConfig;
|
|
3397
|
+
if (old.log.level !== newConfig.log.level) {
|
|
3398
|
+
this.logger.info(`[config] log.level: ${old.log.level} \u2192 ${newConfig.log.level}`);
|
|
3399
|
+
}
|
|
3400
|
+
this.logger.info("[config] hot reload applied");
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* 通过斜杠指令部分更新配置。
|
|
3404
|
+
* 同时写回配置文件(如果 configWriter 已注入)。
|
|
3405
|
+
*/
|
|
3406
|
+
applyConfigPatch(patch) {
|
|
3407
|
+
const c = this.config;
|
|
3408
|
+
const filePatches = {};
|
|
3409
|
+
if (patch.display) {
|
|
3410
|
+
c.message.display = patch.display;
|
|
3411
|
+
filePatches["message.display.preset"] = patch.display.preset;
|
|
3412
|
+
this.logger.info(`[config] display \u2192 ${patch.display.preset} (tool=${patch.display.tool}, thought=${patch.display.thought}, plan=${patch.display.plan})`);
|
|
3413
|
+
this.logger.debug(`[config] display verify: preset=${this.config.message.display.preset} thought=${this.config.message.display.thought}`);
|
|
3414
|
+
}
|
|
3415
|
+
if (patch.logLevel) {
|
|
3416
|
+
c.log.level = patch.logLevel;
|
|
3417
|
+
filePatches["log.level"] = patch.logLevel;
|
|
3418
|
+
this.logger.info(`[config] log.level \u2192 ${patch.logLevel}`);
|
|
3419
|
+
}
|
|
3420
|
+
if (patch.streamingC2c !== void 0) {
|
|
3421
|
+
c.streaming.c2c = patch.streamingC2c;
|
|
3422
|
+
filePatches["streaming.c2c"] = patch.streamingC2c;
|
|
3423
|
+
this.logger.info(`[config] streaming.c2c \u2192 ${patch.streamingC2c}`);
|
|
3424
|
+
}
|
|
3425
|
+
if (patch.streamingThrottleMs !== void 0) {
|
|
3426
|
+
c.streaming.throttleMs = patch.streamingThrottleMs;
|
|
3427
|
+
filePatches["streaming.throttleMs"] = patch.streamingThrottleMs;
|
|
3428
|
+
}
|
|
3429
|
+
if (this.configWriter && Object.keys(filePatches).length > 0) {
|
|
3430
|
+
this.configWriter(filePatches, this.config);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
// ============ Backend 工厂 ============
|
|
3434
|
+
createBackend(config) {
|
|
3435
|
+
switch (config.backend.type) {
|
|
3436
|
+
case "echo":
|
|
3437
|
+
return new EchoBackend();
|
|
3438
|
+
case "openai": {
|
|
3439
|
+
const opts = config.backend.openai;
|
|
3440
|
+
if (!opts) throw new Error("backend.openai \u914D\u7F6E\u7F3A\u5931");
|
|
3441
|
+
return new OpenAIBackend({
|
|
3442
|
+
apiKey: opts.apiKey,
|
|
3443
|
+
baseUrl: opts.baseUrl,
|
|
3444
|
+
model: opts.model,
|
|
3445
|
+
systemPrompt: opts.systemPrompt,
|
|
3446
|
+
maxTokens: opts.maxTokens,
|
|
3447
|
+
temperature: opts.temperature,
|
|
3448
|
+
logger: this.logger
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
case "cloudagent": {
|
|
3452
|
+
const opts = config.backend.cloudagent;
|
|
3453
|
+
if (!opts) throw new Error("backend.cloudagent \u914D\u7F6E\u7F3A\u5931");
|
|
3454
|
+
return new CloudAgentBackend({
|
|
3455
|
+
config: opts,
|
|
3456
|
+
appId: config.qq.appId,
|
|
3457
|
+
logger: this.logger
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
default:
|
|
3461
|
+
throw new Error(`\u672A\u77E5\u540E\u7AEF\u7C7B\u578B: ${config.backend.type}`);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
// ============ Middleware 装配 ============
|
|
3465
|
+
setupMiddleware(config) {
|
|
3466
|
+
const mw = config.middleware;
|
|
3467
|
+
this.bot.use(errorHandler());
|
|
3468
|
+
this.bot.use(telemetryMiddleware({ appId: this.config.qq.appId }));
|
|
3469
|
+
this.bot.use(messageFilter({
|
|
3470
|
+
skipSelfEcho: mw.messageFilter.skipSelfEcho,
|
|
3471
|
+
dedup: mw.messageFilter.dedup
|
|
3472
|
+
}));
|
|
3473
|
+
this.bot.use(contentSanitizer({
|
|
3474
|
+
stripBotMention: mw.contentSanitizer.stripBotMention,
|
|
3475
|
+
collapseWhitespace: mw.contentSanitizer.collapseWhitespace
|
|
3476
|
+
}));
|
|
3477
|
+
this.bot.use(mentionGate({
|
|
3478
|
+
requireMentionInGroup: mw.mentionGate.requireMentionInGroup,
|
|
3479
|
+
alwaysAnswerC2C: mw.mentionGate.alwaysAnswerC2C
|
|
3480
|
+
}));
|
|
3481
|
+
if (mw.slashCommands.enabled) {
|
|
3482
|
+
this.bot.use(createSlashMiddleware({
|
|
3483
|
+
prefixes: mw.slashCommands.prefixes,
|
|
3484
|
+
allowFrom: mw.slashCommands.allowFrom,
|
|
3485
|
+
ctx: {
|
|
3486
|
+
backend: this.backend,
|
|
3487
|
+
logger: this.logger,
|
|
3488
|
+
getConfig: () => this.config,
|
|
3489
|
+
updateConfig: (patch) => this.applyConfigPatch(patch)
|
|
3490
|
+
}
|
|
3491
|
+
}));
|
|
3492
|
+
}
|
|
3493
|
+
if (mw.rateLimiter.enabled) {
|
|
3494
|
+
this.bot.use(rateLimiter({
|
|
3495
|
+
perSender: mw.rateLimiter.perSender,
|
|
3496
|
+
global: mw.rateLimiter.global,
|
|
3497
|
+
onLimit: (ctx, tier) => {
|
|
3498
|
+
const scopeType = ctx.message.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3499
|
+
rateLimitRejectedTotal.add(1, { scope: scopeType, tier });
|
|
3500
|
+
}
|
|
3501
|
+
}));
|
|
3502
|
+
}
|
|
3503
|
+
this.bot.use(quoteRef());
|
|
3504
|
+
this.bot.use(envelopeFormatter({
|
|
3505
|
+
format: (ctx) => formatQQBotEnvelope(ctx)
|
|
3506
|
+
}));
|
|
3507
|
+
this.bot.use(concurrencyGuard({
|
|
3508
|
+
strategy: mw.concurrency.strategy,
|
|
3509
|
+
maxQueue: mw.concurrency.maxQueue,
|
|
3510
|
+
onMerge: (buffered) => {
|
|
3511
|
+
const scopeType = buffered[0].message.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3512
|
+
mergeTotal.add(buffered.length, { scope: scopeType });
|
|
3513
|
+
const last = buffered[buffered.length - 1];
|
|
3514
|
+
if (buffered.length === 1) return last;
|
|
3515
|
+
const envelopes = buffered.map((ctx) => ctx.state.envelope ?? ctx.message.content ?? "").filter(Boolean);
|
|
3516
|
+
if (envelopes.length > 0) {
|
|
3517
|
+
last.state.envelope = envelopes.join("\n\n");
|
|
3518
|
+
}
|
|
3519
|
+
const allAttachments = buffered.flatMap((ctx) => ctx.message.attachments ?? []);
|
|
3520
|
+
if (allAttachments.length > 0) {
|
|
3521
|
+
last.message.attachments = allAttachments;
|
|
3522
|
+
}
|
|
3523
|
+
return last;
|
|
3524
|
+
},
|
|
3525
|
+
onDispatch: (merged) => {
|
|
3526
|
+
const scopeType = merged.message.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3527
|
+
mergeEventsTotal.add(1, { scope: scopeType });
|
|
3528
|
+
return this.handleMessage(merged, merged.message);
|
|
3529
|
+
},
|
|
3530
|
+
onDrop: (ctx) => {
|
|
3531
|
+
const scopeType = ctx.message.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3532
|
+
dropTotal.add(1, { scope: scopeType });
|
|
3533
|
+
}
|
|
3534
|
+
}));
|
|
3535
|
+
if (mw.typingIndicator.enabled) {
|
|
3536
|
+
this.bot.use(typingIndicator({
|
|
3537
|
+
durationSec: mw.typingIndicator.durationSec,
|
|
3538
|
+
keepAliveIntervalMs: mw.typingIndicator.keepAliveIntervalMs
|
|
3539
|
+
}));
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
// ============ 消息路由 ============
|
|
3543
|
+
async handleMessage(ctx, msg) {
|
|
3544
|
+
ctx.state.handled = true;
|
|
3545
|
+
const envelope = ctx.state.envelope;
|
|
3546
|
+
const text = (msg.content ?? "").trim();
|
|
3547
|
+
if (!envelope && !text && (!msg.attachments || msg.attachments.length === 0)) {
|
|
3548
|
+
return;
|
|
3549
|
+
}
|
|
3550
|
+
const qualifier = `${msg.replyTarget.scope}-${msg.replyTarget.targetId}`;
|
|
3551
|
+
const extSessionId = this.resolveExtSessionId(msg);
|
|
3552
|
+
const scopeType = msg.replyTarget.scope.startsWith("group") ? "group" : "c2c";
|
|
3553
|
+
if (envelope) {
|
|
3554
|
+
envelopeLength.record(envelope.length, { scope: scopeType });
|
|
3555
|
+
}
|
|
3556
|
+
const targetKey = `${msg.replyTarget.scope}:${msg.replyTarget.targetId}`;
|
|
3557
|
+
if (msg.replyTarget.msgId) {
|
|
3558
|
+
this.msgIdCache.push(targetKey, msg.replyTarget.msgId);
|
|
3559
|
+
}
|
|
3560
|
+
const rctx = {
|
|
3561
|
+
bot: this.bot,
|
|
3562
|
+
backend: this.backend,
|
|
3563
|
+
config: this.config,
|
|
3564
|
+
logger: this.logger
|
|
3565
|
+
};
|
|
3566
|
+
try {
|
|
3567
|
+
const sessionId = await this.backend.getOrCreateSession(qualifier, extSessionId);
|
|
3568
|
+
let attachments = msg.attachments?.map((att) => ({
|
|
3569
|
+
url: att.url.startsWith("//") ? `https:${att.url}` : att.url,
|
|
3570
|
+
mimeType: att.content_type,
|
|
3571
|
+
name: att.filename ?? "file",
|
|
3572
|
+
size: att.size
|
|
3573
|
+
}));
|
|
3574
|
+
let effectiveText = envelope || text;
|
|
3575
|
+
const cmdMatch = /^\/(\S+)(?:\s+(.*))?$/.exec(text);
|
|
3576
|
+
if (cmdMatch && !attachments?.length) {
|
|
3577
|
+
const [, cmd, args] = cmdMatch;
|
|
3578
|
+
effectiveText = args ?? "";
|
|
3579
|
+
attachments = [{ url: `command://${cmd}`, mimeType: "text/x-command", name: cmd, size: void 0 }];
|
|
3580
|
+
}
|
|
3581
|
+
const replyMethod = this.config.streaming.c2c && msg.replyTarget.scope === "c2c" && msg.replyTarget.msgId ? "streamReply" : "batchReply";
|
|
3582
|
+
await withClientMetrics({
|
|
3583
|
+
callerMethod: "reply",
|
|
3584
|
+
calleeService: "qq-platform",
|
|
3585
|
+
calleeMethod: replyMethod,
|
|
3586
|
+
extras: { user_ext1: scopeType }
|
|
3587
|
+
}, async () => {
|
|
3588
|
+
if (replyMethod === "streamReply") {
|
|
3589
|
+
await streamReply(rctx, ctx, msg, sessionId, qualifier, effectiveText, attachments);
|
|
3590
|
+
} else {
|
|
3591
|
+
await batchReply(rctx, ctx, msg, sessionId, qualifier, effectiveText, attachments);
|
|
3592
|
+
}
|
|
3593
|
+
});
|
|
3594
|
+
} catch (err) {
|
|
3595
|
+
if (ctx.aborted) {
|
|
3596
|
+
this.logger.debug(`[handler] ${qualifier} aborted, skip error reply`);
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3600
|
+
this.logger.error(`[handler] ${qualifier} \u5904\u7406\u5931\u8D25: ${errMsg}`);
|
|
3601
|
+
try {
|
|
3602
|
+
let userMessage = this.formatErrorForUser(errMsg);
|
|
3603
|
+
if (this.config.message.errorMessages.debug) {
|
|
3604
|
+
userMessage += `
|
|
3605
|
+
|
|
3606
|
+
[debug] ${errMsg}`;
|
|
3607
|
+
}
|
|
3608
|
+
await this.bot.sendText(msg.replyTarget, userMessage);
|
|
3609
|
+
} catch {
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
/**
|
|
3614
|
+
* 从 messageScene.ext 中提取外部 sessionid。
|
|
3615
|
+
* 返回 undefined 表示事件中没有指定 sessionid。
|
|
3616
|
+
*/
|
|
3617
|
+
resolveExtSessionId(msg) {
|
|
3618
|
+
const ext = msg.messageScene?.ext;
|
|
3619
|
+
if (ext) {
|
|
3620
|
+
for (const entry of ext) {
|
|
3621
|
+
if (entry.startsWith("sessionid=")) {
|
|
3622
|
+
const sessionId = entry.slice("sessionid=".length);
|
|
3623
|
+
if (sessionId) {
|
|
3624
|
+
this.logger.debug?.(`[handler] using ext sessionid: ${sessionId}`);
|
|
3625
|
+
return sessionId;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
return void 0;
|
|
3631
|
+
}
|
|
3632
|
+
formatErrorForUser(errMsg) {
|
|
3633
|
+
const { rules, unknown, troubleshootHint } = this.config.message.errorMessages;
|
|
3634
|
+
const lower = errMsg.toLowerCase();
|
|
3635
|
+
for (const rule of rules) {
|
|
3636
|
+
if (rule.match.some((keyword) => lower.includes(keyword.toLowerCase()))) {
|
|
3637
|
+
const hint = rule.hint ?? troubleshootHint;
|
|
3638
|
+
return hint ? `${rule.reply}
|
|
3639
|
+
${hint}` : rule.reply;
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
return troubleshootHint ? `${unknown}
|
|
3643
|
+
${troubleshootHint}` : unknown;
|
|
3644
|
+
}
|
|
3645
|
+
};
|
|
3646
|
+
|
|
3647
|
+
export {
|
|
3648
|
+
loadConfig,
|
|
3649
|
+
ConfigWatcher,
|
|
3650
|
+
GALILEO_RESOURCE,
|
|
3651
|
+
setBizDefaultAttrs,
|
|
3652
|
+
BotRunner
|
|
3653
|
+
};
|
|
3654
|
+
//# sourceMappingURL=chunk-XIJ6OSLY.js.map
|