@rcpooley/botyard 0.4.3 → 0.5.1

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 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: botyard read ${quote(server)} ${quote(channel)} --before ${res.older}` : null;
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
- return watch({
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: botyard send ${quote(server)} ${thread.id} "..."`,
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) => ` botyard ${c.usage}\n ${c.summary}`)].join('\n'));
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
- 'Options: --bot <name|id> picks a bot, --json prints raw API responses, botyard <command> --help explains one command.',
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
- "- To hear about new messages as they arrive, run `botyard 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.",
423
- '- One topic, one thread: post a short opening message in the channel, then `botyard 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.',
424
- "- 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: `botyard thread ... --add <their user id>` (or `botyard thread-add <server> <thread> <user id>...` later). Adding does not ping them.",
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
- return [`botyard ${c.usage}`, '', `${c.summary[0].toUpperCase()}${c.summary.slice(1)}.`, ...(c.details ? ['', ...c.details] : [])].join('\n');
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
- json: { type: 'boolean', default: false },
451
- help: { type: 'boolean', short: 'h', default: false },
452
- version: { type: 'boolean', short: 'v', default: false },
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 botyard for usage.\n`);
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, positionals } = parsed;
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 botyard with no arguments for the list.\n`);
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: botyard ${command.usage}\n`);
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/format.js CHANGED
@@ -12,7 +12,7 @@ function pad(s, n) {
12
12
  export function me(m) {
13
13
  const servers = m.servers.length ? m.servers.map((s) => ` ${s.id} ${s.name}`).join('\n') : ' (none)';
14
14
  return [
15
- `Bot: ${m.bot.name} (${m.bot.id}), ${m.bot.status}`,
15
+ `Bot: ${m.bot.name} (${m.bot.id}), ${m.bot.status}${m.bot.transport === 'rest' ? ' (over REST while its gateway is down; watch lags a few seconds)' : ''}`,
16
16
  `Key: ${m.token.name} (${m.token.keyId})`,
17
17
  `Can: ${m.token.scopes.join(', ')}`,
18
18
  `Servers: ${m.token.allServers ? 'all of the bot\'s servers, now and later' : 'only these'}`,
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 -- in every channel and thread a
13
- * message has come from during this run, and the ones asked for. Only a
14
- * refusal (a revoked key, a server it may not read) ends it.
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
- // Snowflakes grow with time, across every channel, so "newer than the last
36
- // one printed" is a single number comparison.
37
- let last = 0n;
38
- /** Channels and threads to catch up on after a drop, by id (Discord's ids are unique across servers). */
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 <= last)
47
- return;
48
- last = id;
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: { authorization: `Bearer ${o.key}`, accept: 'text/event-stream', 'user-agent': 'botyard-cli' },
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
- // The stream is open, so nothing new can slip past it. Before reading it,
77
- // catch up on what was posted while it was down; the stream's own copy of
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 botyard message`);
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
- /** A --channel or --thread as typed, standing in for where its messages were posted until one arrives. */
111
- function named(ref, thread) {
112
- const split = ref.lastIndexOf('#');
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
- /** Read the channels and threads this run has seen and print what is newer than the last message printed, oldest first. */
118
- // `own` is the bot's user id when its own messages are to be left out: the
119
- // stream drops them server-side, but catch-up reads history, which has them.
120
- async function catchUp(o, channels, seen, print, own) {
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
- for (const where of channels) {
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=100`);
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(...page.messages.filter((m) => m.author.id !== own).map((m) => ({ ...m, ...(seen.get(m.channelId) ?? where) })));
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
- o.log(`botyard: could not catch up on ${place(where)} (${e instanceof Error ? e.message : e})`);
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
- // A channel both asked for and seen is read twice; print() drops the second copy by id.
133
- missed.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1)).forEach(print);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcpooley/botyard",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Read and send in Discord through your own bot, hosted by Botyard. Made for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {