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
package/README.md CHANGED
@@ -34,10 +34,12 @@ The framework loads every file of `src/`, wherever it is and whatever it is call
34
34
 
35
35
  ## What it does
36
36
 
37
- - **Slash commands** with typed options, choices, autocomplete, permissions, cooldowns and translations.
37
+ - **Slash commands** with typed options, choices, autocomplete, permissions, cooldowns and translations, and **menu commands** on the right click of a message or a person.
38
38
  - **Events**: the intents follow from the files you write, privileged ones included, with a direct link when one must be enabled.
39
39
  - **Components**: buttons, select menus, forms and embeds, with typed `data` carried in the message and no id to write.
40
+ - **Waiting for an answer**: `await channel.waitForMessage({ matches: 'paris', within: '30s' })`, a click or a reaction, right where your function asked.
40
41
  - **Tasks**: every so often or with a cron, in the timezone you choose.
42
+ - **Presence**: one activity, or several in turn, with texts computed when shown.
41
43
  - **Storage**: `store<Warning[]>({ expires: '30d' })` keeps data from one restart to the next, per server, typed, in a JSON file written for you, or in Redis with `storage: 'redis'` and a `REDIS_URL`; `ranking()` keeps a score per member in order, for leaderboards.
42
44
  - **Translated messages**: `t('welcome', { name })` speaks the language of who will read it.
43
45
  - **Voice**: join a channel and play a file, an attachment or a link, end-to-end encrypted (DAVE).
@@ -4,7 +4,7 @@
4
4
  import { existsSync, watch } from 'node:fs';
5
5
  import { readdir } from 'node:fs/promises';
6
6
  import { join, relative, sep } from 'node:path';
7
- import { watchFolder } from '../loader/watch.js';
7
+ import { snapshotFolder, watchFolder } from '../loader/watch.js';
8
8
  /** The folder, at the root of the project. */
9
9
  export const PUBLIC_FOLDER = 'public';
10
10
  /**
@@ -69,9 +69,9 @@ export function watchPublic(projectDir, onChange) {
69
69
  const appeared = () => {
70
70
  if (inner)
71
71
  return;
72
- // The folder was not there: what it has by the time it is watched
73
- // (files copied with it, or written right after) is all new.
74
- watchInside(Promise.resolve(''));
72
+ // The folder was not there: what it has now (files copied with it) is
73
+ // told at once, and what is written after it by the watch.
74
+ watchInside(snapshotFolder(root));
75
75
  if (!inner)
76
76
  return;
77
77
  outer?.close();
@@ -1,4 +1,5 @@
1
1
  import { type BuiltFile } from '../loader/loader.js';
2
+ import { type FoundUses } from '../loader/usage.js';
2
3
  import type { Log } from './log.js';
3
4
  /** Where the build of a project is, from its folder. */
4
5
  export declare const buildDir: (cwd: string) => string;
@@ -12,8 +13,11 @@ export interface BuildInfo {
12
13
  sources: string;
13
14
  commands: number;
14
15
  events: number;
15
- /** Whether the files join voice channels (an intent to ask for). */
16
- voice: boolean;
16
+ /**
17
+ * What the files do that only their code shows, with the files doing it
18
+ * (`CODE_USES`: intents to ask for).
19
+ */
20
+ uses: FoundUses;
17
21
  }
18
22
  /** One value for everything in a folder: it changes when a file does. */
19
23
  export declare function fingerprint(dir: string): Promise<string>;
package/dist/cli/build.js CHANGED
@@ -14,9 +14,9 @@ import { join, relative, sep } from 'node:path';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import { enableProjectLoader } from '../loader/hot.js';
16
16
  import { listSources } from '../loader/loader.js';
17
- import { sourcesJoinVoice } from '../voice/usage.js';
17
+ import { scanUses } from '../loader/usage.js';
18
18
  import { MemoryStoreBackend } from '../store/memory-backend.js';
