Skip to content

Commit 4fee54f

Browse files
committed
feat(commands): add /<N> defer-send + /0 cancel per chat
`/<N> <message>` queues `<message>` to fire N minutes from now (default unit = minutes). The whole feature lives in the numeric `/<digits>` namespace so it can never collide with a word command: `/0` cancels the chat's pending deferred message — no ID needed. Per-chat single-slot rule: at most one pending deferred message per (bot, chat). A second `/<N>` while one is queued is rejected with the existing entry's details; `/status` surfaces the pending entry. Max defer is 7 days. Scheduler is injected via setter (process-singleton, mirrors setSessionRegistry); bots without one get a clear notice.
1 parent 2c13192 commit 4fee54f

8 files changed

Lines changed: 456 additions & 4 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,9 @@ MetaBot 以 `bypassPermissions` 模式运行 Claude Code — 无交互式确认
432432
|------|------|
433433
| `/reset` | 清除会话 |
434434
| `/stop` | 中止当前任务 |
435-
| `/status` | 查看会话状态(含当前模型) |
435+
| `/status` | 查看会话状态(含当前模型 + 待发送队列) |
436+
| `/<N> <消息>` |`<消息>` 排到 **N 分钟后**自动发送(每个聊天只能排一条,最长 7 天) |
437+
| `/0` | 取消该聊天的待发送消息(无需 ID;纯数字命名空间,零冲突) |
436438
| `/goal <条件>` | 设置目标,Agent 跨多轮持续推进直到达成。`/goal clear` 停止 |
437439
| `/model` | 查看当前模型;`/model list` 查看可用模型;`/model <name>` 切换;`/model reset` 恢复默认 |
438440
| `/memory list` | 浏览知识库目录 |

README_EN.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@ MetaBot runs Claude Code in `bypassPermissions` mode — no interactive approval
435435
|---------|-------------|
436436
| `/reset` | Clear session |
437437
| `/stop` | Abort current task |
438-
| `/status` | Session info (includes current model) |
438+
| `/status` | Session info (current model + pending deferred message) |
439+
| `/<N> <message>` | Queue `<message>` to fire **N minutes** from now (one slot per chat, max 7 days) |
440+
| `/0` | Drop the chat's pending deferred message (no ID needed; numeric namespace, zero collision) |
439441
| `/goal <condition>` | Set a goal the agent keeps pursuing across turns. `/goal clear` to stop |
440442
| `/model` | Show current engine/model; `/model list` — available engines/models; `/model claude`, `/model kimi`, or `/model codex` — switch engine; `/model <name>` — set model; `/model reset` — restore default |
441443
| `/memory list` | Browse knowledge tree |

src/bridge/command-handler.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import type { EngineName } from '../engines/index.js';
77
import { MemoryClient } from '../memory/memory-client.js';
88
import { AuditLogger } from '../utils/audit-logger.js';
99
import type { DocSync } from '../sync/doc-sync.js';
10+
import type { TaskScheduler } from '../scheduler/task-scheduler.js';
11+
12+
const MAX_DEFER_MINUTES = 7 * 24 * 60; // 7 days — beyond this use `mb schedule cron`
1013

1114
export class CommandHandler {
1215
private docSync: DocSync | null = null;
16+
private scheduler: TaskScheduler | null = null;
1317

1418
constructor(
1519
private config: BotConfigBase,
@@ -42,6 +46,14 @@ export class CommandHandler {
4246
this.docSync = docSync;
4347
}
4448

49+
/** Inject the task scheduler used by `/<N>` and `/0` (cancel). Until this
50+
* is called, those commands report a clear "scheduler unavailable" notice
51+
* instead of crashing — keeps unit tests and bots without a registered
52+
* scheduler working. */
53+
setScheduler(scheduler: TaskScheduler): void {
54+
this.scheduler = scheduler;
55+
}
56+
4557
/** Returns true if the message was handled as a command, false otherwise. */
4658
async handle(msg: IncomingMessage): Promise<boolean> {
4759
const { text } = msg;
@@ -50,6 +62,27 @@ export class CommandHandler {
5062
const { userId, chatId } = msg;
5163
const [cmd] = text.split(/\s+/);
5264

65+
// Defer-send lives entirely in the numeric `/<digits>` namespace so it
66+
// can never collide with a word command:
67+
// `/<N> <message>` — queue the message to run N minutes from now.
68+
// `/0` — cancel the chat's pending deferred message.
69+
// Per-chat single-slot — a second `/<N>` while one is pending is
70+
// rejected with the existing entry's details (use `/0` to drop it).
71+
// Match the WHOLE command token to avoid colliding with other commands
72+
// that happen to start with a digit later (currently none).
73+
const deferMatch = cmd.match(/^\/(\d+)$/);
74+
if (deferMatch) {
75+
this.audit.log({ event: 'command', botName: this.config.name, chatId, userId, prompt: cmd });
76+
const minutes = Number(deferMatch[1]);
77+
if (minutes === 0) {
78+
await this.handleCancelDeferred(chatId);
79+
return true;
80+
}
81+
const prompt = text.slice(cmd.length).trim();
82+
await this.handleDeferredSend(chatId, userId, minutes, prompt);
83+
return true;
84+
}
85+
5386
this.audit.log({ event: 'command', botName: this.config.name, chatId, userId, prompt: cmd });
5487

5588
switch (cmd.toLowerCase()) {
@@ -65,6 +98,10 @@ export class CommandHandler {
6598
'`/memory` - Memory document commands',
6699
'`/help` - Show this help message',
67100
'',
101+
'**Deferred Send** (one slot per chat — execute, then queue the next):',
102+
'`/<N> <message>` - Queue `<message>` to run **N minutes** from now (e.g. `/60 写个总结`)',
103+
'`/0` - Cancel the pending deferred message (no ID needed — only one slot)',
104+
'',
68105
'**Agent Commands** (pass through to the agent — Claude only):',
69106
'`/goal <description>` - Set a goal the agent keeps pursuing across turns',
70107
'`/background <prompt>` - Run a task in the background while you continue chatting',
@@ -133,13 +170,15 @@ export class CommandHandler {
133170
const activeEngine = session.engine ?? botEngine;
134171
const defaultModel = this.defaultModelForEngine(activeEngine) || '_default_';
135172
const activeModel = session.model || defaultModel;
173+
const deferredLine = this.formatDeferredStatusLine(chatId);
136174
await this.sender.sendTextNotice(chatId, '📊 Status', [
137175
`**User:** \`${userId}\``,
138176
`**Engine:** \`${activeEngine}\`${session.engine ? ' (session override)' : ''}`,
139177
`**Working Directory:** \`${session.workingDirectory}\``,
140178
`**Session:** ${session.sessionId ? `\`${session.sessionId.slice(0, 8)}...\`` : '_None_'}`,
141179
`**Model:** \`${activeModel}\`${session.model ? ' (session override)' : ''}`,
142180
`**Running:** ${isRunning ? 'Yes ⏳' : 'No'}`,
181+
`**Deferred:** ${deferredLine}`,
143182
].join('\n'));
144183
return true;
145184
}
@@ -431,6 +470,149 @@ export class CommandHandler {
431470
}
432471
}
433472

473+
/** Handle `/<N> <message>` — queue the message N minutes from now. */
474+
private async handleDeferredSend(
475+
chatId: string,
476+
_userId: string,
477+
minutes: number,
478+
prompt: string,
479+
): Promise<void> {
480+
if (!this.scheduler) {
481+
await this.sender.sendTextNotice(
482+
chatId,
483+
'❌ Defer Unavailable',
484+
'The scheduler is not wired up for this bot — `/defer` cannot be used here.',
485+
'red',
486+
);
487+
return;
488+
}
489+
490+
if (!Number.isFinite(minutes) || minutes <= 0) {
491+
await this.sender.sendTextNotice(
492+
chatId,
493+
'❌ Invalid Delay',
494+
'The delay (in minutes) must be a positive integer. Example: `/60 写个总结`.',
495+
'red',
496+
);
497+
return;
498+
}
499+
500+
if (minutes > MAX_DEFER_MINUTES) {
501+
await this.sender.sendTextNotice(
502+
chatId,
503+
'❌ Delay Too Long',
504+
`Maximum defer is **${MAX_DEFER_MINUTES} minutes** (7 days). For longer schedules use the recurring scheduler API.`,
505+
'red',
506+
);
507+
return;
508+
}
509+
510+
if (!prompt) {
511+
await this.sender.sendTextNotice(
512+
chatId,
513+
'❌ Missing Message',
514+
`Usage: \`/${minutes} <message>\` — the message to send ${minutes} minutes from now.`,
515+
'red',
516+
);
517+
return;
518+
}
519+
520+
// Per-chat single-slot — refuse a second `/<N>` while one is pending.
521+
const existing = this.scheduler.getChatTask(this.config.name, chatId);
522+
if (existing) {
523+
const remaining = Math.max(0, Math.round((existing.executeAt - Date.now()) / 60_000));
524+
await this.sender.sendTextNotice(
525+
chatId,
526+
'⛔ Deferred Slot Taken',
527+
[
528+
`This chat already has a pending deferred message (one slot per chat).`,
529+
`**Fires in:** ~${remaining} min`,
530+
`**Message:** ${truncatePrompt(existing.prompt)}`,
531+
'',
532+
'Use `/0` to drop it, then queue a new one.',
533+
].join('\n'),
534+
'orange',
535+
);
536+
return;
537+
}
538+
539+
const task = this.scheduler.scheduleTask({
540+
botName: this.config.name,
541+
chatId,
542+
prompt,
543+
delaySeconds: minutes * 60,
544+
sendCards: true,
545+
label: 'slash-defer',
546+
});
547+
548+
const fireAt = new Date(task.executeAt).toLocaleString('zh-CN', { hour12: false });
549+
await this.sender.sendTextNotice(
550+
chatId,
551+
'⏱ Deferred Queued',
552+
[
553+
`Will run in **${minutes} min** (≈ ${fireAt}).`,
554+
`**Message:** ${truncatePrompt(prompt)}`,
555+
'',
556+
'Use `/0` to drop it, or `/status` to inspect.',
557+
].join('\n'),
558+
'green',
559+
);
560+
}
561+
562+
/** Handle `/0` — drop the chat's pending deferred message. */
563+
private async handleCancelDeferred(chatId: string): Promise<void> {
564+
if (!this.scheduler) {
565+
await this.sender.sendTextNotice(
566+
chatId,
567+
'❌ Defer Unavailable',
568+
'The scheduler is not wired up for this bot — nothing to cancel.',
569+
'red',
570+
);
571+
return;
572+
}
573+
574+
const existing = this.scheduler.getChatTask(this.config.name, chatId);
575+
if (!existing) {
576+
// No-op fallback: `/0` with nothing queued is harmless — just a
577+
// gentle reminder, never an error.
578+
await this.sender.sendTextNotice(
579+
chatId,
580+
'ℹ️ Nothing to Cancel',
581+
'No deferred message is pending in this chat — nothing to do. Use `/<N> <message>` to queue one (e.g. `/60 写个总结`).',
582+
'blue',
583+
);
584+
return;
585+
}
586+
587+
const ok = this.scheduler.cancelTask(existing.id);
588+
if (!ok) {
589+
// Race: the task fired between getChatTask() and cancelTask().
590+
await this.sender.sendTextNotice(
591+
chatId,
592+
'ℹ️ Already Fired',
593+
'The deferred message already started — nothing to cancel.',
594+
'blue',
595+
);
596+
return;
597+
}
598+
599+
await this.sender.sendTextNotice(
600+
chatId,
601+
'🗑 Deferred Cancelled',
602+
`Dropped: ${truncatePrompt(existing.prompt)}`,
603+
'green',
604+
);
605+
}
606+
607+
/** Build the `/status` "Deferred" line — minutes remaining + preview, or `_None_`. */
608+
private formatDeferredStatusLine(chatId: string): string {
609+
if (!this.scheduler) return '_n/a_';
610+
const task = this.scheduler.getChatTask(this.config.name, chatId);
611+
if (!task) return '_None_';
612+
const remaining = Math.max(0, Math.round((task.executeAt - Date.now()) / 60_000));
613+
return `~${remaining} min — ${truncatePrompt(task.prompt)}`;
614+
}
615+
434616
private authTipForEngine(engine: EngineName): string {
435617
switch (engine) {
436618
case 'claude':
@@ -446,3 +628,9 @@ export class CommandHandler {
446628
function isEngineName(value: string): value is EngineName {
447629
return value === 'claude' || value === 'kimi' || value === 'codex';
448630
}
631+
632+
/** Trim a queued prompt to a single-line preview suitable for status / notice cards. */
633+
function truncatePrompt(prompt: string, max = 60): string {
634+
const oneLine = prompt.replace(/\s+/g, ' ').trim();
635+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
636+
}

src/bridge/message-bridge.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { OutputHandler } from './output-handler.js';
2525
import { CostTracker } from '../utils/cost-tracker.js';
2626
import { metrics } from '../utils/metrics.js';
2727
import type { SessionRegistry } from '../session/session-registry.js';
28+
import type { TaskScheduler } from '../scheduler/task-scheduler.js';
2829

2930
const TASK_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
3031
const QUESTION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for user to answer
@@ -353,6 +354,14 @@ export class MessageBridge {
353354
this.sessionRegistry = registry;
354355
}
355356

357+
/** Inject the task scheduler so the slash-command layer can queue
358+
* per-chat deferred sends (`/<N>` / `/0`). The scheduler is a
359+
* process-singleton built in index.ts after every bot is registered,
360+
* hence setter injection (mirrors {@link setSessionRegistry}). */
361+
setScheduler(scheduler: TaskScheduler): void {
362+
this.commandHandler.setScheduler(scheduler);
363+
}
364+
356365
/** Expose session manager for cross-platform session linking. */
357366
getSessionManager(): SessionManager {
358367
return this.sessionManager;

src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,16 @@ async function main() {
262262

263263
// Initialize cross-platform session registry
264264
const sessionRegistry = new SessionRegistry(logger);
265-
// Inject into all bot bridges
265+
// Inject session registry + scheduler into all bot bridges so the
266+
// slash-command layer (`/<N>`, `/0`) can queue per-chat deferred
267+
// sends. Setter injection, because both objects are process-singletons
268+
// built after the bot registry is populated.
266269
for (const info of registry.list()) {
267270
const bot = registry.get(info.name);
268-
if (bot) bot.bridge.setSessionRegistry(sessionRegistry);
271+
if (bot) {
272+
bot.bridge.setSessionRegistry(sessionRegistry);
273+
bot.bridge.setScheduler(scheduler);
274+
}
269275
}
270276

271277
// Resolve bots config path for API-driven bot CRUD

src/scheduler/task-scheduler.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,28 @@ export class TaskScheduler {
185185
return this.listTasks().length;
186186
}
187187

188+
/**
189+
* Look up the most recently created pending one-shot task for a given
190+
* bot+chat. Returns undefined if none. Drives the slash-command
191+
* defer-send flow (`/<N>` and `/0`), which enforces a per-chat
192+
* single-slot rule: a second `/<N>` rejects with the existing entry's
193+
* details, and `/0` drops it without needing an ID.
194+
*
195+
* HTTP/CLI callers can still create multiple pending tasks per chat —
196+
* this helper just exposes "which one would the slash UI surface?" and
197+
* makes the latest visible to that UI.
198+
*/
199+
getChatTask(botName: string, chatId: string): ScheduledTask | undefined {
200+
let latest: ScheduledTask | undefined;
201+
for (const t of this.tasks.values()) {
202+
if (t.status !== 'pending') continue;
203+
if (t.botName !== botName) continue;
204+
if (t.chatId !== chatId) continue;
205+
if (!latest || t.createdAt > latest.createdAt) latest = t;
206+
}
207+
return latest;
208+
}
209+
188210
// ===== Recurring task methods =====
189211

190212
scheduleRecurring(input: RecurringScheduleInput): RecurringTask {

0 commit comments

Comments
 (0)