@rcpooley/botyard 0.2.0 → 0.4.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/botyard.js CHANGED
@@ -97,12 +97,13 @@ const COMMANDS = [
97
97
  {
98
98
  name: 'watch',
99
99
  group: 'Reading',
100
- usage: 'watch [--server <server>]... [--channel <server>#<channel>]... [--include-own] [--json]',
100
+ usage: 'watch [--server <server>]... [--channel <server>#<channel>]... [--thread <server>#<thread>]... [--include-own] [--json]',
101
101
  summary: 'stay open and print each new message the moment it is posted, one line each',
102
102
  details: [
103
- 'With no options it carries every server the bot is in that the key can read. --server <server> and --channel <server>#<channel> narrow it; repeat either, and a message matching any of them prints. Ids or names: --channel "My Server#general".',
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".',
104
105
  'Botyard pushes each message as it is posted: no polling, nothing printed while it is quiet.',
105
- "Each line is one message: id, time (UTC), server #channel, author, text (newlines shown as ⏎). The bot's own messages are skipped unless --include-own. --json prints one JSON object per line instead.",
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).",
106
107
  'It runs until stopped. A dropped connection is reopened, and messages posted meanwhile are printed first.',
107
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.',
108
109
  ],
@@ -114,6 +115,7 @@ const COMMANDS = [
114
115
  key,
115
116
  servers: opts.server ?? [],
116
117
  channels: opts.channel ?? [],
118
+ threads: opts.thread ?? [],
117
119
  includeOwn: opts['include-own'] === true,
118
120
  json: opts.json,
119
121
  out: (l) => process.stdout.write(l + '\n'),
@@ -175,6 +177,70 @@ const COMMANDS = [
175
177
  return opts.json ? json(m) : `Sent ${m.id}\n${m.url}`;
176
178
  },
177
179
  },
180
+ {
181
+ name: 'thread',
182
+ group: 'Writing',
183
+ usage: 'thread <server> <channel> <name> [--from <message id>] [--add <user id>]...',
184
+ summary: 'start a thread in a channel, from one of its messages with --from, adding people with --add',
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
+ "--add <user id> adds that person to the thread (repeat it, up to 10), silently: no ping. Discord notifies someone about a thread's messages only once they are a member of it, so add the humans who should hear about it. Someone Discord refuses (not in the server, say) is listed and the thread is still made. For a thread that already exists: botyard thread-add.",
191
+ ],
192
+ args: 3,
193
+ rest: true,
194
+ run: async ([server, channel, name], opts) => {
195
+ const thread = await api(opts, 'POST', `${where(server, channel)}/threads`, {
196
+ name,
197
+ ...(opts.from ? { message: opts.from } : {}),
198
+ ...(opts.add?.length ? { members: opts.add } : {}),
199
+ });
200
+ if (opts.json)
201
+ return json(thread);
202
+ return [
203
+ `Started thread ${thread.id} ${thread.name}`,
204
+ ...memberLines(thread.added ?? [], thread.failed ?? []),
205
+ `Send to it: botyard send ${quote(server)} ${thread.id} "..."`,
206
+ ].join('\n');
207
+ },
208
+ },
209
+ {
210
+ name: 'thread-add',
211
+ group: 'Writing',
212
+ usage: 'thread-add <server> <thread> <user id>...',
213
+ summary: 'add people to an existing thread, silently',
214
+ details: [
215
+ 'Adds each user id to the thread without pinging them. Discord notifies a person about a thread\'s messages only once they are a member of it, so this is how the humans who own a project get notified of the threads you start.',
216
+ '<thread> is its id or, while it is active, its name. A user id is a number (Discord: Settings > Advanced > Developer Mode, then right-click a person > Copy User ID).',
217
+ 'Prints who was added and who Discord refused and why (someone not in the server, an archived or locked thread); exits 1 if any failed.',
218
+ ],
219
+ args: 3,
220
+ many: true,
221
+ run: async ([server, thread, ...users], opts) => {
222
+ const added = [];
223
+ const failed = [];
224
+ for (const id of users) {
225
+ try {
226
+ await api(opts, 'PUT', `${where(server, thread)}/members/${seg(id)}`);
227
+ added.push(id);
228
+ }
229
+ catch (e) {
230
+ failed.push({ id, error: e instanceof Error ? e.message : String(e) });
231
+ }
232
+ }
233
+ if (opts.json) {
234
+ if (failed.length > 0)
235
+ process.exitCode = 1;
236
+ return json({ added, ...(failed.length > 0 ? { failed } : {}) });
237
+ }
238
+ const lines = memberLines(added, failed).join('\n');
239
+ if (failed.length > 0)
240
+ throw new Error(lines);
241
+ return lines;
242
+ },
243
+ },
178
244
  {
179
245
  name: 'edit',
180
246
  group: 'Writing',
@@ -344,7 +410,7 @@ function guide() {
344
410
  '',
345
411
  ...groups.flatMap((g) => [g, '']),
346
412
  'Options: --bot <name|id> picks a bot, --json prints raw API responses, botyard <command> --help explains one command.',
347
- 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces.',
413
+ 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces. A thread is a <channel> too.',
348
414
  '',
349
415
  'Rules, for agents:',
350
416
  `- Never print, copy or store the key. The CLI keeps it (in ${configPath()}) and sends it for you.`,
@@ -353,7 +419,9 @@ function guide() {
353
419
  "- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
354
420
  '- Writes are limited to 60 a minute. Never send or react in a loop.',
355
421
  "- 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.",
356
- "- To hear about new messages as they arrive, run `botyard watch` (in Claude Code, with the Monitor tool; repeatable --server and --channel <server>#<channel> narrow it). Don't poll with read.",
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.",
357
425
  '',
358
426
  skillInstalled()
359
427
  ? `Claude Code skill: installed (${skillDir()}).`
@@ -363,6 +431,10 @@ function guide() {
363
431
  function commandHelp(c) {
364
432
  return [`botyard ${c.usage}`, '', `${c.summary[0].toUpperCase()}${c.summary.slice(1)}.`, ...(c.details ? ['', ...c.details] : [])].join('\n');
365
433
  }
434
+ /** Who a thread's members call added, and who Discord refused. */
435
+ function memberLines(added, failed) {
436
+ return [...(added.length > 0 ? [`Added (silently, no ping): ${added.join(', ')}`] : []), ...failed.map((f) => `Could not add ${f.id}: ${f.error}`)];
437
+ }
366
438
  function quote(s) {
367
439
  return /^[\w#.-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`;
368
440
  }
@@ -387,6 +459,9 @@ async function main(argv) {
387
459
  'include-own': { type: 'boolean', default: false },
388
460
  server: { type: 'string', multiple: true },
389
461
  channel: { type: 'string', multiple: true },
462
+ thread: { type: 'string', multiple: true },
463
+ from: { type: 'string' },
464
+ add: { type: 'string', multiple: true },
390
465
  },
391
466
  });
392
467
  }
@@ -417,8 +492,8 @@ async function main(argv) {
417
492
  process.stdout.write(commandHelp(command) + '\n');
418
493
  return 0;
419
494
  }
420
- const given = command.rest && args.length > command.args ? [...args.slice(0, command.args - 1), args.slice(command.args - 1).join(' ')] : args;
421
- if (given.length !== command.args) {
495
+ 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
+ if (command.many ? given.length < command.args : given.length !== command.args) {
422
497
  process.stderr.write(`botyard: usage: botyard ${command.usage}\n`);
423
498
  return 2;
424
499
  }
package/dist/skill.js CHANGED
@@ -26,7 +26,9 @@ The \`botyard\` CLI is installed and connected to the user's Discord bot (or bot
26
26
 
27
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
28
 
29
- To follow a channel, don't poll: run \`botyard watch\` with the Monitor tool (repeatable \`--server <server>\` and \`--channel "<server>#<channel>"\` narrow it to what you care about). Botyard pushes each new message the moment it is posted, as one line.
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. Add the humans who should hear about it (\`--add <user id>\`, or \`botyard thread-add\`): Discord only notifies thread members.
30
32
 
31
33
  Never print, copy or store the key the CLI keeps; it handles authentication itself.
32
34
  `;
package/dist/watch.js CHANGED
@@ -3,13 +3,15 @@ import { ApiFailure, call, seg, warnOnce, warnText } from "./api.js";
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
5
5
  * output (Claude Code's Monitor) reacts in about a second without polling.
6
- * With no filters it carries every server the key can read; --server and
7
- * --channel <server>#<channel> narrow it, each repeatable.
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.
8
10
  *
9
11
  * It never returns on its own: a dropped stream is reopened, and what was
10
- * posted meanwhile is read and printed first -- in every channel a message has
11
- * come from during this run, and the one asked for. Only a refusal (a revoked
12
- * key, a server it may not read) ends it.
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.
13
15
  */
14
16
  const RETRY_FIRST_MS = 1_000;
15
17
  const RETRY_MAX_MS = 30_000;
@@ -18,30 +20,32 @@ export async function watch(o) {
18
20
  if (c.lastIndexOf('#') <= 0)
19
21
  throw new Error(`--channel '${c}' needs its server: use <server>#<channel>, e.g. --channel "My Server#general".`);
20
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
+ }
21
27
  const me = await call(o.url, o.key, 'GET', '/me');
22
- const own = me.bot.userId || me.bot.id;
23
- const query = new URLSearchParams([...o.servers.map((s) => ['server', s]), ...o.channels.map((c) => ['channel', c])]);
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
+ ]);
24
35
  // Snowflakes grow with time, across every channel, so "newer than the last
25
36
  // one printed" is a single number comparison.
26
37
  let last = 0n;
27
- /** Channels to catch up on after a drop, by server id + channel id. */
38
+ /** Channels and threads to catch up on after a drop, by id (Discord's ids are unique across servers). */
28
39
  const seen = new Map();
29
- /** The channels asked for by name, caught up on even before a message has come from them. */
30
- const asked = o.channels.map((c) => {
31
- const split = c.lastIndexOf('#');
32
- const server = c.slice(0, split);
33
- const channel = c.slice(split + 1);
34
- return { server: { id: server, name: server }, channel: { id: channel, name: channel } };
35
- });
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))];
36
42
  let retry = RETRY_FIRST_MS;
