@jerryliang122/openclaw-qqbot 1.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.
Files changed (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * QQBot Heartbeat 适配器
3
+ *
4
+ * Typing 指示器
5
+ */
6
+
7
+ import { startTypingWithRenewal, stopTyping } from './typing-lifecycle.js';
8
+ import { inferQQBotScope } from './features/quota-manager.js';
9
+ import { getGateway } from './outbound/outbound-service.js';
10
+ import { tryParseTarget } from './outbound/target.js';
11
+
12
+ /**
13
+ * QQBot Heartbeat Adapter
14
+ */
15
+ export const qqbotHeartbeatAdapter = {
16
+ sendTyping: async (params: {
17
+ cfg: any;
18
+ to: string;
19
+ accountId?: string | null;
20
+ threadId?: string | number | null;
21
+ replyToId?: string;
22
+ }) => {
23
+ const { to, accountId, replyToId } = params;
24
+
25
+ if (!accountId || !replyToId) {
26
+ return;
27
+ }
28
+
29
+ const scope = inferQQBotScope(to);
30
+ if (scope !== 'c2c') {
31
+ return;
32
+ }
33
+
34
+ await startTypingWithRenewal({
35
+ accountId,
36
+ to,
37
+ replyToId,
38
+ sendTyping: async ({ to, msgId }) => {
39
+ const gw = getGateway(accountId);
40
+ if (!gw) return false;
41
+
42
+ const target = tryParseTarget(to);
43
+ if (!target) {
44
+ return false;
45
+ }
46
+
47
+ const targetWithMsgId = msgId ? { ...target, msgId } : target;
48
+ try {
49
+ await gw.sendTyping(targetWithMsgId);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ },
55
+ });
56
+ },
57
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * QQBot Message 适配器
3
+ *
4
+ * 定义消息生命周期能力
5
+ */
6
+
7
+ import { qqbotChannelOutbound } from './outbound-adapter.js';
8
+
9
+ /**
10
+ * QQBot Message Adapter
11
+ *
12
+ * 能力声明:
13
+ * - draftPreview: false (QQBot 不支持)
14
+ * - previewFinalization: false
15
+ * - progressUpdates: true (流式支持)
16
+ * - finalEdit: false (QQBot 流式限制)
17
+ */
18
+ export const qqbotMessageAdapter = {
19
+ id: 'qqbot',
20
+ live: {
21
+ capabilities: {
22
+ draftPreview: false,
23
+ previewFinalization: false,
24
+ progressUpdates: true,
25
+ },
26
+ finalizer: {
27
+ capabilities: {
28
+ finalEdit: false,
29
+ normalFallback: true,
30
+ previewReceipt: false,
31
+ retainOnAmbiguousFailure: true,
32
+ },
33
+ },
34
+ },
35
+ receive: {
36
+ defaultAckPolicy: 'after_agent_dispatch' as const,
37
+ supportedAckPolicies: ['after_receive_record', 'after_agent_dispatch'] as const,
38
+ },
39
+ outbound: qqbotChannelOutbound,
40
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * QQBot Messaging 适配器
3
+ *
4
+ * 处理目标解析、会话路由
5
+ */
6
+
7
+ import { normalizeTarget, isQQBotTarget, tryParseTarget } from './outbound/target.js';
8
+
9
+ /**
10
+ * 解析会话目标
11
+ */
12
+ function resolveQQBotInboundConversation(params: {
13
+ to?: string;
14
+ conversationId?: string;
15
+ threadId?: string | number;
16
+ }): { conversationId: string; parentConversationId: string } | null {
17
+ const rawTarget = params.to || params.conversationId || '';
18
+ if (!rawTarget) {
19
+ return null;
20
+ }
21
+
22
+ const parsed = tryParseTarget(rawTarget);
23
+ if (!parsed) {
24
+ return null;
25
+ }
26
+
27
+ if (parsed.scope !== 'c2c' && parsed.scope !== 'group') {
28
+ return null;
29
+ }
30
+
31
+ // 保存完整目标字符串(包括 scope),避免后续解析时丢失 scope 信息
32
+ const fullTarget = `qqbot:${parsed.scope}:${parsed.targetId}`;
33
+
34
+ return {
35
+ conversationId: fullTarget,
36
+ parentConversationId: fullTarget,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 解析投递目标
42
+ */
43
+ function resolveQQBotDeliveryTarget(params: {
44
+ conversationId: string;
45
+ parentConversationId?: string;
46
+ }): { to: string } | null {
47
+ const targetStr = params.parentConversationId || params.conversationId;
48
+ if (!targetStr) {
49
+ return null;
50
+ }
51
+
52
+ const parsed = tryParseTarget(targetStr);
53
+ if (!parsed) {
54
+ return null;
55
+ }
56
+
57
+ if (parsed.scope !== 'c2c' && parsed.scope !== 'group') {
58
+ return null;
59
+ }
60
+
61
+ return {
62
+ to: `qqbot:${parsed.scope}:${parsed.targetId}`,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * QQBot Messaging Adapter
68
+ */
69
+ export const qqbotMessagingAdapter = {
70
+ targetPrefixes: ['qqbot'],
71
+ normalizeTarget: (target: string) => normalizeTarget(target),
72
+ resolveInboundConversation: resolveQQBotInboundConversation,
73
+ resolveDeliveryTarget: resolveQQBotDeliveryTarget,
74
+ targetResolver: {
75
+ looksLikeId: isQQBotTarget,
76
+ hint: 'QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)',
77
+ },
78
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 动态访问控制中间件。
3
+ *
4
+ * 从 ctx.state.policy(由 policy-injector 注入)动态读取策略,支持配置热更新。
5
+ * 模式:disabled | open | allowlist | pairing(仅 c2c)
6
+ */
7
+ import type { Middleware } from '@tencent-connect/qqbot-nodejs';
8
+ import { getPairingApi } from '../adapter/pairing.js';
9
+
10
+
11
+ /**
12
+ * 创建动态访问控制中间件。
13
+ *
14
+ * 决策优先级:
15
+ * 1. disabled → 拒绝
16
+ * 2. open → 放行
17
+ * 3. allowlist → allowFrom 匹配(默认模式)
18
+ * 4. pairing → allowFrom + pairing store,未配对发起挑战
19
+ */
20
+ export function dynamicAccessControl(params: {
21
+ accountId: string;
22
+ getRuntime: () => any;
23
+ }): Middleware {
24
+ const { accountId, getRuntime } = params;
25
+
26
+ return async (ctx, next) => {
27
+ const p = ctx.state.policy as Record<string, unknown> | undefined;
28
+ const isGroup = ctx.message.kind === 'group';
29
+ const mode: string = isGroup
30
+ ? (p?.groupMode as string) ?? 'open'
31
+ : (p?.c2cMode as string) ?? 'allowlist';
32
+
33
+ if (mode === 'disabled') {
34
+ ctx.log?.info?.(`[access] blocked ${isGroup ? 'group' : 'c2c'} from ${ctx.message.senderId}: policy disabled`);
35
+ ctx.stop('access:policy_disabled');
36
+ return;
37
+ }
38
+ if (mode === 'open') {
39
+ await next();
40
+ return;
41
+ }
42
+
43
+ // allowlist / pairing:c2c 用 allowFrom,group 用 groupAllowFrom
44
+ const allowList = isGroup
45
+ ? ((p?.groupAllowFrom as string[]) ?? [])
46
+ : ((p?.allowFrom as string[]) ?? []);
47
+ if (!allowList.length || allowList.includes('*')) {
48
+ await next();
49
+ return;
50
+ }
51
+
52
+ const id = isGroup
53
+ ? (ctx.message.groupOpenid ?? '')
54
+ : (ctx.message.senderId as string);
55
+ if (allowList.includes(id)) {
56
+ await next();
57
+ return;
58
+ }
59
+
60
+ // allowlist 未匹配 → pairing 模式尝试 pairing store(仅 c2c)
61
+ if (mode === 'pairing' && !isGroup) {
62
+ await checkPairingMode(ctx, next, {
63
+ accountId,
64
+ getRuntime,
65
+ senderId: ctx.message.senderId as string,
66
+ });
67
+ return;
68
+ }
69
+
70
+ // 未匹配 → 拒绝
71
+ const listLabel = isGroup ? 'groupAllowFrom' : 'allowFrom';
72
+ ctx.log?.info?.(
73
+ `[access] blocked ${isGroup ? 'group' : 'c2c'} from ${id}: not in ${listLabel}`,
74
+ );
75
+ ctx.stop('access:not_allowlisted');
76
+ };
77
+ }
78
+
79
+ /**
80
+ * pairing 模式:检查 pairing store + 发起配对挑战。
81
+ */
82
+ async function checkPairingMode(
83
+ ctx: any,
84
+ next: () => Promise<void>,
85
+ opts: { accountId: string; getRuntime: () => any; senderId: string },
86
+ ): Promise<void> {
87
+ const api = getPairingApi();
88
+
89
+ try {
90
+ const storeIds = await api.readAllowFromStore({
91
+ channel: 'qqbot',
92
+ accountId: opts.accountId,
93
+ });
94
+ if (storeIds.includes(opts.senderId) || storeIds.includes('*')) {
95
+ await next();
96
+ return;
97
+ }
98
+
99
+ const challenge = await api.issueChallenge({
100
+ channel: 'qqbot',
101
+ id: opts.senderId,
102
+ accountId: opts.accountId,
103
+ });
104
+ const frameworkReply = api.buildReply({
105
+ code: challenge.code,
106
+ channel: 'qqbot',
107
+ });
108
+ const reply = [
109
+ frameworkReply,
110
+ '',
111
+ 'QQ 管理员可直接执行:',
112
+ '',
113
+ '```',
114
+ `/bot-pairing approve ${challenge.code}`,
115
+ '```',
116
+ ].join('\n');
117
+
118
+ ctx.log?.info?.(`[access] pairing required for ${opts.senderId}`);
119
+ await ctx.bot.sendText(ctx.replyTarget, reply).catch(() => {/* ignore */});
120
+ ctx.stop('access:pairing_required');
121
+ } catch (err) {
122
+ ctx.log?.error?.(`[access] pairing error: ${(err as Error).message}`);
123
+ ctx.stop(`access:pairing_error: ${(err as Error).message}`);
124
+ }
125
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * 入站附件处理中间件
3
+ *
4
+ * 处理入站消息中的语音/图片/视频附件:
5
+ * - 语音:下载 → SILK转WAV → STT转文字 → 写入 ctx.state.processedAttachments
6
+ * - 图片:提取 URL 列表
7
+ * - 其他:标记为附件描述
8
+ *
9
+ * 插入位置:envelopeFormatter 之前
10
+ */
11
+ import * as path from 'node:path';
12
+ import type { MiddlewareContext } from '@tencent-connect/qqbot-nodejs';
13
+ import {
14
+ convertSilkToWav,
15
+ isVoiceAttachment,
16
+ } from '@tencent-connect/qqbot-nodejs/protocol';
17
+ import type { MessageAttachment } from '../types.js';
18
+ import { transcribeAudio, resolveSTTConfig, shouldUsePlatformAsr } from '../utils/stt.js';
19
+ import { formatVoiceText, formatDuration, type VoiceTranscript, type TranscriptSource } from '../utils/voice-text.js';
20
+ import { downloadRemoteMedia } from '../adapter/media.js';
21
+ import { getAdapters } from '../adapter/resolve.js';
22
+
23
+ export { formatVoiceText, formatDuration };
24
+ export type { VoiceTranscript, TranscriptSource };
25
+
26
+ /** 处理后的附件结果(写入 ctx.state.processedAttachments) */
27
+ export interface ProcessedAttachments {
28
+ voiceText: string;
29
+ imageUrls: string[];
30
+ otherInfo: string;
31
+ transcripts: VoiceTranscript[];
32
+ /** 下载到本地的媒体路径(图片 + 语音,供 AI 引用) */
33
+ localMediaPaths: string[];
34
+ /** 对应 localMediaPaths 的 MIME type */
35
+ localMediaTypes: string[];
36
+ /** 远端 URL 列表(下载失败时的回退) */
37
+ remoteMediaUrls: string[];
38
+ /**
39
+ * 结构化媒体清单(全部附件,保持原始顺序)。
40
+ * 供 ctx-builder 组装 core 正规媒体通道的 media facts(MediaKind: image|audio|video|document)。
41
+ */
42
+ media: InboundMediaEntry[];
43
+ }
44
+
45
+ /**
46
+ * 单个入站媒体条目(对齐 core MediaKind/ChannelInboundMediaInput 语义)。
47
+ * localPath 与 remoteUrl 二选一可能都没有(下载失败且无 URL 时)。
48
+ */
49
+ export interface InboundMediaEntry {
50
+ kind: 'image' | 'audio' | 'video' | 'document';
51
+ localPath?: string;
52
+ remoteUrl?: string;
53
+ contentType?: string;
54
+ filename?: string;
55
+ /** 仅语音:STT/平台 ASR 产出真实转写文本时为 true */
56
+ transcribed?: boolean;
57
+ }
58
+
59
+ interface AttachmentMiddlewareOptions {
60
+ /** 获取 runtime */
61
+ getRuntime: () => any;
62
+ }
63
+
64
+ /**
65
+ * 附件处理中间件
66
+ *
67
+ * SDK ctx 已提供 log 和 accountId,只需传入配置获取函数。
68
+ */
69
+ export function attachmentProcessor(opts: AttachmentMiddlewareOptions) {
70
+ return async (ctx: MiddlewareContext, next: () => Promise<void>) => {
71
+ const msg = ctx.message;
72
+ const attachments = msg.attachments as MessageAttachment[] | undefined;
73
+
74
+ if (attachments?.length) {
75
+ const runtime = opts.getRuntime();
76
+ const adapters = getAdapters(runtime);
77
+ const cfg = (adapters.getConfig?.() ?? {}) as Record<string, unknown>;
78
+ const log = ctx.log;
79
+ const result = await processAttachments(attachments, cfg, log);
80
+
81
+ if (result.voiceText || result.imageUrls.length > 0 || result.otherInfo || result.localMediaPaths.length > 0) {
82
+ ctx.state.processedAttachments = result;
83
+ }
84
+ }
85
+
86
+ await next();
87
+ };
88
+ }
89
+
90
+ // ── 核心处理逻辑 ──
91
+
92
+ type Log = { info: (m: string) => void; error: (m: string) => void; debug?: (m: string) => void };
93
+
94
+ export async function processAttachments(
95
+ attachments: MessageAttachment[],
96
+ cfg: Record<string, unknown>,
97
+ log?: Log,
98
+ ): Promise<ProcessedAttachments> {
99
+ const sttCfg = resolveSTTConfig(cfg);
100
+ const usePlatformAsr = shouldUsePlatformAsr(cfg);
101
+ const audioPolicy = resolveAudioPolicy(cfg);
102
+
103
+ const imageUrls: string[] = [];
104
+ const otherParts: string[] = [];
105
+ const transcripts: VoiceTranscript[] = [];
106
+ const localMediaPaths: string[] = [];
107
+ const localMediaTypes: string[] = [];
108
+ const remoteMediaUrls: string[] = [];
109
+ const media: InboundMediaEntry[] = [];
110
+
111
+ // 并行下载所有附件
112
+ const tasks = attachments.map(async (att) => {
113
+ const isVoice = isVoiceAttachment(att);
114
+ const isImage = att.content_type?.startsWith('image/');
115
+ const url = normalizeUrl(att.url);
116
+
117
+ if (isImage && url) {
118
+ const localPath = await downloadMediaFile(url, att.filename, log);
119
+ return { type: 'image' as const, localPath, url, contentType: att.content_type ?? 'image/png', filename: att.filename };
120
+ }
121
+
122
+ if (isVoice) {
123
+ const transcript = await processVoiceAttachment(att, sttCfg, usePlatformAsr, audioPolicy, log);
124
+ return { type: 'voice' as const, transcript };
125
+ }
126
+
127
+ // other 类型也尝试下载
128
+ if (url) {
129
+ const localPath = await downloadMediaFile(url, att.filename, log);
130
+ return { type: 'other' as const, localPath, url, filename: att.filename ?? att.content_type, contentType: att.content_type };
131
+ }
132
+ return { type: 'other' as const, localPath: null, url: '', filename: att.filename ?? att.content_type, contentType: att.content_type };
133
+ });
134
+
135
+ const results = await Promise.all(tasks);
136
+
137
+ // 按原始顺序收集结果
138
+ for (const result of results) {
139
+ if (result.type === 'image') {
140
+ if (result.localPath) {
141
+ imageUrls.push(result.localPath);
142
+ localMediaPaths.push(result.localPath);
143
+ localMediaTypes.push(result.contentType);
144
+ } else {
145
+ imageUrls.push(result.url);
146
+ remoteMediaUrls.push(result.url);
147
+ }
148
+ media.push({
149
+ kind: 'image',
150
+ ...(result.localPath ? { localPath: result.localPath } : { remoteUrl: result.url }),
151
+ contentType: result.contentType,
152
+ filename: result.filename,
153
+ });
154
+ } else if (result.type === 'voice') {
155
+ transcripts.push(result.transcript);
156
+ if (result.transcript.localPath) {
157
+ localMediaPaths.push(result.transcript.localPath);
158
+ localMediaTypes.push('audio/wav');
159
+ } else if (result.transcript.remoteUrl) {
160
+ remoteMediaUrls.push(result.transcript.remoteUrl);
161
+ }
162
+ media.push({
163
+ kind: 'audio',
164
+ ...(result.transcript.localPath
165
+ ? { localPath: result.transcript.localPath }
166
+ : result.transcript.remoteUrl
167
+ ? { remoteUrl: result.transcript.remoteUrl }
168
+ : {}),
169
+ contentType: 'audio/wav',
170
+ transcribed: result.transcript.source !== 'fallback',
171
+ });
172
+ } else if (result.type === 'other') {
173
+ if (result.localPath) {
174
+ otherParts.push(`[Attachment: ${result.localPath}]`);
175
+ localMediaPaths.push(result.localPath);
176
+ localMediaTypes.push('application/octet-stream');
177
+ } else {
178
+ otherParts.push(`[Attachment: ${result.filename}]`);
179
+ }
180
+ media.push({
181
+ kind: kindFromContentType(result.contentType),
182
+ ...(result.localPath ? { localPath: result.localPath } : result.url ? { remoteUrl: result.url } : {}),
183
+ ...(result.contentType ? { contentType: result.contentType } : {}),
184
+ ...(result.filename ? { filename: result.filename } : {}),
185
+ });
186
+ }
187
+ }
188
+
189
+ return {
190
+ voiceText: formatVoiceText(transcripts),
191
+ imageUrls,
192
+ otherInfo: otherParts.join('\n'),
193
+ transcripts,
194
+ localMediaPaths,
195
+ localMediaTypes,
196
+ remoteMediaUrls,
197
+ media,
198
+ };
199
+ }
200
+
201
+ /** content_type → core MediaKind(无法识别时归为 document) */
202
+ function kindFromContentType(contentType: string | undefined): InboundMediaEntry['kind'] {
203
+ if (contentType?.startsWith('video/')) return 'video';
204
+ if (contentType?.startsWith('image/')) return 'image';
205
+ if (contentType?.startsWith('audio/')) return 'audio';
206
+ return 'document';
207
+ }
208
+
209
+ // ── 语音处理 ──
210
+
211
+ async function processVoiceAttachment(
212
+ att: MessageAttachment,
213
+ sttCfg: ReturnType<typeof resolveSTTConfig>,
214
+ usePlatformAsr: boolean,
215
+ audioPolicy: AudioPolicyResolved,
216
+ log?: Log,
217
+ ): Promise<VoiceTranscript> {
218
+ // 平台转写(asr_refer_text)仅在显式 asrFallback: true 时参与;
219
+ // 缺省/false 时在所有场景下丢弃——包括 STT 未配置(语音落占位文本)
220
+ // 与 STT 失败(不当兜底),三条泄漏路径(转写成功携带 / 转写失败回退 /
221
+ // 下载失败回退)一并堵死。
222
+ const rawAsrText = att.asr_refer_text?.trim() || undefined;
223
+ const asrReferText = usePlatformAsr ? rawAsrText : undefined;
224
+ // 远端 URL 兜底:优先 wav_url,其次原始 url
225
+ const remoteUrl = normalizeUrl(att.voice_wav_url) || normalizeUrl(att.url) || undefined;
226
+
227
+ // STT 未配置:占位文本;显式 asrFallback: true 时退回平台转写
228
+ if (!sttCfg) {
229
+ if (!usePlatformAsr && rawAsrText) {
230
+ log?.info(`Voice: STT not configured; platform asr_refer_text discarded (asrFallback not enabled)`);
231
+ }
232
+ if (asrReferText) {
233
+ log?.debug?.(`Voice: using asr_refer_text (STT not configured, asrFallback enabled)`);
234
+ return { text: asrReferText, source: 'asr', asrReferText, remoteUrl };
235
+ }
236
+ return {
237
+ text: '[Voice message - transcription unavailable]',
238
+ source: 'fallback',
239
+ asrReferText,
240
+ remoteUrl,
241
+ };
242
+ }
243
+
244
+ let localPath: string | undefined;
245
+ let duration: number | undefined;
246
+
247
+ try {
248
+ const wavUrl = normalizeUrl(att.voice_wav_url);
249
+ if (wavUrl) {
250
+ const downloaded = await downloadMediaFile(wavUrl, undefined, log);
251
+ if (downloaded) {
252
+ localPath = downloaded;
253
+ log?.debug?.(`Voice: downloaded WAV from voice_wav_url`);
254
+ }
255
+ }
256
+
257
+ if (!localPath) {
258
+ const silkUrl = normalizeUrl(att.url);
259
+ if (silkUrl) {
260
+ const silkPath = await downloadMediaFile(silkUrl, att.filename, log);
261
+ if (silkPath) {
262
+ const ext = path.extname(silkPath).toLowerCase();
263
+ if (audioPolicy.sttDirectFormats.includes(ext)) {
264
+ localPath = silkPath;
265
+ } else {
266
+ const wavResult = await convertSilkToWav(silkPath);
267
+ if (wavResult) {
268
+ localPath = wavResult.wavPath;
269
+ duration = wavResult.duration / 1000;
270
+ log?.debug?.(`Voice: SILK→WAV (${formatDuration(duration)})`);
271
+ } else {
272
+ localPath = silkPath;
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
278
+ } catch (err) {
279
+ log?.error(`Voice download/convert failed: ${err instanceof Error ? err.message : String(err)}`);
280
+ }
281
+
282
+ if (localPath) {
283
+ try {
284
+ const transcript = await transcribeAudio(localPath, cfg2stt(sttCfg));
285
+ if (transcript) {
286
+ log?.debug?.(`Voice STT: ${transcript.slice(0, 80)}...`);
287
+ return { text: transcript, source: 'stt', duration, localPath, remoteUrl, asrReferText };
288
+ }
289
+ } catch (err) {
290
+ log?.error(`Voice STT failed: ${err instanceof Error ? err.message : String(err)}`);
291
+ }
292
+ }
293
+
294
+ if (asrReferText) {
295
+ return { text: asrReferText, source: 'asr', duration, localPath, remoteUrl, asrReferText };
296
+ }
297
+
298
+ return {
299
+ text: '[Voice message - transcription failed]',
300
+ source: 'fallback',
301
+ duration,
302
+ localPath,
303
+ remoteUrl,
304
+ asrReferText,
305
+ };
306
+ }
307
+
308
+ // ── 配置 ──
309
+
310
+ interface AudioPolicyResolved {
311
+ sttDirectFormats: string[];
312
+ uploadDirectFormats: string[];
313
+ transcodeEnabled: boolean;
314
+ }
315
+
316
+ function resolveAudioPolicy(cfg: Record<string, unknown>): AudioPolicyResolved {
317
+ const channels = cfg.channels as Record<string, unknown> | undefined;
318
+ const qqbot = channels?.qqbot as Record<string, unknown> | undefined;
319
+ const policy = qqbot?.audioFormatPolicy as Record<string, unknown> | undefined;
320
+
321
+ return {
322
+ sttDirectFormats: normalizeFormats((policy?.sttDirectFormats as string[]) ?? []),
323
+ uploadDirectFormats: normalizeFormats(
324
+ (policy?.uploadDirectFormats as string[]) ?? ['.wav', '.mp3', '.silk'],
325
+ ),
326
+ transcodeEnabled: (policy?.transcodeEnabled as boolean) !== false,
327
+ };
328
+ }
329
+
330
+ function normalizeFormats(formats: string[]): string[] {
331
+ return formats.map((f) => {
332
+ const lower = f.toLowerCase().trim();
333
+ return lower.startsWith('.') ? lower : `.${lower}`;
334
+ });
335
+ }
336
+
337
+ function cfg2stt(sttCfg: NonNullable<ReturnType<typeof resolveSTTConfig>>): Record<string, unknown> {
338
+ return { channels: { qqbot: { stt: sttCfg } } };
339
+ }
340
+
341
+ // ── 文件工具 ──
342
+
343
+ function normalizeUrl(url: string | undefined): string {
344
+ if (!url) return '';
345
+ return url.startsWith('//') ? `https:${url}` : url;
346
+ }
347
+
348
+ async function downloadMediaFile(
349
+ url: string,
350
+ filename?: string,
351
+ log?: Log,
352
+ ): Promise<string | null> {
353
+ // 仅允许 HTTPS(安全策略)
354
+ if (!url.startsWith('https://')) {
355
+ log?.debug?.(`Skipping non-HTTPS URL: ${url.slice(0, 80)}`);
356
+ return null;
357
+ }
358
+
359
+ try {
360
+ const result = await downloadRemoteMedia({
361
+ url,
362
+ subdir: 'qqbot/downloads',
363
+ originalFilename: filename,
364
+ maxBytes: 500 * 1024 * 1024,
365
+ timeoutMs: 120_000,
366
+ });
367
+ log?.debug?.(`Downloaded: ${result.path}`);
368
+ return result.path;
369
+ } catch (err) {
370
+ log?.error(`Download failed: ${url.slice(0, 80)} — ${err instanceof Error ? err.message : String(err)}`);
371
+ return null;
372
+ }
373
+ }