@rcpooley/botyard 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +13 -5
- package/dist/botyard.js +134 -31
- package/dist/watch.js +150 -39
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -10,6 +10,18 @@ export class ApiFailure extends Error {
|
|
|
10
10
|
this.status = status;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Who is calling, for Botyard's audit log: --actor or BOTYARD_ACTOR, free text
|
|
15
|
+
* such as an agent's session id. Sent as a header on every call, when set.
|
|
16
|
+
*/
|
|
17
|
+
let actor = null;
|
|
18
|
+
export function setActor(value) {
|
|
19
|
+
// It goes into a header and the audit log: printable ASCII, and short.
|
|
20
|
+
actor = value?.replace(/[^\x20-\x7e]/g, '').trim().slice(0, 100) || null;
|
|
21
|
+
}
|
|
22
|
+
export function headers(key) {
|
|
23
|
+
return { ...(key ? { authorization: `Bearer ${key}` } : {}), ...(actor ? { 'botyard-actor': actor } : {}), 'user-agent': 'botyard-cli' };
|
|
24
|
+
}
|
|
13
25
|
/** A wait Discord or Botyard asks for that is short enough to just sit out, once. */
|
|
14
26
|
const MAX_RETRY_WAIT_S = 10;
|
|
15
27
|
export async function call(url, key, method, path, body, retried = false) {
|
|
@@ -17,11 +29,7 @@ export async function call(url, key, method, path, body, retried = false) {
|
|
|
17
29
|
try {
|
|
18
30
|
res = await fetch(`${url.replace(/\/+$/, '')}/api/v1${path}`, {
|
|
19
31
|
method,
|
|
20
|
-
headers: {
|
|
21
|
-
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
22
|
-
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
23
|
-
'user-agent': 'botyard-cli',
|
|
24
|
-
},
|
|
32
|
+
headers: { ...headers(key), ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
25
33
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
34
|
signal: AbortSignal.timeout(30_000),
|
|
27
35
|
});
|
package/dist/botyard.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { ApiFailure, call, seg } from "./api.js";
|
|
5
|
+
import { ApiFailure, call, seg, setActor } from "./api.js";
|
|
6
6
|
import { configPath, DEFAULT_URL, load, pickBot, save } from "./config.js";
|
|
7
7
|
import * as fmt from "./format.js";
|
|
8
8
|
import { installSkill, SKILL, skillDir, skillInstalled } from "./skill.js";
|
|
@@ -16,6 +16,35 @@ import { watch } from "./watch.js";
|
|
|
16
16
|
* none of them can describe a command the others do not know.
|
|
17
17
|
*/
|
|
18
18
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
19
|
+
/**
|
|
20
|
+
* What the CLI calls itself in usage lines and hints. A tool that wraps it
|
|
21
|
+
* (Robert's `quiver discord`) sets BOTYARD_PROG so the commands it suggests
|
|
22
|
+
* are ones its own users can run.
|
|
23
|
+
*/
|
|
24
|
+
const PROG = process.env.BOTYARD_PROG?.trim() || 'botyard';
|
|
25
|
+
/** Options any command takes. */
|
|
26
|
+
const GLOBAL_OPTIONS = {
|
|
27
|
+
json: { type: 'boolean', description: 'print the raw API response (one JSON object per line for watch)' },
|
|
28
|
+
bot: { type: 'string', value: 'name|id', description: 'act as this connected bot instead of the default' },
|
|
29
|
+
actor: { type: 'string', value: 'id', description: 'who is calling (an agent session id), recorded by Botyard; also BOTYARD_ACTOR' },
|
|
30
|
+
help: { type: 'boolean', description: 'explain the command' },
|
|
31
|
+
};
|
|
32
|
+
/** Options only some commands take: each command lists its own. */
|
|
33
|
+
const OPTIONS = {
|
|
34
|
+
url: { type: 'string', value: 'site', description: 'the Botyard site to connect to' },
|
|
35
|
+
limit: { type: 'string', value: 'n', description: 'how many messages, 1-100 (default 50)' },
|
|
36
|
+
before: { type: 'string', value: 'message id', description: 'page back from this message' },
|
|
37
|
+
'reply-to': { type: 'string', value: 'message id', description: 'reply to this message in the same channel' },
|
|
38
|
+
mentions: { type: 'string', value: 'users|none|all', description: 'who the text may ping (default users; all only when asked)' },
|
|
39
|
+
'include-own': { type: 'boolean', description: "also print the bot's own messages" },
|
|
40
|
+
server: { type: 'string', multiple: true, value: 'server', description: 'only this server' },
|
|
41
|
+
channel: { type: 'string', multiple: true, value: 'server#channel', description: 'only this channel and its threads' },
|
|
42
|
+
thread: { type: 'string', multiple: true, value: 'server#thread', description: 'only this thread' },
|
|
43
|
+
since: { type: 'string', value: 'message id', description: 'first print what was posted after this message, then go on' },
|
|
44
|
+
once: { type: 'boolean', description: 'exit after the first message (or the --since catch-up), saying the --since to pass next' },
|
|
45
|
+
from: { type: 'string', value: 'message id', description: 'start the thread from this message in the channel' },
|
|
46
|
+
add: { type: 'string', multiple: true, value: 'user id', description: 'add this person to the thread, without a ping (up to 10)' },
|
|
47
|
+
};
|
|
19
48
|
// --- what a command needs --------------------------------------------------
|
|
20
49
|
/** The bot to act as: BOTYARD_KEY wins (for scripts), then --bot, then the default. */
|
|
21
50
|
function target(opts) {
|
|
@@ -29,6 +58,9 @@ async function api(opts, method, path, body) {
|
|
|
29
58
|
return call(url, key, method, path, body);
|
|
30
59
|
}
|
|
31
60
|
const json = (value) => JSON.stringify(value, null, 2);
|
|
61
|
+
/** A mistake in how the command was typed: exit 2, like a bad option. */
|
|
62
|
+
class UsageError extends Error {
|
|
63
|
+
}
|
|
32
64
|
/** Message text: the words given, or stdin when the text is "-". */
|
|
33
65
|
function text(raw) {
|
|
34
66
|
if (raw !== '-')
|
|
@@ -81,6 +113,7 @@ const COMMANDS = [
|
|
|
81
113
|
summary: 'recent messages, oldest first',
|
|
82
114
|
details: ['--limit is 1-100 (default 50). When there are older messages, the output ends with the command for the next page.'],
|
|
83
115
|
args: 2,
|
|
116
|
+
options: ['limit', 'before'],
|
|
84
117
|
run: async ([server, channel], opts) => {
|
|
85
118
|
const q = new URLSearchParams();
|
|
86
119
|
if (opts.limit)
|
|
@@ -90,37 +123,50 @@ const COMMANDS = [
|
|
|
90
123
|
const res = await api(opts, 'GET', `${where(server, channel)}/messages${q.size ? `?${q}` : ''}`);
|
|
91
124
|
if (opts.json)
|
|
92
125
|
return json(res);
|
|
93
|
-
const more = res.older ? `Older:
|
|
126
|
+
const more = res.older ? `Older: ${PROG} read ${quote(server)} ${quote(channel)} --before ${res.older}` : null;
|
|
94
127
|
return fmt.messages(res.messages, `#${channel.replace(/^#/, '')} in ${server}`, more);
|
|
95
128
|
},
|
|
96
129
|
},
|
|
97
130
|
{
|
|
98
131
|
name: 'watch',
|
|
99
132
|
group: 'Reading',
|
|
100
|
-
usage: 'watch [--server <server>]... [--channel <server>#<channel>]... [--thread <server>#<thread>]... [--include-own] [--json]',
|
|
133
|
+
usage: 'watch [--server <server>]... [--channel <server>#<channel>]... [--thread <server>#<thread>]... [--since <message id>] [--once] [--include-own] [--json]',
|
|
101
134
|
summary: 'stay open and print each new message the moment it is posted, one line each',
|
|
102
135
|
details: [
|
|
103
136
|
'With no options it carries every server, channel and thread the bot is in that the key can read.',
|
|
104
137
|
'--server <server>, --channel <server>#<channel> (the channel and every thread in it) and --thread <server>#<thread> (just that thread) narrow it; repeat any of them, and a message matching any one prints. Ids or names: --channel "My Server#general", --thread "My Server#release plans".',
|
|
105
138
|
'Botyard pushes each message as it is posted: no polling, nothing printed while it is quiet.',
|
|
106
139
|
"Each line is one message: id, time (UTC), server #channel (or server #channel › thread), author, text (newlines shown as ⏎). The bot's own messages are left out unless --include-own. --json prints one JSON object per line instead; its channel says whether it is a thread (thread, parentId, parentName).",
|
|
107
|
-
'It runs until stopped. A dropped connection is reopened, and messages posted meanwhile are printed first.',
|
|
140
|
+
'It runs until stopped. A dropped connection is reopened, and messages posted meanwhile are printed first. Stopped (Ctrl-C, a kill), it says the --since to resume from.',
|
|
141
|
+
'--since <message id> first prints what was posted after that message, oldest first, in everything it watches (only channels with something new are read), then goes on watching.',
|
|
142
|
+
'--once exits as soon as it has printed something: the --since catch-up if that found anything, else the next message. Its last line (on stderr) is the --since to pass next time. Run that way as a background command that ends on each event, it costs nothing while the channel is quiet and misses nothing between runs.',
|
|
108
143
|
'In Claude Code, run it with the Monitor tool, so each new message wakes you the moment it arrives; narrow it to what you need, since every line wakes you. Never poll with read in a loop.',
|
|
109
144
|
],
|
|
110
145
|
args: 0,
|
|
146
|
+
options: ['server', 'channel', 'thread', 'since', 'once', 'include-own'],
|
|
147
|
+
streams: true,
|
|
111
148
|
run: async (_a, opts) => {
|
|
112
149
|
const { url, key } = target(opts);
|
|
113
|
-
|
|
150
|
+
if (opts.since !== undefined && !/^\d{17,20}$/.test(opts.since))
|
|
151
|
+
throw new UsageError(`--since takes a message id, got '${opts.since}'.`);
|
|
152
|
+
await watch({
|
|
114
153
|
url,
|
|
115
154
|
key,
|
|
155
|
+
prog: PROG,
|
|
116
156
|
servers: opts.server ?? [],
|
|
117
157
|
channels: opts.channel ?? [],
|
|
118
158
|
threads: opts.thread ?? [],
|
|
159
|
+
since: opts.since === undefined ? null : BigInt(opts.since),
|
|
160
|
+
once: opts.once === true,
|
|
119
161
|
includeOwn: opts['include-own'] === true,
|
|
120
162
|
json: opts.json,
|
|
121
163
|
out: (l) => process.stdout.write(l + '\n'),
|
|
122
164
|
log: (l) => process.stderr.write(l + '\n'),
|
|
123
165
|
});
|
|
166
|
+
// Only --once gets here. Exit now rather than when every socket the
|
|
167
|
+
// stream used has been let go, which can take a while. Writes to pipes
|
|
168
|
+
// and files are synchronous, so nothing printed is lost.
|
|
169
|
+
process.exit(0);
|
|
124
170
|
},
|
|
125
171
|
},
|
|
126
172
|
{
|
|
@@ -171,6 +217,8 @@ const COMMANDS = [
|
|
|
171
217
|
],
|
|
172
218
|
args: 3,
|
|
173
219
|
rest: true,
|
|
220
|
+
options: ['reply-to', 'mentions'],
|
|
221
|
+
writes: true,
|
|
174
222
|
run: async ([server, channel, words], opts) => {
|
|
175
223
|
const body = { content: text(words), replyTo: opts['reply-to'], mentions: opts.mentions };
|
|
176
224
|
const m = await api(opts, 'POST', `${where(server, channel)}/messages`, body);
|
|
@@ -191,6 +239,8 @@ const COMMANDS = [
|
|
|
191
239
|
],
|
|
192
240
|
args: 3,
|
|
193
241
|
rest: true,
|
|
242
|
+
options: ['from', 'add'],
|
|
243
|
+
writes: true,
|
|
194
244
|
run: async ([server, channel, name], opts) => {
|
|
195
245
|
const thread = await api(opts, 'POST', `${where(server, channel)}/threads`, {
|
|
196
246
|
name,
|
|
@@ -202,7 +252,7 @@ const COMMANDS = [
|
|
|
202
252
|
return [
|
|
203
253
|
`Started thread ${thread.id} ${thread.name}`,
|
|
204
254
|
...memberLines(thread.added ?? [], thread.failed ?? []),
|
|
205
|
-
`Send to it:
|
|
255
|
+
`Send to it: ${PROG} send ${quote(server)} ${thread.id} "..."`,
|
|
206
256
|
].join('\n');
|
|
207
257
|
},
|
|
208
258
|
},
|
|
@@ -218,6 +268,7 @@ const COMMANDS = [
|
|
|
218
268
|
],
|
|
219
269
|
args: 3,
|
|
220
270
|
many: true,
|
|
271
|
+
writes: true,
|
|
221
272
|
run: async ([server, thread, ...users], opts) => {
|
|
222
273
|
const added = [];
|
|
223
274
|
const failed = [];
|
|
@@ -248,6 +299,8 @@ const COMMANDS = [
|
|
|
248
299
|
summary: "replace the text of one of the bot's own messages",
|
|
249
300
|
args: 4,
|
|
250
301
|
rest: true,
|
|
302
|
+
options: ['mentions'],
|
|
303
|
+
writes: true,
|
|
251
304
|
run: async ([server, channel, id, words], opts) => {
|
|
252
305
|
const m = await api(opts, 'PATCH', `${where(server, channel)}/messages/${seg(id)}`, { content: text(words), mentions: opts.mentions });
|
|
253
306
|
return opts.json ? json(m) : `Edited ${m.id}\n${m.url}`;
|
|
@@ -259,6 +312,7 @@ const COMMANDS = [
|
|
|
259
312
|
usage: `${name} <server> <channel> <message id>`,
|
|
260
313
|
summary: name === 'pin' ? 'pin a message' : 'unpin a message',
|
|
261
314
|
args: 3,
|
|
315
|
+
writes: true,
|
|
262
316
|
run: async ([server, channel, id], opts) => {
|
|
263
317
|
await api(opts, name === 'pin' ? 'PUT' : 'DELETE', `${where(server, channel)}/messages/${seg(id)}/pin`);
|
|
264
318
|
return `${name === 'pin' ? 'Pinned' : 'Unpinned'} ${id}`;
|
|
@@ -271,6 +325,7 @@ const COMMANDS = [
|
|
|
271
325
|
summary: name === 'react' ? 'add a reaction' : "remove the bot's own reaction",
|
|
272
326
|
details: ['The emoji itself (👍), or a custom one as <:name:id>. :shortcodes: are a Discord-app feature and do not work.'],
|
|
273
327
|
args: 4,
|
|
328
|
+
writes: true,
|
|
274
329
|
run: async ([server, channel, id, emoji], opts) => {
|
|
275
330
|
await api(opts, name === 'react' ? 'PUT' : 'DELETE', `${where(server, channel)}/messages/${seg(id)}/reactions/${seg(emoji)}`);
|
|
276
331
|
return `${name === 'react' ? 'Reacted' : 'Removed'} ${emoji} ${name === 'react' ? 'to' : 'from'} ${id}`;
|
|
@@ -286,6 +341,7 @@ const COMMANDS = [
|
|
|
286
341
|
'The key it is exchanged for is stored for you and never printed.',
|
|
287
342
|
],
|
|
288
343
|
args: 1,
|
|
344
|
+
options: ['url'],
|
|
289
345
|
run: async ([code], opts) => {
|
|
290
346
|
const url = (opts.url || DEFAULT_URL).replace(/\/+$/, '');
|
|
291
347
|
const res = await call(url, null, 'POST', '/connect', { code, client: `botyard CLI on ${hostname()}` });
|
|
@@ -373,6 +429,7 @@ const COMMANDS = [
|
|
|
373
429
|
summary: 'install the Claude Code skill that tells future sessions about botyard',
|
|
374
430
|
details: [`install writes ${skillDir()}/SKILL.md (set CLAUDE_CONFIG_DIR to change where). show prints it instead.`],
|
|
375
431
|
args: 1,
|
|
432
|
+
params: ['action'],
|
|
376
433
|
run: async ([action]) => {
|
|
377
434
|
if (action === 'show')
|
|
378
435
|
return SKILL;
|
|
@@ -381,6 +438,18 @@ const COMMANDS = [
|
|
|
381
438
|
throw new Error("Use 'botyard skill install' or 'botyard skill show'.");
|
|
382
439
|
},
|
|
383
440
|
},
|
|
441
|
+
{
|
|
442
|
+
name: 'commands',
|
|
443
|
+
group: 'Setup',
|
|
444
|
+
usage: 'commands',
|
|
445
|
+
summary: 'every command and option as JSON, for tools that wrap this CLI',
|
|
446
|
+
details: [
|
|
447
|
+
'One object: version, the options every command takes, and per command its name, group, usage, summary, details, positional arguments (variadic: any number; rest: the remaining words joined into one), options, and whether it writes to Discord or streams until stopped.',
|
|
448
|
+
'A wrapper can build its own commands from this and pass them straight through, so it follows new CLI versions without changes.',
|
|
449
|
+
],
|
|
450
|
+
args: 0,
|
|
451
|
+
run: async () => json(commandTable()),
|
|
452
|
+
},
|
|
384
453
|
];
|
|
385
454
|
// --- the guide -------------------------------------------------------------
|
|
386
455
|
function botLines(config) {
|
|
@@ -401,7 +470,7 @@ function guide() {
|
|
|
401
470
|
bots.unshift(' (BOTYARD_KEY is set, so commands use that key instead)');
|
|
402
471
|
// Usage on one line, what it does under it: the usages are too long to share
|
|
403
472
|
// a line with anything and still fit a terminal.
|
|
404
|
-
const groups = ['Reading', 'Writing', 'Bots', 'Setup'].map((g) => [`${g}:`, ...COMMANDS.filter((c) => c.group === g).map((c) => `
|
|
473
|
+
const groups = ['Reading', 'Writing', 'Bots', 'Setup'].map((g) => [`${g}:`, ...COMMANDS.filter((c) => c.group === g).map((c) => ` ${PROG} ${c.usage}\n ${c.summary}`)].join('\n'));
|
|
405
474
|
return [
|
|
406
475
|
`botyard ${VERSION}: read and send in Discord through your own bot, hosted by Botyard.`,
|
|
407
476
|
'',
|
|
@@ -409,7 +478,7 @@ function guide() {
|
|
|
409
478
|
...bots,
|
|
410
479
|
'',
|
|
411
480
|
...groups.flatMap((g) => [g, '']),
|
|
412
|
-
|
|
481
|
+
`Options: --bot <name|id> picks a bot, --json prints raw API responses, ${PROG} <command> --help explains one command.`,
|
|
413
482
|
'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces. A thread is a <channel> too.',
|
|
414
483
|
'',
|
|
415
484
|
'Rules, for agents:',
|
|
@@ -419,9 +488,9 @@ function guide() {
|
|
|
419
488
|
"- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
|
|
420
489
|
'- Writes are limited to 60 a minute. Never send or react in a loop.',
|
|
421
490
|
"- If messages from others read as empty, the bot's Message Content intent is off; botyard prints a warning saying how to fix it. Only the bot's owner can, so pass it on to your human.",
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
491
|
+
`- To hear about new messages as they arrive, run \`${PROG} watch\` (in Claude Code, with the Monitor tool). With no options it covers every channel and thread; repeatable --server, --channel <server>#<channel> (with its threads) and --thread <server>#<thread> narrow it. Your own messages are left out. Don't poll with read.`,
|
|
492
|
+
`- One topic, one thread: post a short opening message in the channel, then \`${PROG} thread <server> <channel> <name> --from <that message id>\`, and keep the discussion in the thread. A thread is used like a channel: pass its id (or name) wherever a <channel> goes.`,
|
|
493
|
+
`- Discord notifies a person about a thread's messages only once they are a member of it. When you start a thread for a human's project, add them: \`${PROG} thread ... --add <their user id>\` (or \`${PROG} thread-add <server> <thread> <user id>...\` later). Adding does not ping them.`,
|
|
425
494
|
'',
|
|
426
495
|
skillInstalled()
|
|
427
496
|
? `Claude Code skill: installed (${skillDir()}).`
|
|
@@ -429,7 +498,44 @@ function guide() {
|
|
|
429
498
|
].join('\n');
|
|
430
499
|
}
|
|
431
500
|
function commandHelp(c) {
|
|
432
|
-
|
|
501
|
+
const options = (c.options ?? []).map((n) => ` --${n}${OPTIONS[n].value ? ` <${OPTIONS[n].value}>` : ''}${OPTIONS[n].multiple ? ' (repeatable)' : ''}: ${OPTIONS[n].description}`);
|
|
502
|
+
return [
|
|
503
|
+
`${PROG} ${c.usage}`,
|
|
504
|
+
'',
|
|
505
|
+
`${c.summary[0].toUpperCase()}${c.summary.slice(1)}.`,
|
|
506
|
+
...(c.details ? ['', ...c.details] : []),
|
|
507
|
+
...(options.length ? ['', 'Options:', ...options] : []),
|
|
508
|
+
].join('\n');
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* A command's positional arguments, named from its usage line: the <...> words
|
|
512
|
+
* before the first option. `<message id>` is message-id; `<text | ->` is text.
|
|
513
|
+
*/
|
|
514
|
+
function positionals(c) {
|
|
515
|
+
const words = [...c.usage.split(' [')[0].matchAll(/<([^>]+)>(\.\.\.)?/g)].slice(0, c.args);
|
|
516
|
+
return words.map((w, i) => ({
|
|
517
|
+
name: c.params?.[i] ?? w[1].split('|')[0].trim().replace(/\s+/g, '-'),
|
|
518
|
+
variadic: (c.many === true && i === c.args - 1) || w[2] === '...',
|
|
519
|
+
rest: c.rest === true && i === c.args - 1,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
function commandTable() {
|
|
523
|
+
const option = ([name, o]) => ({ name, type: o.type, multiple: o.multiple === true, ...(o.value ? { value: o.value } : {}), description: o.description });
|
|
524
|
+
return {
|
|
525
|
+
version: VERSION,
|
|
526
|
+
globalOptions: Object.entries(GLOBAL_OPTIONS).map(option),
|
|
527
|
+
commands: COMMANDS.map((c) => ({
|
|
528
|
+
name: c.name,
|
|
529
|
+
group: c.group,
|
|
530
|
+
usage: c.usage,
|
|
531
|
+
summary: c.summary,
|
|
532
|
+
details: c.details ?? [],
|
|
533
|
+
positionals: positionals(c),
|
|
534
|
+
options: (c.options ?? []).map((n) => option([n, OPTIONS[n]])),
|
|
535
|
+
writes: c.writes === true,
|
|
536
|
+
streams: c.streams === true,
|
|
537
|
+
})),
|
|
538
|
+
};
|
|
433
539
|
}
|
|
434
540
|
/** Who a thread's members call added, and who Discord refused. */
|
|
435
541
|
function memberLines(added, failed) {
|
|
@@ -447,29 +553,19 @@ async function main(argv) {
|
|
|
447
553
|
allowPositionals: true,
|
|
448
554
|
strict: true,
|
|
449
555
|
options: {
|
|
450
|
-
|
|
451
|
-
help: { type: 'boolean', short: 'h'
|
|
452
|
-
version: { type: 'boolean', short: 'v'
|
|
453
|
-
bot: { type: 'string' },
|
|
454
|
-
url: { type: 'string' },
|
|
455
|
-
limit: { type: 'string' },
|
|
456
|
-
before: { type: 'string' },
|
|
457
|
-
'reply-to': { type: 'string' },
|
|
458
|
-
mentions: { type: 'string' },
|
|
459
|
-
'include-own': { type: 'boolean', default: false },
|
|
460
|
-
server: { type: 'string', multiple: true },
|
|
461
|
-
channel: { type: 'string', multiple: true },
|
|
462
|
-
thread: { type: 'string', multiple: true },
|
|
463
|
-
from: { type: 'string' },
|
|
464
|
-
add: { type: 'string', multiple: true },
|
|
556
|
+
...Object.fromEntries(Object.entries({ ...GLOBAL_OPTIONS, ...OPTIONS }).map(([n, o]) => [n, { type: o.type, ...(o.multiple ? { multiple: true } : {}) }])),
|
|
557
|
+
help: { type: 'boolean', short: 'h' },
|
|
558
|
+
version: { type: 'boolean', short: 'v' },
|
|
465
559
|
},
|
|
466
560
|
});
|
|
467
561
|
}
|
|
468
562
|
catch (e) {
|
|
469
|
-
process.stderr.write(`botyard: ${e instanceof Error ? e.message : String(e)}. Run
|
|
563
|
+
process.stderr.write(`botyard: ${e instanceof Error ? e.message : String(e)}. Run ${PROG} for usage.\n`);
|
|
470
564
|
return 2;
|
|
471
565
|
}
|
|
472
|
-
const { values,
|
|
566
|
+
const values = { ...parsed.values, json: parsed.values.json === true };
|
|
567
|
+
const { positionals } = parsed;
|
|
568
|
+
setActor(values.actor ?? process.env.BOTYARD_ACTOR);
|
|
473
569
|
if (values.version) {
|
|
474
570
|
process.stdout.write(`${VERSION}\n`);
|
|
475
571
|
return 0;
|
|
@@ -485,16 +581,21 @@ async function main(argv) {
|
|
|
485
581
|
process.stdout.write(guide() + '\n');
|
|
486
582
|
return 0;
|
|
487
583
|
}
|
|
488
|
-
process.stderr.write(`botyard: no command '${name}'. Run
|
|
584
|
+
process.stderr.write(`botyard: no command '${name}'. Run ${PROG} with no arguments for the list.\n`);
|
|
489
585
|
return 2;
|
|
490
586
|
}
|
|
491
587
|
if (values.help) {
|
|
492
588
|
process.stdout.write(commandHelp(command) + '\n');
|
|
493
589
|
return 0;
|
|
494
590
|
}
|
|
591
|
+
const stray = Object.keys(values).filter((n) => n in OPTIONS && !(command.options ?? []).includes(n));
|
|
592
|
+
if (stray.length) {
|
|
593
|
+
process.stderr.write(`botyard: ${command.name} takes no --${stray[0]}. ${PROG} ${command.name} --help lists what it takes.\n`);
|
|
594
|
+
return 2;
|
|
595
|
+
}
|
|
495
596
|
const given = command.many ? args : command.rest && args.length > command.args ? [...args.slice(0, command.args - 1), args.slice(command.args - 1).join(' ')] : args;
|
|
496
597
|
if (command.many ? given.length < command.args : given.length !== command.args) {
|
|
497
|
-
process.stderr.write(`botyard: usage:
|
|
598
|
+
process.stderr.write(`botyard: usage: ${PROG} ${command.usage}\n`);
|
|
498
599
|
return 2;
|
|
499
600
|
}
|
|
500
601
|
try {
|
|
@@ -506,6 +607,8 @@ async function main(argv) {
|
|
|
506
607
|
catch (e) {
|
|
507
608
|
const message = e instanceof Error ? e.message : String(e);
|
|
508
609
|
process.stderr.write(`botyard: ${message}\n`);
|
|
610
|
+
if (e instanceof UsageError)
|
|
611
|
+
return 2;
|
|
509
612
|
return e instanceof ApiFailure && e.status === 0 ? 3 : 1;
|
|
510
613
|
}
|
|
511
614
|
}
|
package/dist/watch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiFailure, call, seg, warnOnce, warnText } from "./api.js";
|
|
1
|
+
import { ApiFailure, call, headers, seg, warnOnce, warnText } from "./api.js";
|
|
2
2
|
/**
|
|
3
3
|
* `botyard watch`: hold the event stream open and print each new message the
|
|
4
4
|
* moment Botyard pushes it, one line per message, so a tool that wakes on
|
|
@@ -9,12 +9,18 @@ import { ApiFailure, call, seg, warnOnce, warnText } from "./api.js";
|
|
|
9
9
|
* bot's own messages out of the stream unless --include-own.
|
|
10
10
|
*
|
|
11
11
|
* It never returns on its own: a dropped stream is reopened, and what was
|
|
12
|
-
* posted meanwhile is read and printed first
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* posted meanwhile is read and printed first. Only a refusal (a revoked key, a
|
|
13
|
+
* server it may not read) ends it -- or --once, after the first thing it
|
|
14
|
+
* prints. --since <id> starts with a catch-up from that message, so a watch
|
|
15
|
+
* run once per event (a background command that ends on each one) misses
|
|
16
|
+
* nothing between runs.
|
|
15
17
|
*/
|
|
16
18
|
const RETRY_FIRST_MS = 1_000;
|
|
17
19
|
const RETRY_MAX_MS = 30_000;
|
|
20
|
+
/** How many printed ids to remember, so the stream's copy of a message already caught up on is dropped. */
|
|
21
|
+
const REMEMBER = 2_000;
|
|
22
|
+
/** One page of messages: what a catch-up reads per channel. */
|
|
23
|
+
const PAGE = 100;
|
|
18
24
|
export async function watch(o) {
|
|
19
25
|
for (const c of o.channels) {
|
|
20
26
|
if (c.lastIndexOf('#') <= 0)
|
|
@@ -25,6 +31,7 @@ export async function watch(o) {
|
|
|
25
31
|
throw new Error(`--thread '${c}' needs its server: use <server>#<thread>, e.g. --thread "My Server#release plans".`);
|
|
26
32
|
}
|
|
27
33
|
const me = await call(o.url, o.key, 'GET', '/me');
|
|
34
|
+
const own = o.includeOwn ? null : me.bot.userId || me.bot.id;
|
|
28
35
|
const query = new URLSearchParams([
|
|
29
36
|
...o.servers.map((s) => ['server', s]),
|
|
30
37
|
...o.channels.map((c) => ['channel', c]),
|
|
@@ -32,22 +39,42 @@ export async function watch(o) {
|
|
|
32
39
|
// Botyard leaves the bot's own messages out unless asked.
|
|
33
40
|
...(o.includeOwn ? [['own', '1']] : []),
|
|
34
41
|
]);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
/** The newest message printed: the --since to resume from. */
|
|
43
|
+
let last = o.since ?? 0n;
|
|
44
|
+
/**
|
|
45
|
+
* The newest message printed in each channel, where a catch-up there starts.
|
|
46
|
+
* Per channel, not one number, because messages from different channels can
|
|
47
|
+
* arrive slightly out of order, and a lower id is still a new message.
|
|
48
|
+
*/
|
|
49
|
+
const marks = new Map();
|
|
50
|
+
/** Recently printed ids: a message is printed once however many ways it arrives. */
|
|
51
|
+
const printed = new Set();
|
|
52
|
+
/** Channels and threads a message has come from during this run, to catch up on after a drop. */
|
|
39
53
|
const seen = new Map();
|
|
40
|
-
/** The channels and threads asked for by name, caught up on even before a message has come from them. */
|
|
41
|
-
const asked = [...o.channels.map((c) => named(c, false)), ...o.threads.map((c) => named(c, true))];
|
|
42
54
|
let retry = RETRY_FIRST_MS;
|
|
55
|
+
let opened = false;
|
|
56
|
+
let caughtUp = false;
|
|
43
57
|
const print = (m) => {
|
|
44
58
|
seen.set(m.channel.id, { server: m.server, channel: m.channel });
|
|
59
|
+
if (printed.has(m.id))
|
|
60
|
+
return false;
|
|
61
|
+
printed.add(m.id);
|
|
62
|
+
if (printed.size > REMEMBER)
|
|
63
|
+
printed.delete(printed.values().next().value);
|
|
45
64
|
const id = BigInt(m.id);
|
|
46
|
-
if (id
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
if (id > last)
|
|
66
|
+
last = id;
|
|
67
|
+
if (id > (marks.get(m.channel.id) ?? 0n))
|
|
68
|
+
marks.set(m.channel.id, id);
|
|
49
69
|
o.out(o.json ? JSON.stringify(m) : line(m));
|
|
70
|
+
return true;
|
|
50
71
|
};
|
|
72
|
+
const stopped = () => {
|
|
73
|
+
o.log(`botyard: stopped.${last > 0n ? ` Resume with --since ${last}` : ''}`);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
};
|
|
76
|
+
process.once('SIGINT', stopped);
|
|
77
|
+
process.once('SIGTERM', stopped);
|
|
51
78
|
const backoff = async (why) => {
|
|
52
79
|
o.log(`botyard: ${why}; retrying in ${retry / 1000}s`);
|
|
53
80
|
await sleep(retry);
|
|
@@ -57,7 +84,7 @@ export async function watch(o) {
|
|
|
57
84
|
let res;
|
|
58
85
|
try {
|
|
59
86
|
res = await fetch(`${o.url.replace(/\/+$/, '')}/api/v1/events${query.size ? `?${query}` : ''}`, {
|
|
60
|
-
headers: {
|
|
87
|
+
headers: { ...headers(o.key), accept: 'text/event-stream' },
|
|
61
88
|
});
|
|
62
89
|
}
|
|
63
90
|
catch (e) {
|
|
@@ -73,24 +100,36 @@ export async function watch(o) {
|
|
|
73
100
|
await backoff(message);
|
|
74
101
|
continue;
|
|
75
102
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// any of those is skipped by the id check in print().
|
|
79
|
-
if (last > 0n)
|
|
80
|
-
await catchUp(o, [...asked, ...seen.values()], seen, print, o.includeOwn ? null : me.bot.userId || me.bot.id);
|
|
81
|
-
else {
|
|
103
|
+
if (!opened) {
|
|
104
|
+
opened = true;
|
|
82
105
|
const named = [...o.servers.map((s) => `all of ${s}`), ...o.channels.map((c) => `${c} and its threads`), ...o.threads.map((c) => `thread ${c}`)];
|
|
83
106
|
const scope = named.length ? named.join(', ') : 'every server, channel and thread this key can read';
|
|
84
107
|
o.log(`botyard: watching ${scope}. Each new message prints as one line${o.includeOwn ? '' : `; ${me.bot.name}'s own are left out`}.`);
|
|
85
108
|
}
|
|
109
|
+
// The stream is open, so nothing new can slip past it. Before reading it,
|
|
110
|
+
// catch up on what was posted before: since --since, or while it was down.
|
|
111
|
+
// The stream's own copy of any of those is dropped by print().
|
|
112
|
+
if (last > 0n) {
|
|
113
|
+
const caught = await catchUp(o, me, { seen, marks, last, own }, print);
|
|
114
|
+
if (o.since !== null && !caughtUp && caught >= 0)
|
|
115
|
+
o.log(`botyard: ${caught} message${caught === 1 ? '' : 's'} since ${o.since}.`);
|
|
116
|
+
caughtUp = true;
|
|
117
|
+
if (o.once && caught > 0) {
|
|
118
|
+
// The stream is open but unread: close it, or its socket keeps the process alive.
|
|
119
|
+
await res.body.cancel().catch(() => { });
|
|
120
|
+
return next(o, last);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
86
123
|
retry = RETRY_FIRST_MS;
|
|
87
124
|
try {
|
|
88
125
|
for await (const event of events(res.body)) {
|
|
89
|
-
if (event.name === 'message')
|
|
90
|
-
print(JSON.parse(event.data))
|
|
126
|
+
if (event.name === 'message') {
|
|
127
|
+
if (print(JSON.parse(event.data)) && o.once)
|
|
128
|
+
return next(o, last);
|
|
129
|
+
}
|
|
91
130
|
else if (event.name === 'missed') {
|
|
92
131
|
const m = JSON.parse(event.data);
|
|
93
|
-
o.log(`botyard: missed message ${m.id} in ${place(m)}; read it with
|
|
132
|
+
o.log(`botyard: missed message ${m.id} in ${place(m)}; read it with ${o.prog} message ${m.server.id} ${m.channel.id} ${m.id}`);
|
|
94
133
|
}
|
|
95
134
|
else if (event.name === 'warning')
|
|
96
135
|
warnText(JSON.parse(event.data).warning);
|
|
@@ -107,30 +146,102 @@ export async function watch(o) {
|
|
|
107
146
|
await sleep(RETRY_FIRST_MS);
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
|
-
/**
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
const server = ref.slice(0, split);
|
|
114
|
-
const channel = ref.slice(split + 1);
|
|
115
|
-
return { server: { id: server, name: server }, channel: { id: channel, name: channel, thread, parentId: null, parentName: null } };
|
|
149
|
+
/** --once is done: say where the next run starts. */
|
|
150
|
+
function next(o, last) {
|
|
151
|
+
o.log(`botyard: next: --since ${last}`);
|
|
116
152
|
}
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Read what was posted after each channel's mark, in everything the watch
|
|
155
|
+
* covers, and print it oldest first. Returns how many it printed, or -1 if it
|
|
156
|
+
* could read nothing at all.
|
|
157
|
+
*
|
|
158
|
+
* The channel lists say each channel's newest message, so only channels with
|
|
159
|
+
* something new are read: a --channel catches up on its threads too, and a
|
|
160
|
+
* --server or an unfiltered watch on every channel, for one list call per
|
|
161
|
+
* server. Against a Botyard too old to say, every channel is read.
|
|
162
|
+
*/
|
|
163
|
+
async function catchUp(o, me, state, print) {
|
|
164
|
+
const lists = new Map();
|
|
165
|
+
const list = (server) => {
|
|
166
|
+
if (!lists.has(server))
|
|
167
|
+
lists.set(server, call(o.url, o.key, 'GET', `/servers/${seg(server)}/channels`).catch(() => null));
|
|
168
|
+
return lists.get(server);
|
|
169
|
+
};
|
|
170
|
+
const serverOf = (ref) => me.servers.find((s) => s.id === ref) ?? me.servers.find((s) => s.name.toLowerCase() === ref.toLowerCase()) ?? { id: ref, name: ref };
|
|
171
|
+
const markOf = (id) => state.marks.get(id) ?? state.last;
|
|
172
|
+
const targets = new Map();
|
|
173
|
+
const add = (where, named, lastMessageId) => {
|
|
174
|
+
if (!targets.has(where.channel.id) || named)
|
|
175
|
+
targets.set(where.channel.id, { where, named, mark: markOf(where.channel.id), lastMessageId });
|
|
176
|
+
};
|
|
177
|
+
const whereIn = (server, c, all) => {
|
|
178
|
+
const thread = c.kind === 'thread';
|
|
179
|
+
return {
|
|
180
|
+
server,
|
|
181
|
+
channel: { id: c.id, name: c.name, thread, parentId: thread ? c.parentId : null, parentName: thread ? (all.find((p) => p.id === c.parentId)?.name ?? null) : null },
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const readable = (c) => (c.lastMessageId !== undefined ? c.lastMessageId !== null : ['text', 'announcement', 'thread'].includes(c.kind));
|
|
185
|
+
const everything = o.servers.length + o.channels.length + o.threads.length === 0;
|
|
186
|
+
for (const ref of everything ? me.servers.map((s) => s.id) : o.servers) {
|
|
187
|
+
const server = serverOf(ref);
|
|
188
|
+
const all = await list(server.id);
|
|
189
|
+
for (const c of (all ?? []).filter(readable))
|
|
190
|
+
add(whereIn(server, c, all), false, c.lastMessageId);
|
|
191
|
+
}
|
|
192
|
+
for (const ref of o.channels) {
|
|
193
|
+
const split = ref.lastIndexOf('#');
|
|
194
|
+
const server = serverOf(ref.slice(0, split));
|
|
195
|
+
const wanted = ref.slice(split + 1).replace(/^#/, '');
|
|
196
|
+
const all = await list(server.id);
|
|
197
|
+
const channel = all?.find((c) => c.id === wanted) ?? all?.find((c) => c.name === wanted && c.kind !== 'voice' && c.kind !== 'thread');
|
|
198
|
+
if (!channel || !all) {
|
|
199
|
+
add(named(ref, false), true);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
add(whereIn(server, channel, all), true, channel.lastMessageId);
|
|
203
|
+
for (const t of all.filter((c) => c.kind === 'thread' && c.parentId === channel.id))
|
|
204
|
+
add(whereIn(server, t, all), false, t.lastMessageId);
|
|
205
|
+
}
|
|
206
|
+
for (const ref of o.threads)
|
|
207
|
+
add(named(ref, true), true);
|
|
208
|
+
// After a drop, everything this run has heard from, wherever it was.
|
|
209
|
+
for (const where of state.seen.values())
|
|
210
|
+
add(where, false);
|
|
121
211
|
const missed = [];
|
|
122
|
-
|
|
212
|
+
let read = 0;
|
|
213
|
+
let failed = 0;
|
|
214
|
+
for (const { where, named: wasNamed, mark, lastMessageId } of targets.values()) {
|
|
215
|
+
// Nothing newer than what was printed: no need to read it.
|
|
216
|
+
if (lastMessageId && BigInt(lastMessageId) <= mark)
|
|
217
|
+
continue;
|
|
123
218
|
try {
|
|
124
|
-
const page = await call(o.url, o.key, 'GET', `/servers/${seg(where.server.id)}/channels/${seg(where.channel.id)}/messages?limit
|
|
219
|
+
const page = await call(o.url, o.key, 'GET', `/servers/${seg(where.server.id)}/channels/${seg(where.channel.id)}/messages?limit=${PAGE}`);
|
|
220
|
+
read++;
|
|
221
|
+
const newer = page.messages.filter((m) => BigInt(m.id) > mark);
|
|
222
|
+
if (newer.length === PAGE) {
|
|
223
|
+
o.log(`botyard: more than ${PAGE} new messages in ${place(where)}; the latest ${PAGE} follow. Older: ${o.prog} read ${where.server.id} ${where.channel.id} --before ${newer[0].id}`);
|
|
224
|
+
}
|
|
125
225
|
// A channel asked for by name prints under its real name once one of its messages has come through the stream.
|
|
126
|
-
missed.push(...
|
|
226
|
+
missed.push(...newer.filter((m) => m.author.id !== state.own).map((m) => ({ ...m, ...(state.seen.get(m.channelId) ?? where) })));
|
|
127
227
|
}
|
|
128
228
|
catch (e) {
|
|
129
|
-
|
|
229
|
+
failed++;
|
|
230
|
+
// A channel the bot can't read, found by listing, is not worth a line every run.
|
|
231
|
+
if (wasNamed)
|
|
232
|
+
o.log(`botyard: could not catch up on ${place(where)} (${e instanceof Error ? e.message : e})`);
|
|
130
233
|
}
|
|
131
234
|
}
|
|
132
|
-
|
|
133
|
-
|
|
235
|
+
if (read === 0 && failed > 0)
|
|
236
|
+
return -1;
|
|
237
|
+
return missed.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1)).filter(print).length;
|
|
238
|
+
}
|
|
239
|
+
/** A --channel or --thread as typed, standing in for where its messages were posted until one arrives. */
|
|
240
|
+
function named(ref, thread) {
|
|
241
|
+
const split = ref.lastIndexOf('#');
|
|
242
|
+
const server = ref.slice(0, split);
|
|
243
|
+
const channel = ref.slice(split + 1);
|
|
244
|
+
return { server: { id: server, name: server }, channel: { id: channel, name: channel, thread, parentId: null, parentName: null } };
|
|
134
245
|
}
|
|
135
246
|
/** One message on one line: newlines in the text become ⏎, so each line is exactly one message. */
|
|
136
247
|
export function line(m) {
|