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
@@ -11,6 +11,28 @@ import { importFile } from './hot.js';
11
11
  export const siteName = (site) => site.export === undefined || site.export === 'default'
12
12
  ? site.file
13
13
  : `${site.file} (${site.export})`;
14
+ /** What tells one export of the project from another: `src/a.ts#ban`. */
15
+ export const siteKey = (site) => `${site.file}#${site.export}`;
16
+ /**
17
+ * The order in which declarations that can't both be kept (two with one
18
+ * name) are read, the first one winning: what already ran first (`running`,
19
+ * the `siteKey`s of the declarations of the last load), so that a file saved
20
+ * in dev never takes its name from one that works, then by file and export,
21
+ * so that the choice never depends on the order the files were found in.
22
+ */
23
+ export function bySite(entries, running = new Set()) {
24
+ const before = (entry) => (running.has(siteKey(entry)) ? 0 : 1);
25
+ return [...entries].sort((a, b) => before(a) - before(b) ||
26
+ (a.file === b.file
27
+ ? a.export < b.export
28
+ ? -1
29
+ : a.export > b.export
30
+ ? 1
31
+ : 0
32
+ : a.file < b.file
33
+ ? -1
34
+ : 1));
35
+ }
14
36
  const SOURCE = /\.(?:ts|mts|js|mjs)$/;
15
37
  const DECLARATION = /\.d\.m?ts$/;
16
38
  /** Tests next to the code: they are for a test runner, not for the bot. */
@@ -68,7 +90,7 @@ export async function loadFiles(files) {
68
90
  result.modules.push({ file, exports: await importFile(path) });
69
91
  }
70
92
  catch (error) {
71
- result.failed.push({ file, error });
93
+ result.failed.push({ file, error: readable(error) });
72
94
  }
73
95
  }));
74
96
  const byFile = (a, b) => a.file < b.file ? -1 : 1;
@@ -76,6 +98,19 @@ export async function loadFiles(files) {
76
98
  result.failed.sort(byFile);
77
99
  return result;
78
100
  }
101
+ /**
102
+ * A file Node can't read as TypeScript gets the words of its parser
103
+ * ("Expected ',', got ';'"): what they mean is said after them. The error
104
+ * is the same, so its line is still found.
105
+ */
106
+ function readable(error) {
107
+ if (error instanceof SyntaxError &&
108
+ error.code === 'ERR_INVALID_TYPESCRIPT_SYNTAX' &&
109
+ !error.message.includes('This line can not be read')) {
110
+ error.message = `${error.message.replace(/\.?$/, '.')} This line can not be read: look for a bracket, a comma or a quote missing or in excess, here or just before.`;
111
+ }
112
+ return error;
113
+ }
79
114
  /** The name of a file, without its folders and its extension. */
80
115
  const baseName = (file) => file
81
116
  .split('/')
@@ -103,22 +138,32 @@ export function readDeclarations(modules, declaration) {
103
138
  export: exported,
104
139
  name: exported === 'default' ? baseName(file) : exported,
105
140
  };
