chapterjs 0.2.3 → 0.4.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 +65 -0
- package/dist/cli/build.js +14 -1
- package/dist/cli/cli.js +41 -6
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +49 -7
- package/dist/cli/project.js +103 -19
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +446 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- package/dist/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +17 -0
- package/dist/store/memory-backend.js +82 -0
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +83 -33
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
|
@@ -75,6 +75,10 @@ export function refuse(interaction, key, params) {
|
|
|
75
75
|
* seconds: when `run` takes longer, the framework says the answer is
|
|
76
76
|
* coming. When `run` throws, the person gets a plain answer and the
|
|
77
77
|
* developer the error with its file and line.
|
|
78
|
+
*
|
|
79
|
+
* Resolves once everything is done (the function, and the answer sent for
|
|
80
|
+
* it when it failed): nothing of the bot waits for it, `chapterjs test`
|
|
81
|
+
* does.
|
|
78
82
|
*/
|
|
79
83
|
export function runInteraction(options) {
|
|
80
84
|
const { interaction, file, name, reporter } = options;
|
|
@@ -82,7 +86,7 @@ export function runInteraction(options) {
|
|
|
82
86
|
sleep(reporter.deferAfter ?? 2000, undefined, { signal: stop.signal })
|
|
83
87
|
.then(() => options.defer())
|
|
84
88
|
.catch(() => { });
|
|
85
|
-
new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
|
|
89
|
+
return new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
|
|
86
90
|
.then(() => {
|
|
87
91
|
if (!interaction.answered) {
|
|
88
92
|
reporter.onWarning(file, `${name} ${options.unanswered}`);
|
|
@@ -105,7 +109,7 @@ export function runInteraction(options) {
|
|
|
105
109
|
: interaction.answered
|
|
106
110
|
? interaction.followUp({ content, ephemeral: true })
|
|
107
111
|
: interaction.reply({ content, ephemeral: true });
|
|
108
|
-
tell.
|
|
112
|
+
return tell.then(() => { }, () => { });
|
|
109
113
|
})
|
|
110
114
|
.finally(() => stop.abort());
|
|
111
115
|
}
|
package/dist/loader/hot.js
CHANGED
|
@@ -45,8 +45,15 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
45
45
|
result = nextResolve(specifier, context);
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
|
48
|
-
|
|
48
|
+
// Not found, or a folder (Node refuses those as they are).
|
|
49
|
+
const code = error.code;
|
|
50
|
+
if (!fromProject ||
|
|
51
|
+
(code !== 'ERR_MODULE_NOT_FOUND' &&
|
|
52
|
+
code !== 'ERR_UNSUPPORTED_DIR_IMPORT')) {
|
|
49
53
|
throw error;
|
|
54
|
+
}
|
|
55
|
+
if (EXTENSION.test(specifier))
|
|
56
|
+
throw missing(specifier, false);
|
|
50
57
|
// No extension: look for the file people mean.
|
|
51
58
|
for (const candidate of CANDIDATES) {
|
|
52
59
|
try {
|
|
@@ -58,7 +65,7 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
if (!result)
|
|
61
|
-
throw
|
|
68
|
+
throw missing(specifier, true);
|
|
62
69
|
}
|
|
63
70
|
if (hot && root !== null && result.url.startsWith(root)) {
|
|
64
71
|
return {
|
|
@@ -70,6 +77,19 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
70
77
|
},
|
|
71
78
|
});
|
|
72
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The error for an import of the project that names no file: said in the
|
|
82
|
+
* words of the import, not with the absolute paths Node gives.
|
|
83
|
+
*/
|
|
84
|
+
function missing(specifier, tried) {
|
|
85
|
+
const name = specifier.replace(/\/$/, '').split('/').pop() ?? specifier;
|
|
86
|
+
const looked = tried
|
|
87
|
+
? ` No ${name}.ts, ${name}.js or ${name}/index.ts is there.`
|
|
88
|
+
: '';
|
|
89
|
+
const error = new Error(`'${specifier}' does not exist.${looked} Check the path and the name of the file.`);
|
|
90
|
+
error.code = 'ERR_MODULE_NOT_FOUND';
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
73
93
|
/** Makes the next imports load the files again. */
|
|
74
94
|
export function nextGeneration() {
|
|
75
95
|
generation++;
|
package/dist/loader/loader.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export interface FoundFile {
|
|
|
59
59
|
}
|
|
60
60
|
/** Every source file of `src/`, in a stable order. */
|
|
61
61
|
export declare function listSources(projectDir: string): Promise<FoundFile[]>;
|
|
62
|
+
/**
|
|
63
|
+
* Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
|
|
64
|
+
* what `chapterjs test` runs, and what the bot never loads.
|
|
65
|
+
*/
|
|
66
|
+
export declare function listTests(projectDir: string): Promise<FoundFile[]>;
|
|
62
67
|
/** A file of the project, run: what it exports. */
|
|
63
68
|
export interface LoadedModule {
|
|
64
69
|
/** The path from the project folder: `src/ping.ts`. */
|
|
@@ -72,6 +77,8 @@ export interface LoadedSources {
|
|
|
72
77
|
}
|
|
73
78
|
/** Runs every file of `src/`. A file that fails to run never throws. */
|
|
74
79
|
export declare function loadSources(projectDir: string): Promise<LoadedSources>;
|
|
80
|
+
/** Runs the files given. A file that fails to run never throws. */
|
|
81
|
+
export declare function loadFiles(files: readonly FoundFile[]): Promise<LoadedSources>;
|
|
75
82
|
/**
|
|
76
83
|
* Reads the declarations of one kind from what the files export. Every
|
|
77
84
|
* export is looked at; the ones the kind recognises are read, the others
|
package/dist/loader/loader.js
CHANGED
|
@@ -16,7 +16,7 @@ const DECLARATION = /\.d\.m?ts$/;
|
|
|
16
16
|
/** Tests next to the code: they are for a test runner, not for the bot. */
|
|
17
17
|
const TEST = /\.(?:test|spec)\.[^.]+$/;
|
|
18
18
|
/** The source files of a folder and its subfolders, in a stable order. */
|
|
19
|
-
async function scan(dir) {
|
|
19
|
+
async function scan(dir, tests = false) {
|
|
20
20
|
let entries;
|
|
21
21
|
try {
|
|
22
22
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -30,10 +30,10 @@ async function scan(dir) {
|
|
|
30
30
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
31
31
|
const path = join(dir, entry.name);
|
|
32
32
|
if (entry.isDirectory())
|
|
33
|
-
files.push(...(await scan(path)));
|
|
33
|
+
files.push(...(await scan(path, tests)));
|
|
34
34
|
else if (SOURCE.test(entry.name) &&
|
|
35
35
|
!DECLARATION.test(entry.name) &&
|
|
36
|
-
|
|
36
|
+
TEST.test(entry.name) === tests) {
|
|
37
37
|
files.push(path);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -41,19 +41,29 @@ async function scan(dir) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Every source file of `src/`, in a stable order. */
|
|
43
43
|
export async function listSources(projectDir) {
|
|
44
|
-
|
|
45
|
-
return paths
|
|
46
|
-
.map(path => ({
|
|
47
|
-
file: relative(projectDir, path).split(sep).join('/'),
|
|
48
|
-
path,
|
|
49
|
-
}))
|
|
50
|
-
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
44
|
+
return found(projectDir, await scan(join(projectDir, 'src')));
|
|
51
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
|
|
48
|
+
* what `chapterjs test` runs, and what the bot never loads.
|
|
49
|
+
*/
|
|
50
|
+
export async function listTests(projectDir) {
|
|
51
|
+
return found(projectDir, await scan(join(projectDir, 'src'), true));
|
|
52
|
+
}
|
|
53
|
+
const found = (projectDir, paths) => paths
|
|
54
|
+
.map(path => ({
|
|
55
|
+
file: relative(projectDir, path).split(sep).join('/'),
|
|
56
|
+
path,
|
|
57
|
+
}))
|
|
58
|
+
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
52
59
|
/** Runs every file of `src/`. A file that fails to run never throws. */
|
|
53
60
|
export async function loadSources(projectDir) {
|
|
54
|
-
|
|
61
|
+
return loadFiles(await listSources(projectDir));
|
|
62
|
+
}
|
|
63
|
+
/** Runs the files given. A file that fails to run never throws. */
|
|
64
|
+
export async function loadFiles(files) {
|
|
55
65
|
const result = { modules: [], failed: [] };
|
|
56
|
-
await Promise.all(
|
|
66
|
+
await Promise.all(files.map(async ({ file, path }) => {
|
|
57
67
|
try {
|
|
58
68
|
result.modules.push({ file, exports: await importFile(path) });
|
|
59
69
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Declaration } from '../loader/loader.js';
|
|
2
|
+
import { type StoreStorage } from '../store/backend.js';
|
|
3
|
+
import type { StoreScope } from '../store/store.js';
|
|
4
|
+
import { type RankingOrder } from './ranking.js';
|
|
5
|
+
/** A ranking, checked: what the project keeps of it. */
|
|
6
|
+
export interface LoadedRanking {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly scope: StoreScope;
|
|
9
|
+
readonly storage: StoreStorage;
|
|
10
|
+
readonly order: RankingOrder;
|
|
11
|
+
}
|
|
12
|
+
export declare const rankingDeclaration: Declaration<LoadedRanking>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What a file declares with `ranking()`, checked when it loads, so a wrong
|
|
2
|
+
// scope, storage or order is explained before any score is kept; the
|
|
3
|
+
// ranking is then bound to the name of its export.
|
|
4
|
+
import { STORAGES } from '../store/backend.js';
|
|
5
|
+
import { bindRanking, isRankingDeclaration, rankingStateOf, } from './ranking.js';
|
|
6
|
+
const fail = (message) => {
|
|
7
|
+
throw new TypeError(message);
|
|
8
|
+
};
|
|
9
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
const KEYS = ['scope', 'storage', 'order'];
|
|
11
|
+
const SCOPES = ['guild', 'global'];
|
|
12
|
+
const ORDERS = ['highest', 'lowest'];
|
|
13
|
+
/** A name can name a file in `data/`, like a store's. */
|
|
14
|
+
const NAME = /^[\w.-]+$/;
|
|
15
|
+
export const rankingDeclaration = {
|
|
16
|
+
one: 'ranking',
|
|
17
|
+
many: 'rankings',
|
|
18
|
+
is: isRankingDeclaration,
|
|
19
|
+
list: false,
|
|
20
|
+
read(value, { name }) {
|
|
21
|
+
const config = rankingStateOf(value)?.config;
|
|
22
|
+
if (!isRecord(config)) {
|
|
23
|
+
return fail("ranking() takes an object, or nothing: ranking({ order: 'lowest' }).");
|
|
24
|
+
}
|
|
25
|
+
for (const key of Object.keys(config)) {
|
|
26
|
+
if (!KEYS.includes(key)) {
|
|
27
|
+
fail(`"${key}" is not something a ranking has. It can have: ${KEYS.join(', ')}.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!NAME.test(name)) {
|
|
31
|
+
fail(`"${name}" can't name a ranking: the name of the export is the name of the ranking and of its file in data/, made of letters, digits, _ . and -.`);
|
|
32
|
+
}
|
|
33
|
+
const scope = (config.scope ?? 'guild');
|
|
34
|
+
if (!SCOPES.includes(scope)) {
|
|
35
|
+
fail(`"scope" is 'guild' (a ranking per server, the default) or 'global' (one ranking for the whole bot), got ${JSON.stringify(config.scope)}.`);
|
|
36
|
+
}
|
|
37
|
+
const storage = (config.storage ?? 'file');
|
|
38
|
+
if (!STORAGES.includes(storage)) {
|
|
39
|
+
fail(`"storage" is 'file' (a file in data/, the default) or 'redis' (the Redis of REDIS_URL in .env), got ${JSON.stringify(config.storage)}.`);
|
|
40
|
+
}
|
|
41
|
+
const order = (config.order ?? 'highest');
|
|
42
|
+
if (!ORDERS.includes(order)) {
|
|
43
|
+
fail(`"order" is 'highest' (the highest score first, the default) or 'lowest' (the lowest first), got ${JSON.stringify(config.order)}.`);
|
|
44
|
+
}
|
|
45
|
+
bindRanking(value, { name, scope, storage, order });
|
|
46
|
+
return Object.freeze({ name, scope, storage, order });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Guild } from '../structures/guild.js';
|
|
2
|
+
import { type StoreStorage } from '../store/backend.js';
|
|
3
|
+
import { type RankingOrder } from '../store/ranking-backend.js';
|
|
4
|
+
import type { StoreScope } from '../store/store.js';
|
|
5
|
+
export type { RankingOrder };
|
|
6
|
+
/** What a file gives to `ranking()`. */
|
|
7
|
+
export interface RankingConfig {
|
|
8
|
+
/**
|
|
9
|
+
* `'guild'` (the default): a ranking per server, and a function that
|
|
10
|
+
* runs in a server reads and writes the ranking of that server, with
|
|
11
|
+
* nothing to say. `'global'`: one ranking for the whole bot.
|
|
12
|
+
*/
|
|
13
|
+
scope?: StoreScope;
|
|
14
|
+
/**
|
|
15
|
+
* Where the scores are kept: `'file'` (the default), a file in the
|
|
16
|
+
* `data/` folder of the project, or `'redis'`, the Redis of `REDIS_URL`
|
|
17
|
+
* in `.env`, where a ranking is a sorted set.
|
|
18
|
+
*/
|
|
19
|
+
storage?: StoreStorage;
|
|
20
|
+
/**
|
|
21
|
+
* Which end comes first: `'highest'` (the default), the highest score
|
|
22
|
+
* first, or `'lowest'`, the lowest first (a time, a number of tries).
|
|
23
|
+
*/
|
|
24
|
+
order?: RankingOrder;
|
|
25
|
+
}
|
|
26
|
+
/** A member of a ranking, as `top()` lists them. */
|
|
27
|
+
export interface RankingEntry {
|
|
28
|
+
/** The id given to `add()` or `set()`: a member, or anything else. */
|
|
29
|
+
id: string;
|
|
30
|
+
score: number;
|
|
31
|
+
/** Where it is, from 1. Equal scores are ordered by id. */
|
|
32
|
+
rank: number;
|
|
33
|
+
}
|
|
34
|
+
/** A ranking, for one server or for the whole bot. */
|
|
35
|
+
export interface ScopedRanking {
|
|
36
|
+
/**
|
|
37
|
+
* Adds `points` (negative to take away) to the score of `id`, starting
|
|
38
|
+
* from 0, in one go: nothing added meanwhile is lost, even by another
|
|
39
|
+
* process. Resolves with the new score.
|
|
40
|
+
*/
|
|
41
|
+
add(id: string, points: number): Promise<number>;
|
|
42
|
+
/** Sets the score of `id`, whatever it was. */
|
|
43
|
+
set(id: string, score: number): Promise<void>;
|
|
44
|
+
/** The score of `id`, or `undefined` when it has none. */
|
|
45
|
+
score(id: string): Promise<number | undefined>;
|
|
46
|
+
/** Where `id` is, from 1, or `undefined` when it has no score. */
|
|
47
|
+
rankOf(id: string): Promise<number | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* The first `count` (10 by default) members, after skipping `skip` (0
|
|
50
|
+
* by default): `top(10, 10)` is the second page.
|
|
51
|
+
*/
|
|
52
|
+
top(count?: number, skip?: number): Promise<RankingEntry[]>;
|
|
53
|
+
/** Forgets the score of `id`. `true` when it had one. */
|
|
54
|
+
remove(id: string): Promise<boolean>;
|
|
55
|
+
/** How many members have a score. */
|
|
56
|
+
size(): Promise<number>;
|
|
57
|
+
/** Forgets every score. Resolves with how many there were. */
|
|
58
|
+
clear(): Promise<number>;
|
|
59
|
+
}
|
|
60
|
+
/** What `ranking()` returns: a declaration the framework finds in the exports of a file. */
|
|
61
|
+
export interface Ranking extends ScopedRanking {
|
|
62
|
+
/**
|
|
63
|
+
* The same ranking, for one server: what a task, or a function that
|
|
64
|
+
* runs outside a server, uses to read and write the ranking of that
|
|
65
|
+
* server.
|
|
66
|
+
*/
|
|
67
|
+
in(guild: Guild | string): ScopedRanking;
|
|
68
|
+
}
|
|
69
|
+
/** A ranking as the loader knows it: its config, and once loaded, its name. */
|
|
70
|
+
export interface RankingState {
|
|
71
|
+
readonly config: unknown;
|
|
72
|
+
name?: string;
|
|
73
|
+
scope: StoreScope;
|
|
74
|
+
storage: StoreStorage;
|
|
75
|
+
order: RankingOrder;
|
|
76
|
+
}
|
|
77
|
+
export declare function rankingStateOf(value: unknown): RankingState | undefined;
|
|
78
|
+
export declare const isRankingDeclaration: (value: unknown) => boolean;
|
|
79
|
+
/** Binds a declared ranking to the name of its export. */
|
|
80
|
+
export declare function bindRanking(declared: unknown, bound: {
|
|
81
|
+
name: string;
|
|
82
|
+
scope: StoreScope;
|
|
83
|
+
storage: StoreStorage;
|
|
84
|
+
order: RankingOrder;
|
|
85
|
+
}): void;
|
|
86
|
+
/**
|
|
87
|
+
* Declares a ranking: a score per member, kept in order from one restart
|
|
88
|
+
* to the next. Export the result from any file of `src/`: the name of the
|
|
89
|
+
* export is the name of the ranking. Per server unless `scope: 'global'`
|
|
90
|
+
* is given, the highest score first unless `order: 'lowest'` is.
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { ranking } from 'chapterjs';
|
|
94
|
+
*
|
|
95
|
+
* export const xp = ranking();
|
|
96
|
+
*
|
|
97
|
+
* // In an event of a message, in a server:
|
|
98
|
+
* await xp.add(message.author.id, 10);
|
|
99
|
+
* const best = await xp.top(10);
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare function ranking(config?: RankingConfig): Ranking;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// What a file declares with `ranking()`: scores per member, kept in order,
|
|
2
|
+
// per server by default, from one restart to the next. Named by its
|
|
3
|
+
// export, like a store: `export const xp = ranking()` is the ranking `xp`.
|
|
4
|
+
// Its scores live where its `storage` says, through the rankings of that
|
|
5
|
+
// backend (`store/ranking-backend.ts`): sorted sets in Redis, entries of
|
|
6
|
+
// the store backend anywhere else.
|
|
7
|
+
import { storeBackend } from '../store/backend.js';
|
|
8
|
+
import { rankingsOf, } from '../store/ranking-backend.js';
|
|
9
|
+
import { currentGuildId, guildIdOf, scopeParts } from '../store/scope.js';
|
|
10
|
+
const STATE = Symbol.for('chapterjs.ranking');
|
|
11
|
+
export function rankingStateOf(value) {
|
|
12
|
+
if (typeof value !== 'object' || value === null)
|
|
13
|
+
return undefined;
|
|
14
|
+
return value[STATE];
|
|
15
|
+
}
|
|
16
|
+
export const isRankingDeclaration = (value) => rankingStateOf(value) !== undefined;
|
|
17
|
+
/** Binds a declared ranking to the name of its export. */
|
|
18
|
+
export function bindRanking(declared, bound) {
|
|
19
|
+
const state = rankingStateOf(declared);
|
|
20
|
+
if (!state)
|
|
21
|
+
throw new TypeError('Not a ranking.');
|
|
22
|
+
Object.assign(state, bound);
|
|
23
|
+
}
|
|
24
|
+
const checkId = (id) => {
|
|
25
|
+
if (typeof id !== 'string' || id === '') {
|
|
26
|
+
throw new TypeError(`The id of a member of a ranking is a text that is not empty, got ${JSON.stringify(id) ?? typeof id}. Write an id as a text: member.id, not a number.`);
|
|
27
|
+
}
|
|
28
|
+
return id;
|
|
29
|
+
};
|
|
30
|
+
const checkScore = (value, what) => {
|
|
31
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
32
|
+
throw new TypeError(`${what} is a number, got ${JSON.stringify(value) ?? typeof value}.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const checkCount = (value, what) => {
|
|
37
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
38
|
+
throw new TypeError(`${what} is a whole number, 0 or more, got ${JSON.stringify(value) ?? typeof value}.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
/** The ranking as its functions see it: the backend, the name, the scope. */
|
|
43
|
+
function scoped(state, guildOf) {
|
|
44
|
+
const ready = () => {
|
|
45
|
+
if (!state.name) {
|
|
46
|
+
throw new Error('This ranking was not loaded by ChapterJS: a ranking is exported from a file of src/ (export const name = ranking()), and used once the bot started.');
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
backend: rankingsOf(storeBackend(state.storage, state.name)),
|
|
50
|
+
name: state.name,
|
|
51
|
+
scope: scopeParts('ranking', state.name, state.scope, guildOf),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
async add(id, points) {
|
|
56
|
+
const { backend, name, scope } = ready();
|
|
57
|
+
return backend.add(name, scope, checkId(id), checkScore(points, 'What add() adds'));
|
|
58
|
+
},
|
|
59
|
+
async set(id, score) {
|
|
60
|
+
const { backend, name, scope } = ready();
|
|
61
|
+
await backend.set(name, scope, checkId(id), checkScore(score, 'The score set()'));
|
|
62
|
+
},
|
|
63
|
+
async score(id) {
|
|
64
|
+
const { backend, name, scope } = ready();
|
|
65
|
+
return backend.score(name, scope, checkId(id));
|
|
66
|
+
},
|
|
67
|
+
async rankOf(id) {
|
|
68
|
+
const { backend, name, scope } = ready();
|
|
69
|
+
const rank = await backend.rank(name, scope, checkId(id), state.order);
|
|
70
|
+
return rank === undefined ? undefined : rank + 1;
|
|
71
|
+
},
|
|
72
|
+
async top(count = 10, skip = 0) {
|
|
73
|
+
const { backend, name, scope } = ready();
|
|
74
|
+
checkCount(count, 'How many top() lists');
|
|
75
|
+
checkCount(skip, 'How many top() skips');
|
|
76
|
+
const ranked = await backend.range(name, scope, state.order, skip, count);
|
|
77
|
+
return ranked.map((entry, index) => ({
|
|
78
|
+
id: entry.id,
|
|
79
|
+
score: entry.score,
|
|
80
|
+
rank: skip + index + 1,
|
|
81
|
+
}));
|
|
82
|
+
},
|
|
83
|
+
async remove(id) {
|
|
84
|
+
const { backend, name, scope } = ready();
|
|
85
|
+
return backend.remove(name, scope, checkId(id));
|
|
86
|
+
},
|
|
87
|
+
async size() {
|
|
88
|
+
const { backend, name, scope } = ready();
|
|
89
|
+
return backend.size(name, scope);
|
|
90
|
+
},
|
|
91
|
+
async clear() {
|
|
92
|
+
const { backend, name, scope } = ready();
|
|
93
|
+
return backend.clear(name, scope);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Declares a ranking: a score per member, kept in order from one restart
|
|
99
|
+
* to the next. Export the result from any file of `src/`: the name of the
|
|
100
|
+
* export is the name of the ranking. Per server unless `scope: 'global'`
|
|
101
|
+
* is given, the highest score first unless `order: 'lowest'` is.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* import { ranking } from 'chapterjs';
|
|
105
|
+
*
|
|
106
|
+
* export const xp = ranking();
|
|
107
|
+
*
|
|
108
|
+
* // In an event of a message, in a server:
|
|
109
|
+
* await xp.add(message.author.id, 10);
|
|
110
|
+
* const best = await xp.top(10);
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function ranking(config = {}) {
|
|
114
|
+
const state = {
|
|
115
|
+
config,
|
|
116
|
+
scope: 'guild',
|
|
117
|
+
storage: 'file',
|
|
118
|
+
order: 'highest',
|
|
119
|
+
};
|
|
120
|
+
const declared = {
|
|
121
|
+
...scoped(state, () => currentGuildId() ?? null),
|
|
122
|
+
in: guild => {
|
|
123
|
+
const id = guildIdOf(guild);
|
|
124
|
+
return scoped(state, () => id);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
Object.defineProperty(declared, STATE, { value: state });
|
|
128
|
+
return declared;
|
|
129
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RedisReply } from './protocol.js';
|
|
2
|
+
import { type RedisAddress } from './url.js';
|
|
3
|
+
export interface RedisClientOptions {
|
|
4
|
+
/** The connection could not be made, or was lost: said once per outage. */
|
|
5
|
+
onError?: (message: string) => void;
|
|
6
|
+
/** The connection is back after an outage. */
|
|
7
|
+
onReconnect?: () => void;
|
|
8
|
+
/** How long a connection attempt may take. */
|
|
9
|
+
connectTimeout?: number;
|
|
10
|
+
/** The first wait before trying again, doubled each time up to `maxRetryDelay`. */
|
|
11
|
+
retryDelay?: number;
|
|
12
|
+
maxRetryDelay?: number;
|
|
13
|
+
}
|
|
14
|
+
/** One connection to Redis, which only opens when a command needs it. */
|
|
15
|
+
export declare class RedisClient {
|
|
16
|
+
#private;
|
|
17
|
+
readonly address: RedisAddress;
|
|
18
|
+
constructor(address: RedisAddress, options?: RedisClientOptions);
|
|
19
|
+
/** `host:port` and the database, for messages. */
|
|
20
|
+
get where(): string;
|
|
21
|
+
/** Whether a command sent now goes out at once. */
|
|
22
|
+
get connected(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Sends a command and resolves with its reply. An error reply (`-ERR`)
|
|
25
|
+
* rejects with a `RedisError`; a connection that fails or is lost
|
|
26
|
+
* rejects with what happened, in plain words.
|
|
27
|
+
*/
|
|
28
|
+
send(...args: readonly (string | number)[]): Promise<RedisReply>;
|
|
29
|
+
/**
|
|
30
|
+
* Makes sure Redis answers: the connection is opened, logged in and on
|
|
31
|
+
* the right database, or the reason it can't be is thrown.
|
|
32
|
+
*/
|
|
33
|
+
ping(): Promise<void>;
|
|
34
|
+
/** Ends the connection; every command sent from now on fails. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|