@foxden-app/foxclaw 0.5.51 → 0.5.53

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 CHANGED
@@ -2,6 +2,28 @@
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.53 - 2026-06-20
6
+
7
+ ### 中文
8
+ - 修复单 bot 默认 runtime 的 `telegram-voice-delivery` 无法找到私聊的问题:当 `CODEX_HOME` 不含 bot ID 时,`send-voice` 现在会从唯一配置的 Telegram token 安全补全 bot ID,再读取已经记录的私聊目标。
9
+ - 将内置 `telegram-voice-delivery` Skill 的触发说明、工作流和 UI 元数据改为中文,并明确只有确实没有私聊记录时才要求用户发送 `/status`。
10
+
11
+ ### English
12
+ - Fixed `telegram-voice-delivery` failing to find the private chat in a single-bot default runtime. When `CODEX_HOME` has no bot ID, `send-voice` now safely derives it from the only configured Telegram token before reading the remembered chat target.
13
+ - Localized the bundled `telegram-voice-delivery` skill instructions and UI metadata into Chinese, and clarified that users should only be asked to send `/status` when no private chat is actually remembered.
14
+
15
+ ## 0.5.52 - 2026-06-20
16
+
17
+ ### 中文
18
+ - 新增 `telegram-voice-delivery` Codex Skill;FoxClaw 启动时会把它同步到每个 Telegram runtime 的 `CODEX_HOME`,Codex 生成音频后可自动投递到当前 Telegram 私聊,不再要求用户输入 `/voice file`。
19
+ - 新增 `foxclaw send-voice <path> [caption]` CLI;它会从当前 `CODEX_HOME` 识别 bot,并从 FoxClaw 本地数据库读取该 bot 最近记录的私聊目标。
20
+ - CLI 支持 `--bot-id`、`--chat-id` 显式覆盖,复用 Telegram voice 的格式和 50MB 限制,并且不会输出或暴露 bot token。
21
+
22
+ ### English
23
+ - 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`.
24
+ - 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.
25
+ - The CLI supports explicit `--bot-id` and `--chat-id` overrides, reuses Telegram voice format and 50MB limits, and never prints bot tokens.
26
+
5
27
  ## 0.5.51 - 2026-06-20
6
28
 
7
29
  ### 中文
package/README.md CHANGED
@@ -310,7 +310,7 @@ foxclaw weixin-login
310
310
 
311
311
  ## Codex Skill
312
312
 
313
- 仓库自带一个 Codex skill。用法看 [FoxClaw Skill 中文说明](./docs/zh/foxclaw-skill.md)。它可以让 Codex 通过 SSH 在本机或远程 Mac bootstrap FoxClaw——写 `.env`、构建、跑 doctor、装 launchd、引导首次消息验证,一条龙。
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 a Codex skill at [`skills/foxclaw`](./skills/foxclaw). Use it when you want Codex to bootstrap FoxClaw locally or on another Mac over SSH write `.env`, build, run doctor, install launchd, and guide first-message validation.
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
- : 'Supported Telegram voice file formats: .ogg, .opus, .oga, .mp3, .m4a.');
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 > 50 * 1024 * 1024) {
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,14 @@ 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';
19
+ import { inferTelegramBotId, resolveTelegramVoiceTarget } from './voice/target.js';
17
20
  const rawCommand = process.argv[2];
18
21
  const command = rawCommand || 'serve';
19
22
  loadEnv();
@@ -227,6 +230,11 @@ async function main() {
227
230
  await runWeixinLoginCli();
228
231
  return;
229
232
  }
233
+ if (command === 'send-voice' || command === 'voice-file') {
234
+ requireNode24(command);
235
+ await runSendVoiceCli();
236
+ return;
237
+ }
230
238
  if (command !== 'serve') {
231
239
  console.error(`Unknown command: ${command}`);
232
240
  printUsage();
@@ -263,12 +271,91 @@ Usage:
263
271
  foxclaw status
264
272
  foxclaw start|restart|stop
265
273
  foxclaw update
274
+ foxclaw send-voice <path> [caption]
266
275
  foxclaw install-systemd|uninstall-systemd
267
276
  foxclaw install-launchd|uninstall-launchd
268
277
  foxclaw weixin-login [account-id]
269
278
  foxclaw --version
270
279
  foxclaw --help`);
271
280
  }
281
+ async function runSendVoiceCli() {
282
+ const parsed = parseSendVoiceCliArgs(process.argv.slice(3));
283
+ if (!parsed.fileArg) {
284
+ console.error('Usage: foxclaw send-voice <path> [caption] [--bot-id <bot-id>] [--chat-id <chat-id>]');
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+ const config = loadConfig();
289
+ const filePath = path.resolve(config.defaultCwd, parsed.fileArg);
290
+ const contentType = telegramVoiceContentType(filePath);
291
+ if (!contentType) {
292
+ throw new Error(`Unsupported Telegram voice file format. Supported formats: ${TELEGRAM_VOICE_SUPPORTED_EXTENSIONS}.`);
293
+ }
294
+ const stat = await fs.promises.stat(filePath).catch(() => null);
295
+ if (!stat?.isFile()) {
296
+ throw new Error(`Audio file not found: ${filePath}`);
297
+ }
298
+ if (stat.size > TELEGRAM_VOICE_MAX_BYTES) {
299
+ throw new Error('Telegram voice files must be 50MB or smaller.');
300
+ }
301
+ const inferredBotId = parsed.botId ?? inferTelegramBotId(process.env.CODEX_HOME) ?? inferTelegramBotId(config.codexHome);
302
+ const { botId, botToken } = resolveTelegramVoiceTarget(config.tgBotTokens, inferredBotId);
303
+ const { BridgeStore } = await import('./store/database.js');
304
+ const store = new BridgeStore(config.storePath);
305
+ let chatId = parsed.chatId;
306
+ try {
307
+ chatId ??= botId ? store.getTelegramPrivateScope(botId)?.chatId ?? null : null;
308
+ }
309
+ finally {
310
+ store.close();
311
+ }
312
+ if (!chatId) {
313
+ const target = botId ? ` for ${botId}` : '';
314
+ throw new Error(`No remembered Telegram private chat${target}. Send /status to the bot once, or pass --chat-id <chat-id>.`);
315
+ }
316
+ const contents = await fs.promises.readFile(filePath);
317
+ const { callTelegramMultipartApi } = await import('./telegram/api.js');
318
+ const result = await callTelegramMultipartApi(botToken, 'sendVoice', {
319
+ chat_id: chatId,
320
+ caption: parsed.caption || 'FoxClaw voice',
321
+ }, [{
322
+ fieldName: 'voice',
323
+ filename: path.basename(filePath),
324
+ contents,
325
+ contentType,
326
+ }]);
327
+ if (!result.ok || !result.result) {
328
+ throw new Error(result.description || 'Telegram sendVoice failed.');
329
+ }
330
+ console.log(`Sent Telegram voice message ${result.result.message_id}: ${filePath}`);
331
+ }
332
+ function parseSendVoiceCliArgs(args) {
333
+ const positional = [];
334
+ let botId = null;
335
+ let chatId = null;
336
+ for (let index = 0; index < args.length; index += 1) {
337
+ const arg = args[index];
338
+ if (arg === '--bot-id' || arg === '--chat-id') {
339
+ const value = args[index + 1]?.trim();
340
+ if (!value) {
341
+ throw new Error(`${arg} requires a value.`);
342
+ }
343
+ if (arg === '--bot-id')
344
+ botId = value;
345
+ else
346
+ chatId = value;
347
+ index += 1;
348
+ continue;
349
+ }
350
+ positional.push(arg);
351
+ }
352
+ return {
353
+ fileArg: positional[0]?.trim() || null,
354
+ caption: positional.slice(1).join(' ').trim(),
355
+ botId,
356
+ chatId,
357
+ };
358
+ }
272
359
  function formatRuntimeStatusSummary(status) {
273
360
  const lines = [];
274
361
  const age = formatAge(status.updatedAt);
@@ -388,6 +475,7 @@ async function runServeCli() {
388
475
  if (!sharedDefaultRuntime) {
389
476
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
390
477
  }
478
+ installBundledCodexSkills(packageRoot, home);
391
479
  const runtimeConfig = {
392
480
  ...config,
393
481
  tgBotToken: token,
@@ -670,6 +758,8 @@ async function runServeCli() {
670
758
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
671
759
  return;
672
760
  }
761
+ const singleCodexHome = config.codexHome ?? path.join(os.homedir(), '.codex');
762
+ installBundledCodexSkills(packageRoot, singleCodexHome);
673
763
  const bot = new TelegramGateway(config.tgBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger);
674
764
  const app = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger);
675
765
  const telegramMessaging = new TelegramMessagingPort(bot);
@@ -0,0 +1,3 @@
1
+ export declare const TELEGRAM_VOICE_MAX_BYTES: number;
2
+ export declare const TELEGRAM_VOICE_SUPPORTED_EXTENSIONS = ".ogg, .opus, .oga, .mp3, .m4a";
3
+ export declare function telegramVoiceContentType(filePath: string): string | null;
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export type TelegramVoiceTarget = {
2
+ botId: string;
3
+ botToken: string;
4
+ };
5
+ export declare function inferTelegramBotId(codexHome: string | null | undefined): string | null;
6
+ export declare function resolveTelegramVoiceTarget(tokens: string[], requestedBotId: string | null): TelegramVoiceTarget;
@@ -0,0 +1,23 @@
1
+ export function inferTelegramBotId(codexHome) {
2
+ if (!codexHome)
3
+ return null;
4
+ const match = codexHome.match(/(?:^|[\\/])(bot\d+)(?:[\\/]|$)/i);
5
+ return match?.[1]?.toLowerCase() ?? null;
6
+ }
7
+ export function resolveTelegramVoiceTarget(tokens, requestedBotId) {
8
+ if (requestedBotId) {
9
+ const numericId = requestedBotId.replace(/^bot/i, '');
10
+ const matched = tokens.find(token => token.startsWith(`${numericId}:`));
11
+ if (matched)
12
+ return { botId: `bot${numericId}`, botToken: matched };
13
+ throw new Error(`No configured Telegram token matches ${requestedBotId}. Pass --bot-id for a configured bot.`);
14
+ }
15
+ if (tokens.length === 1) {
16
+ const botToken = tokens[0];
17
+ const numericId = botToken.slice(0, botToken.indexOf(':'));
18
+ if (/^\d+$/.test(numericId)) {
19
+ return { botId: `bot${numericId}`, botToken };
20
+ }
21
+ }
22
+ throw new Error('Cannot infer the Telegram bot from this Codex session. Pass --bot-id <bot-id>.');
23
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.51",
3
+ "version": "0.5.53",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: telegram-voice-delivery
3
+ description: 通过 FoxClaw 将 Codex 已生成或找到的语音、解说、播客等音频文件直接发送到当前 Telegram 会话。需要把音频交付给用户且不应要求用户手动执行 FoxClaw 命令时使用。
4
+ ---
5
+
6
+ # Telegram 语音投递
7
+
8
+ 把已经完成的音频直接发送到当前 FoxClaw Telegram 私聊,不要让用户操作 `/voice`。
9
+
10
+ ## 操作流程
11
+
12
+ 1. 确认音频文件已经完整生成。
13
+ 2. 优先使用适合 Telegram 语音播放的 `.ogg` 或 `.opus`;也支持 `.oga`、`.mp3` 和 `.m4a`。
14
+ 3. 文件不得超过 50MB。
15
+ 4. 执行:
16
+
17
+ ```bash
18
+ foxclaw send-voice "/音频的绝对路径/audio.ogg" "简短说明"
19
+ ```
20
+
21
+ 5. 命令成功即表示 Telegram 已接收该语音;随后简短告知用户已经发送。
22
+
23
+ FoxClaw 会从 `CODEX_HOME` 推断当前 Telegram bot;单 bot 默认 runtime 会从唯一配置的 token 补全 bot ID。随后从本地数据库读取该 bot 最近记录的私聊,并调用 Telegram `sendVoice`。不要读取、打印或泄露 Telegram bot token。
24
+
25
+ 音频已经存在时直接发送,不要重新生成。只有 FoxClaw 明确报告没有记录私聊时,才请用户对当前 bot 发送一次 `/status`;仅在自动会话推断确实不可用时使用 `--bot-id <bot-id>` 或 `--chat-id <chat-id>`,不要凭猜测指定其他 bot。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Telegram 语音投递"
3
+ short_description: "把 Codex 已生成的音频直接发送到当前 Telegram 会话"
4
+ default_prompt: "使用 $telegram-voice-delivery,把已完成的音频文件发送到当前 Telegram 会话。"