@rcpooley/botyard 0.2.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/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,24 @@ 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>]',
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
+ },
178
198
  {
179
199
  name: 'edit',
180
200
  group: 'Writing',
@@ -344,7 +364,7 @@ function guide() {
344
364
  '',
345
365
  ...groups.flatMap((g) => [g, '']),
346
366
  '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.',
367
+ 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces. A thread is a <channel> too.',
348
368
  '',
349
369
  'Rules, for agents:',
350
370
  `- Never print, copy or store the key. The CLI keeps it (in ${configPath()}) and sends it for you.`,
@@ -353,7 +373,8 @@ function guide() {
353
373
  "- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
354
374
  '- Writes are limited to 60 a minute. Never send or react in a loop.',
355
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.",
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.",
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.',
357
378
  '',
358
379
  skillInstalled()
359
380
  ? `Claude Code skill: installed (${skillDir()}).`
@@ -387,6 +408,8 @@ async function main(argv) {
387
408
  'include-own': { type: 'boolean', default: false },
388
409
  server: { type: 'string', multiple: true },
389
410
  channel: { type: 'string', multiple: true },
411
+ thread: { type: 'string', multiple: true },
412
+ from: { type: 'string' },
390
413
  },
391
414
  });
392
415
  }
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.
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.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": {