106
- const read = (one) => {
141
+ const read = (one, at) => {
107
142
  if (seen.has(one))
108
143
  return;
109
144
  seen.add(one);
110
- result.loaded.push({ ...site, value: declaration.read(one, site) });
145
+ result.loaded.push({ ...at, value: declaration.read(one, at) });
111
146
  };
112
147
  try {
113
148
  if (declaration.is(value))
114
- read(value);
149
+ read(value, site);
115
150
  else if (Array.isArray(value) && value.some(declaration.is)) {
116
151
  if (!declaration.list) {
117
152
  throw new TypeError(`This export is a list with a ${declaration.one} in it: export each ${declaration.one} on its own (export const ${site.name} = ...), its name is the name of its export.`);
118
153
  }
119
- for (const one of value)
120
- if (declaration.is(one))
121
- read(one);
154
+ // Each one of the list is told apart by its place in it (`events[2]`),
155
+ // and read on its own: one that is wrong leaves the others running.
156
+ value.forEach((one, index) => {
157
+ if (!declaration.is(one))
158
+ return;
159
+ const at = { ...site, export: `${exported}[${index}]` };
160
+ try {
161
+ read(one, at);
162
+ }
163
+ catch (error) {
164
+ result.failed.push({ file, export: at.export, error });
165
+ }
166
+ });
122
167
  }
123
168
  }
124
169
  catch (error) {
@@ -0,0 +1,15 @@
1
+ /** A use of the framework seen in the code, and the intents it needs. */
2
+ export interface CodeUse {
3
+ /** What the use is called in `build.json`: `'voice'`, `'messages'`... */
4
+ readonly name: string;
5
+ /** What the code looks like when it does it. */
6
+ readonly pattern: RegExp;
7
+ /** The intents Discord must be asked for (`GatewayIntent` bits). */
8
+ readonly intents: number;
9
+ }
10
+ /** The uses found: for each name, the files that do it (`src/quiz.ts`). */
11
+ export type FoundUses = Readonly<Record<string, readonly string[]>>;
12
+ /** Reads the files of `src/` for each use. */
13
+ export declare function scanUses(cwd: string, uses: readonly CodeUse[]): Promise<FoundUses>;
14
+ /** The intents the uses found need. */
15
+ export declare function usesIntents(uses: readonly CodeUse[], found: FoundUses): number;
@@ -0,0 +1,40 @@
1
+ // What a project does that only its code shows: no declaration says that
2
+ // it joins voice or waits for a message, yet Discord only sends what that
3
+ // needs with an intent. Each feature describes such a use (`CodeUse`) and
4
+ // the files of `src/` are read for it, so the intent is asked for when the
5
+ // project needs it, and only then, like the intents of its events.
6
+ import { readdir, readFile } from 'node:fs/promises';
7
+ import { join, relative } from 'node:path';
8
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
9
+ /** Test files are never loaded by the bot. */
10
+ const TEST = /\.(?:test|spec)\.[^.]+$/;
11
+ /** Reads the files of `src/` for each use. */
12
+ export async function scanUses(cwd, uses) {
13
+ const found = {};
14
+ const src = join(cwd, 'src');
15
+ const entries = await readdir(src, {
16
+ recursive: true,
17
+ withFileTypes: true,
18
+ }).catch(() => []);
19
+ for (const entry of entries) {
20
+ if (!entry.isFile() || !SOURCE.test(entry.name) || TEST.test(entry.name)) {
21
+ continue;
22
+ }
23
+ if (entry.name.endsWith('.d.ts'))
24
+ continue;
25
+ const path = join(entry.parentPath, entry.name);
26
+ const code = await readFile(path, 'utf8').catch(() => '');
27
+ for (const use of uses) {
28
+ if (use.pattern.test(code)) {
29
+ (found[use.name] ??= []).push(relative(cwd, path).split('\\').join('/'));
30
+ }
31
+ }
32
+ }
33
+ for (const files of Object.values(found))
34
+ files.sort();
35
+ return found;
36
+ }
37
+ /** The intents the uses found need. */
38
+ export function usesIntents(uses, found) {
39
+ return uses.reduce((intents, use) => (found[use.name]?.length ?? 0) > 0 ? intents | use.intents : intents, 0);
40
+ }
@@ -4,9 +4,11 @@
4
4
  */
5
5
  export declare function snapshotFolder(dir: string): Promise<string>;
6
6
  /**
7
- * Calls `onChange` when files under `dir` are saved, once per burst: an
8
- * editor often writes a file in several steps, and saving several files at
9
- * once is one change.
7
+ * Calls `onChange` when files under `dir` are saved, once per change: an
8
+ * editor often writes a file in several steps, saving several files at once
9
+ * is one change, and the system may tell a save twice, a moment apart (macOS
10
+ * does for a file written then renamed over the old one). What is compared
11
+ * is the folder itself, so a save that changed nothing reloads nothing.
10
12
  */
11
13
  export declare function watchFolder(dir: string, onChange: () => void, { delay, loaded, }?: {
12
14
  delay?: number;
@@ -20,13 +20,31 @@ export async function snapshotFolder(dir) {
20
20
  return files.sort().join('\n');
21
21
  }
22
22
  /**
23
- * Calls `onChange` when files under `dir` are saved, once per burst: an
24
- * editor often writes a file in several steps, and saving several files at
25
- * once is one change.
23
+ * Calls `onChange` when files under `dir` are saved, once per change: an
24
+ * editor often writes a file in several steps, saving several files at once
25
+ * is one change, and the system may tell a save twice, a moment apart (macOS
26
+ * does for a file written then renamed over the old one). What is compared
27
+ * is the folder itself, so a save that changed nothing reloads nothing.
26
28
  */
27
29
  export function watchFolder(dir, onChange, { delay = 60, loaded = snapshotFolder(dir), } = {}) {
28
30
  let timer = null;
29
31
  let closed = false;
32
+ /** The folder as the last load saw it. */
33
+ let lastSeen = loaded;
34
+ /** One comparison at a time, in the order the bursts ended. */
35
+ let comparing = Promise.resolve();
36
+ const compare = () => {
37
+ comparing = comparing.then(async () => {
38
+ if (closed)
39
+ return;
40
+ await watcher.refresh();
41
+ const [then, now] = await Promise.all([lastSeen, snapshotFolder(dir)]);
42
+ if (closed || then === now)
43
+ return;
44
+ lastSeen = Promise.resolve(now);
45
+ onChange();
46
+ });
47
+ };
30
48
  const changed = () => {
31
49
  if (closed)
32
50
  return;
@@ -34,25 +52,14 @@ export function watchFolder(dir, onChange, { delay = 60, loaded = snapshotFolder
34
52
  clearTimeout(timer);
35
53
  timer = setTimeout(() => {
36
54
  timer = null;
37
- onChange();
55
+ compare();
38
56
  }, delay);
39
57
  };
40
- const watcher = watch(dir, { recursive: true }, changed);
41
- // The folder being deleted is not a reason to crash the bot.
42
- watcher.on('error', () => { });
58
+ const watcher = watchTree(dir, changed);
43
59
  // The system takes a moment to really start watching: a file saved
44
60
  // between the load and that moment would be missed. The folder is
45
61
  // compared, a little later, with what it was when it was loaded.
46
- const before = loaded;
47
- const catchUps = [300, 1500].map(wait => setTimeout(() => {
48
- void Promise.all([before, snapshotFolder(dir)]).then(([then, now]) => {
49
- if (then !== now && lastSeen !== now) {
50
- lastSeen = now;
51
- changed();
52
- }
53
- });
54
- }, wait));
55
- let lastSeen = null;
62
+ const catchUps = [300, 1500].map(wait => setTimeout(compare, wait));
56
63
  return {
57
64
  close() {
58
65
  closed = true;
@@ -64,3 +71,67 @@ export function watchFolder(dir, onChange, { delay = 60, loaded = snapshotFolder
64
71
  },
65
72
  };
66
73
  }
74
+ /**
75
+ * Calls `listener` when anything under `dir` changes. macOS and Windows watch
76
+ * a tree themselves. Elsewhere Node watches every file on its own, and a file
77
+ * written then renamed over the old one (how vim and JetBrains save) is a new
78
+ * file it never watches again: its next saves would be missed. So each folder
79
+ * is watched instead, which hears every file in it whatever replaced it, and
80
+ * `refresh()` follows the folders created or deleted since.
81
+ */
82
+ function watchTree(dir, listener) {
83
+ const open = (path, recursive) => {
84
+ const watcher = watch(path, { recursive }, listener);
85
+ // A folder being deleted is not a reason to crash the bot.
86
+ watcher.on('error', () => { });
87
+ return watcher;
88
+ };
89
+ if (process.platform === 'darwin' || process.platform === 'win32') {
90
+ const watcher = open(dir, true);
91
+ return { refresh: async () => { }, close: () => watcher.close() };
92
+ }
93
+ const watchers = new Map();
94
+ let closed = false;
95
+ const refresh = async () => {
96
+ const entries = await readdir(dir, {
97
+ recursive: true,
98
+ withFileTypes: true,
99
+ }).catch(() => []);
100
+ if (closed)
101
+ return;
102
+ const folders = new Set([
103
+ dir,
104
+ ...entries
105
+ .filter(entry => entry.isDirectory())
106
+ .map(entry => join(entry.parentPath, entry.name)),
107
+ ]);
108
+ for (const [path, watcher] of watchers) {
109
+ if (folders.has(path))
110
+ continue;
111
+ watcher.close();
112
+ watchers.delete(path);
113
+ }
114
+ for (const path of folders) {
115
+ if (watchers.has(path))
116
+ continue;
117
+ try {
118
+ watchers.set(path, open(path, false));
119
+ }
120
+ catch {
121
+ // Deleted since it was listed: the next refresh forgets it.
122
+ }
123
+ }
124
+ };
125
+ // The folder itself at once, so nothing is missed while the rest is read.
126
+ watchers.set(dir, open(dir, false));
127
+ void refresh();
128
+ return {
129
+ refresh,
130
+ close() {
131
+ closed = true;
132
+ for (const watcher of watchers.values())
133
+ watcher.close();
134
+ watchers.clear();
135
+ },
136
+ };
137
+ }
@@ -25,6 +25,9 @@ export const defaultLanguageFile = (messages) => messages?.files.get(messages.de
25
25
  * already does. What is wrong is reported on the file it is wrong in.
26
26
  */
27
27
  export function translateCommand(file, command, messages) {
28
+ // A menu command has its name and translations in its file, always.
29
+ if (command.type !== 'slash')
30
+ return { command, failed: [] };
28
31
  const path = command.path.join(' ');
29
32
  const name = commandName(command.path);
30
33
  const failed = [];
@@ -8,6 +8,7 @@ import type { Locale } from '../discord/types/common.js';
8
8
  * out. `{count}` in a form is the number itself.
9
9
  */
10
10
  export interface PluralText {
11
+ /** For 0, in every language; and where a language has a form of its own for other numbers too (Arabic, Latvian). */
11
12
  zero?: string;
12
13
  one?: string;
13
14
  two?: string;
@@ -146,7 +146,11 @@ export function fill(key, template, params, locale = 'en-US') {
146
146
  if (typeof count !== 'number') {
147
147
  throw new TypeError(`The message "${key}" is a plural: its {count} is a number, got ${JSON.stringify(count)}.`);
148
148
  }
149
- const form = pluralRule(locale).select(count);
149
+ // A `zero` written is for 0 in every language, as people expect it
150
+ // ("no apple"): the rules of English never pick it by themselves.
151
+ const form = count === 0 && template.forms.has('zero')
152
+ ? 'zero'
153
+ : pluralRule(locale).select(count);
150
154
  template = template.forms.get(form) ?? template.forms.get('other');
151
155
  }
152
156
  let text = '';
@@ -1,17 +1,64 @@
1
1
  import type { Declaration } from '../loader/loader.js';
2
2
  import { type RawGatewayPresenceUpdate } from '../discord/types/gateway-events.js';
3
+ import { type ActivityKind, type PresenceContext, type PresenceStatusName } from './presence.js';
4
+ /** The text of an activity as a file gives it: a text, or a function. */
5
+ export type LoadedText = string | ((context: PresenceContext) => string | Promise<string>);
6
+ /** An activity, checked. */
7
+ export interface LoadedActivity {
8
+ readonly type: ActivityKind | 'streaming';
9
+ readonly name: LoadedText;
10
+ readonly url?: string;
11
+ /** How long it is shown, in milliseconds. */
12
+ readonly for: number;
13
+ }
14
+ /** The function of a file that gives the activities. */
15
+ export type ActivitiesFunction = (context: PresenceContext) => unknown | Promise<unknown>;
3
16
  /** The presence, checked: what the gateway sends. */
4
17
  export interface LoadedPresence {
5
- /** The Update Presence payload (also sent with Identify). */
18
+ readonly status: PresenceStatusName;
19
+ /** The activities shown in turn (none, one or several), or the function giving them. */
20
+ readonly activities: readonly LoadedActivity[] | ActivitiesFunction;
21
+ /**
22
+ * What the bot connects with (sent with Identify): the first activity
23
+ * when it is known without running anything, else the status alone.
24
+ */
6
25
  readonly raw: RawGatewayPresenceUpdate;
7
- /** What tells one presence from another: the payload, as text. */
26
+ /**
27
+ * What tells one presence from another: what it sends, and the code of
28
+ * its functions, as text. The same file saved again has the same key.
29
+ */
8
30
  readonly key: string;
9
31
  }
32
+ /** How long an activity is shown when the file does not say. */
33
+ export declare const DEFAULT_FOR = 30000;
34
+ /**
35
+ * The shortest time an activity may be shown. "Clients may only update
36
+ * their game status 5 times per 20 seconds": one every 5 seconds is
37
+ * always sent at once.
38
+ * @see https://docs.discord.com/developers/events/gateway-events#update-presence
39
+ */
40
+ export declare const MIN_FOR = 5000;
10
41
  /**
11
42
  * What a bot without a presence shows: online, doing nothing. Sent when
12
43
  * the declaration is removed while the bot runs.
13
44
  */
14
45
  export declare const DEFAULT_PRESENCE: RawGatewayPresenceUpdate;
46
+ /**
47
+ * What the gateway sends for a status and the activity shown, with its
48
+ * text. `since` and `afk` are about a person's client going idle: a bot is
49
+ * never away from its keyboard.
50
+ */
51
+ export declare function toRaw(status: PresenceStatusName, activity: {
52
+ type: LoadedActivity['type'];
53
+ url?: string;
54
+ } | null, name: string): RawGatewayPresenceUpdate;
55
+ /** Checks the text of an activity a function gave, when it ran. */
56
+ export declare function checkText(name: unknown): string;
57
+ /**
58
+ * Checks a list of activities: the one of a file, or what its function
59
+ * gave when it ran.
60
+ */
61
+ export declare function readActivities(value: unknown): LoadedActivity[];
15
62
  /**
16
63
  * The presence of the bot, declared once anywhere in `src/` with
17
64
  * `presence()` and exported.
@@ -1,14 +1,17 @@
1
1
  // What a file declares with `presence()`: what the bot shows under its
2
2
  // name. It is checked when it loads and turned into what the gateway
3
3
  // sends, so a wrong status or type is explained before the bot connects.
4
+ // The activities a function gives are checked the same way when it runs
5
+ // (`readActivities`).
4
6
  import { ActivityType, } from '../discord/types/gateway-events.js';
5
- import { isPresenceDeclaration } from './presence.js';
7
+ import { DURATION_EXAMPLE, parseDuration } from '../util/duration.js';
8
+ import { isPresenceDeclaration, } from './presence.js';
6
9
  const fail = message => {
7
10
  throw new TypeError(message);
8
11
  };
9
12
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
10
- const KEYS = ['status', 'activity'];
11
- const ACTIVITY_KEYS = ['type', 'name', 'url'];
13
+ const KEYS = ['status', 'activity', 'activities'];
14
+ const ACTIVITY_KEYS = ['type', 'name', 'url', 'for'];
12
15
  /**
13
16
  * The statuses a file may give.
14
17
  * @see https://docs.discord.com/developers/events/gateway-events#update-presence-status-types
@@ -32,6 +35,17 @@ const TYPES = {
32
35
  * @see https://docs.discord.com/developers/events/gateway-events#activity-object-activity-types
33
36
  */
34
37
  const STREAM_URL = /^https:\/\/(?:www\.)?(?:twitch\.tv|youtube\.com)\//;
38
+ /** How long an activity is shown when the file does not say. */
39
+ export const DEFAULT_FOR = 30_000;
40
+ /**
41
+ * The shortest time an activity may be shown. "Clients may only update
42
+ * their game status 5 times per 20 seconds": one every 5 seconds is
43
+ * always sent at once.
44
+ * @see https://docs.discord.com/developers/events/gateway-events#update-presence
45
+ */
46
+ export const MIN_FOR = 5_000;
47
+ /** The most activities a list may have. */
48
+ const MAX_ACTIVITIES = 100;
35
49
  const list = (words) => words.map(word => `'${word}'`).join(', ');
36
50
  const EXAMPLE = `presence({ status: 'online', activity: { type: 'watching', name: 'over the server' } })`;
37
51
  /**
@@ -44,25 +58,86 @@ export const DEFAULT_PRESENCE = Object.freeze({
44
58
  status: 'online',
45
59
  afk: false,
46
60
  });
47
- /** Turns a checked config into what the gateway sends. */
48
- function toRaw(config) {
61
+ /**
62
+ * What the gateway sends for a status and the activity shown, with its
63
+ * text. `since` and `afk` are about a person's client going idle: a bot is
64
+ * never away from its keyboard.
65
+ */
66
+ export function toRaw(status, activity, name) {
49
67
  const activities = [];
50
- if (config.activity) {
51
- const { type, name, url } = config.activity;
68
+ if (activity) {
69
+ const { type, url } = activity;
52
70
  activities.push(type === 'custom'
53
71
  ? // A custom status shows its `state`; `name` is not shown but required.
54
72
  { name: 'Custom Status', type: TYPES.custom, state: name }
55
73
  : { name, type: TYPES[type], ...(url ? { url } : {}) });
56
74
  }
57
- // `since` and `afk` are about a person's client going idle: a bot is
58
- // never away from its keyboard.
59
- return {
60
- since: null,
61
- activities,
62
- status: config.status ?? 'online',
63
- afk: false,
64
- };
75
+ return { since: null, activities, status, afk: false };
65
76
  }
77
+ /** Checks the text of an activity a function gave, when it ran. */
78
+ export function checkText(name) {
79
+ if (typeof name !== 'string' || name.trim() === '') {
80
+ return fail(`The "name" of the activity is the text shown under the name of the bot: it can't be empty${typeof name === 'string' ? '' : ` (got ${JSON.stringify(name) ?? typeof name})`}.`);
81
+ }
82
+ return name;
83
+ }
84
+ /** Checks one activity: `where` says which one, in messages. */
85
+ function readActivity(activity, where) {
86
+ if (!isRecord(activity)) {
87
+ return fail(`${where} is what the bot is doing, an object: { type: 'playing', name: '/help' }`);
88
+ }
89
+ for (const key of Object.keys(activity)) {
90
+ if (!ACTIVITY_KEYS.includes(key)) {
91
+ fail(`"${key}" is not something an activity has. It can have: ${ACTIVITY_KEYS.join(', ')}.`);
92
+ }
93
+ }
94
+ const { type, name, url } = activity;
95
+ if (typeof type !== 'string' || !(type in TYPES)) {
96
+ fail(`The "type" of ${where} is ${JSON.stringify(type)}: it can be ${list(Object.keys(TYPES))}.`);
97
+ }
98
+ if (typeof name !== 'function')
99
+ checkText(name);
100
+ if (type === 'streaming') {
101
+ if (typeof url !== 'string' || !STREAM_URL.test(url)) {
102
+ fail(`A "streaming" activity needs the "url" of the stream, and Discord only accepts a https://twitch.tv/ or a https://youtube.com/ link${typeof url === 'string' ? ` (got ${JSON.stringify(url)})` : ''}.`);
103
+ }
104
+ }
105
+ else if (url !== undefined) {
106
+ fail(`"url" only goes with a "streaming" activity: a ${JSON.stringify(type)} activity has no link.`);
107
+ }
108
+ let ms = DEFAULT_FOR;
109
+ if (activity.for !== undefined) {
110
+ const parsed = typeof activity.for === 'string' ? parseDuration(activity.for) : null;
111
+ if (parsed === null) {
112
+ fail(`"for" of ${where} is how long it is shown, a duration (${DURATION_EXAMPLE}): got ${JSON.stringify(activity.for) ?? typeof activity.for}.`);
113
+ }
114
+ if (parsed < MIN_FOR) {
115
+ fail(`"for" of ${where} is ${JSON.stringify(activity.for)}: an activity is shown 5 seconds at least, since Discord takes 5 changes of presence per 20 seconds.`);
116
+ }
117
+ ms = parsed;
118
+ }
119
+ return Object.freeze({
120
+ type: type,
121
+ name: name,
122
+ ...(url === undefined ? {} : { url: url }),
123
+ for: ms,
124
+ });
125
+ }
126
+ /**
127
+ * Checks a list of activities: the one of a file, or what its function
128
+ * gave when it ran.
129
+ */
130
+ export function readActivities(value) {
131
+ if (!Array.isArray(value)) {
132
+ return fail(`"activities" is a list of activities, like [{ type: 'playing', name: '/help', for: '30s' }]: got ${JSON.stringify(value) ?? typeof value}.`);
133
+ }
134
+ if (value.length > MAX_ACTIVITIES) {
135
+ fail(`"activities" has ${value.length} activities: ${MAX_ACTIVITIES} at most.`);
136
+ }
137
+ return value.map((activity, index) => readActivity(activity, `activity ${index + 1} of "activities"`));
138
+ }
139
+ /** A function as text, so that the same code saved again is not a change. */
140
+ const codeOf = (value) => typeof value === 'function' ? `ƒ ${String(value)}` : value;
66
141
  /**
67
142
  * The presence of the bot, declared once anywhere in `src/` with
68
143
  * `presence()` and exported.
@@ -82,37 +157,45 @@ export const presenceDeclaration = {
82
157
  fail(`"${key}" is not something a presence has. It can have: ${KEYS.join(', ')}.`);
83
158
  }
84
159
  }
85
- const { status, activity } = config;
86
- if (status !== undefined &&
87
- !STATUSES.includes(status)) {
160
+ const { status = 'online', activity } = config;
161
+ if (!STATUSES.includes(status)) {
88
162
  fail(`"status" is ${JSON.stringify(status)}: it can be ${list(STATUSES)}.`);
89
163
  }
164
+ if (activity !== undefined && config.activities !== undefined) {
165
+ fail(`A presence has "activity" (one) or "activities" (several, shown in turn), not both.`);
166
+ }
167
+ let activities = [];
90
168
  if (activity !== undefined) {
91
169
  if (!isRecord(activity)) {
92
170
  fail(`"activity" is what the bot is doing, an object: activity: { type: 'playing', name: '/help' }`);
93
171
  }
94
- for (const key of Object.keys(activity)) {
95
- if (!ACTIVITY_KEYS.includes(key)) {
96
- fail(`"${key}" is not something an activity has. It can have: ${ACTIVITY_KEYS.join(', ')}.`);
97
- }
98
- }
99
- const { type, name, url } = activity;
100
- if (typeof type !== 'string' || !(type in TYPES)) {
101
- fail(`The "type" of the activity is ${JSON.stringify(type)}: it can be ${list(Object.keys(TYPES))}.`);
102
- }
103
- if (typeof name !== 'string' || name.trim() === '') {
104
- fail(`The "name" of the activity is the text shown under the name of the bot: it can't be empty.`);
105
- }
106
- if (type === 'streaming') {
107
- if (typeof url !== 'string' || !STREAM_URL.test(url)) {
108
- fail(`A "streaming" activity needs the "url" of the stream, and Discord only accepts a https://twitch.tv/ or a https://youtube.com/ link${typeof url === 'string' ? ` (got ${JSON.stringify(url)})` : ''}.`);
109
- }
110
- }
111
- else if (url !== undefined) {
112
- fail(`"url" only goes with a "streaming" activity: a ${JSON.stringify(type)} activity has no link.`);
172
+ activities = [readActivity(activity, 'the activity')];
173
+ }
174
+ else if (typeof config.activities === 'function') {
175
+ activities = config.activities;
176
+ }
177
+ else if (config.activities !== undefined) {
178
+ activities = readActivities(config.activities);
179
+ if (activities.length === 0) {
180
+ fail(`"activities" is empty: give it the activities to show in turn, or leave it out.`);
113
181
  }
114
182
  }
115
- const raw = toRaw(config);
116
- return { raw, key: JSON.stringify(raw) };
183
+ const first = Array.isArray(activities) ? activities[0] : undefined;
184
+ const raw = toRaw(status, first && typeof first.name === 'string' ? first : null, first && typeof first.name === 'string' ? first.name : '');
185
+ const shape = Array.isArray(activities)
186
+ ? activities.map(one => ({ ...one, name: codeOf(one.name) }))
187
+ : codeOf(activities);
188
+ return {
189
+ status: status,
190
+ activities,
191
+ raw,
192
+ // One activity whose text is known: told apart by what it sends, so
193
+ // a presence written another way but sending the same is no change.
194
+ key: Array.isArray(activities) &&
195
+ activities.length <= 1 &&
196
+ activities.every(one => typeof one.name === 'string' && one.for === DEFAULT_FOR)
197
+ ? JSON.stringify(raw)
198
+ : JSON.stringify({ status, activities: shape }),
199
+ };
117
200
  },
118
201
  };