@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,45 @@
1
+ /** A number of events in a window of time, in milliseconds, such as 60 posts an hour. */
2
+ export type Rate = {
3
+ readonly n: number;
4
+ readonly per: number;
5
+ };
6
+ /** The budget of posts, replies included: 60 an hour, six tenths of the server's 300 in three hours (ADR-0094 §6). */
7
+ export declare const defaultPostRate: Rate;
8
+ /** The budget of the other writes (reactions, favourites, follows): 60 a minute. */
9
+ export declare const defaultWriteRate: Rate;
10
+ /** The most replies to one sender: 5 in 10 minutes and 15 in an hour (ADR-0094 §8). */
11
+ export declare const defaultReplyLimits: readonly Rate[];
12
+ /** The longest a write waits for the send budget before it is dropped: 10 minutes. */
13
+ export declare const defaultMaxSendWait: number;
14
+ /** The budget a write draws on. */
15
+ export type SendKind = "post" | "write";
16
+ /**
17
+ * The client-side send budget of a bot: a token bucket for posts and one for the other writes, and the pause that the
18
+ * server's RateLimit fields ask for when less than a tenth of the limit remains.
19
+ */
20
+ export declare class Budget {
21
+ #private;
22
+ constructor(post: Rate, write: Rate);
23
+ /**
24
+ * Reserves a send of kind at now and returns how long to wait before sending it; null, reserving nothing, when the
25
+ * wait would be longer than maxWait.
26
+ */
27
+ reserve(kind: SendKind, now: number, maxWait: number): number | null;
28
+ /**
29
+ * Reads the RateLimit and RateLimit-Policy fields of an answer (draft-ietf-httpapi-ratelimit-headers,
30
+ * `"name";r=<remaining>;t=<seconds>` and `"name";q=<quota>;w=<window>`): when less than a tenth of a policy's quota
31
+ * remains, the writes wait until its window resets.
32
+ */
33
+ observe(headers: Headers, now: number): void;
34
+ }
35
+ /** Counts the replies to each sender, for the reply limits of ADR-0094 §8. */
36
+ export declare class ReplyLimiter {
37
+ #private;
38
+ constructor(limits: readonly Rate[]);
39
+ /** Whether one more reply to sender at now would go over a limit. */
40
+ full(sender: string, now: number): boolean;
41
+ /** Records a reply to sender at now; false, recording nothing, when it would go over a limit. */
42
+ take(sender: string, now: number): boolean;
43
+ /** Forgets the reply to sender recorded at at, which was not sent. */
44
+ untake(sender: string, at: number): void;
45
+ }
package/dist/budget.js ADDED
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: MIT
2
+ const minute = 60_000;
3
+ const hour = 60 * minute;
4
+ /** The budget of posts, replies included: 60 an hour, six tenths of the server's 300 in three hours (ADR-0094 §6). */
5
+ export const defaultPostRate = { n: 60, per: hour };
6
+ /** The budget of the other writes (reactions, favourites, follows): 60 a minute. */
7
+ export const defaultWriteRate = { n: 60, per: minute };
8
+ /** The most replies to one sender: 5 in 10 minutes and 15 in an hour (ADR-0094 §8). */
9
+ export const defaultReplyLimits = [
10
+ { n: 5, per: 10 * minute },
11
+ { n: 15, per: hour },
12
+ ];
13
+ /** The longest a write waits for the send budget before it is dropped: 10 minutes. */
14
+ export const defaultMaxSendWait = 10 * minute;
15
+ /** A token bucket that holds up to rate.n tokens and gains rate.n every rate.per. */
16
+ class Bucket {
17
+ #rate;
18
+ #tokens;
19
+ #last = null;
20
+ constructor(rate) {
21
+ this.#rate = rate;
22
+ this.#tokens = rate.n;
23
+ }
24
+ /** Refills the bucket at now and returns how long until it holds one token. */
25
+ wait(now) {
26
+ const { n, per } = this.#rate;
27
+ if (n <= 0 || per <= 0)
28
+ return 0;
29
+ if (this.#last !== null)
30
+ this.#tokens = Math.min(n, this.#tokens + ((now - this.#last) * n) / per);
31
+ this.#last = now;
32
+ if (this.#tokens >= 1)
33
+ return 0;
34
+ return ((1 - this.#tokens) * per) / n;
35
+ }
36
+ /** Uses one token, which wait made sure of or reserved. */
37
+ take() {
38
+ if (this.#rate.n > 0 && this.#rate.per > 0)
39
+ this.#tokens--;
40
+ }
41
+ }
42
+ /**
43
+ * The client-side send budget of a bot: a token bucket for posts and one for the other writes, and the pause that the
44
+ * server's RateLimit fields ask for when less than a tenth of the limit remains.
45
+ */
46
+ export class Budget {
47
+ #buckets;
48
+ #pause = 0;
49
+ constructor(post, write) {
50
+ this.#buckets = { post: new Bucket(post), write: new Bucket(write) };
51
+ }
52
+ /**
53
+ * Reserves a send of kind at now and returns how long to wait before sending it; null, reserving nothing, when the
54
+ * wait would be longer than maxWait.
55
+ */
56
+ reserve(kind, now, maxWait) {
57
+ const bucket = this.#buckets[kind];
58
+ const wait = Math.max(bucket.wait(now), this.#pause - now);
59
+ if (wait > maxWait)
60
+ return null;
61
+ bucket.take();
62
+ return Math.max(wait, 0);
63
+ }
64
+ /**
65
+ * Reads the RateLimit and RateLimit-Policy fields of an answer (draft-ietf-httpapi-ratelimit-headers,
66
+ * `"name";r=<remaining>;t=<seconds>` and `"name";q=<quota>;w=<window>`): when less than a tenth of a policy's quota
67
+ * remains, the writes wait until its window resets.
68
+ */
69
+ observe(headers, now) {
70
+ const limits = headers.get("RateLimit");
71
+ if (limits === null)
72
+ return;
73
+ const quotas = new Map();
74
+ for (const item of structuredItems(headers.get("RateLimit-Policy") ?? "")) {
75
+ const q = item.params.get("q");
76
+ if (q !== undefined)
77
+ quotas.set(item.name, q);
78
+ }
79
+ for (const item of structuredItems(limits)) {
80
+ const r = item.params.get("r");
81
+ const t = item.params.get("t");
82
+ const q = quotas.get(item.name);
83
+ if (r === undefined || t === undefined || q === undefined || q <= 0 || r * 10 >= q)
84
+ continue;
85
+ this.#pause = Math.max(this.#pause, now + t * 1000);
86
+ }
87
+ }
88
+ }
89
+ /** Reads the members of a list field such as `"api";r=12;t=30, "posts";r=1;t=600`, skipping the parameters that are not integers. */
90
+ function structuredItems(value) {
91
+ if (value.trim() === "")
92
+ return [];
93
+ return value.split(",").map((member) => {
94
+ const [name = "", ...params] = member.split(";");
95
+ const item = { name: name.trim().replaceAll(/^"|"$/g, ""), params: new Map() };
96
+ for (const p of params) {
97
+ const eq = p.indexOf("=");
98
+ if (eq < 0)
99
+ continue;
100
+ const v = p.slice(eq + 1).trim();
101
+ if (/^-?\d+$/.test(v))
102
+ item.params.set(p.slice(0, eq).trim(), Number(v));
103
+ }
104
+ return item;
105
+ });
106
+ }
107
+ /** Counts the replies to each sender, for the reply limits of ADR-0094 §8. */
108
+ export class ReplyLimiter {
109
+ #limits;
110
+ #sent = new Map();
111
+ constructor(limits) {
112
+ this.#limits = limits;
113
+ }
114
+ /** Whether one more reply to sender at now would go over a limit. */
115
+ full(sender, now) {
116
+ const longest = Math.max(0, ...this.#limits.map((r) => r.per));
117
+ // Drop what no window reaches any more.
118
+ const keep = (this.#sent.get(sender) ?? []).filter((at) => now - at < longest);
119
+ if (keep.length === 0)
120
+ this.#sent.delete(sender);
121
+ else
122
+ this.#sent.set(sender, keep);
123
+ return this.#limits.some((r) => r.n > 0 && r.per > 0 && keep.filter((at) => now - at < r.per).length >= r.n);
124
+ }
125
+ /** Records a reply to sender at now; false, recording nothing, when it would go over a limit. */
126
+ take(sender, now) {
127
+ if (this.full(sender, now))
128
+ return false;
129
+ this.#sent.set(sender, [...(this.#sent.get(sender) ?? []), now]);
130
+ return true;
131
+ }
132
+ /** Forgets the reply to sender recorded at at, which was not sent. */
133
+ untake(sender, at) {
134
+ const times = this.#sent.get(sender);
135
+ if (times === undefined)
136
+ return;
137
+ const i = times.lastIndexOf(at);
138
+ if (i >= 0)
139
+ times.splice(i, 1);
140
+ }
141
+ }
@@ -0,0 +1,158 @@
1
+ import type { Api, components, Sleep } from "@mycroblog/mykit";
2
+ import type { Budget, Rate, ReplyLimiter } from "./budget.js";
3
+ import type { Job } from "./dispatch.js";
4
+ import { type EveryJob } from "./every.js";
5
+ import { type Logger } from "./logger.js";
6
+ type Notification = components["schemas"]["Notification"];
7
+ type Status = components["schemas"]["Status"];
8
+ type Account = components["schemas"]["Account"];
9
+ type CredentialAccount = components["schemas"]["CredentialAccount"];
10
+ type Visibility = components["schemas"]["Visibility"];
11
+ /**
12
+ * The Idempotency-Key of the n-th reply (from 1) that a handler posts for the event source: a notification ID, or for
13
+ * onStatus the ID of the post (ADR-0094 §6). The server keeps the keys of each account, so the same event processed
14
+ * twice (after a restart in the middle of its handler) posts each reply once. Use it when posting with mykit directly:
15
+ *
16
+ * ```ts
17
+ * await api.POST("/statuses", { params: { header: { "Idempotency-Key": replyIdempotencyKey(id, 1) } }, body });
18
+ * ```
19
+ */
20
+ export declare function replyIdempotencyKey(source: string, n: number): string;
21
+ /**
22
+ * The visibility of a reply to a post of visibility v: never wider than v (ADR-0094 §8), and unlisted for a public
23
+ * post, so that a bot's replies stay out of the public timelines.
24
+ */
25
+ export declare function replyVisibility(v: Visibility): Visibility;
26
+ /** The error of reply, react, and favourite for an event without a post, such as a follow. */
27
+ export declare class NoStatusError extends Error {
28
+ readonly name = "NoStatusError";
29
+ constructor();
30
+ }
31
+ /** The error of follow for an event without an account. */
32
+ export declare class NoAccountError extends Error {
33
+ readonly name = "NoAccountError";
34
+ constructor();
35
+ }
36
+ /** The error of reply when the replies to the sender reached a limit of the replyLimits option. The reply was not sent. */
37
+ export declare class ReplyLimitError extends Error {
38
+ readonly name = "ReplyLimitError";
39
+ constructor();
40
+ }
41
+ /** The error of a write that would have waited for the send budget longer than the maxSendWait option. It was not sent. */
42
+ export declare class BudgetExceededError extends Error {
43
+ readonly name = "BudgetExceededError";
44
+ constructor();
45
+ }
46
+ /** Handles one event. What it throws is logged; the event counts as processed all the same, so that a failing event is not retried for ever. */
47
+ export type Handler = (ctx: Context) => void | Promise<void>;
48
+ /**
49
+ * Wraps the handlers, such as to check who may run a command, or to log in a format of one's own: it calls next to
50
+ * run the rest of the chain and the handler, or returns without calling it to skip them.
51
+ */
52
+ export type Middleware = (ctx: Context, next: () => Promise<void>) => Promise<void>;
53
+ /** The data of an answer, which mykit sets whenever the call resolves; an error when the answer has none. */
54
+ export declare function answered<T>(data: T | undefined, operation: string): T;
55
+ /** What the contexts of a running bot share. */
56
+ export type Runtime = {
57
+ readonly me: CredentialAccount;
58
+ readonly api: Api;
59
+ readonly logger: Logger;
60
+ readonly now: () => number;
61
+ readonly sleep: Sleep;
62
+ readonly budget: Budget;
63
+ readonly replies: ReplyLimiter;
64
+ /** The reply limits, null when they are lifted. */
65
+ readonly replyLimits: readonly Rate[] | null;
66
+ readonly maxSendWait: number;
67
+ readonly respondToBots: boolean;
68
+ };
69
+ /** The key of the library's state of a Context, which the default middleware reads. */
70
+ export declare const internal: unique symbol;
71
+ /** The library's state of a Context. */
72
+ export type ContextState = {
73
+ readonly runtime: Runtime;
74
+ /** The ID that the idempotency keys of the replies derive from. */
75
+ readonly source: string;
76
+ /** The key of the account for the reply limits and the order of processing. */
77
+ readonly sender: string;
78
+ replies: number;
79
+ /** The scheduled job of the run, undefined for an event. */
80
+ readonly every: EveryJob | undefined;
81
+ /** Whether the run of the scheduled job posted. */
82
+ posted: boolean;
83
+ /** Makes the helpers wait for the send budget (the sendBudget middleware). */
84
+ budgeted: boolean;
85
+ };
86
+ /**
87
+ * What a handler receives: the event, its post and its sender, the plain text and the command of a mention, and
88
+ * helpers that answer within the defaults of the library; or for a scheduled job its name and time, and post. Its
89
+ * signal aborts when the bot stops and the grace period is over.
90
+ */
91
+ export declare class Context {
92
+ #private;
93
+ /** The notification of the event; undefined for onStatus. */
94
+ readonly notification: Notification | undefined;
95
+ /**
96
+ * The post of the event: the post that mentions the bot, the bot's post that was reacted to, favourited, boosted, or
97
+ * quoted, the quote, or the post of onStatus. Undefined for an event without one.
98
+ */
99
+ readonly status: Status | undefined;
100
+ /** The sender: the account that acted. Undefined for a notification without one. */
101
+ readonly account: Account | undefined;
102
+ /** The content of status as plain text (plainText), "" without a status. */
103
+ readonly text: string;
104
+ /** Whether status replies to a post. */
105
+ readonly isReply: boolean;
106
+ /** Whether status is a direct post. */
107
+ readonly isDirect: boolean;
108
+ /** The lowercase name of the command of onCommand, "" otherwise. */
109
+ command: string;
110
+ /** The text after the command's name, with the spaces around it trimmed. */
111
+ args: string;
112
+ /** The name of the scheduled job (Bot.every) that the context runs, "" for an event. */
113
+ readonly job: string;
114
+ /**
115
+ * The time of the run of the scheduled job, in milliseconds since the Unix epoch, which may be earlier than now for
116
+ * a run that catches up; undefined for an event.
117
+ */
118
+ readonly scheduledAt: number | undefined;
119
+ /** Aborts when the bot stops and the grace period is over; pass it to the requests of the handler. */
120
+ readonly signal: AbortSignal;
121
+ /** The library's state; not for handlers. */
122
+ readonly [internal]: ContextState;
123
+ /** A context is made by the bot for each event. */
124
+ constructor(runtime: Runtime, job: Job, signal: AbortSignal);
125
+ /** The bot's own account, as verifyCredentials returned it when the bot started. */
126
+ get me(): CredentialAccount;
127
+ /**
128
+ * The client of the server, for what the helpers do not cover. Writes through it skip the send budget and the reply
129
+ * limits: use replyIdempotencyKey for the posts.
130
+ */
131
+ get api(): Api;
132
+ /** The logger of the bot, with the event's attributes. */
133
+ get logger(): Logger;
134
+ /**
135
+ * Posts text as a reply to status. The reply mentions the sender alone, never the other accounts that the post
136
+ * mentions; its visibility is replyVisibility of the post's; and it carries the Idempotency-Key
137
+ * replyIdempotencyKey(event, n) for the n-th reply of the handler, so that processing the event again posts it once.
138
+ * It counts toward the reply limits of the sender and waits for the send budget.
139
+ */
140
+ reply(text: string): Promise<Status>;
141
+ /**
142
+ * Reacts to status with emoji: a Unicode emoji, or the shortcode of a custom emoji of the server. Reacting again
143
+ * with the same emoji changes nothing, so it needs no idempotency key. It waits for the send budget.
144
+ */
145
+ react(emoji: string): Promise<void>;
146
+ /** Favourites status. A second favourite changes nothing. It waits for the send budget. */
147
+ favourite(): Promise<void>;
148
+ /** Follows the sender (for a locked account, requests to). Following again changes nothing. It waits for the send budget. */
149
+ follow(): Promise<void>;
150
+ /**
151
+ * Posts text from the handler of a scheduled job, with the visibility of its EveryOptions and the Idempotency-Key
152
+ * everyIdempotencyKey(job, scheduledAt): a run posts once, even when two processes of the bot run it, or a restart
153
+ * runs it again. It waits for the send budget. Outside a scheduled job it throws a NotScheduledError, and a second
154
+ * post of a run a PostedAlreadyError.
155
+ */
156
+ post(text: string): Promise<Status>;
157
+ }
158
+ export {};
@@ -0,0 +1,255 @@
1
+ import { everyIdempotencyKey, NotScheduledError, PostedAlreadyError } from "./every.js";
2
+ import { withAttributes } from "./logger.js";
3
+ import { plainText } from "./text.js";
4
+ /**
5
+ * The Idempotency-Key of the n-th reply (from 1) that a handler posts for the event source: a notification ID, or for
6
+ * onStatus the ID of the post (ADR-0094 §6). The server keeps the keys of each account, so the same event processed
7
+ * twice (after a restart in the middle of its handler) posts each reply once. Use it when posting with mykit directly:
8
+ *
9
+ * ```ts
10
+ * await api.POST("/statuses", { params: { header: { "Idempotency-Key": replyIdempotencyKey(id, 1) } }, body });
11
+ * ```
12
+ */
13
+ export function replyIdempotencyKey(source, n) {
14
+ return `mybot:reply:${source}:${String(n)}`;
15
+ }
16
+ /**
17
+ * The visibility of a reply to a post of visibility v: never wider than v (ADR-0094 §8), and unlisted for a public
18
+ * post, so that a bot's replies stay out of the public timelines.
19
+ */
20
+ export function replyVisibility(v) {
21
+ switch (v) {
22
+ case "direct":
23
+ case "private":
24
+ case "local":
25
+ case "unlisted":
26
+ return v;
27
+ default:
28
+ return "unlisted";
29
+ }
30
+ }
31
+ /** The error of reply, react, and favourite for an event without a post, such as a follow. */
32
+ export class NoStatusError extends Error {
33
+ name = "NoStatusError";
34
+ constructor() {
35
+ super("mybot: the event has no post");
36
+ }
37
+ }
38
+ /** The error of follow for an event without an account. */
39
+ export class NoAccountError extends Error {
40
+ name = "NoAccountError";
41
+ constructor() {
42
+ super("mybot: the event has no account");
43
+ }
44
+ }
45
+ /** The error of reply when the replies to the sender reached a limit of the replyLimits option. The reply was not sent. */
46
+ export class ReplyLimitError extends Error {
47
+ name = "ReplyLimitError";
48
+ constructor() {
49
+ super("mybot: the replies to this sender reached their limit");
50
+ }
51
+ }
52
+ /** The error of a write that would have waited for the send budget longer than the maxSendWait option. It was not sent. */
53
+ export class BudgetExceededError extends Error {
54
+ name = "BudgetExceededError";
55
+ constructor() {
56
+ super("mybot: the send budget is used up for longer than the longest wait");
57
+ }
58
+ }
59
+ /** The data of an answer, which mykit sets whenever the call resolves; an error when the answer has none. */
60
+ export function answered(data, operation) {
61
+ if (data === undefined)
62
+ throw new Error(`mybot: ${operation} answered no data`);
63
+ return data;
64
+ }
65
+ /** The key of the library's state of a Context, which the default middleware reads. */
66
+ export const internal = Symbol("mybot.internal");
67
+ /**
68
+ * What a handler receives: the event, its post and its sender, the plain text and the command of a mention, and
69
+ * helpers that answer within the defaults of the library; or for a scheduled job its name and time, and post. Its
70
+ * signal aborts when the bot stops and the grace period is over.
71
+ */
72
+ export class Context {
73
+ /** The notification of the event; undefined for onStatus. */
74
+ notification;
75
+ /**
76
+ * The post of the event: the post that mentions the bot, the bot's post that was reacted to, favourited, boosted, or
77
+ * quoted, the quote, or the post of onStatus. Undefined for an event without one.
78
+ */
79
+ status;
80
+ /** The sender: the account that acted. Undefined for a notification without one. */
81
+ account;
82
+ /** The content of status as plain text (plainText), "" without a status. */
83
+ text;
84
+ /** Whether status replies to a post. */
85
+ isReply;
86
+ /** Whether status is a direct post. */
87
+ isDirect;
88
+ /** The lowercase name of the command of onCommand, "" otherwise. */
89
+ command = "";
90
+ /** The text after the command's name, with the spaces around it trimmed. */
91
+ args = "";
92
+ /** The name of the scheduled job (Bot.every) that the context runs, "" for an event. */
93
+ job;
94
+ /**
95
+ * The time of the run of the scheduled job, in milliseconds since the Unix epoch, which may be earlier than now for
96
+ * a run that catches up; undefined for an event.
97
+ */
98
+ scheduledAt;
99
+ /** Aborts when the bot stops and the grace period is over; pass it to the requests of the handler. */
100
+ signal;
101
+ /** The library's state; not for handlers. */
102
+ [internal];
103
+ /** A context is made by the bot for each event. */
104
+ constructor(runtime, job, signal) {
105
+ this.signal = signal;
106
+ this.job = job.every?.name ?? "";
107
+ this.scheduledAt = job.every === undefined ? undefined : job.at;
108
+ if (job.every !== undefined) {
109
+ this.notification = undefined;
110
+ this.status = undefined;
111
+ this.account = undefined;
112
+ }
113
+ else if (job.notification !== undefined) {
114
+ this.notification = job.notification;
115
+ this.status = job.notification.status;
116
+ this.account = job.notification.account;
117
+ }
118
+ else {
119
+ this.notification = undefined;
120
+ this.status = job.status;
121
+ this.account = job.status.account;
122
+ }
123
+ this.text = this.status === undefined ? "" : plainText(this.status.content);
124
+ this.isReply = this.status?.in_reply_to_id !== undefined;
125
+ this.isDirect = this.status?.visibility === "direct";
126
+ this[internal] = {
127
+ runtime,
128
+ source: job.notification?.id ?? job.status?.id ?? "",
129
+ sender: job.sender,
130
+ replies: 0,
131
+ every: job.every,
132
+ posted: false,
133
+ budgeted: false,
134
+ };
135
+ }
136
+ /** The bot's own account, as verifyCredentials returned it when the bot started. */
137
+ get me() {
138
+ return this[internal].runtime.me;
139
+ }
140
+ /**
141
+ * The client of the server, for what the helpers do not cover. Writes through it skip the send budget and the reply
142
+ * limits: use replyIdempotencyKey for the posts.
143
+ */
144
+ get api() {
145
+ return this[internal].runtime.api;
146
+ }
147
+ /** The logger of the bot, with the event's attributes. */
148
+ get logger() {
149
+ const { logger } = this[internal].runtime;
150
+ if (this.notification !== undefined)
151
+ return withAttributes(logger, { notification: this.notification.id, type: this.notification.type });
152
+ if (this.status !== undefined)
153
+ return withAttributes(logger, { status: this.status.id });
154
+ if (this.job !== "")
155
+ return withAttributes(logger, { job: this.job, scheduledAt: new Date(this.scheduledAt ?? 0).toISOString() });
156
+ return logger;
157
+ }
158
+ /**
159
+ * Posts text as a reply to status. The reply mentions the sender alone, never the other accounts that the post
160
+ * mentions; its visibility is replyVisibility of the post's; and it carries the Idempotency-Key
161
+ * replyIdempotencyKey(event, n) for the n-th reply of the handler, so that processing the event again posts it once.
162
+ * It counts toward the reply limits of the sender and waits for the send budget.
163
+ */
164
+ async reply(text) {
165
+ const st = this.status;
166
+ if (st === undefined)
167
+ throw new NoStatusError();
168
+ const state = this[internal];
169
+ const { runtime } = state;
170
+ state.replies++;
171
+ const key = replyIdempotencyKey(state.source, state.replies);
172
+ const body = this.account !== undefined && this.account.id !== runtime.me.id ? `@${this.account.acct} ${text}` : text;
173
+ const limited = runtime.replyLimits !== null && this.account !== undefined;
174
+ const at = runtime.now();
175
+ if (limited && !runtime.replies.take(state.sender, at))
176
+ throw new ReplyLimitError();
177
+ try {
178
+ await this.#waitBudget("post");
179
+ }
180
+ catch (error) {
181
+ if (limited)
182
+ runtime.replies.untake(state.sender, at);
183
+ throw error;
184
+ }
185
+ const { data } = await runtime.api.POST("/statuses", {
186
+ params: { header: { "Idempotency-Key": key } },
187
+ body: { text: body, visibility: replyVisibility(st.visibility), in_reply_to_id: st.id },
188
+ signal: this.signal,
189
+ });
190
+ return answered(data, "createStatus");
191
+ }
192
+ /**
193
+ * Reacts to status with emoji: a Unicode emoji, or the shortcode of a custom emoji of the server. Reacting again
194
+ * with the same emoji changes nothing, so it needs no idempotency key. It waits for the send budget.
195
+ */
196
+ async react(emoji) {
197
+ if (this.status === undefined)
198
+ throw new NoStatusError();
199
+ await this.#waitBudget("write");
200
+ await this.api.PUT("/statuses/{id}/reaction", { params: { path: { id: this.status.id } }, body: { emoji }, signal: this.signal });
201
+ }
202
+ /** Favourites status. A second favourite changes nothing. It waits for the send budget. */
203
+ async favourite() {
204
+ if (this.status === undefined)
205
+ throw new NoStatusError();
206
+ await this.#waitBudget("write");
207
+ await this.api.POST("/statuses/{id}/favourite", { params: { path: { id: this.status.id } }, signal: this.signal });
208
+ }
209
+ /** Follows the sender (for a locked account, requests to). Following again changes nothing. It waits for the send budget. */
210
+ async follow() {
211
+ if (this.account === undefined)
212
+ throw new NoAccountError();
213
+ await this.#waitBudget("write");
214
+ await this.api.POST("/accounts/{id}/follow", { params: { path: { id: this.account.id } }, body: {}, signal: this.signal });
215
+ }
216
+ /**
217
+ * Posts text from the handler of a scheduled job, with the visibility of its EveryOptions and the Idempotency-Key
218
+ * everyIdempotencyKey(job, scheduledAt): a run posts once, even when two processes of the bot run it, or a restart
219
+ * runs it again. It waits for the send budget. Outside a scheduled job it throws a NotScheduledError, and a second
220
+ * post of a run a PostedAlreadyError.
221
+ */
222
+ async post(text) {
223
+ const state = this[internal];
224
+ const { every } = state;
225
+ if (every === undefined || this.scheduledAt === undefined)
226
+ throw new NotScheduledError();
227
+ if (state.posted)
228
+ throw new PostedAlreadyError();
229
+ await this.#waitBudget("post");
230
+ state.posted = true;
231
+ const { data } = await state.runtime.api.POST("/statuses", {
232
+ params: { header: { "Idempotency-Key": everyIdempotencyKey(every.name, this.scheduledAt) } },
233
+ body: { text, visibility: every.visibility },
234
+ signal: this.signal,
235
+ });
236
+ return answered(data, "createStatus");
237
+ }
238
+ /**
239
+ * Waits for the send budget of kind when the sendBudget middleware is in the chain. A wait longer than the
240
+ * maxSendWait option drops the write, logged, with a BudgetExceededError.
241
+ */
242
+ async #waitBudget(kind) {
243
+ const state = this[internal];
244
+ if (!state.budgeted)
245
+ return;
246
+ const { runtime } = state;
247
+ const wait = runtime.budget.reserve(kind, runtime.now(), runtime.maxSendWait);
248
+ if (wait === null) {
249
+ this.logger.warn("mybot: dropped a write that would wait too long for the send budget", { maxWait: runtime.maxSendWait });
250
+ throw new BudgetExceededError();
251
+ }
252
+ if (wait > 0)
253
+ await runtime.sleep(wait, this.signal);
254
+ }
255
+ }
@@ -0,0 +1,60 @@
1
+ import type { components } from "@mycroblog/mykit";
2
+ import type { EveryJob } from "./every.js";
3
+ type Notification = components["schemas"]["Notification"];
4
+ type Status = components["schemas"]["Status"];
5
+ type Account = components["schemas"]["Account"];
6
+ /** One job to run: a notification, a post of the home timeline, or the run at of a scheduled job. */
7
+ export type Job = {
8
+ notification: Notification;
9
+ status?: undefined;
10
+ every?: undefined;
11
+ sender: string;
12
+ } | {
13
+ notification?: undefined;
14
+ status: Status;
15
+ every?: undefined;
16
+ sender: string;
17
+ } | {
18
+ notification?: undefined;
19
+ status?: undefined;
20
+ every: EveryJob;
21
+ at: number;
22
+ sender: string;
23
+ };
24
+ /** The key of the account that acted, "" for an event without one. */
25
+ export declare function senderOf(account: Account | undefined): string;
26
+ /**
27
+ * Runs the jobs: up to its limit at once, those of one sender one after the other in the order they came (ADR-0094
28
+ * §4). A notification that is queued or running is not queued again, so that the catch-up and the event stream may
29
+ * both bring it. run must not reject.
30
+ */
31
+ export declare class Dispatcher {
32
+ #private;
33
+ constructor(concurrency: number, run: (job: Job) => Promise<void>);
34
+ /** Queues job and reports whether it did: not after stop, and not for a notification already queued. */
35
+ submit(job: Job): boolean;
36
+ /** Makes the dispatcher take no more jobs and start none of those queued. */
37
+ stop(): void;
38
+ /** Resolves when every running job has returned. */
39
+ idle(): Promise<void>;
40
+ }
41
+ /**
42
+ * Computes the position of the catch-up (ADR-0094 §3): the newest notification up to which every notification handed
43
+ * to the bot was processed. Handlers end out of order, so a processed notification moves the position only when no
44
+ * older one is still pending; while a catch-up hands out notifications, the position holds still, so that a restart
45
+ * in the middle of it reads them again. Notification IDs are UUIDv7 strings, whose order is the order of time.
46
+ */
47
+ export declare class Tracker {
48
+ #private;
49
+ /** Marks the notification id pending. */
50
+ add(id: string): void;
51
+ /** Drops a pending notification that was not handed out after all. */
52
+ forget(id: string): void;
53
+ /** Marks the notification id processed and returns the new position, null when it does not move. */
54
+ complete(id: string): string | null;
55
+ /** Keeps the position where it is until release. */
56
+ hold(): void;
57
+ /** Ends a hold and returns the new position, null when it does not move. */
58
+ release(): string | null;
59
+ }
60
+ export {};