@mycroblog/mybot 0.0.0-placeholder.0

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.
@@ -0,0 +1,187 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { Cron } from "croner";
3
+ const fields = [
4
+ { name: "minute", lo: 0, hi: 59 },
5
+ { name: "hour", lo: 0, hi: 23 },
6
+ { name: "day of the month", lo: 1, hi: 31 },
7
+ { name: "month", lo: 1, hi: 12 },
8
+ { name: "day of the week", lo: 0, hi: 7 },
9
+ ];
10
+ /** The most days of each month, February in a leap year. */
11
+ const daysIn = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
12
+ /** How far next and prev look: eight years hold every day of the month and of the week in every combination. */
13
+ const searchSpan = (8 * 366 + 1) * 24 * 3_600_000;
14
+ const digits = /^[0-9]+$/;
15
+ /** The error of parseSchedule and Bot.every for an expression or a time zone that mybot does not take. */
16
+ export class ScheduleError extends Error {
17
+ name = "ScheduleError";
18
+ }
19
+ /** Parses a number of a field, digits alone. */
20
+ function value(text, f) {
21
+ const v = Number(text);
22
+ if (!digits.test(text) || v < f.lo || v > f.hi)
23
+ throw new Error(`the ${f.name} "${text}" is not a number from ${String(f.lo)} to ${String(f.hi)}`);
24
+ return v;
25
+ }
26
+ /** Parses one field into the set of its values, as the Go library does. */
27
+ function parseField(field, f) {
28
+ const set = new Set();
29
+ for (const part of field.split(",")) {
30
+ const slash = part.indexOf("/");
31
+ const range = slash < 0 ? part : part.slice(0, slash);
32
+ const stepText = slash < 0 ? null : part.slice(slash + 1);
33
+ let from = f.lo;
34
+ let to = f.hi;
35
+ if (range === "*") {
36
+ // every value
37
+ }
38
+ else if (range.includes("-")) {
39
+ const dash = range.indexOf("-");
40
+ from = value(range.slice(0, dash), f);
41
+ to = value(range.slice(dash + 1), f);
42
+ if (from > to)
43
+ throw new Error(`the ${f.name} range "${range}" runs backwards`);
44
+ }
45
+ else {
46
+ if (stepText !== null)
47
+ throw new Error(`the ${f.name} step "${part}" needs * or a range before the /`);
48
+ from = to = value(range, f);
49
+ }
50
+ let step = 1;
51
+ if (stepText !== null) {
52
+ const n = Number(stepText);
53
+ const most = f.hi - f.lo + 1;
54
+ if (!digits.test(stepText) || n < 1 || n > most)
55
+ throw new Error(`the ${f.name} step "${stepText}" is not a number from 1 to ${String(most)}`);
56
+ step = n;
57
+ }
58
+ for (let v = from; v <= to; v += step)
59
+ set.add(v);
60
+ }
61
+ return set;
62
+ }
63
+ /** The wall-clock hour and minute of at in timeZone. */
64
+ function wallClock(format, at) {
65
+ let hour = -1;
66
+ let minute = -1;
67
+ for (const p of format.formatToParts(at)) {
68
+ if (p.type === "hour")
69
+ hour = Number(p.value) % 24;
70
+ else if (p.type === "minute")
71
+ minute = Number(p.value);
72
+ }
73
+ return { hour, minute };
74
+ }
75
+ /**
76
+ * A parsed cron expression of five fields, minute hour day-of-month month day-of-week, in a time zone (ADR-0094 §5).
77
+ * Each field is *, a number, a range a-b, a step * /n or a-b/n, or a list of those separated by commas; the day of the
78
+ * week is 0-7, where 0 and 7 are Sunday. When both the day of the month and the day of the week are other than *, a
79
+ * day matches when either does, as in cron. A field of seconds, the aliases such as @daily, names of months and days,
80
+ * and the other extensions of some crons (?, L, W, #) are refused: mybot takes the same expressions as the Go library,
81
+ * and uses croner to compute the times alone.
82
+ *
83
+ * The times of a Schedule are wall-clock times of its time zone: one that a daylight saving time change skips does not
84
+ * happen, and one that the change repeats happens once, at its first occurrence. Times are milliseconds since the Unix
85
+ * epoch.
86
+ */
87
+ export class Schedule {
88
+ /** The expression of the schedule. */
89
+ expr;
90
+ /** The IANA time zone of the schedule, "UTC" by default. */
91
+ timeZone;
92
+ #cron;
93
+ #hours;
94
+ #minutes;
95
+ #format;
96
+ /** Use parseSchedule. */
97
+ constructor(expr, timeZone, hours, minutes) {
98
+ this.expr = expr;
99
+ this.timeZone = timeZone;
100
+ this.#hours = hours;
101
+ this.#minutes = minutes;
102
+ this.#format = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
103
+ // legacyMode: a day matches when the day of the month or the day of the week does, as in cron.
104
+ this.#cron = new Cron(expr, { paused: true, mode: "5-part", timezone: timeZone, legacyMode: true });
105
+ }
106
+ /**
107
+ * Parses the cron expression expr in the IANA time zone timeZone, such as "Asia/Tokyo"; "" or absent is UTC. It
108
+ * throws a ScheduleError for an expression or a time zone it does not take.
109
+ */
110
+ static parse(expr, timeZone = "") {
111
+ const tz = timeZone === "" ? "UTC" : timeZone;
112
+ try {
113
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
114
+ }
115
+ catch {
116
+ throw new ScheduleError(`mybot: the time zone "${timeZone}" is no IANA time zone`);
117
+ }
118
+ if (expr.trim().startsWith("@")) {
119
+ throw new ScheduleError(`mybot: the cron expression "${expr}": aliases such as @daily are not supported: write the five fields`);
120
+ }
121
+ const parts = expr.split(/\s+/).filter((p) => p !== "");
122
+ if (parts.length === 6) {
123
+ throw new ScheduleError(`mybot: the cron expression "${expr}": a field of seconds is not supported: write the five fields minute hour day month weekday`);
124
+ }
125
+ if (parts.length !== 5) {
126
+ throw new ScheduleError(`mybot: the cron expression "${expr}" has ${String(parts.length)} fields, not the five minute hour day month weekday`);
127
+ }
128
+ let sets;
129
+ try {
130
+ sets = parts.map((p, i) => parseField(p, fields[i]));
131
+ }
132
+ catch (error) {
133
+ throw new ScheduleError(`mybot: the cron expression "${expr}": ${error.message}`);
134
+ }
135
+ const [minutes, hours, dom, months] = sets;
136
+ // A day exists unless the day of the month alone decides, and no month has it: "0 0 30 2 *".
137
+ if (parts[2] !== "*" && parts[4] === "*" && ![...months].some((m) => [...dom].some((d) => d <= (daysIn[m] ?? 0)))) {
138
+ throw new ScheduleError(`mybot: the cron expression "${expr}" names no day that exists`);
139
+ }
140
+ try {
141
+ return new Schedule(parts.join(" "), tz, hours, minutes);
142
+ }
143
+ catch (error) {
144
+ throw new ScheduleError(`mybot: the cron expression "${expr}": ${error.message}`);
145
+ }
146
+ }
147
+ /** Whether at is a wall-clock time of the schedule, and not one that croner moved out of a skipped hour. */
148
+ #onTime(at) {
149
+ const { hour, minute } = wallClock(this.#format, at);
150
+ return this.#hours.has(hour) && this.#minutes.has(minute);
151
+ }
152
+ /** The first time of the schedule after at; null when there is none in the next eight years. */
153
+ next(at) {
154
+ const end = at + searchSpan;
155
+ let from = at;
156
+ for (;;) {
157
+ const d = this.#cron.nextRun(new Date(from));
158
+ if (d === null || d.getTime() > end)
159
+ return null;
160
+ const t = d.getTime();
161
+ if (this.#onTime(t))
162
+ return t;
163
+ from = t;
164
+ }
165
+ }
166
+ /** The last time of the schedule at or before at; null when there is none in the eight years before. */
167
+ prev(at) {
168
+ const end = at - searchSpan;
169
+ // previousRuns finds the times before a reference in whole seconds: one second on includes at itself.
170
+ let ref = Math.floor(at / 1000) * 1000 + 1000;
171
+ for (;;) {
172
+ const d = this.#cron.previousRuns(1, new Date(ref))[0];
173
+ if (d === undefined || d.getTime() < end)
174
+ return null;
175
+ const t = d.getTime();
176
+ if (t <= at && this.#onTime(t))
177
+ return t;
178
+ // croner moved a skipped time after the gap, and finds it again from any reference in the gap: step back a
179
+ // minute at least, which passes over no time of the schedule, whose times are whole minutes.
180
+ ref = Math.min(ref - 60_000, t);
181
+ }
182
+ }
183
+ }
184
+ /** Parses the cron expression expr in the IANA time zone timeZone ("" or absent is UTC): Schedule.parse. */
185
+ export function parseSchedule(expr, timeZone = "") {
186
+ return Schedule.parse(expr, timeZone);
187
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Keeps what a bot must remember across restarts (ADR-0094 §6): the notifications it processed, the position of the
3
+ * catch-up, the last runs of the scheduled jobs, and the rotated refresh token. MemoryStore and FileStore are the
4
+ * implementations of the library; implement Store to keep the state elsewhere, such as in a database. One Store
5
+ * serves one bot process: it needs no locking between processes. Times are milliseconds since the Unix epoch.
6
+ */
7
+ export type Store = {
8
+ /** Whether the notification id was recorded by markProcessed (and not forgotten since). */
9
+ isProcessed(id: string): Promise<boolean>;
10
+ /** Records that the notification id was processed at at. */
11
+ markProcessed(id: string, at: number): Promise<void>;
12
+ /** Drops the records of the notifications processed before before. */
13
+ forgetProcessed(before: number): Promise<void>;
14
+ /**
15
+ * The ID of the newest notification up to which every notification was processed, null when there is none yet. The
16
+ * catch-up reads the notifications back to it.
17
+ */
18
+ position(): Promise<string | null>;
19
+ /** Sets the position. */
20
+ setPosition(id: string): Promise<void>;
21
+ /** When the scheduled job name last ran, null when it never did. */
22
+ lastRun(name: string): Promise<number | null>;
23
+ /** Records when the scheduled job name ran. */
24
+ setLastRun(name: string, at: number): Promise<void>;
25
+ /** The refresh token saved by setRefreshToken, null when there is none. */
26
+ refreshToken(): Promise<string | null>;
27
+ /** Saves the refresh token that a renewal returned. The previous one no longer works. */
28
+ setRefreshToken(token: string): Promise<void>;
29
+ };
30
+ /**
31
+ * The state of a MemoryStore and the JSON document of a FileStore: the same document as the FileStore of the Go
32
+ * library, with the times as RFC 3339 strings, so that a bot can move between the two.
33
+ */
34
+ export type StoreState = {
35
+ processed: Map<string, number>;
36
+ position: string | null;
37
+ lastRuns: Map<string, number>;
38
+ refreshToken: string | null;
39
+ };
40
+ /** An empty state. */
41
+ export declare function emptyState(): StoreState;
42
+ /** The state as the JSON document of a FileStore. */
43
+ export declare function encodeState(s: StoreState): string;
44
+ /** Reads the JSON document of a FileStore. */
45
+ export declare function decodeState(text: string): StoreState;
46
+ /** A Store in memory that calls save after every change; the base of MemoryStore and FileStore. */
47
+ export declare abstract class StateStore implements Store {
48
+ #private;
49
+ /** Resolves to the state, once it has been read. */
50
+ protected abstract load(): Promise<StoreState>;
51
+ /** Persists the state after a change. */
52
+ protected abstract save(state: StoreState): Promise<void>;
53
+ isProcessed(id: string): Promise<boolean>;
54
+ markProcessed(id: string, at: number): Promise<void>;
55
+ forgetProcessed(before: number): Promise<void>;
56
+ position(): Promise<string | null>;
57
+ setPosition(id: string): Promise<void>;
58
+ lastRun(name: string): Promise<number | null>;
59
+ setLastRun(name: string, at: number): Promise<void>;
60
+ refreshToken(): Promise<string | null>;
61
+ setRefreshToken(token: string): Promise<void>;
62
+ }
63
+ /**
64
+ * A Store in memory, for tests. Everything is lost when the process ends, the rotated refresh token too: the next
65
+ * start uses the refresh token of the options again, which the server has seen used and so revokes. A bot that runs
66
+ * for real, all the time or started again and again, uses a FileStore or a Store of its own.
67
+ */
68
+ export declare class MemoryStore extends StateStore {
69
+ #private;
70
+ protected load(): Promise<StoreState>;
71
+ protected save(): Promise<void>;
72
+ }
package/dist/store.js ADDED
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /** An empty state. */
3
+ export function emptyState() {
4
+ return { processed: new Map(), position: null, lastRuns: new Map(), refreshToken: null };
5
+ }
6
+ /** The state as the JSON document of a FileStore. */
7
+ export function encodeState(s) {
8
+ const doc = {
9
+ processed: Object.fromEntries([...s.processed].map(([id, at]) => [id, new Date(at).toISOString()])),
10
+ };
11
+ if (s.position !== null && s.position !== "")
12
+ doc["position"] = s.position;
13
+ if (s.lastRuns.size > 0)
14
+ doc["last_runs"] = Object.fromEntries([...s.lastRuns].map(([name, at]) => [name, new Date(at).toISOString()]));
15
+ if (s.refreshToken !== null && s.refreshToken !== "")
16
+ doc["refresh_token"] = s.refreshToken;
17
+ return JSON.stringify(doc, null, 2) + "\n";
18
+ }
19
+ /** Reads a map of RFC 3339 times. */
20
+ function decodeTimes(value, field) {
21
+ const out = new Map();
22
+ if (value === undefined || value === null)
23
+ return out;
24
+ if (typeof value !== "object")
25
+ throw new Error(`mybot: the field ${field} of the state is no object`);
26
+ for (const [k, v] of Object.entries(value)) {
27
+ // The Go library writes nanoseconds, which not every runtime's Date.parse reads: milliseconds are enough.
28
+ const at = typeof v === "string" ? Date.parse(v.replace(/(\.\d{3})\d+/, "$1")) : Number.NaN;
29
+ if (Number.isNaN(at))
30
+ throw new Error(`mybot: the field ${field} of the state has a time that is no RFC 3339 string`);
31
+ out.set(k, at);
32
+ }
33
+ return out;
34
+ }
35
+ /** Reads the JSON document of a FileStore. */
36
+ export function decodeState(text) {
37
+ const doc = JSON.parse(text);
38
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
39
+ throw new Error("mybot: the state is no JSON object");
40
+ const d = doc;
41
+ const str = (v, field) => {
42
+ if (v === undefined || v === null || v === "")
43
+ return null;
44
+ if (typeof v !== "string")
45
+ throw new Error(`mybot: the field ${field} of the state is no string`);
46
+ return v;
47
+ };
48
+ return {
49
+ processed: decodeTimes(d["processed"], "processed"),
50
+ position: str(d["position"], "position"),
51
+ lastRuns: decodeTimes(d["last_runs"], "last_runs"),
52
+ refreshToken: str(d["refresh_token"], "refresh_token"),
53
+ };
54
+ }
55
+ /** A Store in memory that calls save after every change; the base of MemoryStore and FileStore. */
56
+ export class StateStore {
57
+ /** Applies f to the state and saves it. */
58
+ async #change(f) {
59
+ const s = await this.load();
60
+ f(s);
61
+ await this.save(s);
62
+ }
63
+ async isProcessed(id) {
64
+ return (await this.load()).processed.has(id);
65
+ }
66
+ markProcessed(id, at) {
67
+ return this.#change((s) => s.processed.set(id, at));
68
+ }
69
+ async forgetProcessed(before) {
70
+ const s = await this.load();
71
+ if (![...s.processed.values()].some((at) => at < before))
72
+ return;
73
+ await this.#change((st) => {
74
+ for (const [id, at] of st.processed)
75
+ if (at < before)
76
+ st.processed.delete(id);
77
+ });
78
+ }
79
+ async position() {
80
+ return (await this.load()).position;
81
+ }
82
+ setPosition(id) {
83
+ return this.#change((s) => {
84
+ s.position = id;
85
+ });
86
+ }
87
+ async lastRun(name) {
88
+ return (await this.load()).lastRuns.get(name) ?? null;
89
+ }
90
+ setLastRun(name, at) {
91
+ return this.#change((s) => s.lastRuns.set(name, at));
92
+ }
93
+ async refreshToken() {
94
+ return (await this.load()).refreshToken;
95
+ }
96
+ setRefreshToken(token) {
97
+ return this.#change((s) => {
98
+ s.refreshToken = token;
99
+ });
100
+ }
101
+ }
102
+ /**
103
+ * A Store in memory, for tests. Everything is lost when the process ends, the rotated refresh token too: the next
104
+ * start uses the refresh token of the options again, which the server has seen used and so revokes. A bot that runs
105
+ * for real, all the time or started again and again, uses a FileStore or a Store of its own.
106
+ */
107
+ export class MemoryStore extends StateStore {
108
+ #state = emptyState();
109
+ load() {
110
+ return Promise.resolve(this.#state);
111
+ }
112
+ save() {
113
+ return Promise.resolve();
114
+ }
115
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A clock that moves only when the test moves it, for the scheduled jobs (Bot.every), the send budget, and the reply
3
+ * limits. Its now and sleep are the options of the same names of a bot, which Server.options sets. Times are
4
+ * milliseconds since the Unix epoch.
5
+ */
6
+ export declare class Clock {
7
+ #private;
8
+ /** A clock at the time at. */
9
+ constructor(at: number);
10
+ /** The time of the clock: the option now of a bot. */
11
+ readonly now: () => number;
12
+ /**
13
+ * Waits until the clock has moved on by ms, or rejects with the reason of signal when it aborts first: the option
14
+ * sleep of a bot. It resolves at once for ms <= 0.
15
+ */
16
+ readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>;
17
+ /** Moves the clock on by ms and wakes the sleepers whose time came. */
18
+ advance(ms: number): void;
19
+ /** Moves the clock to at, which must not be before now, and wakes the sleepers whose time came. */
20
+ set(at: number): void;
21
+ /** How many sleep on the clock. */
22
+ get sleepers(): number;
23
+ /**
24
+ * Waits until at least n sleep on the clock, such as a bot's scheduled jobs waiting for their next time, so that a
25
+ * following advance wakes them. It rejects when signal aborts first, after 10 seconds by default.
26
+ */
27
+ waitForSleepers(n: number, { signal }?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<void>;
30
+ }
31
+ /** Resolves when cond holds, checked whenever a listener of listeners is called, or rejects when signal aborts. */
32
+ export declare function waitFor(listeners: Set<() => void>, cond: () => boolean, signal: AbortSignal, what: string): Promise<void>;
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * A clock that moves only when the test moves it, for the scheduled jobs (Bot.every), the send budget, and the reply
4
+ * limits. Its now and sleep are the options of the same names of a bot, which Server.options sets. Times are
5
+ * milliseconds since the Unix epoch.
6
+ */
7
+ export class Clock {
8
+ #now;
9
+ #waiters = [];
10
+ #listeners = new Set();
11
+ /** A clock at the time at. */
12
+ constructor(at) {
13
+ this.#now = at;
14
+ }
15
+ /** The time of the clock: the option now of a bot. */
16
+ now = () => this.#now;
17
+ /**
18
+ * Waits until the clock has moved on by ms, or rejects with the reason of signal when it aborts first: the option
19
+ * sleep of a bot. It resolves at once for ms <= 0.
20
+ */
21
+ sleep = (ms, signal) => {
22
+ if (signal.aborted)
23
+ return Promise.reject(signal.reason);
24
+ if (ms <= 0)
25
+ return Promise.resolve();
26
+ return new Promise((resolve, reject) => {
27
+ const onAbort = () => {
28
+ this.#waiters = this.#waiters.filter((w) => w !== waiter);
29
+ this.#notify();
30
+ reject(signal.reason);
31
+ };
32
+ const waiter = {
33
+ until: this.#now + ms,
34
+ resolve: () => {
35
+ signal.removeEventListener("abort", onAbort);
36
+ resolve();
37
+ },
38
+ };
39
+ signal.addEventListener("abort", onAbort, { once: true });
40
+ this.#waiters.push(waiter);
41
+ this.#notify();
42
+ });
43
+ };
44
+ /** Moves the clock on by ms and wakes the sleepers whose time came. */
45
+ advance(ms) {
46
+ this.#set(this.#now + ms);
47
+ }
48
+ /** Moves the clock to at, which must not be before now, and wakes the sleepers whose time came. */
49
+ set(at) {
50
+ if (at < this.#now)
51
+ throw new Error("mybot/testing: Clock.set to a time before now");
52
+ this.#set(at);
53
+ }
54
+ #set(at) {
55
+ this.#now = at;
56
+ const due = this.#waiters.filter((w) => w.until <= at);
57
+ this.#waiters = this.#waiters.filter((w) => w.until > at);
58
+ for (const w of due)
59
+ w.resolve();
60
+ this.#notify();
61
+ }
62
+ #notify() {
63
+ for (const listener of this.#listeners)
64
+ listener();
65
+ }
66
+ /** How many sleep on the clock. */
67
+ get sleepers() {
68
+ return this.#waiters.length;
69
+ }
70
+ /**
71
+ * Waits until at least n sleep on the clock, such as a bot's scheduled jobs waiting for their next time, so that a
72
+ * following advance wakes them. It rejects when signal aborts first, after 10 seconds by default.
73
+ */
74
+ waitForSleepers(n, { signal = AbortSignal.timeout(10_000) } = {}) {
75
+ return waitFor(this.#listeners, () => this.#waiters.length >= n, signal, `${String(n)} sleepers on the clock`);
76
+ }
77
+ }
78
+ /** Resolves when cond holds, checked whenever a listener of listeners is called, or rejects when signal aborts. */
79
+ export function waitFor(listeners, cond, signal, what) {
80
+ if (cond())
81
+ return Promise.resolve();
82
+ return new Promise((resolve, reject) => {
83
+ const done = () => {
84
+ listeners.delete(check);
85
+ signal.removeEventListener("abort", onAbort);
86
+ };
87
+ const check = () => {
88
+ if (!cond())
89
+ return;
90
+ done();
91
+ resolve();
92
+ };
93
+ const onAbort = () => {
94
+ done();
95
+ reject(new Error(`mybot/testing: gave up waiting for ${what}`, { cause: signal.reason }));
96
+ };
97
+ if (signal.aborted) {
98
+ onAbort();
99
+ return;
100
+ }
101
+ listeners.add(check);
102
+ signal.addEventListener("abort", onAbort, { once: true });
103
+ });
104
+ }
@@ -0,0 +1,2 @@
1
+ export { Clock } from "./clock.js";
2
+ export { domain, quietLogger, Server, token, type Post, type Reaction, type ServerOptions, type WaitOptions } from "./server.js";
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @mycroblog/mybot/testing: test bots of @mycroblog/mybot without a server (ADR-0094 §10), as mybottest does for the
3
+ // Go library. Server is a fake server of the client API behind a fetch; the test sends the bot notifications with
4
+ // inject and checks what it posted with posted or waitPosted. Its Clock moves only when the test moves it, so that a
5
+ // test runs the scheduled jobs (Bot.every) at once.
6
+ export { Clock } from "./clock.js";
7
+ export { domain, quietLogger, Server, token } from "./server.js";