@rcpooley/botyard 0.1.0 → 0.3.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 CHANGED
@@ -36,6 +36,7 @@ export async function call(url, key, method, path, body, retried = false) {
36
36
  return call(url, key, method, path, body, true);
37
37
  }
38
38
  }
39
+ warnOnce(res);
39
40
  const text = await res.text();
40
41
  if (!res.ok) {
41
42
  let message = `HTTP ${res.status}`;
@@ -49,5 +50,22 @@ export async function call(url, key, method, path, body, retried = false) {
49
50
  }
50
51
  return (text ? JSON.parse(text) : undefined);
51
52
  }
53
+ let warned = false;
54
+ /**
55
+ * Botyard's Botyard-Warning header says something is wrong with the bot itself
56
+ * that its owner has to fix (Message Content switched off). Printed once per
57
+ * run, to stderr, so it never mixes with output a script or agent parses.
58
+ */
59
+ export function warnOnce(res) {
60
+ const warning = res.headers.get('botyard-warning');
61
+ if (warning)
62
+ warnText(warning);
63
+ }
64
+ export function warnText(warning) {
65
+ if (warned)
66
+ return;
67
+ warned = true;
68
+ process.stderr.write(`botyard: warning: ${warning}\n`);
69
+ }
52
70
  /** A path segment from something a person typed: a name with spaces, a #channel, an emoji. */
53
71
  export const seg = (s) => encodeURIComponent(s);
