@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.
package/dist/bot.js ADDED
@@ -0,0 +1,760 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { all, createClient, ProblemError, RefreshingTokenSource, runStream, } from "@mycroblog/mykit";
3
+ import { Budget, defaultMaxSendWait, defaultPostRate, defaultReplyLimits, defaultWriteRate, ReplyLimiter } from "./budget.js";
4
+ import { answered, Context } from "./context.js";
5
+ import { Dispatcher, senderOf, Tracker } from "./dispatch.js";
6
+ import { defaultStatePath, FileStore } from "./filestore.js";
7
+ import { chain, defaultHelp, defaultMiddleware, parseCommand } from "./handler.js";
8
+ import { Schedule } from "./schedule.js";
9
+ const minute = 60_000;
10
+ const hour = 60 * minute;
11
+ /** The interval of the polling mode: a minute. */
12
+ export const defaultPollInterval = minute;
13
+ /** How old a notification the catch-up still handles: 24 hours. */
14
+ export const defaultCatchUpLimit = 24 * hour;
15
+ /** How long the processed notifications are remembered: 7 days, longer than the catch-up limit. */
16
+ export const defaultProcessedRetention = 7 * 24 * hour;
17
+ /** How many handlers run at once. */
18
+ export const defaultConcurrency = 4;
19
+ /** How long the shutdown waits for the running handlers: 10 seconds. */
20
+ export const defaultShutdownGrace = 10_000;
21
+ /** The longest wait of setTimeout, about 24.8 days: a scheduled job further away sleeps again. */
22
+ const maxTimeout = 2 ** 31 - 1;
23
+ /** The product token of the library in User-Agent. */
24
+ const userAgentProduct = "mybot-ts";
25
+ /** The error of run on an account without the bot flag, unless the setBotFlag or allowNonBot option. */
26
+ export class NotBotError extends Error {
27
+ name = "NotBotError";
28
+ constructor() {
29
+ super("mybot: the account has no bot flag: set it, or set the setBotFlag or allowNonBot option");
30
+ }
31
+ }
32
+ /** The error of a second run of a bot. */
33
+ export class AlreadyRunningError extends Error {
34
+ name = "AlreadyRunningError";
35
+ constructor() {
36
+ super("mybot: the bot runs already");
37
+ }
38
+ }
39
+ /** The error of run when the access token lacks scopes that the bot needs. */
40
+ export class ScopeError extends Error {
41
+ name = "ScopeError";
42
+ /** The scopes the token lacks. */
43
+ missing;
44
+ constructor(missing) {
45
+ super(`mybot: the access token lacks the scopes ${missing.join(" ")}`);
46
+ this.missing = missing;
47
+ }
48
+ }
49
+ /** The scopes under follow. */
50
+ const followScopes = ["read:follows", "read:blocks", "read:mutes", "write:follows", "write:blocks", "write:mutes"];
51
+ /** Whether the scope have gives need: itself, or a broad scope above it (the oauth2 security scheme of api/openapi.yaml). */
52
+ function grants(have, need) {
53
+ if (have === need)
54
+ return true;
55
+ if (have === "follow")
56
+ return followScopes.includes(need);
57
+ return (have === "read" || have === "write") && need.startsWith(`${have}:`);
58
+ }
59
+ /** The scopes of need that granted (space-separated) does not give. */
60
+ export function missingScopes(granted, need) {
61
+ const have = granted.split(/\s+/).filter((s) => s !== "");
62
+ return need.filter((n) => !have.some((h) => grants(h, n)));
63
+ }
64
+ /** Waits for ms, or rejects with the reason of signal when it aborts first. */
65
+ function sleepSignal(ms, signal) {
66
+ return new Promise((resolve, reject) => {
67
+ if (signal.aborted) {
68
+ reject(signal.reason);
69
+ return;
70
+ }
71
+ const onAbort = () => {
72
+ clearTimeout(timer);
73
+ reject(signal.reason);
74
+ };
75
+ const timer = setTimeout(() => {
76
+ signal.removeEventListener("abort", onAbort);
77
+ resolve();
78
+ }, ms);
79
+ signal.addEventListener("abort", onAbort, { once: true });
80
+ });
81
+ }
82
+ /** A positive number, or the default when absent or not positive. */
83
+ function positive(value, fallback) {
84
+ return value !== undefined && value > 0 ? value : fallback;
85
+ }
86
+ /** The rate, or the default when absent or the zero rate. */
87
+ function rateOr(r, fallback) {
88
+ return r === undefined || (r.n === 0 && r.per === 0) ? fallback : r;
89
+ }
90
+ /**
91
+ * A bot: the handlers of its events, and run, which receives the events and hands them to the handlers until it
92
+ * stops. Register the handlers before run.
93
+ */
94
+ export class Bot {
95
+ /** The store of the bot. */
96
+ store;
97
+ #options;
98
+ #settings;
99
+ #handlers = {};
100
+ #commands = new Map();
101
+ #middleware = [];
102
+ #every = [];
103
+ #logger;
104
+ #budget;
105
+ #replies;
106
+ #tracker = new Tracker();
107
+ #now;
108
+ #sleep;
109
+ #fetch;
110
+ #started = false;
111
+ // Set by run.
112
+ #runtime = null;
113
+ #dispatcher = null;
114
+ #catchUpTask = null;
115
+ /** Set when the grace period of the shutdown is over: the handlers still running record nothing. */
116
+ #abandoned = false;
117
+ constructor(options) {
118
+ if (options.oauth === undefined && options.tokenSource === undefined) {
119
+ throw new Error("mybot: the options need oauth and refreshToken, or a tokenSource");
120
+ }
121
+ if (options.server === "")
122
+ throw new Error("mybot: the option server is empty");
123
+ this.#options = options;
124
+ this.#settings = {
125
+ mode: options.mode ?? "streaming",
126
+ pollInterval: positive(options.pollInterval, defaultPollInterval),
127
+ // ADR-0094 §3: 0 turns the catch-up off.
128
+ catchUpLimit: options.catchUpLimit ?? defaultCatchUpLimit,
129
+ processedRetention: positive(options.processedRetention, defaultProcessedRetention),
130
+ concurrency: positive(options.concurrency, defaultConcurrency),
131
+ shutdownGrace: positive(options.shutdownGrace, defaultShutdownGrace),
132
+ replyLimits: options.disableReplyLimits === true ? null : (options.replyLimits ?? defaultReplyLimits),
133
+ maxSendWait: positive(options.maxSendWait, defaultMaxSendWait),
134
+ help: options.help ?? defaultHelp,
135
+ };
136
+ this.store = options.store ?? new FileStore(defaultStatePath);
137
+ this.#logger = options.logger ?? console;
138
+ this.#budget = new Budget(rateOr(options.postRate, defaultPostRate), rateOr(options.writeRate, defaultWriteRate));
139
+ this.#replies = new ReplyLimiter(this.#settings.replyLimits ?? []);
140
+ this.#now = options.now ?? Date.now;
141
+ this.#sleep = options.sleep ?? sleepSignal;
142
+ const fetch = options.fetch ?? ((request) => globalThis.fetch(request));
143
+ const userAgent = options.userAgent === undefined || options.userAgent === "" ? userAgentProduct : `${options.userAgent} ${userAgentProduct}`;
144
+ this.#fetch = async (request) => {
145
+ request.headers.set("User-Agent", userAgent);
146
+ const response = await fetch(request);
147
+ this.#budget.observe(response.headers, this.#now());
148
+ return response;
149
+ };
150
+ }
151
+ /** Registers the handler of the mentions of the bot, replies and direct posts included, that are no registered command. */
152
+ onMention(handler) {
153
+ this.#handlers.mention = handler;
154
+ }
155
+ /**
156
+ * Registers the handler of the mentions whose first word, after the mentions of the bot at their start, is name,
157
+ * compared without case: `@bot ping` runs the command ping, whose Context has command "ping" and args the rest of
158
+ * the text. description is the command's line in the answer of help, which the bot gives unless the disableHelp
159
+ * option is set or a command help is registered.
160
+ */
161
+ onCommand(name, description, handler) {
162
+ const lower = name.toLowerCase();
163
+ this.#commands.set(lower, { command: { name: lower, description }, handler });
164
+ }
165
+ /** Registers the handler of new followers. */
166
+ onFollow(handler) {
167
+ this.#handlers.follow = handler;
168
+ }
169
+ /** Registers the handler of the follow requests to a locked bot account. */
170
+ onFollowRequest(handler) {
171
+ this.#handlers.followRequest = handler;
172
+ }
173
+ /** Registers the handler of the emoji reactions to the bot's posts. */
174
+ onReaction(handler) {
175
+ this.#handlers.reaction = handler;
176
+ }
177
+ /** Registers the handler of the favourites of the bot's posts. */
178
+ onFavourite(handler) {
179
+ this.#handlers.favourite = handler;
180
+ }
181
+ /** Registers the handler of the boosts of the bot's posts. */
182
+ onBoost(handler) {
183
+ this.#handlers.boost = handler;
184
+ }
185
+ /** Registers the handler of the quotes of the bot's posts. */
186
+ onQuote(handler) {
187
+ this.#handlers.quote = handler;
188
+ }
189
+ /** Registers the handler of every notification that no handler above takes, the types without a handler of their own among them. */
190
+ onNotification(handler) {
191
+ this.#handlers.notification = handler;
192
+ }
193
+ /**
194
+ * Registers the handler of the new posts of the home timeline, from the event stream alone: they are not caught up
195
+ * after a disconnection or a restart, and the polling mode does not receive them. The bot's own posts are left out.
196
+ */
197
+ onStatus(handler) {
198
+ this.#handlers.status = handler;
199
+ }
200
+ /**
201
+ * Registers a scheduled job: handler runs at each time of the cron expression expr, of the five fields minute hour
202
+ * day-of-month month day-of-week (parseSchedule), in options.timeZone (UTC by default). Its Context carries job and
203
+ * scheduledAt, and post posts with the Idempotency-Key everyIdempotencyKey. The run counts in the store as the job's
204
+ * last run after the handler returns. It throws for an expression or a time zone it does not take (ScheduleError),
205
+ * a visibility other than unlisted, public, and local, and a name that another job has.
206
+ */
207
+ every(expr, handler, options = {}) {
208
+ const schedule = Schedule.parse(expr, options.timeZone ?? "");
209
+ const name = options.name === undefined || options.name === "" ? expr : options.name;
210
+ if (this.#every.some((j) => j.name === name))
211
+ throw new Error(`mybot: a scheduled job is named "${name}" already`);
212
+ const visibility = options.visibility ?? "unlisted";
213
+ if (!["unlisted", "public", "local"].includes(visibility)) {
214
+ throw new Error(`mybot: the scheduled job "${name}" cannot post with the visibility "${visibility}": use unlisted, public, or local`);
215
+ }
216
+ this.#every.push({ name, schedule, handler, catchUp: options.catchUp ?? "skip", visibility });
217
+ }
218
+ /** Adds middleware inside the default chain, in the order given. */
219
+ use(...middleware) {
220
+ this.#middleware.push(...middleware);
221
+ }
222
+ /** The registered commands, by name, with help when the bot answers it. */
223
+ commands() {
224
+ const commands = [...this.#commands.values()].map((c) => c.command);
225
+ if (!this.#commands.has("help") && this.#options.disableHelp !== true)
226
+ commands.push({ name: "help", description: "list the commands" });
227
+ return commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
228
+ }
229
+ /** The scopes the bot needs: its own account, the notifications and the event stream, and posting (ADR-0094 §7); and write:accounts to set the bot flag. */
230
+ #requiredScopes() {
231
+ const scopes = ["read:accounts", "read:notifications", "read:statuses", "write:statuses"];
232
+ if (this.#options.setBotFlag === true)
233
+ scopes.push("write:accounts");
234
+ return scopes;
235
+ }
236
+ /** The token source of the options: a RefreshingTokenSource that saves every rotated refresh token in the store, starting from the one the store holds. */
237
+ async #newTokenSource() {
238
+ const { oauth, tokenSource } = this.#options;
239
+ if (oauth === undefined) {
240
+ if (tokenSource === undefined)
241
+ throw new Error("mybot: the options need oauth and refreshToken, or a tokenSource");
242
+ return tokenSource;
243
+ }
244
+ let refreshToken;
245
+ try {
246
+ refreshToken = await this.store.refreshToken();
247
+ }
248
+ catch (error) {
249
+ throw new Error("mybot: read the refresh token from the store", { cause: error });
250
+ }
251
+ if (refreshToken === null || refreshToken === "")
252
+ refreshToken = this.#options.refreshToken ?? "";
253
+ if (refreshToken === "")
254
+ throw new Error("mybot: no refresh token: set the refreshToken option");
255
+ return new RefreshingTokenSource({ ...oauth, server: oauth.server ?? this.#options.server, fetch: oauth.fetch ?? this.#fetch }, { accessToken: "", refreshToken }, async (token) => {
256
+ if (token.refreshToken === undefined || token.refreshToken === "")
257
+ return;
258
+ try {
259
+ await this.store.setRefreshToken(token.refreshToken);
260
+ }
261
+ catch (error) {
262
+ throw new Error("mybot: save the rotated refresh token", { cause: error });
263
+ }
264
+ }, this.#now);
265
+ }
266
+ /** Connects to the server: the client, the scopes of the token, the own account and its bot flag. */
267
+ async #start(signal) {
268
+ const tokenSource = await this.#newTokenSource();
269
+ try {
270
+ await tokenSource.getAccessToken();
271
+ }
272
+ catch (error) {
273
+ throw new Error("mybot: get the access token", { cause: error });
274
+ }
275
+ const scope = tokenSource instanceof RefreshingTokenSource ? tokenSource.token?.scope : undefined;
276
+ if (scope !== undefined && scope !== "") {
277
+ const missing = missingScopes(scope, this.#requiredScopes());
278
+ if (missing.length > 0)
279
+ throw new ScopeError(missing);
280
+ }
281
+ const api = createClient({
282
+ server: this.#options.server,
283
+ tokenSource,
284
+ fetch: this.#fetch,
285
+ ...(this.#options.acceptLanguage === undefined ? {} : { acceptLanguage: this.#options.acceptLanguage }),
286
+ });
287
+ let me = answered((await api.GET("/accounts/verify_credentials", { signal })).data, "verifyCredentials");
288
+ if (!me.bot) {
289
+ if (this.#options.setBotFlag === true) {
290
+ me = answered((await api.PATCH("/accounts/update_credentials", { body: { bot: true }, signal })).data, "updateCredentials");
291
+ this.#logger.info("mybot: set the bot flag of the account", { account: me.acct });
292
+ }
293
+ else if (this.#options.allowNonBot === true) {
294
+ this.#logger.warn("mybot: the account has no bot flag", { account: me.acct });
295
+ }
296
+ else {
297
+ throw new NotBotError();
298
+ }
299
+ }
300
+ const runtime = {
301
+ me,
302
+ api,
303
+ logger: this.#logger,
304
+ now: this.#now,
305
+ sleep: this.#sleep,
306
+ budget: this.#budget,
307
+ replies: this.#replies,
308
+ replyLimits: this.#settings.replyLimits,
309
+ maxSendWait: this.#settings.maxSendWait,
310
+ respondToBots: this.#options.respondToBots === true,
311
+ };
312
+ return { runtime, tokenSource };
313
+ }
314
+ /**
315
+ * Starts the bot and handles its events until signal aborts, then shuts down gracefully: it stops receiving, waits
316
+ * for the running handlers for the shutdownGrace option, aborts the signals of those still running, whose events
317
+ * stay unprocessed for the next start, and resolves. It rejects when the bot cannot start (NotBotError, ScopeError,
318
+ * a ProblemError of verifyCredentials) or when the server refuses the event stream for good, such as for a revoked
319
+ * token.
320
+ *
321
+ * It verifies the account, then receives the notifications: in the streaming mode from the event stream, catching
322
+ * up with listNotifications back to the store's position after every connection; in the polling mode by reading
323
+ * them every pollInterval. Each notification is handled once: it counts as processed, in the store, after its
324
+ * handler returns, and one that the store knows is skipped.
325
+ */
326
+ async run({ signal = new AbortController().signal } = {}) {
327
+ if (this.#started)
328
+ throw new AlreadyRunningError();
329
+ this.#started = true;
330
+ const { runtime, tokenSource } = await this.#start(signal);
331
+ this.#runtime = runtime;
332
+ await this.#prune();
333
+ const stopRun = new AbortController();
334
+ const runSignal = AbortSignal.any([signal, stopRun.signal]);
335
+ const stopHandlers = new AbortController();
336
+ const dispatcher = new Dispatcher(this.#settings.concurrency, (job) => this.#process(stopHandlers.signal, job));
337
+ this.#dispatcher = dispatcher;
338
+ const pruning = this.#pruneLoop(runSignal);
339
+ await this.#submitDue(false);
340
+ const scheduling = Promise.all(this.#every.map((j) => this.#schedule(j, runSignal)));
341
+ let failure = null;
342
+ try {
343
+ if (this.#settings.mode === "polling")
344
+ await this.#poll(runSignal);
345
+ else
346
+ await this.#stream(runSignal, runtime, tokenSource);
347
+ }
348
+ catch (error) {
349
+ failure = { error };
350
+ }
351
+ stopRun.abort();
352
+ await pruning;
353
+ await scheduling;
354
+ await this.#catchUpTask;
355
+ await this.#shutdown(dispatcher, stopHandlers);
356
+ if (failure !== null && !signal.aborted)
357
+ throw failure.error;
358
+ }
359
+ /**
360
+ * Starts the bot, handles once what is due, and resolves, for a bot that a cron or a job runner such as Cloud Run
361
+ * jobs starts again and again (ADR-0094 §9): the notifications since the store's position, back to the catchUpLimit
362
+ * option, and for each scheduled job the newest of its past times after its last run, whatever its catchUp. It
363
+ * resolves when their handlers have returned. When signal aborts first, it shuts down as run does, and what did not
364
+ * finish is due again on the next run. It rejects with the errors of starting that run rejects with, and with that
365
+ * of reading the notifications.
366
+ */
367
+ async runOnce({ signal = new AbortController().signal } = {}) {
368
+ if (this.#started)
369
+ throw new AlreadyRunningError();
370
+ this.#started = true;
371
+ const { runtime } = await this.#start(signal);
372
+ this.#runtime = runtime;
373
+ await this.#prune();
374
+ const stopHandlers = new AbortController();
375
+ const dispatcher = new Dispatcher(this.#settings.concurrency, (job) => this.#process(stopHandlers.signal, job));
376
+ this.#dispatcher = dispatcher;
377
+ let failure = null;
378
+ try {
379
+ await this.#catchUp(signal);
380
+ }
381
+ catch (error) {
382
+ failure = { error };
383
+ }
384
+ await this.#submitDue(true);
385
+ let onAbort;
386
+ await Promise.race([
387
+ dispatcher.idle(),
388
+ new Promise((resolve) => {
389
+ onAbort = resolve;
390
+ if (signal.aborted)
391
+ resolve();
392
+ else
393
+ signal.addEventListener("abort", onAbort, { once: true });
394
+ }),
395
+ ]);
396
+ if (onAbort !== undefined)
397
+ signal.removeEventListener("abort", onAbort);
398
+ await this.#shutdown(dispatcher, stopHandlers);
399
+ if (failure !== null && !signal.aborted)
400
+ throw failure.error;
401
+ }
402
+ /**
403
+ * Stops the dispatcher, waits for the running handlers for the shutdownGrace option, and aborts the signals of those
404
+ * still running, which record nothing.
405
+ */
406
+ async #shutdown(dispatcher, stopHandlers) {
407
+ dispatcher.stop();
408
+ let timer;
409
+ const graceOver = await Promise.race([
410
+ dispatcher.idle().then(() => false),
411
+ new Promise((resolve) => {
412
+ timer = setTimeout(() => {
413
+ resolve(true);
414
+ }, this.#settings.shutdownGrace);
415
+ }),
416
+ ]);
417
+ clearTimeout(timer);
418
+ if (graceOver) {
419
+ this.#abandoned = true;
420
+ this.#logger.warn("mybot: aborted the handlers still running after the grace period", { grace: this.#settings.shutdownGrace });
421
+ }
422
+ stopHandlers.abort();
423
+ }
424
+ /** The newest time of job at or before now and after its last run, null when there is none. */
425
+ async #due(job, now) {
426
+ let last;
427
+ try {
428
+ last = await this.store.lastRun(job.name);
429
+ }
430
+ catch (error) {
431
+ throw new Error(`mybot: read the last run of "${job.name}" from the store`, { cause: error });
432
+ }
433
+ const at = job.schedule.prev(now);
434
+ if (at === null || (last !== null && at <= last))
435
+ return null;
436
+ return at;
437
+ }
438
+ /** Hands the jobs their due time: every job (runOnce), or those of catchUp "latest" (run). */
439
+ async #submitDue(all) {
440
+ const now = this.#now();
441
+ for (const job of this.#every) {
442
+ if (!all && job.catchUp !== "latest")
443
+ continue;
444
+ try {
445
+ const at = await this.#due(job, now);
446
+ if (at !== null)
447
+ this.#submitRun(job, at);
448
+ }
449
+ catch (error) {
450
+ this.#logger.error("mybot: the scheduled job did not catch up", { job: job.name, error });
451
+ }
452
+ }
453
+ }
454
+ /** Hands the run at of job to the dispatcher; the runs of one job go one after the other. */
455
+ #submitRun(job, at) {
456
+ this.#dispatcher?.submit({ every: job, at, sender: `\u0000every:${job.name}` });
457
+ }
458
+ /**
459
+ * Runs job at its times until signal aborts. A run that the clock reaches late, such as after the machine slept,
460
+ * runs once, and the times it passed are skipped.
461
+ */
462
+ async #schedule(job, signal) {
463
+ let next = job.schedule.next(this.#now());
464
+ while (next !== null) {
465
+ try {
466
+ await this.#sleep(Math.min(next - this.#now(), maxTimeout), signal);
467
+ }
468
+ catch {
469
+ return;
470
+ }
471
+ if (signal.aborted)
472
+ return;
473
+ const now = this.#now();
474
+ // Woken early, as by the longest timeout or a clock of a test.
475
+ if (now < next)
476
+ continue;
477
+ this.#submitRun(job, next);
478
+ next = job.schedule.next(Math.max(this.#now(), next));
479
+ }
480
+ }
481
+ /** Runs one time of a scheduled job and records it as the job's last run. */
482
+ async #runScheduled(signal, job) {
483
+ const runtime = this.#running();
484
+ const { every } = job;
485
+ try {
486
+ const last = await this.store.lastRun(every.name);
487
+ // Ran already.
488
+ if (last !== null && job.at <= last)
489
+ return;
490
+ const ctx = new Context(runtime, job, signal);
491
+ try {
492
+ await chain(this.#chain(), every.handler)(ctx);
493
+ }
494
+ catch (error) {
495
+ ctx.logger.error("mybot: the handler failed", { error });
496
+ }
497
+ if (this.#abandoned || signal.aborted)
498
+ return;
499
+ await this.store.setLastRun(every.name, job.at);
500
+ }
501
+ catch (error) {
502
+ this.#logger.error("mybot: record the run of the scheduled job", { job: every.name, error });
503
+ }
504
+ }
505
+ /** Receives the event stream until signal aborts or the server refuses it, catching up after every connection. */
506
+ async #stream(signal, runtime, tokenSource) {
507
+ let again = false;
508
+ const takeAgain = () => {
509
+ const a = again;
510
+ again = false;
511
+ return a;
512
+ };
513
+ const catchUp = () => {
514
+ if (this.#catchUpTask !== null) {
515
+ again = true;
516
+ return;
517
+ }
518
+ this.#catchUpTask = (async () => {
519
+ do {
520
+ try {
521
+ await this.#catchUp(signal);
522
+ }
523
+ catch (error) {
524
+ if (!signal.aborted)
525
+ this.#logger.warn("mybot: the catch-up failed; it runs again on the next connection", { error });
526
+ }
527
+ } while (takeAgain() && !signal.aborted);
528
+ this.#catchUpTask = null;
529
+ })();
530
+ };
531
+ await runStream({
532
+ server: this.#options.server,
533
+ tokenSource,
534
+ fetch: this.#fetch,
535
+ signal,
536
+ ...(this.#options.acceptLanguage === undefined ? {} : { acceptLanguage: this.#options.acceptLanguage }),
537
+ onOpen: (reconnected) => {
538
+ if (reconnected)
539
+ this.#logger.info("mybot: the event stream reconnected");
540
+ catchUp();
541
+ },
542
+ onEvent: (event) => {
543
+ if (event.type === "notification") {
544
+ this.#submitNotification(event.notification);
545
+ }
546
+ else if (event.type === "update" && this.#handlers.status !== undefined && event.status.account.id !== runtime.me.id) {
547
+ this.#dispatcher?.submit({ status: event.status, sender: senderOf(event.status.account) });
548
+ }
549
+ },
550
+ onError: (error) => {
551
+ this.#logger.warn("mybot: the event stream failed; connecting again", { error });
552
+ },
553
+ });
554
+ }
555
+ /** Catches up every pollInterval until signal aborts. */
556
+ async #poll(signal) {
557
+ for (;;) {
558
+ try {
559
+ await this.#catchUp(signal);
560
+ }
561
+ catch (error) {
562
+ if (signal.aborted)
563
+ return;
564
+ if (error instanceof ProblemError && error.status >= 400 && error.status < 500 && error.status !== 429)
565
+ throw error;
566
+ this.#logger.warn("mybot: reading the notifications failed; trying again", { error });
567
+ }
568
+ try {
569
+ await this.#sleep(this.#settings.pollInterval, signal);
570
+ }
571
+ catch (error) {
572
+ if (signal.aborted)
573
+ return;
574
+ throw error;
575
+ }
576
+ }
577
+ }
578
+ /**
579
+ * Reads the notifications newest first back to the store's position, or to the catch-up limit, and hands them to
580
+ * the handlers oldest first (ADR-0094 §3).
581
+ */
582
+ async #catchUp(signal) {
583
+ if (this.#settings.catchUpLimit <= 0)
584
+ return;
585
+ const { api } = this.#running();
586
+ let position;
587
+ try {
588
+ position = await this.store.position();
589
+ }
590
+ catch (error) {
591
+ throw new Error("mybot: read the position from the store", { cause: error });
592
+ }
593
+ this.#tracker.hold();
594
+ try {
595
+ const cutoff = this.#now() - this.#settings.catchUpLimit;
596
+ const found = [];
597
+ for await (const n of all(async (cursor) => {
598
+ const { data } = await api.GET("/notifications", { params: { query: { limit: 40, ...(cursor === undefined ? {} : { cursor }) } }, signal });
599
+ return answered(data, "listNotifications");
600
+ })) {
601
+ if ((position !== null && n.id <= position) || Date.parse(n.created_at) < cutoff)
602
+ break;
603
+ found.push(n);
604
+ }
605
+ for (const n of found.reverse())
606
+ this.#submitNotification(n);
607
+ }
608
+ finally {
609
+ const p = this.#tracker.release();
610
+ if (p !== null)
611
+ await this.#setPosition(p);
612
+ }
613
+ }
614
+ /** Hands a notification to the dispatcher. */
615
+ #submitNotification(n) {
616
+ // The job starts after an await at the earliest, so it is pending in the tracker before it can complete.
617
+ if (this.#dispatcher?.submit({ notification: n, sender: senderOf(n.account) }) === true)
618
+ this.#tracker.add(n.id);
619
+ }
620
+ /** Handles one job and records its notification processed. */
621
+ async #process(signal, job) {
622
+ if (job.every !== undefined) {
623
+ await this.#runScheduled(signal, job);
624
+ return;
625
+ }
626
+ const runtime = this.#running();
627
+ const n = job.notification;
628
+ try {
629
+ if (n !== undefined && (await this.store.isProcessed(n.id))) {
630
+ await this.#completed(n.id);
631
+ return;
632
+ }
633
+ const ctx = new Context(runtime, job, signal);
634
+ if (!this.#isMe(ctx.account)) {
635
+ const handler = this.#route(ctx);
636
+ if (handler !== undefined) {
637
+ try {
638
+ await chain(this.#chain(), handler)(ctx);
639
+ }
640
+ catch (error) {
641
+ ctx.logger.error("mybot: the handler failed", { error });
642
+ }
643
+ }
644
+ }
645
+ // Aborted by the shutdown: handled again by the next start.
646
+ if (n === undefined || this.#abandoned || signal.aborted)
647
+ return;
648
+ await this.store.markProcessed(n.id, this.#now());
649
+ await this.#completed(n.id);
650
+ }
651
+ catch (error) {
652
+ this.#logger.error("mybot: record the notification processed", { notification: n?.id, error });
653
+ }
654
+ }
655
+ /** Moves the position after a processed notification. */
656
+ async #completed(id) {
657
+ const p = this.#tracker.complete(id);
658
+ if (p !== null)
659
+ await this.#setPosition(p);
660
+ }
661
+ /** Saves the position when it is newer than the saved one. */
662
+ async #setPosition(p) {
663
+ try {
664
+ const current = await this.store.position();
665
+ if (current !== null && current >= p)
666
+ return;
667
+ await this.store.setPosition(p);
668
+ }
669
+ catch (error) {
670
+ this.#logger.error("mybot: save the position", { error });
671
+ }
672
+ }
673
+ /** The runtime of the running bot. */
674
+ #running() {
675
+ if (this.#runtime === null)
676
+ throw new Error("mybot: the bot has not started");
677
+ return this.#runtime;
678
+ }
679
+ /** Whether account is the bot's own, whose events never reach a handler (ADR-0094 §8). */
680
+ #isMe(account) {
681
+ return account !== undefined && account.id === this.#runtime?.me.id;
682
+ }
683
+ /** The middleware of every handler: the default chain outermost, then those of use. */
684
+ #chain() {
685
+ return this.#options.noDefaultMiddleware === true ? this.#middleware : [...defaultMiddleware(), ...this.#middleware];
686
+ }
687
+ /** Picks the handler of the event of ctx, filling in its command; undefined when none takes it. */
688
+ #route(ctx) {
689
+ const n = ctx.notification;
690
+ if (n === undefined)
691
+ return this.#handlers.status;
692
+ let handler;
693
+ switch (n.type) {
694
+ case "mention": {
695
+ const parsed = parseCommand(ctx.text, ctx.me);
696
+ if (parsed !== null) {
697
+ const command = this.#commands.get(parsed.name);
698
+ if (command !== undefined) {
699
+ ctx.command = parsed.name;
700
+ ctx.args = parsed.args;
701
+ return command.handler;
702
+ }
703
+ if (parsed.name === "help" && this.#options.disableHelp !== true && this.#commands.size > 0) {
704
+ ctx.command = parsed.name;
705
+ ctx.args = parsed.args;
706
+ return (c) => this.#help(c);
707
+ }
708
+ }
709
+ handler = this.#handlers.mention;
710
+ break;
711
+ }
712
+ case "follow":
713
+ handler = this.#handlers.follow;
714
+ break;
715
+ case "follow_request":
716
+ handler = this.#handlers.followRequest;
717
+ break;
718
+ case "reaction":
719
+ handler = this.#handlers.reaction;
720
+ break;
721
+ case "favourite":
722
+ handler = this.#handlers.favourite;
723
+ break;
724
+ case "boost":
725
+ handler = this.#handlers.boost;
726
+ break;
727
+ case "quote":
728
+ handler = this.#handlers.quote;
729
+ break;
730
+ default:
731
+ break;
732
+ }
733
+ return handler ?? this.#handlers.notification;
734
+ }
735
+ /** Answers the help command with the registered commands. */
736
+ async #help(ctx) {
737
+ await ctx.reply(this.#settings.help(this.commands()));
738
+ }
739
+ /** Forgets the processed notifications older than the processedRetention option. */
740
+ async #prune() {
741
+ try {
742
+ await this.store.forgetProcessed(this.#now() - this.#settings.processedRetention);
743
+ }
744
+ catch (error) {
745
+ this.#logger.warn("mybot: forget the old processed notifications", { error });
746
+ }
747
+ }
748
+ /** Prunes every hour until signal aborts. */
749
+ async #pruneLoop(signal) {
750
+ for (;;) {
751
+ try {
752
+ await this.#sleep(hour, signal);
753
+ }
754
+ catch {
755
+ return;
756
+ }
757
+ await this.#prune();
758
+ }
759
+ }
760
+ }