@c4t4/heyamigo 0.9.20 → 0.9.22

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.
@@ -227,3 +227,89 @@ A shared Chrome runs on the server at `localhost:9222` with the owner's real ses
227
227
  **Never call `browser_*` / `mcp__*playwright*` tools inline.** All browser work goes via `[ASYNC-BROWSER:...]`. See the two-track section above.
228
228
 
229
229
  To send a screenshot back: the browser worker takes it (saving to `storage/outbox/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
230
+
231
+ ## Scheduling: reminders and recurring crons
232
+
233
+ The bot has a built-in scheduler. When the user asks for any future or recurring action, you MUST emit a marker at the END of your reply — saying "I'll remind you" without a marker creates no schedule and the user gets nothing.
234
+
235
+ The current local time is shown at the top of every chat preamble in the SENDER's timezone. Use it when interpreting "at 10:30am" / "tomorrow morning" / etc.
236
+
237
+ ### One-shot reminders — ONE canonical format
238
+
239
+ ```
240
+ [REMIND: YYYY-MM-DD HH:MM — <text the user will receive>]
241
+ ```
242
+
243
+ The time is always in the SENDER's timezone (shown in your preamble each
244
+ turn as "Current local time"). YOU translate the user's natural-language
245
+ date/time into the ISO form. Never pass through their raw phrasing.
246
+
247
+ Translation table (assume current time = `2026-05-25 11:25` BA tz):
248
+
249
+ | User says | You emit |
250
+ |---|---|
251
+ | `in 30 minutes` | `[REMIND: 2026-05-25 11:55 — ...]` |
252
+ | `in 3 hours` | `[REMIND: 2026-05-25 14:25 — ...]` |
253
+ | `tomorrow` | `[REMIND: 2026-05-26 09:00 — ...]` |
254
+ | `tomorrow morning` | `[REMIND: 2026-05-26 09:00 — ...]` |
255
+ | `tomorrow at 9am` | `[REMIND: 2026-05-26 09:00 — ...]` |
256
+ | `at 10:30am` | `[REMIND: 2026-05-26 10:30 — ...]` (past today → rolls to tomorrow) |
257
+ | `at 2pm` | `[REMIND: 2026-05-25 14:00 — ...]` (still future today) |
258
+ | `20.10` / `20/10` | `[REMIND: 2026-10-20 09:00 — ...]` |
259
+ | `20.10 at 14:00` | `[REMIND: 2026-10-20 14:00 — ...]` |
260
+ | `october 20 at 2pm` | `[REMIND: 2026-10-20 14:00 — ...]` |
261
+ | `next monday` | `[REMIND: 2026-06-01 09:00 — ...]` |
262
+ | `monday morning` | `[REMIND: 2026-06-01 09:00 — ...]` |
263
+ | `december 25` | `[REMIND: 2026-12-25 09:00 — ...]` |
264
+ | `next week` | `[REMIND: 2026-06-01 11:25 — ...]` (+7 days, same time) |
265
+ | `in a couple hours` | `[REMIND: 2026-05-25 13:25 — ...]` (interpret as 2h) |
266
+
267
+ **Defaults when fields are missing:**
268
+ - No time given → 09:00 sender-tz
269
+ - No date given (just a time) → today, roll to tomorrow if past
270
+ - No year given → current year, roll to next year if past
271
+
272
+ Examples in actual reply text:
273
+
274
+ ```
275
+ [REMIND: 2026-05-25 11:55 — take the chicken out of the oven]
276
+ [REMIND: 2026-05-26 09:00 — gym]
277
+ [REMIND: 2026-06-01 09:00 — weekly planning]
278
+ ```
279
+
280
+ Do NOT use `in 30m`, `tomorrow at 9am`, or other shorthands. The parser
281
+ accepts them as a fallback but the ISO form is the contract. Single
282
+ format = no ambiguity, no locale concerns, no parser surprises.
283
+
284
+ ### Recurring crons — `[CRON: <recurrence> — <text>]`
285
+
286
+ Recurrence forms:
287
+
288
+ | Form | Example | Meaning |
289
+ |---|---|---|
290
+ | `@every N<unit>` | `@every 1h` / `@every 5m` | Fires repeatedly at that interval. |
291
+ | `@daily HH:MM` | `@daily 09:00` | Every day at that user-local time (24h format). |
292
+ | `@weekly <DOW> HH:MM` | `@weekly mon 09:00` | Every week on that weekday at that time. |
293
+
294
+ Examples:
295
+ ```
296
+ [CRON: @daily 09:00 — morning check-in: what's the focus today?]
297
+ [CRON: @weekly sun 18:00 — weekly review: what worked, what didn't]
298
+ [CRON: @every 2h — hydration reminder]
299
+ ```
300
+
301
+ ### Cross-chat send — `[SEND-TEXT: ...]`
302
+
303
+ Send a text to a DIFFERENT chat than the one you're responding in. Rare; usually owner-only.
304
+
305
+ ```
306
+ [SEND-TEXT: address=wa:dm:5491234567890@s.whatsapp.net body="heads up: just posted"]
307
+ ```
308
+
309
+ ### Rules
310
+
311
+ - Acknowledge the schedule in your CHAT REPLY ("got it, reminding you at 10:30") so the user has immediate feedback. The marker is the side effect; the text is what they see right now.
312
+ - ONE marker per scheduled item. Multiple markers in one reply OK.
313
+ - Times are always in the **sender's timezone**, never the server's. The preamble shows the current local time so you can compute deltas if needed.
314
+ - If parsing fails (malformed marker), the bot logs a warning and the schedule is dropped silently. Stick to the grammars above.
315
+ - To cancel a scheduled item, the user types `/reminders` or `/crons` to see what's pending, then deletes via chat command.
@@ -133,7 +133,7 @@ export function syncIdentitiesFromAccess() {
133
133
  logger.info({ personsUpserted, identitiesUpserted, seeded: seeds.length }, 'identity sync from access.json complete');
134
134
  return { personsUpserted, identitiesUpserted };
135
135
  }
136
- // Lookup helper used by the inbound resolution step (in later phases).
136
+ // Lookup helper used by the inbound resolution step.
137
137
  // Returns null if the address has no matching person — caller decides
138
138
  // whether to auto-create or treat as stranger.
139
139
  export function personIdForAddress(address) {
@@ -145,3 +145,35 @@ export function personIdForAddress(address) {
145
145
  .get();
146
146
  return row?.personId ?? null;
147
147
  }
148
+ // Timezone for a person, falling back to owner timezone when unknown.
149
+ // Used by scheduling code (REMIND/CRON) so absolute times like "at
150
+ // 10:30am" land in the SENDER's local time, not the server's.
151
+ export function getTimezoneForPerson(personId) {
152
+ if (!personId)
153
+ return config.owner.timezone;
154
+ const db = getDb();
155
+ const row = db
156
+ .select({ tz: persons.timezone })
157
+ .from(persons)
158
+ .where(eq(persons.id, personId))
159
+ .get();
160
+ return row?.tz ?? config.owner.timezone;
161
+ }
162
+ // Convenience: resolve sender's address → person → timezone.
163
+ // Address can be a WA jid or a wa:dm:... formatted address.
164
+ export function getTimezoneForAddress(address) {
165
+ const pid = personIdForAddress(address);
166
+ return getTimezoneForPerson(pid);
167
+ }
168
+ // Sender-number → tz (handles the senderNumber field on Job/inbound
169
+ // rows). Builds the wa:dm: address shape internally so we don't have
170
+ // to duplicate the format-from-number logic at every call site.
171
+ export function getTimezoneForSenderNumber(senderNumber) {
172
+ if (!senderNumber)
173
+ return config.owner.timezone;
174
+ const sanitized = senderNumber.replace(/\D/g, '');
175
+ if (!sanitized)
176
+ return config.owner.timezone;
177
+ const address = formatAddress(jidToAddress(`${sanitized}@s.whatsapp.net`));
178
+ return getTimezoneForAddress(address);
179
+ }
package/dist/db/schema.js CHANGED
@@ -115,6 +115,10 @@ export const crons = sqliteTable('crons', {
115
115
  enqueueInto: text('enqueue_into').notNull(), // 'inbound'|'async'|'outbound'|'memory_writes'
116
116
  payload: text('payload').notNull(), // JSON passed to the target queue
117
117
  recurrence: text('recurrence'), // null = one-shot
118
+ // IANA timezone for resolving @daily HH:MM / @weekly DOW HH:MM
119
+ // recurrences. Set to the sender's local tz when an agent emits a
120
+ // [CRON:] tag; nullable for system crons that prefer owner tz.
121
+ timezone: text('timezone'),
118
122
  nextRunAt: integer('next_run_at').notNull(),
119
123
  lastRunAt: integer('last_run_at'),
120
124
  enabled: integer('enabled').notNull().default(1), // SQLite bool = int
@@ -68,5 +68,16 @@ export async function tryCommand(ctx) {
68
68
  await sendText(ctx.sock, ctx.jid, formatQueuesSnapshot(snap), ctx.quoted);
69
69
  return true;
70
70
  }
71
+ if (cmd === 'reminders' || cmd === 'crons') {
72
+ const { listChatSchedules, formatScheduleList } = await import('../queue/schedule-list.js');
73
+ const { formatAddress, jidToAddress } = await import('../db/address.js');
74
+ const { getTimezoneForSenderNumber } = await import('../db/identity-sync.js');
75
+ const chatAddress = formatAddress(jidToAddress(ctx.jid));
76
+ const tz = getTimezoneForSenderNumber(ctx.senderNumber);
77
+ const onlyKind = cmd === 'reminders' ? 'one-shot' : 'recurring';
78
+ const items = listChatSchedules(chatAddress, onlyKind);
79
+ await sendText(ctx.sock, ctx.jid, formatScheduleList(items, tz, onlyKind), ctx.quoted);
80
+ return true;
81
+ }
71
82
  return false;
72
83
  }
@@ -8,6 +8,8 @@
8
8
  // drops it entirely. That bug leaked two real markers into user-facing
9
9
  // replies today (DIGEST ~morning, ASYNC later). This parser closes that
10
10
  // whole class of failure.
11
+ import { logger } from '../logger.js';
12
+ import { parseTimeExpression } from '../queue/time-expr.js';
11
13
  const KINDS = [
12
14
  'DIGEST',
13
15
  'JOURNAL',
@@ -115,16 +117,22 @@ export function extractFlags(reply) {
115
117
  const parsed = parseSendTextPayload(payload);
116
118
  if (parsed)
117
119
  sendTexts.unshift(parsed);
120
+ else
121
+ logger.warn({ payload }, 'SEND-TEXT tag dropped: unparseable payload');
118
122
  }
119
123
  else if (kind === 'CRON') {
120
124
  const parsed = parseCronPayload(payload);
121
125
  if (parsed)
122
126
  crons.unshift(parsed);
127
+ else
128
+ logger.warn({ payload }, 'CRON tag dropped: unparseable payload');
123
129
  }
124
130
  else if (kind === 'REMIND') {
125
131
  const parsed = parseRemindPayload(payload);
126
132
  if (parsed)
127
133
  reminds.unshift(parsed);
134
+ else
135
+ logger.warn({ payload }, 'REMIND tag dropped: unparseable payload');
128
136
  }
129
137
  }
130
138
  return {
@@ -178,7 +186,10 @@ function parseCronPayload(payload) {
178
186
  return null;
179
187
  return { recurrence, body };
180
188
  }
181
- // Parse `in <n><unit> — <body>` payload. Supported units: s,m,h,d.
189
+ // Parse `<time-spec> — <body>` payload. Time spec is anything the
190
+ // TimeExpression parser accepts: `in 30m`, `at 10:30am`, `tomorrow
191
+ // at 9am`, `mon at 9am`, `YYYY-MM-DD HH:MM`. Resolution happens
192
+ // later in the worker using the sender's timezone.
182
193
  function parseRemindPayload(payload) {
183
194
  const sepMatch = payload.match(/\s+[—–-]\s+/);
184
195
  if (!sepMatch || sepMatch.index === undefined)
@@ -187,15 +198,10 @@ function parseRemindPayload(payload) {
187
198
  const body = payload.slice(sepMatch.index + sepMatch[0].length).trim();
188
199
  if (!timeSpec || !body)
189
200
  return null;
190
- const m = timeSpec.match(/^in\s+(\d+)\s*([smhd])$/i);
191
- if (!m)
201
+ const when = parseTimeExpression(timeSpec);
202
+ if (!when)
192
203
  return null;
193
- const n = parseInt(m[1], 10);
194
- const unit = m[2].toLowerCase();
195
- const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
196
- if (n <= 0)
197
- return null;
198
- return { whenSecondsFromNow: n * mult, body };
204
+ return { when, body };
199
205
  }
200
206
  // Parse `address=<addr> body="..."` style key=value payload.
201
207
  // Body is delimited by double quotes; everything else by whitespace.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { config } from '../config.js';
4
+ import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
4
5
  import { listAsyncTasks } from '../queue/async-tasks.js';
5
6
  import { readCompressed } from './compressed.js';
6
7
  import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
@@ -11,6 +12,50 @@ import { getRoleForContext } from '../wa/whitelist.js';
11
12
  const DIGEST_REMINDER = `When something worth remembering happens (new preference, key fact, life event, changed plan), append [DIGEST: <one-line reason>] to the END of your reply. It will be stripped before sending. Flag sparingly.`;
12
13
  const JOURNAL_REMINDER = `When a message contains info for one of the journals above, append [JOURNAL:<slug> — <one-line note>] to the END of your reply. Multiple tags OK. Only use slugs listed; never invent. Full rules are in your memory instructions.`;
13
14
  const ASYNC_REMINDER = `TWO TRACKS run in parallel: you are the chat track, a separate browser track runs a persistent Claude session dedicated to the shared Chrome at localhost:9222. Never call browser tools (browser_*, mcp__*playwright*) yourself — delegate via [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply, plus a short ack ("On it, will report back."). For non-browser long work (>30s, multi-step reasoning) use [ASYNC: ...]. Irreversible actions (DM send, post, purchase) split into gather→confirm→act phases — never send on your own judgment.`;
15
+ // Buildable per-turn so the agent always sees the SENDER's current
16
+ // time and timezone — not the server's. Critical for resolving
17
+ // "today at 10:30am" / "tomorrow morning" relative to the user.
18
+ function buildSchedulingReminder(nowLocal, tz) {
19
+ return [
20
+ `SCHEDULING (reminders and recurring schedules):`,
21
+ `Current local time for THIS sender: ${nowLocal} (${tz}).`,
22
+ `Saying "I'll remind you" is NOT enough — you must emit a tag.`,
23
+ `Without the tag, NO schedule is created and the user gets nothing.`,
24
+ ``,
25
+ `### One-shot reminder — ONE canonical format, always:`,
26
+ ` [REMIND: YYYY-MM-DD HH:MM — <text the user will receive>]`,
27
+ ``,
28
+ `The time is in the sender's timezone (${tz}). YOU compute the`,
29
+ `absolute date and time from the user's natural language using the`,
30
+ `CURRENT LOCAL TIME above. NEVER pass the user's raw phrasing to`,
31
+ `the tag.`,
32
+ ``,
33
+ `Translation examples (current time = ${nowLocal}):`,
34
+ ` user: "in 30 minutes" → [REMIND: <now + 30m> — <text>]`,
35
+ ` user: "in 3 hours" → [REMIND: <now + 3h> — <text>]`,
36
+ ` user: "tomorrow" → [REMIND: <tomorrow> 09:00 — <text>]`,
37
+ ` user: "tomorrow morning" → [REMIND: <tomorrow> 09:00 — <text>]`,
38
+ ` user: "at 10:30am" → [REMIND: <today or tomorrow if past> 10:30 — <text>]`,
39
+ ` user: "20.10" / "20/10" → [REMIND: <yyyy>-10-20 09:00 — <text>]`,
40
+ ` user: "october 20 at 2pm" → [REMIND: <yyyy>-10-20 14:00 — <text>]`,
41
+ ` user: "next monday" → [REMIND: <next-mon> 09:00 — <text>]`,
42
+ ` user: "december 25" → [REMIND: <yyyy>-12-25 09:00 — <text>]`,
43
+ ``,
44
+ `Default time when user omits one: 09:00 sender-tz.`,
45
+ `Default date when user only gives a time: today (roll to tomorrow if past).`,
46
+ `Year: current year if the resulting date is in the future, else next year.`,
47
+ ``,
48
+ `### Recurring schedule — same shape, recurrence is symbolic:`,
49
+ ` [CRON: @daily HH:MM — <text>] (HH:MM in sender's tz)`,
50
+ ` [CRON: @every N(s|m|h|d) — <text>] (every N units)`,
51
+ ` [CRON: @weekly <DOW> HH:MM — <text>] (DOW = mon..sun)`,
52
+ ``,
53
+ `### Cross-chat send (rare):`,
54
+ ` [SEND-TEXT: address=wa:dm:1234567890@s.whatsapp.net body="..."]`,
55
+ ``,
56
+ `Acknowledge the schedule in your chat reply ("got it, reminding you tomorrow at 9am") and emit the tag at the END. Reply text is what the user sees right now; the tag is the side effect.`,
57
+ ].join('\n');
58
+ }
14
59
  function buildCriticalSection(params) {
15
60
  const { senderNumber, roleName, role, userName } = params;
16
61
  const who = userName
@@ -131,6 +176,23 @@ export function buildMemoryPreamble(params) {
131
176
  sections.push(`[Journals: active]\n${journalsBlock}`);
132
177
  instructions.push(JOURNAL_REMINDER);
133
178
  }
179
+ // Scheduling reminder — tells the agent the current local time in
180
+ // the SENDER's timezone + lists the REMIND/CRON/SEND-TEXT grammar.
181
+ // Without this the agent never emits the tag and reminders silently
182
+ // never fire (was the root-cause of the May 2026 reminders-not-
183
+ // working bug).
184
+ const senderTz = getTimezoneForSenderNumber(params.senderNumber);
185
+ const nowLocal = new Intl.DateTimeFormat('en-GB', {
186
+ timeZone: senderTz,
187
+ weekday: 'short',
188
+ year: 'numeric',
189
+ month: 'short',
190
+ day: '2-digit',
191
+ hour: '2-digit',
192
+ minute: '2-digit',
193
+ hour12: false,
194
+ }).format(new Date());
195
+ instructions.push(buildSchedulingReminder(nowLocal, senderTz));
134
196
  // Async tasks in progress for this chat — so Claude doesn't re-promise or
135
197
  // contradict work already running in the background.
136
198
  const asyncTasks = listAsyncTasks(params.jid);
@@ -23,6 +23,7 @@ export function enqueueCron(input) {
23
23
  const db = getDb();
24
24
  const now = Math.floor(Date.now() / 1000);
25
25
  const enabled = (input.enabled ?? true) ? 1 : 0;
26
+ const tz = input.timezone ?? config.owner.timezone;
26
27
  if (input.recurrence) {
27
28
  const existing = db
28
29
  .select()
@@ -36,6 +37,7 @@ export function enqueueCron(input) {
36
37
  enqueueInto: input.enqueueInto,
37
38
  payload: JSON.stringify(input.payload),
38
39
  recurrence: input.recurrence,
40
+ timezone: tz,
39
41
  enabled,
40
42
  })
41
43
  .where(eq(crons.name, input.name))
@@ -44,7 +46,7 @@ export function enqueueCron(input) {
44
46
  return updated;
45
47
  }
46
48
  }
47
- const firstRunAt = input.firstRunAt ?? computeNextRun(input.recurrence, now);
49
+ const firstRunAt = input.firstRunAt ?? computeNextRun(input.recurrence, now, tz);
48
50
  if (firstRunAt === null) {
49
51
  throw new Error(`enqueueCron(${input.name}): one-shot requires firstRunAt`);
50
52
  }
@@ -55,6 +57,7 @@ export function enqueueCron(input) {
55
57
  enqueueInto: input.enqueueInto,
56
58
  payload: JSON.stringify(input.payload),
57
59
  recurrence: input.recurrence,
60
+ timezone: tz,
58
61
  nextRunAt: firstRunAt,
59
62
  lastRunAt: null,
60
63
  enabled,
@@ -74,7 +77,8 @@ export function listDueCrons(asOf = Math.floor(Date.now() / 1000)) {
74
77
  }
75
78
  // Called by orchestrator after the cron's payload has been enqueued
76
79
  // into its target queue. Recurring crons get nextRunAt advanced;
77
- // one-shots get deleted.
80
+ // one-shots get deleted. Uses the row's stored timezone for @daily /
81
+ // @weekly resolution; falls back to owner tz for legacy rows.
78
82
  export function markCronFired(row) {
79
83
  const db = getDb();
80
84
  const now = Math.floor(Date.now() / 1000);
@@ -82,7 +86,8 @@ export function markCronFired(row) {
82
86
  db.delete(crons).where(eq(crons.id, row.id)).run();
83
87
  return;
84
88
  }
85
- const next = computeNextRun(row.recurrence, now);
89
+ const tz = row.timezone ?? config.owner.timezone;
90
+ const next = computeNextRun(row.recurrence, now, tz);
86
91
  if (next === null) {
87
92
  logger.error({ id: row.id, name: row.name, recurrence: row.recurrence }, 'cron has unparseable recurrence after firing; disabling');
88
93
  db.update(crons)
@@ -128,8 +133,10 @@ const DOW_INDEX = {
128
133
  sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
129
134
  };
130
135
  // Returns the next-run timestamp (unix seconds) for a recurrence, or
131
- // null for an unparseable format.
132
- export function computeNextRun(recurrence, nowSec) {
136
+ // null for an unparseable format. tz defaults to owner timezone but
137
+ // per-user crons should pass the sender's local tz so @daily HH:MM
138
+ // fires in the user's wall-clock time, not the server's.
139
+ export function computeNextRun(recurrence, nowSec, tz = config.owner.timezone) {
133
140
  if (!recurrence)
134
141
  return null;
135
142
  const everyMatch = EVERY_RE.exec(recurrence);
@@ -142,20 +149,19 @@ export function computeNextRun(recurrence, nowSec) {
142
149
  }
143
150
  const dailyMatch = DAILY_RE.exec(recurrence);
144
151
  if (dailyMatch) {
145
- return nextLocalHourMinute(nowSec, parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), null);
152
+ return nextLocalHourMinute(nowSec, parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), null, tz);
146
153
  }
147
154
  const weeklyMatch = WEEKLY_RE.exec(recurrence);
148
155
  if (weeklyMatch) {
149
156
  const dow = DOW_INDEX[weeklyMatch[1].toLowerCase()];
150
- return nextLocalHourMinute(nowSec, parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), dow);
157
+ return nextLocalHourMinute(nowSec, parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), dow, tz);
151
158
  }
152
159
  return null;
153
160
  }
154
- // Compute the next unix-seconds at HH:MM in the owner timezone,
161
+ // Compute the next unix-seconds at HH:MM in the given timezone,
155
162
  // optionally constrained to a day-of-week. Always returns a moment
156
163
  // strictly in the future (> nowSec).
157
- function nextLocalHourMinute(nowSec, hour, minute, dayOfWeek) {
158
- const tz = config.owner.timezone;
164
+ function nextLocalHourMinute(nowSec, hour, minute, dayOfWeek, tz) {
159
165
  const fmt = new Intl.DateTimeFormat('en-CA', {
160
166
  timeZone: tz,
161
167
  year: 'numeric', month: '2-digit', day: '2-digit',
@@ -0,0 +1,61 @@
1
+ // Helpers for the /reminders and /crons chat commands. Lists pending
2
+ // cron rows that target a particular chat address, formatted for
3
+ // reading on a phone.
4
+ import { and, asc, eq, like } from 'drizzle-orm';
5
+ import { getDb } from '../db/index.js';
6
+ import { crons } from '../db/schema.js';
7
+ import { formatLocalTime } from './time-expr.js';
8
+ // Pull crons whose payload targets the given chat address. We tag
9
+ // chat-emitted crons with names prefixed by 'chat-cron-' (recurring)
10
+ // or 'chat-remind-' (one-shot), so a LIKE filter on the JSON address
11
+ // is the simplest way to scope per chat.
12
+ export function listChatSchedules(chatAddress, kind) {
13
+ const db = getDb();
14
+ // SQLite has no JSON containment operator that's portable; use a
15
+ // simple substring search on the serialized payload. Payloads
16
+ // include `"address":"<addr>"` which is unique enough.
17
+ const addressNeedle = `%"address":"${chatAddress.replace(/"/g, '\\"')}"%`;
18
+ const rows = db
19
+ .select()
20
+ .from(crons)
21
+ .where(and(eq(crons.enabled, 1), like(crons.payload, addressNeedle)))
22
+ .orderBy(asc(crons.nextRunAt))
23
+ .all();
24
+ return rows
25
+ .filter((r) => kind === 'one-shot' ? r.recurrence === null : r.recurrence !== null)
26
+ .map((r) => ({
27
+ id: r.id,
28
+ name: r.name,
29
+ recurrence: r.recurrence,
30
+ nextRunAt: r.nextRunAt,
31
+ bodyPreview: extractBodyPreview(r.payload),
32
+ }));
33
+ }
34
+ function extractBodyPreview(payload) {
35
+ try {
36
+ const obj = JSON.parse(payload);
37
+ const t = obj.text ?? '';
38
+ return t.length > 80 ? t.slice(0, 77) + '...' : t;
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ export function formatScheduleList(items, tz, kind) {
45
+ if (items.length === 0) {
46
+ return kind === 'one-shot'
47
+ ? 'No reminders pending for this chat.'
48
+ : 'No recurring schedules for this chat.';
49
+ }
50
+ const header = kind === 'one-shot' ? '*Pending reminders*' : '*Recurring schedules*';
51
+ const lines = [header];
52
+ for (const item of items) {
53
+ const when = formatLocalTime(item.nextRunAt, tz);
54
+ const tail = item.recurrence ? ` · ${item.recurrence}` : '';
55
+ lines.push(` ${when}${tail}`);
56
+ if (item.bodyPreview)
57
+ lines.push(` "${item.bodyPreview}"`);
58
+ }
59
+ lines.push(`Timezone: ${tz}`);
60
+ return lines.join('\n');
61
+ }
@@ -0,0 +1,192 @@
1
+ // Time expression parsing and resolution.
2
+ //
3
+ // Split into two stages so timezone application happens at resolve
4
+ // time, not parse time. The parser doesn't know whose timezone to
5
+ // use — it just produces a structured TimeExpression. The worker
6
+ // looks up the sender's timezone and calls resolveTimeExpression()
7
+ // to get a unix-second timestamp.
8
+ //
9
+ // Grammars supported (case-insensitive, trimmed):
10
+ // in <n>(s|m|h|d) relative
11
+ // in <n> second(s)|minute(s)|hour(s)|day(s)
12
+ // at <H>(:MM)?(am|pm)? today, user-tz
13
+ // tomorrow at <H>(:MM)?(am|pm)?
14
+ // <mon|tue|wed|thu|fri|sat|sun> at <H>(:MM)?(am|pm)?
15
+ // YYYY-MM-DD HH:MM ISO-style, user-tz
16
+ //
17
+ // Past times today (e.g. user says "at 9am" at 11am) shift to
18
+ // tomorrow. Weekday targets land on the NEXT occurrence of that
19
+ // weekday including today if it's still in the future.
20
+ const DOW_INDEX = {
21
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
22
+ };
23
+ const UNIT_SECONDS = {
24
+ s: 1, second: 1, seconds: 1,
25
+ m: 60, minute: 60, minutes: 60,
26
+ h: 3600, hour: 3600, hours: 3600,
27
+ d: 86400, day: 86400, days: 86400,
28
+ };
29
+ const REL_RE = /^in\s+(\d+)\s*([a-z]+)$/i;
30
+ const TODAY_RE = /^at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
31
+ const TOMORROW_RE = /^tomorrow(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
32
+ const WEEKDAY_RE = /^(mon|tue|wed|thu|fri|sat|sun)(?:day)?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
33
+ const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/;
34
+ export function parseTimeExpression(input) {
35
+ const s = input.trim();
36
+ const m1 = REL_RE.exec(s);
37
+ if (m1) {
38
+ const n = parseInt(m1[1], 10);
39
+ const unit = m1[2].toLowerCase();
40
+ const mult = UNIT_SECONDS[unit];
41
+ if (!mult || n <= 0)
42
+ return null;
43
+ return { kind: 'relative', seconds: n * mult };
44
+ }
45
+ const m2 = TODAY_RE.exec(s);
46
+ if (m2) {
47
+ const hm = parseHourMinute(m2[1], m2[2], m2[3]);
48
+ if (!hm)
49
+ return null;
50
+ return { kind: 'today', hour: hm.hour, minute: hm.minute };
51
+ }
52
+ const m3 = TOMORROW_RE.exec(s);
53
+ if (m3) {
54
+ const hm = parseHourMinute(m3[1], m3[2], m3[3]);
55
+ if (!hm)
56
+ return null;
57
+ return { kind: 'tomorrow', hour: hm.hour, minute: hm.minute };
58
+ }
59
+ const m4 = WEEKDAY_RE.exec(s);
60
+ if (m4) {
61
+ const dow = DOW_INDEX[m4[1].toLowerCase()];
62
+ if (dow === undefined)
63
+ return null;
64
+ const hm = parseHourMinute(m4[2], m4[3], m4[4]);
65
+ if (!hm)
66
+ return null;
67
+ return { kind: 'weekday', dayOfWeek: dow, hour: hm.hour, minute: hm.minute };
68
+ }
69
+ const m5 = ISO_RE.exec(s);
70
+ if (m5) {
71
+ const year = parseInt(m5[1], 10);
72
+ const month = parseInt(m5[2], 10);
73
+ const day = parseInt(m5[3], 10);
74
+ const hour = parseInt(m5[4], 10);
75
+ const minute = parseInt(m5[5], 10);
76
+ if (month < 1 || month > 12 || day < 1 || day > 31)
77
+ return null;
78
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
79
+ return null;
80
+ return { kind: 'iso', year, month, day, hour, minute };
81
+ }
82
+ return null;
83
+ }
84
+ function parseHourMinute(hStr, mStr, ampm) {
85
+ let h = parseInt(hStr, 10);
86
+ const m = mStr ? parseInt(mStr, 10) : 0;
87
+ if (m < 0 || m > 59)
88
+ return null;
89
+ if (ampm) {
90
+ const meridiem = ampm.toLowerCase();
91
+ if (h < 1 || h > 12)
92
+ return null;
93
+ if (meridiem === 'am')
94
+ h = h === 12 ? 0 : h;
95
+ else
96
+ h = h === 12 ? 12 : h + 12;
97
+ }
98
+ else {
99
+ // 24h form when no am/pm
100
+ if (h < 0 || h > 23)
101
+ return null;
102
+ }
103
+ return { hour: h, minute: m };
104
+ }
105
+ // Resolve to absolute unix seconds in the given timezone. Always
106
+ // returns a moment strictly in the future relative to `nowSec`.
107
+ export function resolveTimeExpression(expr, tz, nowSec) {
108
+ if (expr.kind === 'relative') {
109
+ return nowSec + expr.seconds;
110
+ }
111
+ if (expr.kind === 'today') {
112
+ const today = localCalendarDate(nowSec, tz);
113
+ const candidate = makeDateInTz(today.year, today.month, today.day, expr.hour, expr.minute, tz);
114
+ // If already past today, roll to tomorrow.
115
+ return candidate > nowSec
116
+ ? candidate
117
+ : makeDateInTz(today.year, today.month, today.day + 1, expr.hour, expr.minute, tz);
118
+ }
119
+ if (expr.kind === 'tomorrow') {
120
+ const today = localCalendarDate(nowSec, tz);
121
+ return makeDateInTz(today.year, today.month, today.day + 1, expr.hour, expr.minute, tz);
122
+ }
123
+ if (expr.kind === 'weekday') {
124
+ // Walk forward 0..7 days in user-tz until day-of-week matches AND
125
+ // the resulting moment is in the future.
126
+ const today = localCalendarDate(nowSec, tz);
127
+ for (let offset = 0; offset < 8; offset++) {
128
+ const candidate = makeDateInTz(today.year, today.month, today.day + offset, expr.hour, expr.minute, tz);
129
+ const candidateDow = localCalendarDate(candidate, tz).dayOfWeek;
130
+ if (candidateDow === expr.dayOfWeek && candidate > nowSec) {
131
+ return candidate;
132
+ }
133
+ }
134
+ // Shouldn't reach — fallback to a week from now.
135
+ return nowSec + 7 * 86400;
136
+ }
137
+ // ISO
138
+ return makeDateInTz(expr.year, expr.month - 1, expr.day, expr.hour, expr.minute, tz);
139
+ }
140
+ // Build a unix-seconds for a given Y/M/D HH:MM interpreted in a named
141
+ // timezone. Guess-and-correct via Intl.DateTimeFormat — same technique
142
+ // the cron parser uses. Handles DST seamlessly.
143
+ function makeDateInTz(year, month, day, hour, minute, tz) {
144
+ const guessUtcMs = Date.UTC(year, month, day, hour, minute, 0);
145
+ const fmt = new Intl.DateTimeFormat('en-US', {
146
+ timeZone: tz,
147
+ year: 'numeric', month: '2-digit', day: '2-digit',
148
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
149
+ hour12: false,
150
+ });
151
+ const parts = fmt.formatToParts(new Date(guessUtcMs));
152
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '0';
153
+ let parsedHour = parseInt(get('hour'), 10);
154
+ // Intl formats midnight as "24" in some locale/version combos; normalize.
155
+ if (parsedHour === 24)
156
+ parsedHour = 0;
157
+ const renderedUtcMs = Date.UTC(parseInt(get('year'), 10), parseInt(get('month'), 10) - 1, parseInt(get('day'), 10), parsedHour, parseInt(get('minute'), 10), parseInt(get('second'), 10));
158
+ const offsetMs = guessUtcMs - renderedUtcMs;
159
+ return Math.floor((guessUtcMs + offsetMs) / 1000);
160
+ }
161
+ // Decompose a unix-second into Y/M/D and day-of-week in a named
162
+ // timezone. Returns calendar fields as the local user would see
163
+ // them — used to compute "today" anchors before applying HH:MM.
164
+ function localCalendarDate(sec, tz) {
165
+ const fmt = new Intl.DateTimeFormat('en-CA', {
166
+ timeZone: tz,
167
+ year: 'numeric', month: '2-digit', day: '2-digit',
168
+ weekday: 'short',
169
+ });
170
+ const parts = fmt.formatToParts(new Date(sec * 1000));
171
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
172
+ return {
173
+ year: parseInt(get('year'), 10),
174
+ month: parseInt(get('month'), 10) - 1, // 0-indexed for makeDateInTz Date.UTC compat
175
+ day: parseInt(get('day'), 10),
176
+ dayOfWeek: (DOW_INDEX[get('weekday').toLowerCase()] ?? 0),
177
+ };
178
+ }
179
+ // Human-readable formatter used in chat acks. Renders an absolute
180
+ // resolved time back into the user's local time.
181
+ export function formatLocalTime(unixSec, tz) {
182
+ return new Intl.DateTimeFormat('en-GB', {
183
+ timeZone: tz,
184
+ weekday: 'short',
185
+ year: 'numeric',
186
+ month: 'short',
187
+ day: '2-digit',
188
+ hour: '2-digit',
189
+ minute: '2-digit',
190
+ hour12: false,
191
+ }).format(new Date(unixSec * 1000));
192
+ }