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
@@ -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';
@@ -16,9 +19,12 @@ import { componentDeclaration, findDuplicates, } from '../components/declaration
16
19
  import { ComponentRouter } from '../components/router.js';
17
20
  import { taskDeclaration } from '../tasks/declaration.js';
18
21
  import { storeDeclaration } from '../store/declaration.js';
19
- import { setStoreBackend } from '../store/backend.js';
22
+ import { rankingDeclaration, } from '../ranking/declaration.js';
23
+ import { nowhereToKeep, NowhereToKeepError, setStoreBackend, STORAGES, } from '../store/backend.js';
20
24
  import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
21
- 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';
22
28
  import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
23
29
  import { translation } from '../messages/translate.js';
24
30
  import { translateCommand, unknownCommands } from '../messages/commands.js';
@@ -29,7 +35,7 @@ import { GatewayIntent } from '../discord/intents.js';
29
35
  import { eventDeclaration } from '../events/declaration.js';
30
36
  import { EventRouter, intentsFor, intentsOf, limitsFor, } from '../events/router.js';
31
37
  import { GatewayFatalError, SessionLimitError } from '../gateway/errors.js';
32
- import { loadSources, readDeclarations, siteName, } from '../loader/loader.js';
38
+ import { loadSources, bySite, readDeclarations, siteKey, siteName, } from '../loader/loader.js';
33
39
  import { locate, messageOf } from '../loader/locate.js';
