@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.
- package/config/memory-instructions.md +86 -0
- package/dist/db/identity-sync.js +33 -1
- package/dist/db/schema.js +4 -0
- package/dist/gateway/commands.js +11 -0
- package/dist/memory/digest-flag.js +15 -9
- package/dist/memory/preamble.js +62 -0
- package/dist/queue/crons.js +16 -10
- package/dist/queue/schedule-list.js +61 -0
- package/dist/queue/time-expr.js +192 -0
- package/dist/queue/worker.js +36 -4
- package/migrations/0008_crons_timezone.sql +1 -0
- package/migrations/meta/0008_snapshot.json +931 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -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.
|
package/dist/db/identity-sync.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/gateway/commands.js
CHANGED
|
@@ -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
|
|
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
|
|
191
|
-
if (!
|
|
201
|
+
const when = parseTimeExpression(timeSpec);
|
|
202
|
+
if (!when)
|
|
192
203
|
return null;
|
|
193
|
-
|
|
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.
|
package/dist/memory/preamble.js
CHANGED
|
@@ -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);
|
package/dist/queue/crons.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|