@tusile/bot 0.1.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * One thing somebody asked a bot to do, and the ways of answering it.
3
+ *
4
+ * The protocol underneath is a response type and a JSON body. Written out by hand that is
5
+ * four lines of ceremony around every answer, repeated at every branch of a switch, which
6
+ * is how a bot ends up with a `defer` it forgot to follow up and an `ephemeral` it meant
7
+ * to be public. These are the same four lines, named after what they do.
8
+ */
9
+
10
+ /** How long the server gives a bot to say something before the person is told it did not. */
11
+ export const ANSWER_WINDOW_MS = 10_000;
12
+
13
+ export class Interaction {
14
+ constructor(data, server) {
15
+ this.raw = data;
16
+ this.server = server;
17
+
18
+ this.id = data.id;
19
+ this.type = data.type;
20
+ this.command = data.command || '';
21
+ this.options = data.options || {};
22
+ this.customId = data.custom_id || '';
23
+ this.values = data.values || [];
24
+ this.fields = data.fields || {};
25
+ this.channelId = data.channel_id;
26
+ this.userId = data.user_id;
27
+ /** The message a control came from, when there is one. */
28
+ this.messageId = data.message_id || '';
29
+ /** The earlier answer a control came from, for a control on a private answer. */
30
+ this.sourceInteractionId = data.source_interaction_id || '';
31
+
32
+ this.answered = false;
33
+ }
34
+
35
+ get isCommand() {
36
+ return this.type === 'command';
37
+ }
38
+
39
+ get isComponent() {
40
+ return this.type === 'component';
41
+ }
42
+
43
+ get isModalSubmit() {
44
+ return this.type === 'modal_submit';
45
+ }
46
+
47
+ /**
48
+ * Answer it.
49
+ *
50
+ * A string is the common case and means a public message. An object is passed through,
51
+ * so anything the API accepts is still reachable.
52
+ */
53
+ reply(response, { ephemeral = false } = {}) {
54
+ const body =
55
+ typeof response === 'string'
56
+ ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
57
+ : { type: ephemeral ? 'ephemeral' : 'message', ...response };
58
+ return this.#answer(body);
59
+ }
60
+
61
+ /** Answer only the person who asked. Not channel history, and gone within a day. */
62
+ replyPrivately(response) {
63
+ return this.reply(response, { ephemeral: true });
64
+ }
65
+
66
+ /**
67
+ * Say "give me a moment".
68
+ *
69
+ * Buys fifteen minutes. Without it a bot that takes longer than ten seconds is reported
70
+ * to the person as not having responded, which is a lie about a bot that is working.
71
+ */
72
+ defer() {
73
+ return this.#answer({ type: 'defer' });
74
+ }
75
+
76
+ /**
77
+ * Rewrite the message the control is on.
78
+ *
79
+ * The usual answer to a press: a confirm that removes its own buttons, a page that
80
+ * turns, a toggle that redraws. `components: []` is how the buttons come off.
81
+ */
82
+ update(response) {
83
+ const body = typeof response === 'string' ? { content: response } : response;
84
+ return this.#answer({ type: 'update_message', ...body });
85
+ }
86
+
87
+ /** Acknowledge a press that needs no visible change, because it did its work elsewhere. */
88
+ acknowledge() {
89
+ return this.#answer({ type: 'defer_update' });
90
+ }
91
+
92
+ /** Ask the person to fill something in. The submission arrives as a modal_submit. */
93
+ showModal(modal) {
94
+ return this.#answer({ type: 'modal', modal });
95
+ }
96
+
97
+ /** Another message after the first. Only after defer or a first answer. */
98
+ followUp(response, { ephemeral = false } = {}) {
99
+ const body =
100
+ typeof response === 'string'
101
+ ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
102
+ : { type: ephemeral ? 'ephemeral' : 'message', ...response };
103
+ return this.server.request('POST', `/bot/interactions/${this.id}/followup`, body);
104
+ }
105
+
106
+ /**
107
+ * Change the answer already given.
108
+ *
109
+ * What a slow bot wants: defer, say "working on it", then say what happened, in the
110
+ * same message rather than a second one underneath the first.
111
+ */
112
+ editReply(response) {
113
+ const body = typeof response === 'string' ? { content: response } : response;
114
+ return this.server.request('PATCH', `/bot/interactions/${this.id}/response`, body);
115
+ }
116
+
117
+ /** Take the answer back. */
118
+ deleteReply() {
119
+ return this.server.request('DELETE', `/bot/interactions/${this.id}/response`);
120
+ }
121
+
122
+ async #answer(body) {
123
+ // Guarded here as well as on the server, because the server's refusal arrives as a
124
+ // 409 in a log somewhere and this arrives in the bot author's own stack trace.
125
+ if (this.answered) {
126
+ throw new Error(
127
+ `interaction ${this.id} has already been answered; use followUp or editReply`,
128
+ );
129
+ }
130
+ this.answered = true;
131
+ try {
132
+ return await this.server.request('POST', `/bot/interactions/${this.id}/callback`, body);
133
+ } catch (err) {
134
+ // Not answered after all, so a retry is allowed. Otherwise one dropped connection
135
+ // makes the interaction unanswerable for the rest of its life.
136
+ this.answered = false;
137
+ throw err;
138
+ }
139
+ }
140
+ }
package/src/rest.js ADDED
@@ -0,0 +1,234 @@
1
+ import { Transport, query } from './http.js';
2
+
3
+ /**
4
+ * One community, and everything a bot can do in it.
5
+ *
6
+ * Every method here is a line and a half over `request`, which is deliberate: the wrapper
7
+ * exists so that a bot author does not have to know that history comes back wrapped in an
8
+ * envelope or that a reaction has to be url-encoded, and `request` stays public so that an
9
+ * endpoint added next month needs no new release of this package.
10
+ *
11
+ * What a bot may actually do is decided by its roles in the community, not here. A call
12
+ * the bot lacks permission for comes back as a 403 with a reason, not as silence.
13
+ */
14
+ export class ServerClient {
15
+ #transport;
16
+
17
+ constructor({ apiUrl, token, serverId = '', fetch, sleep, maxRetries }) {
18
+ this.serverId = serverId;
19
+ this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
20
+ this.#transport = new Transport({
21
+ baseUrl: this.apiUrl,
22
+ authorization: () => `Bearer ${token()}`,
23
+ fetch,
24
+ sleep,
25
+ maxRetries,
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Anything at all, with this community's token.
31
+ *
32
+ * The escape hatch, and not a second-class one: everything below is written in terms of
33
+ * it, so a bot reaching an endpoint this package has never heard of is in exactly the
34
+ * same position as one calling a named method.
35
+ */
36
+ request(method, path, body) {
37
+ return this.#transport.request(method, path, body);
38
+ }
39
+
40
+ /** What the budget looked like a moment ago, for a bot that would rather not find out. */
41
+ get rateLimit() {
42
+ return this.#transport.rateLimit;
43
+ }
44
+
45
+ // The community itself.
46
+
47
+ serverInfo() {
48
+ return this.request('GET', '/bot/server');
49
+ }
50
+
51
+ // Messages.
52
+
53
+ /** Say something. A string is the common case; an object carries embeds and controls. */
54
+ send(channelId, message) {
55
+ return this.request('POST', `/bot/channels/${enc(channelId)}/messages`, contentOf(message));
56
+ }
57
+
58
+ /** Recent messages, newest first. `before` is a message id, for walking backwards. */
59
+ async history(channelId, { limit, before } = {}) {
60
+ const page = await this.request(
61
+ 'GET',
62
+ `/bot/channels/${enc(channelId)}/messages${query({ limit, before })}`,
63
+ );
64
+ return page?.messages ?? [];
65
+ }
66
+
67
+ message(messageId) {
68
+ return this.request('GET', `/bot/messages/${enc(messageId)}`);
69
+ }
70
+
71
+ editMessage(messageId, message) {
72
+ return this.request('PATCH', `/bot/messages/${enc(messageId)}`, contentOf(message));
73
+ }
74
+
75
+ deleteMessage(messageId) {
76
+ return this.request('DELETE', `/bot/messages/${enc(messageId)}`);
77
+ }
78
+
79
+ /** Clear several at once, which the server does as one act rather than a flood. */
80
+ deleteMessages(channelId, messageIds) {
81
+ return this.request('POST', `/bot/channels/${enc(channelId)}/messages/bulk-delete`, {
82
+ message_ids: messageIds,
83
+ });
84
+ }
85
+
86
+ /** The typing dots, for a bot about to take a second or two over a plain message. */
87
+ typing(channelId) {
88
+ return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
89
+ }
90
+
91
+ pin(messageId) {
92
+ return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
93
+ }
94
+
95
+ unpin(messageId) {
96
+ return this.request('DELETE', `/bot/messages/${enc(messageId)}/pin`);
97
+ }
98
+
99
+ react(messageId, emoji) {
100
+ return this.request('PUT', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
101
+ }
102
+
103
+ unreact(messageId, emoji) {
104
+ return this.request('DELETE', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
105
+ }
106
+
107
+ // Channels.
108
+
109
+ async channels() {
110
+ const out = await this.request('GET', '/bot/channels');
111
+ return out?.channels ?? [];
112
+ }
113
+
114
+ createChannel(channel) {
115
+ return this.request('POST', '/bot/channels', channel);
116
+ }
117
+
118
+ updateChannel(channelId, changes) {
119
+ return this.request('PATCH', `/bot/channels/${enc(channelId)}`, changes);
120
+ }
121
+
122
+ deleteChannel(channelId) {
123
+ return this.request('DELETE', `/bot/channels/${enc(channelId)}`);
124
+ }
125
+
126
+ // Roles.
127
+
128
+ async roles() {
129
+ const out = await this.request('GET', '/bot/roles');
130
+ return out?.roles ?? [];
131
+ }
132
+
133
+ createRole(role) {
134
+ return this.request('POST', '/bot/roles', role);
135
+ }
136
+
137
+ updateRole(roleId, changes) {
138
+ return this.request('PATCH', `/bot/roles/${enc(roleId)}`, changes);
139
+ }
140
+
141
+ deleteRole(roleId) {
142
+ return this.request('DELETE', `/bot/roles/${enc(roleId)}`);
143
+ }
144
+
145
+ // Members.
146
+
147
+ async members() {
148
+ const out = await this.request('GET', '/bot/members');
149
+ return out?.members ?? [];
150
+ }
151
+
152
+ addRole(userId, roleId) {
153
+ return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
154
+ }
155
+
156
+ removeRole(userId, roleId) {
157
+ return this.request('DELETE', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
158
+ }
159
+
160
+ /** A nickname, or null to put somebody back to their own name. `@me` renames the bot. */
161
+ setNickname(userId, nickname) {
162
+ return this.request('PATCH', `/bot/members/${enc(userId)}`, { nickname });
163
+ }
164
+
165
+ // Moderation.
166
+
167
+ kick(userId) {
168
+ return this.request('DELETE', `/bot/members/${enc(userId)}`);
169
+ }
170
+
171
+ /** Silence somebody for a while. Seconds, because "until" invites timezone bugs. */
172
+ timeout(userId, seconds, reason = '') {
173
+ return this.request('POST', `/bot/members/${enc(userId)}/timeout`, { seconds, reason });
174
+ }
175
+
176
+ clearTimeout(userId) {
177
+ return this.request('DELETE', `/bot/members/${enc(userId)}/timeout`);
178
+ }
179
+
180
+ ban(userId, reason = '') {
181
+ return this.request('POST', '/bot/bans', { user_id: userId, reason });
182
+ }
183
+
184
+ unban(userId) {
185
+ return this.request('DELETE', `/bot/bans/${enc(userId)}`);
186
+ }
187
+
188
+ async bans() {
189
+ const out = await this.request('GET', '/bot/bans');
190
+ return out?.bans ?? [];
191
+ }
192
+
193
+ // Commands.
194
+
195
+ async commands() {
196
+ const out = await this.request('GET', '/bot/commands');
197
+ return out?.commands ?? [];
198
+ }
199
+
200
+ /** Replace the command list. Whatever is not in it stops being offered. */
201
+ setCommands(commands) {
202
+ return this.request('PUT', '/bot/commands', { commands });
203
+ }
204
+
205
+ // Voice.
206
+
207
+ /**
208
+ * Appear in a voice channel.
209
+ *
210
+ * This is the presence half, which is what most bots want. Carrying audio needs a
211
+ * LiveKit client as well (`@livekit/rtc-node`, joining room `channel_<id>` with the
212
+ * token from `voiceToken`).
213
+ */
214
+ joinVoice(channelId) {
215
+ return this.request('POST', '/bot/voice/join', { channel_id: channelId });
216
+ }
217
+
218
+ leaveVoice(channelId) {
219
+ return this.request('POST', '/bot/voice/leave', { channel_id: channelId });
220
+ }
221
+
222
+ voiceToken(channelId) {
223
+ return this.request('POST', '/bot/livekit/token', { room: `channel_${channelId}` });
224
+ }
225
+ }
226
+
227
+ /** A string is content; anything else is already a body. */
228
+ function contentOf(message) {
229
+ return typeof message === 'string' ? { content: message } : message;
230
+ }
231
+
232
+ function enc(value) {
233
+ return encodeURIComponent(String(value ?? ''));
234
+ }