34
40
  import { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from '../rest/errors.js';
35
41
  import { PreflightFailure } from './preflight.js';
@@ -68,9 +74,34 @@ export const DECLARATIONS = [
68
74
  componentDeclaration,
69
75
  taskDeclaration,
70
76
  storeDeclaration,
77
+ rankingDeclaration,
71
78
  presenceDeclaration,
72
79
  languageDeclaration,
73
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];
86
+ /**
87
+ * A promise of the files that nobody waited for (`store.set()` without
88
+ * `await`, in a `run` that is not `async`) rejects with nobody to tell:
89
+ * Node would stop the bot. Reported instead, with the file and the line,
90
+ * and what to write. A rejection from outside the files stays what it is:
91
+ * a bug of the framework, loud. Returns what stops watching.
92
+ */
93
+ export function reportLooseRejections(cwd, log) {
94
+ const listener = (reason) => {
95
+ const where = locate(reason, cwd);
96
+ if (!where)
97
+ throw reason;
98
+ log.error(`${where.file}:${where.line} ${messageOf(reason)}\nThis call was not awaited: write await in front of it (and async before run), so that your function waits for it.`);
99
+ };
100
+ process.on('unhandledRejection', listener);
101
+ return () => {
102
+ process.off('unhandledRejection', listener);
103
+ };
104
+ }
74
105
  /** Whether the folder is a project: it has a `src` folder. */
75
106
  export async function hasSources(cwd, log) {
76
107
  if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
@@ -86,32 +117,25 @@ const PRIVATE_INTENTS = GatewayIntent.DirectMessages |
86
117
  GatewayIntent.DirectMessagePolls;
87
118
  const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
88
119
  /** What tells one declaration from another: its file and its export. */
89
- const keyOf = (site) => `${site.file}#${site.export}`;
120
+ const keyOf = siteKey;
90
121
  /**
91
122
  * Two declarations with the same name, where the name is the export's
92
123
  * (a task): the later one is left out, with a message saying so.
93
124
  */
94
- function sameNames(entries, nameOf, what) {
125
+ function sameNames(entries, nameOf, what, running) {
95
126
  const seen = new Map();
96
127
  const conflicts = [];
97
- const valid = [...entries]
98
- .sort((a, b) => a.file === b.file
99
- ? a.export < b.export
100
- ? -1
101
- : 1
102
- : a.file < b.file
103
- ? -1
104
- : 1)
105
- .filter(entry => {
128
+ const valid = bySite(entries, running).filter(entry => {
106
129
  const first = seen.get(nameOf(entry));
107
130
  if (!first) {
108
131
  seen.set(nameOf(entry), entry);
109
132
  return true;
110
133
  }
134
+ const kind = (one) => typeof what === 'string' ? what : what(one);
111
135
  conflicts.push({
112
136
  file: entry.file,
113
137
  export: entry.export,
114
- message: `There is already a ${what} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${what}, so rename one of them.`,
138
+ message: `There is already a ${kind(first)} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${kind(entry)}, so rename one of them.`,
115
139
  });
116
140
  return false;
117
141
  });
@@ -121,9 +145,18 @@ export function createProject(options) {
121
145
  const { cwd, log } = options;
122
146
  // Where `asset()` reads the files of the project from.
123
147
  setPublicDir(cwd);
124
- // Where the stores keep their data, for every store of the process.
125
- const storeBackend = options.stores ?? new FileStoreBackend(join(cwd, DATA_FOLDER));
126
- setStoreBackend(storeBackend);
148
+ // Where the stores keep their data, for every store of the process: a
149
+ // backend per storage the project has one for.
150
+ const storeBackends = new Map();
151
+ for (const storage of STORAGES) {
152
+ const backend = options.stores?.[storage] ??
153
+ (storage === 'file'
154
+ ? new FileStoreBackend(join(cwd, DATA_FOLDER))
155
+ : null);
156
+ setStoreBackend(storage, backend);
157
+ if (backend)
158
+ storeBackends.set(storage, backend);
159
+ }
127
160
  const report = (failure) => {
128
161
  const where = locate(failure.error, cwd);
129
162
  log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
@@ -150,20 +183,80 @@ export function createProject(options) {
150
183
  let components = new Map();
151
184
  let tasks = new Map();
152
185
  let stores = new Map();
186
+ let rankings = new Map();
153
187
  let presence = null;
188
+ /** Where the presence is declared, for what its functions do wrong. */
189
+ let presenceSite = { file: 'the presence', export: 'default' };
154
190
  /** The files the presence was declared in, to keep it when they break. */
155
191
  let presenceFiles = new Set();
156
- // Joining voice needs the bot's own voice state, sent with an intent.
157
- let voice = options.joinsVoice ?? false;
158
- 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);
159
196
  let messages = null;
160
197
  /** The last version of each language that loaded. */
161
198
  let languages = new Map();
162
199
  let languageDefaults = [];
200
+ /** Whether the files were loaded before: what a reload compares with. */
201
+ let loadedOnce = false;
163
202
  /** The bot running the files now, to give it what a reload changes. */
164
203
  let running = null;
165
204
  /** The presence each bot was given last, to send only what changed. */
166
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
+ });
167
260
  let lost = false;
168
261
  const count = (size, one, many) => `${size} ${size === 1 ? one : many}`;
169
262
  return {
@@ -182,7 +275,20 @@ export function createProject(options) {
182
275
  get stores() {
183
276
  return stores;
184
277
  },
185
- storeBackend,
278
+ storeBackends,
279
+ get rankings() {
280
+ return rankings;
281
+ },
282
+ redisStores: () => ({
283
+ stores: [...stores.values()]
284
+ .filter(entry => entry.store.storage === 'redis')
285
+ .map(entry => entry.store.name)
286
+ .sort(),
287
+ rankings: [...rankings.values()]
288
+ .filter(entry => entry.ranking.storage === 'redis')
289
+ .map(entry => entry.ranking.name)
290
+ .sort(),
291
+ }),
186
292
  get presence() {
187
293
  return presence;
188
294
  },
@@ -198,7 +304,10 @@ export function createProject(options) {
198
304
  })),
199
305
  ...(messages ? { defaultLocale: messages.default } : {}),
200
306
  languageDefaults,
201
- 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 }) => ({
202
311
  name: command.path.join(' '),
203
312
  described: command.described,
204
313
  })),
@@ -206,19 +315,20 @@ export function createProject(options) {
206
315
  report,
207
316
  async load() {
208
317
  const { built } = options;
209
- if (options.joinsVoice === undefined) {
210
- voice = await sourcesJoinVoice(cwd);
318
+ if (options.uses === undefined) {
319
+ uses = await scanUses(cwd, CODE_USES);
211
320
  }
212
321
  // Every file of src/ runs (or ran, in a build); what each exports is
213
322
  // then read, kind by kind.
214
323
  const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
215
324
  const read = (declaration) => readDeclarations(sources.modules, declaration);
216
- const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
325
+ const [eventItems, commandItems, componentItems, taskItems, storeItems, rankingItems, presenceItems, languageItems,] = [
217
326
  read(eventDeclaration),
218
327
  read(commandDeclaration),
219
328
  read(componentDeclaration),
220
329
  read(taskDeclaration),
221
330
  read(storeDeclaration),
331
+ read(rankingDeclaration),
222
332
  read(presenceDeclaration),
223
333
  read(languageDeclaration),
224
334
  ];
@@ -246,13 +356,14 @@ export function createProject(options) {
246
356
  event: item.value,
247
357
  }));
248
358
  router.set(events.values());
359
+ // A name two declarations share stays with the one that ran before.
249
360
  const { valid, conflicts } = findConflicts([
250
361
  ...next(commands, commandItems, item => ({
251
362
  file: item.file,
252
363
  export: item.export,
253
364
  command: item.value,
254
365
  })).values(),
255
- ]);
366
+ ], new Set(commands.keys()));
256
367
  commands = new Map(valid.map(entry => [keyOf(entry), entry]));
257
368
  const unique = findDuplicates([
258
369
  ...next(components, componentItems, item => ({
@@ -260,7 +371,7 @@ export function createProject(options) {
260
371
  export: item.export,
261
372
  component: item.value,
262
373
  })).values(),
263
- ]);
374
+ ], new Set(components.keys()));
264
375
  components = new Map(unique.valid.map(entry => [keyOf(entry), entry]));
265
376
  componentRouter.set(components.values());
266
377
  const sameTask = sameNames([
@@ -269,22 +380,43 @@ export function createProject(options) {
269
380
  export: item.export,
270
381
  task: item.value,
271
382
  })).values(),
272
- ], entry => entry.task.name, 'task');
383
+ ], entry => entry.task.name, 'task', new Set(tasks.keys()));
273
384
  tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
