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.
Files changed (84) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +3 -3
  5. package/dist/cli/dev.js +8 -5
  6. package/dist/cli/project.d.ts +31 -2
  7. package/dist/cli/project.js +166 -34
  8. package/dist/cli/redis.js +14 -2
  9. package/dist/cli/start.js +11 -1
  10. package/dist/cli/test.js +108 -16
  11. package/dist/commands/command.d.ts +140 -5
  12. package/dist/commands/command.js +17 -2
  13. package/dist/commands/declaration.d.ts +17 -4
  14. package/dist/commands/declaration.js +128 -2
  15. package/dist/commands/router.js +78 -18
  16. package/dist/commands/tree.d.ts +25 -4
  17. package/dist/commands/tree.js +95 -26
  18. package/dist/commands/twins.d.ts +8 -0
  19. package/dist/commands/twins.js +24 -6
  20. package/dist/components/button.d.ts +8 -3
  21. package/dist/components/declaration.d.ts +2 -2
  22. package/dist/components/declaration.js +16 -12
  23. package/dist/components/render.js +5 -0
  24. package/dist/components/router.js +41 -0
  25. package/dist/components/select.d.ts +12 -5
  26. package/dist/core/bot.d.ts +7 -0
  27. package/dist/core/bot.js +44 -0
  28. package/dist/discord/api.d.ts +2 -0
  29. package/dist/discord/api.js +2 -0
  30. package/dist/discord/permissions.d.ts +4 -4
  31. package/dist/discord/permissions.js +13 -0
  32. package/dist/events/registry.d.ts +19 -10
  33. package/dist/events/registry.js +10 -4
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +1 -1
  36. package/dist/interactions/dispatch.js +4 -2
  37. package/dist/loader/loader.d.ts +16 -0
  38. package/dist/loader/loader.js +52 -7
  39. package/dist/loader/usage.d.ts +15 -0
  40. package/dist/loader/usage.js +40 -0
  41. package/dist/loader/watch.d.ts +5 -3
  42. package/dist/loader/watch.js +88 -17
  43. package/dist/messages/commands.js +3 -0
  44. package/dist/messages/messages.d.ts +1 -0
  45. package/dist/messages/translate.js +5 -1
  46. package/dist/presence/declaration.d.ts +49 -2
  47. package/dist/presence/declaration.js +122 -39
  48. package/dist/presence/presence.d.ts +65 -6
  49. package/dist/presence/presence.js +11 -0
  50. package/dist/presence/rotation.d.ts +32 -0
  51. package/dist/presence/rotation.js +167 -0
  52. package/dist/rest/errors.d.ts +0 -1
  53. package/dist/rest/errors.js +15 -1
  54. package/dist/rest/rest.js +10 -1
  55. package/dist/store/file-backend.d.ts +0 -1
  56. package/dist/store/file-backend.js +18 -1
  57. package/dist/store/scope.d.ts +6 -1
  58. package/dist/store/scope.js +9 -3
  59. package/dist/structures/channel.d.ts +13 -0
  60. package/dist/structures/channel.js +24 -0
  61. package/dist/structures/context.d.ts +3 -0
  62. package/dist/structures/entities.js +1 -0
  63. package/dist/structures/interaction.d.ts +6 -0
  64. package/dist/structures/interaction.js +20 -0
  65. package/dist/structures/message.d.ts +17 -0
  66. package/dist/structures/message.js +33 -0
  67. package/dist/structures/wait.d.ts +105 -0
  68. package/dist/structures/wait.js +85 -0
  69. package/dist/testing/harness.d.ts +48 -8
  70. package/dist/testing/harness.js +156 -36
  71. package/dist/testing/index.d.ts +1 -1
  72. package/dist/testing/smoke.js +112 -1
  73. package/dist/testing/world.js +51 -2
  74. package/dist/util/call-site.d.ts +4 -0
  75. package/dist/util/call-site.js +22 -0
  76. package/dist/voice/connection.d.ts +2 -0
  77. package/dist/voice/connection.js +15 -1
  78. package/dist/voice/usage.d.ts +3 -2
  79. package/dist/voice/usage.js +7 -19
  80. package/dist/waiting/usage.d.ts +5 -0
  81. package/dist/waiting/usage.js +19 -0
  82. package/dist/waiting/waiters.d.ts +48 -0
  83. package/dist/waiting/waiters.js +107 -0
  84. package/package.json +1 -1
@@ -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
  };
@@ -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 ?? this.roles.size,
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,4 @@
1
+ /** The stack of the moment a call is made. */
2
+ export declare function callSite(): Error;
3
+ /** Adds where the call was made to an error that came later. */
4
+ export declare function fromSite(error: unknown, site: Error): unknown;
@@ -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;
@@ -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
- await this.#request(guild.id, channel, options);
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;
@@ -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>;
@@ -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 { readdir, readFile } from 'node:fs/promises';
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
- const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterjs",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Discord framework: ultra simple, but ultra customizable. Your files become a bot, typed end to end, with nothing to register.",
5
5
  "keywords": [
6
6
  "discord",