@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 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
- 仓库自带一个 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,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,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
+ }
@@ -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.52",
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: 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.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Telegram Voice Delivery"
3
+ short_description: "Send generated audio back through FoxClaw"
4
+ default_prompt: "Use $telegram-voice-delivery to send a completed audio file to the current Telegram conversation through FoxClaw."