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
@@ -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: string;
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: string;
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
- /** What a file gives to `presence()`. */
30
- export interface PresenceConfig {
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 });
@@ -0,0 +1,32 @@
1
+ import type { RawGatewayPresenceUpdate } from '../discord/types/gateway-events.js';
2
+ import { type LoadedActivity, type LoadedPresence } from './declaration.js';
3
+ import type { PresenceContext } from './presence.js';
4
+ export interface RotationOptions {
5
+ /** Sends a presence to the bot of the moment. */
6
+ apply(raw: RawGatewayPresenceUpdate): void;
7
+ /** What the functions receive, or `null` while there is no bot to show it. */
8
+ context(): PresenceContext | null;
9
+ /** A function of the file threw, or gave something wrong. Said once until it changes. */
10
+ onError(error: unknown): void;
11
+ /** The clock, in milliseconds (tests). */
12
+ now?: () => number;
13
+ }
14
+ /**
15
+ * Which activity of a list the clock says to show, and how long until the
16
+ * next one.
17
+ */
18
+ export declare function activityAt(activities: readonly LoadedActivity[], now: number): {
19
+ index: number;
20
+ left: number;
21
+ };
22
+ /** The presence of the project, shown in turn while it is started. */
23
+ export declare class PresenceRotation {
24
+ #private;
25
+ constructor(options: RotationOptions);
26
+ /** Changes the presence (a load, a reload): shown at once when started. */
27
+ set(presence: LoadedPresence | null): void;
28
+ /** Starts showing the presence. */
29
+ start(): void;
30
+ /** Stops: nothing runs after this. */
31
+ stop(): void;
32
+ }
@@ -0,0 +1,167 @@
1
+ // Shows the activities of the presence in turn, each for its `for`, and
2
+ // runs the functions of the file when their activity comes: the text of an
3
+ // activity, or the list itself.
4
+ //
5
+ // Which activity is shown is read from the clock: the list is a cycle
6
+ // starting at the epoch, so every process of a large bot shows the same
7
+ // activity at the same time, with nothing to coordinate.
8
+ import { checkText, DEFAULT_FOR, DEFAULT_PRESENCE, readActivities, toRaw, } from './declaration.js';
9
+ /** How long to wait for a bot before trying again. */
10
+ const NO_BOT_RETRY = 1_000;
11
+ /**
12
+ * Which activity of a list the clock says to show, and how long until the
13
+ * next one.
14
+ */
15
+ export function activityAt(activities, now) {
16
+ const cycle = activities.reduce((sum, one) => sum + one.for, 0);
17
+ let position = now % cycle;
18
+ for (const [index, activity] of activities.entries()) {
19
+ if (position < activity.for) {
20
+ return { index, left: activity.for - position };
21
+ }
22
+ position -= activity.for;
23
+ }
24
+ // Not reached: the position is below the cycle.
25
+ return { index: 0, left: activities[0].for };
26
+ }
27
+ /** The presence of the project, shown in turn while it is started. */
28
+ export class PresenceRotation {
29
+ #options;
30
+ #now;
31
+ #presence = null;
32
+ #started = false;
33
+ #timer;
34
+ /** Each tick has its number: one started since then makes it give up. */
35
+ #generation = 0;
36
+ /** The list a function gave last, and until when it is used. */
37
+ #list = null;
38
+ #listUntil = 0;
39
+ /** The last text of each activity a function gave, by its place. */
40
+ #texts = new Map();
41
+ /** What was said last of each function (`list`, or the place of an activity). */
42
+ #problems = new Map();
43
+ constructor(options) {
44
+ this.#options = options;
45
+ this.#now = options.now ?? Date.now;
46
+ }
47
+ /** Changes the presence (a load, a reload): shown at once when started. */
48
+ set(presence) {
49
+ this.#presence = presence;
50
+ this.#list = null;
51
+ this.#texts.clear();
52
+ this.#problems.clear();
53
+ if (this.#started)
54
+ void this.#tick();
55
+ }
56
+ /** Starts showing the presence. */
57
+ start() {
58
+ this.#started = true;
59
+ void this.#tick();
60
+ }
61
+ /** Stops: nothing runs after this. */
62
+ stop() {
63
+ this.#started = false;
64
+ this.#generation++;
65
+ clearTimeout(this.#timer);
66
+ }
67
+ /** What is shown right now, as the gateway sends it: one step of the cycle. */
68
+ async #tick() {
69
+ const generation = ++this.#generation;
70
+ clearTimeout(this.#timer);
71
+ const current = () => this.#started && generation === this.#generation;
72
+ const presence = this.#presence;
73
+ if (!presence) {
74
+ this.#options.apply(DEFAULT_PRESENCE);
75
+ return;
76
+ }
77
+ const context = this.#options.context();
78
+ if (!context) {
79
+ this.#later(NO_BOT_RETRY);
80
+ return;
81
+ }
82
+ let activities;
83
+ if (typeof presence.activities === 'function') {
84
+ if (this.#list === null || this.#now() >= this.#listUntil) {
85
+ try {
86
+ const given = await presence.activities(context);
87
+ if (!current())
88
+ return;
89
+ this.#list = readActivities(given);
90
+ this.#texts.clear();
91
+ this.#problems.delete('list');
92
+ }
93
+ catch (error) {
94
+ if (!current())
95
+ return;
96
+ this.#problem('list', error);
97
+ // The last good list stays; none at all shows the status alone.
98
+ this.#list ??= [];
99
+ }
100
+ const cycle = this.#list.reduce((sum, one) => sum + one.for, 0);
101
+ this.#listUntil = this.#now() + (cycle || DEFAULT_FOR);
102
+ }
103
+ activities = this.#list;
104
+ }
105
+ else {
106
+ activities = presence.activities;
107
+ }
108
+ if (activities.length === 0) {
109
+ this.#options.apply(toRaw(presence.status, null, ''));
110
+ // A function may give activities later.
111
+ if (typeof presence.activities === 'function') {
112
+ this.#later(this.#listUntil - this.#now());
113
+ }
114
+ return;
115
+ }
116
+ const { index, left } = activityAt(activities, this.#now());
117
+ const activity = activities[index];
118
+ let text;
119
+ if (typeof activity.name === 'function') {
120
+ try {
121
+ text = checkText(await activity.name(context));
122
+ if (!current())
123
+ return;
124
+ this.#texts.set(index, text);
125
+ this.#problems.delete(`name ${index}`);
126
+ }
127
+ catch (error) {
128
+ if (!current())
129
+ return;
130
+ this.#problem(`name ${index}`, error);
131
+ text = this.#texts.get(index);
132
+ }
133
+ }
134
+ else {
135
+ text = activity.name;
136
+ }
137
+ if (text !== undefined) {
138
+ this.#options.apply(toRaw(presence.status, activity, text));
139
+ }
140
+ const moves = activities.length > 1 ||
141
+ typeof activity.name === 'function' ||
142
+ typeof presence.activities === 'function';
143
+ if (moves) {
144
+ // A list that ends before this activity does is asked again then.
145
+ const until = typeof presence.activities === 'function'
146
+ ? Math.min(left, Math.max(0, this.#listUntil - this.#now()))
147
+ : left;
148
+ this.#later(until);
149
+ }
150
+ }
151
+ #later(ms) {
152
+ if (!this.#started)
153
+ return;
154
+ // At least 1 ms: a list asked again on the very boundary.
155
+ this.#timer = setTimeout(() => void this.#tick(), Math.max(1, ms));
156
+ // Nothing of the bot waits for it: the gateway keeps the process up.
157
+ this.#timer.unref();
158
+ }
159
+ /** Says what went wrong with a function, once until it changes. */
160
+ #problem(what, error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ if (this.#problems.get(what) === message)
163
+ return;
164
+ this.#problems.set(what, message);
165
+ this.#options.onError(error);
166
+ }
167
+ }
@@ -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
+ }