@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 +82 -7
- package/dist/skill.js +3 -1
- package/dist/watch.js +47 -28
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7
|
-
* --channel <server>#<channel>
|
|
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
|
|
11
|
-
* come from during this run, and the
|
|
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
|
|
23
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|