19
- import { createProject, hasSources, writeTypes } from './project.js';
19
+ import { CODE_USES, createProject, hasSources, writeTypes } from './project.js';
20
20
  /** Where the build of a project is, from its folder. */
21
21
  export const buildDir = (cwd) => join(cwd, '.chapterjs', 'build');
22
22
  /** The file the whole bot is compiled into, in the build. */
@@ -206,7 +206,7 @@ export async function build(options) {
206
206
  sources: await fingerprint(src),
207
207
  commands: built.commands.size,
208
208
  events: built.events.size,
209
- voice: await sourcesJoinVoice(cwd),
209
+ uses: await scanUses(cwd, CODE_USES),
210
210
  };
211
211
  await writeFile(join(out, 'build.json'), JSON.stringify(info, null, 2));
212
212
  if (built.isEmpty())
package/dist/cli/dev.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // server, and reloads every file of the project when it is saved.
3
3
  import { join } from 'node:path';
4
4
  import { registerCommands } from '../commands/register.js';
5
- import { buildCommands, commandName, privateOnly } from '../commands/tree.js';
5
+ import { buildCommands, capitalize, describeCommand, privateOnly, } from '../commands/tree.js';
6
6
  import { withoutGlobalTwins } from '../commands/twins.js';
7
7
  import { configure } from '../core/memory.js';
8
8
  import { GetGlobalApplicationCommands } from '../discord/endpoints.js';
@@ -150,7 +150,7 @@ export async function dev(options) {
150
150
  if (saidTwice.has(name))
151
151
  continue;
152
152
  saidTwice.add(name);
153
- log.info(`/${name} is not the same here as in production, so ${guild.name} shows it twice: yours, and the one everyone has. It is shown once again when production runs your version.`);
153
+ log.info(`${capitalize(name)} is not the same here as in production, so ${guild.name} shows it twice: yours, and the one everyone has. It is shown once again when production runs your version.`);
154
154
  }
