@foxden-app/foxclaw 0.5.46 → 0.5.48
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/.env.example +15 -0
- package/CHANGELOG.md +24 -0
- package/dist/auth/mirror.d.ts +1 -0
- package/dist/auth/mirror.js +10 -0
- package/dist/channels/bridge_messaging_router.d.ts +1 -0
- package/dist/channels/bridge_messaging_router.js +6 -0
- package/dist/channels/telegram/telegram_messaging_port.d.ts +1 -0
- package/dist/channels/telegram/telegram_messaging_port.js +4 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -0
- package/dist/controller/controller.d.ts +8 -0
- package/dist/controller/controller.js +101 -2
- package/dist/main.js +15 -0
- package/dist/telegram/gateway.d.ts +1 -0
- package/dist/telegram/gateway.js +16 -0
- package/dist/voice/tts.d.ts +8 -0
- package/dist/voice/tts.js +205 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -53,6 +53,21 @@ CODEX_CLI_BIN=/absolute/path/to/codex
|
|
|
53
53
|
# toggle this at runtime and writes AUTH_AUTO_DELETE_NEEDS_REPAIR back here.
|
|
54
54
|
# AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
55
55
|
|
|
56
|
+
# Optional Telegram voice summaries.
|
|
57
|
+
# When enabled, completed final answers get a "listen" button and /voice can
|
|
58
|
+
# generate Telegram voice messages. SSH mode runs TTS and ffmpeg conversion on
|
|
59
|
+
# the speech host; HTTP mode calls VOICE_TTS_URL and converts locally.
|
|
60
|
+
# VOICE_TTS_ENABLED=false
|
|
61
|
+
# VOICE_TTS_MODE=ssh
|
|
62
|
+
# VOICE_TTS_SSH_HOST=thinkbook16p
|
|
63
|
+
# VOICE_TTS_SSH_DIR=/home/wuya/dev/qwen-speech-server
|
|
64
|
+
# VOICE_TTS_DESIGN_INSTRUCT=用自然清晰的中文女声朗读,语速适中。
|
|
65
|
+
# VOICE_SUMMARY_BUTTON_ENABLED=true
|
|
66
|
+
# VOICE_TEXT_LIMIT=2800
|
|
67
|
+
# VOICE_TTS_URL=https://tts.foxden.app
|
|
68
|
+
# VOICE_TTS_TOKEN=
|
|
69
|
+
# VOICE_FFMPEG_BIN=ffmpeg
|
|
70
|
+
|
|
56
71
|
# Optional: standard HTTP(S) proxy for Telegram and ChatGPT/Codex backend requests.
|
|
57
72
|
# Put these in the same env file that `foxclaw start` installs into systemd/launchd.
|
|
58
73
|
# FoxClaw passes them to the service and enables Node's env proxy support.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.48 - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 新增 Telegram 总结语音能力:最终答复完成后可附带“听总结”按钮,点击后由 FoxClaw 调用语音服务生成 OGG/OPUS 并通过 Telegram `sendVoice` 发回聊天。
|
|
9
|
+
- 新增 `/voice <文本>` 和 `/voice last`,便于直接测试任意文本或最近一次最终总结的朗读效果;微信通道会明确提示当前不支持语音消息。
|
|
10
|
+
- 支持 SSH 语音后端和 HTTP 语音后端两种模式;SSH 模式可直接使用 16p 上的 Qwen TTS 服务和远端 `ffmpeg` 转码,并在 custom speaker 不可用时自动降级到 VoiceDesign endpoint。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Added Telegram voice summaries: completed final answers can include a "Listen" button that asks FoxClaw to synthesize OGG/OPUS audio and return it through Telegram `sendVoice`.
|
|
14
|
+
- Added `/voice <text>` and `/voice last` for quick manual checks against arbitrary text or the latest final answer; Weixin scopes now get an explicit unsupported-channel message.
|
|
15
|
+
- Supports both SSH and HTTP speech backends. SSH mode can use the Qwen TTS service and remote `ffmpeg` on the 16p host, and automatically falls back to the VoiceDesign endpoint when custom speakers are unavailable.
|
|
16
|
+
|
|
17
|
+
## 0.5.47 - 2026-06-20
|
|
18
|
+
|
|
19
|
+
### 中文
|
|
20
|
+
- 修复远端 auth 候选已成功导入后,`/auth` 面板仍保留旧的 `needs_repair`/禁用状态,导致候选显示问号且无法切换的问题。
|
|
21
|
+
- 远端导入成功现在会恢复该候选的全局状态和各 Telegram runtime 覆盖状态为 active/enabled,确保“确认可用后文件和 UI 状态一致”。
|
|
22
|
+
- 增加 mirror 远端导入 hook 回归测试,避免后续同步链路再次只更新 auth 文件、不恢复健康标记。
|
|
23
|
+
|
|
24
|
+
### English
|
|
25
|
+
- Fixed `/auth` keeping stale `needs_repair`/disabled state after a remote auth candidate was successfully imported, which left the candidate shown with a question mark and blocked switching.
|
|
26
|
+
- Successful remote imports now restore the candidate's global state and each Telegram runtime override to active/enabled, keeping the UI state aligned with validated auth files.
|
|
27
|
+
- Added a regression test for the mirror remote-import hook so future sync changes do not update auth files without clearing health markers.
|
|
28
|
+
|
|
5
29
|
## 0.5.46 - 2026-06-20
|
|
6
30
|
|
|
7
31
|
### 中文
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface AuthMirrorSyncAllResult {
|
|
|
61
61
|
}
|
|
62
62
|
export interface AuthMirrorHooks {
|
|
63
63
|
onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
|
|
64
|
+
onRemoteImported?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
|
|
64
65
|
}
|
|
65
66
|
export type AuthMirrorNotification = {
|
|
66
67
|
kind: 'local_synced';
|
package/dist/auth/mirror.js
CHANGED
|
@@ -304,6 +304,16 @@ export class AuthCandidateMirror {
|
|
|
304
304
|
sourceLabel,
|
|
305
305
|
};
|
|
306
306
|
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(notification)));
|
|
307
|
+
await this.hooks.onRemoteImported?.({
|
|
308
|
+
status: this.lastStatus,
|
|
309
|
+
record: {
|
|
310
|
+
raw,
|
|
311
|
+
...metadata,
|
|
312
|
+
candidateName,
|
|
313
|
+
sourceRuntimeId: `remote:${source.nodeId}`,
|
|
314
|
+
sourceLabel,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
307
317
|
return {
|
|
308
318
|
ok: true,
|
|
309
319
|
imported: true,
|
|
@@ -17,6 +17,7 @@ export declare class BridgeMessagingRouter {
|
|
|
17
17
|
sendHtml(scopeId: string, text: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
18
18
|
sendRichHtml(scopeId: string, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
19
19
|
sendRichMarkdown(scopeId: string, markdown: string, fallbackText: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
20
|
+
sendVoice(scopeId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
20
21
|
editPlain(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
21
22
|
editHtml(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
22
23
|
editRichHtml(scopeId: string, messageId: number, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
@@ -49,6 +49,12 @@ export class BridgeMessagingRouter {
|
|
|
49
49
|
}
|
|
50
50
|
return this.telegram.sendRichMarkdown(scopeId, markdown, keyboard);
|
|
51
51
|
}
|
|
52
|
+
sendVoice(scopeId, filename, contents, caption) {
|
|
53
|
+
if (this.isWeixinScope(scopeId)) {
|
|
54
|
+
throw new Error(`Voice messages are not supported for Weixin scope ${scopeId}`);
|
|
55
|
+
}
|
|
56
|
+
return this.telegram.sendVoice(scopeId, filename, contents, caption);
|
|
57
|
+
}
|
|
52
58
|
editPlain(scopeId, messageId, text, keyboard) {
|
|
53
59
|
if (this.isWeixinScope(scopeId)) {
|
|
54
60
|
return this.requireWeixinTransport(scopeId).editPlain(scopeId, messageId, text, keyboard);
|
|
@@ -15,6 +15,7 @@ export declare class TelegramMessagingPort implements ChannelPort {
|
|
|
15
15
|
sendHtml(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
16
16
|
sendRichHtml(bridgeScopeId: string, html: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
17
17
|
sendRichMarkdown(bridgeScopeId: string, markdown: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
18
|
+
sendVoice(bridgeScopeId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
18
19
|
editPlain(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
19
20
|
editHtml(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
20
21
|
editRichHtml(bridgeScopeId: string, messageId: number, html: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
@@ -24,6 +24,10 @@ export class TelegramMessagingPort {
|
|
|
24
24
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
25
25
|
return this.gateway.sendRichMessage(target.chatId, telegramRichMarkdown(markdown, { skipEntityDetection: true }), inlineKeyboard, target.topicId);
|
|
26
26
|
}
|
|
27
|
+
async sendVoice(bridgeScopeId, filename, contents, caption) {
|
|
28
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
29
|
+
return this.gateway.sendVoice(target.chatId, filename, contents, caption, target.topicId);
|
|
30
|
+
}
|
|
27
31
|
async editPlain(bridgeScopeId, messageId, text, inlineKeyboard) {
|
|
28
32
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
29
33
|
await this.gateway.editMessage(target.chatId, messageId, text, inlineKeyboard);
|
package/dist/config.d.ts
CHANGED
|
@@ -63,6 +63,16 @@ export interface AppConfig {
|
|
|
63
63
|
authSyncStatePath: string;
|
|
64
64
|
authSyncTempDir: string;
|
|
65
65
|
authAutoDeleteNeedsRepair: boolean;
|
|
66
|
+
voiceTtsEnabled: boolean;
|
|
67
|
+
voiceTtsMode: 'http' | 'ssh';
|
|
68
|
+
voiceTtsUrl: string | null;
|
|
69
|
+
voiceTtsToken: string | null;
|
|
70
|
+
voiceTtsSshHost: string;
|
|
71
|
+
voiceTtsSshDir: string;
|
|
72
|
+
voiceTtsDesignInstruct: string;
|
|
73
|
+
voiceFfmpegBin: string;
|
|
74
|
+
voiceSummaryButtonEnabled: boolean;
|
|
75
|
+
voiceTextLimit: number;
|
|
66
76
|
}
|
|
67
77
|
export declare function loadConfig(): AppConfig;
|
|
68
78
|
export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
|
package/dist/config.js
CHANGED
|
@@ -97,6 +97,16 @@ export function loadConfig() {
|
|
|
97
97
|
authSyncStatePath: process.env.AUTH_SYNC_STATE_PATH || DEFAULT_AUTH_SYNC_STATE_PATH,
|
|
98
98
|
authSyncTempDir: process.env.AUTH_SYNC_TEMP_DIR || DEFAULT_AUTH_SYNC_TEMP_DIR,
|
|
99
99
|
authAutoDeleteNeedsRepair: boolEnv('AUTH_AUTO_DELETE_NEEDS_REPAIR', false),
|
|
100
|
+
voiceTtsEnabled: boolEnv('VOICE_TTS_ENABLED', false),
|
|
101
|
+
voiceTtsMode: parseVoiceTtsMode(process.env.VOICE_TTS_MODE || (process.env.VOICE_TTS_URL?.trim() ? 'http' : 'ssh')),
|
|
102
|
+
voiceTtsUrl: optional('VOICE_TTS_URL') ?? 'https://tts.foxden.app',
|
|
103
|
+
voiceTtsToken: optional('VOICE_TTS_TOKEN'),
|
|
104
|
+
voiceTtsSshHost: process.env.VOICE_TTS_SSH_HOST?.trim() || 'thinkbook16p',
|
|
105
|
+
voiceTtsSshDir: process.env.VOICE_TTS_SSH_DIR?.trim() || '/home/wuya/dev/qwen-speech-server',
|
|
106
|
+
voiceTtsDesignInstruct: process.env.VOICE_TTS_DESIGN_INSTRUCT?.trim() || '用自然清晰的中文女声朗读,语速适中。',
|
|
107
|
+
voiceFfmpegBin: process.env.VOICE_FFMPEG_BIN?.trim() || 'ffmpeg',
|
|
108
|
+
voiceSummaryButtonEnabled: boolEnv('VOICE_SUMMARY_BUTTON_ENABLED', true),
|
|
109
|
+
voiceTextLimit: intEnv('VOICE_TEXT_LIMIT', 2800),
|
|
100
110
|
};
|
|
101
111
|
ensureAppDirs(config);
|
|
102
112
|
return config;
|
|
@@ -182,6 +192,9 @@ function parseSandboxMode(value) {
|
|
|
182
192
|
return value;
|
|
183
193
|
return 'workspace-write';
|
|
184
194
|
}
|
|
195
|
+
function parseVoiceTtsMode(value) {
|
|
196
|
+
return value.trim().toLowerCase() === 'http' ? 'http' : 'ssh';
|
|
197
|
+
}
|
|
185
198
|
function resolveCommand(commandName) {
|
|
186
199
|
try {
|
|
187
200
|
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
@@ -89,6 +89,8 @@ export declare class BridgeSessionCore {
|
|
|
89
89
|
private approvalTimers;
|
|
90
90
|
private submittedUserInputTimers;
|
|
91
91
|
private restartPreviewRecoveryTimers;
|
|
92
|
+
private voiceSnippets;
|
|
93
|
+
private latestVoiceSnippetByScope;
|
|
92
94
|
private selfUpdatePollTimer;
|
|
93
95
|
private proactiveAuthRefreshTimer;
|
|
94
96
|
private proactiveAuthRefreshInProgress;
|
|
@@ -316,6 +318,9 @@ export declare class BridgeSessionCore {
|
|
|
316
318
|
private handleAccountCommand;
|
|
317
319
|
private handleQuotaCommand;
|
|
318
320
|
private handleQuotaNudgeCommand;
|
|
321
|
+
private handleVoiceCommand;
|
|
322
|
+
private handleVoiceCallback;
|
|
323
|
+
private sendVoiceForText;
|
|
319
324
|
private handleLoginDeviceCommand;
|
|
320
325
|
private handleLoginCancelCommand;
|
|
321
326
|
private handleLogoutCommand;
|
|
@@ -470,5 +475,8 @@ export declare class BridgeSessionCore {
|
|
|
470
475
|
private syncSegmentTimeline;
|
|
471
476
|
private shouldPromoteSegmentToRich;
|
|
472
477
|
private promoteSegmentMessagesToRich;
|
|
478
|
+
private voiceKeyboardForSegment;
|
|
479
|
+
private registerVoiceSnippet;
|
|
480
|
+
private pruneVoiceSnippets;
|
|
473
481
|
}
|
|
474
482
|
export { BridgeSessionCore as BridgeController };
|
|
@@ -16,6 +16,7 @@ import { renderTelegramMarkdownRichHtml } from '../telegram/rich_markdown.js';
|
|
|
16
16
|
import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
|
|
17
17
|
import { BRIDGE_SCOPE_WEIXIN_PREFIX, parseTelegramTargetFromBridgeScope, parseWeixinBridgeScope } from '../core/bridge_scope.js';
|
|
18
18
|
import { resolveTelegramRenderRoute } from '../telegram/rendering.js';
|
|
19
|
+
import { normalizeVoiceText, synthesizeTelegramVoice } from '../voice/tts.js';
|
|
19
20
|
import { readCodexLocalUsageSnapshot, readCodexLocalUsageStats, writeCodexLocalUsageSnapshot, } from '../codex_app/local_usage.js';
|
|
20
21
|
import { normalizeTurnActivityEvent, } from './activity.js';
|
|
21
22
|
import { normalizeAccessPreset, resolveAccessMode } from './access.js';
|
|
@@ -143,6 +144,8 @@ export class BridgeSessionCore {
|
|
|
143
144
|
approvalTimers = new Map();
|
|
144
145
|
submittedUserInputTimers = new Map();
|
|
145
146
|
restartPreviewRecoveryTimers = new Map();
|
|
147
|
+
voiceSnippets = new Map();
|
|
148
|
+
latestVoiceSnippetByScope = new Map();
|
|
146
149
|
selfUpdatePollTimer = null;
|
|
147
150
|
proactiveAuthRefreshTimer = null;
|
|
148
151
|
proactiveAuthRefreshInProgress = false;
|
|
@@ -528,6 +531,10 @@ export class BridgeSessionCore {
|
|
|
528
531
|
await this.handleQuotaNudgeCommand(scopeId, locale, args);
|
|
529
532
|
return;
|
|
530
533
|
}
|
|
534
|
+
case 'voice': {
|
|
535
|
+
await this.handleVoiceCommand(scopeId, locale, args);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
531
538
|
case 'login':
|
|
532
539
|
case 'login_device': {
|
|
533
540
|
await this.handleLoginDeviceCommand(scopeId, locale);
|
|
@@ -1078,6 +1085,11 @@ export class BridgeSessionCore {
|
|
|
1078
1085
|
await this.handleConfigToggleCallback(event, configMatch[1] === 'on', locale);
|
|
1079
1086
|
return;
|
|
1080
1087
|
}
|
|
1088
|
+
const voiceMatch = /^voice:([a-f0-9]+)$/.exec(event.data);
|
|
1089
|
+
if (voiceMatch) {
|
|
1090
|
+
await this.handleVoiceCallback(event, voiceMatch[1], locale);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1081
1093
|
const settingsMatch = /^settings:(model|effort|access):(.+)$/.exec(event.data);
|
|
1082
1094
|
if (settingsMatch) {
|
|
1083
1095
|
await this.handleSettingsCallback(event, settingsMatch[1], settingsMatch[2], locale);
|
|
@@ -5162,6 +5174,45 @@ export class BridgeSessionCore {
|
|
|
5162
5174
|
await this.app.sendAddCreditsNudgeEmail(creditType);
|
|
5163
5175
|
await this.sendMessage(scopeId, t(locale, 'quota_nudge_sent'));
|
|
5164
5176
|
}
|
|
5177
|
+
async handleVoiceCommand(scopeId, locale, args) {
|
|
5178
|
+
const raw = args.join(' ').trim();
|
|
5179
|
+
const snippetId = raw.toLowerCase() === 'last' ? this.latestVoiceSnippetByScope.get(scopeId) ?? null : null;
|
|
5180
|
+
const text = snippetId ? this.voiceSnippets.get(snippetId)?.text ?? '' : raw;
|
|
5181
|
+
if (!text) {
|
|
5182
|
+
await this.sendMessage(scopeId, locale === 'zh' ? '用法:/voice 要朗读的文本,或 /voice last' : 'Usage: /voice text to read, or /voice last');
|
|
5183
|
+
return;
|
|
5184
|
+
}
|
|
5185
|
+
await this.sendVoiceForText(scopeId, locale, text);
|
|
5186
|
+
}
|
|
5187
|
+
async handleVoiceCallback(event, localId, locale) {
|
|
5188
|
+
const snippet = this.voiceSnippets.get(localId);
|
|
5189
|
+
if (!snippet || snippet.scopeId !== event.scopeId) {
|
|
5190
|
+
await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh' ? '这条总结语音已过期' : 'This voice summary has expired');
|
|
5191
|
+
return;
|
|
5192
|
+
}
|
|
5193
|
+
await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh' ? '正在生成语音...' : 'Generating voice...');
|
|
5194
|
+
await this.sendVoiceForText(event.scopeId, locale, snippet.text);
|
|
5195
|
+
}
|
|
5196
|
+
async sendVoiceForText(scopeId, locale, text) {
|
|
5197
|
+
if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
|
|
5198
|
+
await this.sendMessage(scopeId, locale === 'zh' ? '当前只有 Telegram 支持语音消息。' : 'Voice messages are currently supported only on Telegram.');
|
|
5199
|
+
return;
|
|
5200
|
+
}
|
|
5201
|
+
if (!this.config.voiceTtsEnabled) {
|
|
5202
|
+
await this.sendMessage(scopeId, locale === 'zh' ? '语音服务未启用。' : 'Voice TTS is not enabled.');
|
|
5203
|
+
return;
|
|
5204
|
+
}
|
|
5205
|
+
try {
|
|
5206
|
+
const voice = await synthesizeTelegramVoice(text, this.config);
|
|
5207
|
+
await this.messaging.sendVoice(scopeId, voice.filename, voice.contents, locale === 'zh' ? 'FoxClaw 总结语音' : 'FoxClaw voice summary');
|
|
5208
|
+
}
|
|
5209
|
+
catch (error) {
|
|
5210
|
+
this.logger.warn('voice.summary_failed', { scopeId, error: toErrorMeta(error) });
|
|
5211
|
+
await this.sendMessage(scopeId, locale === 'zh'
|
|
5212
|
+
? `语音生成失败:${formatUserError(error)}`
|
|
5213
|
+
: `Voice generation failed: ${formatUserError(error)}`);
|
|
5214
|
+
}
|
|
5215
|
+
}
|
|
5165
5216
|
async handleLoginDeviceCommand(scopeId, locale) {
|
|
5166
5217
|
const login = await this.app.startDeviceLogin();
|
|
5167
5218
|
const oldLoginId = this.pendingLoginsByScope.get(scopeId);
|
|
@@ -8286,8 +8337,9 @@ export class BridgeSessionCore {
|
|
|
8286
8337
|
if (existing.richHtml === chunk || existing.richFailedForText === chunk) {
|
|
8287
8338
|
continue;
|
|
8288
8339
|
}
|
|
8340
|
+
const keyboard = this.voiceKeyboardForSegment(active, segment, index, chunks.length);
|
|
8289
8341
|
try {
|
|
8290
|
-
await this.messaging.editRichMarkdown(active.scopeId, existing.messageId, chunk, chunk);
|
|
8342
|
+
await this.messaging.editRichMarkdown(active.scopeId, existing.messageId, chunk, chunk, keyboard);
|
|
8291
8343
|
existing.richHtml = chunk;
|
|
8292
8344
|
existing.richFailedForText = null;
|
|
8293
8345
|
}
|
|
@@ -8301,7 +8353,7 @@ export class BridgeSessionCore {
|
|
|
8301
8353
|
});
|
|
8302
8354
|
const richHtml = renderTelegramMarkdownRichHtml(chunk);
|
|
8303
8355
|
try {
|
|
8304
|
-
await this.messaging.editRichHtml(active.scopeId, existing.messageId, richHtml, escapeTelegramHtml(chunk));
|
|
8356
|
+
await this.messaging.editRichHtml(active.scopeId, existing.messageId, richHtml, escapeTelegramHtml(chunk), keyboard);
|
|
8305
8357
|
existing.richHtml = richHtml;
|
|
8306
8358
|
existing.richFailedForText = null;
|
|
8307
8359
|
continue;
|
|
@@ -8323,6 +8375,52 @@ export class BridgeSessionCore {
|
|
|
8323
8375
|
}
|
|
8324
8376
|
}
|
|
8325
8377
|
}
|
|
8378
|
+
voiceKeyboardForSegment(active, segment, chunkIndex, chunkCount) {
|
|
8379
|
+
if (!this.config.voiceTtsEnabled
|
|
8380
|
+
|| !this.config.voiceSummaryButtonEnabled
|
|
8381
|
+
|| active.scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)
|
|
8382
|
+
|| active.isObserved
|
|
8383
|
+
|| segment.outputKind !== 'final_answer'
|
|
8384
|
+
|| chunkIndex !== chunkCount - 1) {
|
|
8385
|
+
return undefined;
|
|
8386
|
+
}
|
|
8387
|
+
const snippetId = segment.voiceSnippetId ?? this.registerVoiceSnippet(active.scopeId, segment.text);
|
|
8388
|
+
segment.voiceSnippetId = snippetId;
|
|
8389
|
+
const locale = this.localeForChat(active.scopeId);
|
|
8390
|
+
return [[{
|
|
8391
|
+
text: locale === 'zh' ? '🔊 听总结' : '🔊 Listen',
|
|
8392
|
+
callback_data: `voice:${snippetId}`,
|
|
8393
|
+
}]];
|
|
8394
|
+
}
|
|
8395
|
+
registerVoiceSnippet(scopeId, text) {
|
|
8396
|
+
this.pruneVoiceSnippets();
|
|
8397
|
+
const normalized = normalizeVoiceText(text, this.config.voiceTextLimit);
|
|
8398
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
8399
|
+
this.voiceSnippets.set(id, { scopeId, text: normalized, createdAt: Date.now() });
|
|
8400
|
+
this.latestVoiceSnippetByScope.set(scopeId, id);
|
|
8401
|
+
return id;
|
|
8402
|
+
}
|
|
8403
|
+
pruneVoiceSnippets() {
|
|
8404
|
+
const expiresBefore = Date.now() - 24 * 60 * 60_000;
|
|
8405
|
+
for (const [id, snippet] of this.voiceSnippets.entries()) {
|
|
8406
|
+
if (snippet.createdAt < expiresBefore) {
|
|
8407
|
+
this.voiceSnippets.delete(id);
|
|
8408
|
+
if (this.latestVoiceSnippetByScope.get(snippet.scopeId) === id) {
|
|
8409
|
+
this.latestVoiceSnippetByScope.delete(snippet.scopeId);
|
|
8410
|
+
}
|
|
8411
|
+
}
|
|
8412
|
+
}
|
|
8413
|
+
while (this.voiceSnippets.size > 100) {
|
|
8414
|
+
const oldest = [...this.voiceSnippets.entries()]
|
|
8415
|
+
.sort((left, right) => left[1].createdAt - right[1].createdAt)[0];
|
|
8416
|
+
if (!oldest)
|
|
8417
|
+
break;
|
|
8418
|
+
this.voiceSnippets.delete(oldest[0]);
|
|
8419
|
+
if (this.latestVoiceSnippetByScope.get(oldest[1].scopeId) === oldest[0]) {
|
|
8420
|
+
this.latestVoiceSnippetByScope.delete(oldest[1].scopeId);
|
|
8421
|
+
}
|
|
8422
|
+
}
|
|
8423
|
+
}
|
|
8326
8424
|
}
|
|
8327
8425
|
function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
8328
8426
|
let segment = active.segments.find((entry) => entry.itemId === itemId);
|
|
@@ -8348,6 +8446,7 @@ function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
|
8348
8446
|
startedAtMs: Date.now(),
|
|
8349
8447
|
completedAtMs: null,
|
|
8350
8448
|
messages: [],
|
|
8449
|
+
voiceSnippetId: null,
|
|
8351
8450
|
};
|
|
8352
8451
|
active.segments.push(segment);
|
|
8353
8452
|
return segment;
|
package/dist/main.js
CHANGED
|
@@ -410,6 +410,7 @@ async function runServeCli() {
|
|
|
410
410
|
seeds.push({ id, home, authDir, sharedDefaultRuntime, config: runtimeConfig, bot, app });
|
|
411
411
|
}
|
|
412
412
|
let authSync = null;
|
|
413
|
+
const authRuntimeIds = seeds.map((runtime) => runtime.id);
|
|
413
414
|
const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
|
|
414
415
|
id: runtime.id,
|
|
415
416
|
label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
|
|
@@ -423,6 +424,9 @@ async function runServeCli() {
|
|
|
423
424
|
onSynced: async (event) => {
|
|
424
425
|
await authSync?.publishCandidate(event.record.candidateName);
|
|
425
426
|
},
|
|
427
|
+
onRemoteImported: (event) => {
|
|
428
|
+
restoreImportedAuthCandidateState(store, event.record.candidateName, authRuntimeIds);
|
|
429
|
+
},
|
|
426
430
|
});
|
|
427
431
|
await mirror.initialize();
|
|
428
432
|
activeAuthMirror = mirror;
|
|
@@ -757,6 +761,9 @@ async function runServeCli() {
|
|
|
757
761
|
onSynced: async (event) => {
|
|
758
762
|
await singleAuthSync?.publishCandidate(event.record.candidateName);
|
|
759
763
|
},
|
|
764
|
+
onRemoteImported: (event) => {
|
|
765
|
+
restoreImportedAuthCandidateState(store, event.record.candidateName, ['default']);
|
|
766
|
+
},
|
|
760
767
|
});
|
|
761
768
|
await singleMirror.initialize();
|
|
762
769
|
activeAuthMirror = singleMirror;
|
|
@@ -863,6 +870,14 @@ async function runServeCli() {
|
|
|
863
870
|
throw error;
|
|
864
871
|
}
|
|
865
872
|
}
|
|
873
|
+
function restoreImportedAuthCandidateState(store, candidateName, runtimeIds) {
|
|
874
|
+
store.setCodexAuthCandidateState(candidateName, 'active');
|
|
875
|
+
store.setCodexAuthCandidateDisabled(candidateName, false);
|
|
876
|
+
for (const runtimeId of runtimeIds) {
|
|
877
|
+
store.setCodexAuthCandidateState(candidateName, 'active', runtimeId);
|
|
878
|
+
store.setCodexAuthCandidateDisabled(candidateName, false, runtimeId);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
866
881
|
function createAuthMirrorNotifier(store, botId, bot, aggregator, options = {}) {
|
|
867
882
|
return async (event) => {
|
|
868
883
|
if (options.suppressBackgroundNotifications?.())
|
|
@@ -68,6 +68,7 @@ export declare class TelegramGateway extends EventEmitter {
|
|
|
68
68
|
callback_data: string;
|
|
69
69
|
}>>, messageThreadId?: number | null): Promise<number>;
|
|
70
70
|
sendDocument(chatId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
71
|
+
sendVoice(chatId: string, filename: string, contents: Buffer, caption?: string, messageThreadId?: number | null): Promise<number>;
|
|
71
72
|
sendMessageDraft(chatId: string, draftId: number, text: string, messageThreadId?: number | null): Promise<void>;
|
|
72
73
|
sendRichMessageDraft(chatId: string, draftId: number, richMessage: TelegramInputRichMessage, messageThreadId?: number | null): Promise<void>;
|
|
73
74
|
editMessage(chatId: string, messageId: number, text: string, inlineKeyboard?: Array<Array<{
|
package/dist/telegram/gateway.js
CHANGED
|
@@ -83,6 +83,22 @@ export class TelegramGateway extends EventEmitter {
|
|
|
83
83
|
}
|
|
84
84
|
return result.result.message_id;
|
|
85
85
|
}
|
|
86
|
+
async sendVoice(chatId, filename, contents, caption, messageThreadId) {
|
|
87
|
+
const result = await callTelegramMultipartApi(this.botToken, 'sendVoice', {
|
|
88
|
+
chat_id: chatId,
|
|
89
|
+
...(caption ? { caption } : {}),
|
|
90
|
+
...(messageThreadId !== null && messageThreadId !== undefined ? { message_thread_id: String(messageThreadId) } : {}),
|
|
91
|
+
}, [{
|
|
92
|
+
fieldName: 'voice',
|
|
93
|
+
filename,
|
|
94
|
+
contents,
|
|
95
|
+
contentType: 'audio/ogg',
|
|
96
|
+
}]);
|
|
97
|
+
if (!result.ok || !result.result) {
|
|
98
|
+
throw new Error(result.description || 'Failed to send Telegram voice message');
|
|
99
|
+
}
|
|
100
|
+
return result.result.message_id;
|
|
101
|
+
}
|
|
86
102
|
async sendMessageDraft(chatId, draftId, text, messageThreadId) {
|
|
87
103
|
const result = await callTelegramApi(this.botToken, 'sendMessageDraft', {
|
|
88
104
|
chat_id: chatId,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppConfig } from '../config.js';
|
|
2
|
+
export interface VoiceSynthesisResult {
|
|
3
|
+
filename: string;
|
|
4
|
+
contents: Buffer;
|
|
5
|
+
contentType: 'audio/ogg';
|
|
6
|
+
}
|
|
7
|
+
export declare function synthesizeTelegramVoice(text: string, config: AppConfig): Promise<VoiceSynthesisResult>;
|
|
8
|
+
export declare function normalizeVoiceText(text: string, limit: number): string;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function synthesizeTelegramVoice(text, config) {
|
|
8
|
+
if (!config.voiceTtsEnabled) {
|
|
9
|
+
throw new Error('voice TTS is disabled');
|
|
10
|
+
}
|
|
11
|
+
const normalized = normalizeVoiceText(text, config.voiceTextLimit);
|
|
12
|
+
if (!normalized) {
|
|
13
|
+
throw new Error('voice text is empty');
|
|
14
|
+
}
|
|
15
|
+
const contents = config.voiceTtsMode === 'ssh'
|
|
16
|
+
? await synthesizeViaSsh(normalized, config)
|
|
17
|
+
: await synthesizeViaHttp(normalized, config);
|
|
18
|
+
return {
|
|
19
|
+
filename: `foxclaw-summary-${Date.now()}.ogg`,
|
|
20
|
+
contents,
|
|
21
|
+
contentType: 'audio/ogg',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function normalizeVoiceText(text, limit) {
|
|
25
|
+
const normalized = text
|
|
26
|
+
.replace(/```[\s\S]*?```/g, ' 代码块已省略。 ')
|
|
27
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
28
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
29
|
+
.replace(/!\[[^\]]*]\([^)]+\)/g, '')
|
|
30
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
31
|
+
.replace(/[*_~]{1,3}/g, '')
|
|
32
|
+
.replace(/^\s*[-*+]\s+/gm, ' - ')
|
|
33
|
+
.replace(/^\s*\d+\.\s+/gm, '')
|
|
34
|
+
.replace(/\|/g, ' ')
|
|
35
|
+
.replace(/[ \t]+/g, ' ')
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
37
|
+
.trim();
|
|
38
|
+
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 20)).trim()}。后文省略。` : normalized;
|
|
39
|
+
}
|
|
40
|
+
async function synthesizeViaHttp(text, config) {
|
|
41
|
+
if (!config.voiceTtsUrl) {
|
|
42
|
+
throw new Error('VOICE_TTS_URL is not configured');
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return convertToOggOpus(await requestTtsWav(config, '/v1/tts/custom', { text, language: 'zh' }), config.voiceFfmpegBin);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (!isNoCustomSpeakerError(error)) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const wav = await requestTtsWav(config, '/v1/tts/design', {
|
|
53
|
+
text,
|
|
54
|
+
language: 'zh',
|
|
55
|
+
instruct: config.voiceTtsDesignInstruct,
|
|
56
|
+
});
|
|
57
|
+
return convertToOggOpus(wav, config.voiceFfmpegBin);
|
|
58
|
+
}
|
|
59
|
+
async function requestTtsWav(config, pathname, body) {
|
|
60
|
+
if (!config.voiceTtsUrl) {
|
|
61
|
+
throw new Error('VOICE_TTS_URL is not configured');
|
|
62
|
+
}
|
|
63
|
+
const endpoint = new URL(pathname, config.voiceTtsUrl.endsWith('/') ? config.voiceTtsUrl : `${config.voiceTtsUrl}/`);
|
|
64
|
+
const response = await fetch(endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'content-type': 'application/json',
|
|
68
|
+
...(config.voiceTtsToken ? { authorization: `Bearer ${config.voiceTtsToken}` } : {}),
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`TTS request failed: ${response.status} ${response.statusText}: ${await response.text().catch(() => '')}`);
|
|
74
|
+
}
|
|
75
|
+
return Buffer.from(await response.arrayBuffer());
|
|
76
|
+
}
|
|
77
|
+
function isNoCustomSpeakerError(error) {
|
|
78
|
+
return error instanceof Error && error.message.includes('no supported speakers');
|
|
79
|
+
}
|
|
80
|
+
async function convertToOggOpus(input, ffmpegBin) {
|
|
81
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'foxclaw-voice-'));
|
|
82
|
+
const inputPath = path.join(root, 'input.wav');
|
|
83
|
+
const outputPath = path.join(root, 'voice.ogg');
|
|
84
|
+
try {
|
|
85
|
+
await fs.writeFile(inputPath, input, { mode: 0o600 });
|
|
86
|
+
await execFileAsync(ffmpegBin, [
|
|
87
|
+
'-hide_banner',
|
|
88
|
+
'-loglevel',
|
|
89
|
+
'error',
|
|
90
|
+
'-y',
|
|
91
|
+
'-i',
|
|
92
|
+
inputPath,
|
|
93
|
+
'-ac',
|
|
94
|
+
'1',
|
|
95
|
+
'-c:a',
|
|
96
|
+
'libopus',
|
|
97
|
+
'-b:a',
|
|
98
|
+
'32k',
|
|
99
|
+
outputPath,
|
|
100
|
+
], { timeout: 60_000, maxBuffer: 2 * 1024 * 1024 });
|
|
101
|
+
return await fs.readFile(outputPath);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => { });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function synthesizeViaSsh(text, config) {
|
|
108
|
+
const encodedText = Buffer.from(text, 'utf8').toString('base64');
|
|
109
|
+
const script = String.raw `set -euo pipefail
|
|
110
|
+
TEXT="$(printf '%s' "$1" | base64 -d)"
|
|
111
|
+
SERVICE_DIR="$2"
|
|
112
|
+
cd "$SERVICE_DIR"
|
|
113
|
+
set -a
|
|
114
|
+
. ./.env
|
|
115
|
+
set +a
|
|
116
|
+
tmp="$(mktemp -d)"
|
|
117
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
118
|
+
TEXT="$TEXT" python3 - <<'PY' > "$tmp/body.json"
|
|
119
|
+
import json
|
|
120
|
+
import os
|
|
121
|
+
print(json.dumps({"text": os.environ["TEXT"], "language": "zh"}, ensure_ascii=False))
|
|
122
|
+
PY
|
|
123
|
+
status="$(curl -sS -w '%{http_code}' -X POST http://127.0.0.1:18081/v1/tts/custom \
|
|
124
|
+
-H "Authorization: Bearer $QWEN_SPEECH_API_TOKEN" \
|
|
125
|
+
-H "Content-Type: application/json" \
|
|
126
|
+
--data-binary "@$tmp/body.json" \
|
|
127
|
+
--output "$tmp/tts.wav")"
|
|
128
|
+
if [[ "$status" != "200" ]]; then
|
|
129
|
+
if grep -q 'no supported speakers' "$tmp/tts.wav"; then
|
|
130
|
+
INSTRUCT="$3" TEXT="$TEXT" python3 - <<'PY' > "$tmp/body.json"
|
|
131
|
+
import json
|
|
132
|
+
import os
|
|
133
|
+
print(json.dumps({
|
|
134
|
+
"text": os.environ["TEXT"],
|
|
135
|
+
"language": "zh",
|
|
136
|
+
"instruct": os.environ["INSTRUCT"],
|
|
137
|
+
}, ensure_ascii=False))
|
|
138
|
+
PY
|
|
139
|
+
status="$(curl -sS -w '%{http_code}' -X POST http://127.0.0.1:18081/v1/tts/design \
|
|
140
|
+
-H "Authorization: Bearer $QWEN_SPEECH_API_TOKEN" \
|
|
141
|
+
-H "Content-Type: application/json" \
|
|
142
|
+
--data-binary "@$tmp/body.json" \
|
|
143
|
+
--output "$tmp/tts.wav")"
|
|
144
|
+
fi
|
|
145
|
+
fi
|
|
146
|
+
if [[ "$status" != "200" ]]; then
|
|
147
|
+
python3 - "$tmp/tts.wav" <<'PY' >&2
|
|
148
|
+
import pathlib
|
|
149
|
+
import sys
|
|
150
|
+
sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
151
|
+
PY
|
|
152
|
+
exit 22
|
|
153
|
+
fi
|
|
154
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
155
|
+
python3 - "$tmp/voice.ogg" <<'PY'
|
|
156
|
+
import pathlib
|
|
157
|
+
import sys
|
|
158
|
+
sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
159
|
+
PY
|
|
160
|
+
`;
|
|
161
|
+
return runSshBinary([
|
|
162
|
+
config.voiceTtsSshHost,
|
|
163
|
+
'bash',
|
|
164
|
+
'-s',
|
|
165
|
+
'--',
|
|
166
|
+
encodedText,
|
|
167
|
+
config.voiceTtsSshDir,
|
|
168
|
+
config.voiceTtsDesignInstruct,
|
|
169
|
+
], script);
|
|
170
|
+
}
|
|
171
|
+
async function runSshBinary(args, stdin) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
174
|
+
const stdout = [];
|
|
175
|
+
const stderr = [];
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
child.kill('SIGTERM');
|
|
178
|
+
reject(new Error('remote TTS timed out'));
|
|
179
|
+
}, 120_000);
|
|
180
|
+
child.stdout.on('data', (chunk) => {
|
|
181
|
+
stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
182
|
+
});
|
|
183
|
+
child.stderr.on('data', (chunk) => {
|
|
184
|
+
stderr.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
185
|
+
});
|
|
186
|
+
child.on('error', (error) => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
reject(error);
|
|
189
|
+
});
|
|
190
|
+
child.on('close', (code) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
if (code !== 0) {
|
|
193
|
+
reject(new Error(`remote TTS failed with exit ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const output = Buffer.concat(stdout);
|
|
197
|
+
if (output.length === 0) {
|
|
198
|
+
reject(new Error(`remote TTS returned empty audio: ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
resolve(output);
|
|
202
|
+
});
|
|
203
|
+
child.stdin.end(stdin);
|
|
204
|
+
});
|
|
205
|
+
}
|