@jerryliang122/openclaw-qqbot 1.0.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -28
- package/README.zh.md +15 -28
- package/dist/index.cjs +264 -247
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -7
- package/package.json +1 -1
- package/src/middleware/attachment.ts +30 -19
- package/src/openclaw-plugin-sdk.d.ts +38 -2
- package/src/tools/platform.ts +86 -72
- package/src/tools/remind.ts +53 -42
- package/src/tools/secret-input.ts +112 -80
- package/src/tools/tool-session.ts +74 -0
- package/src/types.ts +21 -7
- package/src/utils/stt.ts +49 -92
|
@@ -10,23 +10,31 @@
|
|
|
10
10
|
* 记录里,且 AI 随后要读取使用;secret kind 是「受保护的机密」
|
|
11
11
|
* (write-only,代理不可读),存进去 AI 反而用不了(2026-08-30 用户反馈)。
|
|
12
12
|
*
|
|
13
|
+
* 会话目标解析(2026-09-23 修复):双层回退——请求级 ALS 优先,框架
|
|
14
|
+
* run 级 deliveryContext 兜底。openclaw 把 turn 延迟到入站消息异步子树
|
|
15
|
+
* 之外执行(followup/collect 队列)时 ALS 为空,此前工具直接报
|
|
16
|
+
* 「无法获取当前会话目标」失败;工厂注册形式拿到的 deliveryContext
|
|
17
|
+
* 由框架按当前 run 的 sessionCtx 派生,延迟 turn 下依然正确(见
|
|
18
|
+
* src/tools/tool-session.ts)。
|
|
19
|
+
*
|
|
13
20
|
* 非阻塞:工具发卡后立即返回,AI 应结束本轮;执行与回执完全由插件自治。
|
|
14
21
|
*/
|
|
15
22
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
16
|
-
import {
|
|
23
|
+
import { resolveGatewayForSend } from '../outbound/outbound-service.js';
|
|
17
24
|
import { isValidSecretName } from '../features/secret-store-cli.js';
|
|
18
25
|
import {
|
|
19
26
|
DEFAULT_SECRET_INPUT_TTL_MS,
|
|
20
27
|
cancelPendingSecretInput,
|
|
21
28
|
registerPendingSecretInput,
|
|
22
29
|
} from '../features/secret-input-store.js';
|
|
23
|
-
import {
|
|
24
|
-
import { parseTarget } from '../outbound/target.js';
|
|
30
|
+
import { parseTarget, QQBOT_PREFIX_RE } from '../outbound/target.js';
|
|
25
31
|
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
32
|
+
import { resolveToolSessionRoute, type ToolDeliveryContext } from './tool-session.js';
|
|
26
33
|
import type { InlineKeyboard, KeyboardButton } from '../types.js';
|
|
27
34
|
|
|
28
|
-
// 工具 execute 无 logger
|
|
29
|
-
//
|
|
35
|
+
// 工具 execute 无 logger 参数可传;入站消息处理链路内 request-context
|
|
36
|
+
// 处于活跃,模块级 logger 的 enrichMeta 自动带上 accountId/openId/messageId
|
|
37
|
+
// (延迟/排队 turn 中 ALS 为空,路由解析回退 deliveryContext)
|
|
30
38
|
const toolLog = createPluginLogger({ prefix: '[tool:secret-input]' });
|
|
31
39
|
|
|
32
40
|
// ========== JSON Schema ==========
|
|
@@ -104,89 +112,113 @@ function json(data: unknown) {
|
|
|
104
112
|
|
|
105
113
|
// ========== 注册入口 ==========
|
|
106
114
|
|
|
115
|
+
// 工厂注册形式(2026-09-23 修复):框架调用工厂时携带当次 run 的
|
|
116
|
+
// deliveryContext——turn 被框架延迟/排队执行(ALS 已失效)时,工具
|
|
117
|
+
// 仍能从 deliveryContext 解析当前会话目标(见 tool-session.ts)。
|
|
107
118
|
export function registerSecretInputTool(api: OpenClawPluginApi): void {
|
|
108
119
|
api.registerTool(
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return json({
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
120
|
+
(factoryCtx) => {
|
|
121
|
+
const delivery = (factoryCtx as { deliveryContext?: ToolDeliveryContext }).deliveryContext;
|
|
122
|
+
return {
|
|
123
|
+
name: 'qqbot_secret_input',
|
|
124
|
+
label: 'QQBot Secret Input Card',
|
|
125
|
+
description:
|
|
126
|
+
'当用户要交付一个新密钥/凭据(API Key、Token 等)给系统保存时调用:' +
|
|
127
|
+
'向用户私聊发送一张密钥输入卡片,用户把密钥粘贴在输入框发送后,' +
|
|
128
|
+
'插件会自动拦截该消息并执行 openclaw secrets store set 完成保存,' +
|
|
129
|
+
'结果由插件直接回复用户。密钥内容不会进入对话。' +
|
|
130
|
+
'必须由用户提供 name 参数(环境变量名)。',
|
|
131
|
+
parameters: SecretInputSchema,
|
|
132
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
133
|
+
const p = params as { name?: string; description?: string };
|
|
134
|
+
const name = (p.name ?? '').trim();
|
|
135
|
+
if (!name) return json({ error: 'name 为必填参数(环境变量名)' });
|
|
136
|
+
if (!isValidSecretName(name)) {
|
|
137
|
+
return json({
|
|
138
|
+
error:
|
|
139
|
+
'环境变量名不合法:必须以大写字母开头,仅含大写字母/数字/下划线,最长 128 字符' +
|
|
140
|
+
`(收到 "${name}")。示例:OPENWEATHER_API_KEY。请修正后重试。`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// 恒为 env:见文件头注释(卡片值 = 代理可读环境,secret 是 write-only 代理不可读)
|
|
144
|
+
const kind = 'env' as const;
|
|
132
145
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const route = resolveToolSessionRoute(delivery);
|
|
147
|
+
if (!route) {
|
|
148
|
+
return json({
|
|
149
|
+
error:
|
|
150
|
+
'当前运行没有 QQ 会话来源(可能由定时任务或框架内部触发的 run),' +
|
|
151
|
+
'无法定位要发卡的用户。请引导用户在 QQ 私聊中直接发起密钥输入,' +
|
|
152
|
+
'不要在无会话上下文的任务里调用本工具。',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// scope 必须在 parseTarget 之前显式判定:parseTarget 会把 channel:
|
|
156
|
+
// 归一化为 c2c(target.ts「channel 不被 SDK ChatScope 支持」),
|
|
157
|
+
// 频道会话若不在此拦截就会把频道 ID 当 c2c openid 发卡
|
|
158
|
+
// (Sourcery 复审,2026-09-23)
|
|
159
|
+
const scope = QQBOT_PREFIX_RE.exec(route.target)?.[1]?.toLowerCase();
|
|
160
|
+
const parsed = parseTarget(route.target);
|
|
161
|
+
if (scope !== 'c2c' || !parsed.targetId) {
|
|
162
|
+
return json({
|
|
163
|
+
error:
|
|
164
|
+
`密钥输入卡片仅支持私聊(c2c)会话,当前目标 ${route.target} 不适用` +
|
|
165
|
+
'——密钥不应出现在群聊或频道中。请引导用户在私聊中发起。',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
146
168
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
// 发送账户解析:路由 accountId(缺省 default)→ 精确命中,
|
|
170
|
+
// 未命中且仅单账号运行时回退(与出站发送路径一致,issue #15 约定)。
|
|
171
|
+
// pending 必须登记在**实际发送账号**名下——捕获中间件按其网关的
|
|
172
|
+
// accountId 查询 pending。
|
|
173
|
+
const requestedAccountId = route.accountId ?? 'default';
|
|
174
|
+
const resolvedGateway = resolveGatewayForSend(requestedAccountId);
|
|
175
|
+
if (!resolvedGateway) {
|
|
176
|
+
return json({ error: `账户 ${requestedAccountId} 的 gateway 尚未启动,无法发送密钥输入卡片` });
|
|
177
|
+
}
|
|
178
|
+
const accountId = resolvedGateway.accountId;
|
|
179
|
+
const bot = resolvedGateway.gw.bot;
|
|
151
180
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
162
|
-
try {
|
|
163
|
-
await bot.sendTextWithKeyboard(
|
|
164
|
-
{ scope: 'c2c', targetId: parsed.targetId },
|
|
165
|
-
buildSecretInputCard(name, description, DEFAULT_SECRET_INPUT_TTL_MS / 60_000),
|
|
166
|
-
buildSecretInputKeyboard() as never,
|
|
167
|
-
);
|
|
168
|
-
} catch (err) {
|
|
169
|
-
cancelPendingSecretInput(accountId, parsed.targetId);
|
|
170
|
-
toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
|
|
171
|
-
return json({
|
|
172
|
-
error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
+
const description = p.description?.trim() || undefined;
|
|
182
|
+
// 先登记后发卡(避免「先发后登」竞态,同 multi-question 约定)
|
|
183
|
+
registerPendingSecretInput({
|
|
184
|
+
accountId,
|
|
185
|
+
senderOpenid: parsed.targetId,
|
|
186
|
+
name,
|
|
187
|
+
kind,
|
|
188
|
+
description,
|
|
189
|
+
createdAt: Date.now(),
|
|
173
190
|
});
|
|
174
|
-
|
|
191
|
+
try {
|
|
192
|
+
await bot.sendTextWithKeyboard(
|
|
193
|
+
{ scope: 'c2c', targetId: parsed.targetId },
|
|
194
|
+
buildSecretInputCard(name, description, DEFAULT_SECRET_INPUT_TTL_MS / 60_000),
|
|
195
|
+
buildSecretInputKeyboard() as never,
|
|
196
|
+
);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
cancelPendingSecretInput(accountId, parsed.targetId);
|
|
199
|
+
toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
|
|
200
|
+
return json({
|
|
201
|
+
error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
175
204
|
|
|
176
|
-
|
|
205
|
+
toolLog.info(
|
|
206
|
+
`card sent, pending registered name=${name} accountId=${accountId} route=${route.source} ttlMinutes=${DEFAULT_SECRET_INPUT_TTL_MS / 60_000}`,
|
|
207
|
+
);
|
|
177
208
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
return json({
|
|
210
|
+
ok: true,
|
|
211
|
+
name,
|
|
212
|
+
kind,
|
|
213
|
+
command: `openclaw secrets store set ${name} --kind env --value <用户输入>`,
|
|
214
|
+
instruction:
|
|
215
|
+
'卡片已发送给用户。用户下一条私聊消息将被插件自动拦截(不会进入对话),' +
|
|
216
|
+
`执行上述命令保存密钥,成功/失败由插件直接回复用户。` +
|
|
217
|
+
'请直接结束本轮(可告知用户按卡片提示发送密钥),不要等待用户输入,' +
|
|
218
|
+
'也不要让用户把密钥内容发到对话里。',
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
190
222
|
},
|
|
191
223
|
{ name: 'qqbot_secret_input' },
|
|
192
224
|
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具执行时的会话路由解析(双层回退)
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09-23 事故):qqbot_secret_input 等工具此前仅靠
|
|
5
|
+
* request-context(AsyncLocalStorage)获取当前会话目标。ALS 只在
|
|
6
|
+
* handleMessage → dispatchToOpenClaw 的异步子树内有效;openclaw 框架
|
|
7
|
+
* 经常把 agent turn 延迟到该子树之外执行——活动 turn 之后排队的
|
|
8
|
+
* followup/collect 批处理、框架自有定时器驱动的队列 drain 等。此时
|
|
9
|
+
* 工具执行处 ALS 为空,工具报「无法获取当前会话目标」直接失败。
|
|
10
|
+
*
|
|
11
|
+
* 修复:优先 ALS(逐消息精确,含 messageId/openId 供日志富化);
|
|
12
|
+
* ALS 缺失时回退到框架在工具工厂上下文里提供的 deliveryContext
|
|
13
|
+
* (openclaw 按当前 run 的 sessionCtx 派生的可信投递路由:
|
|
14
|
+
* { channel, to, accountId },qqbot 通道的 to 即规范目标地址
|
|
15
|
+
* qqbot:c2c:{openid} / qqbot:group:{group_openid})。
|
|
16
|
+
*
|
|
17
|
+
* 工厂注册形式:api.registerTool((ctx) => ({ ...tool }), { name })。
|
|
18
|
+
* 框架在装配工具面(或缓存描述符路径下的每次执行)时调用工厂,
|
|
19
|
+
* ctx 携带当次 run 的 deliveryContext——描述符缓存键包含
|
|
20
|
+
* deliveryContext,不会跨会话复用旧路由。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { getRequestAccountId, getRequestTarget } from '../request-context.js';
|
|
24
|
+
|
|
25
|
+
/** 框架 deliveryContext 中本项目用到的字段(见 openclaw OpenClawPluginToolContext) */
|
|
26
|
+
export interface ToolDeliveryContext {
|
|
27
|
+
channel?: string;
|
|
28
|
+
to?: string;
|
|
29
|
+
accountId?: string;
|
|
30
|
+
threadId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 解析出的会话路由 */
|
|
34
|
+
export interface ToolSessionRoute {
|
|
35
|
+
/** 规范目标地址:qqbot:c2c:{openid} / qqbot:group:{group_openid} */
|
|
36
|
+
target: string;
|
|
37
|
+
/** 账户 ID(多账户场景;可能为空,由调用方决定回退策略) */
|
|
38
|
+
accountId?: string;
|
|
39
|
+
/** 路由来源:request-context(ALS)优先,delivery-context(框架 run 级)回退 */
|
|
40
|
+
source: 'request-context' | 'delivery-context';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 解析当前工具调用所属的会话路由。
|
|
45
|
+
*
|
|
46
|
+
* 返回 undefined 表示当前 run 没有可识别的 QQ 会话来源
|
|
47
|
+
* (如 cron/定时任务、内部 UI 发起的 run)——调用方应返回明确错误。
|
|
48
|
+
*/
|
|
49
|
+
export function resolveToolSessionRoute(
|
|
50
|
+
delivery?: ToolDeliveryContext | null,
|
|
51
|
+
): ToolSessionRoute | undefined {
|
|
52
|
+
// 第一层:请求级 ALS(入站消息处理链路内,逐消息精确)
|
|
53
|
+
const alsTarget = getRequestTarget();
|
|
54
|
+
if (alsTarget) {
|
|
55
|
+
return {
|
|
56
|
+
target: alsTarget,
|
|
57
|
+
accountId: getRequestAccountId(),
|
|
58
|
+
source: 'request-context',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 第二层:框架 run 级 deliveryContext(延迟/排队 turn 的回退)
|
|
63
|
+
const to = typeof delivery?.to === 'string' ? delivery.to.trim() : '';
|
|
64
|
+
if (!to) return undefined;
|
|
65
|
+
const channel = typeof delivery?.channel === 'string' ? delivery.channel.trim() : '';
|
|
66
|
+
// 防御:deliveryContext 属于其他通道(理论上不会发生)时不误认
|
|
67
|
+
if (channel && channel !== 'qqbot') return undefined;
|
|
68
|
+
if (!/^qqbot:(c2c|group|channel):/i.test(to)) return undefined;
|
|
69
|
+
return {
|
|
70
|
+
target: to,
|
|
71
|
+
accountId: delivery?.accountId,
|
|
72
|
+
source: 'delivery-context',
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -211,8 +211,9 @@ export interface QQBotAccountConfig {
|
|
|
211
211
|
sendMode?: 'stream' | 'static';
|
|
212
212
|
};
|
|
213
213
|
/**
|
|
214
|
-
* STT (语音转文字)
|
|
215
|
-
*
|
|
214
|
+
* STT (语音转文字) 行为开关
|
|
215
|
+
* 转录凭证统一走框架 tools.media.audio.models;
|
|
216
|
+
* 框架未配置时直接使用 QQ 平台转写(asr_refer_text)
|
|
216
217
|
*/
|
|
217
218
|
stt?: STTChannelConfig;
|
|
218
219
|
/**
|
|
@@ -301,17 +302,30 @@ export interface AudioFormatPolicy {
|
|
|
301
302
|
|
|
302
303
|
/**
|
|
303
304
|
* STT (语音转文字) 配置
|
|
305
|
+
*
|
|
306
|
+
* 2026-10 起转录统一走框架音频理解管线(tools.media.audio.models 凭证),
|
|
307
|
+
* 本块只保留行为开关;旧凭证键已废弃(检测到会打迁移提示日志)。
|
|
308
|
+
* 框架 STT 未配置时,QQ 平台转写(asr_refer_text)直接作为唯一来源。
|
|
304
309
|
*/
|
|
305
310
|
export interface STTChannelConfig {
|
|
306
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* 是否启用框架 STT 转录(默认 true)。
|
|
313
|
+
* false = 不调用外部 STT,语音只用平台转写(或无转写时占位文本)。
|
|
314
|
+
*/
|
|
307
315
|
enabled?: boolean;
|
|
308
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* 平台转写(asr_refer_text)参与开关。默认参与:
|
|
318
|
+
* 框架 STT 未配置时直接作为唯一来源,STT 失败/为空时兜底。
|
|
319
|
+
* 设为 false 恢复严格模式——所有场景丢弃平台转写。
|
|
320
|
+
*/
|
|
321
|
+
asrFallback?: boolean;
|
|
322
|
+
/** @deprecated 2026-10 起忽略——STT 凭证统一配置在框架 tools.media.audio.models */
|
|
309
323
|
provider?: string;
|
|
310
|
-
/**
|
|
324
|
+
/** @deprecated 同上 */
|
|
311
325
|
baseUrl?: string;
|
|
312
|
-
/**
|
|
326
|
+
/** @deprecated 同上 */
|
|
313
327
|
apiKey?: string;
|
|
314
|
-
/**
|
|
328
|
+
/** @deprecated 同上 */
|
|
315
329
|
model?: string;
|
|
316
330
|
}
|
|
317
331
|
|
package/src/utils/stt.ts
CHANGED
|
@@ -1,114 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* STT (Speech-to-Text)
|
|
2
|
+
* STT (Speech-to-Text) 语音转文字 — 框架音频理解管线
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 转录统一委托给 openclaw/plugin-sdk/media-understanding-runtime 的
|
|
5
|
+
* `transcribeAudioFile`(provider 注册表、附件缓存、SSRF 策略与错误语义
|
|
6
|
+
* 均由框架维护),插件不再自带 OpenAI 兼容 HTTP 调用;STT 凭证只认
|
|
7
|
+
* 框架级 `tools.media.audio.models` 配置(与内置 telegram 通道一致)。
|
|
8
|
+
*
|
|
9
|
+
* 平台转写(asr_refer_text):QQ 平台对语音消息自动 STT 并随事件 JSON 下发。
|
|
10
|
+
* 默认策略——框架 STT 未配置时**直接采用平台转写**;已配置时平台转写作为
|
|
11
|
+
* 自有转录失败/为空的兜底。`channels.qqbot.stt.asrFallback: false` 可整体
|
|
12
|
+
* 禁用平台转写(严格模式,恢复 2026-08-17 的丢弃行为)。
|
|
8
13
|
*/
|
|
9
|
-
import * as fs from 'node:fs';
|
|
10
14
|
import * as path from 'node:path';
|
|
15
|
+
import { transcribeAudioFile } from 'openclaw/plugin-sdk/media-understanding-runtime';
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
enabled: boolean;
|
|
14
|
-
baseUrl: string;
|
|
15
|
-
apiKey: string;
|
|
16
|
-
model: string;
|
|
17
|
-
}
|
|
17
|
+
type TranscribeParams = Parameters<typeof transcribeAudioFile>[0];
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* 平台转写(asr_refer_text
|
|
21
|
-
*
|
|
22
|
-
* 缺省、false 或 stt 块整体不存在时一律丢弃——包括 STT 未配置的场景
|
|
23
|
-
* (此时语音消息落占位文本,而不是退回平台转写)。
|
|
24
|
-
* 读取独立于 STT 凭证解析成败:stt 块无凭证但 asrFallback: true 仍生效。
|
|
20
|
+
* 平台转写(asr_refer_text)是否参与(独立于框架 STT 配置读取)。
|
|
21
|
+
* 默认 true;显式 `channels.qqbot.stt.asrFallback: false` 时关闭(严格模式)。
|
|
25
22
|
*/
|
|
26
23
|
export function shouldUsePlatformAsr(cfg: Record<string, unknown>): boolean {
|
|
27
24
|
const channels = asRecord(cfg.channels);
|
|
28
25
|
const qqbot = asRecord(channels?.qqbot);
|
|
29
|
-
return asRecord(qqbot?.stt)?.asrFallback
|
|
26
|
+
return asRecord(qqbot?.stt)?.asrFallback !== false;
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
/**
|
|
33
|
-
*
|
|
30
|
+
* 框架 STT(tools.media.audio)是否可用:
|
|
31
|
+
* - `channels.qqbot.stt.enabled === false` → 插件级显式关闭(只用平台转写)
|
|
32
|
+
* - `tools.media.audio.enabled === false` → 框架级关闭
|
|
33
|
+
* - `models` 为空 → 未配置
|
|
34
|
+
*
|
|
35
|
+
* 仅做存在性探测控制流程;provider 解析与实际调用由 transcribeAudioFile 完成。
|
|
34
36
|
*/
|
|
35
|
-
export function
|
|
37
|
+
export function isFrameworkSttConfigured(cfg: Record<string, unknown>): boolean {
|
|
36
38
|
const channels = asRecord(cfg.channels);
|
|
37
39
|
const qqbot = asRecord(channels?.qqbot);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// 显式禁用
|
|
41
|
-
if (sttCfg?.enabled === false) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const models = asRecord(cfg.models);
|
|
46
|
-
const providers = asRecord(models?.providers);
|
|
47
|
-
|
|
48
|
-
// 1. 插件级 STT 配置
|
|
49
|
-
if (sttCfg) {
|
|
50
|
-
const providerId = readString(sttCfg, 'provider') ?? 'openai';
|
|
51
|
-
const providerCfg = asRecord(providers?.[providerId]);
|
|
52
|
-
const baseUrl = readString(sttCfg, 'baseUrl') ?? readString(providerCfg, 'baseUrl');
|
|
53
|
-
const apiKey = readString(sttCfg, 'apiKey') ?? readString(providerCfg, 'apiKey');
|
|
54
|
-
const model = readString(sttCfg, 'model') ?? 'whisper-1';
|
|
55
|
-
if (baseUrl && apiKey) {
|
|
56
|
-
return { enabled: true, baseUrl: baseUrl.replace(/\/+$/, ''), apiKey, model };
|
|
57
|
-
}
|
|
40
|
+
if (asRecord(qqbot?.stt)?.enabled === false) {
|
|
41
|
+
return false;
|
|
58
42
|
}
|
|
59
|
-
|
|
60
|
-
// 2. 框架级 audio model fallback
|
|
61
43
|
const tools = asRecord(cfg.tools);
|
|
62
44
|
const media = asRecord(tools?.media);
|
|
63
45
|
const audio = asRecord(media?.audio);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (audioModelEntry) {
|
|
67
|
-
const providerId = readString(audioModelEntry, 'provider') ?? 'openai';
|
|
68
|
-
const providerCfg = asRecord(providers?.[providerId]);
|
|
69
|
-
const baseUrl = readString(audioModelEntry, 'baseUrl') ?? readString(providerCfg, 'baseUrl');
|
|
70
|
-
const apiKey = readString(audioModelEntry, 'apiKey') ?? readString(providerCfg, 'apiKey');
|
|
71
|
-
const model = readString(audioModelEntry, 'model') ?? 'whisper-1';
|
|
72
|
-
if (baseUrl && apiKey) {
|
|
73
|
-
return { enabled: true, baseUrl: baseUrl.replace(/\/+$/, ''), apiKey, model };
|
|
74
|
-
}
|
|
46
|
+
if (!audio || audio.enabled === false) {
|
|
47
|
+
return false;
|
|
75
48
|
}
|
|
49
|
+
return Array.isArray(audio.models) && audio.models.length > 0;
|
|
50
|
+
}
|
|
76
51
|
|
|
77
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 检测已废弃的插件级 STT 凭证(channels.qqbot.stt.provider/baseUrl/apiKey/model)。
|
|
54
|
+
* 2026-10 起凭证统一走框架 `tools.media.audio.models`,旧键被忽略;
|
|
55
|
+
* 返回 true 时调用方打一次性迁移提示。
|
|
56
|
+
*/
|
|
57
|
+
export function hasLegacySttCredentials(cfg: Record<string, unknown>): boolean {
|
|
58
|
+
const channels = asRecord(cfg.channels);
|
|
59
|
+
const qqbot = asRecord(channels?.qqbot);
|
|
60
|
+
const stt = asRecord(qqbot?.stt);
|
|
61
|
+
if (!stt) return false;
|
|
62
|
+
return ['provider', 'baseUrl', 'apiKey', 'model'].some(
|
|
63
|
+
(key) => typeof stt[key] === 'string' && (stt[key] as string).trim().length > 0,
|
|
64
|
+
);
|
|
78
65
|
}
|
|
79
66
|
|
|
80
67
|
/**
|
|
81
|
-
*
|
|
68
|
+
* 经框架音频理解管线转录本地音频文件。
|
|
69
|
+
* 返回修剪后的转录文本;无文本返回 null。
|
|
70
|
+
* provider 缺失/调用失败会抛错,由调用方捕获后走平台转写兜底。
|
|
82
71
|
*/
|
|
83
|
-
export async function
|
|
72
|
+
export async function transcribeAudioViaFramework(
|
|
84
73
|
audioPath: string,
|
|
85
74
|
cfg: Record<string, unknown>,
|
|
86
75
|
): Promise<string | null> {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const fileBuffer = fs.readFileSync(audioPath);
|
|
93
|
-
const fileName = sanitizeFileName(path.basename(audioPath));
|
|
94
|
-
const mime = guessMimeType(fileName);
|
|
95
|
-
|
|
96
|
-
const form = new FormData();
|
|
97
|
-
form.append('file', new Blob([fileBuffer], { type: mime }), fileName);
|
|
98
|
-
form.append('model', sttCfg.model);
|
|
99
|
-
|
|
100
|
-
const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
|
|
103
|
-
body: form,
|
|
76
|
+
const result = await transcribeAudioFile({
|
|
77
|
+
filePath: audioPath,
|
|
78
|
+
cfg: cfg as unknown as TranscribeParams['cfg'],
|
|
79
|
+
mime: guessMimeType(audioPath),
|
|
104
80
|
});
|
|
105
|
-
|
|
106
|
-
if (!resp.ok) {
|
|
107
|
-
const detail = await resp.text().catch(() => '');
|
|
108
|
-
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const result = (await resp.json()) as { text?: string };
|
|
112
81
|
return result.text?.trim() || null;
|
|
113
82
|
}
|
|
114
83
|
|
|
@@ -121,18 +90,6 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
121
90
|
return undefined;
|
|
122
91
|
}
|
|
123
92
|
|
|
124
|
-
function readString(obj: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
125
|
-
const val = obj?.[key];
|
|
126
|
-
if (typeof val === 'string' && val.trim()) {
|
|
127
|
-
return val.trim();
|
|
128
|
-
}
|
|
129
|
-
return undefined;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function sanitizeFileName(name: string): string {
|
|
133
|
-
return name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
93
|
function guessMimeType(fileName: string): string {
|
|
137
94
|
const ext = path.extname(fileName).toLowerCase();
|
|
138
95
|
const mimeMap: Record<string, string> = {
|