From ae2f339fcf58774ba931eefcbd2d692d0e4edeb6 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 24 Feb 2026 14:45:26 +0200 Subject: [PATCH] chore(skills): rebase core skills (telegram, discord, voice) to latest main and fix db schema gaps --- .../skills/add-discord/modify/src/config.ts | 3 +- .../skills/add-discord/modify/src/index.ts | 106 ++++++---------- .../add-telegram/add/src/channels/telegram.ts | 6 +- .../skills/add-telegram/modify/src/config.ts | 3 +- .../skills/add-telegram/modify/src/index.ts | 120 +++++++----------- .../modify/src/channels/whatsapp.ts | 16 ++- 6 files changed, 107 insertions(+), 147 deletions(-) diff --git a/.claude/skills/add-discord/modify/src/config.ts b/.claude/skills/add-discord/modify/src/config.ts index 3204fb7..5f3fa6a 100644 --- a/.claude/skills/add-discord/modify/src/config.ts +++ b/.claude/skills/add-discord/modify/src/config.ts @@ -1,3 +1,4 @@ +import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; @@ -21,7 +22,7 @@ export const SCHEDULER_POLL_INTERVAL = 60000; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); -const HOME_DIR = process.env.HOME || '/Users/user'; +const HOME_DIR = process.env.HOME || os.homedir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers export const MOUNT_ALLOWLIST_PATH = path.join( diff --git a/.claude/skills/add-discord/modify/src/index.ts b/.claude/skills/add-discord/modify/src/index.ts index 577a7eb..4b6f30e 100644 --- a/.claude/skills/add-discord/modify/src/index.ts +++ b/.claude/skills/add-discord/modify/src/index.ts @@ -1,10 +1,8 @@ -import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { ASSISTANT_NAME, - DATA_DIR, DISCORD_BOT_TOKEN, DISCORD_ONLY, IDLE_TIMEOUT, @@ -20,6 +18,7 @@ import { writeGroupsSnapshot, writeTasksSnapshot, } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -36,6 +35,7 @@ import { storeMessage, } from './db.js'; import { GroupQueue } from './group-queue.js'; +import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; import { startSchedulerLoop } from './task-scheduler.js'; @@ -81,11 +81,21 @@ function saveState(): void { } function registerGroup(jid: string, group: RegisteredGroup): void { + let groupDir: string; + try { + groupDir = resolveGroupFolderPath(group.folder); + } catch (err) { + logger.warn( + { jid, folder: group.folder, err }, + 'Rejecting group registration with invalid folder', + ); + return; + } + registeredGroups[jid] = group; setRegisteredGroup(jid, group); // Create group folder - const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); logger.info( @@ -126,7 +136,10 @@ async function processGroupMessages(chatJid: string): Promise { if (!group) return true; const channel = findChannel(channels, chatJid); - if (!channel) return true; + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + return true; + } const isMainGroup = group.folder === MAIN_GROUP_FOLDER; @@ -187,6 +200,10 @@ async function processGroupMessages(chatJid: string): Promise { resetIdleTimer(); } + if (result.status === 'success') { + queue.notifyIdle(chatJid); + } + if (result.status === 'error') { hadError = true; } @@ -266,6 +283,7 @@ async function runAgent( groupFolder: group.folder, chatJid, isMain, + assistantName: ASSISTANT_NAME, }, (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), wrappedOnOutput, @@ -328,7 +346,10 @@ async function startMessageLoop(): Promise { if (!group) continue; const channel = findChannel(channels, chatJid); - if (!channel) continue; + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + continue; + } const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const needsTrigger = !isMainGroup && group.requiresTrigger !== false; @@ -363,7 +384,9 @@ async function startMessageLoop(): Promise { messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message - channel.setTyping?.(chatJid, true); + channel.setTyping?.(chatJid, true)?.catch((err) => + logger.warn({ chatJid, err }, 'Failed to set typing indicator'), + ); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); @@ -396,65 +419,8 @@ function recoverPendingMessages(): void { } function ensureContainerSystemRunning(): void { - try { - execSync('container system status', { stdio: 'pipe' }); - logger.debug('Apple Container system already running'); - } catch { - logger.info('Starting Apple Container system...'); - try { - execSync('container system start', { stdio: 'pipe', timeout: 30000 }); - logger.info('Apple Container system started'); - } catch (err) { - logger.error({ err }, 'Failed to start Apple Container system'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Apple Container system failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without Apple Container. To fix: ║', - ); - console.error( - '║ 1. Install from: https://github.com/apple/container/releases ║', - ); - console.error( - '║ 2. Run: container system start ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); - throw new Error('Apple Container system is required but failed to start'); - } - } - - // Kill and clean up orphaned NanoClaw containers from previous runs - try { - const output = execSync('container ls --format json', { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - }); - const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); - const orphans = containers - .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) - .map((c) => c.configuration.id); - for (const name of orphans) { - try { - execSync(`container stop ${name}`, { stdio: 'pipe' }); - } catch { /* already stopped */ } - } - if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); - } - } catch (err) { - logger.warn({ err }, 'Failed to clean up orphaned containers'); - } + ensureContainerRuntimeRunning(); + cleanupOrphans(); } async function main(): Promise { @@ -502,7 +468,10 @@ async function main(): Promise { onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); - if (!channel) return; + if (!channel) { + console.log(`Warning: no channel owns JID ${jid}, cannot send message`); + return; + } const text = formatOutbound(rawText); if (text) await channel.sendMessage(jid, text); }, @@ -521,7 +490,10 @@ async function main(): Promise { }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); - startMessageLoop(); + startMessageLoop().catch((err) => { + logger.fatal({ err }, 'Message loop crashed unexpectedly'); + process.exit(1); + }); } // Guard: only run when executed directly, not when imported by tests diff --git a/.claude/skills/add-telegram/add/src/channels/telegram.ts b/.claude/skills/add-telegram/add/src/channels/telegram.ts index 8914486..43a6266 100644 --- a/.claude/skills/add-telegram/add/src/channels/telegram.ts +++ b/.claude/skills/add-telegram/add/src/channels/telegram.ts @@ -92,7 +92,8 @@ export class TelegramChannel implements Channel { } // Store chat metadata for discovery - this.opts.onChatMetadata(chatJid, timestamp, chatName); + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); // Only deliver full message for registered groups const group = this.opts.registeredGroups()[chatJid]; @@ -135,7 +136,8 @@ export class TelegramChannel implements Channel { 'Unknown'; const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; - this.opts.onChatMetadata(chatJid, timestamp); + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); this.opts.onMessage(chatJid, { id: ctx.message.message_id.toString(), chat_jid: chatJid, diff --git a/.claude/skills/add-telegram/modify/src/config.ts b/.claude/skills/add-telegram/modify/src/config.ts index 43a5496..f0093f2 100644 --- a/.claude/skills/add-telegram/modify/src/config.ts +++ b/.claude/skills/add-telegram/modify/src/config.ts @@ -1,3 +1,4 @@ +import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; @@ -21,7 +22,7 @@ export const SCHEDULER_POLL_INTERVAL = 60000; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); -const HOME_DIR = process.env.HOME || '/Users/user'; +const HOME_DIR = process.env.HOME || os.homedir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers export const MOUNT_ALLOWLIST_PATH = path.join( diff --git a/.claude/skills/add-telegram/modify/src/index.ts b/.claude/skills/add-telegram/modify/src/index.ts index c130367..b91e244 100644 --- a/.claude/skills/add-telegram/modify/src/index.ts +++ b/.claude/skills/add-telegram/modify/src/index.ts @@ -1,10 +1,8 @@ -import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { ASSISTANT_NAME, - DATA_DIR, IDLE_TIMEOUT, MAIN_GROUP_FOLDER, POLL_INTERVAL, @@ -12,14 +10,15 @@ import { TELEGRAM_ONLY, TRIGGER_PATTERN, } from './config.js'; -import { WhatsAppChannel } from './channels/whatsapp.js'; import { TelegramChannel } from './channels/telegram.js'; +import { WhatsAppChannel } from './channels/whatsapp.js'; import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot, } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -36,6 +35,7 @@ import { storeMessage, } from './db.js'; import { GroupQueue } from './group-queue.js'; +import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; import { startSchedulerLoop } from './task-scheduler.js'; @@ -81,11 +81,21 @@ function saveState(): void { } function registerGroup(jid: string, group: RegisteredGroup): void { + let groupDir: string; + try { + groupDir = resolveGroupFolderPath(group.folder); + } catch (err) { + logger.warn( + { jid, folder: group.folder, err }, + 'Rejecting group registration with invalid folder', + ); + return; + } + registeredGroups[jid] = group; setRegisteredGroup(jid, group); // Create group folder - const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); logger.info( @@ -126,7 +136,10 @@ async function processGroupMessages(chatJid: string): Promise { if (!group) return true; const channel = findChannel(channels, chatJid); - if (!channel) return true; + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + return true; + } const isMainGroup = group.folder === MAIN_GROUP_FOLDER; @@ -187,6 +200,10 @@ async function processGroupMessages(chatJid: string): Promise { resetIdleTimer(); } + if (result.status === 'success') { + queue.notifyIdle(chatJid); + } + if (result.status === 'error') { hadError = true; } @@ -266,6 +283,7 @@ async function runAgent( groupFolder: group.folder, chatJid, isMain, + assistantName: ASSISTANT_NAME, }, (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), wrappedOnOutput, @@ -328,7 +346,10 @@ async function startMessageLoop(): Promise { if (!group) continue; const channel = findChannel(channels, chatJid); - if (!channel) continue; + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + continue; + } const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const needsTrigger = !isMainGroup && group.requiresTrigger !== false; @@ -363,7 +384,9 @@ async function startMessageLoop(): Promise { messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message - channel.setTyping?.(chatJid, true); + channel.setTyping?.(chatJid, true)?.catch((err) => + logger.warn({ chatJid, err }, 'Failed to set typing indicator'), + ); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); @@ -396,65 +419,8 @@ function recoverPendingMessages(): void { } function ensureContainerSystemRunning(): void { - try { - execSync('container system status', { stdio: 'pipe' }); - logger.debug('Apple Container system already running'); - } catch { - logger.info('Starting Apple Container system...'); - try { - execSync('container system start', { stdio: 'pipe', timeout: 30000 }); - logger.info('Apple Container system started'); - } catch (err) { - logger.error({ err }, 'Failed to start Apple Container system'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Apple Container system failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without Apple Container. To fix: ║', - ); - console.error( - '║ 1. Install from: https://github.com/apple/container/releases ║', - ); - console.error( - '║ 2. Run: container system start ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); - throw new Error('Apple Container system is required but failed to start'); - } - } - - // Kill and clean up orphaned NanoClaw containers from previous runs - try { - const output = execSync('container ls --format json', { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - }); - const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); - const orphans = containers - .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) - .map((c) => c.configuration.id); - for (const name of orphans) { - try { - execSync(`container stop ${name}`, { stdio: 'pipe' }); - } catch { /* already stopped */ } - } - if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); - } - } catch (err) { - logger.warn({ err }, 'Failed to clean up orphaned containers'); - } + ensureContainerRuntimeRunning(); + cleanupOrphans(); } async function main(): Promise { @@ -482,18 +448,18 @@ async function main(): Promise { }; // Create and connect channels - if (!TELEGRAM_ONLY) { - whatsapp = new WhatsAppChannel(channelOpts); - channels.push(whatsapp); - await whatsapp.connect(); - } - if (TELEGRAM_BOT_TOKEN) { const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts); channels.push(telegram); await telegram.connect(); } + if (!TELEGRAM_ONLY) { + whatsapp = new WhatsAppChannel(channelOpts); + channels.push(whatsapp); + await whatsapp.connect(); + } + // Start subsystems (independently of connection handler) startSchedulerLoop({ registeredGroups: () => registeredGroups, @@ -502,7 +468,10 @@ async function main(): Promise { onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); - if (!channel) return; + if (!channel) { + console.log(`Warning: no channel owns JID ${jid}, cannot send message`); + return; + } const text = formatOutbound(rawText); if (text) await channel.sendMessage(jid, text); }, @@ -521,7 +490,10 @@ async function main(): Promise { }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); - startMessageLoop(); + startMessageLoop().catch((err) => { + logger.fatal({ err }, 'Message loop crashed unexpectedly'); + process.exit(1); + }); } // Guard: only run when executed directly, not when imported by tests diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts index 6fb963b..0781185 100644 --- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts +++ b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts @@ -6,6 +6,7 @@ import makeWASocket, { Browsers, DisconnectReason, WASocket, + fetchLatestWaWebVersion, makeCacheableSignalKeyStore, useMultiFileAuthState, } from '@whiskeysockets/baileys'; @@ -56,7 +57,12 @@ export class WhatsAppChannel implements Channel { const { state, saveCreds } = await useMultiFileAuthState(authDir); + const { version } = await fetchLatestWaWebVersion({}).catch((err) => { + logger.warn({ err }, 'Failed to fetch latest WA Web version, using default'); + return { version: undefined }; + }); this.sock = makeWASocket({ + version, auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger), @@ -81,7 +87,7 @@ export class WhatsAppChannel implements Channel { if (connection === 'close') { this.connected = false; - const reason = (lastDisconnect?.error as any)?.output?.statusCode; + const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode; const shouldReconnect = reason !== DisconnectReason.loggedOut; logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed'); @@ -104,7 +110,9 @@ export class WhatsAppChannel implements Channel { logger.info('Connected to WhatsApp'); // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch(() => {}); + this.sock.sendPresenceUpdate('available').catch((err) => { + logger.warn({ err }, 'Failed to send presence update'); + }); // Build LID to phone mapping from auth state for self-chat translation if (this.sock.user) { @@ -172,6 +180,10 @@ export class WhatsAppChannel implements Channel { msg.message?.videoMessage?.caption || ''; + // Skip protocol messages with no text content (encryption keys, read receipts, etc.) + // but allow voice messages through for transcription + if (!content && !isVoiceMessage(msg)) continue; + const sender = msg.key.participant || msg.key.remoteJid || ''; const senderName = msg.pushName || sender.split('@')[0];