37
43
  const print = (m) => {
38
- seen.set(`${m.server.id}/${m.channel.id}`, { server: m.server, channel: m.channel });
44
+ seen.set(m.channel.id, { server: m.server, channel: m.channel });
39
45
  const id = BigInt(m.id);
40
46
  if (id <= last)
41
47
  return;
42
48
  last = id;
43
- if (!o.includeOwn && m.author.id === own)
44
- return;
45
49
  o.out(o.json ? JSON.stringify(m) : line(m));
46
50
  };
47
51
  const backoff = async (why) => {
@@ -73,11 +77,11 @@ export async function watch(o) {
73
77
  // catch up on what was posted while it was down; the stream's own copy of
74
78
  // any of those is skipped by the id check in print().
75
79
  if (last > 0n)
76
- await catchUp(o, [...asked, ...seen.values()], print);
80
+ await catchUp(o, [...asked, ...seen.values()], seen, print);
77
81
  else {
78
- const named = [...o.servers.map((s) => `all of ${s}`), ...o.channels];
79
- const scope = named.length ? named.join(', ') : 'every server this key can read';
80
- o.log(`botyard: watching ${scope}. Each new message prints as one line${o.includeOwn ? '' : `; ${me.bot.name}'s own are skipped`}.`);
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`}.`);
81
85
  }
82
86
  retry = RETRY_FIRST_MS;
83
87
  try {
@@ -86,7 +90,7 @@ export async function watch(o) {
86
90
  print(JSON.parse(event.data));
87
91
  else if (event.name === 'missed') {
88
92
  const m = JSON.parse(event.data);
89
- o.log(`botyard: missed message ${m.id} in ${m.server.name} #${m.channel.name}; read it with botyard message`);
93
+ o.log(`botyard: missed message ${m.id} in ${place(m)}; read it with botyard message`);
90
94
  }
91
95
  else if (event.name === 'warning')
92
96
  warnText(JSON.parse(event.data).warning);
@@ -103,16 +107,24 @@ export async function watch(o) {
103
107
  await sleep(RETRY_FIRST_MS);
104
108
  }
105
109
  }
106
- /** Read the channels this run has seen and print what is newer than the last message printed, oldest first. */
107
- async function catchUp(o, channels, print) {
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) {
108
119
  const missed = [];
109
120
  for (const where of channels) {
110
121
  try {
111
122
  const page = await call(o.url, o.key, 'GET', `/servers/${seg(where.server.id)}/channels/${seg(where.channel.id)}/messages?limit=100`);
112
- missed.push(...page.messages.map((m) => ({ ...m, ...where })));
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) })));
113
125
  }
