chapterjs 0.4.0 → 0.5.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/README.md +3 -1
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +3 -3
- package/dist/cli/dev.js +8 -5
- package/dist/cli/project.d.ts +31 -2
- package/dist/cli/project.js +166 -34
- package/dist/cli/redis.js +14 -2
- package/dist/cli/start.js +11 -1
- package/dist/cli/test.js +108 -16
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +19 -10
- package/dist/events/registry.js +10 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -0
- package/dist/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- package/dist/store/file-backend.d.ts +0 -1
- package/dist/store/file-backend.js +18 -1
- package/dist/store/scope.d.ts +6 -1
- package/dist/store/scope.js +9 -3
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
package/dist/testing/smoke.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// function threw: the content is the scenarios' business.
|
|
6
6
|
import { Permissions } from '../discord/permissions.js';
|
|
7
7
|
import { ChannelType } from '../discord/types/channel.js';
|
|
8
|
-
import { commandName } from '../commands/tree.js';
|
|
8
|
+
import { commandName, describeCommand, } from '../commands/tree.js';
|
|
9
9
|
import { EVENTS } from '../events/registry.js';
|
|
10
10
|
const UNICODE = '🙂 héllo wörld — ça va ?';
|
|
11
11
|
const FILE = {
|
|
@@ -152,9 +152,90 @@ function hard(option) {
|
|
|
152
152
|
];
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
/** The longest message Discord accepts. */
|
|
156
|
+
const MAX_MESSAGE = 2000;
|
|
157
|
+
/**
|
|
158
|
+
* The checks of a menu command: used on an ordinary message or person, and
|
|
159
|
+
* on the hard ones (a message at its longest, the bot, the owner, someone
|
|
160
|
+
* who left...).
|
|
161
|
+
*/
|
|
162
|
+
function menuCheck({ command, file }) {
|
|
163
|
+
const declaration = { config: { run: command.run } };
|
|
164
|
+
const subject = describeCommand(command);
|
|
165
|
+
const dm = command.where === 'dm';
|
|
166
|
+
const cases = [];
|
|
167
|
+
const use = (session, on, where = {}) => {
|
|
168
|
+
const by = userFor(session, command);
|
|
169
|
+
return Promise.resolve(on(session, by))
|
|
170
|
+
.then(target => session.use(declaration, { on: target }, {
|
|
171
|
+
by,
|
|
172
|
+
...where,
|
|
173
|
+
}))
|
|
174
|
+
.then(() => { });
|
|
175
|
+
};
|
|
176
|
+
if (command.type === 'message') {
|
|
177
|
+
// The message is written where the menu is used.
|
|
178
|
+
const say = (content, author) => (session, by) => session.say(content, {
|
|
179
|
+
by: author?.(session) ?? session.someone,
|
|
180
|
+
...(dm ? { in: 'dm', by } : {}),
|
|
181
|
+
});
|
|
182
|
+
cases.push({ label: 'on a message', run: session => use(session, say('Hello!')) }, {
|
|
183
|
+
label: 'on a message at its longest, with unicode',
|
|
184
|
+
run: session => use(session, say(`${UNICODE} ${textOf(MAX_MESSAGE)}`.slice(0, MAX_MESSAGE))),
|
|
185
|
+
});
|
|
186
|
+
if (!dm) {
|
|
187
|
+
cases.push({
|
|
188
|
+
label: 'on a message of the owner',
|
|
189
|
+
run: session => use(session, say('Hello!', current => current.owner)),
|
|
190
|
+
}, {
|
|
191
|
+
label: 'on a message of someone who left',
|
|
192
|
+
run: session => use(session, async (current, by) => {
|
|
193
|
+
// Someone of its own, who writes and then leaves.
|
|
194
|
+
const writer = current.member('gone-writer');
|
|
195
|
+
const message = await say('Hello!', () => writer)(current, by);
|
|
196
|
+
current.world.members.delete(writer.id);
|
|
197
|
+
return message;
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (command.where === 'both') {
|
|
202
|
+
cases.push({
|
|
203
|
+
label: 'in a private message',
|
|
204
|
+
run: session => use(session, (current, by) => current.say('Hello!', { by, in: 'dm' })),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return { subject, file, cases };
|
|
208
|
+
}
|
|
209
|
+
cases.push({
|
|
210
|
+
label: 'on someone',
|
|
211
|
+
run: session => use(session, current => current.member('alice')),
|
|
212
|
+
}, { label: 'on who uses it', run: session => use(session, (_, by) => by) }, { label: 'on the bot', run: session => use(session, current => current.me) });
|
|
213
|
+
if (!dm) {
|
|
214
|
+
cases.push({
|
|
215
|
+
label: 'on the owner',
|
|
216
|
+
run: session => use(session, current => current.owner),
|
|
217
|
+
}, {
|
|
218
|
+
label: 'on someone who left',
|
|
219
|
+
run: session => use(session, current => {
|
|
220
|
+
const gone = current.member('gone');
|
|
221
|
+
current.world.members.delete(gone.id);
|
|
222
|
+
return gone;
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (command.where === 'both') {
|
|
227
|
+
cases.push({
|
|
228
|
+
label: 'in a private message',
|
|
229
|
+
run: session => use(session, (_, by) => by, { in: 'dm' }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return { subject, file, cases };
|
|
233
|
+
}
|
|
155
234
|
/** The checks of the commands. */
|
|
156
235
|
function commandChecks(project) {
|
|
157
236
|
return [...project.commands.values()].map((entry) => {
|
|
237
|
+
if (entry.command.type !== 'slash')
|
|
238
|
+
return menuCheck(entry);
|
|
158
239
|
const { command, file } = entry;
|
|
159
240
|
const declaration = { config: { run: command.run } };
|
|
160
241
|
const subject = `command ${commandName(command.path)}`;
|
|
@@ -274,6 +355,9 @@ function componentChecks(project) {
|
|
|
274
355
|
const { component, file } = entry;
|
|
275
356
|
if (component.kind === 'embed')
|
|
276
357
|
continue;
|
|
358
|
+
// Only waited for (`message.waitForClick()`): nothing of its own runs.
|
|
359
|
+
if (component.kind !== 'modal' && !component.run)
|
|
360
|
+
continue;
|
|
277
361
|
const { name, data } = component;
|
|
278
362
|
const target = (values) => ({
|
|
279
363
|
customId: [
|
|
@@ -644,6 +728,32 @@ function taskChecks(project) {
|
|
|
644
728
|
],
|
|
645
729
|
}));
|
|
646
730
|
}
|
|
731
|
+
/**
|
|
732
|
+
* The check of the presence, when it has functions: each runs once, as
|
|
733
|
+
* the rotation would run it.
|
|
734
|
+
*/
|
|
735
|
+
function presenceChecks(project) {
|
|
736
|
+
const file = project.presenceFile;
|
|
737
|
+
const presence = project.presence;
|
|
738
|
+
if (!file || !presence)
|
|
739
|
+
return [];
|
|
740
|
+
const hasFunctions = typeof presence.activities === 'function' ||
|
|
741
|
+
presence.activities.some(one => typeof one.name === 'function');
|
|
742
|
+
if (!hasFunctions)
|
|
743
|
+
return [];
|
|
744
|
+
return [
|
|
745
|
+
{
|
|
746
|
+
subject: 'the presence',
|
|
747
|
+
file,
|
|
748
|
+
cases: [
|
|
749
|
+
{
|
|
750
|
+
label: 'once',
|
|
751
|
+
run: session => session.project.runPresence(session.bot),
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
];
|
|
756
|
+
}
|
|
647
757
|
/** Every check of the project, in the order of the report. */
|
|
648
758
|
export function smokeChecks(project) {
|
|
649
759
|
const events = eventChecks(project);
|
|
@@ -653,6 +763,7 @@ export function smokeChecks(project) {
|
|
|
653
763
|
...componentChecks(project),
|
|
654
764
|
...events.checks,
|
|
655
765
|
...taskChecks(project),
|
|
766
|
+
...presenceChecks(project),
|
|
656
767
|
],
|
|
657
768
|
notSimulated: events.notSimulated,
|
|
658
769
|
};
|
package/dist/testing/world.js
CHANGED
|
@@ -59,6 +59,11 @@ const unknown = (what, code) => ({
|
|
|
59
59
|
status: 404,
|
|
60
60
|
body: { message: `Unknown ${what}`, code },
|
|
61
61
|
});
|
|
62
|
+
/** What Discord answers an action the bot is not high enough for. */
|
|
63
|
+
const MISSING_PERMISSIONS = {
|
|
64
|
+
status: 403,
|
|
65
|
+
body: { message: 'Missing Permissions', code: 50013 },
|
|
66
|
+
};
|
|
62
67
|
/** The regular expression matching a documented path, placeholders filled. */
|
|
63
68
|
function patternOf(path) {
|
|
64
69
|
const source = path
|
|
@@ -238,15 +243,49 @@ export class World {
|
|
|
238
243
|
tertiary_color: null,
|
|
239
244
|
},
|
|
240
245
|
hoist: false,
|
|
241
|
-
position: options.position ??
|
|
246
|
+
position: options.position ?? 1,
|
|
242
247
|
permissions: (options.permissions ?? 0n).toString(),
|
|
243
248
|
managed: false,
|
|
244
249
|
mentionable: false,
|
|
245
250
|
flags: 0,
|
|
246
251
|
};
|
|
252
|
+
// A new role goes at the bottom, just above @everyone, as Discord
|
|
253
|
+
// places it: the ones from there up move up one.
|
|
254
|
+
for (const other of this.roles.values()) {
|
|
255
|
+
if (other.position >= role.position && other.id !== this.guildId)
|
|
256
|
+
other.position += 1;
|
|
257
|
+
}
|
|
247
258
|
this.roles.set(role.id, role);
|
|
248
259
|
return role;
|
|
249
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Whether the bot may act on someone: kick, ban, change their nickname,
|
|
263
|
+
* roles or timeout. Never on the owner of the server, and only on members
|
|
264
|
+
* whose highest role is lower than the bot's.
|
|
265
|
+
* @see https://docs.discord.com/developers/topics/permissions#permission-hierarchy
|
|
266
|
+
*/
|
|
267
|
+
#outranks(userId) {
|
|
268
|
+
if (userId === this.ownerId)
|
|
269
|
+
return false;
|
|
270
|
+
if (userId === this.botId)
|
|
271
|
+
return true;
|
|
272
|
+
const member = this.members.get(userId);
|
|
273
|
+
return !member || this.#highest(this.botId) > this.#highest(userId);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Whether the bot may give or take this role from someone: a role lower
|
|
277
|
+
* than its highest, on someone it outranks (itself included).
|
|
278
|
+
*/
|
|
279
|
+
#givable(userId, roleId) {
|
|
280
|
+
const role = this.roles.get(roleId);
|
|
281
|
+
return (this.#outranks(userId) &&
|
|
282
|
+
(role?.position ?? 0) < this.#highest(this.botId));
|
|
283
|
+
}
|
|
284
|
+
/** The position of someone's highest role (0: @everyone only). */
|
|
285
|
+
#highest(userId) {
|
|
286
|
+
const member = this.members.get(userId);
|
|
287
|
+
return Math.max(0, ...(member?.roles ?? []).map(id => this.roles.get(id)?.position ?? 0));
|
|
288
|
+
}
|
|
250
289
|
addChannel(name, options = {}) {
|
|
251
290
|
const channel = {
|
|
252
291
|
id: this.snowflake(),
|
|
@@ -750,6 +789,8 @@ export class World {
|
|
|
750
789
|
return found.refused;
|
|
751
790
|
const { member } = found;
|
|
752
791
|
const changes = body(request);
|
|
792
|
+
if (!this.#outranks(userId))
|
|
793
|
+
return MISSING_PERMISSIONS;
|
|
753
794
|
if ('nick' in changes)
|
|
754
795
|
member.nick = changes.nick ?? null;
|
|
755
796
|
if (Array.isArray(changes.roles))
|
|
@@ -783,6 +824,8 @@ export class World {
|
|
|
783
824
|
return found.refused;
|
|
784
825
|
if (!this.roles.has(roleId))
|
|
785
826
|
return unknown('Role', 10011);
|
|
827
|
+
if (!this.#givable(userId, roleId))
|
|
828
|
+
return MISSING_PERMISSIONS;
|
|
786
829
|
if (!found.member.roles.includes(roleId))
|
|
787
830
|
found.member.roles.push(roleId);
|
|
788
831
|
this.memberUpdated(found.member);
|
|
@@ -794,6 +837,8 @@ export class World {
|
|
|
794
837
|
return found.refused;
|
|
795
838
|
if (!this.roles.has(roleId))
|
|
796
839
|
return unknown('Role', 10011);
|
|
840
|
+
if (!this.#givable(userId, roleId))
|
|
841
|
+
return MISSING_PERMISSIONS;
|
|
797
842
|
found.member.roles = found.member.roles.filter(id => id !== roleId);
|
|
798
843
|
this.memberUpdated(found.member);
|
|
799
844
|
return {};
|
|
@@ -802,6 +847,8 @@ export class World {
|
|
|
802
847
|
const found = memberOr404(guildId, userId);
|
|
803
848
|
if ('refused' in found)
|
|
804
849
|
return found.refused;
|
|
850
|
+
if (!this.#outranks(userId))
|
|
851
|
+
return MISSING_PERMISSIONS;
|
|
805
852
|
this.removeMember(userId);
|
|
806
853
|
return {};
|
|
807
854
|
});
|
|
@@ -828,6 +875,8 @@ export class World {
|
|
|
828
875
|
const user = this.users.get(userId);
|
|
829
876
|
if (!user)
|
|
830
877
|
return unknown('User', 10013);
|
|
878
|
+
if (!this.#outranks(userId))
|
|
879
|
+
return MISSING_PERMISSIONS;
|
|
831
880
|
const reason = request.headers['x-audit-log-reason'];
|
|
832
881
|
this.bans.set(userId, {
|
|
833
882
|
user,
|
|
@@ -863,7 +912,7 @@ export class World {
|
|
|
863
912
|
const failed = [];
|
|
864
913
|
for (const id of ids) {
|
|
865
914
|
const user = this.users.get(id);
|
|
866
|
-
if (!user || this.bans.has(id)) {
|
|
915
|
+
if (!user || this.bans.has(id) || !this.#outranks(id)) {
|
|
867
916
|
failed.push(id);
|
|
868
917
|
continue;
|
|
869
918
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Where a call of the files was made, for an error that comes later. An
|
|
2
|
+
// error made after an `await` only has the lines of the files in its stack
|
|
3
|
+
// when they awaited the call: `member.ban()` written without `await`, and
|
|
4
|
+
// refused by Discord, would point nowhere in the project. `callSite()` is
|
|
5
|
+
// taken when the call is made, before anything is awaited, and
|
|
6
|
+
// `fromSite()` adds its lines to the error, so `locate()` finds the line
|
|
7
|
+
// that asked whether the call was awaited or not.
|
|
8
|
+
/** The stack of the moment a call is made. */
|
|
9
|
+
export function callSite() {
|
|
10
|
+
return new Error('Called here');
|
|
11
|
+
}
|
|
12
|
+
/** Adds where the call was made to an error that came later. */
|
|
13
|
+
export function fromSite(error, site) {
|
|
14
|
+
if (!(error instanceof Error) || typeof error.stack !== 'string') {
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
const frames = (site.stack ?? '').split('\n').slice(1).join('\n');
|
|
18
|
+
if (frames && !error.stack.includes(frames)) {
|
|
19
|
+
error.stack = `${error.stack}\n${frames}`;
|
|
20
|
+
}
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
@@ -69,6 +69,8 @@ export declare class VoiceManager {
|
|
|
69
69
|
secure?: boolean;
|
|
70
70
|
joinTimeout?: number;
|
|
71
71
|
endedGrace?: number;
|
|
72
|
+
/** Where nothing can play: what `join()` says at once. */
|
|
73
|
+
unavailable?: string;
|
|
72
74
|
});
|
|
73
75
|
/** The connection of the bot in a server, if it is in voice there. */
|
|
74
76
|
connectionOf(guildId: Snowflake): VoiceConnection | null;
|
package/dist/voice/connection.js
CHANGED
|
@@ -218,6 +218,8 @@ export class VoiceManager {
|
|
|
218
218
|
#secure;
|
|
219
219
|
#joinTimeout;
|
|
220
220
|
#endedGrace;
|
|
221
|
+
/** Why voice can't be joined here (`chapterjs test`), or `null`. */
|
|
222
|
+
#unavailable;
|
|
221
223
|
constructor(ctx, gateway, options) {
|
|
222
224
|
this.#ctx = ctx;
|
|
223
225
|
this.#gateway = gateway;
|
|
@@ -225,6 +227,7 @@ export class VoiceManager {
|
|
|
225
227
|
this.#secure = options.secure ?? true;
|
|
226
228
|
this.#joinTimeout = options.joinTimeout ?? JOIN_TIMEOUT;
|
|
227
229
|
this.#endedGrace = options.endedGrace ?? 3000;
|
|
230
|
+
this.#unavailable = options.unavailable ?? null;
|
|
228
231
|
}
|
|
229
232
|
/** The connection of the bot in a server, if it is in voice there. */
|
|
230
233
|
connectionOf(guildId) {
|
|
@@ -249,6 +252,8 @@ export class VoiceManager {
|
|
|
249
252
|
* same server. Resolves once audio can be sent.
|
|
250
253
|
*/
|
|
251
254
|
async join(channel, options = {}) {
|
|
255
|
+
if (this.#unavailable !== null)
|
|
256
|
+
throw new Error(this.#unavailable);
|
|
252
257
|
const guild = channel.guild;
|
|
253
258
|
const me = guild.me;
|
|
254
259
|
const permissions = me.permissionsIn(channel);
|
|
@@ -273,7 +278,16 @@ export class VoiceManager {
|
|
|
273
278
|
? existing
|
|
274
279
|
: new VoiceConnection(this, guild.id, channel.id);
|
|
275
280
|
this.#connections.set(guild.id, connection);
|
|
276
|
-
|
|
281
|
+
try {
|
|
282
|
+
await this.#request(guild.id, channel, options);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// Made again here: the reason comes from a timer or from leave(),
|
|
286
|
+
// whose lines are not the one of the file that asked to join.
|
|
287
|
+
throw error instanceof Error
|
|
288
|
+
? new Error(error.message, { cause: error })
|
|
289
|
+
: error;
|
|
290
|
+
}
|
|
277
291
|
if (channel.isStage)
|
|
278
292
|
await this.#speakOnStage(guild.id, channel);
|
|
279
293
|
return connection;
|
package/dist/voice/usage.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import type { CodeUse } from '../loader/usage.js';
|
|
2
|
+
/** Joining voice, as `loader/usage.ts` looks for it. */
|
|
3
|
+
export declare const VOICE_USE: CodeUse;
|
|
1
4
|
/** Whether some code joins a voice channel. */
|
|
2
5
|
export declare function joinsVoice(code: string): boolean;
|
|
3
|
-
/** Whether a file of `src/` joins a voice channel. */
|
|
4
|
-
export declare function sourcesJoinVoice(cwd: string): Promise<boolean>;
|
package/dist/voice/usage.js
CHANGED
|
@@ -2,32 +2,20 @@
|
|
|
2
2
|
// a bot its own voice state with the GUILD_VOICE_STATES intent, without
|
|
3
3
|
// which `join()` can't finish: like every intent, it is asked for when the
|
|
4
4
|
// project needs it, and only then.
|
|
5
|
-
import {
|
|
6
|
-
import { join } from 'node:path';
|
|
5
|
+
import { GatewayIntent } from '../discord/intents.js';
|
|
7
6
|
/**
|
|
8
7
|
* A call to `join()` without an argument or with options: what joining a
|
|
9
8
|
* voice channel looks like. The `join(', ')` of a list does not match; a
|
|
10
9
|
* `join()` of a list does, and only costs an intent.
|
|
11
10
|
*/
|
|
12
11
|
const JOINS_VOICE = /\.join\(\s*(?:\)|\{)/;
|
|
13
|
-
|
|
12
|
+
/** Joining voice, as `loader/usage.ts` looks for it. */
|
|
13
|
+
export const VOICE_USE = {
|
|
14
|
+
name: 'voice',
|
|
15
|
+
pattern: JOINS_VOICE,
|
|
16
|
+
intents: GatewayIntent.GuildVoiceStates,
|
|
17
|
+
};
|
|
14
18
|
/** Whether some code joins a voice channel. */
|
|
15
19
|
export function joinsVoice(code) {
|
|
16
20
|
return JOINS_VOICE.test(code);
|
|
17
21
|
}
|
|
18
|
-
/** Whether a file of `src/` joins a voice channel. */
|
|
19
|
-
export async function sourcesJoinVoice(cwd) {
|
|
20
|
-
const src = join(cwd, 'src');
|
|
21
|
-
const entries = await readdir(src, {
|
|
22
|
-
recursive: true,
|
|
23
|
-
withFileTypes: true,
|
|
24
|
-
}).catch(() => []);
|
|
25
|
-
for (const entry of entries) {
|
|
26
|
-
if (!entry.isFile() || !SOURCE.test(entry.name))
|
|
27
|
-
continue;
|
|
28
|
-
const code = await readFile(join(entry.parentPath, entry.name), 'utf8');
|
|
29
|
-
if (joinsVoice(code))
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CodeUse } from '../loader/usage.js';
|
|
2
|
+
/** `waitForMessage(` or `collectMessages(`: the messages of a channel. */
|
|
3
|
+
export declare const MESSAGES_USE: CodeUse;
|
|
4
|
+
/** `waitForReaction(`: the reactions to a message. */
|
|
5
|
+
export declare const REACTIONS_USE: CodeUse;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// What waiting needs from Discord, seen in the code: messages only reach
|
|
2
|
+
// the bot with the intents of messages (and their text with Message
|
|
3
|
+
// Content), reactions with the intents of reactions. Clicks need none.
|
|
4
|
+
// https://docs.discord.com/developers/events/gateway#gateway-intents
|
|
5
|
+
import { GatewayIntent } from '../discord/intents.js';
|
|
6
|
+
/** `waitForMessage(` or `collectMessages(`: the messages of a channel. */
|
|
7
|
+
export const MESSAGES_USE = {
|
|
8
|
+
name: 'messages',
|
|
9
|
+
pattern: /\.(?:waitForMessage|collectMessages)\s*\(/,
|
|
10
|
+
intents: GatewayIntent.GuildMessages |
|
|
11
|
+
GatewayIntent.DirectMessages |
|
|
12
|
+
GatewayIntent.MessageContent,
|
|
13
|
+
};
|
|
14
|
+
/** `waitForReaction(`: the reactions to a message. */
|
|
15
|
+
export const REACTIONS_USE = {
|
|
16
|
+
name: 'reactions',
|
|
17
|
+
pattern: /\.waitForReaction\s*\(/,
|
|
18
|
+
intents: GatewayIntent.GuildMessageReactions | GatewayIntent.DirectMessageReactions,
|
|
19
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** What can be waited for. */
|
|
2
|
+
export type WaitKind = 'message' | 'reaction' | 'click';
|
|
3
|
+
/** One wait, as a structure asks for it. */
|
|
4
|
+
export interface WaitRequest {
|
|
5
|
+
readonly kind: WaitKind;
|
|
6
|
+
/** Whether what is offered is what the wait is for. */
|
|
7
|
+
match(offered: unknown): boolean;
|
|
8
|
+
/** How many to take before the wait ends: 1 to wait for one. */
|
|
9
|
+
readonly max: number;
|
|
10
|
+
/** How long it waits, in milliseconds. */
|
|
11
|
+
readonly within: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Told when a run of the files starts and stops waiting, for `chapterjs
|
|
15
|
+
* test`: a run that waits is not one to wait for.
|
|
16
|
+
*/
|
|
17
|
+
export interface WaitWatcher {
|
|
18
|
+
parked(run: object): void;
|
|
19
|
+
resumed(run: object): void;
|
|
20
|
+
}
|
|
21
|
+
/** The waits of a bot. */
|
|
22
|
+
export interface Waiters {
|
|
23
|
+
/**
|
|
24
|
+
* Waits for what fits the request, until `max` were taken or the time is
|
|
25
|
+
* up: resolves with what was taken, in order (maybe nothing).
|
|
26
|
+
*/
|
|
27
|
+
wait<T>(request: WaitRequest): Promise<T[]>;
|
|
28
|
+
/** Whether a wait is waiting for this kind: nothing is built otherwise. */
|
|
29
|
+
wants(kind: WaitKind): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Gives what just happened to the waits that want it. A click goes to
|
|
32
|
+
* one wait only, and nothing else runs it: returns whether one took it.
|
|
33
|
+
*/
|
|
34
|
+
offer(kind: WaitKind, offered: unknown): boolean;
|
|
35
|
+
/** Ends every wait with what it has: the bot stops. */
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
/** The waits of a bot, in memory: the default. */
|
|
39
|
+
export declare class MemoryWaiters implements Waiters {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(options?: {
|
|
42
|
+
watch?: WaitWatcher;
|
|
43
|
+
});
|
|
44
|
+
wait<T>(request: WaitRequest): Promise<T[]>;
|
|
45
|
+
wants(kind: WaitKind): boolean;
|
|
46
|
+
offer(kind: WaitKind, offered: unknown): boolean;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Waiting for what comes next: the next message of a channel, a click on
|
|
2
|
+
// the buttons of a message, a reaction. A function of the files asks
|
|
3
|
+
// (`channel.waitForMessage()`, `message.waitForClick()`...), the bot offers
|
|
4
|
+
// what Discord sends, and the first thing that fits ends the wait, or the
|
|
5
|
+
// time does. One mechanism for every kind: a new kind of wait is a new
|
|
6
|
+
// `WaitKind` and the place that offers it.
|
|
7
|
+
//
|
|
8
|
+
// Waits live in the process, behind the `Waiters` interface: everything of
|
|
9
|
+
// one server reaches one process (its shard), so a wait always hears what
|
|
10
|
+
// it waits for. A restart ends them: nothing is kept.
|
|
11
|
+
import { currentRun } from '../store/scope.js';
|
|
12
|
+
/** The kinds a single wait takes for itself: nothing else runs them. */
|
|
13
|
+
const EXCLUSIVE = new Set(['click']);
|
|
14
|
+
/** The waits of a bot, in memory: the default. */
|
|
15
|
+
export class MemoryWaiters {
|
|
16
|
+
#pending = new Map();
|
|
17
|
+
/** How many waits each run has going. */
|
|
18
|
+
#runs = new Map();
|
|
19
|
+
#watch;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.#watch = options.watch;
|
|
22
|
+
}
|
|
23
|
+
wait(request) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const run = currentRun();
|
|
26
|
+
const taken = [];
|
|
27
|
+
const list = this.#pending.get(request.kind) ?? new Set();
|
|
28
|
+
this.#pending.set(request.kind, list);
|
|
29
|
+
const pending = {
|
|
30
|
+
request,
|
|
31
|
+
taken,
|
|
32
|
+
run,
|
|
33
|
+
// Nothing of the bot waits for it: a wait never keeps it running.
|
|
34
|
+
timer: setTimeout(() => pending.end(), request.within).unref(),
|
|
35
|
+
end: error => {
|
|
36
|
+
if (!list.delete(pending))
|
|
37
|
+
return;
|
|
38
|
+
clearTimeout(pending.timer);
|
|
39
|
+
if (list.size === 0)
|
|
40
|
+
this.#pending.delete(request.kind);
|
|
41
|
+
this.#leave(run);
|
|
42
|
+
if (error === undefined)
|
|
43
|
+
resolve(taken);
|
|
44
|
+
else
|
|
45
|
+
reject(error);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
list.add(pending);
|
|
49
|
+
this.#enter(run);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
wants(kind) {
|
|
53
|
+
return (this.#pending.get(kind)?.size ?? 0) > 0;
|
|
54
|
+
}
|
|
55
|
+
offer(kind, offered) {
|
|
56
|
+
const list = this.#pending.get(kind);
|
|
57
|
+
if (!list)
|
|
58
|
+
return false;
|
|
59
|
+
let took = false;
|
|
60
|
+
for (const pending of [...list]) {
|
|
61
|
+
let fits;
|
|
62
|
+
try {
|
|
63
|
+
fits = pending.request.match(offered);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// A test of the files that throws ends its wait with the error,
|
|
67
|
+
// thrown where the files wait: reported with their line.
|
|
68
|
+
pending.end(error ?? new Error('The test of the wait threw.'));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!fits)
|
|
72
|
+
continue;
|
|
73
|
+
took = true;
|
|
74
|
+
pending.taken.push(offered);
|
|
75
|
+
if (pending.taken.length >= pending.request.max)
|
|
76
|
+
pending.end();
|
|
77
|
+
if (EXCLUSIVE.has(kind))
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
return took;
|
|
81
|
+
}
|
|
82
|
+
close() {
|
|
83
|
+
for (const list of [...this.#pending.values()]) {
|
|
84
|
+
for (const pending of [...list])
|
|
85
|
+
pending.end();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
#enter(run) {
|
|
89
|
+
if (!run)
|
|
90
|
+
return;
|
|
91
|
+
const count = this.#runs.get(run) ?? 0;
|
|
92
|
+
this.#runs.set(run, count + 1);
|
|
93
|
+
if (count === 0)
|
|
94
|
+
this.#watch?.parked(run);
|
|
95
|
+
}
|
|
96
|
+
#leave(run) {
|
|
97
|
+
if (!run)
|
|
98
|
+
return;
|
|
99
|
+
const count = (this.#runs.get(run) ?? 1) - 1;
|
|
100
|
+
if (count > 0) {
|
|
101
|
+
this.#runs.set(run, count);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.#runs.delete(run);
|
|
105
|
+
this.#watch?.resumed(run);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/package.json
CHANGED