package/dist/botyard.js CHANGED
@@ -6,6 +6,7 @@ import { ApiFailure, call, seg } 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";
9
+ import { watch } from "./watch.js";
9
10
  /**
10
11
  * botyard: Discord through Botyard, from a terminal. Made for AI agents first,
11
12
  * so the guide (no arguments, or --help) is written to one, and every error is
@@ -93,6 +94,35 @@ const COMMANDS = [
93
94
  return fmt.messages(res.messages, `#${channel.replace(/^#/, '')} in ${server}`, more);
94
95
  },
95
96
  },
97
+ {
98
+ name: 'watch',
99
+ group: 'Reading',
100
+ usage: 'watch [--server <server>]... [--channel <server>#<channel>]... [--thread <server>#<thread>]... [--include-own] [--json]',
101
+ summary: 'stay open and print each new message the moment it is posted, one line each',
102
+ details: [
103
+ 'With no options it carries every server, channel and thread the bot is in that the key can read.',
104
+ '--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
+ 'Botyard pushes each message as it is posted: no polling, nothing printed while it is quiet.',
106
+ "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.',
108
+ '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
+ ],
110
+ args: 0,
111
+ run: async (_a, opts) => {
112
+ const { url, key } = target(opts);
113
+ return watch({
114
+ url,
115
+ key,
116
+ servers: opts.server ?? [],
117
+ channels: opts.channel ?? [],
118
+ threads: opts.thread ?? [],
119
+ includeOwn: opts['include-own'] === true,
120
+ json: opts.json,
121
+ out: (l) => process.stdout.write(l + '\n'),
122
+ log: (l) => process.stderr.write(l + '\n'),
123
+ });
124
+ },
125
+ },
96
126
  {
97
127
  name: 'message',
98
128
  group: 'Reading',
@@ -147,6 +177,24 @@ const COMMANDS = [
147
177
  return opts.json ? json(m) : `Sent ${m.id}\n${m.url}`;
148
178
  },
149
179
  },
180
+ {
181
+ name: 'thread',
182
+ group: 'Writing',
183
+ usage: 'thread <server> <channel> <name> [--from <message id>]',
184
+ summary: 'start a thread in a channel, from one of its messages with --from',
185
+ details: [
186
+ 'Prints the new thread\'s id and name. Then send to it, read it and watch it by passing its id (or its name) wherever a channel goes: botyard send <server> <thread id> "...".',
187
+ 'Names are 1-100 characters; words after <channel> are joined, so quotes are optional. Without --from it is a new public thread with no opening message.',
188
+ 'For a new topic: 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. The channel then holds one entrance post per topic.',
189
+ 'Threads close themselves after a week without messages; sending to one opens it again.',
190
+ ],
191
+ args: 3,
192
+ rest: true,
193
+ run: async ([server, channel, name], opts) => {
194
+ const thread = await api(opts, 'POST', `${where(server, channel)}/threads`, { name, ...(opts.from ? { message: opts.from } : {}) });
195
+ return opts.json ? json(thread) : `Started thread ${thread.id} ${thread.name}\nSend to it: botyard send ${quote(server)} ${thread.id} "..."`;
196
+ },
197
+ },
150
198
  {
151
199
  name: 'edit',
152
200
  group: 'Writing',
@@ -208,6 +256,8 @@ const COMMANDS = [
208
256
  if (!config.default || !config.bots[config.default])
209
257
  config.default = res.me.bot.id;
210
258
  save(config);
259
+ // So every future Claude Code session on this machine knows botyard is here.
260
+ const skill = skillInstalled() ? null : installSkill();
211
261
  if (opts.json)
212
262
  return json(res.me);
213
263
  return [
@@ -219,6 +269,7 @@ const COMMANDS = [
219
269
  ? `This replaces the key you had for this bot (${previous.keyId}). It still works until its owner revokes it on Botyard.\n`
220
270
  : '',
221
271
  config.default === res.me.bot.id ? 'It is your default bot.' : `Your default bot is still ${config.bots[config.default].name}; pass --bot ${quote(res.me.bot.name)} to use this one.`,
272
+ skill ? `Installed the Claude Code skill (${skill}), so future sessions know about botyard.` : '',
222
273
  'Next: run botyard with no arguments to see what you can do.',
223
274
  ]
224
275
  .filter((l) => l !== '')
@@ -313,7 +364,7 @@ function guide() {
313
364
  '',
314
365
  ...groups.flatMap((g) => [g, '']),
315
366
  'Options: --bot <name|id> picks a bot, --json prints raw API responses, botyard <command> --help explains one command.',
316
- 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces.',
367
+ 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces. A thread is a <channel> too.',
317
368
  '',
318
369
  'Rules, for agents:',
319
370
  `- Never print, copy or store the key. The CLI keeps it (in ${configPath()}) and sends it for you.`,
@@ -321,6 +372,9 @@ function guide() {
321
372
  '- Mentions ping people, not @everyone/@here/roles, unless you pass --mentions all. Only do that when asked.',
322
373
  "- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
323
374
  '- Writes are limited to 60 a minute. Never send or react in a loop.',
375
+ "- 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.",
376
+ "- 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.",
377
+ '- 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.',
324
378
  '',
325
379
  skillInstalled()
326
380
  ? `Claude Code skill: installed (${skillDir()}).`
@@ -351,6 +405,11 @@ async function main(argv) {
351
405
  before: { type: 'string' },
352
406
  'reply-to': { type: 'string' },
353
407
  mentions: { type: 'string' },
408
+ 'include-own': { type: 'boolean', default: false },
409
+ server: { type: 'string', multiple: true },
410
+ channel: { type: 'string', multiple: true },
411
+ thread: { type: 'string', multiple: true },
412
+ from: { type: 'string' },
354
413
  },
355
414
  });
356
415
  }
package/dist/skill.js CHANGED
@@ -15,7 +15,7 @@ export function skillInstalled() {
15
15
  }
16
16
  export const SKILL = `---
17
17
  name: botyard
18
- description: Read and send Discord messages through the user's own bot, using the botyard CLI. Use whenever the user asks about Discord -- reading a channel, catching up on a server, posting, replying, reacting or pinning -- or mentions Botyard.
18
+ description: Read, send and follow Discord messages through the user's own bot, using the botyard CLI. Use whenever the user asks about Discord -- reading a channel, catching up on a server, posting, replying, reacting, pinning, or watching a channel for new messages -- or mentions Botyard.
19
19
  ---
20
20
 
21
21
  # botyard
@@ -24,6 +24,12 @@ The \`botyard\` CLI is installed and connected to the user's Discord bot (or bot
24
24
 
25
25
  **Run \`botyard\` with no arguments first.** It lists the connected bots, every command with an example, and the rules for using them. \`botyard <command> --help\` covers one command.
26
26
 
27
+ If messages from other people come back empty, the bot's Message Content intent is off. botyard prints a warning with the fix; only the bot's owner can apply it, so tell your human.
28
+
29
+ To follow Discord, don't poll: run \`botyard watch\` with the Monitor tool. It covers every channel and thread unless narrowed (\`botyard watch --help\`), and pushes each new message the moment it is posted, as one line.
30
+
31
+ For a new topic, post a short opening message, then start a thread from it with \`botyard thread\` and keep the discussion there.
32
+
27
33
  Never print, copy or store the key the CLI keeps; it handles authentication itself.
28
34
  `;
29
35
  export function installSkill() {
package/dist/watch.js ADDED
@@ -0,0 +1,179 @@
1
+ import { ApiFailure, call, seg, warnOnce, warnText } from "./api.js";
2
+ /**
3
+ * `botyard watch`: hold the event stream open and print each new message the
4
+ * moment Botyard pushes it, one line per message, so a tool that wakes on
5
+ * output (Claude Code's Monitor) reacts in about a second without polling.
6
+ * With no filters it carries every server, channel and thread the key can
7
+ * read; --server, --channel <server>#<channel> (with its threads) and
8
+ * --thread <server>#<thread> narrow it, each repeatable. Botyard leaves the
9
+ * bot's own messages out of the stream unless --include-own.
10
+ *
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.
15
+ */
16
+ const RETRY_FIRST_MS = 1_000;
17
+ const RETRY_MAX_MS = 30_000;
18
+ export async function watch(o) {
19
+ for (const c of o.channels) {
20
+ if (c.lastIndexOf('#') <= 0)
21
+ throw new Error(`--channel '${c}' needs its server: use <server>#<channel>, e.g. --channel "My Server#general".`);
22
+ }
23
+ for (const c of o.threads) {
24
+ if (c.lastIndexOf('#') <= 0)
25
+ throw new Error(`--thread '${c}' needs its server: use <server>#<thread>, e.g. --thread "My Server#release plans".`);
26
+ }
27
+ const me = await call(o.url, o.key, 'GET', '/me');
28
+ const query = new URLSearchParams([
29
+ ...o.servers.map((s) => ['server', s]),
30
+ ...o.channels.map((c) => ['channel', c]),
31
+ ...o.threads.map((c) => ['thread', c]),
32
+ // Botyard leaves the bot's own messages out unless asked.
33
+ ...(o.includeOwn ? [['own', '1']] : []),
34
+ ]);
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). */
39
+ 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
+ let retry = RETRY_FIRST_MS;
43
+ const print = (m) => {
44
+ seen.set(m.channel.id, { server: m.server, channel: m.channel });
45
+ const id = BigInt(m.id);
46
+ if (id <= last)
47
+ return;
48
+ last = id;
49
+ o.out(o.json ? JSON.stringify(m) : line(m));
50
+ };
51
+ const backoff = async (why) => {
52
+ o.log(`botyard: ${why}; retrying in ${retry / 1000}s`);
53
+ await sleep(retry);
54
+ retry = Math.min(retry * 2, RETRY_MAX_MS);
55
+ };
56
+ for (;;) {
57
+ let res;
58
+ try {
59
+ 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' },
61
+ });
62
+ }
63
+ catch (e) {
64
+ await backoff(`could not reach ${o.url} (${e instanceof Error ? e.message : e})`);
65
+ continue;
66
+ }
67
+ warnOnce(res);
68
+ if (!res.ok || !res.body) {
69
+ const message = await res.text().then(errorText, () => `HTTP ${res.status}`);
70
+ // A refusal will not change by retrying; a 5xx (the bot reconnecting, a deploy) will.
71
+ if (res.status < 500 && res.status !== 429)
72
+ throw new ApiFailure(res.status, message);
73
+ await backoff(message);
74
+ continue;
75
+ }
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);
81
+ else {
82
+ const named = [...o.servers.map((s) => `all of ${s}`), ...o.channels.map((c) => `${c} and its threads`), ...o.threads.map((c) => `thread ${c}`)];
83
+ const scope = named.length ? named.join(', ') : 'every server, channel and thread this key can read';
84
+ o.log(`botyard: watching ${scope}. Each new message prints as one line${o.includeOwn ? '' : `; ${me.bot.name}'s own are left out`}.`);
85
+ }
86
+ retry = RETRY_FIRST_MS;
87
+ try {
88
+ for await (const event of events(res.body)) {
89
+ if (event.name === 'message')
90
+ print(JSON.parse(event.data));
91
+ else if (event.name === 'missed') {
92
+ const m = JSON.parse(event.data);
93
+ o.log(`botyard: missed message ${m.id} in ${place(m)}; read it with botyard message`);
94
+ }
95
+ else if (event.name === 'warning')
96
+ warnText(JSON.parse(event.data).warning);
97
+ else if (event.name === 'end')
98
+ throw new ApiFailure(403, JSON.parse(event.data).error);
99
+ }
100
+ o.log('botyard: the stream closed; reopening');
101
+ }
102
+ catch (e) {
103
+ if (e instanceof ApiFailure)
104
+ throw e;
105
+ o.log(`botyard: the stream dropped (${e instanceof Error ? e.message : e}); reopening`);
106
+ }
107
+ await sleep(RETRY_FIRST_MS);
108
+ }
109
+ }
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 } };
116
+ }
117
+ /** Read the channels and threads this run has seen and print what is newer than the last message printed, oldest first. */
118
+ async function catchUp(o, channels, seen, print) {
119
+ const missed = [];
120
+ for (const where of channels) {
121
+ try {
122
+ const page = await call(o.url, o.key, 'GET', `/servers/${seg(where.server.id)}/channels/${seg(where.channel.id)}/messages?limit=100`);
123
+ // A channel asked for by name prints under its real name once one of its messages has come through the stream.
124
+ missed.push(...page.messages.map((m) => ({ ...m, ...(seen.get(m.channelId) ?? where) })));
125
+ }
126
+ catch (e) {
127
+ o.log(`botyard: could not catch up on ${place(where)} (${e instanceof Error ? e.message : e})`);
128
+ }
129
+ }
130
+ // A channel both asked for and seen is read twice; print() drops the second copy by id.
131
+ missed.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1)).forEach(print);
132
+ }
133
+ /** One message on one line: newlines in the text become ⏎, so each line is exactly one message. */
134
+ export function line(m) {
135
+ const name = m.author.displayName && m.author.displayName !== m.author.username ? `${m.author.displayName} (${m.author.username})` : m.author.username;
136
+ const flags = [m.author.bot ? '[bot]' : '', m.replyTo ? `↩ ${m.replyTo}` : ''].filter(Boolean).join(' ');
137
+ const extras = [...m.attachments.map((a) => `[attachment ${a.name}]`), ...m.embeds.map((e) => `[embed ${e.title ?? e.url ?? ''}]`)];
138
+ const text = [m.content.replace(/\r?\n/g, ' ⏎ '), ...extras].filter(Boolean).join(' ') || '(no text)';
139
+ return `${m.id} ${m.createdAt.slice(11, 19)} ${place(m)} ${name}${flags ? ' ' + flags : ''}: ${text}`;
140
+ }
141
+ /** "Server #channel", or "Server #channel › thread" for a thread ("Server › thread" if its channel is unknown). */
142
+ export function place(w) {
143
+ const c = w.channel;
144
+ if (!c.thread)
145
+ return `${w.server.name} #${c.name}`;
146
+ return `${w.server.name} ${c.parentName ? `#${c.parentName} ` : ''}› ${c.name}`;
147
+ }
148
+ /** Server-Sent Events from a fetch body: { name, data } per event; comments are skipped. */
149
+ async function* events(body) {
150
+ const decoder = new TextDecoder();
151
+ let buffer = '';
152
+ for await (const chunk of body) {
153
+ buffer += decoder.decode(chunk, { stream: true });
154
+ let end;
155
+ while ((end = buffer.indexOf('\n\n')) >= 0) {
156
+ const block = buffer.slice(0, end);
157
+ buffer = buffer.slice(end + 2);
158
+ let name = 'message';
159
+ const data = [];
160
+ for (const l of block.split('\n')) {
161
+ if (l.startsWith('event:'))
162
+ name = l.slice(6).trim();
163
+ else if (l.startsWith('data:'))
164
+ data.push(l.slice(5).trimStart());
165
+ }
166
+ if (data.length)
167
+ yield { name, data: data.join('\n') };
168
+ }
169
+ }
170
+ }
171
+ function errorText(text) {
172
+ try {
173
+ return JSON.parse(text).error ?? text;
174
+ }
175
+ catch {
176
+ return text || 'no answer';
177
+ }
178
+ }
179
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcpooley/botyard",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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": {