diff --git a/docs/cron-setup.md b/docs/cron-setup.md index 571ff54..8daa1b8 100644 --- a/docs/cron-setup.md +++ b/docs/cron-setup.md @@ -42,12 +42,14 @@ lettabot-cron create \ - `--name` - Job name (required) - `--schedule` - Cron expression (required) - `--message` - Message sent when job runs (required) -- `--deliver` - Where to send: `channel:chatId` (defaults to last messaged chat) +- `--deliver` - Where to send: `channel:chatId` (defaults to last messaged chat at creation time; falls back to last messaged chat at runtime) +- `--silent` - Do not deliver response automatically (agent must use `lettabot-message send`) ### Managing Jobs ```bash lettabot-cron list # Show all jobs +lettabot-cron update ... # Update job properties (--deliver, --name, --message, etc.) lettabot-cron delete # Delete a job lettabot-cron enable # Enable a job lettabot-cron disable # Disable a job @@ -144,11 +146,20 @@ Only actionable tasks are shown in the heartbeat prompt: - `completed: false` - `snoozed_until` not set, or already in the past -## Silent Mode +## Delivery Behavior -Both cron jobs and heartbeats run in **Silent Mode**: +### Cron Jobs + +Cron jobs deliver responses automatically: +- If `--deliver` was specified at creation, responses go to that channel/chat +- If `--deliver` was omitted, the CLI auto-fills from the last messaged chat +- At runtime, if a job has no configured delivery target, it falls back to the most recent message target +- Use `--silent` at creation to explicitly opt out of automatic delivery + +### Heartbeats (Silent Mode) + +Heartbeats run in **Silent Mode** -- responses are NOT automatically delivered: -- The agent's text output is NOT automatically sent to users - The agent sees a `[SILENT MODE]` banner with instructions - To send messages, the agent must explicitly run: @@ -207,4 +218,8 @@ Migration note: ### Jobs running but no messages received -The agent runs in Silent Mode - it must actively choose to send messages. Check the agent's behavior in the ADE to see what it's doing during background tasks. +1. Check `lettabot-cron list` -- does the job show a delivery target? +2. If delivery shows `(none)`, the job was created without `--deliver` and no user had messaged the bot yet +3. Fix: `lettabot-cron update --deliver telegram:123456789` (or your channel:chatId) +4. Alternatively, send the bot any message to establish a last-message target -- new runs will auto-deliver +5. Check logs for `"mode":"silent"` entries -- this confirms the job ran but had nowhere to send the response diff --git a/src/cron/cli.ts b/src/cron/cli.ts index 3a93538..511dd7d 100644 --- a/src/cron/cli.ts +++ b/src/cron/cli.ts @@ -16,6 +16,9 @@ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, copyFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js'; +import { loadLastTarget } from '../cli/shared.js'; + +const VALID_CHANNELS = ['telegram', 'telegram-mtproto', 'slack', 'discord', 'whatsapp', 'signal']; // Parse ISO datetime string function parseISODateTime(input: string): Date { @@ -40,6 +43,7 @@ interface CronJob { channel: string; chatId: string; }; + silent?: boolean; deleteAfterRun?: boolean; state: { lastRunAt?: string; @@ -156,6 +160,10 @@ function listJobs(): void { } if (job.deliver) { console.log(` Deliver: ${job.deliver.channel}:${job.deliver.chatId}`); + } else if (job.silent) { + console.log(` Deliver: silent (no delivery)`); + } else { + console.log(` Deliver: (none -- will use last message target at runtime)`); } } console.log(''); @@ -167,6 +175,7 @@ function createJob(args: string[]): void { let at = ''; // One-off timer: ISO datetime or relative (e.g., "5m", "1h") let message = ''; let enabled = true; + let silent = false; let deliverChannel = ''; let deliverChatId = ''; @@ -189,13 +198,14 @@ function createJob(args: string[]): void { i++; } else if (arg === '--disabled') { enabled = false; + } else if (arg === '--silent') { + silent = true; } else if ((arg === '--deliver' || arg === '-d') && next) { // Format: channel:chatId (e.g., telegram:123456789 or discord:123456789012345678) const [ch, ...rest] = next.split(':'); const id = rest.join(':'); // Rejoin in case chatId contains colons - const validChannels = ['telegram', 'telegram-mtproto', 'slack', 'discord', 'whatsapp', 'signal']; - if (!validChannels.includes(ch)) { - console.error(`Error: invalid channel "${ch}". Must be one of: ${validChannels.join(', ')}`); + if (!VALID_CHANNELS.includes(ch)) { + console.error(`Error: invalid channel "${ch}". Must be one of: ${VALID_CHANNELS.join(', ')}`); process.exit(1); } if (!id) { @@ -208,6 +218,21 @@ function createJob(args: string[]): void { } } + // Auto-fill deliver from last message target when not explicitly set + if (!silent && !deliverChannel) { + const lastTarget = loadLastTarget(); + if (lastTarget) { + deliverChannel = lastTarget.channel; + deliverChatId = lastTarget.chatId; + console.log(` Delivering to ${deliverChannel}:${deliverChatId} (from last message target)`); + console.log(` Use --silent for no delivery, or --deliver channel:chatId to override.`); + } else { + console.warn('Warning: No --deliver target and no previous messages found.'); + console.warn('Responses will not be delivered until a user messages the bot.'); + console.warn('Use --deliver channel:chatId to set a target, or --silent for intentional silent mode.'); + } + } + if (!name || (!schedule && !at) || !message) { console.error('Error: --name, (--schedule or --at), and --message are required'); console.error(''); @@ -246,7 +271,8 @@ function createJob(args: string[]): void { enabled, schedule: cronSchedule, message, - deliver: deliverChannel && deliverChatId ? { channel: deliverChannel, chatId: deliverChatId } : undefined, + deliver: !silent && deliverChannel && deliverChatId ? { channel: deliverChannel, chatId: deliverChatId } : undefined, + silent: silent || undefined, deleteAfterRun, state: {}, }; @@ -332,6 +358,13 @@ function showJob(id: string): void { console.log(`Enabled: ${job.enabled}`); console.log(`Schedule: ${job.schedule.kind === 'cron' ? job.schedule.expr : JSON.stringify(job.schedule)}`); console.log(`Message:\n ${job.message}`); + if (job.deliver) { + console.log(`Deliver: ${job.deliver.channel}:${job.deliver.chatId}`); + } else if (job.silent) { + console.log(`Deliver: silent (no delivery)`); + } else { + console.log(`Deliver: (none -- will use last message target at runtime)`); + } console.log(`\nState:`); console.log(` Last run: ${formatDate(job.state.lastRunAt)}`); console.log(` Next run: ${formatDate(job.state.nextRunAt)}`); @@ -343,6 +376,82 @@ function showJob(id: string): void { +function updateJob(args: string[]): void { + const id = args[0]; + if (!id) { + console.error('Error: Job ID required'); + console.error('Usage: lettabot-schedule update [--name ...] [--message ...] [--schedule ...] [--at ...] [--deliver channel:chatId] [--silent]'); + process.exit(1); + } + + const store = loadStore(); + const job = store.jobs.find(j => j.id === id); + + if (!job) { + console.error(`Error: Job not found: ${id}`); + process.exit(1); + } + + const updates: string[] = []; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if ((arg === '--name' || arg === '-n') && next) { + job.name = next; + updates.push(`name="${next}"`); + i++; + } else if ((arg === '--message' || arg === '-m') && next) { + job.message = next; + updates.push(`message updated`); + i++; + } else if ((arg === '--schedule' || arg === '-s') && next) { + job.schedule = { kind: 'cron', expr: next }; + job.deleteAfterRun = false; + updates.push(`schedule="${next}"`); + i++; + } else if ((arg === '--at' || arg === '-a') && next) { + const date = parseISODateTime(next); + job.schedule = { kind: 'at', date }; + job.deleteAfterRun = true; + updates.push(`at=${date.toISOString()}`); + i++; + } else if ((arg === '--deliver' || arg === '-d') && next) { + const [ch, ...rest] = next.split(':'); + const chatId = rest.join(':'); + if (!VALID_CHANNELS.includes(ch)) { + console.error(`Error: invalid channel "${ch}". Must be one of: ${VALID_CHANNELS.join(', ')}`); + process.exit(1); + } + if (!chatId) { + console.error('Error: --deliver requires format channel:chatId (e.g., telegram:123456789)'); + process.exit(1); + } + job.deliver = { channel: ch, chatId }; + job.silent = undefined; + updates.push(`deliver=${ch}:${chatId}`); + i++; + } else if (arg === '--silent') { + job.deliver = undefined; + job.silent = true; + updates.push('silent mode (no delivery)'); + } + } + + if (updates.length === 0) { + console.error('Error: No updates specified'); + console.error('Usage: lettabot-schedule update [--name ...] [--message ...] [--schedule ...] [--at ...] [--deliver channel:chatId] [--silent]'); + process.exit(1); + } + + saveStore(store); + + log('job_updated', { id, name: job.name, updates }); + + console.log(`✓ Updated "${job.name}": ${updates.join(', ')}`); +} + function showHelp(): void { console.log(` lettabot-schedule - Manage scheduled tasks and reminders @@ -350,26 +459,36 @@ lettabot-schedule - Manage scheduled tasks and reminders Commands: list List all scheduled tasks create [options] Create a new task + update [options] Update an existing task delete Delete a task enable Enable a task disable Disable a task show Show task details -Create options: - --name, -n Task name (required) +Create/update options: + --name, -n Task name (required for create) --schedule, -s Cron expression for recurring tasks --at, -a ISO datetime for one-off reminder (auto-deletes after) - --message, -m Prompt sent to agent when job fires (required) - --deliver, -d Auto-deliver response to channel:chatId (omit for silent mode) + --message, -m Prompt sent to agent when job fires (required for create) + --deliver, -d Deliver response to channel:chatId (defaults to last messaged chat) + --silent Do not deliver response (agent must use lettabot-message CLI) --disabled Create in disabled state + Note: Use 'enable ' / 'disable ' to toggle job state. + Examples: # One-off reminder (calculate ISO: new Date(Date.now() + 5*60*1000).toISOString()) lettabot-schedule create -n "Standup" --at "2026-01-28T20:15:00Z" -m "Time to stand!" - # Recurring daily at 8am + # Recurring daily at 8am (delivers to last messaged chat) lettabot-schedule create -n "Morning" -s "0 8 * * *" -m "Good morning!" + # Deliver to specific channel + lettabot-schedule create -n "Morning" -s "0 8 * * *" -m "Good morning!" -d telegram:123456789 + + # Update delivery target on existing job + lettabot-schedule update --deliver telegram:123456789 + # List and delete lettabot-schedule list lettabot-schedule delete job-1234567890-abc123 @@ -391,6 +510,11 @@ switch (command) { createJob(args.slice(1)); break; + case 'update': + case 'edit': + updateJob(args.slice(1)); + break; + case 'delete': case 'rm': case 'remove': diff --git a/src/cron/service.ts b/src/cron/service.ts index 3299e3c..2b008a1 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -420,19 +420,27 @@ export class CronService { // Send message to agent const response = await this.bot.sendToAgent(messageWithMetadata); - // Deliver response to channel if configured - const deliverMode = job.deliver ? 'deliver' : 'silent'; - if (job.deliver && response) { + // Resolve delivery target: explicit config > last message target fallback > silent + let deliverTarget: { channel: string; chatId: string } | null = job.deliver ?? null; + if (!deliverTarget && !job.silent) { + const last = this.bot.getLastMessageTarget(); + if (last) { + log.info(`No deliver target configured for "${job.name}", using last message target: ${last.channel}:${last.chatId}`); + deliverTarget = { channel: last.channel, chatId: last.chatId }; + } + } + const deliverMode = deliverTarget ? 'deliver' : 'silent'; + if (deliverTarget && response) { try { - await this.bot.deliverToChannel(job.deliver.channel, job.deliver.chatId, { text: response }); - log.info(`📬 Delivered response to ${job.deliver.channel}:${job.deliver.chatId}`); + await this.bot.deliverToChannel(deliverTarget.channel, deliverTarget.chatId, { text: response }); + log.info(`📬 Delivered response to ${deliverTarget.channel}:${deliverTarget.chatId}`); } catch (deliverError) { - log.error(`Failed to deliver response to ${job.deliver.channel}:${job.deliver.chatId}:`, deliverError); + log.error(`Failed to deliver response to ${deliverTarget.channel}:${deliverTarget.chatId}:`, deliverError); logEvent('job_deliver_failed', { id: job.id, name: job.name, - channel: job.deliver.channel, - chatId: job.deliver.chatId, + channel: deliverTarget.channel, + chatId: deliverTarget.chatId, error: deliverError instanceof Error ? deliverError.message : String(deliverError), }); } @@ -457,9 +465,9 @@ export class CronService { log.info(`✅ JOB COMPLETED: ${job.name} [${deliverMode.toUpperCase()} MODE]`); log.info(` Response: ${response?.slice(0, 200)}${(response?.length || 0) > 200 ? '...' : ''}`); if (deliverMode === 'silent') { - log.info(` (Response NOT auto-delivered - agent uses lettabot-message CLI)`); + log.info(` (Response NOT auto-delivered - no target available)`); } else { - log.info(` (Response delivered to ${job.deliver!.channel}:${job.deliver!.chatId})`); + log.info(` (Response delivered to ${deliverTarget!.channel}:${deliverTarget!.chatId}${!job.deliver ? ' via fallback' : ''})`); } log.info(`${'='.repeat(50)}`); @@ -468,7 +476,8 @@ export class CronService { name: job.name, status: 'ok', mode: deliverMode, - deliverTarget: job.deliver ? `${job.deliver.channel}:${job.deliver.chatId}` : undefined, + deliverTarget: deliverTarget ? `${deliverTarget.channel}:${deliverTarget.chatId}` : undefined, + deliverSource: job.deliver ? 'configured' : (deliverTarget ? 'lastMessageTarget' : undefined), nextRun: job.state.nextRunAt?.toISOString(), responseLength: response?.length || 0, }); diff --git a/src/cron/types.ts b/src/cron/types.ts index 38b32cf..96fd2d5 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -30,6 +30,9 @@ export interface CronJob { chatId: string; }; + // Explicitly suppress delivery (skips lastMessageTarget fallback) + silent?: boolean; + // Delete after running (for one-shot jobs) deleteAfterRun?: boolean;