274
385
  scheduler.set(tasks.values());
275
- // Two stores with one name would share one file: the later is left out.
386
+ const keptOf = (entry) => 'store' in entry ? entry.store : entry.ranking;
276
387
  const sameStore = sameNames([
277
388
  ...next(stores, storeItems, item => ({
278
389
  file: item.file,
279
390
  export: item.export,
280
391
  store: item.value,
281
392
  })).values(),
282
- ], entry => entry.store.name, 'store');
283
- stores = new Map(sameStore.valid.map(entry => [keyOf(entry), entry]));
393
+ ...next(rankings, rankingItems, item => ({
394
+ file: item.file,
395
+ export: item.export,
396
+ ranking: item.value,
397
+ })).values(),
398
+ ], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'), new Set([...stores.keys(), ...rankings.keys()]));
399
+ // What is kept where nothing keeps it (Redis without REDIS_URL) is
400
+ // reported and left out: it says so itself when used.
401
+ const unkept = sameStore.valid.filter(entry => !storeBackends.has(keptOf(entry).storage));
402
+ const kept = sameStore.valid.filter(entry => !unkept.includes(entry));
403
+ stores = new Map(kept
404
+ .filter((entry) => 'store' in entry)
405
+ .map(entry => [keyOf(entry), entry]));
406
+ rankings = new Map(kept
407
+ .filter((entry) => 'ranking' in entry)
408
+ .map(entry => [keyOf(entry), entry]));
409
+ const nowhere = unkept.map(entry => ({
410
+ file: entry.file,
411
+ export: entry.export,
412
+ error: new NowhereToKeepError(nowhereToKeep(keptOf(entry).storage, keptOf(entry).name, 'store' in entry ? 'store' : 'ranking')),
413
+ }));
284
414
  // One presence. A second one is left out.
285
415
  const [first, ...extra] = presenceItems.loaded;
