@rcpooley/botyard 0.1.0 → 0.2.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 +18 -0
- package/dist/botyard.js +36 -0
- package/dist/skill.js +5 -1
- package/dist/watch.js +160 -0
- package/package.json +1 -1
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,33 @@ 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>]... [--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 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".',
|
|
104
|
+
'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
|
+
'It runs until stopped. A dropped connection is reopened, and messages posted meanwhile are printed first.',
|
|
107
|
+
'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
|
+
args: 0,
|
|
110
|
+
run: async (_a, opts) => {
|
|
111
|
+
const { url, key } = target(opts);
|
|
112
|
+
return watch({
|
|
113
|
+
url,
|
|
114
|
+
key,
|
|
115
|
+
servers: opts.server ?? [],
|
|
116
|
+
channels: opts.channel ?? [],
|
|
117
|
+
includeOwn: opts['include-own'] === true,
|
|
118
|
+
json: opts.json,
|
|
119
|
+
out: (l) => process.stdout.write(l + '\n'),
|
|
120
|
+
log: (l) => process.stderr.write(l + '\n'),
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
},
|
|
96
124
|
{
|
|
97
125
|
name: 'message',
|
|
98
126
|
group: 'Reading',
|
|
@@ -208,6 +236,8 @@ const COMMANDS = [
|
|
|
208
236
|
if (!config.default || !config.bots[config.default])
|
|
209
237
|
config.default = res.me.bot.id;
|
|
210
238
|
save(config);
|
|
239
|
+
// So every future Claude Code session on this machine knows botyard is here.
|
|
240
|
+
const skill = skillInstalled() ? null : installSkill();
|
|
211
241
|
if (opts.json)
|
|
212
242
|
return json(res.me);
|
|
213
243
|
return [
|
|
@@ -219,6 +249,7 @@ const COMMANDS = [
|
|
|
219
249
|
? `This replaces the key you had for this bot (${previous.keyId}). It still works until its owner revokes it on Botyard.\n`
|
|
220
250
|
: '',
|
|
221
251
|
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.`,
|
|
252
|
+
skill ? `Installed the Claude Code skill (${skill}), so future sessions know about botyard.` : '',
|
|
222
253
|
'Next: run botyard with no arguments to see what you can do.',
|
|
223
254
|
]
|
|
224
255
|
.filter((l) => l !== '')
|
|
@@ -321,6 +352,8 @@ function guide() {
|
|
|
321
352
|
'- Mentions ping people, not @everyone/@here/roles, unless you pass --mentions all. Only do that when asked.',
|
|
322
353
|
"- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
|
|
323
354
|
'- Writes are limited to 60 a minute. Never send or react in a loop.',
|
|
355
|
+
"- 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.",
|
|
324
357
|
'',
|
|
325
358
|
skillInstalled()
|
|
326
359
|
? `Claude Code skill: installed (${skillDir()}).`
|
|
@@ -351,6 +384,9 @@ async function main(argv) {
|
|
|
351
384
|
before: { type: 'string' },
|
|
352
385
|
'reply-to': { type: 'string' },
|
|
353
386
|
mentions: { type: 'string' },
|
|
387
|
+
'include-own': { type: 'boolean', default: false },
|
|
388
|
+
server: { type: 'string', multiple: true },
|
|
389
|
+
channel: { type: 'string', multiple: true },
|
|
354
390
|
},
|
|
355
391
|
});
|
|
356
392
|
}
|
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
|
|
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,10 @@ 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 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.
|
|
30
|
+
|
|
27
31
|
Never print, copy or store the key the CLI keeps; it handles authentication itself.
|
|
28
32
|
`;
|
|
29
33
|
export function installSkill() {
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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 the key can read; --server and
|
|
7
|
+
* --channel <server>#<channel> narrow it, each repeatable.
|
|
8
|
+
*
|
|
9
|
+
* 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.
|
|
13
|
+
*/
|
|
14
|
+
const RETRY_FIRST_MS = 1_000;
|
|
15
|
+
const RETRY_MAX_MS = 30_000;
|
|
16
|
+
export async function watch(o) {
|
|
17
|
+
for (const c of o.channels) {
|
|
18
|
+
if (c.lastIndexOf('#') <= 0)
|
|
19
|
+
throw new Error(`--channel '${c}' needs its server: use <server>#<channel>, e.g. --channel "My Server#general".`);
|
|
20
|
+
}
|
|
21
|
+
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])]);
|
|
24
|
+
// Snowflakes grow with time, across every channel, so "newer than the last
|
|
25
|
+
// one printed" is a single number comparison.
|
|
26
|
+
let last = 0n;
|
|
27
|
+
/** Channels to catch up on after a drop, by server id + channel id. */
|
|
28
|
+
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
|
+
});
|
|
36
|
+
let retry = RETRY_FIRST_MS;
|
|
37
|
+
const print = (m) => {
|
|
38
|
+
seen.set(`${m.server.id}/${m.channel.id}`, { server: m.server, channel: m.channel });
|
|
39
|
+
const id = BigInt(m.id);
|
|
40
|
+
if (id <= last)
|
|
41
|
+
return;
|
|
42
|
+
last = id;
|
|
43
|
+
if (!o.includeOwn && m.author.id === own)
|
|
44
|
+
return;
|
|
45
|
+
o.out(o.json ? JSON.stringify(m) : line(m));
|
|
46
|
+
};
|
|
47
|
+
const backoff = async (why) => {
|
|
48
|
+
o.log(`botyard: ${why}; retrying in ${retry / 1000}s`);
|
|
49
|
+
await sleep(retry);
|
|
50
|
+
retry = Math.min(retry * 2, RETRY_MAX_MS);
|
|
51
|
+
};
|
|
52
|
+
for (;;) {
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(`${o.url.replace(/\/+$/, '')}/api/v1/events${query.size ? `?${query}` : ''}`, {
|
|
56
|
+
headers: { authorization: `Bearer ${o.key}`, accept: 'text/event-stream', 'user-agent': 'botyard-cli' },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
await backoff(`could not reach ${o.url} (${e instanceof Error ? e.message : e})`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
warnOnce(res);
|
|
64
|
+
if (!res.ok || !res.body) {
|
|
65
|
+
const message = await res.text().then(errorText, () => `HTTP ${res.status}`);
|
|
66
|
+
// A refusal will not change by retrying; a 5xx (the bot reconnecting, a deploy) will.
|
|
67
|
+
if (res.status < 500 && res.status !== 429)
|
|
68
|
+
throw new ApiFailure(res.status, message);
|
|
69
|
+
await backoff(message);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The stream is open, so nothing new can slip past it. Before reading it,
|
|
73
|
+
// catch up on what was posted while it was down; the stream's own copy of
|
|
74
|
+
// any of those is skipped by the id check in print().
|
|
75
|
+
if (last > 0n)
|
|
76
|
+
await catchUp(o, [...asked, ...seen.values()], print);
|
|
77
|
+
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`}.`);
|
|
81
|
+
}
|
|
82
|
+
retry = RETRY_FIRST_MS;
|
|
83
|
+
try {
|
|
84
|
+
for await (const event of events(res.body)) {
|
|
85
|
+
if (event.name === 'message')
|
|
86
|
+
print(JSON.parse(event.data));
|
|
87
|
+
else if (event.name === 'missed') {
|
|
88
|
+
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`);
|
|
90
|
+
}
|
|
91
|
+
else if (event.name === 'warning')
|
|
92
|
+
warnText(JSON.parse(event.data).warning);
|
|
93
|
+
else if (event.name === 'end')
|
|
94
|
+
throw new ApiFailure(403, JSON.parse(event.data).error);
|
|
95
|
+
}
|
|
96
|
+
o.log('botyard: the stream closed; reopening');
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e instanceof ApiFailure)
|
|
100
|
+
throw e;
|
|
101
|
+
o.log(`botyard: the stream dropped (${e instanceof Error ? e.message : e}); reopening`);
|
|
102
|
+
}
|
|
103
|
+
await sleep(RETRY_FIRST_MS);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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) {
|
|
108
|
+
const missed = [];
|
|
109
|
+
for (const where of channels) {
|
|
110
|
+
try {
|
|
111
|
+
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 })));
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
o.log(`botyard: could not catch up on ${where.server.name} #${where.channel.name} (${e instanceof Error ? e.message : e})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// A channel both asked for and seen is read twice; print() drops the second copy by id.
|
|
119
|
+
missed.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1)).forEach(print);
|
|
120
|
+
}
|
|
121
|
+
/** One message on one line: newlines in the text become ⏎, so each line is exactly one message. */
|
|
122
|
+
export function line(m) {
|
|
123
|
+
const name = m.author.displayName && m.author.displayName !== m.author.username ? `${m.author.displayName} (${m.author.username})` : m.author.username;
|
|
124
|
+
const flags = [m.author.bot ? '[bot]' : '', m.replyTo ? `↩ ${m.replyTo}` : ''].filter(Boolean).join(' ');
|
|
125
|
+
const extras = [...m.attachments.map((a) => `[attachment ${a.name}]`), ...m.embeds.map((e) => `[embed ${e.title ?? e.url ?? ''}]`)];
|
|
126
|
+
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}`;
|
|
128
|
+
}
|
|
129
|
+
/** Server-Sent Events from a fetch body: { name, data } per event; comments are skipped. */
|
|
130
|
+
async function* events(body) {
|
|
131
|
+
const decoder = new TextDecoder();
|
|
132
|
+
let buffer = '';
|
|
133
|
+
for await (const chunk of body) {
|
|
134
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
135
|
+
let end;
|
|
136
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
137
|
+
const block = buffer.slice(0, end);
|
|
138
|
+
buffer = buffer.slice(end + 2);
|
|
139
|
+
let name = 'message';
|
|
140
|
+
const data = [];
|
|
141
|
+
for (const l of block.split('\n')) {
|
|
142
|
+
if (l.startsWith('event:'))
|
|
143
|
+
name = l.slice(6).trim();
|
|
144
|
+
else if (l.startsWith('data:'))
|
|
145
|
+
data.push(l.slice(5).trimStart());
|
|
146
|
+
}
|
|
147
|
+
if (data.length)
|
|
148
|
+
yield { name, data: data.join('\n') };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function errorText(text) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(text).error ?? text;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return text || 'no answer';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|