obol-ai 0.3.29 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.3.30
2
+ - add /schedule wizard for interactive event creation
3
+ - 0.3.29 — republish scheduler fix
4
+ - 0.3.28 — fix scheduler instructions not persisted in list, not wiped on update
5
+ - 0.3.27 — cap proactive follow-ups to max 3 days
6
+ - 0.3.26 — fix proactive follow-ups never delivered, expire stale events
7
+ - 0.3.25 — auto-schedule from images, route email/event queries to sonnet
8
+ - 0.3.24 — inline token stats, fix bg task null results
9
+ - fix failing soul and telegram tests
10
+
1
11
  ## 0.3.22
2
12
  - update changelog
3
13
  - fix soul backup version number to use evolutionNumber
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -59,7 +59,7 @@ function createBot(telegramConfig, config) {
59
59
 
60
60
  bot.use(sequentialize((ctx) => {
61
61
  const cbData = ctx.callbackQuery?.data;
62
- if (cbData?.startsWith('stop:') || cbData?.startsWith('force:') || cbData?.startsWith('ask:')) return undefined;
62
+ if (cbData?.startsWith('stop:') || cbData?.startsWith('force:') || cbData?.startsWith('ask:') || cbData?.startsWith('sched:')) return undefined;
63
63
  return ctx.chat?.id.toString();
64
64
  }));
65
65
 
@@ -102,6 +102,7 @@ function createBot(telegramConfig, config) {
102
102
  { command: 'toolimit', description: 'View or set max tool iterations per message' },
103
103
  { command: 'options', description: 'Toggle optional features on/off' },
104
104
  { command: 'topics', description: 'Edit news topics' },
105
+ { command: 'schedule', description: 'Create a new scheduled event' },
105
106
  { command: 'stop', description: 'Stop the current request' },
106
107
  { command: 'restart', description: 'Restart the bot (pm2)' },
107
108
  { command: 'upgrade', description: 'Check for updates and upgrade' },
@@ -121,6 +122,12 @@ function createBot(telegramConfig, config) {
121
122
  await sendTopicEditor(ctx, config);
122
123
  });
123
124
 
125
+ bot.command('schedule', async (ctx) => {
126
+ if (!ctx.from) return;
127
+ const { startWizard } = require('./schedule-wizard');
128
+ await startWizard(ctx, config);
129
+ });
130
+
124
131
  const deps = { config, allowedUsers, bot, createAsk };
125
132
  registerTextHandler(bot, deps);
126
133
  registerMediaHandler(bot, telegramConfig, deps);
@@ -1,6 +1,7 @@
1
1
  const { handleToolCallback } = require('../commands/tools');
2
2
  const { handleVoiceCallback } = require('../voice');
3
3
  const { handleTopicCallback } = require('../topics');
4
+ const { handleSchedCallback } = require('../schedule-wizard');
4
5
 
5
6
  function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
6
7
  bot.on('callback_query:data', async (ctx) => {
@@ -43,6 +44,11 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
43
44
  return;
44
45
  }
45
46
 
47
+ if (data.startsWith('sched:')) {
48
+ await handleSchedCallback(ctx, data, answer, { getTenant, config, bot });
49
+ return;
50
+ }
51
+
46
52
  if (data.startsWith('bridge:reply:')) {
47
53
  const targetUserId = parseInt(data.split(':')[2]);
48
54
  const reactingUserId = ctx.from.id;
@@ -304,6 +304,12 @@ function registerTextHandler(bot, { config, allowedUsers, createAsk }) {
304
304
  return;
305
305
  }
306
306
 
307
+ const { isPendingSchedInput, handleSchedText } = require('../schedule-wizard');
308
+ if (isPendingSchedInput(userId)) {
309
+ await handleSchedText(ctx, userMessage, { getTenant, config, bot });
310
+ return;
311
+ }
312
+
307
313
  const rateResult = bot._rateLimiter.check(userId);
308
314
  if (rateResult === 'cooldown' || rateResult === 'skip') return;
309
315
  if (rateResult === 'spam') {
@@ -0,0 +1,543 @@
1
+ const { InlineKeyboard } = require('grammy');
2
+ const { CronExpressionParser } = require('cron-parser');
3
+ const { sendHtml, editHtml } = require('./utils');
4
+ const { toUTC } = require('../claude/utils');
5
+
6
+ const TITLE_TTL_MS = 120_000;
7
+ const TIME_TTL_MS = 120_000;
8
+ const CRON_TTL_MS = 120_000;
9
+ const DESC_TTL_MS = 120_000;
10
+ const INSTR_TTL_MS = 180_000;
11
+
12
+ /** @type {Map<number, { chatId: number, messageId: number }[]>} */
13
+ const schedFlowMessages = new Map();
14
+
15
+ /** @type {Map<number, { step: string, isRecurring: boolean, isAgentic: boolean, title?: string, dueAt?: string, timezone?: string, cronExpr?: string, maxRuns?: number, description?: string, instructions?: string, editMsgId?: number }>} */
16
+ const schedDrafts = new Map();
17
+
18
+ /** @type {Map<number, { timer: ReturnType<typeof setTimeout>, field: string }>} */
19
+ const pendingSchedInput = new Map();
20
+
21
+ function trackMsg(userId, chatId, messageId) {
22
+ if (!schedFlowMessages.has(userId)) schedFlowMessages.set(userId, []);
23
+ schedFlowMessages.get(userId).push({ chatId, messageId });
24
+ }
25
+
26
+ async function clearSchedFlow(userId, bot) {
27
+ const msgs = schedFlowMessages.get(userId);
28
+ if (!msgs) return;
29
+ schedFlowMessages.delete(userId);
30
+ cancelPending(userId);
31
+ schedDrafts.delete(userId);
32
+ for (const { chatId, messageId } of msgs) {
33
+ bot.api.deleteMessage(chatId, messageId).catch(() => {});
34
+ }
35
+ }
36
+
37
+ function cancelPending(userId) {
38
+ const pending = pendingSchedInput.get(userId);
39
+ if (pending) {
40
+ clearTimeout(pending.timer);
41
+ pendingSchedInput.delete(userId);
42
+ }
43
+ }
44
+
45
+ function setPendingInput(userId, field, ttlMs) {
46
+ cancelPending(userId);
47
+ const timer = setTimeout(() => pendingSchedInput.delete(userId), ttlMs);
48
+ pendingSchedInput.set(userId, { timer, field });
49
+ }
50
+
51
+ function isPendingSchedInput(userId) {
52
+ return pendingSchedInput.has(userId);
53
+ }
54
+
55
+ async function startWizard(ctx, config) {
56
+ const userId = ctx.from.id;
57
+ await clearSchedFlow(userId, ctx.api);
58
+
59
+ schedDrafts.set(userId, { step: 'type', isRecurring: false, isAgentic: false });
60
+
61
+ const kb = new InlineKeyboard()
62
+ .text('One-time reminder', 'sched:type:reminder')
63
+ .text('Recurring reminder', 'sched:type:recurring').row()
64
+ .text('Agentic task', 'sched:type:agentic')
65
+ .text('Recurring agentic task', 'sched:type:agentic-rec');
66
+
67
+ const msg = await ctx.reply('What would you like to schedule?', { reply_markup: kb });
68
+ trackMsg(userId, msg.chat.id, msg.message_id);
69
+ }
70
+
71
+ async function stepTitle(ctx, userId) {
72
+ const draft = schedDrafts.get(userId);
73
+ if (!draft) return;
74
+ draft.step = 'title';
75
+
76
+ setPendingInput(userId, 'title', TITLE_TTL_MS);
77
+ const msg = await ctx.reply('What\'s the title for this event?');
78
+ trackMsg(userId, msg.chat.id, msg.message_id);
79
+ }
80
+
81
+ async function stepTime(ctx, userId) {
82
+ const draft = schedDrafts.get(userId);
83
+ if (!draft) return;
84
+ draft.step = 'time';
85
+
86
+ const { getTenant } = require('../tenant');
87
+ let tz = 'UTC';
88
+ try {
89
+ const tenant = await getTenant(userId, ctx._config || {});
90
+ if (tenant.memory) {
91
+ const hits = await tenant.memory.search('timezone', { limit: 1, threshold: 0.3 });
92
+ for (const h of hits) {
93
+ const match = h.content.match(/(?:timezone|time zone)[:\s]+([A-Za-z_/]+)/i);
94
+ if (match) { tz = match[1]; break; }
95
+ }
96
+ }
97
+ } catch {}
98
+
99
+ draft.timezone = tz;
100
+ setPendingInput(userId, 'time', TIME_TTL_MS);
101
+
102
+ const kb = new InlineKeyboard()
103
+ .text(`Use ${tz}`, 'sched:tz:confirm')
104
+ .text('Change timezone', 'sched:tz:change');
105
+
106
+ const msg = await sendHtml(ctx,
107
+ `When should it ${draft.isRecurring ? 'first fire' : 'fire'}?\n\n` +
108
+ `Examples: \`2026-03-10 14:00\`, \`tomorrow at 9am\`\n` +
109
+ `Timezone: ${tz}`,
110
+ { reply_markup: kb }
111
+ );
112
+ trackMsg(userId, msg.chat.id, msg.message_id);
113
+ }
114
+
115
+ async function stepCron(ctx, userId) {
116
+ const draft = schedDrafts.get(userId);
117
+ if (!draft || !draft.isRecurring) { stepDescription(ctx, userId); return; }
118
+ draft.step = 'cron';
119
+
120
+ const due = new Date(draft.dueAt);
121
+ const h = due.getUTCHours();
122
+ const m = due.getUTCMinutes();
123
+
124
+ const kb = new InlineKeyboard()
125
+ .text('Every hour', `sched:cron:hourly`)
126
+ .text('Every day', `sched:cron:daily`).row()
127
+ .text('Every weekday', `sched:cron:weekday`)
128
+ .text('Every week', `sched:cron:weekly`).row()
129
+ .text('Every month', `sched:cron:monthly`)
130
+ .text('Custom cron', `sched:cron:custom`);
131
+
132
+ const msg = await ctx.reply('How often should it repeat?', { reply_markup: kb });
133
+ trackMsg(userId, msg.chat.id, msg.message_id);
134
+ }
135
+
136
+ async function stepLimits(ctx, userId) {
137
+ const draft = schedDrafts.get(userId);
138
+ if (!draft || !draft.isRecurring) { stepDescription(ctx, userId); return; }
139
+ draft.step = 'limits';
140
+
141
+ const kb = new InlineKeyboard()
142
+ .text('Max 10 runs', 'sched:maxruns:10')
143
+ .text('Max 50 runs', 'sched:maxruns:50').row()
144
+ .text('No limit', 'sched:maxruns:0')
145
+ .text('Skip', 'sched:limits:skip');
146
+
147
+ const msg = await ctx.reply('Set limits? (optional)', { reply_markup: kb });
148
+ trackMsg(userId, msg.chat.id, msg.message_id);
149
+ }
150
+
151
+ async function stepDescription(ctx, userId) {
152
+ const draft = schedDrafts.get(userId);
153
+ if (!draft) return;
154
+ draft.step = 'description';
155
+
156
+ setPendingInput(userId, 'description', DESC_TTL_MS);
157
+ const kb = new InlineKeyboard().text('Skip', 'sched:desc:skip');
158
+ const msg = await ctx.reply('Add a description? (optional)\nType one, or tap Skip.', { reply_markup: kb });
159
+ trackMsg(userId, msg.chat.id, msg.message_id);
160
+ }
161
+
162
+ async function stepInstructions(ctx, userId) {
163
+ const draft = schedDrafts.get(userId);
164
+ if (!draft || !draft.isAgentic) { stepReview(ctx, userId); return; }
165
+ draft.step = 'instructions';
166
+
167
+ setPendingInput(userId, 'instructions', INSTR_TTL_MS);
168
+ const msg = await sendHtml(ctx,
169
+ 'What should the bot do when this event fires?\n\n' +
170
+ 'Be specific — this runs as an LLM task. Examples:\n' +
171
+ '• "Check my email and summarize unread messages"\n' +
172
+ '• "Fetch the weather for Brussels and send a morning briefing"\n' +
173
+ '• "Check Bitcoin price and alert me if above $100k"'
174
+ );
175
+ trackMsg(userId, msg.chat.id, msg.message_id);
176
+ }
177
+
178
+ async function stepReview(ctx, userId) {
179
+ const draft = schedDrafts.get(userId);
180
+ if (!draft) return;
181
+ draft.step = 'review';
182
+ cancelPending(userId);
183
+
184
+ const typeLabel = draft.isRecurring && draft.isAgentic ? 'Recurring agentic task'
185
+ : draft.isRecurring ? 'Recurring reminder'
186
+ : draft.isAgentic ? 'Agentic task'
187
+ : 'One-time reminder';
188
+
189
+ const tz = draft.timezone || 'UTC';
190
+ const dueLocal = new Date(draft.dueAt).toLocaleString('en-US', {
191
+ timeZone: tz, dateStyle: 'medium', timeStyle: 'short',
192
+ });
193
+
194
+ let text = `<b>Title:</b> ${escHtml(draft.title)}\n` +
195
+ `<b>Type:</b> ${typeLabel}\n` +
196
+ `<b>Fire:</b> ${dueLocal} (${tz})`;
197
+
198
+ if (draft.cronExpr) {
199
+ text += `\n<b>Repeat:</b> ${escHtml(draft.cronExpr)}`;
200
+ if (draft.maxRuns) text += ` (max ${draft.maxRuns} runs)`;
201
+ }
202
+ if (draft.description) text += `\n<b>Description:</b> ${escHtml(draft.description)}`;
203
+ if (draft.instructions) text += `\n<b>Instructions:</b> ${escHtml(draft.instructions.substring(0, 200))}${draft.instructions.length > 200 ? '...' : ''}`;
204
+
205
+ const kb = new InlineKeyboard()
206
+ .text('Confirm', 'sched:confirm')
207
+ .text('Cancel', 'sched:cancel').row()
208
+ .text('Edit title', 'sched:edit:title')
209
+ .text('Edit time', 'sched:edit:time');
210
+
211
+ if (draft.isRecurring) kb.text('Edit schedule', 'sched:edit:cron');
212
+ kb.row();
213
+ if (draft.description) kb.text('Edit description', 'sched:edit:desc');
214
+ if (draft.isAgentic) kb.text('Edit instructions', 'sched:edit:instr');
215
+
216
+ const msg = await ctx.api.sendMessage(ctx.chat?.id || ctx.from.id, text, {
217
+ parse_mode: 'HTML',
218
+ reply_markup: kb,
219
+ });
220
+ trackMsg(userId, msg.chat.id, msg.message_id);
221
+ }
222
+
223
+ async function confirmAndCreate(ctx, userId, { getTenant, config, bot }) {
224
+ const draft = schedDrafts.get(userId);
225
+ if (!draft) return;
226
+
227
+ const tenant = await getTenant(userId, config);
228
+ if (!tenant.scheduler) {
229
+ await ctx.api.sendMessage(ctx.chat?.id || userId, 'Scheduler not configured.');
230
+ await clearSchedFlow(userId, bot);
231
+ return;
232
+ }
233
+
234
+ try {
235
+ await tenant.scheduler.add(
236
+ ctx.chat?.id || userId,
237
+ draft.title,
238
+ draft.dueAt,
239
+ draft.timezone || 'UTC',
240
+ draft.description || null,
241
+ draft.cronExpr || null,
242
+ draft.maxRuns || null,
243
+ null,
244
+ draft.instructions || null
245
+ );
246
+ await ctx.api.sendMessage(ctx.chat?.id || userId, `Scheduled: ${draft.title}`);
247
+ } catch (e) {
248
+ await ctx.api.sendMessage(ctx.chat?.id || userId, `Failed to create event: ${e.message}`);
249
+ }
250
+
251
+ await clearSchedFlow(userId, bot);
252
+ }
253
+
254
+ function parseDateTime(input, timezone) {
255
+ const now = new Date();
256
+ const lower = input.trim().toLowerCase();
257
+
258
+ const relMatch = lower.match(/^(tomorrow|today)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
259
+ if (relMatch) {
260
+ const [, day, hr, min, ampm] = relMatch;
261
+ let h = parseInt(hr);
262
+ if (ampm?.toLowerCase() === 'pm' && h < 12) h += 12;
263
+ if (ampm?.toLowerCase() === 'am' && h === 12) h = 0;
264
+ const m = min ? parseInt(min) : 0;
265
+
266
+ const tzNow = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
267
+ const date = new Date(tzNow);
268
+ if (day === 'tomorrow') date.setDate(date.getDate() + 1);
269
+ date.setHours(h, m, 0, 0);
270
+
271
+ const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
272
+ return toUTC(iso, timezone);
273
+ }
274
+
275
+ const inMatch = lower.match(/^in\s+(\d+)\s*(min(?:ute)?s?|hours?|h|m)$/i);
276
+ if (inMatch) {
277
+ const [, amt, unit] = inMatch;
278
+ const ms = unit.startsWith('h') ? parseInt(amt) * 3600000 : parseInt(amt) * 60000;
279
+ return new Date(now.getTime() + ms).toISOString();
280
+ }
281
+
282
+ const dtMatch = input.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})/);
283
+ if (dtMatch) {
284
+ const [, y, mo, d, h, mi] = dtMatch;
285
+ const iso = `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}T${h.padStart(2, '0')}:${mi}:00`;
286
+ return toUTC(iso, timezone);
287
+ }
288
+
289
+ const dateOnly = input.match(/(\d{4})-(\d{1,2})-(\d{1,2})$/);
290
+ if (dateOnly) {
291
+ const [, y, mo, d] = dateOnly;
292
+ const iso = `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}T09:00:00`;
293
+ return toUTC(iso, timezone);
294
+ }
295
+
296
+ return null;
297
+ }
298
+
299
+ function buildCronFromPreset(preset, dueAtUtc) {
300
+ const due = new Date(dueAtUtc);
301
+ const h = due.getUTCHours();
302
+ const m = due.getUTCMinutes();
303
+ const dow = due.getUTCDay();
304
+ const dom = due.getUTCDate();
305
+
306
+ switch (preset) {
307
+ case 'hourly': return `${m} * * * *`;
308
+ case 'daily': return `${m} ${h} * * *`;
309
+ case 'weekday': return `${m} ${h} * * 1-5`;
310
+ case 'weekly': return `${m} ${h} * * ${dow}`;
311
+ case 'monthly': return `${m} ${h} ${dom} * *`;
312
+ default: return null;
313
+ }
314
+ }
315
+
316
+ function escHtml(str) {
317
+ return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
318
+ }
319
+
320
+ async function handleSchedCallback(ctx, data, answer, { getTenant, config, bot }) {
321
+ if (!ctx.from) return answer();
322
+ const userId = ctx.from.id;
323
+ const parts = data.split(':');
324
+ const action = parts[1];
325
+ const value = parts.slice(2).join(':');
326
+
327
+ if (action === 'type') {
328
+ const draft = schedDrafts.get(userId);
329
+ if (!draft) return answer({ text: 'Session expired' });
330
+
331
+ draft.isRecurring = value === 'recurring' || value === 'agentic-rec';
332
+ draft.isAgentic = value === 'agentic' || value === 'agentic-rec';
333
+ await answer();
334
+ ctx._config = config;
335
+ await stepTitle(ctx, userId);
336
+ return;
337
+ }
338
+
339
+ if (action === 'tz') {
340
+ const draft = schedDrafts.get(userId);
341
+ if (!draft) return answer({ text: 'Session expired' });
342
+
343
+ if (value === 'confirm') {
344
+ await answer();
345
+ return;
346
+ }
347
+ if (value === 'change') {
348
+ await answer();
349
+ cancelPending(userId);
350
+ setPendingInput(userId, 'timezone', TIME_TTL_MS);
351
+ const msg = await ctx.api.sendMessage(ctx.chat?.id || userId,
352
+ 'Enter your timezone (e.g. Europe/Brussels, America/New_York, Asia/Tokyo):');
353
+ trackMsg(userId, msg.chat.id, msg.message_id);
354
+ return;
355
+ }
356
+ return answer();
357
+ }
358
+
359
+ if (action === 'cron') {
360
+ const draft = schedDrafts.get(userId);
361
+ if (!draft) return answer({ text: 'Session expired' });
362
+
363
+ if (value === 'custom') {
364
+ await answer();
365
+ setPendingInput(userId, 'cron', CRON_TTL_MS);
366
+ const msg = await sendHtml(ctx,
367
+ 'Enter a cron expression (5 fields):\n\n' +
368
+ 'Examples:\n' +
369
+ '`0 9 * * *` — daily at 9am\n' +
370
+ '`*/30 * * * *` — every 30 minutes\n' +
371
+ '`0 9 * * 1-5` — weekdays at 9am',
372
+ );
373
+ trackMsg(userId, msg.chat.id, msg.message_id);
374
+ return;
375
+ }
376
+
377
+ const cron = buildCronFromPreset(value, draft.dueAt);
378
+ if (cron) {
379
+ draft.cronExpr = cron;
380
+ await answer({ text: `Schedule: ${cron}` });
381
+ await stepLimits(ctx, userId);
382
+ } else {
383
+ await answer({ text: 'Unknown preset' });
384
+ }
385
+ return;
386
+ }
387
+
388
+ if (action === 'maxruns') {
389
+ const draft = schedDrafts.get(userId);
390
+ if (!draft) return answer({ text: 'Session expired' });
391
+ draft.maxRuns = parseInt(value) || null;
392
+ await answer();
393
+ await stepDescription(ctx, userId);
394
+ return;
395
+ }
396
+
397
+ if (action === 'limits' && value === 'skip') {
398
+ await answer();
399
+ await stepDescription(ctx, userId);
400
+ return;
401
+ }
402
+
403
+ if (action === 'desc' && value === 'skip') {
404
+ const draft = schedDrafts.get(userId);
405
+ if (!draft) return answer({ text: 'Session expired' });
406
+ cancelPending(userId);
407
+ await answer();
408
+ if (draft.isAgentic) {
409
+ await stepInstructions(ctx, userId);
410
+ } else {
411
+ await stepReview(ctx, userId);
412
+ }
413
+ return;
414
+ }
415
+
416
+ if (action === 'confirm') {
417
+ await answer({ text: 'Creating event...' });
418
+ await confirmAndCreate(ctx, userId, { getTenant, config, bot });
419
+ return;
420
+ }
421
+
422
+ if (action === 'cancel') {
423
+ await answer({ text: 'Cancelled' });
424
+ await clearSchedFlow(userId, bot);
425
+ return;
426
+ }
427
+
428
+ if (action === 'edit') {
429
+ const draft = schedDrafts.get(userId);
430
+ if (!draft) return answer({ text: 'Session expired' });
431
+ await answer();
432
+
433
+ switch (value) {
434
+ case 'title': await stepTitle(ctx, userId); break;
435
+ case 'time': ctx._config = config; await stepTime(ctx, userId); break;
436
+ case 'cron': await stepCron(ctx, userId); break;
437
+ case 'desc': await stepDescription(ctx, userId); break;
438
+ case 'instr': await stepInstructions(ctx, userId); break;
439
+ default: break;
440
+ }
441
+ return;
442
+ }
443
+
444
+ return answer();
445
+ }
446
+
447
+ async function handleSchedText(ctx, text, { getTenant, config, bot }) {
448
+ const userId = ctx.from.id;
449
+ const pending = pendingSchedInput.get(userId);
450
+ if (!pending) return;
451
+ const { field } = pending;
452
+ cancelPending(userId);
453
+
454
+ const draft = schedDrafts.get(userId);
455
+ if (!draft) return;
456
+
457
+ trackMsg(userId, ctx.chat.id, ctx.message.message_id);
458
+
459
+ if (field === 'title') {
460
+ draft.title = text.substring(0, 200);
461
+ ctx._config = config;
462
+ await stepTime(ctx, userId);
463
+ return;
464
+ }
465
+
466
+ if (field === 'timezone') {
467
+ try {
468
+ Intl.DateTimeFormat(undefined, { timeZone: text.trim() });
469
+ draft.timezone = text.trim();
470
+ const msg = await ctx.reply(`Timezone set to ${draft.timezone}. Now enter the date/time.`);
471
+ trackMsg(userId, msg.chat.id, msg.message_id);
472
+ setPendingInput(userId, 'time', TIME_TTL_MS);
473
+ } catch {
474
+ const msg = await ctx.reply('Invalid timezone. Try again (e.g. Europe/Brussels, America/New_York):');
475
+ trackMsg(userId, msg.chat.id, msg.message_id);
476
+ setPendingInput(userId, 'timezone', TIME_TTL_MS);
477
+ }
478
+ return;
479
+ }
480
+
481
+ if (field === 'time') {
482
+ const tz = draft.timezone || 'UTC';
483
+ const parsed = parseDateTime(text, tz);
484
+ if (!parsed) {
485
+ const msg = await sendHtml(ctx, 'Could not parse that date/time. Try:\n`2026-03-10 14:00` or `tomorrow at 9am`');
486
+ trackMsg(userId, msg.chat.id, msg.message_id);
487
+ setPendingInput(userId, 'time', TIME_TTL_MS);
488
+ return;
489
+ }
490
+
491
+ const parsedDate = new Date(parsed);
492
+ if (parsedDate <= new Date()) {
493
+ const msg = await ctx.reply('That time is in the past. Enter a future date/time:');
494
+ trackMsg(userId, msg.chat.id, msg.message_id);
495
+ setPendingInput(userId, 'time', TIME_TTL_MS);
496
+ return;
497
+ }
498
+
499
+ draft.dueAt = parsed;
500
+ if (draft.isRecurring) {
501
+ await stepCron(ctx, userId);
502
+ } else {
503
+ await stepDescription(ctx, userId);
504
+ }
505
+ return;
506
+ }
507
+
508
+ if (field === 'cron') {
509
+ try {
510
+ CronExpressionParser.parse(text.trim());
511
+ draft.cronExpr = text.trim();
512
+ await stepLimits(ctx, userId);
513
+ } catch {
514
+ const msg = await sendHtml(ctx, 'Invalid cron expression. Try again:\n`0 9 * * *` — daily at 9am');
515
+ trackMsg(userId, msg.chat.id, msg.message_id);
516
+ setPendingInput(userId, 'cron', CRON_TTL_MS);
517
+ }
518
+ return;
519
+ }
520
+
521
+ if (field === 'description') {
522
+ draft.description = text.substring(0, 500);
523
+ if (draft.isAgentic) {
524
+ await stepInstructions(ctx, userId);
525
+ } else {
526
+ await stepReview(ctx, userId);
527
+ }
528
+ return;
529
+ }
530
+
531
+ if (field === 'instructions') {
532
+ draft.instructions = text.substring(0, 2000);
533
+ await stepReview(ctx, userId);
534
+ return;
535
+ }
536
+ }
537
+
538
+ module.exports = {
539
+ startWizard,
540
+ handleSchedCallback,
541
+ isPendingSchedInput,
542
+ handleSchedText,
543
+ };