286
- if (first)
416
+ if (first) {
287
417
  presence = first.value;
418
+ presenceSite = { file: first.file, export: first.export };
419
+ }
288
420
  else if (presenceItems.failed.length === 0 &&
289
421
  ![...broken].some(file => presenceFiles.has(file))) {
290
422
  presence = null;
@@ -311,14 +443,20 @@ export function createProject(options) {
311
443
  languages = nextLanguages;
312
444
  let assembled = assembleMessages([...languages.values()]);
313
445
  // A language that no longer matches the others keeps its last version
314
- // 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.
315
450
  const restored = assembled.failed.filter(failure => {
316
451
  const key = keyOf({
317
452
  file: failure.file,
318
453
  export: failure.export ?? 'default',
319
454
  });
455
+ const current = languages.get(key);
456
+ if (current === undefined)
457
+ return false;
320
458
  const previous = previousLanguages.get(key);
321
- return previous !== undefined && previous !== languages.get(key);
459
+ return previous === undefined ? loadedOnce : previous !== current;
322
460
  });
323
461
  if (restored.length > 0) {
324
462
  for (const failure of restored) {
@@ -326,7 +464,11 @@ export function createProject(options) {
326
464
  file: failure.file,
327
465
  export: failure.export ?? 'default',
328
466
  });
329
- 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);
330
472
  }
331
473
  const again = assembleMessages([...languages.values()]);
332
474
  assembled = {
@@ -338,6 +480,7 @@ export function createProject(options) {
338
480
  };
339
481
  }
340
482
  messages = assembled.messages;
483
+ loadedOnce = true;
341
484
  if (running)
342
485
  running.ctx.messages = messages;
343
486
  // A command has its texts in its file, or in the language files: the
@@ -345,7 +488,9 @@ export function createProject(options) {
345
488
  // be described is left out.
346
489
  const badTexts = [...assembled.failed];
347
490
  if (messages) {
348
- 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(' ')));
349
494
  for (const { file, path } of unknownCommands(messages, names)) {
350
495
  badTexts.push({
351
496
  file,
@@ -369,10 +514,12 @@ export function createProject(options) {
369
514
  ...componentItems.failed,
370
515
  ...taskItems.failed,
371
516
  ...storeItems.failed,
517
+ ...rankingItems.failed,
372
518
  ...presenceItems.failed,
373
519
  ...languageItems.failed,
374
520
  ...badTexts,
375
521
  ...twice,
522
+ ...nowhere,
376
523
  ...[
377
524
  ...conflicts,
378
525
  ...unique.conflicts,
@@ -404,6 +551,9 @@ export function createProject(options) {
404
551
  : []),
405
552
  ...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
406
553
  ...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
554
+ ...(rankings.size > 0
555
+ ? [count(rankings.size, 'ranking', 'rankings')]
556
+ : []),
407
557
  ...(presence ? ['a presence'] : []),
408
558
  ...(messages
409
559
  ? [
@@ -417,13 +567,17 @@ export function createProject(options) {
417
567
  components.size === 0 &&
418
568
  tasks.size === 0 &&
419
569
  stores.size === 0 &&
570
+ rankings.size === 0 &&
420
571
  presence === null &&
421
572
  messages === null,
422
573
  // A file declaring several events that need the intent is named once.
423
574
  filesNeeding: intent => [
424
- ...new Set([...events.values()]
425
- .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
426
- .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
+ ]),
427
581
  ],
428
582
  intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
429
583
  async connect(connection) {
@@ -435,6 +589,7 @@ export function createProject(options) {
435
589
  guildFilter: connection.guildFilter,
436
590
  privateEvents: connection.privateEvents,
437
591
  presence: presence?.raw,
592
+ waiters: new MemoryWaiters(options.observe?.waits ? { watch: options.observe.waits } : {}),
438
593
  shards: connection.shards,
439
594
  identifyGate: connection.identifyGate,
440
595
  ...(connection.identifyInterval === undefined
@@ -443,7 +598,12 @@ export function createProject(options) {
443
598
  // Only what the files of the project use is remembered.
444
599
  cache: { limits: limitsFor(events.values()) },
445
600
  // A local fake of Discord (tests) serves voice without TLS too.
446
- voice: { secure: !connection.apiUrl?.startsWith('http://') },
601
+ voice: {
602
+ secure: !connection.apiUrl?.startsWith('http://'),
603
+ ...(connection.voiceUnavailable === undefined
604
+ ? {}
605
+ : { unavailable: connection.voiceUnavailable }),
606
+ },
447
607
  rest: {
448
608
  ...(connection.apiUrl ? { baseUrl: connection.apiUrl } : {}),
449
609
  ...(connection.globalLimit === undefined
@@ -487,19 +647,62 @@ export function createProject(options) {
487
647
  },
488
648
  });
489
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;
490
654
  created.ctx.messages = messages;
491
655
  running = created;
492
656
  await created.connect();
493
657
  return created;
494
658
  },
495
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);
496
663
  const key = presence?.key ?? '';
497
664
  if (given.get(bot) === key)
498
665
  return false;
499
666
  given.set(bot, key);
500
- bot.setPresence(presence?.raw ?? DEFAULT_PRESENCE);
501
667
  return true;
502
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
+ },
503
706
  ready(bot) {
504
707
  const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
505
708
  router.emit('ready', {
@@ -530,7 +733,10 @@ export function createProject(options) {
530
733
  },
531
734
  // The bot looks after its own memory: it remembers less before memory
532
735
  // is full, and only speaks up when it can't give up anything more.
533
- closeStores: () => storeBackend.close(),
736
+ closeStores: async () => {
737
+ for (const backend of storeBackends.values())
738
+ await backend.close();
739
+ },
534
740
  watchMemory: current => watchMemory({
535
741
  cache: () => current()?.ctx.cache,
536
742
  onTightened: ({ members, forgotten }, { used, limit }) => log.info(`Memory was getting full (${megabytes(used)} of ${megabytes(limit)}): the bot now remembers ${members} members per server at most, and forgot ${forgotten} members and users it had not seen for the longest. Nothing changes for your code.`),
@@ -0,0 +1,23 @@
1
+ import type { RedisAddress } from '../redis/url.js';
2
+ import { RedisStoreBackend } from '../store/redis-backend.js';
3
+ import type { Log } from './log.js';
4
+ import type { Project } from './project.js';
5
+ /**
6
+ * The backend of the stores kept in Redis. Nothing is connected until a
7
+ * store is used, or `checkRedis()` asks; an outage is said once with `⚠`
8
+ * and its end with `↻`.
9
+ */
10
+ export declare function redisStores(address: RedisAddress, log: Log, options?: {
11
+ prefix?: string;
12
+ }): RedisStoreBackend;
13
+ /**
14
+ * The `ℹ` line naming the stores and rankings kept in Redis, or `null`
15
+ * without any: "The store sessions and the ranking xp are kept in Redis…".
16
+ */
17
+ export declare function describeRedisStores(project: Project, backend: RedisStoreBackend | null): string | null;
18
+ /**
19
+ * Makes sure Redis answers before the bot goes online, when a store is
20
+ * kept there: a Redis that can't be reached is an error now, not when
21
+ * someone uses the bot.
22
+ */
23
+ export declare function checkRedis(project: Project, backend: RedisStoreBackend | null, log: Log): Promise<boolean>;
@@ -0,0 +1,69 @@
1
+ // What `dev` and `start` share about Redis: the backend made of
2
+ // `REDIS_URL`, checked before Discord is reached when a store needs it,
3
+ // and the line that says which stores are kept there.
4
+ import { RedisClient } from '../redis/client.js';
5
+ import { RedisStoreBackend } from '../store/redis-backend.js';
6
+ /**
7
+ * The backend of the stores kept in Redis. Nothing is connected until a
8
+ * store is used, or `checkRedis()` asks; an outage is said once with `⚠`
9
+ * and its end with `↻`.
10
+ */
11
+ export function redisStores(address, log, options = {}) {
12
+ const client = new RedisClient(address, {
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
+ },
19
+ onReconnect: () => log.reload(`Redis at ${client.where} is back`),
20
+ });
21
+ const backend = new RedisStoreBackend(client, options);
22
+ return backend;
23
+ }
24
+ /** The backends `checkRedis()` is asking right now. */
25
+ const checking = new WeakSet();
26
+ /** `a`, `a and b`, `a, b and c`. */
27
+ const and = (items) => items.length <= 1
28
+ ? (items[0] ?? '')
29
+ : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
30
+ /**
31
+ * The `ℹ` line naming the stores and rankings kept in Redis, or `null`
32
+ * without any: "The store sessions and the ranking xp are kept in Redis…".
33
+ */
34
+ export function describeRedisStores(project, backend) {
35
+ const { stores, rankings } = project.redisStores();
36
+ const total = stores.length + rankings.length;
37
+ if (!backend || total === 0)
38
+ return null;
39
+ const group = (one, many, names) => names.length === 0
40
+ ? []
41
+ : [`the ${names.length === 1 ? one : many} ${and(names)}`];
42
+ const list = and([
43
+ ...group('store', 'stores', stores),
44
+ ...group('ranking', 'rankings', rankings),
45
+ ]);
46
+ return `${list[0].toUpperCase()}${list.slice(1)} ${total === 1 ? 'is' : 'are'} kept in Redis at ${backend.client.where}`;
47
+ }
48
+ /**
49
+ * Makes sure Redis answers before the bot goes online, when a store is
50
+ * kept there: a Redis that can't be reached is an error now, not when
51
+ * someone uses the bot.
52
+ */
53
+ export async function checkRedis(project, backend, log) {
54
+ const { stores, rankings } = project.redisStores();
55
+ if (!backend || stores.length + rankings.length === 0)
56
+ return true;
57
+ checking.add(backend);
58
+ try {
59
+ await backend.client.ping();
60
+ return true;
61
+ }
62
+ catch (error) {
63
+ log.error(`${error instanceof Error ? error.message.replace(/ Trying again in \d+ s\.$/, '') : String(error)}`);
64
+ return false;
65
+ }
66
+ finally {
67
+ checking.delete(backend);
68
+ }
69
+ }