@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,156 @@
1
+ import { type components } from "@mycroblog/mykit";
2
+ import type { BotOptions } from "../bot.js";
3
+ import type { Logger } from "../logger.js";
4
+ import { type Store } from "../store.js";
5
+ import { Clock } from "./clock.js";
6
+ type Notification = components["schemas"]["Notification"];
7
+ type NotificationType = components["schemas"]["NotificationType"];
8
+ type Status = components["schemas"]["Status"];
9
+ type Account = components["schemas"]["Account"];
10
+ type CredentialAccount = components["schemas"]["CredentialAccount"];
11
+ type Visibility = components["schemas"]["Visibility"];
12
+ type CreateStatusRequest = components["schemas"]["CreateStatusRequest"];
13
+ /** The domain of the server's accounts, and of its origin https://bot.test. */
14
+ export declare const domain = "bot.test";
15
+ /** The access token of Server.options; the server answers 401 to a request without an access token. */
16
+ export declare const token = "mybottest-token";
17
+ /** A post that the bot created with createStatus. */
18
+ export type Post = {
19
+ /** The Idempotency-Key of the request, such as replyIdempotencyKey of the event; null without one. */
20
+ readonly idempotencyKey: string | null;
21
+ /** The body of the request. */
22
+ readonly request: CreateStatusRequest;
23
+ /** The post as the server answered it. */
24
+ readonly status: Status;
25
+ };
26
+ /** An emoji reaction of the bot to a post (reactToStatus). */
27
+ export type Reaction = {
28
+ readonly statusId: string;
29
+ readonly emoji: string;
30
+ };
31
+ /** The options of the waits of Server. */
32
+ export type WaitOptions = {
33
+ /** Gives up waiting when it aborts; after 10 seconds by default. */
34
+ signal?: AbortSignal;
35
+ };
36
+ /** The options of new Server. */
37
+ export type ServerOptions = {
38
+ /**
39
+ * The time at which the server's clock starts, in milliseconds since the Unix epoch; the current time by default.
40
+ * A test that moves the clock to fixed times (clock.set) starts it at or before the first of them, so that it runs
41
+ * the same on any day.
42
+ */
43
+ now?: number;
44
+ };
45
+ /** A logger that drops everything, the logger of Server.options. */
46
+ export declare const quietLogger: Logger;
47
+ /**
48
+ * A fake server of the client API for the tests of a bot (ADR-0094 §10): a fetch, which Server.options hands the bot,
49
+ * that answers the operations that mybot calls (verifyAccountCredentials, updateAccountCredentials,
50
+ * listNotifications, streamEvents, createStatus, and those of the helpers of Context: reactToStatus, favouriteStatus,
51
+ * followAccount) with the types of api/openapi.yaml, and keeps in memory what the bot did. inject sends a notification
52
+ * to the bot, posted returns what it posted, and clock moves the time of its scheduled jobs. It mirrors mybottest of
53
+ * the Go library.
54
+ *
55
+ * ```ts
56
+ * const srv = new Server();
57
+ * const bot = new Bot(srv.options());
58
+ * bot.onCommand("ping", "answer pong", async (ctx) => {
59
+ * await ctx.reply("pong");
60
+ * });
61
+ * const controller = new AbortController();
62
+ * const done = bot.run({ signal: controller.signal });
63
+ * srv.inject(srv.mention(srv.account("alice"), "ping", "public"));
64
+ * const [post] = await srv.waitPosted(1); // post.request.text === "@alice pong"
65
+ * controller.abort();
66
+ * await done;
67
+ * ```
68
+ *
69
+ * injectMissed adds a notification that only the bot's catch-up finds, such as one that came while the bot was
70
+ * stopped; dropStreams ends the event streams, for the bot to connect again and catch up, and disconnect holds its
71
+ * connections until reconnect, so that the notifications of inject meanwhile come by the catch-up alone. Like the
72
+ * server, it posts once per Idempotency-Key. It answers what the library needs and no more: it keeps no timelines,
73
+ * follows, or visibility rules.
74
+ */
75
+ export declare class Server {
76
+ #private;
77
+ /** The origin of the server, for the option server of a bot. */
78
+ readonly url = "https://bot.test";
79
+ /** The clock of the server: options sets it as the bot's, and the notifications and posts take its time. */
80
+ readonly clock: Clock;
81
+ /**
82
+ * A server whose own account, me, is the bot account "bot" with the bot flag, and whose clock is at the current
83
+ * time, or at the option now.
84
+ */
85
+ constructor(options?: ServerOptions);
86
+ /**
87
+ * The options of a bot of the server: its url and fetch, the access token token, a MemoryStore, the server's clock,
88
+ * and a logger that drops everything. Override what the test needs.
89
+ */
90
+ options(extra?: Partial<BotOptions>): BotOptions & {
91
+ store: Store;
92
+ };
93
+ /** Ends the event streams and lets the held connections through. */
94
+ close(): void;
95
+ /** The bot's own account, as verifyAccountCredentials answers it. */
96
+ get me(): CredentialAccount;
97
+ /** Sets the bot flag of the bot's own account, such as to off to test the option setBotFlag. */
98
+ setBot(on: boolean): void;
99
+ /** A new account of another user, with acct such as "alice" (a local account) or "alice@remote.example" (a remote one). */
100
+ account(acct: string): Account;
101
+ /**
102
+ * A new post of from with the plain text text, whose content mentions the accounts of mentions (none for a post that
103
+ * mentions nobody) at its start, as the server renders them.
104
+ */
105
+ status(from: Account, text: string, visibility: Visibility, ...mentions: Account[]): Status;
106
+ /**
107
+ * A new mention notification: a post of from with visibility that mentions the bot, then says text, such as
108
+ * mention(alice, "ping", "public") for "@bot ping". Hand it to inject.
109
+ */
110
+ mention(from: Account, text: string, visibility: Visibility): Notification;
111
+ /** A new notification of type from from about st (none for one without a post, such as a follow). Hand it to inject. */
112
+ notification(type: NotificationType, from: Account, st?: Status): Notification;
113
+ /**
114
+ * Adds the notification n and sends it on the open event streams, as the server does when it happens. An empty ID
115
+ * and creation time are filled in (a new ID, newer than those before, and the clock's time); it returns n as it was
116
+ * added. A bot whose stream is not open yet finds it when it catches up.
117
+ */
118
+ inject(n: Notification): Notification;
119
+ /**
120
+ * Adds the notification n without sending it on the event streams, as one that came while the bot was stopped: only
121
+ * the bot's catch-up (listNotifications) finds it, when the bot starts or connects again. The catch-up reads back to
122
+ * the newest notification that the bot processed, so add the missed ones before the bot runs, or between disconnect
123
+ * and reconnect. It fills n in as inject does.
124
+ */
125
+ injectMissed(n: Notification): Notification;
126
+ /** Sends the post st on the open event streams as a new post of the home timeline (the event update, Bot.onStatus). */
127
+ injectStatus(st: Status): void;
128
+ /** Ends every open event stream, as the server does when it shuts down: the bot connects again and catches up. */
129
+ dropStreams(): void;
130
+ /**
131
+ * Ends every open event stream and holds the bot's next connections until reconnect, as a network that is down: the
132
+ * notifications that inject adds meanwhile reach the bot only by its catch-up after reconnect.
133
+ */
134
+ disconnect(): void;
135
+ /** Lets the connections that disconnect holds through. */
136
+ reconnect(): void;
137
+ /** How many event streams are open. */
138
+ get streams(): number;
139
+ /** The notifications of the server, newest first, as listNotifications answers them. */
140
+ notifications(): Notification[];
141
+ /** The posts that the bot created, oldest first: one per Idempotency-Key. */
142
+ posted(): Post[];
143
+ /** The reactions of the bot, oldest first; reacting again with the same emoji adds none. */
144
+ reactions(): Reaction[];
145
+ /** The IDs of the posts that the bot favourited, oldest first. */
146
+ favourites(): string[];
147
+ /** The IDs of the accounts that the bot followed, oldest first. */
148
+ follows(): string[];
149
+ /** Waits until the bot has created at least n posts and resolves to them; rejects when the signal of options aborts first. */
150
+ waitPosted(n: number, { signal }?: WaitOptions): Promise<Post[]>;
151
+ /** Waits until at least n event streams are open; rejects when the signal of options aborts first. */
152
+ waitStreams(n: number, { signal }?: WaitOptions): Promise<void>;
153
+ /** Answers a request of the bot: the option fetch of a bot. */
154
+ readonly fetch: (request: Request) => Promise<Response>;
155
+ }
156
+ export {};
@@ -0,0 +1,512 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { staticToken } from "@mycroblog/mykit";
3
+ import { MemoryStore } from "../store.js";
4
+ import { Clock, waitFor } from "./clock.js";
5
+ /** The domain of the server's accounts, and of its origin https://bot.test. */
6
+ export const domain = "bot.test";
7
+ /** The access token of Server.options; the server answers 401 to a request without an access token. */
8
+ export const token = "mybottest-token";
9
+ /** The interval of the comments that keep an event stream alive, as the server sends them. */
10
+ const heartbeat = 30_000;
11
+ /** A logger that drops everything, the logger of Server.options. */
12
+ export const quietLogger = { debug() { }, info() { }, warn() { }, error() { } };
13
+ /** Escapes text for HTML. */
14
+ function escapeHTML(text) {
15
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&#34;").replace(/'/g, "&#39;");
16
+ }
17
+ /** An account of acct ("alice" local, "alice@remote.example" remote), with every field that the spec requires. */
18
+ function newAccount(id, acct, createdAt) {
19
+ const at = acct.indexOf("@");
20
+ const username = at < 0 ? acct : acct.slice(0, at);
21
+ const d = at < 0 ? domain : acct.slice(at + 1);
22
+ return {
23
+ id,
24
+ username,
25
+ acct,
26
+ domain: d,
27
+ uri: `https://${d}/users/${username}`,
28
+ display_name: "",
29
+ note: "",
30
+ url: `https://${d}/@${username}`,
31
+ locked: false,
32
+ bot: false,
33
+ created_at: new Date(createdAt).toISOString(),
34
+ fields: [],
35
+ emojis: [],
36
+ badges: [],
37
+ };
38
+ }
39
+ /** The account of a post of the bot's own account. */
40
+ function accountOf(me) {
41
+ const { discoverable: _d, indexable: _i, ai_opt_out: _a, source: _s, posting: _p, permissions: _m, ...account } = me;
42
+ return account;
43
+ }
44
+ /**
45
+ * A fake server of the client API for the tests of a bot (ADR-0094 §10): a fetch, which Server.options hands the bot,
46
+ * that answers the operations that mybot calls (verifyAccountCredentials, updateAccountCredentials,
47
+ * listNotifications, streamEvents, createStatus, and those of the helpers of Context: reactToStatus, favouriteStatus,
48
+ * followAccount) with the types of api/openapi.yaml, and keeps in memory what the bot did. inject sends a notification
49
+ * to the bot, posted returns what it posted, and clock moves the time of its scheduled jobs. It mirrors mybottest of
50
+ * the Go library.
51
+ *
52
+ * ```ts
53
+ * const srv = new Server();
54
+ * const bot = new Bot(srv.options());
55
+ * bot.onCommand("ping", "answer pong", async (ctx) => {
56
+ * await ctx.reply("pong");
57
+ * });
58
+ * const controller = new AbortController();
59
+ * const done = bot.run({ signal: controller.signal });
60
+ * srv.inject(srv.mention(srv.account("alice"), "ping", "public"));
61
+ * const [post] = await srv.waitPosted(1); // post.request.text === "@alice pong"
62
+ * controller.abort();
63
+ * await done;
64
+ * ```
65
+ *
66
+ * injectMissed adds a notification that only the bot's catch-up finds, such as one that came while the bot was
67
+ * stopped; dropStreams ends the event streams, for the bot to connect again and catch up, and disconnect holds its
68
+ * connections until reconnect, so that the notifications of inject meanwhile come by the catch-up alone. Like the
69
+ * server, it posts once per Idempotency-Key. It answers what the library needs and no more: it keeps no timelines,
70
+ * follows, or visibility rules.
71
+ */
72
+ export class Server {
73
+ /** The origin of the server, for the option server of a bot. */
74
+ url = `https://${domain}`;
75
+ /** The clock of the server: options sets it as the bot's, and the notifications and posts take its time. */
76
+ clock;
77
+ #seq = 0;
78
+ #me;
79
+ /** Newest first. */
80
+ #notifications = [];
81
+ #statuses = new Map();
82
+ #posts = [];
83
+ /** Idempotency-Key to the index in #posts. */
84
+ #keys = new Map();
85
+ #reactions = [];
86
+ #favourites = [];
87
+ #follows = [];
88
+ #streams = new Set();
89
+ /** While disconnect holds the connections, the resolvers of the held ones. */
90
+ #held = null;
91
+ #listeners = new Set();
92
+ /**
93
+ * A server whose own account, me, is the bot account "bot" with the bot flag, and whose clock is at the current
94
+ * time, or at the option now.
95
+ */
96
+ constructor(options = {}) {
97
+ this.clock = new Clock(options.now ?? Math.floor(Date.now() / 1000) * 1000);
98
+ const me = newAccount(this.#newId(), "bot", this.clock.now());
99
+ this.#me = {
100
+ ...me,
101
+ bot: true,
102
+ discoverable: false,
103
+ indexable: false,
104
+ ai_opt_out: false,
105
+ source: { note: "", fields: [] },
106
+ posting: { visibilities: ["public", "local", "unlisted", "private", "direct"], visibility_ceiling: "public" },
107
+ permissions: [],
108
+ };
109
+ }
110
+ /**
111
+ * The options of a bot of the server: its url and fetch, the access token token, a MemoryStore, the server's clock,
112
+ * and a logger that drops everything. Override what the test needs.
113
+ */
114
+ options(extra = {}) {
115
+ return {
116
+ server: this.url,
117
+ fetch: this.fetch,
118
+ tokenSource: staticToken(token),
119
+ store: new MemoryStore(),
120
+ now: this.clock.now,
121
+ sleep: this.clock.sleep,
122
+ logger: quietLogger,
123
+ ...extra,
124
+ };
125
+ }
126
+ /** Ends the event streams and lets the held connections through. */
127
+ close() {
128
+ this.reconnect();
129
+ this.dropStreams();
130
+ }
131
+ /** The bot's own account, as verifyAccountCredentials answers it. */
132
+ get me() {
133
+ return this.#me;
134
+ }
135
+ /** Sets the bot flag of the bot's own account, such as to off to test the option setBotFlag. */
136
+ setBot(on) {
137
+ this.#me = { ...this.#me, bot: on };
138
+ }
139
+ /** A new account of another user, with acct such as "alice" (a local account) or "alice@remote.example" (a remote one). */
140
+ account(acct) {
141
+ return newAccount(this.#newId(), acct, this.clock.now());
142
+ }
143
+ /**
144
+ * A new post of from with the plain text text, whose content mentions the accounts of mentions (none for a post that
145
+ * mentions nobody) at its start, as the server renders them.
146
+ */
147
+ status(from, text, visibility, ...mentions) {
148
+ const st = this.#newStatus(from, text, visibility);
149
+ st.mentions = mentions.map((m) => ({ id: m.id, acct: m.acct, username: m.username, url: m.url }));
150
+ const prefix = mentions
151
+ .map((m) => `<span class="h-card"><a href="${escapeHTML(m.url)}" class="u-url mention">@<span>${escapeHTML(m.username)}</span></a></span> `)
152
+ .join("");
153
+ st.content = `<p>${prefix}${escapeHTML(text)}</p>`;
154
+ this.#statuses.set(st.id, st);
155
+ return st;
156
+ }
157
+ /**
158
+ * A new mention notification: a post of from with visibility that mentions the bot, then says text, such as
159
+ * mention(alice, "ping", "public") for "@bot ping". Hand it to inject.
160
+ */
161
+ mention(from, text, visibility) {
162
+ const st = this.status(from, text, visibility, accountOf(this.#me));
163
+ return this.notification("mention", from, st);
164
+ }
165
+ /** A new notification of type from from about st (none for one without a post, such as a follow). Hand it to inject. */
166
+ notification(type, from, st) {
167
+ return { id: "", type, created_at: "", account: from, ...(st === undefined ? {} : { status: st }) };
168
+ }
169
+ /**
170
+ * Adds the notification n and sends it on the open event streams, as the server does when it happens. An empty ID
171
+ * and creation time are filled in (a new ID, newer than those before, and the clock's time); it returns n as it was
172
+ * added. A bot whose stream is not open yet finds it when it catches up.
173
+ */
174
+ inject(n) {
175
+ const added = this.#add(n);
176
+ this.#broadcast("notification", added);
177
+ return added;
178
+ }
179
+ /**
180
+ * Adds the notification n without sending it on the event streams, as one that came while the bot was stopped: only
181
+ * the bot's catch-up (listNotifications) finds it, when the bot starts or connects again. The catch-up reads back to
182
+ * the newest notification that the bot processed, so add the missed ones before the bot runs, or between disconnect
183
+ * and reconnect. It fills n in as inject does.
184
+ */
185
+ injectMissed(n) {
186
+ return this.#add(n);
187
+ }
188
+ /** Sends the post st on the open event streams as a new post of the home timeline (the event update, Bot.onStatus). */
189
+ injectStatus(st) {
190
+ this.#statuses.set(st.id, st);
191
+ this.#broadcast("update", st);
192
+ }
193
+ /** Ends every open event stream, as the server does when it shuts down: the bot connects again and catches up. */
194
+ dropStreams() {
195
+ for (const s of this.#streams)
196
+ s.close();
197
+ this.#streams.clear();
198
+ this.#notify();
199
+ }
200
+ /**
201
+ * Ends every open event stream and holds the bot's next connections until reconnect, as a network that is down: the
202
+ * notifications that inject adds meanwhile reach the bot only by its catch-up after reconnect.
203
+ */
204
+ disconnect() {
205
+ this.#held ??= [];
206
+ this.dropStreams();
207
+ }
208
+ /** Lets the connections that disconnect holds through. */
209
+ reconnect() {
210
+ const held = this.#held;
211
+ this.#held = null;
212
+ for (const resolve of held ?? [])
213
+ resolve();
214
+ }
215
+ /** How many event streams are open. */
216
+ get streams() {
217
+ return this.#streams.size;
218
+ }
219
+ /** The notifications of the server, newest first, as listNotifications answers them. */
220
+ notifications() {
221
+ return [...this.#notifications];
222
+ }
223
+ /** The posts that the bot created, oldest first: one per Idempotency-Key. */
224
+ posted() {
225
+ return [...this.#posts];
226
+ }
227
+ /** The reactions of the bot, oldest first; reacting again with the same emoji adds none. */
228
+ reactions() {
229
+ return [...this.#reactions];
230
+ }
231
+ /** The IDs of the posts that the bot favourited, oldest first. */
232
+ favourites() {
233
+ return [...this.#favourites];
234
+ }
235
+ /** The IDs of the accounts that the bot followed, oldest first. */
236
+ follows() {
237
+ return [...this.#follows];
238
+ }
239
+ /** Waits until the bot has created at least n posts and resolves to them; rejects when the signal of options aborts first. */
240
+ async waitPosted(n, { signal = AbortSignal.timeout(10_000) } = {}) {
241
+ await waitFor(this.#listeners, () => this.#posts.length >= n, signal, `${String(n)} posts`);
242
+ return this.posted();
243
+ }
244
+ /** Waits until at least n event streams are open; rejects when the signal of options aborts first. */
245
+ waitStreams(n, { signal = AbortSignal.timeout(10_000) } = {}) {
246
+ return waitFor(this.#listeners, () => this.#streams.size >= n, signal, `${String(n)} event streams`);
247
+ }
248
+ #notify() {
249
+ for (const listener of this.#listeners)
250
+ listener();
251
+ }
252
+ /** A new UUIDv7-shaped ID, after every ID before it in their order, as the server's IDs are. */
253
+ #newId() {
254
+ this.#seq++;
255
+ return `01900000-0000-7000-8000-${String(this.#seq).padStart(12, "0")}`;
256
+ }
257
+ #add(n) {
258
+ const added = {
259
+ ...n,
260
+ id: n.id === "" ? this.#newId() : n.id,
261
+ created_at: n.created_at === "" ? new Date(this.clock.now()).toISOString() : n.created_at,
262
+ };
263
+ if (added.status !== undefined)
264
+ this.#statuses.set(added.status.id, added.status);
265
+ this.#notifications.unshift(added);
266
+ this.#notify();
267
+ return added;
268
+ }
269
+ #broadcast(event, data) {
270
+ const text = JSON.stringify(data);
271
+ for (const s of this.#streams)
272
+ s.send(event, text);
273
+ }
274
+ /** A post of account by the server, at the clock's time. */
275
+ #newStatus(account, text, visibility) {
276
+ const id = this.#newId();
277
+ return {
278
+ id,
279
+ uri: `https://${domain}/statuses/${id}`,
280
+ account,
281
+ content: `<p>${escapeHTML(text)}</p>`,
282
+ spoiler_text: "",
283
+ sensitive: false,
284
+ visibility,
285
+ language: "und",
286
+ created_at: new Date(this.clock.now()).toISOString(),
287
+ media_attachments: [],
288
+ mentions: [],
289
+ tags: [],
290
+ emojis: [],
291
+ replies_count: 0,
292
+ boosts_count: 0,
293
+ favourites_count: 0,
294
+ reactions: [],
295
+ };
296
+ }
297
+ /** Answers a request of the bot: the option fetch of a bot. */
298
+ fetch = async (request) => {
299
+ if (!(request.headers.get("Authorization") ?? "").startsWith("Bearer ")) {
300
+ return problem(401, "/api/v1/problems/unauthenticated", "The operation needs an access token.", { "WWW-Authenticate": 'Bearer error="invalid_token"' });
301
+ }
302
+ const url = new URL(request.url);
303
+ const route = `${request.method} ${url.pathname}`;
304
+ switch (route) {
305
+ case "GET /api/v1/accounts/verify_credentials":
306
+ return json(this.#me);
307
+ case "PATCH /api/v1/accounts/update_credentials":
308
+ return this.#updateCredentials(request);
309
+ case "GET /api/v1/notifications":
310
+ return this.#listNotifications(url);
311
+ case "GET /api/v1/streaming":
312
+ return this.#streamEvents(request);
313
+ case "POST /api/v1/statuses":
314
+ return this.#createStatus(request);
315
+ default:
316
+ break;
317
+ }
318
+ const reaction = /^PUT \/api\/v1\/statuses\/([^/]+)\/reaction$/.exec(route);
319
+ if (reaction !== null)
320
+ return this.#reactToStatus(decodeURIComponent(reaction[1] ?? ""), request);
321
+ const favourite = /^POST \/api\/v1\/statuses\/([^/]+)\/favourite$/.exec(route);
322
+ if (favourite !== null)
323
+ return this.#favouriteStatus(decodeURIComponent(favourite[1] ?? ""));
324
+ const follow = /^POST \/api\/v1\/accounts\/([^/]+)\/follow$/.exec(route);
325
+ if (follow !== null)
326
+ return this.#followAccount(decodeURIComponent(follow[1] ?? ""));
327
+ return problem(404, "about:blank", `mybot/testing does not serve ${request.method} ${url.pathname}.`);
328
+ };
329
+ async #updateCredentials(request) {
330
+ const body = (await request.json());
331
+ this.#me = {
332
+ ...this.#me,
333
+ ...(body.bot === undefined ? {} : { bot: body.bot }),
334
+ ...(body.display_name === undefined ? {} : { display_name: body.display_name }),
335
+ };
336
+ return json(this.#me);
337
+ }
338
+ /** The notifications newest first, a page of limit (20 by default, 40 at most) from the opaque cursor. */
339
+ #listNotifications(url) {
340
+ const q = url.searchParams;
341
+ let limit = 20;
342
+ const l = q.get("limit");
343
+ if (l !== null) {
344
+ limit = Number(l);
345
+ if (!/^[0-9]+$/.test(l) || limit < 1 || limit > 40)
346
+ return problem(400, "about:blank", "limit is from 1 to 40.");
347
+ }
348
+ const types = q.getAll("types");
349
+ let items = this.notifications();
350
+ if (types.length > 0)
351
+ items = items.filter((n) => types.includes(n.type));
352
+ // The cursor is the ID of the last notification of the previous page.
353
+ const cursor = q.get("cursor");
354
+ let start = 0;
355
+ if (cursor !== null && cursor !== "") {
356
+ start = items.findIndex((n) => n.id < cursor);
357
+ if (start < 0)
358
+ start = items.length;
359
+ }
360
+ const end = Math.min(start + limit, items.length);
361
+ const last = items[end - 1];
362
+ return json({ items: items.slice(start, end), ...(end < items.length && last !== undefined ? { next_cursor: last.id } : {}) });
363
+ }
364
+ async #streamEvents(request) {
365
+ const held = this.#held;
366
+ if (held !== null) {
367
+ await new Promise((resolve) => {
368
+ held.push(resolve);
369
+ request.signal.addEventListener("abort", () => {
370
+ resolve();
371
+ }, { once: true });
372
+ });
373
+ if (request.signal.aborted)
374
+ return new Response(null, { status: 499 });
375
+ }
376
+ const encoder = new TextEncoder();
377
+ let controller;
378
+ let open = true;
379
+ const stream = {
380
+ send(event, data) {
381
+ if (open)
382
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${data}\n\n`));
383
+ },
384
+ close: () => {
385
+ end();
386
+ try {
387
+ controller.close();
388
+ }
389
+ catch {
390
+ // Cancelled by the client already.
391
+ }
392
+ },
393
+ };
394
+ const end = () => {
395
+ if (!open)
396
+ return;
397
+ open = false;
398
+ clearInterval(timer);
399
+ if (this.#streams.delete(stream))
400
+ this.#notify();
401
+ };
402
+ const body = new ReadableStream({
403
+ start(c) {
404
+ controller = c;
405
+ },
406
+ cancel() {
407
+ end();
408
+ },
409
+ });
410
+ request.signal.addEventListener("abort", () => {
411
+ stream.close();
412
+ }, { once: true });
413
+ const timer = setInterval(() => {
414
+ if (open)
415
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
416
+ }, heartbeat);
417
+ this.#streams.add(stream);
418
+ this.#notify();
419
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" } });
420
+ }
421
+ async #createStatus(request) {
422
+ const body = (await request.json());
423
+ if (body.visibility === undefined)
424
+ return problem(422, "about:blank", "visibility is required.");
425
+ const key = request.headers.get("Idempotency-Key");
426
+ const seen = key === null || key === "" ? undefined : this.#keys.get(key);
427
+ if (seen !== undefined)
428
+ return json(this.#posts[seen]?.status);
429
+ const st = this.#newStatus(accountOf(this.#me), body.text, body.visibility);
430
+ if (body.in_reply_to_id !== undefined) {
431
+ st.in_reply_to_id = body.in_reply_to_id;
432
+ const parent = this.#statuses.get(body.in_reply_to_id);
433
+ if (parent !== undefined)
434
+ st.in_reply_to_account_id = parent.account.id;
435
+ }
436
+ if (body.spoiler_text !== undefined)
437
+ st.spoiler_text = body.spoiler_text;
438
+ if (body.language !== undefined)
439
+ st.language = body.language;
440
+ this.#statuses.set(st.id, st);
441
+ if (key !== null && key !== "")
442
+ this.#keys.set(key, this.#posts.length);
443
+ this.#posts.push({ idempotencyKey: key, request: body, status: st });
444
+ this.#notify();
445
+ return json(st);
446
+ }
447
+ async #reactToStatus(id, request) {
448
+ const body = (await request.json());
449
+ if (body.emoji === undefined || body.emoji === "")
450
+ return problem(400, "about:blank", "emoji is required.");
451
+ const st = this.#statuses.get(id);
452
+ if (st === undefined)
453
+ return problem(404, "about:blank", `No status ${id}.`);
454
+ const emoji = body.emoji;
455
+ if (!this.#reactions.some((r) => r.statusId === id && r.emoji === emoji)) {
456
+ this.#reactions.push({ statusId: id, emoji });
457
+ this.#notify();
458
+ }
459
+ return json(st);
460
+ }
461
+ #favouriteStatus(id) {
462
+ const st = this.#statuses.get(id);
463
+ if (st === undefined)
464
+ return problem(404, "about:blank", `No status ${id}.`);
465
+ if (!this.#favourites.includes(id)) {
466
+ this.#favourites.push(id);
467
+ this.#notify();
468
+ }
469
+ return json({ ...st, favourited: true });
470
+ }
471
+ #followAccount(id) {
472
+ if (!this.#follows.includes(id)) {
473
+ this.#follows.push(id);
474
+ this.#notify();
475
+ }
476
+ const relationship = {
477
+ id,
478
+ following: true,
479
+ requested: false,
480
+ followed_by: false,
481
+ requested_by: false,
482
+ show_boosts: true,
483
+ notify: false,
484
+ blocking: false,
485
+ muting: false,
486
+ muting_notifications: false,
487
+ };
488
+ return json(relationship);
489
+ }
490
+ }
491
+ function json(body) {
492
+ return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
493
+ }
494
+ /** An RFC 9457 problem document, as the server writes it. */
495
+ function problem(status, type, detail, headers = {}) {
496
+ return new Response(JSON.stringify({ type, title: statusText(status), status, detail }), {
497
+ status,
498
+ headers: { "Content-Type": "application/problem+json", "Cache-Control": "no-store", ...headers },
499
+ });
500
+ }
501
+ function statusText(status) {
502
+ switch (status) {
503
+ case 400:
504
+ return "Bad Request";
505
+ case 401:
506
+ return "Unauthorized";
507
+ case 404:
508
+ return "Not Found";
509
+ default:
510
+ return "Unprocessable Entity";
511
+ }
512
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Turns the HTML content of a post into plain text: the text of every element, mentions and links as they read
3
+ * (`@alice`, `https://example.com/`), line breaks for <br> and between paragraphs, and character references decoded.
4
+ * It is for reading a post, not for showing it: nothing of the markup survives.
5
+ */
6
+ export declare function plainText(content: string): string;