155
155
  try {
156
156
  return await registerCommands({
@@ -176,6 +176,7 @@ export async function dev(options) {
176
176
  project.ready(bot);
177
177
  // Tasks follow the bot through reconnections: they read the current one.
178
178
  project.startTasks(() => bot);
179
+ project.startPresence(() => bot);
179
180
  // Said once per file: what only happens in private messages can't be
180
181
  // tried here. Discord only offers the commands of a server in that
181
182
  // server, and private messages are answered by the bot in production.
@@ -188,7 +189,7 @@ export async function dev(options) {
188
189
  log.info(`${file} ${what} only works in private messages, and chapterjs dev only runs your bot in ${guild.name}. Try it with chapterjs start.`);
189
190
  };
190
191
  for (const entry of privateOnly([...project.commands.values()])) {
191
- note(siteName(entry), commandName(entry.command.path));
192
+ note(siteName(entry), describeCommand(entry.command));
192
193
  }
193
194
  for (const entry of project.events.values()) {
194
195
  const { event } = entry;
@@ -251,7 +252,7 @@ export async function dev(options) {
251
252
  bot = await connect();
252
253
  log.success(`Reconnected, ${project.summary()}`);
253
254
  if (await syncCommands())
254
- log.reload('Commands updated on Discord');
255
+ log.reload(`Commands updated on ${guild.name}`);
255
256
  return;
256
257
  }
257
258
  const ms = Math.max(1, Math.round(performance.now() - started));
@@ -262,7 +263,7 @@ export async function dev(options) {
262
263
  log.warn(`Reloaded with ${failures.length === 1 ? 'an error' : `${failures.length} errors`}: ${failures.length === 1 ? 'that file keeps' : 'those files keep'} running ${failures.length === 1 ? 'its' : 'their'} last working version`);
263
264
  }
264
265
  if (updated)
265
- log.reload('Commands updated on Discord');
266
+ log.reload(`Commands updated on ${guild.name}`);
266
267
  })
267
268
  .catch(error => {
268
269
  if (!signal.aborted)
@@ -277,6 +278,7 @@ export async function dev(options) {
277
278
  const code = await ended;
278
279
  await reloading.catch(() => { });
279
280
  project.stopTasks();
281
+ project.stopPresence();
280
282
  watcher.close();
281
283
  assets.close();
282
284
  memory.stop();
@@ -289,6 +291,7 @@ export async function dev(options) {
289
291
  }
290
292
  catch (error) {
291
293
  project.stopTasks();
294
+ project.stopPresence();
292
295
  watcher?.close();
293
296
  assets.close();
294
297
  memory.stop();
@@ -1,3 +1,5 @@
1
+ import { type WaitWatcher } from '../waiting/waiters.js';
2
+ import { type FoundUses } from '../loader/usage.js';
1
3
  import { type CommandEntry } from '../commands/tree.js';
2
4
  import { type ComponentEntry } from '../components/router.js';
3
5
  import { type LoadedStore } from '../store/declaration.js';
@@ -27,10 +29,11 @@ export interface ProjectOptions {
27
29
  */
28
30
  built?: readonly BuiltFile[];
29
31
  /**
30
- * Whether the files join voice channels, as `chapterjs build` found.
32
+ * What the files do that only their code shows (`CODE_USES`: joining
33
+ * voice, waiting for messages or reactions), as `chapterjs build` found.
31
34
  * Without it, the files of `src` are read on every load.
32
35
  */
33
- joinsVoice?: boolean;
36
+ uses?: FoundUses;
34
37
  /**
35
38
  * Where the stores of the project keep their data, by storage. `file`: a
36
39
  * file per store in the `data/` folder of the project by default, the
@@ -53,6 +56,11 @@ export interface ProjectObserver {
53
56
  interaction?(raw: RawInteraction, done: Promise<void>): void;
54
57
  /** A gateway event was given to the files; `done` once they handled it. */
55
58
  event?(event: GatewayDispatchEventName, data: unknown, done: Promise<void>): void;
59
+ /**
60
+ * Told when a run of the files starts and stops waiting for what comes
61
+ * next (`waitForMessage()`...): its `done` waits for the test to act.
62
+ */
63
+ waits?: WaitWatcher;
56
64
  }
57
65
  /** What the types of a project are written from, once its files ran. */
58
66
  export interface TypesInfo {
@@ -106,6 +114,8 @@ export interface ConnectOptions {
106
114
  globalLimit?: number | undefined;
107
115
  /** Only tests change this. */
108
116
  identifyInterval?: number | undefined;
117
+ /** Where nothing can play (`chapterjs test`): what `join()` says at once. */
118
+ voiceUnavailable?: string | undefined;
109
119
  /** Discord refused the bot for good. */
110
120
  onFatal: (bot: Bot) => void;
111
121
  }
@@ -162,6 +172,20 @@ export interface Project {
162
172
  * change since the bot was given one.
163
173
  */
164
174
  applyPresence(bot: Bot): boolean;
175
+ /**
176
+ * Shows the activities of the presence in turn on the bot `current`
177
+ * returns, and runs its functions when their time comes.
178
+ */
179
+ startPresence(current: () => Bot | null): void;
180
+ /** Stops the presence: nothing of it runs after this. */
181
+ stopPresence(): void;
182
+ /** The file of the presence, or `null` without one. */
183
+ readonly presenceFile: string | null;
184
+ /**
185
+ * Runs every function of the presence once (the list, then each text),
186
+ * as the rotation would, with the bot given. What goes wrong is reported.
187
+ */
188
+ runPresence(bot: Bot): Promise<void>;
165
189
  /**
166
190
  * Starts the tasks of the project, with the bot `current` returns (none
167
191
  * while it reconnects). Only one process of a bot runs them.
@@ -181,6 +205,11 @@ export interface Project {
181
205
  /** Writes what the stores still have to write. Nothing is kept after this. */
182
206
  closeStores(): Promise<void>;
183
207
  }
208
+ /**
209
+ * What the files may do that only their code shows, each with the intents
210
+ * it needs: read in `src/` (dev, at every load) or kept by the build.
211
+ */
212
+ export declare const CODE_USES: readonly [import("../loader/usage.js").CodeUse, import("../loader/usage.js").CodeUse, import("../loader/usage.js").CodeUse];
184
213
  /**
185
214
  * A promise of the files that nobody waited for (`store.set()` without
186
215
  * `await`, in a `run` that is not `async`) rejects with nobody to tell:
@@ -7,7 +7,10 @@ import { join } from 'node:path';
7
7
  import { setPublicDir } from '../assets/asset.js';
8
8
  import { listPublic, publicDeclarations } from '../assets/public.js';
9
9
  import { MissingForEvent } from '../events/registry.js';
10
- import { sourcesJoinVoice } from '../voice/usage.js';
10
+ import { VOICE_USE } from '../voice/usage.js';
11
+ import { MESSAGES_USE, REACTIONS_USE } from '../waiting/usage.js';
12
+ import { MemoryWaiters } from '../waiting/waiters.js';
13
+ import { scanUses, usesIntents } from '../loader/usage.js';
11
14
  import { writeGenerated } from '../loader/generated.js';
12
15
  import { commandDeclaration, scanCommands } from '../commands/declaration.js';
13
16
  import { CommandRouter } from '../commands/router.js';
@@ -19,7 +22,9 @@ import { storeDeclaration } from '../store/declaration.js';
19
22
  import { rankingDeclaration, } from '../ranking/declaration.js';
20
23
  import { nowhereToKeep, NowhereToKeepError, setStoreBackend, STORAGES, } from '../store/backend.js';
21
24
  import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
22
- import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
25
+ import { checkText, DEFAULT_PRESENCE, presenceDeclaration, readActivities, } from '../presence/declaration.js';
26
+ import { PresenceRotation } from '../presence/rotation.js';
27
+ import { GetCurrentApplication } from '../discord/endpoints.js';
23
28
  import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
24
29
  import { translation } from '../messages/translate.js';
25
30
  import { translateCommand, unknownCommands } from '../messages/commands.js';
@@ -30,7 +35,7 @@ import { GatewayIntent } from '../discord/intents.js';
30
35
  import { eventDeclaration } from '../events/declaration.js';
31
36
  import { EventRouter, intentsFor, intentsOf, limitsFor, } from '../events/router.js';
32
37
  import { GatewayFatalError, SessionLimitError } from '../gateway/errors.js';
33
- import { loadSources, readDeclarations, siteName, } from '../loader/loader.js';
38
+ import { loadSources, bySite, readDeclarations, siteKey, siteName, } from '../loader/loader.js';
34
39
  import { locate, messageOf } from '../loader/locate.js';
35
40
  import { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from '../rest/errors.js';
36
41
  import { PreflightFailure } from './preflight.js';
@@ -73,6 +78,11 @@ export const DECLARATIONS = [
73
78
  presenceDeclaration,
74
79
  languageDeclaration,
75
80
  ];
81
+ /**
82
+ * What the files may do that only their code shows, each with the intents
83
+ * it needs: read in `src/` (dev, at every load) or kept by the build.
84
+ */
85
+ export const CODE_USES = [VOICE_USE, MESSAGES_USE, REACTIONS_USE];
76
86
  /**
77
87
  * A promise of the files that nobody waited for (`store.set()` without
78
88
  * `await`, in a `run` that is not `async`) rejects with nobody to tell:
@@ -107,23 +117,15 @@ const PRIVATE_INTENTS = GatewayIntent.DirectMessages |
107
117
  GatewayIntent.DirectMessagePolls;
108
118
  const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
109
119
  /** What tells one declaration from another: its file and its export. */
110
- const keyOf = (site) => `${site.file}#${site.export}`;
120
+ const keyOf = siteKey;
111
121
  /**
112
122
  * Two declarations with the same name, where the name is the export's
113
123
  * (a task): the later one is left out, with a message saying so.
114
124
  */
115
- function sameNames(entries, nameOf, what) {
125
+ function sameNames(entries, nameOf, what, running) {
116
126
  const seen = new Map();
117
127
  const conflicts = [];
118
- const valid = [...entries]
119
- .sort((a, b) => a.file === b.file
120
- ? a.export < b.export
121
- ? -1
122
- : 1
123
- : a.file < b.file
124
- ? -1
125
- : 1)
126
- .filter(entry => {
128
+ const valid = bySite(entries, running).filter(entry => {
127
129
  const first = seen.get(nameOf(entry));
128
130
  if (!first) {
129
131
  seen.set(nameOf(entry), entry);
@@ -183,19 +185,78 @@ export function createProject(options) {
183
185
  let stores = new Map();
184
186
  let rankings = new Map();
185
187
  let presence = null;
188
+ /** Where the presence is declared, for what its functions do wrong. */
189
+ let presenceSite = { file: 'the presence', export: 'default' };
186
190
  /** The files the presence was declared in, to keep it when they break. */
187
191
  let presenceFiles = new Set();
188
- // Joining voice needs the bot's own voice state, sent with an intent.
189
- let voice = options.joinsVoice ?? false;
190
- const neededIntents = () => intentsFor(events.values()) | (voice ? GatewayIntent.GuildVoiceStates : 0);
192
+ // Joining voice, waiting for messages or reactions: intents the code
193
+ // needs without any declaration saying it.
194
+ let uses = options.uses ?? {};
195
+ const neededIntents = () => intentsFor(events.values()) | usesIntents(CODE_USES, uses);
191
196
  let messages = null;
192
197
  /** The last version of each language that loaded. */
193
198
  let languages = new Map();
194
199
  let languageDefaults = [];
200
+ /** Whether the files were loaded before: what a reload compares with. */
201
+ let loadedOnce = false;
195
202
  /** The bot running the files now, to give it what a reload changes. */
196
203
  let running = null;
197
204
  /** The presence each bot was given last, to send only what changed. */
198
205
  const given = new WeakMap();
206
+ /** What each bot shows now, to send only what changed. */
207
+ const shown = new WeakMap();
208
+ /** Whether the running bot is only part of the bot (a cluster). */
209
+ let partial = false;
210
+ /** How many servers the whole bot is in, as Discord counted it last. */
211
+ let counted = null;
212
+ let counting = false;
213
+ /**
214
+ * How many servers the bot is in: what this process knows when it runs
215
+ * every shard; else what Discord counts for the application, asked at
216
+ * most every 10 minutes.
217
+ * @see https://docs.discord.com/developers/resources/application#application-object-application-structure
218
+ */
219
+ const guildCount = (bot) => {
220
+ const local = bot.ctx.cache.guilds.size;
221
+ if (!partial)
222
+ return local;
223
+ if (!counting && (!counted || Date.now() - counted.at > 600_000)) {
224
+ counting = true;
225
+ bot.ctx.rest
226
+ .request(GetCurrentApplication, [])
227
+ .then(application => {
228
+ const guilds = application.approximate_guild_count;
229
+ if (guilds !== undefined)
230
+ counted = { guilds, at: Date.now() };
231
+ })
232
+ .catch(() => { })
233
+ .finally(() => (counting = false));
234
+ }
235
+ return counted?.guilds ?? local;
236
+ };
237
+ let presenceBot = () => null;
238
+ const rotation = new PresenceRotation({
239
+ apply(raw) {
240
+ const bot = presenceBot();
241
+ if (!bot)
242
+ return;
243
+ const text = JSON.stringify(raw);
244
+ if (shown.get(bot) === text)
245
+ return;
246
+ shown.set(bot, text);
247
+ bot.setPresence(raw);
248
+ },
249
+ context() {
250
+ const bot = presenceBot();
251
+ if (!bot?.ctx.self)
252
+ return null;
253
+ return {
254
+ guilds: guildCount(bot),
255
+ ...translation(bot.ctx, null),
256
+ };
257
+ },
258
+ onError: error => report({ ...presenceSite, error }),
259
+ });
199
260
  let lost = false;
200
261
  const count = (size, one, many) => `${size} ${size === 1 ? one : many}`;
201
262
  return {
@@ -243,7 +304,10 @@ export function createProject(options) {
243
304
  })),
244
305
  ...(messages ? { defaultLocale: messages.default } : {}),
245
306
  languageDefaults,
246
- commands: [...commands.values()].map(({ command }) => ({
307
+ // Only slash commands have texts in the language files.
308
+ commands: [...commands.values()]
309
+ .filter(({ command }) => command.type === 'slash')
310
+ .map(({ command }) => ({
247
311
  name: command.path.join(' '),
248
312
  described: command.described,
249
313
  })),
@@ -251,8 +315,8 @@ export function createProject(options) {
251
315
  report,
252
316
  async load() {
253
317
  const { built } = options;
254
- if (options.joinsVoice === undefined) {
255
- voice = await sourcesJoinVoice(cwd);
318
+ if (options.uses === undefined) {
319
+ uses = await scanUses(cwd, CODE_USES);
256
320
  }
257
321
  // Every file of src/ runs (or ran, in a build); what each exports is
258
322
  // then read, kind by kind.
@@ -292,13 +356,14 @@ export function createProject(options) {
292
356
  event: item.value,
293
357
  }));
294
358
  router.set(events.values());
359
+ // A name two declarations share stays with the one that ran before.
295
360
  const { valid, conflicts } = findConflicts([
296
361
  ...next(commands, commandItems, item => ({
297
362
  file: item.file,
298
363
  export: item.export,
299
364
  command: item.value,
300
365
  })).values(),
301
- ]);
366
+ ], new Set(commands.keys()));
302
367
  commands = new Map(valid.map(entry => [keyOf(entry), entry]));
303
368
  const unique = findDuplicates([
304
369
  ...next(components, componentItems, item => ({
@@ -306,7 +371,7 @@ export function createProject(options) {
306
371
  export: item.export,
307
372
  component: item.value,
308
373
  })).values(),
309
- ]);
374
+ ], new Set(components.keys()));
310
375
  components = new Map(unique.valid.map(entry => [keyOf(entry), entry]));
311
376
  componentRouter.set(components.values());
312
377
  const sameTask = sameNames([
@@ -315,7 +380,7 @@ export function createProject(options) {
315
380
  export: item.export,
316
381
  task: item.value,
317
382
  })).values(),
318
- ], entry => entry.task.name, 'task');
383
+ ], entry => entry.task.name, 'task', new Set(tasks.keys()));
319
384
  tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
320
385
  scheduler.set(tasks.values());
321
386
  const keptOf = (entry) => 'store' in entry ? entry.store : entry.ranking;
@@ -330,7 +395,7 @@ export function createProject(options) {
330
395
  export: item.export,
331
396
  ranking: item.value,
332
397
  })).values(),
333
- ], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'));
398
+ ], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'), new Set([...stores.keys(), ...rankings.keys()]));
334
399
  // What is kept where nothing keeps it (Redis without REDIS_URL) is
335
400
  // reported and left out: it says so itself when used.
336
401
  const unkept = sameStore.valid.filter(entry => !storeBackends.has(keptOf(entry).storage));
@@ -348,8 +413,10 @@ export function createProject(options) {
348
413
  }));
349
414
  // One presence. A second one is left out.
350
415
  const [first, ...extra] = presenceItems.loaded;
351
- if (first)
416
+ if (first) {
352
417
  presence = first.value;
418
+ presenceSite = { file: first.file, export: first.export };
419
+ }
353
420
  else if (presenceItems.failed.length === 0 &&
354
421
  ![...broken].some(file => presenceFiles.has(file))) {
355
422
  presence = null;
@@ -376,14 +443,20 @@ export function createProject(options) {
376
443
  languages = nextLanguages;
377
444
  let assembled = assembleMessages([...languages.values()]);
378
445
  // A language that no longer matches the others keeps its last version
379
- // that did, like a declaration that no longer loads.
446
+ // that did, like a declaration that no longer loads; one that was not
447
+ // there before is left out. What already ran is never what breaks
448
+ // the languages: a second default: true saved in a new file must not
449
+ // take the texts of every command away.
380
450
  const restored = assembled.failed.filter(failure => {
381
451
  const key = keyOf({
382
452
  file: failure.file,
383
453
  export: failure.export ?? 'default',
384
454
  });
455
+ const current = languages.get(key);
456
+ if (current === undefined)
457
+ return false;
385
458
  const previous = previousLanguages.get(key);
386
- return previous !== undefined && previous !== languages.get(key);
459
+ return previous === undefined ? loadedOnce : previous !== current;
387
460
  });
388
461
  if (restored.length > 0) {
389
462
  for (const failure of restored) {
@@ -391,7 +464,11 @@ export function createProject(options) {
391
464
  file: failure.file,
392
465
  export: failure.export ?? 'default',
393
466
  });
394
- languages.set(key, previousLanguages.get(key));
467
+ const previous = previousLanguages.get(key);
468
+ if (previous)
469
+ languages.set(key, previous);
470
+ else
471
+ languages.delete(key);
395
472
  }
396
473
  const again = assembleMessages([...languages.values()]);
397
474
  assembled = {
@@ -403,6 +480,7 @@ export function createProject(options) {
403
480
  };
404
481
  }
405
482
  messages = assembled.messages;
483
+ loadedOnce = true;
406
484
  if (running)
407
485
  running.ctx.messages = messages;
408
486
  // A command has its texts in its file, or in the language files: the
@@ -410,7 +488,9 @@ export function createProject(options) {
410
488
  // be described is left out.
411
489
  const badTexts = [...assembled.failed];
412
490
  if (messages) {
413
- const names = new Set([...commands.values()].map(entry => entry.command.path.join(' ')));
491
+ const names = new Set([...commands.values()]
492
+ .filter(entry => entry.command.type === 'slash')
493
+ .map(entry => entry.command.path.join(' ')));
414
494
  for (const { file, path } of unknownCommands(messages, names)) {
415
495
  badTexts.push({
416
496
  file,
@@ -492,9 +572,12 @@ export function createProject(options) {
492
572
  messages === null,
493
573
  // A file declaring several events that need the intent is named once.
494
574
  filesNeeding: intent => [
495
- ...new Set([...events.values()]
496
- .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
497
- .map(({ file }) => file)),
575
+ ...new Set([
576
+ ...[...events.values()]
577
+ .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
578
+ .map(({ file }) => file),
579
+ ...CODE_USES.filter(use => (use.intents & GatewayIntent[intent]) !== 0).flatMap(use => uses[use.name] ?? []),
580
+ ]),
498
581
  ],
499
582
  intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
500
583
  async connect(connection) {
@@ -506,6 +589,7 @@ export function createProject(options) {
506
589
  guildFilter: connection.guildFilter,
507
590
  privateEvents: connection.privateEvents,
508
591
  presence: presence?.raw,
592
+ waiters: new MemoryWaiters(options.observe?.waits ? { watch: options.observe.waits } : {}),
509
593
  shards: connection.shards,
510
594
  identifyGate: connection.identifyGate,
511
595
  ...(connection.identifyInterval === undefined
@@ -514,7 +598,12 @@ export function createProject(options) {
514
598
  // Only what the files of the project use is remembered.
515
599
  cache: { limits: limitsFor(events.values()) },
516
600
  // A local fake of Discord (tests) serves voice without TLS too.
517
- voice: { secure: !connection.apiUrl?.startsWith('http://') },
601
+ voice: {
602
+ secure: !connection.apiUrl?.startsWith('http://'),
603
+ ...(connection.voiceUnavailable === undefined
604
+ ? {}
605
+ : { unavailable: connection.voiceUnavailable }),
606
+ },
518
607
  rest: {
519
608
  ...(connection.apiUrl ? { baseUrl: connection.apiUrl } : {}),
520
609
  ...(connection.globalLimit === undefined
@@ -558,19 +647,62 @@ export function createProject(options) {
558
647
  },
559
648
  });
560
649
  given.set(created, presence?.key ?? '');
650
+ shown.set(created, JSON.stringify(presence?.raw ?? DEFAULT_PRESENCE));
651
+ partial =
652
+ connection.shards !== undefined &&
653
+ connection.shards.ids.length < connection.shards.count;
561
654
  created.ctx.messages = messages;
562
655
  running = created;
563
656
  await created.connect();
564
657
  return created;
565
658
  },
566
659
  applyPresence(bot) {
660
+ // Always the functions of the files as they are now: a file they
661
+ // import may have changed. What is shown is only sent when it differs.
662
+ rotation.set(presence);
567
663
  const key = presence?.key ?? '';
568
664
  if (given.get(bot) === key)
569
665
  return false;
570
666
  given.set(bot, key);
571
- bot.setPresence(presence?.raw ?? DEFAULT_PRESENCE);
572
667
  return true;
573
668
  },
669
+ startPresence(current) {
670
+ presenceBot = current;
671
+ rotation.set(presence);
672
+ rotation.start();
673
+ },
674
+ stopPresence: () => rotation.stop(),
675
+ get presenceFile() {
676
+ return presence ? presenceSite.file : null;
677
+ },
678
+ async runPresence(bot) {
679
+ if (!presence)
680
+ return;
681
+ const context = {
682
+ guilds: guildCount(bot),
683
+ ...translation(bot.ctx, null),
684
+ };
685
+ const failed = (error) => report({ ...presenceSite, error });
686
+ let activities = presence.activities;
687
+ if (typeof activities === 'function') {
688
+ try {
689
+ activities = readActivities(await activities(context));
690
+ }
691
+ catch (error) {
692
+ return failed(error);
693
+ }
694
+ }
695
+ for (const { name } of activities) {
696
+ if (typeof name !== 'function')
697
+ continue;
698
+ try {
699
+ checkText(await name(context));
700
+ }
701
+ catch (error) {
702
+ failed(error);
703
+ }
704
+ }
705
+ },
574
706
  ready(bot) {
575
707
  const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
576
708
  router.emit('ready', {
package/dist/cli/redis.js CHANGED
@@ -10,11 +10,19 @@ import { RedisStoreBackend } from '../store/redis-backend.js';
10
10
  */
11
11
  export function redisStores(address, log, options = {}) {
12
12
  const client = new RedisClient(address, {
13
- onError: message => log.warn(message),
13
+ // While checkRedis() asks, a failure is what stops the bot, said once
14
+ // by it: "trying again" would not be true.
15
+ onError: message => {
16
+ if (!checking.has(backend))
17
+ log.warn(message);
18
+ },
14
19
  onReconnect: () => log.reload(`Redis at ${client.where} is back`),
15
20
  });
16
- return new RedisStoreBackend(client, options);
21
+ const backend = new RedisStoreBackend(client, options);
22
+ return backend;
17
23
  }
24
+ /** The backends `checkRedis()` is asking right now. */
25
+ const checking = new WeakSet();
18
26
  /** `a`, `a and b`, `a, b and c`. */
19
27
  const and = (items) => items.length <= 1
20
28
  ? (items[0] ?? '')
@@ -46,6 +54,7 @@ export async function checkRedis(project, backend, log) {
46
54
  const { stores, rankings } = project.redisStores();
47
55
  if (!backend || stores.length + rankings.length === 0)
48
56
  return true;
57
+ checking.add(backend);
49
58
  try {
50
59
  await backend.client.ping();
51
60
  return true;
@@ -54,4 +63,7 @@ export async function checkRedis(project, backend, log) {
54
63
  log.error(`${error instanceof Error ? error.message.replace(/ Trying again in \d+ s\.$/, '') : String(error)}`);
55
64
  return false;
56
65
  }
66
+ finally {
67
+ checking.delete(backend);
68
+ }
57
69
  }