feat: Reminders (#44)

Checklist:

- [x] Suivre les indications de `CONTRIBUTING.md`
- [x] Référence aux tickets (par exemple `Closes #xyz`)

Closes #1

Additional changes:
- use of ChatInputCommandInteraction (part of v14)
- fixed locales progress bars
- updates dependencies
- better handle of errors
- support of modals
- support of buttons

Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Reviewed-on: https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/pulls/44
This commit is contained in:
Anri 2023-01-17 12:15:14 +01:00
parent 9d5c65bf9d
commit 8f096b9589
27 changed files with 3655 additions and 584 deletions

View file

@ -31,7 +31,8 @@
"no-lonely-if": "error", "no-lonely-if": "error",
"no-multi-spaces": "error", "no-multi-spaces": "error",
"no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }],
"no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], "no-shadow": "off",
"@typescript-eslint/no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }],
"no-trailing-spaces": ["error"], "no-trailing-spaces": ["error"],
"no-var": "error", "no-var": "error",
"object-curly-spacing": ["error", "always"], "object-curly-spacing": ["error", "always"],

3
.gitignore vendored
View file

@ -9,3 +9,6 @@ docker-compose.yml
# JS generated files # JS generated files
dist/ dist/
# Databse
*.sqlite3

View file

@ -1,4 +1,5 @@
# Comment contribuer ? <!-- omit in toc --> # Comment contribuer ? <!-- omit in toc -->
Ce guide contient méthodes et conseils sur comment aider le projet. Ce guide contient méthodes et conseils sur comment aider le projet.
Lisez attentivement si vous êtes un nouveau contributeur. Lisez attentivement si vous êtes un nouveau contributeur.
@ -9,6 +10,7 @@ d'un [ticket](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/issues) ou
une [Pull Request](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/pulls). une [Pull Request](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/pulls).
## Sommaire <!-- omit in toc --> ## Sommaire <!-- omit in toc -->
- [Recevoir de l'aide](#recevoir-de-laide) - [Recevoir de l'aide](#recevoir-de-laide)
- [Langues](#langues) - [Langues](#langues)
- [Ajouter une langue](#ajouter-une-langue) - [Ajouter une langue](#ajouter-une-langue)
@ -16,15 +18,19 @@ une [Pull Request](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/pulls).
- [Projet](#projet) - [Projet](#projet)
- [Ajouter une commande](#ajouter-une-commande) - [Ajouter une commande](#ajouter-une-commande)
- [Ajouter un évènement](#ajouter-un-évènement) - [Ajouter un évènement](#ajouter-un-évènement)
- [Modèles](#modèles)
- [Boutons](#boutons)
- [Modifier du code](#modifier-du-code) - [Modifier du code](#modifier-du-code)
- [Soumettre ses modifications](#soumettre-ses-modifications) - [Soumettre ses modifications](#soumettre-ses-modifications)
- [Gestion du dépôt](#gestion-du-dépôt) - [Gestion du dépôt](#gestion-du-dépôt)
## Recevoir de l'aide ## Recevoir de l'aide
Si tu as besoin d'aide, tu peux poser ta question sur Si tu as besoin d'aide, tu peux poser ta question sur
le [Discord](https://discord.gg/Z5ePxH4). le [Discord](https://discord.gg/Z5ePxH4).
## Langues ## Langues
La langue par défaut est définie dans La langue par défaut est définie dans
[`src/utils/client.ts`](src/utils/client.ts) dans `client.config.default_lang`. [`src/utils/client.ts`](src/utils/client.ts) dans `client.config.default_lang`.
@ -33,13 +39,21 @@ traduction est incomplète. On part donc du postulat que la langue par défaut
contient toujours toutes les chaînes de caractère dont le bot a besoin. contient toujours toutes les chaînes de caractère dont le bot a besoin.
La norme pour les nom dans les fichiers est la suivante : La norme pour les nom dans les fichiers est la suivante :
- Chaîne de charactère des commandes : - Chaîne de charactère des commandes :
`c` est utilisé pour `C`ommande. `c` est utilisé pour `C`ommande.
- `c_NOM-COMMANDE_name` : Nom de la commande - `c_NOM-COMMANDE_name` : Nom de la commande
- `c_NOM-COMMANDE_desc` : Description de la commande - `c_NOM-COMMANDE_desc` : Description de la commande
- `c_NOM-COMMANDE_optX_name` : Nom de l'option X - `c_NOM-COMMANDE_optX_name` : Nom de l'option X
- `c_NOM-COMMANDE_optX_desc` : Description de l'option X - `c_NOM-COMMANDE_optX_desc` : Description de l'option X
- `c_NOM-COMMANDE_subX_name` : Nom de la sous-commande X
- `c_NOM-COMMANDE_subX_desc` : Description de la sous-commande X
- `c_NOM-COMMANDEX` : `X` le numéro de la chaîne de caractère - `c_NOM-COMMANDEX` : `X` le numéro de la chaîne de caractère
Évidemment ça peut s'additionner,
par exemple : `c_NOM-COMMANDE_subX_optX_desc`.
- Chaîne de charactère des évènements : - Chaîne de charactère des évènements :
`e` est utilisé pour `E`vènements. `e` est utilisé pour `E`vènements.
- `e_NOM-EVENEMENT_N` : `N` le nom de la chaîne de caractère - `e_NOM-EVENEMENT_N` : `N` le nom de la chaîne de caractère
@ -48,6 +62,7 @@ La norme pour les nom dans les fichiers est la suivante :
- `u_NOM-FICHIER-UTILS_N` : `N` le nom de la chaîne de caractère - `u_NOM-FICHIER-UTILS_N` : `N` le nom de la chaîne de caractère
### Ajouter une langue ### Ajouter une langue
1. Créer un nouveau fichier dans [src/locales/](./src/locales/), le fichier 1. Créer un nouveau fichier dans [src/locales/](./src/locales/), le fichier
doit être nommé `langue.json` avec `langue` suivant doit être nommé `langue.json` avec `langue` suivant
[cette liste](https://discord.com/developers/docs/reference#locales). [cette liste](https://discord.com/developers/docs/reference#locales).
@ -60,14 +75,17 @@ doit être nommé `langue.json` avec `langue` suivant
4. Une fois terminée, [ouvrez une Pull Request](#soumettre-ses-modifications). 4. Une fois terminée, [ouvrez une Pull Request](#soumettre-ses-modifications).
### Mettre à jour une langue ### Mettre à jour une langue
1. Rechercher la langue dans le dossier [src/locales/](./src/locales/). 1. Rechercher la langue dans le dossier [src/locales/](./src/locales/).
2. Modifier/Ajouter des traductions comme 2. Modifier/Ajouter des traductions comme
[expliquer au dessus](#ajouter-une-langue) (à partir du `3.`). [expliquer au dessus](#ajouter-une-langue) (à partir du `3.`).
> Pensez à vérifier si de nouvelles valeurs n'ont pas été ajouté dans > Pensez à vérifier si de nouvelles valeurs n'ont pas été ajouté dans
le fichier langue par défaut, [cf. au dessus](#langues). > le fichier langue par défaut, [cf. au dessus](#langues).
## Projet ## Projet
Le code se trouve dans le dosier [src/](./src/). Dans ce dossier il y a : Le code se trouve dans le dosier [src/](./src/). Dans ce dossier il y a :
- [commands/](./src/commands/) qui contient toutes les commandes, rangés par - [commands/](./src/commands/) qui contient toutes les commandes, rangés par
catégories catégories
- [events/](./src/events/) qui contient tous les évènements, rangés par - [events/](./src/events/) qui contient tous les évènements, rangés par
@ -84,39 +102,48 @@ contiennent chaquin un fichier `loader.js` qui charge respectivement
les commandes et les évènements dans le bot. les commandes et les évènements dans le bot.
## Ajouter une commande ## Ajouter une commande
Pour ajouter une commande au bot, créez un fichier `nom-de-la-commande.ts` dans Pour ajouter une commande au bot, créez un fichier `nom-de-la-commande.ts` dans
un sous dossier de [`src/commands/`](./src/commands/). Vous pouvez créer une un sous dossier de [`src/commands/`](./src/commands/). Vous pouvez créer une
nouvelle catégorie si votre commande n'entre dans aucune qui existe déjà. nouvelle catégorie si votre commande n'entre dans aucune qui existe déjà.
Le contenu du fichier doit commencer comme suit : Le contenu du fichier doit commencer comme suit :
```typescript ```typescript
import { SlashCommandBuilder } from '@discordjs/builders'; import { SlashCommandBuilder } from "@discordjs/builders";
import { Client, CommandInteraction } from 'discord.js'; import { Client, ChatInputCommandInteraction } from "discord.js";
import { getLocale, getLocalizations } from '../../utils/locales'; import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from '../../utils/misc'; import { getFilename } from "../../utils/misc";
export default { export default {
data: (client: Client) => { data: (client: Client) => {
const filename = getFilename(__filename); const filename = getFilename(__filename);
return new SlashCommandBuilder() return new SlashCommandBuilder()
.setName( .setName(filename.toLowerCase())
filename.toLowerCase()) .setDescription(
.setDescription(client.locales.get(client.config.default_lang) client.locales
?.get(`c_${filename}_desc`) ?? '') .get(client.config.default_lang)
?.get(`c_${filename}_desc`) ?? ""
)
.setNameLocalizations( .setNameLocalizations(
getLocalizations(client, `c_${filename}_name`, true)) getLocalizations(client, `c_${filename}_name`, true)
)
.setDescriptionLocalizations( .setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_desc`) getLocalizations(client, `c_${filename}_desc`)
); );
}, },
interaction: async (interaction: CommandInteraction, client: Client) => { interaction: async (
interaction: ChatInputCommandInteraction,
client: Client
) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
/* Votre code ici */ /* Votre code ici */
}, },
}; };
``` ```
Ce template vous permet de commencé rapidement votre commande car il contient Ce template vous permet de commencé rapidement votre commande car il contient
déjà tout ce qu'il faut pour le support des langues. Pensez bien à ne pas écrire déjà tout ce qu'il faut pour le support des langues. Pensez bien à ne pas écrire
directement vos chaînes de caractères ici mais bien dans directement vos chaînes de caractères ici mais bien dans
@ -124,18 +151,21 @@ les [fichiers de langues](./src/locales/), c'est à ça que la variable
`loc` sert. `loc` sert.
Vous devez aussi ajouter **obligatoirement** : Vous devez aussi ajouter **obligatoirement** :
- `"c_COMMANDE_name": "NOM"` au fichier de langue, avec `COMMANDE` le nom de - `"c_COMMANDE_name": "NOM"` au fichier de langue, avec `COMMANDE` le nom de
la commande et `NOM` le nom de votre commande. la commande et `NOM` le nom de votre commande.
- `"c_COMMANDE_desc": "DESCRIPTION"` au fichier de langue, avec `COMMANDE` - `"c_COMMANDE_desc": "DESCRIPTION"` au fichier de langue, avec `COMMANDE`
le nom de la commande et `DESCRIPTION` la description de votre commande. le nom de la commande et `DESCRIPTION` la description de votre commande.
## Ajouter un évènement ## Ajouter un évènement
Pour ajouter le support d'un évènement au bot, créez un fichier Pour ajouter le support d'un évènement au bot, créez un fichier
`nom-evenement.ts` dans un sous dossier de [`src/events/`](./src/events/). Vous `nom-evenement.ts` dans un sous dossier de [`src/events/`](./src/events/). Vous
pouvez créer une nouvelle catégorie si votre commande n'entre dans aucune qui pouvez créer une nouvelle catégorie si votre commande n'entre dans aucune qui
existe déjà. existe déjà.
Vous pouvez préciser que l'évènement ne sera déclenché qu'une seule fois avec Vous pouvez préciser que l'évènement ne sera déclenché qu'une seule fois avec
```typescript ```typescript
export const once = true; export const once = true;
``` ```
@ -144,7 +174,22 @@ De préférence, merci de mettre un lien en commentaire vers la documentation
de discord.js de l'évènement de discord.js de l'évènement
([exemple ici pour l'évènement `ready`](./src/events/client/ready.ts#L3)) ([exemple ici pour l'évènement `ready`](./src/events/client/ready.ts#L3))
## Modèles
Les modèles sont gérés [en dehors séparément du reste](./src/modals/).
## Boutons
Les boutons sont gérés [en dehors séparément du reste](./src/buttons/)
Chaque bouton à une implémentation séparée des autres, même si ils sont dans le
même message.
Contrairement aux autres éléments, les boutons doivent se faire collecter via
la fonction [`collect`](./src/buttons/loader.ts#L46) juste après leur déclaration.
## Modifier du code ## Modifier du code
Quand vous modifiez quelque chose, pensez à mettre-à-jour les langues. Si vous Quand vous modifiez quelque chose, pensez à mettre-à-jour les langues. Si vous
ne savez pas traduire dans une langue, ne vous forcez pas, supprimer simplement ne savez pas traduire dans une langue, ne vous forcez pas, supprimer simplement
la traduction. la traduction.
@ -159,6 +204,7 @@ Pour commencer, vous pouvez jeté un oeil aux
en anglais, ainsi que les commits afin que chacun puisse contribuer. en anglais, ainsi que les commits afin que chacun puisse contribuer.
## Soumettre ses modifications ## Soumettre ses modifications
1. Pensez à bien commenter votre code (en anglais) pour que n'importe qui 1. Pensez à bien commenter votre code (en anglais) pour que n'importe qui
comprennent vos modifications. Vérifier bien dans tout les fichiers si ce que comprennent vos modifications. Vérifier bien dans tout les fichiers si ce que
vous avez modifié n'est pas référencer ailleurs (exemple : si vous modifier vous avez modifié n'est pas référencer ailleurs (exemple : si vous modifier
@ -187,8 +233,9 @@ afin que votre code puisse être revu et fusionné. Vous pouvez suivre cette
> supprimé. > supprimé.
## Gestion du dépôt ## Gestion du dépôt
- On ne push jamais directement sur la branche `main`. - On ne push jamais directement sur la branche `main`.
- Quand on merge des modifications vers `main`, on fait un *squash*, - Quand on merge des modifications vers `main`, on fait un _squash_,
l'historique des modifications reste disponible dans l'historique des modifications reste disponible dans
[le graph](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/graph). [le graph](https://git.kennel.ml/ConfrerieDuKassoulait/Botanique/graph).
- De préférences, suivre les indications de - De préférences, suivre les indications de

View file

@ -1,6 +1,10 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:16.15.0-alpine3.15 FROM node:19.4.0-alpine3.16
ENV DOCKERIZED=1
RUN mkdir /config
RUN chown node:node /config
RUN apk add dumb-init RUN apk add dumb-init
ENV NODE_ENV=production ENV NODE_ENV=production
@ -13,5 +17,6 @@ RUN npm ci --only=production
RUN npx tsc RUN npx tsc
RUN rm -r src/ tsconfig.json RUN rm -r src/ tsconfig.json
RUN npm uninstall typescript discord-api-types @types/sqlite3
CMD ["dumb-init", "node", "./dist/index.js"] CMD ["dumb-init", "node", "./dist/index.js"]

View file

@ -28,6 +28,8 @@ services:
container_name: Botanique container_name: Botanique
environment: environment:
- TOKEN_DISCORD=ton-token-va-ici - TOKEN_DISCORD=ton-token-va-ici
volumes:
- /here/your/path:/config
restart: unless-stopped restart: unless-stopped
``` ```
@ -37,6 +39,11 @@ services:
| TOKEN_DISCORD | Token Discord | Aucune | | TOKEN_DISCORD | Token Discord | Aucune |
| DEFAULT_LANG | Langue par défaut | `fr` | Expérimental, si la langue par défaut n'est pas complète (càd 100%), le bot pourrait ne pas fonctionner correctement.<br>Liste des traductions disponibles [ici](./src/locales/). | DEFAULT_LANG | Langue par défaut | `fr` | Expérimental, si la langue par défaut n'est pas complète (càd 100%), le bot pourrait ne pas fonctionner correctement.<br>Liste des traductions disponibles [ici](./src/locales/).
## Volumes
| Chemin | Description
| :-------: | :-:
| `/config` | Dossier de configuration, par exemple, c'est ici que la base de donnée est.
# Contribuer # Contribuer
Toute contribution est la bienvenue ! Toute contribution est la bienvenue !

2733
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,16 @@
"author": "La confrérie du Kassoulait", "author": "La confrérie du Kassoulait",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@discordjs/rest": "^1.0.1", "@discordjs/rest": "^1.1.0",
"discord-api-types": "^0.36.0", "@types/sqlite3": "^3.1.8",
"discord.js": "^14.0.3", "discord-api-types": "^0.36.3",
"typescript": "^4.7.4" "discord.js": "^14.3.0",
"sqlite3": "^5.0.11",
"typescript": "^4.7.4",
"uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7", "@typescript-eslint/parser": "^5.30.7",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",

68
src/buttons/loader.ts Normal file
View file

@ -0,0 +1,68 @@
import { readdir } from 'fs/promises';
import { removeExtension } from '../utils/misc';
import { ChatInputCommandInteraction, Client, MessageComponentInteraction } from 'discord.js';
import { getLocale } from '../utils/locales';
export default async (client: Client) => {
// Dossier des buttons
const buttons_categories = (await readdir(__dirname))
.filter(element => !element.endsWith('.js') && !element.endsWith('.ts'));
await Promise.all(
// For each categorie
buttons_categories.map(async buttons_category => {
// Retrieve all the commands
const button_files = await readdir(`${__dirname}/${buttons_category}`);
// Add the category to the collection for the help command
client.buttons.categories.set(
buttons_category,
button_files.map(removeExtension),
);
// Add the button
return Promise.all(
button_files.map(async button_file => {
const button = (
await import(`../buttons/${buttons_category}/${button_file}`)
).default;
// Add it to the collection so the interaction will work
client.buttons.list.set(button.data.name, button);
return button.data;
}),
);
}),
);
};
/**
* Collect interactions for buttons
* @param client Client
* @param interaction Chat interaction
* @param id Button ID
* @param deferUpdate defer update in case update take time
*/
export const collect = (client: Client, interaction: ChatInputCommandInteraction | MessageComponentInteraction, id: string, deferUpdate = false) => {
const loc = getLocale(client, interaction.locale);
const button = client.buttons.list.get(id.split('_')[0]);
if (!button) {
interaction.reply({
content: loc.get('e_interacreate_no_button'),
ephemeral: true,
});
}
const filter = (i: MessageComponentInteraction) => i.customId === id;
const collector = interaction.channel?.createMessageComponentCollector({ filter, max: 1 });
collector?.on('collect', async (i) => {
if (deferUpdate) {
await i.deferUpdate();
}
const msg = await button?.interaction(i, client);
if (msg !== undefined) {
await i.update(msg);
}
});
};

View file

@ -0,0 +1,56 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, MessageComponentInteraction, User } from 'discord.js';
import { v4 as uuidv4 } from 'uuid';
import { getLocale } from '../../utils/locales';
import { getFilename } from '../../utils/misc';
import { embedListReminders } from '../../utils/reminder';
import { collect } from '../loader';
export default {
data: {
name: getFilename(__filename),
},
interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.description as string;
// Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]);
if (page + 1 > pageMax) {
page = 1;
} else {
page++;
}
// Retrieve user
const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc)?.[0] as string;
const user = client.users.cache.get(userId) as User;
// Fetch list
const list = await embedListReminders(client, user, interaction.guildId, page, interaction.locale);
const idPrec = 'reminderList-prec_' + uuidv4();
const idNext = 'reminderList-next_' + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(idPrec)
.setLabel(loc.get('c_reminder12'))
.setStyle(ButtonStyle.Primary))
.addComponents(
new ButtonBuilder()
.setCustomId(idNext)
.setLabel(loc.get('c_reminder13'))
.setStyle(ButtonStyle.Primary),
);
// Buttons interactions
collect(client, interaction, idPrec);
collect(client, interaction, idNext);
return {
embeds: [list],
components: [row],
};
},
};

View file

@ -0,0 +1,56 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, MessageComponentInteraction, User } from 'discord.js';
import { v4 as uuidv4 } from 'uuid';
import { getLocale } from '../../utils/locales';
import { getFilename } from '../../utils/misc';
import { embedListReminders } from '../../utils/reminder';
import { collect } from '../loader';
export default {
data: {
name: getFilename(__filename),
},
interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.description as string;
// Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]);
if (page - 1 == 0) {
page = pageMax;
} else {
page--;
}
// Retrieve user
const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc)?.[0] as string;
const user = client.users.cache.get(userId) as User;
// Fetch list
const list = await embedListReminders(client, user, interaction.guildId, page, interaction.locale);
const idPrec = 'reminderList-prec_' + uuidv4();
const idNext = 'reminderList-next_' + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(idPrec)
.setLabel(loc.get('c_reminder12'))
.setStyle(ButtonStyle.Primary))
.addComponents(
new ButtonBuilder()
.setCustomId(idNext)
.setLabel(loc.get('c_reminder13'))
.setStyle(ButtonStyle.Primary),
);
// Buttons interactions
collect(client, interaction, idPrec);
collect(client, interaction, idNext);
return {
embeds: [list],
components: [row],
};
},
};

View file

@ -1,6 +1,6 @@
import { SlashCommandBuilder } from '@discordjs/builders'; import { SlashCommandBuilder } from '@discordjs/builders';
import { Locale } from 'discord-api-types/v9'; import { Locale } from 'discord-api-types/v9';
import { Client, CommandInteraction, EmbedBuilder } from 'discord.js'; import { Client, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { getLocale, getLocalizations } from '../../utils/locales'; import { getLocale, getLocalizations } from '../../utils/locales';
import { getFilename } from '../../utils/misc'; import { getFilename } from '../../utils/misc';
import '../../modules/string'; import '../../modules/string';
@ -31,12 +31,12 @@ export default {
); );
}, },
interaction: async (interaction: CommandInteraction, client: Client) => { interaction: async (interaction: ChatInputCommandInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const desired_command = interaction.options.get(client const desired_command = interaction.options.getString(client
.locales .locales
.get(client.config.default_lang) .get(client.config.default_lang)
?.get(`c_${getFilename(__filename)}_opt1_name`) ?? '')?.value as string; ?.get(`c_${getFilename(__filename)}_opt1_name`) ?? '');
// If a command isn't specified // If a command isn't specified
if (!desired_command) { if (!desired_command) {

View file

@ -1,5 +1,5 @@
import { SlashCommandBuilder } from '@discordjs/builders'; import { SlashCommandBuilder } from '@discordjs/builders';
import { Client, CommandInteraction, Message } from 'discord.js'; import { ChatInputCommandInteraction, Client, Message } from 'discord.js';
import { getLocale, getLocalizations } from '../../utils/locales'; import { getLocale, getLocalizations } from '../../utils/locales';
import { getFilename } from '../../utils/misc'; import { getFilename } from '../../utils/misc';
@ -18,7 +18,7 @@ export default {
); );
}, },
interaction: async (interaction: CommandInteraction, client: Client) => { interaction: async (interaction: ChatInputCommandInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const sent = await interaction.reply({ const sent = await interaction.reply({

View file

@ -0,0 +1,275 @@
import { ModalActionRowComponentBuilder, SlashCommandBuilder } from '@discordjs/builders';
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
import { v4 as uuidv4 } from 'uuid';
import { collect } from '../../buttons/loader';
import { getLocale, getLocalizations } from '../../utils/locales';
import { getFilename } from '../../utils/misc';
import { checkOwnershipReminder, deleteReminder, embedListReminders, getReminderInfo, newReminder } from '../../utils/reminder';
export default {
data: (client: Client) => {
const filename = getFilename(__filename);
const loc_default = client.locales.get(client.config.default_lang);
if (!loc_default) {
return;
}
return new SlashCommandBuilder()
// Command
.setName(filename.toLowerCase())
.setDescription(loc_default.get(`c_${filename}_desc`) ?? '')
.setNameLocalizations(
getLocalizations(client, `c_${filename}_name`, true)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_desc`)
)
// New reminder
.addSubcommand(subcommand => subcommand
.setName(
loc_default.get(`c_${filename}_sub1_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub1_desc`) ?? ''
).setNameLocalizations(
getLocalizations(client, `c_${filename}_sub1_name`, true)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub1_desc`)
)
// Specified Time
.addStringOption(option => option
.setName(
loc_default.get(`c_${filename}_sub1_opt1_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub1_opt1_desc`) ?? ''
).setNameLocalizations(
getLocalizations(
client,
`c_${filename}_sub1_opt1_name`,
true
)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub1_opt1_desc`)
)
)
// Specified message (not required)
.addStringOption(option => option
.setName(
loc_default.get(`c_${filename}_sub1_opt2_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub1_opt2_desc`) ?? ''
).setNameLocalizations(
getLocalizations(
client,
`c_${filename}_sub1_opt2_name`,
true
)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub1_opt2_desc`)
)
)
)
// List reminders
.addSubcommand(subcommand => subcommand
.setName(
loc_default.get(`c_${filename}_sub2_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub2_desc`) ?? ''
).setNameLocalizations(
getLocalizations(client, `c_${filename}_sub2_name`, true)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub2_desc`)
)
// User
.addUserOption(option => option
.setName(
loc_default.get(`c_${filename}_sub2_opt1_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub2_opt1_desc`) ?? ''
).setNameLocalizations(
getLocalizations(
client,
`c_${filename}_sub2_opt1_name`,
true
)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub2_opt1_desc`)
)
)
// Page
.addIntegerOption(option => option
.setName(
loc_default.get(`c_${filename}_sub2_opt2_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub2_opt2_desc`) ?? ''
).setNameLocalizations(
getLocalizations(
client,
`c_${filename}_sub2_opt2_name`,
true
)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub2_opt2_desc`)
)
)
)
// Delete a reminder
.addSubcommand(subcommand => subcommand
.setName(
loc_default.get(`c_${filename}_sub3_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub3_desc`) ?? ''
).setNameLocalizations(
getLocalizations(client, `c_${filename}_sub3_name`, true)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub3_desc`)
)
// ID
.addIntegerOption(option => option
.setName(
loc_default.get(`c_${filename}_sub3_opt1_name`)
?.toLowerCase() ?? ''
).setDescription(
loc_default.get(`c_${filename}_sub3_opt1_desc`) ?? ''
).setNameLocalizations(
getLocalizations(
client,
`c_${filename}_sub3_opt1_name`,
true
)
).setDescriptionLocalizations(
getLocalizations(client, `c_${filename}_sub3_opt1_desc`)
).setRequired(true)
),
);
},
interaction: async (interaction: ChatInputCommandInteraction, client: Client) => {
const loc_default = client.locales.get(client.config.default_lang);
const filename = getFilename(__filename);
const loc = getLocale(client, interaction.locale);
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
// New reminder
case loc_default?.get(`c_${filename}_sub1_name`)
?.toLowerCase() ?? '': {
// If time is already renseigned
const time = interaction.options.getString(loc_default?.get(`c_${filename}_sub1_opt1_name`) as string);
if (time != null) {
// Use the cli because we already have enough data
return newReminder(client, time, {
locale: interaction.locale,
message: interaction.options.getString(loc_default?.get(`c_${filename}_sub1_opt2_name`) as string),
createdAt: interaction.createdAt.getTime(),
channelId: interaction.channelId,
userId: interaction.user.id,
guildId: interaction.guildId,
}).then((msg) => interaction.reply({
content: msg as string,
ephemeral: true,
}));
} else {
// Show modal to user to get at least the time
const modal = new ModalBuilder()
.setCustomId('reminderGUI')
.setTitle(loc.get(`c_${filename}_name`).capitalize());
const timeGUI = new TextInputBuilder()
.setCustomId('reminderGUI-time')
.setLabel(loc.get(`c_${filename}_sub1_opt1_name`).capitalize())
.setStyle(TextInputStyle.Short)
.setPlaceholder('1h')
.setRequired(true);
const messageGUI = new TextInputBuilder()
.setCustomId('reminderGUI-message')
.setLabel(loc.get(`c_${filename}_sub1_opt2_name`).capitalize())
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(loc.get(`c_${filename}_sub1_opt2_desc`))
.setRequired(false);
modal.addComponents(
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(timeGUI),
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(messageGUI)
);
return interaction.showModal(modal);
}
}
// List reminders
case loc_default?.get(`c_${filename}_sub2_name`)
?.toLowerCase() ?? '': {
// Which user to show
let user = interaction.options.getUser(loc_default?.get(`c_${filename}_sub2_opt1_name`) as string);
if (user == null) {
user = interaction.user;
}
const page = interaction.options.getInteger(loc_default?.get(`c_${filename}_sub2_opt2_name`) as string) ?? 1;
const list = await embedListReminders(client, user, interaction.guildId, page, interaction.locale);
const idPrec = 'reminderList-prec_' + uuidv4();
const idNext = 'reminderList-next_' + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(idPrec)
.setLabel(loc.get(`c_${filename}12`))
.setStyle(ButtonStyle.Primary))
.addComponents(
new ButtonBuilder()
.setCustomId(idNext)
.setLabel(loc.get(`c_${filename}13`))
.setStyle(ButtonStyle.Primary),
);
// Buttons interactions
collect(client, interaction, idPrec);
collect(client, interaction, idNext);
return await interaction.reply({ ephemeral: true, embeds: [list], components: [row] });
}
// Delete a reminder
case loc_default?.get(`c_${filename}_sub3_name`)
?.toLowerCase() ?? '': {
const id = interaction.options.getInteger(loc_default?.get(`c_${filename}_sub3_opt1_name`) as string);
if (id === null) {
return interaction.reply({ content: loc.get(`c_${filename}2`), ephemeral: true });
}
// Check if the ID exists and belongs to the user
if (await checkOwnershipReminder(client, id, interaction.user.id, interaction.guildId ?? '0')) {
return interaction.reply({ content: loc.get(`c_${filename}3`), ephemeral: true });
}
// Stop timeout
const reminderInfo = await getReminderInfo(client, id);
clearTimeout(reminderInfo.timeout_id);
// Delete from database
return deleteReminder(client, reminderInfo.creation_date, reminderInfo.user_id)
.then(() => interaction.reply({ content: `Reminder **#${id}** supprimé !`, ephemeral: true }));
}
default: {
console.error(`${__filename}: unknown subcommand (${subcommand})`);
break;
}
}
},
};

View file

@ -1,6 +1,59 @@
import { Client } from 'discord.js';
import { logStart } from '../../utils/misc';
import { dbReminder, deleteReminder, infoReminder, OptionReminder, sendReminder, setTimeoutReminder, updateReminder } from '../../utils/reminder';
export const once = true; export const once = true;
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-ready */ /** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-ready */
export default async () => { export default async (client: Client) => {
console.log('Connected to Discord!'); console.log(logStart('Connection', true));
// Restart all the timeout about reminders here
new Promise((ok, ko) => {
// Fetch all reminders
client.db.all('SELECT * FROM reminder', [], (err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders
ok(row);
});
}).then((data) => {
const now = Date.now();
(data as dbReminder[]).forEach((element) => {
const info = {
locale: element.locale,
message: element.data,
createdAt: Number(element.creation_date),
channelId: `${element.channel_id}`,
userId: `${element.user_id}`,
guildId: `${element.guild_id}`,
} as infoReminder;
if (element.expiration_date <= now) {
// Reminder expired
deleteReminder(client, element.creation_date, `${element.user_id}`).then((res) => {
if (res != true) {
throw res;
}
});
sendReminder(client, info, element.option_id as OptionReminder);
} else {
// Restart timeout
const timeoutId = setTimeoutReminder(client, info, element.option_id, (element.expiration_date - now) / 1000);
// Update timeout in database
element.timeout_id = String(timeoutId);
updateReminder(client, element).then((res) => {
if (res != true) {
throw res;
}
});
}
});
}).catch(err => {
throw err;
});
}; };

View file

@ -3,10 +3,11 @@ import { getLocale } from '../../utils/locales';
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-interactionCreate */ /** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-interactionCreate */
export default (interaction: Interaction, client: Client) => { export default (interaction: Interaction, client: Client) => {
if (interaction.type === InteractionType.ApplicationCommand) { const loc = getLocale(client, interaction.locale);
switch (interaction.type) {
case InteractionType.ApplicationCommand: {
const command = client.commands.list.get(interaction.commandName); const command = client.commands.list.get(interaction.commandName);
if (!command) { if (!command) {
const loc = getLocale(client, interaction.locale);
return interaction.reply({ return interaction.reply({
content: loc.get('e_interacreate_no_command'), content: loc.get('e_interacreate_no_command'),
ephemeral: true, ephemeral: true,
@ -15,4 +16,20 @@ export default (interaction: Interaction, client: Client) => {
return command.interaction(interaction, client); return command.interaction(interaction, client);
} }
case InteractionType.ModalSubmit: {
const modal = client.modals.list.get(interaction.customId);
if (!modal) {
return interaction.reply({
content: loc.get('e_interacreate_no_modal'),
ephemeral: true,
});
}
return modal.interaction(interaction, client);
}
default:
break;
}
}; };

View file

@ -18,6 +18,7 @@ export default async (client: Client) => {
); );
// Remove extension // Remove extension
// TODO: use utils functions
const event_type_ext = event_file.split('.'); const event_type_ext = event_file.split('.');
const ext = event_type_ext.pop(); const ext = event_type_ext.pop();
if (!(ext === 'js' || ext === 'ts')) { if (!(ext === 'js' || ext === 'ts')) {

View file

@ -105,6 +105,7 @@ export default async (message: Message, client: Client) => {
); );
embed.addFields({ embed.addFields({
// TODO: Don't pluralize when there is only one file. // TODO: Don't pluralize when there is only one file.
// TODO: Locales
name: 'Fichiers joints', name: 'Fichiers joints',
// TODO: Check if don't exceed char limit, if yes, split // TODO: Check if don't exceed char limit, if yes, split
// files into multiples field. // files into multiples field.

View file

@ -1,5 +1,7 @@
import loadClient from './utils/client'; import loadClient, { quit } from './utils/client';
import loadEvents from './events/loader'; import loadEvents from './events/loader';
import loadModals from './modals/loader';
import loadButtons from './buttons/loader';
import loadCommands from './commands/loader'; import loadCommands from './commands/loader';
import { logStart } from './utils/misc'; import { logStart } from './utils/misc';
@ -19,30 +21,56 @@ const run = async () => {
const client_name = 'Client'; const client_name = 'Client';
await loadClient() await loadClient()
.then(async client => { .then(async client => {
console.log(logStart(client_name, true));
// Events Discord.JS // Events Discord.JS
const events_name = 'Events'; const events_name = 'Events';
await loadEvents(client) await loadEvents(client)
.then(() => console.log(logStart(events_name, true))) .then(() => console.log(logStart(events_name, true)))
.catch(() => { .catch((err) => {
console.error(err);
throw logStart(events_name, false); throw logStart(events_name, false);
}); });
// Connect the bot to Discord.com // Connect the bot to Discord.com
await client.login(client.config.token_discord); await client.login(client.config.token_discord);
// Modals Discord.JS
const modals_name = 'Modals';
await loadModals(client)
.then(() => console.log(logStart(modals_name, true)))
.catch((err) => {
console.error(err);
throw logStart(modals_name, false);
});
// Buttons Discord.JS
const buttons_name = 'Buttons';
await loadButtons(client)
.then(() => console.log(logStart(buttons_name, true)))
.catch((err) => {
console.error(err);
throw logStart(buttons_name, false);
});
// Commands Slash Discord.JS // Commands Slash Discord.JS
const commands_name = 'Commands'; const commands_name = 'Commands';
await loadCommands(client) await loadCommands(client)
.then(() => console.log(logStart(commands_name, true))) .then(() => console.log(logStart(commands_name, true)))
.catch(() => { .catch((err) => {
console.error(err);
throw logStart(commands_name, false); throw logStart(commands_name, false);
}); });
console.log(logStart(client_name, true));
console.log(`Botanique "${client.user?.username}" v${client.config.version} started!`); console.log(`Botanique "${client.user?.username}" v${client.config.version} started!`);
// ^C
process.on('SIGINT', () => quit(client));
// Container force closed
process.on('SIGTERM', () => quit(client));
}) })
.catch(() => { .catch((err) => {
console.error(err);
throw logStart(client_name, false); throw logStart(client_name, false);
}); });
}; };

View file

@ -1,5 +1,7 @@
{ {
"e_interacreate_no_command": "Désolé, la commande n'existe plus...", "e_interacreate_no_command": "Désolé, la commande n'existe plus...",
"e_interacreate_no_modal": "Désolé, le modèle n'existe plus...",
"e_interacreate_no_button": "Désolé, le bouton n'existe plus...",
"c_ping_name": "Ping", "c_ping_name": "Ping",
"c_ping_desc": "Pong!", "c_ping_desc": "Pong!",
@ -14,5 +16,42 @@
"c_help2": "`/help <commande>` pour obtenir plus d'informations sur une commande.", "c_help2": "`/help <commande>` pour obtenir plus d'informations sur une commande.",
"c_help3": "Impossible de trouver :", "c_help3": "Impossible de trouver :",
"u_time_at": "à" "u_time_at": "à",
"c_reminder_name": "rappel",
"c_reminder_desc": "Commande relative aux rappels",
"c_reminder_sub1_name": "nouveau",
"c_reminder_sub1_desc": "Met en place un rappel",
"c_reminder_sub1_opt1_name": "temps",
"c_reminder_sub1_opt1_desc": "Temps désiré avant le rappel, accolez un @ pour activer la mention ou un p pour envoyer en DM",
"c_reminder_sub1_opt2_name": "message",
"c_reminder_sub1_opt2_desc": "Message du rappel",
"c_reminder_sub2_name": "liste",
"c_reminder_sub2_desc": "Affiche la liste des rappels d'un utilisateur",
"c_reminder_sub2_opt1_name": "utilisateur",
"c_reminder_sub2_opt1_desc": "Affiche la liste de l'utilisateur en question",
"c_reminder_sub2_opt2_name": "page",
"c_reminder_sub2_opt2_desc": "Page à afficher",
"c_reminder_sub3_name": "efface",
"c_reminder_sub3_desc": "Supprime un rappel",
"c_reminder_sub3_opt1_name": "id",
"c_reminder_sub3_opt1_desc": "Rappel à supprimé",
"c_reminder1": "Un rappel a été configuré pour dans",
"c_reminder2": "L'ID renseigné n'est pas valide.",
"c_reminder3": "Rappel non trouvé, pas sur le bon serveur ou qui ne vous appartiens pas.",
"c_reminder4": "Utilisateur inconnu.",
"c_reminder5": "Rappels de",
"c_reminder6": "Page",
"c_reminder7": "Pas de message",
"c_reminder8": "Expire dans",
"c_reminder9": "Fais le",
"c_reminder10": "L'utilisateur n'a aucun rappel en attente ou page n°",
"c_reminder11": "vide",
"c_reminder12": "Précédent",
"c_reminder13": "Suivant",
"c_reminder14": "Message envoyé en DM car le salon n'est plus disponible.",
"c_reminder15": "Message envoyé en DM car vous avez quitté",
"c_reminder16": "Message envoyé en DM car le serveur Discord n'est plus disponible.",
"c_reminder17": "Message d'il y a",
"c_reminder18": "Pas de message"
} }

36
src/modals/loader.ts Normal file
View file

@ -0,0 +1,36 @@
import { readdir } from 'fs/promises';
import { removeExtension } from '../utils/misc';
import { Client } from 'discord.js';
export default async (client: Client) => {
// Dossier des modals
const modals_categories = (await readdir(__dirname))
.filter(element => !element.endsWith('.js') && !element.endsWith('.ts'));
await Promise.all(
// For each categorie
modals_categories.map(async modals_category => {
// Retrieve all the commands
const modal_files = await readdir(`${__dirname}/${modals_category}`);
// Add the category to the collection for the help command
client.modals.categories.set(
modals_category,
modal_files.map(removeExtension),
);
// Add the modal
return Promise.all(
modal_files.map(async modal_file => {
const modal = (
await import(`../modals/${modals_category}/${modal_file}`)
).default;
// Add it to the collection so the interaction will work
client.modals.list.set(modal.data.name, modal);
return modal.data;
}),
);
}),
);
};

View file

@ -0,0 +1,21 @@
import { Client, ModalSubmitInteraction } from 'discord.js';
import { getFilename } from '../../utils/misc';
import { newReminder } from '../../utils/reminder';
export default {
data: {
name: getFilename(__filename),
},
interaction: async (interaction: ModalSubmitInteraction, client: Client) =>
newReminder(client, interaction.fields.fields.get('reminderGUI-time')?.value as string, {
locale: interaction.locale,
message: interaction.fields.fields.get('reminderGUI-message')?.value ?? null,
createdAt: interaction.createdAt.getTime(),
channelId: interaction.channelId,
userId: interaction.user.id,
guildId: interaction.guildId,
}).then((msg) => interaction.reply({
content: msg as string,
ephemeral: true,
})),
};

View file

@ -1,5 +1,6 @@
import { Collection } from 'discord.js'; import { Collection } from 'discord.js';
import { SlashCommandBuilder } from '@discordjs/builders'; import { SlashCommandBuilder } from '@discordjs/builders';
import { Database } from 'sqlite3';
export { }; export { };
@ -15,6 +16,50 @@ declare module 'discord.js' {
/** Default lang used */ /** Default lang used */
default_lang: string default_lang: string
}, },
/** Store all the modals */
modals: {
categories: Collection<
/** Category name */
string,
/** Name of the modals in the category */
string[]
>,
list: Collection<
/** Modal name */
string,
/** Modal itself */
{
/** Data about the modal */
data: {
name: string
},
/** How the modal interact */
interaction: (interaction: ModalSubmitInteraction, client: Client) => unknown
}
>,
},
/** Store all the buttons */
buttons: {
categories: Collection<
/** Category name */
string,
/** Name of the buttons in the category */
string[]
>,
list: Collection<
/** Button name */
string,
/** Button itself */
{
/** Data about the button */
data: {
name: string
},
/** How the button interact */
interaction: (interaction: MessageComponentInteraction, client: Client) => Promise<InteractionUpdateOptions>
}
>,
},
/** Store all the slash commands */ /** Store all the slash commands */
commands: { commands: {
categories: Collection< categories: Collection<
@ -34,8 +79,9 @@ declare module 'discord.js' {
interaction: (interaction: CommandInteraction, client: Client) => unknown interaction: (interaction: CommandInteraction, client: Client) => unknown
} }
>, >,
} },
/** Store all the localizations */ /** Store all the localizations */
locales: Map<string, Map<string, string>> locales: Map<string, Map<string, string>>,
db: Database,
} }
} }

View file

@ -2,6 +2,7 @@ import { Client, Collection, GatewayIntentBits } from 'discord.js';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { loadLocales } from './locales'; import { loadLocales } from './locales';
import '../modules/client'; import '../modules/client';
import { Database } from 'sqlite3';
/** Creation of the client and definition of its properties. */ /** Creation of the client and definition of its properties. */
export default async () => { export default async () => {
@ -18,6 +19,16 @@ export default async () => {
default_lang: process.env.DEFAULT_LANG ?? 'fr', default_lang: process.env.DEFAULT_LANG ?? 'fr',
}; };
client.modals = {
categories: new Collection(),
list: new Collection(),
};
client.buttons = {
categories: new Collection(),
list: new Collection(),
};
client.commands = { client.commands = {
categories: new Collection(), categories: new Collection(),
list: new Collection(), list: new Collection(),
@ -26,5 +37,41 @@ export default async () => {
console.log('Translations progression :'); console.log('Translations progression :');
client.locales = await loadLocales(client.config.default_lang); client.locales = await loadLocales(client.config.default_lang);
client.db = new Database(`${process.env.DOCKERIZED === '1' ? '/config' : './config'}/db.sqlite3`);
initDatabase(client.db);
return client; return client;
}; };
/**
* Quit gracefully the client
* @param client Client
*/
export const quit = (client: Client) => {
// Close DB
client.db.close();
// Close client
client.destroy();
};
/**
* Initalize the database
* @param db Database
*/
const initDatabase = (db: Database) => {
// Table for reminders
db.run('CREATE TABLE IF NOT EXISTS reminder ( \
id INTEGER PRIMARY KEY, \
data TEXT, \
expiration_date TEXT, \
option_id INTEGER, \
channel_id TEXT, \
creation_date TEXT, \
user_id TEXT, \
guild_id TEXT, \
locale TEXT, \
timeout_id TEXT \
);');
};

View file

@ -146,9 +146,9 @@ async (locales: Map<string, Map<string, string>>, default_lang: string) => {
locales_size.forEach((size, lang) => { locales_size.forEach((size, lang) => {
const percentage = (size / max_size) * 100; const percentage = (size / max_size) * 100;
// Colored bar part // Colored bar part
const blocks = ' '.repeat(percentage / bar_size); const blocks = ' '.repeat(Math.floor(percentage / bar_size));
// Blank bar part // Blank bar part
const blank = ' '.repeat((100 - percentage) / bar_size); const blank = ' '.repeat(Math.ceil((100 - percentage) / bar_size));
const color = () => { const color = () => {
switch (true) { switch (true) {
case percentage <= 25: case percentage <= 25:
@ -167,7 +167,8 @@ async (locales: Map<string, Map<string, string>>, default_lang: string) => {
return ''; return '';
} }
}; };
const padding = ' '.repeat(lang.length === 5 ? 1 : 4);
console.log(`${lang} | ${color()}${blocks}\x1b[0m${blank} | ${percentage.toPrecision(3)}%`); console.log(`${padding}${lang} | ${color()}${blocks}\x1b[0m${blank} | ${percentage.toPrecision(3)}%`);
}); });
}; };

View file

@ -7,7 +7,8 @@ import { GuildMember } from 'discord.js';
* @returns String * @returns String
*/ */
export const logStart = (name: string, status: boolean) => { export const logStart = (name: string, status: boolean) => {
return `> ${name} ${status === true ? '✅' : '❌'}`; // TODO Handle precision about the error if status is false
return `> ${name}\t${status === true ? '✅' : '❌'}`;
}; };
/** /**
@ -76,3 +77,27 @@ export const userWithNickname = (member: GuildMember) => {
return member.user.tag; return member.user.tag;
} }
}; };
/**
* Move the text into backtick text, preserving mentions and links
* @param text Text
* @returns Formatted text
*/
export const cleanCodeBlock = (text: string) => {
text = `\`${text.trim()}\``;
// Keep mentions
text = text.replace(/(<@\d+>)/g, function(mention: string) {
return `\`${mention}\``;
});
// Keep links
text = text.replace(/(http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)/g, function(url: string) {
return `\`${url}\``;
});
// Fix issues
text = text.replace('``', '');
return text;
};

363
src/utils/reminder.ts Normal file
View file

@ -0,0 +1,363 @@
import { Client, Colors, EmbedBuilder, User } from 'discord.js';
import { getLocale } from './locales';
import { cleanCodeBlock } from './misc';
import { showDate, strToSeconds, timeDeltaToString } from './time';
/**
* Option possible for reminders
*/
export enum OptionReminder {
/** No parameters */
Nothing,
/** @ */
Mention,
/** p */
DirectMessage,
}
/**
* Store data about the remidner
*/
export type infoReminder = {
locale: string,
message: string | null,
createdAt: number,
channelId: string | null,
userId: string,
guildId: string | null
}
export type dbReminder = {
id: number,
data: string | null,
expiration_date: number,
option_id: OptionReminder,
channel_id: string | null,
creation_date: string,
user_id: string,
guild_id: string | null,
locale: string,
timeout_id: string
}
/**
* Split the time and the extra args `p` and `@`
* @param time raw text from user
* @returns An object with the time and the option
*/
const splitTime = (time: string) => {
if (time?.endsWith('@')) {
return { time: time.slice(0, -1), option: OptionReminder.Mention };
} else if (time?.toLowerCase().endsWith('p')) {
return { time: time.slice(0, -1), option: OptionReminder.DirectMessage };
}
return { time: time, option: OptionReminder.Nothing };
};
/**
* Create a new reminder
* @param client Client
* @param time raw text from user about the time wanted
* @param info data about the context of the reminder
* @returns Promise resolution of the sql request
*/
export const newReminder = async (client: Client, time: string, info: infoReminder) =>
new Promise((ok, ko) => {
const data = splitTime(time);
const timeout = strToSeconds(data.time);
const timeoutId = setTimeoutReminder(client, info, data.option, timeout);
// Add the remind to the db
client.db.run('INSERT INTO reminder ( \
data, expiration_date, option_id, channel_id, creation_date, user_id, guild_id, locale, timeout_id \
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? );', [
info.message,
`${info.createdAt + (timeout * 1000)}`,
data.option.valueOf(),
info.channelId,
`${info.createdAt}`,
info.userId,
info.guildId,
info.locale,
timeoutId], (err) => {
if (err) {
ko(err);
}
// Send confirmation to user
const loc = getLocale(client, info.locale);
ok(`${loc.get('c_reminder1')} ${data.time}.`);
});
});
/**
* Delete a reminder from the database
* @param client Client
* @param createdAt Creation of the reminder
* @param userId User ID who created the reminder
* @returns what the SQlite request sended
*/
export const deleteReminder = (client: Client, createdAt: string, userId: string) => {
// Delete the reminder for the database
return new Promise((ok, ko) => {
// Add the remind to the db
client.db.run('DELETE FROM reminder WHERE creation_date = ? AND user_id = ?', [createdAt, userId], (err) => {
if (err) {
ko(err);
}
// Send confirmation to user
ok(true);
});
});
};
export const sendReminder = (client: Client, info: infoReminder, option: OptionReminder) => {
const loc = getLocale(client, info.locale);
// Send the message in the appropriate channel
// TODO: Embed
let message: string;
if (info.message === null) {
message = loc.get('c_reminder18');
} else {
message = cleanCodeBlock(info.message);
}
const embed = new EmbedBuilder()
.setColor('Random')
.setDescription(message)
.setTimestamp(info.createdAt);
let channelOk = false;
if (info.channelId !== null) {
if (client.channels.cache.get(info.channelId) !== undefined) {
channelOk = true;
} else {
embed.setFooter({ text: loc.get('c_reminder14') });
}
}
let guildOk = false;
if (info.guildId !== null) {
const guild = client.guilds.cache.get(info.guildId);
if (guild !== undefined) {
if (guild.members.cache.get(info.userId) !== undefined) {
guildOk = true;
} else {
embed.setFooter({ text: `${loc.get('c_reminder15')} ${guild.name}.` });
}
} else {
embed.setFooter({ text: loc.get('c_reminder16') });
}
}
if (option == OptionReminder.DirectMessage || !channelOk || !guildOk) {
// Direct message
const user = client.users.cache.get(info.userId);
if (user !== undefined) {
user.send({ embeds: [embed] });
}
} else {
// Channel
client.channels.fetch(info.channelId ?? '').then((channel) => {
if (channel?.isTextBased()) {
let content = `<@${info.userId}>`;
embed.setFooter({ text: `${loc.get('c_reminder17')} ${timeDeltaToString(info.createdAt)}` });
// Mention everybody if needed
if (option == OptionReminder.Mention) {
(info.message?.match(/<@\d+>/g) ?? []).forEach(mention => {
content += ' ' + mention;
});
}
channel.send({ content, embeds: [embed] });
}
});
}
};
/**
* Create a timeout for a reminder
* @param client Client
* @param info info about the reminder
* @param option option used for this reminder (aka location of the response)
* @param timeout Amout of time before the reminder ends
* @returns Timeout's ID
*/
export const setTimeoutReminder = (client: Client, info: infoReminder, option: OptionReminder, timeout: number) => {
return Number(setTimeout(() => {
deleteReminder(client, String(info.createdAt), info.userId).then((val) => {
if (val != true) {
throw val;
}
sendReminder(client, info, option);
});
}, timeout * 1000));
};
/**
* Check the owernship of a reminder by a user
* @param client Client
* @param id ID of the reminder
* @param userId user ID to check
* @param guildId guild ID where the ownership request as been send, 0 if DM
*/
export const checkOwnershipReminder = async (client: Client, id: number, userId: string, guildId: string) => {
const data = await new Promise((ok, ko) => {
// Check the ownership
client.db.all('SELECT EXISTS ( \
SELECT 1 FROM reminder \
WHERE id = ? \
AND user_id = ? \
AND (guild_id = ? OR guild_id = 0) \
)', [
id, userId, guildId,
], (err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders
ok(row[0]);
});
}) as { [key: string]: number };
return Object.keys(data).map((key) => data[key])[0] === 0 ? true : false;
};
/**
* Retrieve info about a reminder
* @param client Client
* @param id Reminder's ID
*/
export const getReminderInfo = async (client: Client, id: number) => {
return await new Promise((ok, ko) => {
// Check the ownership
client.db.all('SELECT * FROM reminder \
WHERE id = ?', [
id], (err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders
ok(row[0]);
});
}) as dbReminder;
};
/**
* Update an entry of the database
* @param client Client
* @param data Data who will override the data in database
*/
export const updateReminder = (client: Client, data: dbReminder) => {
// Delete the reminder for the database
return new Promise((ok, ko) => {
// Update the db
client.db.run('UPDATE reminder \
SET data = ?, \
expiration_date = ?, \
option_id = ?, \
channel_id = ?, \
creation_date = ?, \
user_id = ?, \
guild_id = ?, \
locale = ?, \
timeout_id = ? \
WHERE ID = ?', [
data.data,
data.expiration_date,
data.option_id,
data.channel_id,
data.creation_date,
data.user_id,
data.guild_id,
data.locale,
data.timeout_id,
data.id], (err) => {
if (err) {
ko(err);
}
ok(true);
});
});
};
/**
* Return a list of reminders for a user in a specified context
* @param client Client
* @param userId user ID
* @param guildId guild ID
* @returns List of reminders of a user in a guild
*/
const listReminders = async (client: Client, userId: string, guildId: string | null) => {
return await new Promise((ok, ko) => {
// Check the ownership
client.db.all('SELECT data, creation_date, expiration_date, id FROM reminder \
WHERE user_id = ? AND (guild_id = ? OR guild_id = 0)', [
userId, guildId ?? 0], (err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders
ok(row);
});
}) as dbReminder[];
};
/**
* Return the embed of the reminders
* @param client Client
* @param user User
* @param guildId Guild ID
* @param page Page requested
* @param local Lang
* @returns Pretty embed who list reminders
*/
export const embedListReminders = async (client: Client, user: User, guildId: string | null, page: number, local: string) => {
const loc = getLocale(client, local);
const reminders = await listReminders(client, user.id, guildId);
const elementPerPage = 5;
const pageMax = Math.ceil(reminders.length / elementPerPage);
if (pageMax <= 1) {
page = 1;
}
// TODO: Use Random color or force a color from args
const embed = new EmbedBuilder()
.setColor(Colors.DarkGrey)
.setDescription(`${loc.get('c_reminder5')} ${user}${loc.get('c_reminder6')} ${page}/${pageMax}`)
.setThumbnail(user.displayAvatarURL());
const limit = elementPerPage * page;
if (reminders.length > 0 && page <= pageMax) {
let curseur = limit - elementPerPage;
reminders.splice(0, limit - elementPerPage);
reminders.forEach((remind) => {
if (curseur < limit) {
let text = remind.data ?? loc.get('c_reminder7');
if (text.length > 1024) {
text = `${text.substring(0, 1021)}...`;
}
const expiration = `${loc.get('c_reminder8')} ${timeDeltaToString(remind.expiration_date)}`;
embed.addFields({
name: `#${remind.id}${loc.get('c_reminder9')} ${showDate(local, loc, new Date(Number(remind.creation_date)))}\n${expiration}`,
value: text,
inline: false,
});
curseur++;
}
});
} else {
embed.addFields({ name: '\u200b', value: `${loc.get('c_reminder10')}${page} ${loc.get('c_reminder11')}.` });
}
return embed;
};

View file

@ -10,7 +10,45 @@ export const showDate = (
locale: Map<string, unknown>, locale: Map<string, unknown>,
date: Date date: Date
) => { ) => {
return date.toLocaleString(tz).replace(' ', ` ${ return date.toLocaleString(tz).replace(' ', ` ${locale.get('u_time_at')} `);
locale.get('u_time_at') };
} `);
enum TimeSecond {
Year = 31536000,
Week = 604800,
Day = 86400,
Hour = 3600,
Minute = 60,
Second = 1
}
/**
* Take a cooldown, for example 2min and transform it to seconds, here: 120s
* @param time time in human format
* @returns time in seconds
*/
export const strToSeconds = (time: string) => {
const regex = new RegExp(`(?<${TimeSecond[TimeSecond.Year]}>[0-9]+(?=[y|a]))|(?<${TimeSecond[TimeSecond.Week]}>[0-9]+(?=[w]))|(?<${TimeSecond[TimeSecond.Day]}>[0-9]+(?=[d|j]))|(?<${TimeSecond[TimeSecond.Hour]}>[0-9]+(?=[h]))|(?<${TimeSecond[TimeSecond.Minute]}>[0-9]+(?=[m]))|(?<${TimeSecond[TimeSecond.Second]}>[0-9]+(?=[s]?))`);
const data = Object.assign({}, regex.exec(time)?.groups);
let res = 0;
Object.entries(data).forEach(([key, value]) => {
if (value) {
res += +value * TimeSecond[key as keyof typeof TimeSecond];
}
});
return res;
};
/**
* Calculating the difference between a date and now
* @param time Time
* @returns Delta between the time and now
*/
export const timeDeltaToString = (time: number) => {
const now = Date.now();
// TODO adapt the output and not always parse the time as seconds
return `${strToSeconds(`${(now - time) / 1000}`)} secs`;
}; };