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.
- package/README.md +3 -1
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +3 -3
- package/dist/cli/dev.js +8 -5
- package/dist/cli/project.d.ts +31 -2
- package/dist/cli/project.js +166 -34
- package/dist/cli/redis.js +14 -2
- package/dist/cli/start.js +11 -1
- package/dist/cli/test.js +108 -16
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +19 -10
- package/dist/events/registry.js +10 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -0
- package/dist/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- package/dist/store/file-backend.d.ts +0 -1
- package/dist/store/file-backend.js +18 -1
- package/dist/store/scope.d.ts +6 -1
- package/dist/store/scope.js +9 -3
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/loader/watch.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
* editor often writes a file in several steps,
|
|
9
|
-
*
|
|
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;
|
package/dist/loader/watch.js
CHANGED
|
@@ -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
|
|
24
|
-
* editor often writes a file in several steps,
|
|
25
|
-
*
|
|
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
|
-
|
|
55
|
+
compare();
|
|
38
56
|
}, delay);
|
|
39
57
|
};
|
|
40
|
-
const watcher =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
/**
|
|
48
|
-
|
|
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 (
|
|
51
|
-
const { type,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
116
|
-
|
|
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
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Duration } from '../commands/command.js';
|
|
2
|
+
import type { TranslationContext } from '../messages/messages.js';
|
|
1
3
|
/**
|
|
2
4
|
* The status of the bot: `'online'` (green), `'idle'` (orange), `'dnd'`
|
|
3
5
|
* (red, "do not disturb") or `'invisible'` (shown as offline).
|
|
@@ -9,30 +11,75 @@ export type PresenceStatusName = 'online' | 'idle' | 'dnd' | 'invisible';
|
|
|
9
11
|
* the text itself with `'custom'`.
|
|
10
12
|
*/
|
|
11
13
|
export type ActivityKind = 'playing' | 'listening' | 'watching' | 'competing' | 'custom';
|
|
14
|
+
/**
|
|
15
|
+
* What a presence function receives, each time it runs: how many servers
|
|
16
|
+
* the bot is in, and the texts of the language files in the default
|
|
17
|
+
* language.
|
|
18
|
+
*/
|
|
19
|
+
export type PresenceContext = {
|
|
20
|
+
/** How many servers the bot is in, all processes together. */
|
|
21
|
+
guilds: number;
|
|
22
|
+
} & TranslationContext;
|
|
23
|
+
/**
|
|
24
|
+
* The text of an activity: a text, or a function that gives it (maybe
|
|
25
|
+
* after a promise), run each time the activity is shown.
|
|
26
|
+
*/
|
|
27
|
+
export type ActivityText = string | ((context: PresenceContext) => string | Promise<string>);
|
|
28
|
+
interface ActivityBase {
|
|
29
|
+
/**
|
|
30
|
+
* How long the activity is shown before the next one, at least 5
|
|
31
|
+
* seconds: `'30s'`, `'2m'`. 30 seconds by default. Alone, it is how
|
|
32
|
+
* often a `name` that is a function runs again.
|
|
33
|
+
*/
|
|
34
|
+
for?: Duration;
|
|
35
|
+
}
|
|
12
36
|
/** An activity that is not a stream. */
|
|
13
|
-
export interface PlainActivity {
|
|
37
|
+
export interface PlainActivity extends ActivityBase {
|
|
14
38
|
type: ActivityKind;
|
|
15
39
|
/** What is shown after the type (or alone with `'custom'`). */
|
|
16
|
-
name:
|
|
40
|
+
name: ActivityText;
|
|
17
41
|
url?: never;
|
|
18
42
|
}
|
|
19
43
|
/** A stream: "Streaming {name}", with a link to it. */
|
|
20
|
-
export interface StreamingActivity {
|
|
44
|
+
export interface StreamingActivity extends ActivityBase {
|
|
21
45
|
type: 'streaming';
|
|
22
46
|
/** What is shown after "Streaming". */
|
|
23
|
-
name:
|
|
47
|
+
name: ActivityText;
|
|
24
48
|
/** The stream: Discord only accepts a `https://twitch.tv/` or a `https://youtube.com/` link. */
|
|
25
49
|
url: string;
|
|
26
50
|
}
|
|
27
51
|
/** What the bot is doing. */
|
|
28
52
|
export type Activity = PlainActivity | StreamingActivity;
|
|
29
|
-
/**
|
|
30
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The activities shown in turn: a list, or a function that gives it (maybe
|
|
55
|
+
* after a promise, from a store for example), run again each time the
|
|
56
|
+
* list has been shown in full.
|
|
57
|
+
*/
|
|
58
|
+
export type Activities = readonly Activity[] | ((context: PresenceContext) => readonly Activity[] | Promise<readonly Activity[]>);
|
|
59
|
+
interface PresenceBase {
|
|
31
60
|
/** The status of the bot. `'online'` by default. */
|
|
32
61
|
status?: PresenceStatusName;
|
|
62
|
+
}
|
|
63
|
+
/** A presence with one activity, or none. */
|
|
64
|
+
export interface SinglePresenceConfig extends PresenceBase {
|
|
33
65
|
/** What the bot is doing. Nothing by default. */
|
|
34
66
|
activity?: Activity;
|
|
67
|
+
activities?: never;
|
|
68
|
+
}
|
|
69
|
+
/** A presence whose activities are shown in turn. */
|
|
70
|
+
export interface RotatingPresenceConfig extends PresenceBase {
|
|
71
|
+
activity?: never;
|
|
72
|
+
/**
|
|
73
|
+
* Several activities, shown one after the other, each for its `for`: a
|
|
74
|
+
* list, or a function that gives it.
|
|
75
|
+
*/
|
|
76
|
+
activities: Activities;
|
|
35
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* What a file gives to `presence()`: `activity` for one, or `activities`
|
|
80
|
+
* for several shown in turn.
|
|
81
|
+
*/
|
|
82
|
+
export type PresenceConfig = SinglePresenceConfig | RotatingPresenceConfig;
|
|
36
83
|
/** What `presence()` returns: a declaration the framework finds in the exports of a file. */
|
|
37
84
|
export interface PresenceDeclaration {
|
|
38
85
|
/** What the file gave to `presence()`, not checked yet. */
|
|
@@ -51,7 +98,19 @@ export interface PresenceDeclaration {
|
|
|
51
98
|
* activity: { type: 'watching', name: 'over the server' },
|
|
52
99
|
* });
|
|
53
100
|
* ```
|
|
101
|
+
*
|
|
102
|
+
* Several activities, shown in turn, each for its own time:
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* export default presence({
|
|
106
|
+
* activities: [
|
|
107
|
+
* { type: 'watching', name: 'the tickets', for: '30s' },
|
|
108
|
+
* { type: 'playing', name: ({ guilds }) => `on ${guilds} servers`, for: '1m' },
|
|
109
|
+
* ],
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
54
112
|
*/
|
|
55
113
|
export declare function presence(config: PresenceConfig): PresenceDeclaration;
|
|
56
114
|
/** Whether a value was made by `presence()`. */
|
|
57
115
|
export declare function isPresenceDeclaration(value: unknown): value is PresenceDeclaration;
|
|
116
|
+
export {};
|
|
@@ -15,6 +15,17 @@ const BRAND = Symbol.for('chapterjs.presence');
|
|
|
15
15
|
* activity: { type: 'watching', name: 'over the server' },
|
|
16
16
|
* });
|
|
17
17
|
* ```
|
|
18
|
+
*
|
|
19
|
+
* Several activities, shown in turn, each for its own time:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* export default presence({
|
|
23
|
+
* activities: [
|
|
24
|
+
* { type: 'watching', name: 'the tickets', for: '30s' },
|
|
25
|
+
* { type: 'playing', name: ({ guilds }) => `on ${guilds} servers`, for: '1m' },
|
|
26
|
+
* ],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
18
29
|
*/
|
|
19
30
|
export function presence(config) {
|
|
20
31
|
return Object.freeze({ [BRAND]: true, config });
|