chapterjs 0.3.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 (115) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +17 -4
  5. package/dist/cli/dev.js +37 -6
  6. package/dist/cli/env.d.ts +5 -0
  7. package/dist/cli/env.js +39 -3
  8. package/dist/cli/project.d.ts +61 -9
  9. package/dist/cli/project.js +250 -44
  10. package/dist/cli/redis.d.ts +23 -0
  11. package/dist/cli/redis.js +69 -0
  12. package/dist/cli/start.js +54 -18
  13. package/dist/cli/test.js +112 -18
  14. package/dist/commands/command.d.ts +140 -5
  15. package/dist/commands/command.js +17 -2
  16. package/dist/commands/declaration.d.ts +17 -4
  17. package/dist/commands/declaration.js +128 -2
  18. package/dist/commands/router.js +78 -18
  19. package/dist/commands/tree.d.ts +25 -4
  20. package/dist/commands/tree.js +95 -26
  21. package/dist/commands/twins.d.ts +8 -0
  22. package/dist/commands/twins.js +24 -6
  23. package/dist/components/button.d.ts +8 -3
  24. package/dist/components/declaration.d.ts +2 -2
  25. package/dist/components/declaration.js +16 -12
  26. package/dist/components/render.js +5 -0
  27. package/dist/components/router.js +41 -0
  28. package/dist/components/select.d.ts +12 -5
  29. package/dist/core/bot.d.ts +7 -0
  30. package/dist/core/bot.js +44 -0
  31. package/dist/discord/api.d.ts +2 -0
  32. package/dist/discord/api.js +2 -0
  33. package/dist/discord/permissions.d.ts +4 -4
  34. package/dist/discord/permissions.js +13 -0
  35. package/dist/events/registry.d.ts +265 -243
  36. package/dist/events/registry.js +131 -100
  37. package/dist/index.d.ts +7 -4
  38. package/dist/index.js +2 -1
  39. package/dist/interactions/dispatch.js +4 -2
  40. package/dist/loader/loader.d.ts +16 -0
  41. package/dist/loader/loader.js +52 -7
  42. package/dist/loader/usage.d.ts +15 -0
  43. package/dist/loader/usage.js +40 -0
  44. package/dist/loader/watch.d.ts +5 -3
  45. package/dist/loader/watch.js +88 -17
  46. package/dist/messages/commands.js +3 -0
  47. package/dist/messages/messages.d.ts +1 -0
  48. package/dist/messages/translate.js +5 -1
  49. package/dist/presence/declaration.d.ts +49 -2
  50. package/dist/presence/declaration.js +122 -39
  51. package/dist/presence/presence.d.ts +65 -6
  52. package/dist/presence/presence.js +11 -0
  53. package/dist/presence/rotation.d.ts +32 -0
  54. package/dist/presence/rotation.js +167 -0
  55. package/dist/ranking/declaration.d.ts +12 -0
  56. package/dist/ranking/declaration.js +48 -0
  57. package/dist/ranking/ranking.d.ts +102 -0
  58. package/dist/ranking/ranking.js +129 -0
  59. package/dist/redis/client.d.ts +36 -0
  60. package/dist/redis/client.js +252 -0
  61. package/dist/redis/protocol.d.ts +22 -0
  62. package/dist/redis/protocol.js +99 -0
  63. package/dist/redis/url.d.ts +24 -0
  64. package/dist/redis/url.js +50 -0
  65. package/dist/rest/errors.d.ts +0 -1
  66. package/dist/rest/errors.js +15 -1
  67. package/dist/rest/rest.js +10 -1
  68. package/dist/store/backend.d.ts +49 -4
  69. package/dist/store/backend.js +66 -11
  70. package/dist/store/declaration.d.ts +3 -0
  71. package/dist/store/declaration.js +8 -3
  72. package/dist/store/file-backend.d.ts +3 -2
  73. package/dist/store/file-backend.js +47 -1
  74. package/dist/store/ipc-backend.d.ts +24 -1
  75. package/dist/store/ipc-backend.js +49 -0
  76. package/dist/store/memory-backend.d.ts +4 -2
  77. package/dist/store/memory-backend.js +27 -1
  78. package/dist/store/ranking-backend.d.ts +45 -0
  79. package/dist/store/ranking-backend.js +82 -0
  80. package/dist/store/redis-backend.d.ts +38 -0
  81. package/dist/store/redis-backend.js +245 -0
  82. package/dist/store/redis-ranking.d.ts +15 -0
  83. package/dist/store/redis-ranking.js +69 -0
  84. package/dist/store/scope.d.ts +17 -1
  85. package/dist/store/scope.js +32 -3
  86. package/dist/store/store.d.ts +39 -5
  87. package/dist/store/store.js +79 -32
  88. package/dist/structures/channel.d.ts +13 -0
  89. package/dist/structures/channel.js +24 -0
  90. package/dist/structures/context.d.ts +3 -0
  91. package/dist/structures/entities.js +1 -0
  92. package/dist/structures/interaction.d.ts +6 -0
  93. package/dist/structures/interaction.js +20 -0
  94. package/dist/structures/invite.d.ts +6 -0
  95. package/dist/structures/invite.js +11 -0
  96. package/dist/structures/message.d.ts +17 -0
  97. package/dist/structures/message.js +33 -0
  98. package/dist/structures/wait.d.ts +105 -0
  99. package/dist/structures/wait.js +85 -0
  100. package/dist/testing/harness.d.ts +48 -8
  101. package/dist/testing/harness.js +156 -36
  102. package/dist/testing/index.d.ts +1 -1
  103. package/dist/testing/smoke.js +112 -1
  104. package/dist/testing/world.js +51 -2
  105. package/dist/util/call-site.d.ts +4 -0
  106. package/dist/util/call-site.js +22 -0
  107. package/dist/voice/connection.d.ts +2 -0
  108. package/dist/voice/connection.js +15 -1
  109. package/dist/voice/usage.d.ts +3 -2
  110. package/dist/voice/usage.js +7 -19
  111. package/dist/waiting/usage.d.ts +5 -0
  112. package/dist/waiting/usage.js +19 -0
  113. package/dist/waiting/waiters.d.ts +48 -0
  114. package/dist/waiting/waiters.js +107 -0
  115. package/package.json +1 -1