114
126
  catch (e) {
115
- o.log(`botyard: could not catch up on ${where.server.name} #${where.channel.name} (${e instanceof Error ? e.message : e})`);
127
+ o.log(`botyard: could not catch up on ${place(where)} (${e instanceof Error ? e.message : e})`);
116
128
  }
117
129
  }
118
130
  // A channel both asked for and seen is read twice; print() drops the second copy by id.
@@ -124,7 +136,14 @@ export function line(m) {
124
136
  const flags = [m.author.bot ? '[bot]' : '', m.replyTo ? `↩ ${m.replyTo}` : ''].filter(Boolean).join(' ');
125
137
  const extras = [...m.attachments.map((a) => `[attachment ${a.name}]`), ...m.embeds.map((e) => `[embed ${e.title ?? e.url ?? ''}]`)];
126
138
  const text = [m.content.replace(/\r?\n/g, ' ⏎ '), ...extras].filter(Boolean).join(' ') || '(no text)';
127
- return `${m.id} ${m.createdAt.slice(11, 19)} ${m.server.name} #${m.channel.name} ${name}${flags ? ' ' + flags : ''}: ${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}`;
128
147
  }
129
148
  /** Server-Sent Events from a fetch body: { name, data } per event; comments are skipped. */
130
149
  async function* events(body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcpooley/botyard",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {