@@ -7,9 +7,13 @@ import type { EngineName } from '../engines/index.js';
77import { MemoryClient } from '../memory/memory-client.js' ;
88import { AuditLogger } from '../utils/audit-logger.js' ;
99import 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
1114export 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 {
446628function 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+ }
0 commit comments