@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/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/bot.d.ts +199 -0
- package/dist/bot.js +760 -0
- package/dist/budget.d.ts +45 -0
- package/dist/budget.js +141 -0
- package/dist/context.d.ts +158 -0
- package/dist/context.js +255 -0
- package/dist/dispatch.d.ts +60 -0
- package/dist/dispatch.js +168 -0
- package/dist/every.d.ts +55 -0
- package/dist/every.js +22 -0
- package/dist/filestore.d.ts +17 -0
- package/dist/filestore.js +95 -0
- package/dist/handler.d.ts +42 -0
- package/dist/handler.js +93 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +18 -0
- package/dist/schedule.d.ts +36 -0
- package/dist/schedule.js +187 -0
- package/dist/store.d.ts +72 -0
- package/dist/store.js +115 -0
- package/dist/testing/clock.d.ts +32 -0
- package/dist/testing/clock.js +104 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/server.d.ts +156 -0
- package/dist/testing/server.js +512 -0
- package/dist/text.d.ts +6 -0
- package/dist/text.js +75 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The mycroblog/my contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: MIT -->
|
|
2
|
+
|
|
3
|
+
# @mycroblog/mybot
|
|
4
|
+
|
|
5
|
+
Build bots for [my](https://github.com/mycroblog/my), an ActivityPub microblog server, on its API client
|
|
6
|
+
[`@mycroblog/mykit`](https://github.com/mycroblog/my/blob/main/pkg/mykit/ts/README.md). You write the handlers of the bot's events; the library receives the
|
|
7
|
+
events so that none is lost or answered twice across disconnections and restarts, and keeps the bot polite by default.
|
|
8
|
+
It is ESM only and runs on Node.js 22 or later, Bun, and Deno; it depends on `@mycroblog/mykit` and on
|
|
9
|
+
[`croner`](https://github.com/hexagon/croner) (MIT, no dependencies), which computes the times of the scheduled jobs.
|
|
10
|
+
|
|
11
|
+
The Go library `github.com/mycroblog/my/pkg/mybot` has the same handlers, options, defaults, idempotency keys, and
|
|
12
|
+
state file; the [differences](#differences-from-the-go-library) are at the end.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @mycroblog/mybot
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Bot } from "@mycroblog/mybot";
|
|
22
|
+
|
|
23
|
+
const bot = new Bot({
|
|
24
|
+
server: "https://example.com",
|
|
25
|
+
oauth: { clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET! },
|
|
26
|
+
refreshToken: process.env.REFRESH_TOKEN!,
|
|
27
|
+
userAgent: "echobot/1.0",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
bot.onCommand("ping", "answer pong", async (ctx) => {
|
|
31
|
+
await ctx.reply("pong");
|
|
32
|
+
});
|
|
33
|
+
bot.onMention(async (ctx) => {
|
|
34
|
+
await ctx.reply(ctx.text);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
process.once("SIGINT", () => controller.abort());
|
|
39
|
+
process.once("SIGTERM", () => controller.abort());
|
|
40
|
+
await bot.run({ signal: controller.signal });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`bot.run` verifies the account, receives the events until the signal aborts, then shuts down gracefully and resolves.
|
|
44
|
+
It rejects when the bot cannot start: `NotBotError`, `ScopeError` (with `missing`), or a `ProblemError` of the server.
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
Each example is a `createBot` in `bot.ts`, its tests against [`@mycroblog/mybot/testing`](#testing) in `bot.test.ts`,
|
|
49
|
+
and a `main.ts` that runs it; the Go library has the same examples.
|
|
50
|
+
|
|
51
|
+
| Example | What it does |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| [`examples/echo`](https://github.com/mycroblog/my/blob/main/pkg/mybot/ts/examples/echo) | replies to every mention with its text (`onMention`) |
|
|
54
|
+
| [`examples/schedule`](https://github.com/mycroblog/my/blob/main/pkg/mybot/ts/examples/schedule) | posts a greeting at 9:00 every morning in a time zone (`every` with `catchUp: "latest"`) |
|
|
55
|
+
| [`examples/commands`](https://github.com/mycroblog/my/blob/main/pkg/mybot/ts/examples/commands) | answers the commands `help`, `ping`, and `dice 2d6` (`onCommand`) |
|
|
56
|
+
|
|
57
|
+
The bots read `MYBOT_SERVER`, `MYBOT_CLIENT_ID`, `MYBOT_CLIENT_SECRET`, `MYBOT_REFRESH_TOKEN`, and `MYBOT_STATE` (the
|
|
58
|
+
state file, `./mybot-state.json` by default) from the environment ([`examples/env.ts`](https://github.com/mycroblog/my/blob/main/pkg/mybot/ts/examples/env.ts)):
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
MYBOT_SERVER=https://example.com MYBOT_CLIENT_ID=... MYBOT_CLIENT_SECRET=... MYBOT_REFRESH_TOKEN=... \
|
|
62
|
+
bun examples/echo/main.ts
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Getting a token
|
|
66
|
+
|
|
67
|
+
A bot always acts as a user: run it on an account of its own. The steps:
|
|
68
|
+
|
|
69
|
+
1. Sign in to the server as the bot's account and open **Settings → Development** (`/settings/apps`).
|
|
70
|
+
2. Register an app with the type **Confidential**. The form needs a redirect URI even when no redirect is used:
|
|
71
|
+
`http://127.0.0.1/callback` serves both this and the Go library's `examples/login`. Give it at least the scopes
|
|
72
|
+
below.
|
|
73
|
+
3. Copy the client ID and the secret, which is shown once.
|
|
74
|
+
4. Press **Issue token** on the app and choose the scopes (empty for all of the app's). The server issues an access
|
|
75
|
+
token and a refresh token for your own account, without a redirect, and shows them once.
|
|
76
|
+
5. Pass the client ID and the secret as `oauth` and the refresh token as `refreshToken`. The access token is not
|
|
77
|
+
needed: the bot gets a new one with the refresh token.
|
|
78
|
+
|
|
79
|
+
To authorize a bot through the consent page instead, use the Authorization Code flow with PKCE (mykit's
|
|
80
|
+
`newCodeVerifier`, `codeChallenge`, and `exchange`): the Go library's
|
|
81
|
+
[`examples/login`](https://github.com/mycroblog/my/blob/main/pkg/mybot/examples/login) receives the code on a loopback
|
|
82
|
+
redirect and prints the refresh token, which works for a bot in either language.
|
|
83
|
+
|
|
84
|
+
The scopes the default handlers need are `read:accounts`, `read:notifications`, `read:statuses` (for the event
|
|
85
|
+
stream), and `write:statuses`, plus `write:accounts` with `setBotFlag`. A token without them stops the bot at start.
|
|
86
|
+
|
|
87
|
+
The server rotates the refresh token on every renewal and revokes the whole token family when a used one comes back.
|
|
88
|
+
The bot saves every rotated refresh token in its [store](#state) and, on the next start, uses the saved one rather than
|
|
89
|
+
`refreshToken`. A `tokenSource` (such as mykit's `staticToken`) instead of `oauth` is for tests and short runs: the bot
|
|
90
|
+
saves nothing for it.
|
|
91
|
+
|
|
92
|
+
## Receiving the events
|
|
93
|
+
|
|
94
|
+
- In the default mode `"streaming"` the bot opens the event stream (`GET /api/v1/streaming`; mykit reconnects it).
|
|
95
|
+
After every connection, the first and each reconnection, it reads the notifications newest first back to the position
|
|
96
|
+
in its store, or to `catchUpLimit` (24 hours; `0` turns the catch-up off), and handles those it missed, oldest first.
|
|
97
|
+
- The catch-up covers the time the stream is closed: a stop of the bot, or a cut connection. A notification that the
|
|
98
|
+
server sends while the stream stays open but that never reaches the bot, such as one a proxy drops, is older than the
|
|
99
|
+
next one the bot processes, and the catch-up does not find it
|
|
100
|
+
([#1291](https://github.com/mycroblog/my/issues/1291)). Behind a proxy that drops events, use `mode: "polling"`.
|
|
101
|
+
- Every notification is recorded in the store after its handler returns, and one recorded before is not handled again.
|
|
102
|
+
A process that stops in the middle of a handler handles that notification again on the next start: replies carry an
|
|
103
|
+
`Idempotency-Key` derived from the notification (`replyIdempotencyKey`), so the server posts each of them once.
|
|
104
|
+
- `mode: "polling"` reads the notifications every `pollInterval` (a minute) instead, for networks whose proxies do not
|
|
105
|
+
pass an event stream.
|
|
106
|
+
- `onStatus` receives the new posts of the home timeline from the event stream alone: they are not caught up after a
|
|
107
|
+
disconnection or a restart, and the polling mode does not receive them.
|
|
108
|
+
- Up to `concurrency` (4) handlers run at once; the events of one sender run one after the other.
|
|
109
|
+
|
|
110
|
+
## Handlers
|
|
111
|
+
|
|
112
|
+
| Handler | Runs for |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `onMention` | a mention, replies and direct posts included (`ctx.isReply`, `ctx.isDirect`), that is no command |
|
|
115
|
+
| `onCommand(name, description, h)` | a mention whose first word after the bot's own mentions is `name`, without case |
|
|
116
|
+
| `onFollow` / `onFollowRequest` | `follow` / `follow_request` |
|
|
117
|
+
| `onReaction` / `onFavourite` / `onBoost` / `onQuote` | the notification of the same name |
|
|
118
|
+
| `onNotification` | every notification that no handler above takes |
|
|
119
|
+
| `onStatus` | a new post of the home timeline (the bot's own excepted) |
|
|
120
|
+
|
|
121
|
+
The context `ctx` has the `notification`, its `status` and sender `account`, `text` (the post as plain text), `command`
|
|
122
|
+
and `args`, `me`, `api` (the mykit client), `logger`, and `signal`, which aborts when the bot stops. Its helpers
|
|
123
|
+
`reply(text)`, `react(emoji)`, `favourite()`, and `follow()` keep the defaults below. A handler that throws is logged,
|
|
124
|
+
and its notification counts as processed. `help` answers with the registered commands and their descriptions unless
|
|
125
|
+
`disableHelp` is set or a command `help` is registered.
|
|
126
|
+
|
|
127
|
+
Middleware is `(ctx, next) => Promise<void>`, added with `bot.use(...)` inside the default chain `recover`, `manners`,
|
|
128
|
+
`sendBudget` (`noDefaultMiddleware` leaves that chain out):
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
bot.use(async (ctx, next) => {
|
|
132
|
+
if (ctx.command === "shutdown" && ctx.account?.acct !== "admin") return; // skip the handler
|
|
133
|
+
await next();
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The log goes to `console`, or to any `logger` with `debug`, `info`, `warn`, and `error`; each call has a message and
|
|
138
|
+
an object of attributes.
|
|
139
|
+
|
|
140
|
+
## Scheduled jobs
|
|
141
|
+
|
|
142
|
+
`every` runs a handler at the times of a cron expression of five fields, minute hour day-of-month month day-of-week,
|
|
143
|
+
in UTC or the IANA time zone `timeZone`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
bot.every(
|
|
147
|
+
"0 9 * * *",
|
|
148
|
+
async (ctx) => {
|
|
149
|
+
await ctx.post("Good morning");
|
|
150
|
+
},
|
|
151
|
+
{ name: "morning", timeZone: "Asia/Tokyo" },
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- A field is `*`, a number, a range `a-b`, a step `*/n` or `a-b/n`, or a list of those; the day of the week is 0-7,
|
|
156
|
+
0 and 7 being Sunday, and a day matches when either day field does when both are other than `*`. A field of seconds,
|
|
157
|
+
the aliases such as `@daily`, the names of months and days, and `?`, `L`, `W`, and `#` throw a `ScheduleError`, so
|
|
158
|
+
that the Go library and this one take the same expressions. `parseSchedule(expr, timeZone)` gives the times without
|
|
159
|
+
a bot.
|
|
160
|
+
- The times are wall-clock times of the time zone: one that a daylight saving time change skips does not run, and one
|
|
161
|
+
that the change repeats runs once.
|
|
162
|
+
- `ctx.post(text)` posts once per run, with the visibility `unlisted` by default (`visibility: "public"` or `"local"`)
|
|
163
|
+
and the Idempotency-Key `mybot:every:<name>:<time in RFC 3339>` (`everyIdempotencyKey`): two processes of the bot, or
|
|
164
|
+
a restart in the middle of a run, post it once. `ctx.job` and `ctx.scheduledAt` name the run.
|
|
165
|
+
- The run counts as the job's last run in the store after the handler returns. The times missed while the bot was
|
|
166
|
+
stopped are skipped by default; `catchUp: "latest"` runs the newest of them once when the bot starts (for a job that
|
|
167
|
+
never ran, its newest past time), and never a pile of them.
|
|
168
|
+
- `name` (the expression by default) keys the last run and the idempotency key: keep it when the expression changes.
|
|
169
|
+
|
|
170
|
+
## Manners
|
|
171
|
+
|
|
172
|
+
All on by default, and each can be turned off but the first:
|
|
173
|
+
|
|
174
|
+
- The bot never handles the events of its own account.
|
|
175
|
+
- It does not answer accounts with the bot flag (`respondToBots: true` answers them).
|
|
176
|
+
- It replies to one sender at most 5 times in 10 minutes and 15 times in an hour; the events past that are skipped
|
|
177
|
+
(`replyLimits`, or `disableReplyLimits: true`).
|
|
178
|
+
- A reply is never more visible than the post it answers: `direct`, `private`, `local`, and `unlisted` keep theirs, and
|
|
179
|
+
a reply to a `public` post is `unlisted`. A `local` post reaches the accounts of the bot's server and its followers
|
|
180
|
+
alone.
|
|
181
|
+
- A reply mentions its sender alone, never the other accounts of the post.
|
|
182
|
+
- Writes wait for a budget of the client: 60 posts an hour and 60 other writes a minute (`postRate`, `writeRate`), and,
|
|
183
|
+
when less than a tenth of the server's rate limit remains (its `RateLimit` fields), for the limit to reset. A write
|
|
184
|
+
that would wait longer than `maxSendWait` (10 minutes) is dropped with a `BudgetExceededError` and logged.
|
|
185
|
+
- The bot refuses to start on an account without the bot flag, which tells people and other servers that a program
|
|
186
|
+
posts: turn on **This is a bot** in **Settings → Profile**, or pass `setBotFlag: true` (the bot sets it, with
|
|
187
|
+
`write:accounts`) or `allowNonBot: true`. Posting from an app never sets the flag by itself.
|
|
188
|
+
|
|
189
|
+
Writes through `ctx.api` skip the budget and the reply limits: give their posts an `Idempotency-Key` from
|
|
190
|
+
`replyIdempotencyKey`.
|
|
191
|
+
|
|
192
|
+
These are a floor: **read the rules of the server** (whether it allows bots, how often they may post) before you run
|
|
193
|
+
one, and set the limits lower when they ask for it.
|
|
194
|
+
|
|
195
|
+
## State
|
|
196
|
+
|
|
197
|
+
The `store` keeps the processed notifications (for 7 days, `processedRetention`), the position of the catch-up, the last
|
|
198
|
+
runs of the scheduled jobs, and the rotated refresh token. The default is a `FileStore` at `./mybot-state.json`: one JSON file, rewritten through a
|
|
199
|
+
temporary file and a rename, readable by its owner alone, in the same format as the Go library's. `MemoryStore` is for
|
|
200
|
+
tests: a restart loses the rotated refresh token, and the next start sends the used one, which revokes the token. A bot
|
|
201
|
+
that runs all the time, or is started again and again, needs a `FileStore` or a `Store` of its own (such as in a
|
|
202
|
+
database). Run one process per bot: the store takes no lock.
|
|
203
|
+
|
|
204
|
+
## Shutting down
|
|
205
|
+
|
|
206
|
+
When the signal aborts, the bot closes the event stream, takes no new events, and waits `shutdownGrace` (10 seconds) for
|
|
207
|
+
the running handlers. Then it aborts `ctx.signal` of those still running; their notifications stay unprocessed and are
|
|
208
|
+
handled again by the next start (without posting twice, through the idempotency keys). Give a container longer than
|
|
209
|
+
the grace period to stop: `docker stop` waits 10 seconds by default, so use `docker stop -t 30`.
|
|
210
|
+
|
|
211
|
+
## Running once
|
|
212
|
+
|
|
213
|
+
`bot.runOnce({ signal })` catches up the notifications since the store's position (back to `catchUpLimit`), runs each
|
|
214
|
+
scheduled job's newest past time after its last run, whatever its `catchUp`, waits for the handlers, and resolves: for a
|
|
215
|
+
bot that cron or a job runner such as Cloud Run jobs starts again and again. Such a bot needs a store that outlives the
|
|
216
|
+
process.
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
`@mycroblog/mybot/testing` tests a bot without a server. `Server` is a fake server of the client API behind a `fetch`
|
|
221
|
+
that answers the operations that the bot calls with the types of the API; `options()` gives a bot its `fetch`, a token,
|
|
222
|
+
a `MemoryStore`, and the server's `Clock`, which moves only when the test moves it:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { Bot } from "@mycroblog/mybot";
|
|
226
|
+
import { Server } from "@mycroblog/mybot/testing";
|
|
227
|
+
|
|
228
|
+
const srv = new Server();
|
|
229
|
+
const bot = new Bot(srv.options());
|
|
230
|
+
bot.onCommand("ping", "answer pong", async (ctx) => {
|
|
231
|
+
await ctx.reply("pong");
|
|
232
|
+
});
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const done = bot.run({ signal: controller.signal });
|
|
235
|
+
srv.inject(srv.mention(srv.account("alice"), "ping", "public"));
|
|
236
|
+
const [post] = await srv.waitPosted(1); // post.request.text is "@alice pong"
|
|
237
|
+
controller.abort();
|
|
238
|
+
await done;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `inject` sends a notification on the event stream; `injectMissed` adds one that only the catch-up finds;
|
|
242
|
+
`dropStreams`, `disconnect`, and `reconnect` cut the stream.
|
|
243
|
+
- `posted()`, `reactions()`, `favourites()`, and `follows()` return what the bot did; like the server, it posts once
|
|
244
|
+
per Idempotency-Key.
|
|
245
|
+
- `clock.waitForSleepers(n)` waits for the scheduled jobs (and the pruning of the store) to sleep, and
|
|
246
|
+
`clock.advance(ms)` or `clock.set(at)` moves them on. The clock starts at the current time; a test of fixed times
|
|
247
|
+
starts it before them with `new Server({ now: Date.parse("2026-10-25T08:59:00Z") })`, since `clock.set` cannot move
|
|
248
|
+
it back, so that the test runs the same on any day.
|
|
249
|
+
|
|
250
|
+
## Runtimes
|
|
251
|
+
|
|
252
|
+
The package uses `fetch`, web streams, and `AbortSignal`. `FileStore` loads `node:fs/promises` on first use, which
|
|
253
|
+
Node.js, Bun, and Deno provide; on Deno, run the bot with `--allow-net` and with `--allow-read` and `--allow-write` for
|
|
254
|
+
the state file. With another `Store`, nothing of `node:` is loaded.
|
|
255
|
+
|
|
256
|
+
## Differences from the Go library
|
|
257
|
+
|
|
258
|
+
- Durations are milliseconds, and times in the `Store` are milliseconds since the Unix epoch.
|
|
259
|
+
- A missing value of the `Store` is `null` (the Go library uses `""` and the zero time).
|
|
260
|
+
- Handlers throw instead of returning an error; the errors are classes (`NotBotError`, `ScopeError`,
|
|
261
|
+
`ReplyLimitError`, `BudgetExceededError`, `NoStatusError`, `NoAccountError`, `AlreadyRunningError`).
|
|
262
|
+
- Middleware is `(ctx, next) => Promise<void>` rather than a function that wraps a handler, and `ctx.signal` replaces
|
|
263
|
+
the `context.Context` of the handler.
|
|
264
|
+
- The scopes of a token are checked when they are known: for `oauth`, from the renewal; a `tokenSource` is not checked.
|
|
265
|
+
- `User-Agent` ends with `mybot-ts`.
|
|
266
|
+
- `catchUp` is `"skip"` or `"latest"`, and `ctx.scheduledAt` is `undefined` for an event.
|
|
267
|
+
- The clock is the options `now` and `sleep` rather than a `Clock` interface.
|
|
268
|
+
- The fake server of `@mycroblog/mybot/testing` is a `fetch` rather than an HTTP server, and its waits take a signal
|
|
269
|
+
(10 seconds by default) rather than a `context.Context`.
|
package/dist/bot.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { type OAuthConfig, type Sleep, type TokenSource } from "@mycroblog/mykit";
|
|
2
|
+
import { type Rate } from "./budget.js";
|
|
3
|
+
import { type Handler, type Middleware } from "./context.js";
|
|
4
|
+
import type { EveryOptions } from "./every.js";
|
|
5
|
+
import { type Command } from "./handler.js";
|
|
6
|
+
import type { Logger } from "./logger.js";
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
/**
|
|
9
|
+
* How a bot receives its notifications: "streaming", the default, from the event stream (GET /streaming), catching up
|
|
10
|
+
* with the notifications after every connection; "polling" by reading the notifications every pollInterval, for
|
|
11
|
+
* networks whose proxies do not pass an event stream.
|
|
12
|
+
*/
|
|
13
|
+
export type Mode = "streaming" | "polling";
|
|
14
|
+
/** The interval of the polling mode: a minute. */
|
|
15
|
+
export declare const defaultPollInterval = 60000;
|
|
16
|
+
/** How old a notification the catch-up still handles: 24 hours. */
|
|
17
|
+
export declare const defaultCatchUpLimit: number;
|
|
18
|
+
/** How long the processed notifications are remembered: 7 days, longer than the catch-up limit. */
|
|
19
|
+
export declare const defaultProcessedRetention: number;
|
|
20
|
+
/** How many handlers run at once. */
|
|
21
|
+
export declare const defaultConcurrency = 4;
|
|
22
|
+
/** How long the shutdown waits for the running handlers: 10 seconds. */
|
|
23
|
+
export declare const defaultShutdownGrace = 10000;
|
|
24
|
+
/** Configures a bot. Every option but server and the token has a default. Durations are in milliseconds. */
|
|
25
|
+
export type BotOptions = {
|
|
26
|
+
/** The origin of the server, such as "https://example.com". */
|
|
27
|
+
server: string;
|
|
28
|
+
/** Sends the requests; the global `fetch` by default. */
|
|
29
|
+
fetch?: (input: Request) => Promise<Response>;
|
|
30
|
+
/**
|
|
31
|
+
* oauth and refreshToken make the bot renew its access token with the refresh token (mykit's RefreshingTokenSource;
|
|
32
|
+
* ADR-0094 §7): the token that the server issued to the bot's own account for an app registered on its settings
|
|
33
|
+
* page, or that an authorization code was exchanged for. Every renewal rotates the refresh token, which the bot
|
|
34
|
+
* saves in the store; when the store holds one, the bot starts from it rather than from refreshToken, which the
|
|
35
|
+
* server has seen used. The server of oauth is that of the bot when absent.
|
|
36
|
+
*/
|
|
37
|
+
oauth?: Omit<OAuthConfig, "server"> & {
|
|
38
|
+
server?: string;
|
|
39
|
+
};
|
|
40
|
+
refreshToken?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Gives the access token when oauth is absent: mykit's staticToken for tests and short runs, or a source of one's
|
|
43
|
+
* own. The bot does not save its tokens.
|
|
44
|
+
*/
|
|
45
|
+
tokenSource?: TokenSource;
|
|
46
|
+
/** Keeps the state of the bot across restarts; a FileStore at defaultStatePath by default. */
|
|
47
|
+
store?: Store;
|
|
48
|
+
/** Receives the log of the bot; `console` by default. */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
/** The product of the bot, such as "echobot/1.0", sent before the library's own in User-Agent. */
|
|
51
|
+
userAgent?: string;
|
|
52
|
+
/** Sent as Accept-Language, for the language of the server's problems. */
|
|
53
|
+
acceptLanguage?: string;
|
|
54
|
+
/** "streaming" (the default) or "polling". */
|
|
55
|
+
mode?: Mode;
|
|
56
|
+
/** The interval of the polling mode; defaultPollInterval when absent. */
|
|
57
|
+
pollInterval?: number;
|
|
58
|
+
/**
|
|
59
|
+
* How old a notification the catch-up still handles: after a long stop, older ones are skipped. Absent means
|
|
60
|
+
* defaultCatchUpLimit; 0 turns the catch-up off (ADR-0094 §3).
|
|
61
|
+
*/
|
|
62
|
+
catchUpLimit?: number;
|
|
63
|
+
/** How long the processed notifications are remembered, longer than catchUpLimit; defaultProcessedRetention when absent. */
|
|
64
|
+
processedRetention?: number;
|
|
65
|
+
/** How many handlers run at once; the events of one sender run one after the other. defaultConcurrency when absent. */
|
|
66
|
+
concurrency?: number;
|
|
67
|
+
/**
|
|
68
|
+
* How long run waits for the running handlers when it stops; then it aborts their signals, and their events are
|
|
69
|
+
* handled again by the next start. defaultShutdownGrace when absent.
|
|
70
|
+
*/
|
|
71
|
+
shutdownGrace?: number;
|
|
72
|
+
/** Turns the bot flag of the account on when it is off (with the scope write:accounts), instead of failing to start. */
|
|
73
|
+
setBotFlag?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Runs the bot on an account without the bot flag. By default the bot refuses to start there, since the flag is
|
|
76
|
+
* what tells people and other servers that a program posts.
|
|
77
|
+
*/
|
|
78
|
+
allowNonBot?: boolean;
|
|
79
|
+
/** Hands the events of accounts with the bot flag to the handlers, which by default skip them. */
|
|
80
|
+
respondToBots?: boolean;
|
|
81
|
+
/** The most replies to one sender, each a number in a window; defaultReplyLimits when absent. */
|
|
82
|
+
replyLimits?: readonly Rate[];
|
|
83
|
+
/** Lifts the reply limits. */
|
|
84
|
+
disableReplyLimits?: boolean;
|
|
85
|
+
/** The send budget of the posts; defaultPostRate when absent. */
|
|
86
|
+
postRate?: Rate;
|
|
87
|
+
/** The send budget of the other writes; defaultWriteRate when absent. */
|
|
88
|
+
writeRate?: Rate;
|
|
89
|
+
/** The longest a write waits for the send budget; a longer wait drops it. defaultMaxSendWait when absent. */
|
|
90
|
+
maxSendWait?: number;
|
|
91
|
+
/** Turns off the answer of the command help. */
|
|
92
|
+
disableHelp?: boolean;
|
|
93
|
+
/** Formats the answer of help; defaultHelp when absent. */
|
|
94
|
+
help?: (commands: readonly Command[]) => string;
|
|
95
|
+
/** Leaves defaultMiddleware out of the chain, for a bot that builds its own with use. */
|
|
96
|
+
noDefaultMiddleware?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The clock, in milliseconds since the Unix epoch, of the scheduled jobs, the send budget, and the reply limits; for
|
|
99
|
+
* tests, such as the Clock of @mycroblog/mybot/testing.
|
|
100
|
+
*/
|
|
101
|
+
now?: () => number;
|
|
102
|
+
/** Waits for the scheduled jobs, the send budget, the pruning of the store, and between polls; for tests. */
|
|
103
|
+
sleep?: Sleep;
|
|
104
|
+
};
|
|
105
|
+
/** The error of run on an account without the bot flag, unless the setBotFlag or allowNonBot option. */
|
|
106
|
+
export declare class NotBotError extends Error {
|
|
107
|
+
readonly name = "NotBotError";
|
|
108
|
+
constructor();
|
|
109
|
+
}
|
|
110
|
+
/** The error of a second run of a bot. */
|
|
111
|
+
export declare class AlreadyRunningError extends Error {
|
|
112
|
+
readonly name = "AlreadyRunningError";
|
|
113
|
+
constructor();
|
|
114
|
+
}
|
|
115
|
+
/** The error of run when the access token lacks scopes that the bot needs. */
|
|
116
|
+
export declare class ScopeError extends Error {
|
|
117
|
+
readonly name = "ScopeError";
|
|
118
|
+
/** The scopes the token lacks. */
|
|
119
|
+
readonly missing: readonly string[];
|
|
120
|
+
constructor(missing: readonly string[]);
|
|
121
|
+
}
|
|
122
|
+
/** The scopes of need that granted (space-separated) does not give. */
|
|
123
|
+
export declare function missingScopes(granted: string, need: readonly string[]): string[];
|
|
124
|
+
/**
|
|
125
|
+
* A bot: the handlers of its events, and run, which receives the events and hands them to the handlers until it
|
|
126
|
+
* stops. Register the handlers before run.
|
|
127
|
+
*/
|
|
128
|
+
export declare class Bot {
|
|
129
|
+
#private;
|
|
130
|
+
/** The store of the bot. */
|
|
131
|
+
readonly store: Store;
|
|
132
|
+
constructor(options: BotOptions);
|
|
133
|
+
/** Registers the handler of the mentions of the bot, replies and direct posts included, that are no registered command. */
|
|
134
|
+
onMention(handler: Handler): void;
|
|
135
|
+
/**
|
|
136
|
+
* Registers the handler of the mentions whose first word, after the mentions of the bot at their start, is name,
|
|
137
|
+
* compared without case: `@bot ping` runs the command ping, whose Context has command "ping" and args the rest of
|
|
138
|
+
* the text. description is the command's line in the answer of help, which the bot gives unless the disableHelp
|
|
139
|
+
* option is set or a command help is registered.
|
|
140
|
+
*/
|
|
141
|
+
onCommand(name: string, description: string, handler: Handler): void;
|
|
142
|
+
/** Registers the handler of new followers. */
|
|
143
|
+
onFollow(handler: Handler): void;
|
|
144
|
+
/** Registers the handler of the follow requests to a locked bot account. */
|
|
145
|
+
onFollowRequest(handler: Handler): void;
|
|
146
|
+
/** Registers the handler of the emoji reactions to the bot's posts. */
|
|
147
|
+
onReaction(handler: Handler): void;
|
|
148
|
+
/** Registers the handler of the favourites of the bot's posts. */
|
|
149
|
+
onFavourite(handler: Handler): void;
|
|
150
|
+
/** Registers the handler of the boosts of the bot's posts. */
|
|
151
|
+
onBoost(handler: Handler): void;
|
|
152
|
+
/** Registers the handler of the quotes of the bot's posts. */
|
|
153
|
+
onQuote(handler: Handler): void;
|
|
154
|
+
/** Registers the handler of every notification that no handler above takes, the types without a handler of their own among them. */
|
|
155
|
+
onNotification(handler: Handler): void;
|
|
156
|
+
/**
|
|
157
|
+
* Registers the handler of the new posts of the home timeline, from the event stream alone: they are not caught up
|
|
158
|
+
* after a disconnection or a restart, and the polling mode does not receive them. The bot's own posts are left out.
|
|
159
|
+
*/
|
|
160
|
+
onStatus(handler: Handler): void;
|
|
161
|
+
/**
|
|
162
|
+
* Registers a scheduled job: handler runs at each time of the cron expression expr, of the five fields minute hour
|
|
163
|
+
* day-of-month month day-of-week (parseSchedule), in options.timeZone (UTC by default). Its Context carries job and
|
|
164
|
+
* scheduledAt, and post posts with the Idempotency-Key everyIdempotencyKey. The run counts in the store as the job's
|
|
165
|
+
* last run after the handler returns. It throws for an expression or a time zone it does not take (ScheduleError),
|
|
166
|
+
* a visibility other than unlisted, public, and local, and a name that another job has.
|
|
167
|
+
*/
|
|
168
|
+
every(expr: string, handler: Handler, options?: EveryOptions): void;
|
|
169
|
+
/** Adds middleware inside the default chain, in the order given. */
|
|
170
|
+
use(...middleware: Middleware[]): void;
|
|
171
|
+
/** The registered commands, by name, with help when the bot answers it. */
|
|
172
|
+
commands(): Command[];
|
|
173
|
+
/**
|
|
174
|
+
* Starts the bot and handles its events until signal aborts, then shuts down gracefully: it stops receiving, waits
|
|
175
|
+
* for the running handlers for the shutdownGrace option, aborts the signals of those still running, whose events
|
|
176
|
+
* stay unprocessed for the next start, and resolves. It rejects when the bot cannot start (NotBotError, ScopeError,
|
|
177
|
+
* a ProblemError of verifyCredentials) or when the server refuses the event stream for good, such as for a revoked
|
|
178
|
+
* token.
|
|
179
|
+
*
|
|
180
|
+
* It verifies the account, then receives the notifications: in the streaming mode from the event stream, catching
|
|
181
|
+
* up with listNotifications back to the store's position after every connection; in the polling mode by reading
|
|
182
|
+
* them every pollInterval. Each notification is handled once: it counts as processed, in the store, after its
|
|
183
|
+
* handler returns, and one that the store knows is skipped.
|
|
184
|
+
*/
|
|
185
|
+
run({ signal }?: {
|
|
186
|
+
signal?: AbortSignal;
|
|
187
|
+
}): Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* Starts the bot, handles once what is due, and resolves, for a bot that a cron or a job runner such as Cloud Run
|
|
190
|
+
* jobs starts again and again (ADR-0094 §9): the notifications since the store's position, back to the catchUpLimit
|
|
191
|
+
* option, and for each scheduled job the newest of its past times after its last run, whatever its catchUp. It
|
|
192
|
+
* resolves when their handlers have returned. When signal aborts first, it shuts down as run does, and what did not
|
|
193
|
+
* finish is due again on the next run. It rejects with the errors of starting that run rejects with, and with that
|
|
194
|
+
* of reading the notifications.
|
|
195
|
+
*/
|
|
196
|
+
runOnce({ signal }?: {
|
|
197
|
+
signal?: AbortSignal;
|
|
198
|
+
}): Promise<void>;
|
|
199
|
+
}
|