fix: default cron job delivery to last message target (#454)

This commit is contained in:
Cameron
2026-03-02 12:53:18 -08:00
committed by GitHub
parent 7c5f6eaf63
commit db5630740f
4 changed files with 176 additions and 25 deletions

View File

@@ -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 <id> ... # Update job properties (--deliver, --name, --message, etc.)
lettabot-cron delete <id> # Delete a job
lettabot-cron enable <id> # Enable a job
lettabot-cron disable <id> # 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 <id> --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

View File

@@ -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 <id> [--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 <id> [--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 <id> [options] Update an existing task
delete <id> Delete a task
enable <id> Enable a task
disable <id> Disable a task
show <id> Show task details
Create options:
--name, -n <name> Task name (required)
Create/update options:
--name, -n <name> Task name (required for create)
--schedule, -s <cron> Cron expression for recurring tasks
--at, -a <datetime> ISO datetime for one-off reminder (auto-deletes after)
--message, -m <msg> Prompt sent to agent when job fires (required)
--deliver, -d <target> Auto-deliver response to channel:chatId (omit for silent mode)
--message, -m <msg> Prompt sent to agent when job fires (required for create)
--deliver, -d <target> 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 <id>' / 'disable <id>' 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 <id> --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':

View File

@@ -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,
});

View File

@@ -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;