@foxden-app/foxclaw 0.5.51 → 0.5.52
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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/codex_skills.d.ts +1 -0
- package/dist/codex_skills.js +16 -0
- package/dist/controller/controller.js +3 -17
- package/dist/main.js +107 -0
- package/dist/voice/files.d.ts +3 -0
- package/dist/voice/files.js +18 -0
- package/docs/zh/foxclaw-skill.md +2 -0
- package/package.json +1 -1
- package/skills/telegram-voice-delivery/SKILL.md +25 -0
- package/skills/telegram-voice-delivery/agents/openai.yaml +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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.52 - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 新增 `telegram-voice-delivery` Codex Skill;FoxClaw 启动时会把它同步到每个 Telegram runtime 的 `CODEX_HOME`,Codex 生成音频后可自动投递到当前 Telegram 私聊,不再要求用户输入 `/voice file`。
|
|
9
|
+
- 新增 `foxclaw send-voice <path> [caption]` CLI;它会从当前 `CODEX_HOME` 识别 bot,并从 FoxClaw 本地数据库读取该 bot 最近记录的私聊目标。
|
|
10
|
+
- CLI 支持 `--bot-id`、`--chat-id` 显式覆盖,复用 Telegram voice 的格式和 50MB 限制,并且不会输出或暴露 bot token。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Added the `telegram-voice-delivery` Codex skill. FoxClaw syncs it into every Telegram runtime's `CODEX_HOME` at startup, allowing Codex to automatically deliver generated audio to the current Telegram private chat without asking the user to enter `/voice file`.
|
|
14
|
+
- Added `foxclaw send-voice <path> [caption]`; it infers the bot from the current `CODEX_HOME` and reads that bot's most recently remembered private chat from the FoxClaw store.
|
|
15
|
+
- The CLI supports explicit `--bot-id` and `--chat-id` overrides, reuses Telegram voice format and 50MB limits, and never prints bot tokens.
|
|
16
|
+
|
|
5
17
|
## 0.5.51 - 2026-06-20
|
|
6
18
|
|
|
7
19
|
### 中文
|
package/README.md
CHANGED
|
@@ -310,7 +310,7 @@ foxclaw weixin-login
|
|
|
310
310
|
|
|
311
311
|
## Codex Skill
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
仓库自带 FoxClaw 安装维护 Skill 和 `telegram-voice-delivery` 语音投递 Skill。用法看 [FoxClaw Skill 中文说明](./docs/zh/foxclaw-skill.md)。FoxClaw 启动时会把语音投递 Skill 自动同步到每个 Telegram runtime 的 `CODEX_HOME`;Codex 生成音频后可直接执行 `foxclaw send-voice`,自动把文件送回当前 Telegram 私聊,无需用户再输入 `/voice file`。
|
|
314
314
|
|
|
315
315
|
## 故障排查
|
|
316
316
|
|
package/README_EN.md
CHANGED
|
@@ -310,7 +310,7 @@ Weixin runtime files default to `~/.foxclaw/weixin`. When `TG_BOT_TOKENS` is ena
|
|
|
310
310
|
|
|
311
311
|
## Codex Skill
|
|
312
312
|
|
|
313
|
-
This repo ships
|
|
313
|
+
This repo ships the FoxClaw deployment skill and a [`telegram-voice-delivery`](./skills/telegram-voice-delivery) skill. FoxClaw automatically syncs the delivery skill into every Telegram runtime's `CODEX_HOME` at startup. Codex can then run `foxclaw send-voice` after generating audio and deliver the file to the current Telegram private chat without asking the user to enter `/voice file`.
|
|
314
314
|
|
|
315
315
|
## Troubleshooting
|
|
316
316
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function installBundledCodexSkills(packageRoot: string, codexHome: string): string[];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const BUNDLED_CODEX_SKILLS = ['telegram-voice-delivery'];
|
|
4
|
+
export function installBundledCodexSkills(packageRoot, codexHome) {
|
|
5
|
+
const installed = [];
|
|
6
|
+
for (const skillName of BUNDLED_CODEX_SKILLS) {
|
|
7
|
+
const sourceDir = path.join(packageRoot, 'skills', skillName);
|
|
8
|
+
if (!fs.existsSync(path.join(sourceDir, 'SKILL.md')))
|
|
9
|
+
continue;
|
|
10
|
+
const destinationDir = path.join(codexHome, 'skills', skillName);
|
|
11
|
+
fs.mkdirSync(destinationDir, { recursive: true, mode: 0o700 });
|
|
12
|
+
fs.cpSync(sourceDir, destinationDir, { recursive: true, force: true });
|
|
13
|
+
installed.push(skillName);
|
|
14
|
+
}
|
|
15
|
+
return installed;
|
|
16
|
+
}
|
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from '../voice/files.js';
|
|
5
6
|
import { normalizeLocale, t } from '../i18n.js';
|
|
6
7
|
import { chatGptAuthMetadataMatchesCandidateName, parseChatGptAuthMetadata, readChatGptAuthRecord, readChatGptAuthMetadata, } from '../auth/mirror.js';
|
|
7
8
|
import { readAccessTokenExpiresAtMs } from '../auth/cross_node_sync.js';
|
|
@@ -5205,7 +5206,7 @@ export class BridgeSessionCore {
|
|
|
5205
5206
|
if (!contentType) {
|
|
5206
5207
|
await this.sendMessage(scopeId, locale === 'zh'
|
|
5207
5208
|
? '只支持作为 Telegram voice 发送的音频格式:.ogg、.opus、.oga、.mp3、.m4a。'
|
|
5208
|
-
:
|
|
5209
|
+
: `Supported Telegram voice file formats: ${TELEGRAM_VOICE_SUPPORTED_EXTENSIONS}.`);
|
|
5209
5210
|
return;
|
|
5210
5211
|
}
|
|
5211
5212
|
const stat = await fs.stat(filePath).catch(() => null);
|
|
@@ -5213,7 +5214,7 @@ export class BridgeSessionCore {
|
|
|
5213
5214
|
await this.sendMessage(scopeId, locale === 'zh' ? `找不到音频文件:${filePath}` : `Audio file not found: ${filePath}`);
|
|
5214
5215
|
return;
|
|
5215
5216
|
}
|
|
5216
|
-
if (stat.size >
|
|
5217
|
+
if (stat.size > TELEGRAM_VOICE_MAX_BYTES) {
|
|
5217
5218
|
await this.sendMessage(scopeId, locale === 'zh' ? 'Telegram voice 文件不能超过 50MB。' : 'Telegram voice files must be 50MB or smaller.');
|
|
5218
5219
|
return;
|
|
5219
5220
|
}
|
|
@@ -8496,21 +8497,6 @@ function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
|
8496
8497
|
active.segments.push(segment);
|
|
8497
8498
|
return segment;
|
|
8498
8499
|
}
|
|
8499
|
-
function telegramVoiceContentType(filePath) {
|
|
8500
|
-
const extension = path.extname(filePath).toLowerCase();
|
|
8501
|
-
switch (extension) {
|
|
8502
|
-
case '.ogg':
|
|
8503
|
-
case '.oga':
|
|
8504
|
-
case '.opus':
|
|
8505
|
-
return 'audio/ogg';
|
|
8506
|
-
case '.mp3':
|
|
8507
|
-
return 'audio/mpeg';
|
|
8508
|
-
case '.m4a':
|
|
8509
|
-
return 'audio/mp4';
|
|
8510
|
-
default:
|
|
8511
|
-
return null;
|
|
8512
|
-
}
|
|
8513
|
-
}
|
|
8514
8500
|
function renderCollapsedCommentary(locale, segments) {
|
|
8515
8501
|
const firstAt = segments[0]?.startedAtMs ?? Date.now();
|
|
8516
8502
|
const lastSegment = segments[segments.length - 1];
|
package/dist/main.js
CHANGED
|
@@ -9,11 +9,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
|
|
11
11
|
import { createAuthRefreshNotificationAggregator, } from './auth/notifications.js';
|
|
12
|
+
import { installBundledCodexSkills } from './codex_skills.js';
|
|
12
13
|
import { acquireProcessLock, LockHeldError } from './lock.js';
|
|
13
14
|
import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
|
|
14
15
|
import { buildFoxclawLaunchdPlistText, extractNodePathFromLaunchdPlist, } from './launchd.js';
|
|
15
16
|
import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
|
|
16
17
|
import { clearPendingClusterUpdateBroadcast, createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readPendingClusterUpdateBroadcast, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
|
|
18
|
+
import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from './voice/files.js';
|
|
17
19
|
const rawCommand = process.argv[2];
|
|
18
20
|
const command = rawCommand || 'serve';
|
|
19
21
|
loadEnv();
|
|
@@ -227,6 +229,11 @@ async function main() {
|
|
|
227
229
|
await runWeixinLoginCli();
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
232
|
+
if (command === 'send-voice' || command === 'voice-file') {
|
|
233
|
+
requireNode24(command);
|
|
234
|
+
await runSendVoiceCli();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
230
237
|
if (command !== 'serve') {
|
|
231
238
|
console.error(`Unknown command: ${command}`);
|
|
232
239
|
printUsage();
|
|
@@ -263,12 +270,109 @@ Usage:
|
|
|
263
270
|
foxclaw status
|
|
264
271
|
foxclaw start|restart|stop
|
|
265
272
|
foxclaw update
|
|
273
|
+
foxclaw send-voice <path> [caption]
|
|
266
274
|
foxclaw install-systemd|uninstall-systemd
|
|
267
275
|
foxclaw install-launchd|uninstall-launchd
|
|
268
276
|
foxclaw weixin-login [account-id]
|
|
269
277
|
foxclaw --version
|
|
270
278
|
foxclaw --help`);
|
|
271
279
|
}
|
|
280
|
+
async function runSendVoiceCli() {
|
|
281
|
+
const parsed = parseSendVoiceCliArgs(process.argv.slice(3));
|
|
282
|
+
if (!parsed.fileArg) {
|
|
283
|
+
console.error('Usage: foxclaw send-voice <path> [caption] [--bot-id <bot-id>] [--chat-id <chat-id>]');
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const config = loadConfig();
|
|
288
|
+
const filePath = path.resolve(config.defaultCwd, parsed.fileArg);
|
|
289
|
+
const contentType = telegramVoiceContentType(filePath);
|
|
290
|
+
if (!contentType) {
|
|
291
|
+
throw new Error(`Unsupported Telegram voice file format. Supported formats: ${TELEGRAM_VOICE_SUPPORTED_EXTENSIONS}.`);
|
|
292
|
+
}
|
|
293
|
+
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
294
|
+
if (!stat?.isFile()) {
|
|
295
|
+
throw new Error(`Audio file not found: ${filePath}`);
|
|
296
|
+
}
|
|
297
|
+
if (stat.size > TELEGRAM_VOICE_MAX_BYTES) {
|
|
298
|
+
throw new Error('Telegram voice files must be 50MB or smaller.');
|
|
299
|
+
}
|
|
300
|
+
const botId = parsed.botId ?? inferTelegramBotId(process.env.CODEX_HOME) ?? inferTelegramBotId(config.codexHome);
|
|
301
|
+
const botToken = resolveTelegramBotToken(config.tgBotTokens, botId);
|
|
302
|
+
const { BridgeStore } = await import('./store/database.js');
|
|
303
|
+
const store = new BridgeStore(config.storePath);
|
|
304
|
+
let chatId = parsed.chatId;
|
|
305
|
+
try {
|
|
306
|
+
chatId ??= botId ? store.getTelegramPrivateScope(botId)?.chatId ?? null : null;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
store.close();
|
|
310
|
+
}
|
|
311
|
+
if (!chatId) {
|
|
312
|
+
const target = botId ? ` for ${botId}` : '';
|
|
313
|
+
throw new Error(`No remembered Telegram private chat${target}. Send /status to the bot once, or pass --chat-id <chat-id>.`);
|
|
314
|
+
}
|
|
315
|
+
const contents = await fs.promises.readFile(filePath);
|
|
316
|
+
const { callTelegramMultipartApi } = await import('./telegram/api.js');
|
|
317
|
+
const result = await callTelegramMultipartApi(botToken, 'sendVoice', {
|
|
318
|
+
chat_id: chatId,
|
|
319
|
+
caption: parsed.caption || 'FoxClaw voice',
|
|
320
|
+
}, [{
|
|
321
|
+
fieldName: 'voice',
|
|
322
|
+
filename: path.basename(filePath),
|
|
323
|
+
contents,
|
|
324
|
+
contentType,
|
|
325
|
+
}]);
|
|
326
|
+
if (!result.ok || !result.result) {
|
|
327
|
+
throw new Error(result.description || 'Telegram sendVoice failed.');
|
|
328
|
+
}
|
|
329
|
+
console.log(`Sent Telegram voice message ${result.result.message_id}: ${filePath}`);
|
|
330
|
+
}
|
|
331
|
+
function parseSendVoiceCliArgs(args) {
|
|
332
|
+
const positional = [];
|
|
333
|
+
let botId = null;
|
|
334
|
+
let chatId = null;
|
|
335
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
336
|
+
const arg = args[index];
|
|
337
|
+
if (arg === '--bot-id' || arg === '--chat-id') {
|
|
338
|
+
const value = args[index + 1]?.trim();
|
|
339
|
+
if (!value) {
|
|
340
|
+
throw new Error(`${arg} requires a value.`);
|
|
341
|
+
}
|
|
342
|
+
if (arg === '--bot-id')
|
|
343
|
+
botId = value;
|
|
344
|
+
else
|
|
345
|
+
chatId = value;
|
|
346
|
+
index += 1;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
positional.push(arg);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
fileArg: positional[0]?.trim() || null,
|
|
353
|
+
caption: positional.slice(1).join(' ').trim(),
|
|
354
|
+
botId,
|
|
355
|
+
chatId,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function inferTelegramBotId(codexHome) {
|
|
359
|
+
if (!codexHome)
|
|
360
|
+
return null;
|
|
361
|
+
const match = codexHome.match(/(?:^|[\\/])(bot\d+)(?:[\\/]|$)/i);
|
|
362
|
+
return match?.[1]?.toLowerCase() ?? null;
|
|
363
|
+
}
|
|
364
|
+
function resolveTelegramBotToken(tokens, botId) {
|
|
365
|
+
if (botId) {
|
|
366
|
+
const numericId = botId.replace(/^bot/i, '');
|
|
367
|
+
const matched = tokens.find(token => token.startsWith(`${numericId}:`));
|
|
368
|
+
if (matched)
|
|
369
|
+
return matched;
|
|
370
|
+
throw new Error(`No configured Telegram token matches ${botId}. Pass --bot-id for a configured bot.`);
|
|
371
|
+
}
|
|
372
|
+
if (tokens.length === 1)
|
|
373
|
+
return tokens[0];
|
|
374
|
+
throw new Error('Cannot infer the Telegram bot from this Codex session. Pass --bot-id <bot-id>.');
|
|
375
|
+
}
|
|
272
376
|
function formatRuntimeStatusSummary(status) {
|
|
273
377
|
const lines = [];
|
|
274
378
|
const age = formatAge(status.updatedAt);
|
|
@@ -388,6 +492,7 @@ async function runServeCli() {
|
|
|
388
492
|
if (!sharedDefaultRuntime) {
|
|
389
493
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
390
494
|
}
|
|
495
|
+
installBundledCodexSkills(packageRoot, home);
|
|
391
496
|
const runtimeConfig = {
|
|
392
497
|
...config,
|
|
393
498
|
tgBotToken: token,
|
|
@@ -670,6 +775,8 @@ async function runServeCli() {
|
|
|
670
775
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
671
776
|
return;
|
|
672
777
|
}
|
|
778
|
+
const singleCodexHome = config.codexHome ?? path.join(os.homedir(), '.codex');
|
|
779
|
+
installBundledCodexSkills(packageRoot, singleCodexHome);
|
|
673
780
|
const bot = new TelegramGateway(config.tgBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger);
|
|
674
781
|
const app = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger);
|
|
675
782
|
const telegramMessaging = new TelegramMessagingPort(bot);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export const TELEGRAM_VOICE_MAX_BYTES = 50 * 1024 * 1024;
|
|
3
|
+
export const TELEGRAM_VOICE_SUPPORTED_EXTENSIONS = '.ogg, .opus, .oga, .mp3, .m4a';
|
|
4
|
+
export function telegramVoiceContentType(filePath) {
|
|
5
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
6
|
+
switch (extension) {
|
|
7
|
+
case '.ogg':
|
|
8
|
+
case '.oga':
|
|
9
|
+
case '.opus':
|
|
10
|
+
return 'audio/ogg';
|
|
11
|
+
case '.mp3':
|
|
12
|
+
return 'audio/mpeg';
|
|
13
|
+
case '.m4a':
|
|
14
|
+
return 'audio/mp4';
|
|
15
|
+
default:
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/docs/zh/foxclaw-skill.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
仓库内置的 `skills/foxclaw` 是给 Codex 使用的安装技能。它的用途是让 Codex 在本机或远程 Mac 上自动完成 FoxClaw bootstrap,包括写 `.env`、安装依赖、构建、跑 `doctor`、安装 launchd 服务,并引导你完成第一次 Telegram 消息验证。
|
|
4
4
|
|
|
5
|
+
仓库还内置 `skills/telegram-voice-delivery`。FoxClaw 启动时会把它自动同步到每个 Telegram runtime 的 `CODEX_HOME`。当 Codex 已经生成音频时,它会直接运行 `foxclaw send-voice <path> [caption]`,从当前 `CODEX_HOME` 识别 Telegram bot,并将音频投递到该 bot 最近记录的私聊,不需要用户再输入命令。
|
|
6
|
+
|
|
5
7
|
## 适合什么时候用
|
|
6
8
|
|
|
7
9
|
- 你想让 Codex 通过 SSH 帮另一台 Mac 安装 FoxClaw。
|
package/package.json
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: telegram-voice-delivery
|
|
3
|
+
description: Deliver an audio file from Codex to the current Telegram conversation through FoxClaw. Use when Codex has generated or found speech, narration, a podcast, or another audio file that the user should receive in Telegram without manually running a FoxClaw command.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Telegram Voice Delivery
|
|
7
|
+
|
|
8
|
+
Send completed audio artifacts back to the active FoxClaw Telegram conversation without asking the user to operate `/voice`.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Finish generating the audio file before attempting delivery.
|
|
13
|
+
2. Prefer `.ogg` or `.opus` for Telegram voice playback. `.oga`, `.mp3`, and `.m4a` are also accepted.
|
|
14
|
+
3. Keep the file at or below 50MB.
|
|
15
|
+
4. Run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
foxclaw send-voice "/absolute/path/to/audio.ogg" "Short caption"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
5. Treat a successful command as delivery confirmation and tell the user the audio was sent.
|
|
22
|
+
|
|
23
|
+
FoxClaw infers the current Telegram bot from `CODEX_HOME`, reads that bot's remembered private chat from its local store, and calls Telegram `sendVoice`. Do not read, print, or expose Telegram bot tokens.
|
|
24
|
+
|
|
25
|
+
If an audio file already exists, send it directly instead of regenerating it. If FoxClaw reports that no private chat is remembered, ask the user to send `/status` to that bot once; use `--bot-id <bot-id>` or `--chat-id <chat-id>` only when automatic session inference is unavailable.
|