@@ -10,12 +10,17 @@ import { ComponentType, } from '../discord/types/component.js';
10
10
  import { InteractionType, } from '../discord/types/interaction.js';
11
11
  import { MessageFlags, MessageType, } from '../discord/types/message.js';
12
12
  import { PermissionFlags } from '../discord/types/permissions.js';
13
- import { commandName } from '../commands/tree.js';
13
+ import { capitalize, commandName, describeCommand, } from '../commands/tree.js';
14
14
  import { componentStateOf } from '../components/declared.js';
15
15
  import { isPiece, renderedOf, resolve, } from '../components/instance.js';
16
16
  import { translatorOf } from '../messages/translate.js';
17
17
  import { toCamelCase } from '../util/case.js';
18
18
  import { expectThat, shown, TestFailure } from './failure.js';
19
+ /** The type Discord gives each kind of menu command. */
20
+ const MENU_COMMAND_TYPES = {
21
+ user: ApplicationCommandType.User,
22
+ message: ApplicationCommandType.Message,
23
+ };
19
24
  /** A `Log` that keeps what is written, for the runner to show per check. */
20
25
  export function captureLog() {
21
26
  let current = { errors: [], warnings: [], infos: [] };
@@ -37,10 +42,27 @@ export function captureLog() {
37
42
  /** Watches what the bot does with the files, to wait for it. */
38
43
  export class Observer {
39
44
  #pending = new Set();
45
+ /**
46
+ * The runs of the files waiting for what comes next: each keeps one
47
+ * promise pending that only the test can end (`bot.say()`...).
48
+ */
49
+ #parked = new Set();
50
+ /** Resolves the next time a run starts or stops waiting. */
51
+ #parkChanged = () => { };
40
52
  #interactions = new Map();
41
53
  #interactionWaiters = new Map();
42
54
  #eventWaiters = [];
43
55
  observe = {
56
+ waits: {
57
+ parked: run => {
58
+ this.#parked.add(run);
59
+ this.#parkChanged();
60
+ },
61
+ resumed: run => {
62
+ this.#parked.delete(run);
63
+ this.#parkChanged();
64
+ },
65
+ },
44
66
  interaction: (raw, done) => {
45
67
  const settled = done.then(() => { }, () => { });
46
68
  this.#track(settled);
@@ -67,34 +89,47 @@ export class Observer {
67
89
  this.#pending.add(settled);
68
90
  settled.finally(() => this.#pending.delete(settled));
69
91
  }
70
- /** Forgets the interactions seen so far: a new world starts. */
92
+ /**
93
+ * Forgets everything of the world before: a new world starts. What the
94
+ * files of the last one still run (a case given up on) is never waited
95
+ * for again.
96
+ */
71
97
  reset() {
72
98
  this.#interactions.clear();
99
+ this.#pending.clear();
100
+ this.#parked.clear();
101
+ this.#interactionWaiters.clear();
102
+ this.#eventWaiters.length = 0;
73
103
  }
74
- /** Resolves once the files are done with the interaction `id`. */
104
+ /**
105
+ * Resolves once the files were given the interaction `id`: `settle()`
106
+ * then waits for them to be done with it.
107
+ */
75
108
  interaction(id, timeout) {
76
- const known = this.#interactions.get(id);
77
- if (known)
78
- return known;
109
+ if (this.#interactions.has(id))
110
+ return Promise.resolve();
79
111
  return new Promise((resolve, reject) => {
80
112
  const timer = setTimeout(() => {
81
113
  this.#interactionWaiters.delete(id);
82
114
  reject(new TestFailure(`The bot did not receive the interaction within ${timeout / 1000} s.`));
83
115
  }, timeout);
84
- this.#interactionWaiters.set(id, done => {
116
+ this.#interactionWaiters.set(id, () => {
85
117
  clearTimeout(timer);
86
- resolve(done);
118
+ resolve();
87
119
  });
88
120
  });
89
121
  }
90
- /** Resolves once the files are done with the next event that matches. */
122
+ /**
123
+ * Resolves once the files were given the next event that matches:
124
+ * `settle()` then waits for them to be done with it.
125
+ */
91
126
  event(match, timeout) {
92
127
  return new Promise((resolve, reject) => {
93
128
  const waiter = {
94
129
  match,
95
- resolve: (done) => {
130
+ resolve: () => {
96
131
  clearTimeout(timer);
97
- resolve(done);
132
+ resolve();
98
133
  },
99
134
  };
100
135
  const timer = setTimeout(() => {
@@ -106,13 +141,22 @@ export class Observer {
106
141
  this.#eventWaiters.push(waiter);
107
142
  });
108
143
  }
109
- /** Resolves once nothing the files started is still running. */
144
+ /**
145
+ * Resolves once nothing the files started is still running, but what
146
+ * waits for the test to act (a run waiting for a message, a click...).
147
+ */
110
148
  async settle() {
111
- while (this.#pending.size > 0) {
112
- await Promise.all([...this.#pending]);
113
- }
114
- // Answers sent at the very end have reached the world by now.
115
- await new Promise(resolve => setImmediate(resolve));
149
+ do {
150
+ while (this.#pending.size > this.#parked.size) {
151
+ const changed = new Promise(resolve => (this.#parkChanged = resolve));
152
+ // Looked at again as soon as one is done: the one left may be a
153
+ // run that waits, which only the test ends.
154
+ await Promise.race([...this.#pending, changed]);
155
+ }
156
+ // Answers sent at the very end have reached the world by now, and a
157
+ // wait given what just happened has started running again.
158
+ await new Promise(resolve => setImmediate(resolve));
159
+ } while (this.#pending.size > this.#parked.size);
116
160
  }
117
161
  }
118
162
  // ── The handles ──────────────────────────────────────────────────────
@@ -170,13 +214,24 @@ function collectComponents(components) {
170
214
  walk(components);
171
215
  return { buttons, menus };
172
216
  }
173
- const buttonHandle = (raw) => Object.freeze({
217
+ /**
218
+ * The message a button or a menu was found on, kept out of sight: clicking
219
+ * it clicks that message, as a person would.
220
+ */
221
+ const FOUND_ON = Symbol('found on');
222
+ /** The id of the message a handle was found on, if it was found on one. */
223
+ const foundOn = (target) => typeof target === 'object' && target !== null
224
+ ? target[FOUND_ON]
225
+ : undefined;
226
+ /** Keeps the message a handle was found on, without showing it. */
227
+ const onMessage = (handle, messageId) => Object.freeze(Object.defineProperty(handle, FOUND_ON, { value: messageId }));
228
+ const buttonHandle = (raw, messageId) => onMessage({
174
229
  label: raw.label ?? null,
175
230
  style: BUTTON_STYLES[raw.style] ?? 'secondary',
176
231
  disabled: raw.disabled === true,
177
232
  customId: raw.custom_id ?? null,
178
- });
179
- const menuHandle = (raw) => Object.freeze({
233
+ }, messageId);
234
+ const menuHandle = (raw, messageId) => onMessage({
180
235
  type: MENU_TYPES[raw.type] ?? 'string',
181
236
  placeholder: raw.placeholder ?? null,
182
237
  disabled: raw.disabled === true,
@@ -187,7 +242,7 @@ const menuHandle = (raw) => Object.freeze({
187
242
  }))
188
243
  : [],
189
244
  customId: raw.custom_id,
190
- });
245
+ }, messageId);
191
246
  /** Checks a message or an answer; written once for both. */
192
247
  function check(self, what, get) {
193
248
  const present = () => {
@@ -442,8 +497,8 @@ export class Session {
442
497
  return {
443
498
  content: message.content,
444
499
  embeds: toCamelCase(message.embeds),
445
- buttons: buttons.map(buttonHandle),
446
- menus: menus.map(menuHandle),
500
+ buttons: buttons.map(button => buttonHandle(button, raw.id)),
501
+ menus: menus.map(menu => menuHandle(menu, raw.id)),
447
502
  ephemeral: ((message.flags ?? 0) & MessageFlags.Ephemeral) !== 0,
448
503
  present: true,
449
504
  };
@@ -563,9 +618,14 @@ export class Session {
563
618
  opensForm(form) {
564
619
  expectThat(record.modal !== null, `The bot should have opened a form, it ${record.original ? `answered ${shown(record.original.content)}` : 'opened none'}.`);
565
620
  if (form) {
566
- const expected = 'customId' in form
567
- ? form.customId
568
- : self.#customIdOf(form, 'modal');
621
+ // Only the name is compared: the form itself names it, with no
622
+ // need for the data it was opened with.
623
+ const state = componentStateOf(form);
624
+ const expected = state?.kind === 'modal' && state.bound
625
+ ? state.bound.name
626
+ : typeof form === 'object' && 'customId' in form
627
+ ? form.customId
628
+ : self.#customIdOf(form, 'modal');
569
629
  const opened = record.modal.custom_id.split(':')[0];
570
630
  expectThat(opened === expected.split(':')[0], `The bot should have opened the form ${expected.split(':')[0]}, it opened ${opened}.`);
571
631
  }
@@ -740,8 +800,11 @@ export class Session {
740
800
  return entry;
741
801
  }
742
802
  async use(command, ...args) {
743
- const [given = {}, where] = args;
744
803
  const entry = this.#commandEntry(command);
804
+ if (entry.command.type !== 'slash') {
805
+ return this.#useMenu(entry, args[0], args[1]);
806
+ }
807
+ const [given = {}, where] = args;
745
808
  const actor = this.#actor(where);
746
809
  const channel = this.#place(where, actor, entry.command.where === 'dm');
747
810
  const { options, resolved } = this.#commandOptions(entry, given, channel);
@@ -773,6 +836,54 @@ export class Session {
773
836
  value: choice.value,
774
837
  }));
775
838
  }
839
+ /**
840
+ * Uses a menu command on a person or a message, as Discord's client
841
+ * does: on a message, in the channel of that message; on a person, where
842
+ * `where` says (`#general`, or the private conversation of a
843
+ * `where: 'dm'` command, by default).
844
+ */
845
+ async #useMenu(entry, target, where) {
846
+ const { type, path, where: place } = entry.command;
847
+ const kind = MENU_COMMAND_TYPES[type];
848
+ const name = describeCommand(entry.command);
849
+ const on = target?.on;
850
+ const actor = this.#actor(where);
851
+ const resolved = {};
852
+ let channel;
853
+ let targetId;
854
+ if (type === 'message') {
855
+ const message = on && !this.#isMember(on) ? this.world.findMessage(on.id) : undefined;
856
+ if (!message) {
857
+ throw new TestFailure(on && !this.#isMember(on)
858
+ ? `The message ${on.id} does not exist any more: ${name} can not be used on it.`
859
+ : `${capitalize(name)} is used on a message: bot.use(command, { on: message }), with a message of the test (await bot.say('...'), or one the bot sent).`);
860
+ }
861
+ const found = this.world.channels.get(message.channel_id);
862
+ if (!found) {
863
+ throw new TestFailure(`The channel of the message ${message.id} does not exist any more.`);
864
+ }
865
+ channel = found;
866
+ targetId = message.id;
867
+ (resolved.messages ??= {})[message.id] = message;
868
+ }
869
+ else {
870
+ if (!this.#isMember(on)) {
871
+ throw new TestFailure(`${capitalize(name)} is used on a person: bot.use(command, { on: bot.member('alice') }).`);
872
+ }
873
+ channel = this.#place(where, actor, place === 'dm');
874
+ targetId = this.#resolveMember(`${capitalize(name)}`, on, resolved, channel);
875
+ }
876
+ const registered = this.world.guildCommands.find(command => command.name === path[0] && command.type === kind);
877
+ const raw = this.#rawInteraction(InteractionType.ApplicationCommand, actor, channel, {
878
+ id: registered?.id ?? this.world.snowflake(),
879
+ name: path[0],
880
+ type: kind,
881
+ target_id: targetId,
882
+ resolved,
883
+ ...(channel.guild_id ? { guild_id: channel.guild_id } : {}),
884
+ });
885
+ return this.#reply(await this.#interact(raw));
886
+ }
776
887
  /** The `data` of a command interaction, subcommands nested as Discord does. */
777
888
  #commandData(entry, options, resolved) {
778
889
  const [name, ...rest] = entry.command.path;
@@ -788,7 +899,9 @@ export class Session {
788
899
  },
789
900
  ];
790
901
  }
791
- const registered = this.world.guildCommands.find(command => command.name === name);
902
+ const registered = this.world.guildCommands.find(command => command.name === name &&
903
+ (command.type ?? ApplicationCommandType.ChatInput) ===
904
+ ApplicationCommandType.ChatInput);
792
905
  return {
793
906
  id: registered?.id ?? this.world.snowflake(),
794
907
  name: name,
@@ -991,8 +1104,13 @@ export class Session {
991
1104
  return [...this.project.components.values()].find(entry => entry.component.kind === kind && entry.component.name === name);
992
1105
  }
993
1106
  /** The message a component is on: the one given, or one of the bot. */
994
- #messageFor(where, actor, channel, component) {
1107
+ #messageFor(where, actor, channel, component, target) {
995
1108
  const given = where?.on;
1109
+ // Found on a message: that message, as long as it is there.
1110
+ const found = given ? undefined : foundOn(target);
1111
+ const sent = found ? this.world.findMessage(found) : undefined;
1112
+ if (sent)
1113
+ return sent;
996
1114
  if (given) {
997
1115
  const id = 'message' in given ? given.message?.id : given.id;
998
1116
  const found = id ? this.world.findMessage(id) : undefined;
@@ -1036,11 +1154,7 @@ export class Session {
1036
1154
  const entry = this.#componentEntry('button', customId);
1037
1155
  const actor = this.#actor(where);
1038
1156
  const channel = this.#place(where, actor, entry?.component.kind === 'button' && entry.component.where === 'dm');
1039
- const message = this.#messageFor(where, actor, channel, {
1040
- type: ComponentType.Button,
1041
- style: 2,
1042
- custom_id: customId,
1043
- });
1157
+ const message = this.#messageFor(where, actor, channel, { type: ComponentType.Button, style: 2, custom_id: customId }, button);
1044
1158
  const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, { custom_id: customId, component_type: ComponentType.Button }, message);
1045
1159
  return this.#reply(await this.#interact(raw));
1046
1160
  }
@@ -1091,8 +1205,10 @@ export class Session {
1091
1205
  const message = this.#messageFor(where, actor, channel, {
1092
1206
  type: componentType,
1093
1207
  custom_id: customId,
1094
- ...(componentType === ComponentType.StringSelect ? { options: [] } : {}),
1095
- });
1208
+ ...(componentType === ComponentType.StringSelect
1209
+ ? { options: [] }
1210
+ : {}),
1211
+ }, menu);
1096
1212
  const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, {
1097
1213
  custom_id: customId,
1098
1214
  component_type: componentType,
@@ -1377,6 +1493,10 @@ export class Session {
1377
1493
  settle() {
1378
1494
  return this.#observer.settle();
1379
1495
  }
1496
+ async endWaits() {
1497
+ this.bot.ctx.waiters?.close();
1498
+ await this.#observer.settle();
1499
+ }
1380
1500
  /**
1381
1501
  * Sends a gateway event to the bot and waits for the files to be done
1382
1502
  * with it. For the checks of `chapterjs test`, not for scenarios.
@@ -1,4 +1,4 @@
1
1
  export { scenario } from './scenario.js';
2
2
  export type { ScenarioDeclaration } from './scenario.js';
3
3
  export { TestFailure } from './failure.js';
4
- export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
4
+ export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMenuTarget, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
@@ -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;