Compare commits

..

15 commits

Author SHA1 Message Date
23446eb399
fix: SQL import in Docker images (#202)
All checks were successful
Publish latest version / build (push) Successful in 2m4s
Also don't compile tests

Reviewed-on: #202
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-11-02 18:26:01 +01:00
10f5bf65b3
chore: merge branch dev to main (#200)
Some checks failed
Publish latest version / build (push) Has been cancelled
Close #199
Close #198
Close #145
Close #196
Close #184
Close #187
Close #57

Reviewed-on: #200
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-11-02 17:36:36 +01:00
82e2f5a209
chore: merge branch dev to main (#195)
All checks were successful
Publish latest version / build (push) Successful in 1m45s
Close #188

Also better support of assets

Reviewed-on: #195
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-10-14 23:41:43 +02:00
74fdfb7626
chore: merge branch dev to main (#194)
All checks were successful
Publish latest version / build (push) Successful in 1m58s
- Revert #176 as it look the previous issues has been resolved (I'm 60% sure it will reappears, but it's still better than the scraping-based lib)

Reviewed-on: #194
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-10-08 20:55:16 +02:00
f9cc154b04
fix: docker (#191)
All checks were successful
Publish latest version / build (push) Successful in 1m30s
Reviewed-on: #191
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-27 21:57:15 +02:00
2cc6c0bd74
chore: merge dev to main (#181)
Some checks failed
Publish latest version / build (push) Failing after 1m15s
- close #100 - find timezone based on locale
- Add tests
- Add tests to CI
- Close #182
- Close #183

Reviewed-on: #181
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-27 20:49:36 +02:00
a08d0c0e9b
fix #100 (#180)
All checks were successful
Publish latest version / build (push) Successful in 1m30s
Close #100

We use Intl.DateTimeFormat to format the date, using the local provided from
1st: the guild info
2nd: the default lang used for Botanique

Reviewed-on: #180
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-24 18:37:04 +02:00
b1abeefad2
fix: time related issues (#179)
All checks were successful
Publish latest version / build (push) Successful in 1m44s
- cleanup some code
- fix #100 (finally!)
- update some dependencie
   There is an issue with thoses one:
    @typescript-eslint/eslint-plugin": "~8.7.0"
    @typescript-eslint/parser": "~8.7.0"

Reviewed-on: #179
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-24 18:07:27 +02:00
facf0cd88e
fix: citation issue (#178)
All checks were successful
Publish latest version / build (push) Successful in 2m2s
Reviewed-on: #178
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-23 19:33:21 +02:00
7fed94def8
chore: merge dev to main (#177)
All checks were successful
Publish latest version / build (push) Successful in 2m21s
Close #93
May fix #100

Reviewed-on: #177
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-23 17:51:22 +02:00
088693d2d2
feat: returns to @distube/ytdl-core (#176)
All checks were successful
Publish latest version / build (push) Successful in 2m11s
Close #170

Reviewed-on: #176
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-18 19:35:09 +02:00
929312e0ed
override temporary youtubei.js dependency (#175)
All checks were successful
Publish latest version / build (push) Successful in 2m40s
Ref #170

Reviewed-on: #175
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-18 19:23:54 +02:00
e4d1e307df
fix: no longer get token from youtube (#174)
All checks were successful
Publish latest version / build (push) Successful in 2m10s
Close #173

Reviewed-on: #174
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-18 19:12:35 +02:00
767612a000
chore: merge dev to main (#172)
All checks were successful
Publish latest version / build (push) Successful in 2m2s
Reviewed-on: #172
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-18 18:25:03 +02:00
23d3918459
merge dev to main (#169)
All checks were successful
Publish latest version / build (push) Successful in 1m33s
Close #63
Close #78
Close #79
Close #137

Mutiples fixed, mainly aimed to reminder commands

Also add the Rich Presence, and fix a bug for forwarded messages

Reviewed-on: #169
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
2024-09-17 22:57:55 +02:00
60 changed files with 5310 additions and 614 deletions

View file

@ -1 +1,2 @@
dist dist
tests

View file

@ -1,4 +1,4 @@
name: Lint and Format Check name: PR Check
on: on:
pull_request: pull_request:
@ -14,10 +14,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Run lint - name: Run lint
run: npm run lint run: npm run lint
- name: Run format check - name: Run format check
run: npm run format-check run: npm run format-check
- name: Run tests
run: npm run test -- --ci

View file

@ -32,5 +32,6 @@ jobs:
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}

5
.gitignore vendored
View file

@ -11,7 +11,10 @@ docker-compose.yml
dist/ dist/
# Databse # Databse
*.sqlite3 *.sqlite3*
# Debug file # Debug file
src/events/player/debug.ts src/events/player/debug.ts
# Jest
coverage/

View file

@ -25,6 +25,7 @@ une [Pull Request](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/pulls)
- [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)
- [Tester son code](#tester-son-code)
## Recevoir de l'aide ## Recevoir de l'aide
@ -284,3 +285,20 @@ Pour commencer, vous pouvez jeter un œil aux
[le graphe](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/graph). [le graphe](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/graph).
- De préférences, suivre [ces conventions](https://www.conventionalcommits.org/fr/v1.0.0/) - De préférences, suivre [ces conventions](https://www.conventionalcommits.org/fr/v1.0.0/)
(cf. cette [partie précédente](#soumettre-ses-modifications)). (cf. cette [partie précédente](#soumettre-ses-modifications)).
## Tester son code
Il est souhaité d'écrire des tests quand cela est possible.
```ts
import { fnReturnsTrue } from "../src/utils/file";
describe("test name", () => {
{
const name = "to be tested";
test(name, () => {
expect(fnReturnsTrue() /* function to test */).toBe(true /* expected result */);
});
}
});
```

View file

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:22.8-bullseye-slim FROM node:22.9-bullseye-slim
ENV DOCKERIZED=1 ENV DOCKERIZED=1
RUN mkdir /config && \ RUN mkdir /config && \
@ -14,7 +14,7 @@ COPY --chown=node:node . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm ci --omit=dev && \ RUN npm ci --omit=dev && \
npx tsc && \ npm run compile && \
rm -r src/ tsconfig.json && \ rm -r src/ tsconfig.json && \
npm uninstall typescript @types/sqlite3 && \ npm uninstall typescript @types/sqlite3 && \
npm cache clean --force npm cache clean --force

View file

@ -1,14 +1,12 @@
# 🌱 Botanique [![status-badge](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/badges/workflows/publish.yml/badge.svg)](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/actions?workflow=publish.yml) # 🌱 Botanique [![status-badge](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/badges/workflows/publish.yml/badge.svg)](https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/actions?workflow=publish.yml)
[**Ajoute le bot à ton serveur**](https://discord.com/api/oauth2/authorize?client_id=965598852407230494&permissions=8&scope=bot%20applications.commands) [**Ajoute le bot à un serveur**](https://discord.com/api/oauth2/authorize?client_id=965598852407230494&permissions=8&scope=bot%20applications.commands)
## Lancer le bot ## Lancer le bot
### Avec docker-compose (recommandé) ### Avec docker-compose (recommandé)
```yaml ```yaml
version: "3.9"
services: services:
botanique: botanique:
image: git.mylloon.fr/confreriedukassoulait/botanique:latest image: git.mylloon.fr/confreriedukassoulait/botanique:latest

3546
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,13 @@
"description": "Bot discord", "description": "Bot discord",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"main": "rm -r dist 2> /dev/null; npx tsc && node ./dist/index.js", "compile": "rm -r dist 2> /dev/null; npx tsc && cp -r ./src/sql ./dist/sql",
"main": "npm run compile && node ./dist/index.js",
"debug": "npx tsnd --respawn ./src/index.ts", "debug": "npx tsnd --respawn ./src/index.ts",
"lint": "npx eslint src", "lint": "npx eslint src",
"format-check": "npx prettier --check src", "format-check": "npx prettier --check src",
"format-write": "npx prettier --write src" "format-write": "npx prettier --write src",
"test": "npx jest"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -19,21 +21,26 @@
"dependencies": { "dependencies": {
"@discord-player/extractor": "^4.5.1", "@discord-player/extractor": "^4.5.1",
"@discordjs/rest": "^2.4.0", "@discordjs/rest": "^2.4.0",
"@types/sqlite3": "^3.1.11",
"@types/uuid": "^10.0.0",
"discord-player": "^6.7.1", "discord-player": "^6.7.1",
"discord-player-youtubei": "^1.3.1", "discord-player-youtubei": "^1.3.4",
"discord.js": "^14.16.2", "discord.js": "^14.16.3",
"mediaplex": "^0.0.9", "mediaplex": "^0.0.9",
"moment-timezone": "^0.5.46",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"uuid": "^10.0.0" "uuid": "^11.0.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "~8.6.0", "@types/jest": "~29.5.14",
"@typescript-eslint/parser": "~8.6.0", "@typescript-eslint/eslint-plugin": "~8.12.2",
"@typescript-eslint/parser": "~8.12.2",
"dotenv": "~16.4.5", "dotenv": "~16.4.5",
"jest": "~29.7.0",
"prettier-eslint": "~16.3.0", "prettier-eslint": "~16.3.0",
"ts-jest": "~29.2.5",
"ts-node-dev": "~2.0.0" "ts-node-dev": "~2.0.0"
},
"jest": {
"preset": "ts-jest"
} }
} }

View file

@ -10,9 +10,9 @@ import { getLocale } from "../utils/locales";
export default async (client: Client) => { export default async (client: Client) => {
// Dossier des buttons // Dossier des buttons
const buttons_categories = (await readdir(__dirname)).filter( const buttons_categories = (await readdir(__dirname, { withFileTypes: true }))
(element) => !element.endsWith(".js") && !element.endsWith(".ts"), .filter((element) => element.isDirectory())
); .map((element) => element.name);
await Promise.all( await Promise.all(
// For each categorie // For each categorie

View file

@ -4,12 +4,11 @@ import {
ButtonStyle, ButtonStyle,
Client, Client,
MessageComponentInteraction, MessageComponentInteraction,
User,
} from "discord.js"; } from "discord.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getLocale } from "../../utils/locales"; import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { embedListReminders } from "../../utils/reminder"; import { embedListReminders } from "../../utils/commands/reminder";
import { collect } from "../loader"; import { collect } from "../loader";
export default { export default {
@ -18,11 +17,11 @@ export default {
}, },
interaction: async (interaction: MessageComponentInteraction, client: Client) => { interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.description as string; const embed_desc = interaction.message.embeds.at(0)?.description;
// Retrieve Pages // Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]); const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc!)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]); let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc!)?.[0]);
if (page + 1 > pageMax) { if (page + 1 > pageMax) {
page = 1; page = 1;
} else { } else {
@ -30,8 +29,8 @@ export default {
} }
// Retrieve user // Retrieve user
const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc)?.[0] as string; const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc!)?.[0];
const user = client.users.cache.get(userId) as User; const user = client.users.cache.get(userId!)!;
// Fetch list // Fetch list
const list = await embedListReminders( const list = await embedListReminders(

View file

@ -4,12 +4,11 @@ import {
ButtonStyle, ButtonStyle,
Client, Client,
MessageComponentInteraction, MessageComponentInteraction,
User,
} from "discord.js"; } from "discord.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getLocale } from "../../utils/locales"; import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { embedListReminders } from "../../utils/reminder"; import { embedListReminders } from "../../utils/commands/reminder";
import { collect } from "../loader"; import { collect } from "../loader";
export default { export default {
@ -18,20 +17,20 @@ export default {
}, },
interaction: async (interaction: MessageComponentInteraction, client: Client) => { interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.description as string; const embed_desc = interaction.message.embeds.at(0)?.description;
// Retrieve Pages // Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]); const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc!)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]); let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc!)?.[0]);
if (page - 1 == 0) { if (page - 1 === 0) {
page = pageMax; page = pageMax;
} else { } else {
page--; page--;
} }
// Retrieve user // Retrieve user
const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc)?.[0] as string; const userId = /(?!<@)\d+(?=>)/gm.exec(embed_desc!)?.[0];
const user = client.users.cache.get(userId) as User; const user = client.users.cache.get(userId!)!;
// Fetch list // Fetch list
const list = await embedListReminders( const list = await embedListReminders(

View file

@ -10,8 +10,8 @@ import {
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getLocale } from "../../utils/locales"; import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { embedListQueue } from "../../utils/music";
import { collect } from "../loader"; import { collect } from "../loader";
import { embedListQueue } from "../../utils/commands/music";
export default { export default {
data: { data: {
@ -19,11 +19,11 @@ export default {
}, },
interaction: async (interaction: MessageComponentInteraction, client: Client) => { interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.author?.name as string; const embed_desc = interaction.message.embeds.at(0)?.author?.name;
// Retrieve Pages // Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]); const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc!)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]); let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc!)?.[0]);
if (page + 1 > pageMax) { if (page + 1 > pageMax) {
page = 1; page = 1;
} else { } else {

View file

@ -10,8 +10,8 @@ import {
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getLocale } from "../../utils/locales"; import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { embedListQueue } from "../../utils/music";
import { collect } from "../loader"; import { collect } from "../loader";
import { embedListQueue } from "../../utils/commands/music";
export default { export default {
data: { data: {
@ -19,12 +19,12 @@ export default {
}, },
interaction: async (interaction: MessageComponentInteraction, client: Client) => { interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const embed_desc = interaction.message.embeds.at(0)?.author?.name as string; const embed_desc = interaction.message.embeds.at(0)!.author!.name;
// Retrieve Pages // Retrieve Pages
const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]); const pageMax = Number(/(\d+)(?!.*\d)/gm.exec(embed_desc)?.[0]);
let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]); let page = Number(/(?!• \s+)\d(?=\/)/gm.exec(embed_desc)?.[0]);
if (page - 1 == 0) { if (page - 1 === 0) {
page = pageMax; page = pageMax;
} else { } else {
page--; page--;

View file

@ -8,9 +8,9 @@ import { removeExtension } from "../utils/misc";
export default async (client: Client) => { export default async (client: Client) => {
const rest = new REST({ version: "10" }).setToken(client.token!); const rest = new REST({ version: "10" }).setToken(client.token!);
const command_categories = (await readdir(__dirname)).filter( const command_categories = (await readdir(__dirname, { withFileTypes: true }))
(element) => !element.endsWith(".js") && !element.endsWith(".ts"), .filter((element) => element.isDirectory())
); .map((element) => element.name);
const commands = ( const commands = (
await Promise.all( await Promise.all(
@ -64,14 +64,14 @@ export default async (client: Client) => {
scopedCommands.forEach( scopedCommands.forEach(
async (command, guild) => async (command, guild) =>
await rest.put(Routes.applicationGuildCommands(client.user?.id as string, guild), { await rest.put(Routes.applicationGuildCommands(client.user!.id, guild), {
body: command, body: command,
}), }),
); );
// Send global commands to Discord // Send global commands to Discord
const globalCommands = commands.filter((c) => c.scope().length == 0); const globalCommands = commands.filter((c) => c.scope().length === 0);
return await rest.put(Routes.applicationCommands(client.user?.id as string), { return await rest.put(Routes.applicationCommands(client.user!.id), {
body: globalCommands.map((c) => c.data.toJSON()), body: globalCommands.map((c) => c.data.toJSON()),
}); });
}; };

View file

@ -0,0 +1,139 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import {
ChannelType,
Client,
Colors,
CommandInteraction,
EmbedBuilder,
NonThreadGuildBasedChannel,
} from "discord.js";
import "../../modules/string";
import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
export default {
scope: () => ["807244911350906920"],
data: (client: Client) => {
const filename = getFilename(__filename);
return (
new SlashCommandBuilder()
.setName(filename.toLowerCase())
.setDescription(client.locales.get(client.config.default_lang)!.get(`c_${filename}_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_desc`))
// Command option
.addStringOption((option) =>
option
.setName(
client.locales.get(client.config.default_lang)!.get(`c_${filename}_opt1_name`)!,
)
.setDescription(
client.locales.get(client.config.default_lang)!.get(`c_${filename}_opt1_desc`)!,
)
.setNameLocalizations(getLocalizations(client, `c_${filename}_opt1_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_opt1_desc`)),
)
);
},
interaction: async (interaction: CommandInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale);
const desiredCat = interaction.options.get(
client.locales
.get(client.config.default_lang)!
.get(`c_${getFilename(__filename)}_opt1_name`)!,
)?.value as string;
// If a category isn't specified
if (!desiredCat) {
// Sends a list of commands sorted into categories
return interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(Colors.Blurple)
.setTitle(loc.get("c_archive1"))
.setDescription(loc.get("c_archive2")),
],
});
}
// If a category is specified
const cleanCat = ["L1", "L2", "L3", "M1", "M2"];
const channel = cleanCat.includes(desiredCat);
if (!channel) {
// Category doesn't exist or is not included
return interaction.reply({
content: `${loc.get("c_archive3")} \`${desiredCat}\``,
ephemeral: true,
});
}
const allChannel = interaction.guild?.channels.fetch();
allChannel?.then(async (channelGuild) => {
// Retrieve category to archive
const catToArchive = channelGuild
.filter((chan) => chan?.type === ChannelType.GuildCategory)
.filter((chan) => chan?.name === desiredCat);
// Create/Retrieve the archive category
const catArchivedName = "archive - " + desiredCat;
const catArchivedMap = channelGuild
.filter((chan) => chan?.type === ChannelType.GuildCategory)
.filter((chan) => chan?.name === catArchivedName);
let catArchived: NonThreadGuildBasedChannel | null | undefined;
if (catArchivedMap.size > 0) {
catArchived = catArchivedMap.at(0);
} else {
catArchived = await interaction.guild?.channels.create({
name: catArchivedName,
type: ChannelType.GuildCategory,
});
}
const allChannelDesired = channelGuild
.filter((chan) => chan?.type === 0)
.filter((chan) => chan?.parentId === catToArchive.map((cat) => cat?.id)[0]);
// If no channels in the source category
if (allChannelDesired.size === 0) {
return interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(Colors.Blurple)
.setTitle(loc.get("c_archive6"))
.setDescription(loc.get("c_archive7")),
],
});
}
// Move channels to the archived categoryx
allChannelDesired.forEach((elem) => elem?.setParent(catArchived?.id as string));
return interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(Colors.Blurple)
.setTitle(
loc.get("c_archive4") +
" `" +
catToArchive.map((cat) => cat?.name) +
"` " +
loc.get("c_archive5") +
" `" +
catArchivedName +
"`",
)
.setDescription(
allChannelDesired
.map((cgD) => cgD?.name)
.toString()
.replaceAll(",", "\n"),
),
],
});
});
},
};

View file

@ -1,9 +1,20 @@
import { SlashCommandBuilder } from "@discordjs/builders"; import { SlashCommandBuilder } from "@discordjs/builders";
import { Locale } from "discord-api-types/v9"; import {
import { ChatInputCommandInteraction, Client, Colors, EmbedBuilder } from "discord.js"; ApplicationCommandOptionType,
ChatInputCommandInteraction,
Client,
Colors,
EmbedBuilder,
} from "discord.js";
import "../../modules/string"; import "../../modules/string";
import { getLocale, getLocalizations } from "../../utils/locales"; import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import {
goodDescription,
goodName,
NameNotLocalized,
SubnameNotLocalized,
} from "../../utils/commands/help";
export default { export default {
scope: () => [], scope: () => [],
@ -48,15 +59,35 @@ export default {
}[] = []; }[] = [];
// Load all the command per categories // Load all the command per categories
// TODO: Check if the command exist in the context (guild)
// TODO: List subcommands too
client.commands.categories.forEach((commands_name, category) => { client.commands.categories.forEach((commands_name, category) => {
const commands = commands_name.reduce((data, command_name) => { // Check if the command exist in the context (guild)
return data + `\`/${command_name}\`, `; commands_name = commands_name.filter((command) => {
}, ""); const scope = client.commands.list.get(command)?.scope();
return scope!.length === 0 || scope?.find((v) => v === interaction.guildId) !== undefined;
});
// Add subcommands
const all_commands: string[] = [];
commands_name.forEach((command) => {
const data = client.commands.list.get(command)?.data;
const name = goodName(data!, interaction.locale);
all_commands.push(name);
data
?.toJSON()
.options?.filter((option) => option.type === ApplicationCommandOptionType.Subcommand)
.forEach((subcommand) =>
all_commands.push(name + " " + goodName(subcommand, interaction.locale)),
);
});
const commands = all_commands.reduce(
(data, command_name) => data + `\`/${command_name}\`, `,
"",
);
fields.push({ fields.push({
name: category.capitalize() + ` (${commands_name.length})`, name: category.capitalize() + ` (${all_commands.length})`,
value: commands.slice(0, -2), value: commands.slice(0, -2),
}); });
}); });
@ -73,29 +104,49 @@ export default {
}); });
} }
// If a command is specified const error = `${loc.get("c_help3")} \`${desired_command}\``;
// TODO: Check if the command exist in the context (guild)
const command = client.commands.list.get(desired_command); const [possible_command, possible_subcommand] = desired_command.split(" ");
const command = NameNotLocalized(client, possible_command);
if (!command) { if (!command) {
// Command don't exist return interaction.reply({ content: error, ephemeral: true });
return interaction.reply({
content: `${loc.get("c_help3")} \`${desired_command}\``,
ephemeral: true,
});
} }
const scope = client.commands.list.get(command.name)?.scope();
if (scope!.length > 0 && scope?.find((id) => id === interaction.guildId) === undefined) {
// Command not available for the current guild
return interaction.reply({ content: error, ephemeral: true });
}
let subcommand = undefined;
if (possible_subcommand) {
subcommand = SubnameNotLocalized(command, possible_subcommand);
} else {
subcommand = null;
}
if (!command || subcommand === undefined) {
// Sub/Command don't exist
return interaction.reply({ content: error, ephemeral: true });
}
// Loads the data according to the user's locals
const requestedName =
goodName(command, interaction.locale) +
(subcommand !== null ? " " + goodName(subcommand, interaction.locale) : "");
const requestedDesc = goodDescription(
subcommand !== null ? subcommand : command,
interaction.locale,
);
// Send information about the command // Send information about the command
return interaction.reply({ return interaction.reply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(Colors.Blurple) .setColor(Colors.Blurple)
.setTitle("`/" + command.data.name + "`") .setTitle("`/" + requestedName + "`")
.setDescription( .setDescription(requestedDesc),
// Loads the description
// according to the user's locals
command.data.description_localizations?.[interaction.locale as Locale] ??
command.data.description,
),
], ],
}); });
}, },

118
src/commands/misc/prep.ts Normal file
View file

@ -0,0 +1,118 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { ChannelType, Client, Colors, CommandInteraction, EmbedBuilder } from "discord.js";
import "../../modules/string";
import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
export default {
scope: () => ["807244911350906920"],
data: (client: Client) => {
const filename = getFilename(__filename);
return (
new SlashCommandBuilder()
.setName(filename.toLowerCase())
.setDescription(client.locales.get(client.config.default_lang)!.get(`c_${filename}_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_desc`))
// Command option
.addStringOption((option) =>
option
.setName(
client.locales.get(client.config.default_lang)!.get(`c_${filename}_opt1_name`)!,
)
.setDescription(
client.locales.get(client.config.default_lang)!.get(`c_${filename}_opt1_desc`)!,
)
.setNameLocalizations(getLocalizations(client, `c_${filename}_opt1_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_opt1_desc`)),
)
);
},
interaction: async (interaction: CommandInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale);
const desired_cat = interaction.options.get(
client.locales
.get(client.config.default_lang)!
.get(`c_${getFilename(__filename)}_opt1_name`)!,
)?.value as string;
// If a category isn't specified
if (!desired_cat) {
// Sends a list of commands sorted into categories
return interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(Colors.Blurple)
.setTitle(loc.get("c_prep1"))
.setDescription(loc.get("c_prep2")),
],
});
}
// If a category is specified
const allowedCategories = ["L1", "L2", "L3", "M1", "M2"];
const channel = allowedCategories.includes(desired_cat);
if (!channel) {
// Category doesn't exist or is not allowed
return interaction.reply({
content: `${loc.get("c_prep3")} \`${desired_cat}\``,
ephemeral: true,
});
}
// Send information about the command
const allChannel = interaction.guild?.channels.fetch();
allChannel?.then((channel_guild) => {
const cat_to_prep = channel_guild
.filter((chan) => chan?.type === ChannelType.GuildCategory)
.filter((chan) => chan?.name === desired_cat);
const cat_to_prep_id = cat_to_prep.map((cat) => cat?.id);
const cat_to_prep_name = cat_to_prep.map((cat) => cat?.name);
// console.log(cat_to_prep);
const all_channel_desired = channel_guild
.filter((chan) => chan?.type === 0)
.filter((chan) => chan?.parentId === cat_to_prep_id[0]);
const all_channel_desired_name = all_channel_desired.map((c_d) => c_d?.name);
let desc = "";
const general = "général";
if (all_channel_desired_name.filter((cdn) => cdn === general).length === 0) {
interaction.guild?.channels.create({
name: general,
type: 0,
parent: cat_to_prep_id[0],
});
desc = general + loc.get("c_prep5") + "\n";
}
const info = "informations";
if (all_channel_desired_name.filter((cdn) => cdn === info).length === 0) {
interaction.guild?.channels.create({
name: info,
type: 0,
parent: cat_to_prep_id[0],
});
desc += "`" + info + "` " + loc.get("c_prep5") + "\n";
}
if (desc === "") {
desc = loc.get("c_prep6");
}
return interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(Colors.Blurple)
.setTitle(loc.get("c_prep4") + cat_to_prep_name)
.setDescription(desc),
],
});
});
},
};

View file

@ -19,7 +19,7 @@ import {
embedListReminders, embedListReminders,
getReminderInfo, getReminderInfo,
newReminder, newReminder,
} from "../../utils/reminder"; } from "../../utils/commands/reminder";
export default { export default {
scope: () => [], scope: () => [],
@ -145,25 +145,32 @@ export default {
case loc_default?.get(`c_${filename}_sub1_name`)?.toLowerCase(): { case loc_default?.get(`c_${filename}_sub1_name`)?.toLowerCase(): {
// If time is already renseigned // If time is already renseigned
const time = interaction.options.getString( const time = interaction.options.getString(
loc_default?.get(`c_${filename}_sub1_opt1_name`) as string, loc_default!.get(`c_${filename}_sub1_opt1_name`)!,
); );
if (time != null) { if (time != null) {
// Use the cli because we already have enough data // Use the cli because we already have enough data
return newReminder(client, time, { return newReminder(client, time, {
locale: interaction.locale, locale: interaction.locale,
message: interaction.options.getString( message: interaction.options.getString(
loc_default?.get(`c_${filename}_sub1_opt2_name`) as string, loc_default!.get(`c_${filename}_sub1_opt2_name`)!,
), ),
createdAt: interaction.createdAt.getTime(), createdAt: interaction.createdAt.getTime(),
channelId: interaction.channelId, channelId: interaction.channelId,
userId: interaction.user.id, userId: interaction.user.id,
guildId: interaction.guildId, guildId: interaction.guildId,
}).then((msg) => })
interaction.reply({ .then((msg) =>
content: msg as string, interaction.reply({
ephemeral: true, content: msg as string,
}), ephemeral: true,
); }),
)
.catch((err) => {
interaction.reply({
content: err,
ephemeral: true,
});
});
} else { } else {
// Show modal to user to get at least the time // Show modal to user to get at least the time
const modal = new ModalBuilder() const modal = new ModalBuilder()
@ -195,17 +202,13 @@ export default {
// List reminders // List reminders
case loc_default?.get(`c_${filename}_sub2_name`)?.toLowerCase(): { case loc_default?.get(`c_${filename}_sub2_name`)?.toLowerCase(): {
// Which user to show // Which user to show
let user = interaction.options.getUser( let user = interaction.options.getUser(loc_default!.get(`c_${filename}_sub2_opt1_name`)!);
loc_default?.get(`c_${filename}_sub2_opt1_name`) as string, if (user === null) {
);
if (user == null) {
user = interaction.user; user = interaction.user;
} }
const page = const page =
interaction.options.getInteger( interaction.options.getInteger(loc_default!.get(`c_${filename}_sub2_opt2_name`)!) ?? 1;
loc_default?.get(`c_${filename}_sub2_opt2_name`) as string,
) ?? 1;
const list = await embedListReminders( const list = await embedListReminders(
client, client,
user, user,
@ -246,7 +249,7 @@ export default {
// Delete a reminder // Delete a reminder
case loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase(): { case loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase(): {
const id = interaction.options.getInteger( const id = interaction.options.getInteger(
loc_default?.get(`c_${filename}_sub3_opt1_name`) as string, loc_default!.get(`c_${filename}_sub3_opt1_name`)!,
); );
if (id === null) { if (id === null) {
return interaction.reply({ return interaction.reply({

View file

@ -1,8 +1,9 @@
import { SlashCommandBuilder } from "@discordjs/builders"; import { SlashCommandBuilder } from "@discordjs/builders";
import { Player, useMainPlayer, useQueue } from "discord-player"; import { useMainPlayer, useQueue } from "discord-player";
import { ChatInputCommandInteraction, Client, EmbedBuilder } from "discord.js"; import { ChatInputCommandInteraction, Client, EmbedBuilder, 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";
import { discord_limit_message } from "../../utils/constants";
export default { export default {
scope: () => [], scope: () => [],
@ -57,7 +58,7 @@ export default {
), ),
) )
// Synced // Synced start
.addSubcommand((subcommand) => .addSubcommand((subcommand) =>
subcommand subcommand
.setName(loc_default.get(`c_${filename}_sub3_name`)!.toLowerCase()) .setName(loc_default.get(`c_${filename}_sub3_name`)!.toLowerCase())
@ -65,6 +66,15 @@ export default {
.setNameLocalizations(getLocalizations(client, `c_${filename}_sub3_name`, true)) .setNameLocalizations(getLocalizations(client, `c_${filename}_sub3_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_sub3_desc`)), .setDescriptionLocalizations(getLocalizations(client, `c_${filename}_sub3_desc`)),
) )
// Synced stop
.addSubcommand((subcommand) =>
subcommand
.setName(loc_default.get(`c_${filename}_sub4_name`)!.toLowerCase())
.setDescription(loc_default.get(`c_${filename}_sub4_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_sub4_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_sub4_desc`)),
)
); );
}, },
@ -74,18 +84,16 @@ export default {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
let request = interaction.options.getString( let request = interaction.options.getString(loc_default!.get(`c_${filename}_opt1_name`)!);
loc_default?.get(`c_${filename}_opt1_name`) as string,
);
let data = null; let data = null;
await interaction.deferReply(); await interaction.deferReply();
const player = useMainPlayer() as Player; const player = useMainPlayer();
const queue = useQueue(interaction.guildId!); const queue = useQueue(interaction.guildId!);
if (request) { if (request) {
if ( if (
interaction.options.getSubcommand() == interaction.options.getSubcommand() ===
loc_default?.get(`c_${filename}_sub2_name`)?.toLowerCase() loc_default?.get(`c_${filename}_sub2_name`)?.toLowerCase()
) { ) {
// Romanized // Romanized
@ -111,7 +119,7 @@ export default {
} }
if ( if (
interaction.options.getSubcommand() == interaction.options.getSubcommand() ===
loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase() loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase()
) { ) {
if (queue === null) { if (queue === null) {
@ -125,38 +133,78 @@ export default {
} }
// Load lyrics // Load lyrics
const syncedLyrics = queue.syncedLyrics(data[0]); if (queue.syncedLyricsMemory !== undefined) {
return await interaction.followUp(loc.get("c_lyrics9"));
}
const syncedLyrics = queue.syncedLyrics(data[0]);
queue.syncedLyricsMemory = syncedLyrics;
let message: Message;
syncedLyrics?.onChange(async (lyrics) => { syncedLyrics?.onChange(async (lyrics) => {
const content = `[${data[0].trackName}]: ${lyrics}`;
if (interaction.channel?.isSendable()) { if (interaction.channel?.isSendable()) {
await interaction.channel?.send({ if (message) {
content, const payload = message.cleanContent + "\n" + lyrics;
}); if (payload.length < discord_limit_message) {
message.edit(payload);
return;
}
}
message = await interaction.channel?.send(
(message ? loc.get("c_lyrics6") + " " : "") +
`${data[0].artistName} : **${data[0].trackName}**\n\n` +
lyrics,
);
} else { } else {
await interaction.followUp({ await interaction.followUp(loc.get("c_lyrics5"));
content,
});
} }
}); });
// Live update // Live update
syncedLyrics.subscribe(); syncedLyrics.subscribe();
syncedLyrics.onUnsubscribe(() => {
queue.syncedLyricsMemory = undefined;
});
return await interaction.followUp({ return await interaction.followUp({
content: `🎤 | ${loc.get("c_lyrics4")}`, content: `🎤 | ${loc.get("c_lyrics4")}`,
ephemeral: true, ephemeral: true,
}); });
} }
if (
interaction.options.getSubcommand() ===
loc_default?.get(`c_${filename}_sub4_name`)?.toLowerCase()
) {
if (queue === null) {
return await interaction.followUp(`❌ | ${loc.get("c_lyrics1")}`);
}
if (data === null || !data[0] || !data[0].syncedLyrics) {
return await interaction.followUp(
`❌ | ${loc.get("c_lyrics3")} \`${queue.currentTrack?.cleanTitle}\``,
);
}
// Load lyrics
if (queue.syncedLyricsMemory !== undefined && queue.syncedLyricsMemory.isSubscribed()) {
queue.syncedLyricsMemory.unsubscribe();
return await interaction.followUp(loc.get("c_lyrics7"));
}
return await interaction.followUp(loc.get("c_lyrics8"));
}
if (data && data.length > 0 && data[0].plainLyrics !== null) { if (data && data.length > 0 && data[0].plainLyrics !== null) {
const title = data[0]; const title = data[0];
const limit_desc = 4096; const limit_desc = 4096;
const nb_embed = Math.ceil(title.plainLyrics.length / limit_desc); const nb_embed = Math.ceil(title.plainLyrics.length / limit_desc);
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/186
// TODO: If lyrics < 6000, only send one message with multiples embed // TODO: If lyrics < 6000, only send one message with multiples embed
for (let i = 0, j = 0; i < nb_embed; i++, j += limit_desc) { for (let i = 0, j = 0; i < nb_embed; i++, j += limit_desc) {
// TODO: Better cut in lyrics // + Better cut in lyrics
const lyrics = title.plainLyrics.slice(j, j + limit_desc); const lyrics = title.plainLyrics.slice(j, j + limit_desc);
let embed; let embed;

View file

@ -1,16 +1,19 @@
import { SlashCommandBuilder } from "@discordjs/builders"; import { SlashCommandBuilder } from "@discordjs/builders";
import { Player, SearchResult, useMainPlayer, useQueue } from "discord-player"; import { SearchResult, useMainPlayer, useQueue } from "discord-player";
import { import {
AutocompleteInteraction, AutocompleteInteraction,
ChatInputCommandInteraction, ChatInputCommandInteraction,
Client, Client,
EmbedBuilder, EmbedBuilder,
GuildResolvable,
VoiceBasedChannel,
} from "discord.js"; } from "discord.js";
import { getLocale, getLocalizations } from "../../utils/locales"; import { getLocale, getLocalizations } from "../../utils/locales";
import { Metadata } from "../../utils/metadata"; import { Metadata } from "../../utils/metadata";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import {
discord_limit_autocompletion_list_length,
discord_limit_autocompletion_value_length,
} from "../../utils/constants";
import { timeToString } from "../../utils/time";
export default { export default {
scope: () => [], scope: () => [],
@ -29,14 +32,42 @@ export default {
.setNameLocalizations(getLocalizations(client, `c_${filename}_name`, true)) .setNameLocalizations(getLocalizations(client, `c_${filename}_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_desc`)) .setDescriptionLocalizations(getLocalizations(client, `c_${filename}_desc`))
// Command option // Normal
.addStringOption((option) => .addSubcommand((subcommand) =>
option subcommand
.setName(loc_default.get(`c_${filename}_opt1_name`)!.toLowerCase()) .setName(loc_default.get(`c_${filename}_sub1_name`)!.toLowerCase())
.setDescription(loc_default.get(`c_${filename}_opt1_desc`)!) .setDescription(loc_default.get(`c_${filename}_sub1_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_opt1_name`, true)) .setNameLocalizations(getLocalizations(client, `c_${filename}_sub1_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_opt1_desc`)) .setDescriptionLocalizations(getLocalizations(client, `c_${filename}_sub1_desc`))
.setAutocomplete(true),
// Command option
.addStringOption((option) =>
option
.setName(loc_default.get(`c_${filename}_opt1_name`)!.toLowerCase())
.setDescription(loc_default.get(`c_${filename}_opt1_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_opt1_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_opt1_desc`))
.setAutocomplete(true),
),
)
// Play now
.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`))
// Command option
.addStringOption((option) =>
option
.setName(loc_default.get(`c_${filename}_opt1_name`)!.toLowerCase())
.setDescription(loc_default.get(`c_${filename}_opt1_desc`)!)
.setNameLocalizations(getLocalizations(client, `c_${filename}_opt1_name`, true))
.setDescriptionLocalizations(getLocalizations(client, `c_${filename}_opt1_desc`))
.setAutocomplete(true),
),
) )
); );
}, },
@ -67,11 +98,9 @@ export default {
}); });
} }
const query = interaction.options.getString( const query = interaction.options.getString(loc_default!.get(`c_${filename}_opt1_name`)!);
loc_default?.get(`c_${filename}_opt1_name`) as string,
);
const player = useMainPlayer() as Player; const player = useMainPlayer();
if (!query) { if (!query) {
// Now playing // Now playing
@ -97,7 +126,8 @@ export default {
return await interaction.reply({ embeds: [embed] }); return await interaction.reply({ embeds: [embed] });
} }
const queue = player.nodes.create(interaction.guild as GuildResolvable, { const queue = player.nodes.create(interaction.guild!, {
volume: 50,
defaultFFmpegFilters: ["silenceremove"], defaultFFmpegFilters: ["silenceremove"],
metadata: { metadata: {
channel: interaction.channel, channel: interaction.channel,
@ -106,7 +136,7 @@ export default {
// Verify vc connection // Verify vc connection
try { try {
if (!queue.connection) await queue.connect(member.voice.channel as VoiceBasedChannel); if (!queue.connection) await queue.connect(member.voice.channel!);
} catch { } catch {
queue.delete(); queue.delete();
return await interaction.reply({ return await interaction.reply({
@ -134,7 +164,15 @@ export default {
} else { } else {
const track = result.tracks[0]; const track = result.tracks[0];
queue.addTrack(track); if (
interaction.options.getSubcommand() ===
loc_default?.get(`c_${filename}_sub2_name`)?.toLowerCase()
) {
queue.insertTrack(track, 0);
} else {
queue.addTrack(track);
}
title = track.title; title = track.title;
} }
@ -142,11 +180,29 @@ export default {
queue.node.play(); queue.node.play();
} }
// TODO: When added to an existing queue (size of queue > 0): const positionEstimation = () => {
// - Add position in queue const pos = queue.node.getTrackPosition(result.tracks[0]) + 1;
// - Add estimated time until playing
if (pos === 0) {
return loc.get("c_play_sub2_name");
}
const estimation = timeToString(
[queue.currentTrack, ...queue.tracks.toArray()]
.filter((t) => t !== null)
.slice(0, pos)
.reduce((total, t) => {
if (total === 0) {
return queue.dispatcher ? t.durationMS - queue.dispatcher.streamTime : t.durationMS;
}
return total + t.durationMS;
}, 0),
);
return `${loc.get("c_play10")} ${pos} (${loc.get("c_play11")}${estimation})`;
};
return await interaction.followUp({ return await interaction.followUp({
content: `⏱️ | \`${title}\` ${loc.get("c_play5")}.`, content: `⏱️ | \`${title}\` ${loc.get("c_play5")}, ${loc.get("c_play12")} ${positionEstimation()}.`,
}); });
}, },
@ -155,13 +211,13 @@ export default {
const loc_default = interaction.client.locales.get(interaction.client.config.default_lang); const loc_default = interaction.client.locales.get(interaction.client.config.default_lang);
const filename = getFilename(__filename); const filename = getFilename(__filename);
const player = useMainPlayer() as Player; const player = useMainPlayer();
const query = interaction.options.getString( const query = interaction.options.getString(loc_default!.get(`c_${filename}_opt1_name`)!, true);
loc_default?.get(`c_${filename}_opt1_name`) as string,
true,
);
const limit_value_discord = 100; const limit_value_discord = discord_limit_autocompletion_value_length;
const limit_element_discord = discord_limit_autocompletion_list_length;
const query_discord = query.slice(0, limit_value_discord);
if (query) { if (query) {
/* Since Discord wanna receive a response within 3 secs and results is async /* Since Discord wanna receive a response within 3 secs and results is async
@ -177,7 +233,7 @@ export default {
/* Create a race between a timeout and the search /* Create a race between a timeout and the search
* At the end, Discord will always receive a response */ * At the end, Discord will always receive a response */
let tracks = await Promise.race([ const tracks = await Promise.race([
delay, delay,
player.search(query, { player.search(query, {
requestedBy: interaction.user, requestedBy: interaction.user,
@ -194,23 +250,18 @@ export default {
// If tracks found // If tracks found
if (tracks.length > 0) { if (tracks.length > 0) {
if (tracks.length > 25) { const payload = tracks
tracks = tracks // Assure that URL is under the limit of Discord
// Assure that URL is under the limit of Discord .filter((v) => v.url.length < limit_value_discord)
.filter((v) => v.url.length < limit_value_discord) // Slice the list to respect the limit of Discord
// Slice the list if needed to the 25 first results .slice(0, limit_element_discord - 1)
.slice(0, 25); .map((t) => {
}
// Returns a list of songs with their title and author
return interaction.respond(
tracks.map((t) => {
let title = t.title; let title = t.title;
let author = t.author; let author = t.author;
let name = `${title}${author}`; let name = `${title}${author}`;
// Slice returned data if needed to not exceed the length limit (100) // Slice returned data if needed to not exceed the length limit
if (name.length > 100) { if (name.length > limit_value_discord) {
const newTitle = title.substring(0, 40); const newTitle = title.substring(0, 40);
if (title.length != newTitle.length) { if (title.length != newTitle.length) {
title = `${newTitle}...`; title = `${newTitle}...`;
@ -226,12 +277,18 @@ export default {
name, name,
value: t.url, value: t.url,
}; };
}), });
);
payload.unshift({
name: query_discord,
value: query_discord,
});
// Returns a list of songs with their title and author
return interaction.respond(payload);
} }
} }
return interaction.respond([
{ name: loc.get("c_play9"), value: query.slice(0, limit_value_discord) }, return interaction.respond([{ name: loc.get("c_play9"), value: query_discord }]);
]);
}, },
}; };

View file

@ -12,7 +12,7 @@ import { v4 as uuidv4 } from "uuid";
import { collect } from "../../buttons/loader"; import { collect } from "../../buttons/loader";
import { getLocale, getLocalizations } from "../../utils/locales"; import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { embedListQueue } from "../../utils/music"; import { embedListQueue } from "../../utils/commands/music";
export default { export default {
scope: () => [], scope: () => [],
@ -72,6 +72,7 @@ export default {
// Specified ID // Specified ID
// TODO?: ID range -> as a string: 5-8 remove 5, 6, 7, 8 // TODO?: ID range -> as a string: 5-8 remove 5, 6, 7, 8
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/185
.addNumberOption((option) => .addNumberOption((option) =>
option option
.setName(loc_default.get(`c_${filename}_sub3_opt1_name`)!.toLowerCase()) .setName(loc_default.get(`c_${filename}_sub3_opt1_name`)!.toLowerCase())
@ -103,9 +104,7 @@ export default {
// Show the queue // Show the queue
case loc_default?.get(`c_${filename}_sub1_name`)?.toLowerCase(): { case loc_default?.get(`c_${filename}_sub1_name`)?.toLowerCase(): {
const page = const page =
interaction.options.getNumber( interaction.options.getNumber(loc_default!.get(`c_${filename}_sub1_opt1_name`)!) ?? 1;
loc_default?.get(`c_${filename}_sub1_opt1_name`) as string,
) ?? 1;
embedListQueue(client, embed, queue, page, interaction.locale); embedListQueue(client, embed, queue, page, interaction.locale);
@ -152,8 +151,8 @@ export default {
// Remove <ID> // Remove <ID>
case loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase(): { case loc_default?.get(`c_${filename}_sub3_name`)?.toLowerCase(): {
const id = interaction.options.getNumber( const id = interaction.options.getNumber(
loc_default?.get(`c_${filename}_sub3_opt1_name`) as string, loc_default!.get(`c_${filename}_sub3_opt1_name`)!,
) as number; )!;
const track = queue.removeTrack(id - 1); const track = queue.removeTrack(id - 1);

View file

@ -1,8 +1,7 @@
import { SlashCommandBuilder } from "@discordjs/builders"; import { SlashCommandBuilder } from "@discordjs/builders";
import { Player, useMainPlayer } from "discord-player"; import { useMainPlayer } from "discord-player";
import { ChatInputCommandInteraction, Client, GuildResolvable } from "discord.js"; import { ChatInputCommandInteraction, Client } from "discord.js";
import { getLocale, getLocalizations } from "../../utils/locales"; import { getLocale, getLocalizations } from "../../utils/locales";
import { Metadata } from "../../utils/metadata";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
export default { export default {
@ -25,11 +24,11 @@ export default {
interaction: async (interaction: ChatInputCommandInteraction, client: Client) => { interaction: async (interaction: ChatInputCommandInteraction, client: Client) => {
const loc = getLocale(client, interaction.locale); const loc = getLocale(client, interaction.locale);
const player = useMainPlayer() as Player; const player = useMainPlayer();
const queue = player.nodes.create(interaction.guild as GuildResolvable, { const queue = player.nodes.create(interaction.guild!, {
metadata: { metadata: {
channel: interaction.channel, channel: interaction.channel,
} as Metadata, },
}); });
if (!(queue.connection || queue.node.isPlaying())) { if (!(queue.connection || queue.node.isPlaying())) {

View file

@ -8,7 +8,8 @@ import {
sendReminder, sendReminder,
setTimeoutReminder, setTimeoutReminder,
updateReminder, updateReminder,
} from "../../utils/reminder"; } from "../../utils/commands/reminder";
import { readSQL } from "../../utils/db";
export const once = true; export const once = true;
@ -19,7 +20,7 @@ export default async (client: Client) => {
// Restart all the timeout about reminders here // Restart all the timeout about reminders here
new Promise((ok, ko) => { new Promise((ok, ko) => {
// Fetch all reminders // Fetch all reminders
client.db.all("SELECT * FROM reminder", [], (err, row) => { client.db.all(readSQL("reminder/select"), [], (err, row) => {
if (err) { if (err) {
ko(err); ko(err);
} }
@ -41,14 +42,18 @@ export default async (client: Client) => {
} as infoReminder; } as infoReminder;
if (element.expiration_date <= now) { if (element.expiration_date <= now) {
// Reminder expired sendReminder(client, info, element.option_id as OptionReminder)
deleteReminder(client, element.creation_date, `${element.user_id}`).then((res) => { .then(() =>
if (res != true) { // Reminder expired
throw res; deleteReminder(client, element.creation_date, `${element.user_id}`).then((res) => {
} if (res != true) {
}); throw res;
}
sendReminder(client, info, element.option_id as OptionReminder); }),
)
.catch((err) => {
throw err;
});
} else { } else {
// Restart timeout // Restart timeout
const timeoutId = setTimeoutReminder( const timeoutId = setTimeoutReminder(

View file

@ -1,12 +1,37 @@
import { Player, PlayerEvents, useMainPlayer } from "discord-player"; import { PlayerEvents, useMainPlayer } from "discord-player";
import { Client } from "discord.js"; import { Client } from "discord.js";
import { readdir } from "fs/promises"; import { readdir } from "fs/promises";
import { isDev, splitFilenameExtensions } from "../utils/misc";
/** Load all the events */ /** Load all the events */
export default async (client: Client) => { export default async (client: Client) => {
const events_categories = (await readdir(__dirname)).filter( const events_categories = (await readdir(__dirname, { withFileTypes: true }))
(element) => !element.endsWith(".js") && !element.endsWith(".ts"), .filter((element) => element.isDirectory())
); .map((element) => element.name);
const player = useMainPlayer();
if (isDev) {
player.on("debug", async (message) => {
console.log(`General player debug event: ${message}`);
});
player.events.on("debug", async (_, message) => {
console.log(`Player debug event: ${message}`);
});
}
player.events.on("error", (_, error) => {
// Emitted when the player queue encounters error
console.error(`General player error event: ${error.message}`);
console.error(error);
});
player.events.on("playerError", (_, error) => {
// Emitted when the audio player errors while streaming audio track
console.error(`Player error event: ${error.message}`);
console.error(error);
});
events_categories.forEach(async (event_category) => { events_categories.forEach(async (event_category) => {
// Retrieve events // Retrieve events
@ -20,16 +45,12 @@ export default async (client: Client) => {
); );
// Remove extension // Remove extension
// TODO: use utils functions const { file: event_type, ext } = splitFilenameExtensions(event_file)!;
const event_type_ext = event_file.split(".");
const ext = event_type_ext.pop();
if (!(ext === "js" || ext === "ts")) { if (!(ext === "js" || ext === "ts")) {
throw `Unknown file in ${event_category}: ${event_file}`; throw `Unknown file in ${event_category}: ${event_file}`;
} }
const event_type = event_type_ext.join(".");
if (event_category == "player") { if (event_category === "player") {
const player = useMainPlayer() as Player;
if (once) { if (once) {
// eslint-disable-next-line // eslint-disable-next-line
return player.events.once(event_type as keyof PlayerEvents, (...args: any[]) => { return player.events.once(event_type as keyof PlayerEvents, (...args: any[]) => {

View file

@ -1,7 +1,9 @@
import { Client, EmbedBuilder, GuildMember, Message, TextBasedChannel } from "discord.js"; import { Client, EmbedBuilder, Message, TextBasedChannel } from "discord.js";
import { getLocale } from "../../utils/locales"; import { getLocale } from "../../utils/locales";
import { isImage, userWithNickname } from "../../utils/misc"; import { userWithNickname } from "../../utils/misc";
import { showDate } from "../../utils/time"; import { showDate } from "../../utils/time";
import { RegexC, RegExpFlags } from "../../utils/regex";
import { handleAttachments } from "../../utils/events/citation";
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-messageCreate */ /** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-messageCreate */
export default async (message: Message, client: Client) => { export default async (message: Message, client: Client) => {
@ -24,7 +26,7 @@ export default async (message: Message, client: Client) => {
/* Citation */ /* Citation */
const regex = const regex =
/https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19})/g; /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19})/g;
const urls = message.content.match(new RegExp(regex, "g")); const urls = message.content.match(RegexC(regex, RegExpFlags.Global));
// Ignore message if there is no URLs // Ignore message if there is no URLs
if (!urls) { if (!urls) {
@ -42,7 +44,7 @@ export default async (message: Message, client: Client) => {
}[] = [], }[] = [],
match, match,
) => { ) => {
const [, guild_id, channel_id, message_id] = new RegExp(regex).exec( const [, guild_id, channel_id, message_id] = RegexC(regex).exec(
match, match,
) as RegExpExecArray; ) as RegExpExecArray;
@ -51,10 +53,10 @@ export default async (message: Message, client: Client) => {
return data; return data;
} }
const channel = message.guild.channels.cache.get(channel_id) as TextBasedChannel; const channel = message.guild.channels.cache.get(channel_id);
// If channel doesn't exist in the guild and isn't text // If channel doesn't exist in the guild and isn't text
if (!channel) { if (!channel || !channel.isTextBased()) {
return data; return data;
} }
@ -65,12 +67,25 @@ export default async (message: Message, client: Client) => {
[], [],
) )
.map(async ({ message_id, channel }) => { .map(async ({ message_id, channel }) => {
const quoted_message = await channel.messages.fetch(message_id).catch(() => undefined); let quoted_message = await channel.messages.fetch(message_id).catch(() => undefined);
// If it's a reference, we only check for reference once
const message_reference = quoted_message?.reference;
if (message_reference && message_reference.messageId) {
const channel_reference = client.channels.cache.get(message_reference.channelId);
if (!channel_reference?.isTextBased()) {
return;
}
quoted_message = await channel_reference.messages
.fetch(message_reference.messageId)
.catch(() => undefined);
}
// If message doesn't exist or empty // If message doesn't exist or empty
if ( if (
!quoted_message || !quoted_message ||
(!quoted_message.content && quoted_message.attachments.size == 0) (!quoted_message.content && quoted_message.attachments.size === 0)
) { ) {
return; return;
} }
@ -82,100 +97,88 @@ export default async (message: Message, client: Client) => {
// Remove undefined elements // Remove undefined elements
.filter(Boolean); .filter(Boolean);
const loc = getLocale(client, client.config.default_lang); const loc = getLocale(client);
// Remove duplicates then map the quoted posts // Remove duplicates then map the quoted posts
[...new Set(messages)].map((quoted_post) => { [...new Set(messages)]
const embed = new EmbedBuilder().setColor("#2f3136").setAuthor({ .filter((p) => p !== undefined)
name: "Citation", .map((quoted_post) => {
iconURL: quoted_post?.author.displayAvatarURL(), const embed = new EmbedBuilder().setColor("#2f3136").setAuthor({
}); name: "Citation",
iconURL: quoted_post.author.displayAvatarURL(),
});
// Handle attachments // Handle attachments
if (quoted_post?.attachments.size !== 0) { if (quoted_post.attachments.size !== 0) {
if ( handleAttachments(loc, embed, quoted_post.attachments);
quoted_post?.attachments.size === 1 && }
isImage(quoted_post.attachments.first()?.name as string)
) { // Description as post content
// Only contains one image if (quoted_post.content) {
embed.setImage(quoted_post.attachments.first()?.url as string); // Only if content exists and length > 0
embed.setDescription(quoted_post.content);
}
// Footer
let footer = `Posté le ${showDate(
message.guild?.preferredLocale ?? client.config.default_lang,
loc,
quoted_post.createdAt,
)}`;
if (quoted_post.editedAt) {
footer += ` et modifié le ${showDate(
message.guild?.preferredLocale ?? client.config.default_lang,
loc,
quoted_post.editedAt,
)}`;
}
let author = "Auteur";
if (message.author === quoted_post.author) {
author += " & Citateur";
} else { } else {
// Contains more than one image and/or other files footer += `\nCité par ${userWithNickname(message.member!) ?? "?"} le ${showDate(
let files = ""; message.guild?.preferredLocale ?? client.config.default_lang,
quoted_post?.attachments.forEach((file) => (files += `[${file.name}](${file.url}), `)); loc,
embed.addFields({ message.createdAt,
// TODO: Don't pluralize when there is only one file. )}`;
// TODO: Locales }
name: "Fichiers joints",
// TODO: Check if don't exceed char limit, if yes, split embed.setFooter({
// files into multiples field. text: footer,
value: `${files.slice(0, -2)}.`, iconURL: message.author.avatarURL() ?? undefined,
});
// Location/author of the quoted post
embed.addFields(
{
name: author,
value: `${quoted_post.author}`,
inline: true,
},
{
name: "Message",
value: `${quoted_post.channel} - [Lien Message](${quoted_post.url})`,
inline: true,
},
);
// Delete source message if no content when removing links
if (
!message.content.replace(RegexC(regex, RegExpFlags.Global), "").trim() &&
messages.length === urls.length &&
!message.mentions.repliedUser &&
message.channel.isSendable()
) {
message.delete();
return message.channel.send({ embeds: [embed] });
} else {
return message.reply({
embeds: [embed],
allowedMentions: {
repliedUser: false,
},
}); });
} }
}
// Description as post content
if (quoted_post?.content) {
// Only if content exists and length > 0
embed.setDescription(quoted_post?.content);
}
// Footer
let footer = `Posté le ${showDate(
client.config.default_lang,
loc,
quoted_post?.createdAt as Date,
)}`;
if (quoted_post?.editedAt) {
footer += ` et modifié le ${showDate(client.config.default_lang, loc, quoted_post.editedAt)}`;
}
let author = "Auteur";
if (message.author == quoted_post?.author) {
author += " & Citateur";
} else {
footer += `\nCité par ${userWithNickname(message.member as GuildMember) ?? "?"} le ${showDate(
client.config.default_lang,
loc,
message.createdAt,
)}`;
}
embed.setFooter({
text: footer,
iconURL: message.author.avatarURL() ?? undefined,
}); });
// Location/author of the quoted post
embed.addFields(
{
name: author,
value: `${quoted_post?.author}`,
inline: true,
},
{
name: "Message",
value: `${quoted_post?.channel} - [Lien Message](${quoted_post?.url})`,
inline: true,
},
);
// Delete source message if no content when removing links
if (
!message.content.replace(new RegExp(regex, "g"), "").trim() &&
messages.length === urls.length &&
!message.mentions.repliedUser &&
message.channel.isSendable()
) {
message.delete();
return message.channel.send({ embeds: [embed] });
} else {
return message.reply({
embeds: [embed],
allowedMentions: {
repliedUser: false,
},
});
}
});
}; };

View file

@ -1,8 +1,8 @@
const isDev = process.env.NODE_ENV !== "production"; import { isDev } from "./utils/misc";
/** Load the app */ /** Load the app */
const start_app = () => { const start_app = () => {
import("./load").then((l) => l.run(isDev).catch((error) => console.error(error))); import("./load").then((l) => l.run().catch((error) => console.error(error)));
}; };
// Load .env if not in prod // Load .env if not in prod

View file

@ -4,10 +4,10 @@ import loadEvents from "./events/loader";
import loadModals from "./modals/loader"; import loadModals from "./modals/loader";
import loadClient, { quit } from "./utils/client"; import loadClient, { quit } from "./utils/client";
import { logStart } from "./utils/misc"; import { isDev, logStart } from "./utils/misc";
/** Run the bot */ /** Run the bot */
export const run = async (isDev: boolean) => { export const run = async () => {
console.log("Starting Botanique..."); console.log("Starting Botanique...");
// Client Discord.JS // Client Discord.JS
@ -61,11 +61,11 @@ export const run = async (isDev: boolean) => {
console.log(logStart(client_name, true)); 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 // Handle quit
process.on("SIGINT", () => quit(client)); process.on("exit", () => quit(client));
process.on("SIGHUP", () => process.exit(128 + 1));
// Container force closed process.on("SIGINT", () => process.exit(128 + 2));
process.on("SIGTERM", () => quit(client)); process.on("SIGTERM", () => process.exit(128 + 15));
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);

View file

@ -17,6 +17,29 @@
"c_help2": "`/help <command>` to get more information about a command.", "c_help2": "`/help <command>` to get more information about a command.",
"c_help3": "Can't find :", "c_help3": "Can't find :",
"c_archive_name": "clean",
"c_archive_desc": "Clean category for the new year",
"c_archive_opt1_name": "category",
"c_archive_opt1_desc": "Name of the category to be cleaned",
"c_archive1": "List of categories subject to cleaning",
"c_archive2": "`L1`, `L2`, `L3`, `M1`, `M2`",
"c_archive3": "Unable to find/clean the channel:",
"c_archive4": "List of archived channels in the category",
"c_archive5": "to",
"c_archive6": "Cleaning",
"c_archive7": "Category already cleaned",
"c_prep_name": "Preparation",
"c_prep_desc": "Preparation of general channels for the new year",
"c_prep_opt1_name": "year",
"c_prep_opt1_desc": "Name of the year to be prepared",
"c_prep1": "List of categories submitted to the preparation",
"c_prep2": "`L1`, `L2`, `L3`, `M1`, `M2`",
"c_prep3": "Unable to find/clean the channel:",
"c_prep4": "Lists of prepared channels `",
"c_prep5": "created",
"c_prep6": "No preparation is required",
"u_time_at": "at", "u_time_at": "at",
"c_reminder_name": "reminder", "c_reminder_name": "reminder",
@ -54,9 +77,14 @@
"c_reminder15": "Message sent in DM because you have left", "c_reminder15": "Message sent in DM because you have left",
"c_reminder16": "Message sent in DM because the Discord guild is no longer available.", "c_reminder16": "Message sent in DM because the Discord guild is no longer available.",
"c_reminder17": "Message from", "c_reminder17": "Message from",
"c_reminder18": "Invalid time, try again.",
"c_play_name": "play", "c_play_name": "play",
"c_play_desc": "Plays a song/playlist, no query displays the now playing song", "c_play_desc": "Plays a song/playlist, no query displays the now playing song",
"c_play_sub1_name": "add",
"c_play_sub1_desc": "Adds song/playlist to the queue",
"c_play_sub2_name": "now",
"c_play_sub2_desc": "Adds the song/playlist to the beginning of the queue",
"c_play_opt1_name": "query", "c_play_opt1_name": "query",
"c_play_opt1_desc": "What you want to listen to", "c_play_opt1_desc": "What you want to listen to",
"c_play1": "You're not on any vocal channels.", "c_play1": "You're not on any vocal channels.",
@ -68,6 +96,9 @@
"c_play7": "Currently playing", "c_play7": "Currently playing",
"c_play8": "Asked by", "c_play8": "Asked by",
"c_play9": "No results were found", "c_play9": "No results were found",
"c_play10": "in position",
"c_play11": "estimation",
"c_play12": "play",
"c_stop_name": "stop", "c_stop_name": "stop",
"c_stop_desc": "Stop the music", "c_stop_desc": "Stop the music",
@ -120,12 +151,19 @@
"c_lyrics_sub2_desc": "Search for romanized lyrics (e.g., hangul → Latin)", "c_lyrics_sub2_desc": "Search for romanized lyrics (e.g., hangul → Latin)",
"c_lyrics_sub3_name": "synced", "c_lyrics_sub3_name": "synced",
"c_lyrics_sub3_desc": "Synchronized lyrics search (updates in live)", "c_lyrics_sub3_desc": "Synchronized lyrics search (updates in live)",
"c_lyrics_sub4_name": "stop-synced",
"c_lyrics_sub4_desc": "Stop Synchronized lyrics",
"c_lyrics_opt1_name": "song", "c_lyrics_opt1_name": "song",
"c_lyrics_opt1_desc": "Wanted song", "c_lyrics_opt1_desc": "Wanted song",
"c_lyrics1": "The bot is not playing anything at the moment, and no songs are specified.", "c_lyrics1": "The bot is not playing anything at the moment, and no songs are specified.",
"c_lyrics2": "Unable to find the lyrics for", "c_lyrics2": "Unable to find the lyrics for",
"c_lyrics3": "Unable to find synchronized lyrics for", "c_lyrics3": "Unable to find synchronized lyrics for",
"c_lyrics4": "It's karaoke time!", "c_lyrics4": "It's karaoke time!",
"c_lyrics5": "Unable to post the lyrics here.",
"c_lyrics6": "More of :",
"c_lyrics7": "Stop synchronized lyrics.",
"c_lyrics8": "No synchronized lyrics currently posted.",
"c_lyrics9": "Synchronized lyrics currently posted.",
"c_repeat_name": "repeat", "c_repeat_name": "repeat",
"c_repeat_desc": "Command for the type of music repetition", "c_repeat_desc": "Command for the type of music repetition",
@ -145,5 +183,8 @@
"c_repeat6": "enabled", "c_repeat6": "enabled",
"e_trackstart1": "Asked by", "e_trackstart1": "Asked by",
"e_trackstart2": "Duration :" "e_trackstart2": "Duration :",
"e_attachement": "Attachement",
"e_attachements": "Attachements"
} }

View file

@ -17,6 +17,29 @@
"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 :",
"c_archive_name": "Nettoyer",
"c_archive_desc": "Nettoyage pour le passage à niveau",
"c_archive_opt1_name": "catégorie",
"c_archive_opt1_desc": "Nom de la catégorie à nettoyer",
"c_archive1": "Liste des catégories soumises au nettoyage",
"c_archive2": "`L1`, `L2`, `L3`, `M1`, `M2`",
"c_archive3": "Impossible de trouver/nettoyer le salon :",
"c_archive4": "Liste des salons archivés de la catégorie",
"c_archive5": "vers",
"c_archive6": "Nettoyage",
"c_archive7": "Catégorie déjà nettoyée",
"c_prep_name": "Préparation",
"c_prep_desc": "Préparation des salons généraux pour la nouvelle année",
"c_prep_opt1_name": "année",
"c_prep_opt1_desc": "Nom de l'année à préparer",
"c_prep1": "Liste des catégories soumises à la préparation",
"c_prep2": "`L1`, `L2`, `L3`, `M1`, `M2`",
"c_prep3": "Impossible de trouver/nettoyer le salon :",
"c_prep4": "Listes des salons préparés `",
"c_prep5": "créé",
"c_prep6": "Pas besoin de préparation",
"u_time_at": "à", "u_time_at": "à",
"c_reminder_name": "rappel", "c_reminder_name": "rappel",
@ -54,9 +77,14 @@
"c_reminder15": "Message envoyé en DM car vous avez quitté", "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_reminder16": "Message envoyé en DM car le serveur Discord n'est plus disponible.",
"c_reminder17": "Message d'il y a", "c_reminder17": "Message d'il y a",
"c_reminder18": "Temps invalide, réessayez.",
"c_play_name": "play", "c_play_name": "play",
"c_play_desc": "Joue une chanson/playlist, pas de requête affiche la chanson en cours actuellement", "c_play_desc": "Joue une chanson/playlist, pas de requête affiche la chanson en cours actuellement",
"c_play_sub1_name": "ajouter",
"c_play_sub1_desc": "Ajoute la chanson/playlist à la file d'attente",
"c_play_sub2_name": "maintenant",
"c_play_sub2_desc": "Ajoute la chanson/playlist au début de la file",
"c_play_opt1_name": "requête", "c_play_opt1_name": "requête",
"c_play_opt1_desc": "Ce que vous voulez écouter", "c_play_opt1_desc": "Ce que vous voulez écouter",
"c_play1": "Tu n'es dans aucun salon vocal.", "c_play1": "Tu n'es dans aucun salon vocal.",
@ -68,6 +96,9 @@
"c_play7": "Joue actuellement", "c_play7": "Joue actuellement",
"c_play8": "Demandé par", "c_play8": "Demandé par",
"c_play9": "Aucun résultat trouvé", "c_play9": "Aucun résultat trouvé",
"c_play10": "en position",
"c_play11": "estimation",
"c_play12": "joue",
"c_stop_name": "stop", "c_stop_name": "stop",
"c_stop_desc": "Stop la musique", "c_stop_desc": "Stop la musique",
@ -120,12 +151,19 @@
"c_lyrics_sub2_desc": "Recherche de paroles romanisées (ex: hangul → latin)", "c_lyrics_sub2_desc": "Recherche de paroles romanisées (ex: hangul → latin)",
"c_lyrics_sub3_name": "synced", "c_lyrics_sub3_name": "synced",
"c_lyrics_sub3_desc": "Recherche de paroles synchronisées (se mettent à jour avec la chanson en direct)", "c_lyrics_sub3_desc": "Recherche de paroles synchronisées (se mettent à jour avec la chanson en direct)",
"c_lyrics_sub4_name": "stop-synced",
"c_lyrics_sub4_desc": "Arrête les paroles synchronisées",
"c_lyrics_opt1_name": "chanson", "c_lyrics_opt1_name": "chanson",
"c_lyrics_opt1_desc": "Chanson recherchée", "c_lyrics_opt1_desc": "Chanson recherchée",
"c_lyrics1": "Le bot ne joue rien en ce moment et aucune chanson n'est renseignée.", "c_lyrics1": "Le bot ne joue rien en ce moment et aucune chanson n'est renseignée.",
"c_lyrics2": "Impossible de trouver les paroles pour", "c_lyrics2": "Impossible de trouver les paroles pour",
"c_lyrics3": "Impossible de trouver les paroles synchronisées pour", "c_lyrics3": "Impossible de trouver les paroles synchronisées pour",
"c_lyrics4": "C'est parti !", "c_lyrics4": "C'est parti !",
"c_lyrics5": "Impossible de poster les paroles ici.",
"c_lyrics6": "Suite de :",
"c_lyrics7": "Arrêt des paroles synchronisées.",
"c_lyrics8": "Pas de paroles synchronisées en cours.",
"c_lyrics9": "Paroles synchronisées déjà en cours.",
"c_repeat_name": "repeat", "c_repeat_name": "repeat",
"c_repeat_desc": "Commande relative à la répétition des musiques", "c_repeat_desc": "Commande relative à la répétition des musiques",
@ -145,5 +183,8 @@
"c_repeat6": "activé", "c_repeat6": "activé",
"e_trackstart1": "Demandé par", "e_trackstart1": "Demandé par",
"e_trackstart2": "Durée :" "e_trackstart2": "Durée :",
"e_attachement": "Fichier joint",
"e_attachements": "Fichiers joint"
} }

View file

@ -1,15 +1,17 @@
import { Client, ModalSubmitInteraction } from "discord.js"; import { Client, ModalSubmitInteraction } from "discord.js";
import { getFilename } from "../../utils/misc"; import { getFilename } from "../../utils/misc";
import { newReminder } from "../../utils/reminder"; import { newReminder } from "../../utils/commands/reminder";
export default { export default {
data: { data: {
name: getFilename(__filename), name: getFilename(__filename),
}, },
interaction: async (interaction: ModalSubmitInteraction, client: Client) => interaction: async (interaction: ModalSubmitInteraction, client: Client) => {
newReminder(client, interaction.fields.fields.get("reminderGUI-time")?.value as string, { const message = interaction.fields.fields.get("reminderGUI-message")?.value;
return newReminder(client, interaction.fields.fields.get("reminderGUI-time")!.value, {
locale: interaction.locale, locale: interaction.locale,
message: interaction.fields.fields.get("reminderGUI-message")?.value ?? null, message: message ? (message.length > 0 ? message : null) : null,
createdAt: interaction.createdAt.getTime(), createdAt: interaction.createdAt.getTime(),
channelId: interaction.channelId, channelId: interaction.channelId,
userId: interaction.user.id, userId: interaction.user.id,
@ -19,5 +21,6 @@ export default {
content: msg as string, content: msg as string,
ephemeral: true, ephemeral: true,
}), }),
), );
},
}; };

View file

@ -75,6 +75,8 @@ declare module "discord.js" {
string, string,
/** Command itself */ /** Command itself */
{ {
/** Guilds where the command is active */
scope: () => string[];
/** Data about the command */ /** Data about the command */
data: SlashCommandBuilder; data: SlashCommandBuilder;
/** How the command interact */ /** How the command interact */

12
src/modules/player.ts Normal file
View file

@ -0,0 +1,12 @@
export {};
declare module "discord-player" {
export interface GuildQueue {
syncedLyricsMemory:
| {
isSubscribed: () => unknown;
unsubscribe: () => unknown;
}
| undefined;
}
}

View file

@ -12,5 +12,9 @@ declare global {
/** Capitalize definition */ /** Capitalize definition */
String.prototype.capitalize = function (this: string) { String.prototype.capitalize = function (this: string) {
if (this.length === 0) {
return this;
}
return this[0].toUpperCase() + this.substring(1); return this[0].toUpperCase() + this.substring(1);
}; };

13
src/sql/init.sql Normal file
View file

@ -0,0 +1,13 @@
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
);

14
src/sql/reminder/add.sql Normal file
View file

@ -0,0 +1,14 @@
INSERT INTO
reminder (
data,
expiration_date,
option_id,
channel_id,
creation_date,
user_id,
guild_id,
locale,
timeout_id
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?);

13
src/sql/reminder/find.sql Normal file
View file

@ -0,0 +1,13 @@
SELECT
data,
creation_date,
expiration_date,
id
FROM
reminder
WHERE
user_id = ?
AND (
guild_id = ?
OR guild_id = 0
)

View file

@ -0,0 +1,6 @@
SELECT
*
FROM
reminder
WHERE
id = ?

View file

@ -0,0 +1,14 @@
SELECT
EXISTS (
SELECT
1
FROM
reminder
WHERE
id = ?
AND user_id = ?
AND (
guild_id = ?
OR guild_id = 0
)
)

View file

@ -0,0 +1,4 @@
DELETE FROM reminder
WHERE
creation_date = ?
AND user_id = ?

View file

@ -0,0 +1,4 @@
SELECT
*
FROM
reminder

View file

@ -0,0 +1,13 @@
UPDATE reminder
SET
data = ?,
expiration_date = ?,
option_id = ?,
channel_id = ?,
creation_date = ?,
user_id = ?,
guild_id = ?,
locale = ?,
timeout_id = ?
WHERE
ID = ?

View file

@ -0,0 +1,34 @@
import "../../modules/string";
describe("Capitalize", () => {
{
const name = "test";
test(name, () => {
expect(name.capitalize()).toBe("Test");
});
}
{
const name = "MACHIN";
test(name, () => {
expect(name.capitalize()).toBe("MACHIN");
});
}
{
const name = "tRUC";
test(name, () => {
expect(name.capitalize()).toBe("TRUC");
});
}
{
const name = "Super";
test(name, () => {
expect(name.capitalize()).toBe("Super");
});
}
{
const name = "";
test(name, () => {
expect(name.capitalize()).toBe("");
});
}
});

View file

@ -0,0 +1,28 @@
import { OptionReminder, splitTime } from "../../../utils/commands/reminder";
describe("Time splitter", () => {
{
const name = "";
test(name, () => {
expect(splitTime(name)).toStrictEqual({ option: OptionReminder.Nothing, time: "" });
});
}
{
const name = "2m@p";
test(name, () => {
expect(splitTime(name)).toStrictEqual({ option: OptionReminder.DirectMessage, time: "2m" });
});
}
{
const name = "41@";
test(name, () => {
expect(splitTime(name)).toStrictEqual({ option: OptionReminder.Mention, time: "41" });
});
}
{
const name = "0P";
test(name, () => {
expect(splitTime(name)).toStrictEqual({ option: OptionReminder.DirectMessage, time: "0" });
});
}
});

View file

@ -0,0 +1,149 @@
import { Attachment, Collection, EmbedBuilder } from "discord.js";
import { handleAttachments } from "../../../utils/events/citation";
/**
* Generate a new random string
* @returns random string
*/
const newKey = () => Math.random().toString(36).substring(2);
describe("Attachements Handler", () => {
const map = new Map([
["e_attachements", "yes_s"],
["e_attachement", "no_s"],
]);
// 102 is the maximum for [f](url) before rupture in a field
const max = 102;
const max_field = Array.from({ length: max }, () => "[f](url)").join(", ");
{
const name = "One image";
test(name, () => {
const embedExpected = new EmbedBuilder();
embedExpected.setImage("http://url");
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection([[newKey(), { name: "image.png", url: "http://url" } as Attachment]]),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
{
const name = "Two images";
test(name, () => {
const embedExpected = new EmbedBuilder();
embedExpected.addFields({
name: "yes_s",
value: "[image.png](http://url), [image.png](http://url)",
});
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection([
[newKey(), { name: "image.png", url: "http://url" } as Attachment],
[newKey(), { name: "image.png", url: "http://url" } as Attachment],
]),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
{
const name = "One link";
test(name, () => {
const embedExpected = new EmbedBuilder();
embedExpected.addFields({ name: "no_s", value: "[f](url)" });
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection([[newKey(), { name: "f", url: "url" } as Attachment]]),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
{
const name = "Two files";
test(name, () => {
const embedExpected = new EmbedBuilder();
embedExpected.addFields({ name: "yes_s", value: "[f](url), [f](url)" });
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection([
[newKey(), { name: "f", url: "url" } as Attachment],
[newKey(), { name: "f", url: "url" } as Attachment],
]),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
{
const name = "Two fields with multiples files each";
test(name, () => {
const total = 150;
const embedExpected = new EmbedBuilder();
embedExpected.addFields(
{
name: "yes_s (1)",
value: max_field,
},
{
name: "yes_s (2)",
value: Array.from({ length: total - max }, () => "[f](url)").join(", "),
},
);
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection(
Array.from({ length: total }, () => [newKey(), { name: "f", url: "url" } as Attachment]),
),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
{
const name = "Two fields with one field with one element";
test(name, () => {
const total = 103;
const embedExpected = new EmbedBuilder();
embedExpected.addFields(
{
name: "yes_s (1)",
value: max_field,
},
{
name: "no_s (2)",
value: Array.from({ length: total - max }, () => "[f](url)").join(", "),
},
);
const embedTest = new EmbedBuilder();
handleAttachments(
map,
embedTest,
new Collection(
Array.from({ length: total }, () => [newKey(), { name: "f", url: "url" } as Attachment]),
),
);
expect(embedTest).toStrictEqual(embedExpected);
});
}
});

View file

@ -0,0 +1,151 @@
import {
cleanCodeBlock,
emojiPng,
isImage,
removeExtension,
splitFilenameExtensions,
} from "../../utils/misc";
describe("Filename splitter", () => {
{
const name = "test.js";
test(name, () => {
expect(splitFilenameExtensions(name)).toStrictEqual({ file: "test", ext: "js" });
});
}
{
const name = ".env";
test(name, () => {
expect(splitFilenameExtensions(name)).toStrictEqual({ file: ".env", ext: undefined });
});
}
{
const name = ".env.test";
test(name, () => {
expect(splitFilenameExtensions(name)).toStrictEqual({
file: ".env",
ext: "test",
});
});
}
{
const name = "file.test.js";
test(name, () => {
expect(splitFilenameExtensions(name)).toStrictEqual({ file: "file.test", ext: "js" });
});
}
});
describe("Extension remover", () => {
{
const name = "test.js";
test(name, () => {
expect(removeExtension(name)).toBe("test");
});
}
{
const name = ".env";
test(name, () => {
expect(removeExtension(name)).toBe(".env");
});
}
{
const name = ".env.test";
test(name, () => {
expect(removeExtension(name)).toBe(".env");
});
}
{
const name = "file.test.js";
test(name, () => {
expect(removeExtension(name)).toBe("file.test");
});
}
});
describe("Image checker", () => {
{
const name = "image.Png";
test(name, () => {
expect(isImage(name)).toBe(true);
});
}
{
const name = "image.jpeg";
test(name, () => {
expect(isImage(name)).toBe(true);
});
}
{
const name = "image.wav";
test(name, () => {
expect(isImage(name)).toBe(false);
});
}
{
const name = "image.jpg";
test(name, () => {
expect(isImage(name)).toBe(true);
});
}
{
const name = "image.webP";
test(name, () => {
expect(isImage(name)).toBe(true);
});
}
{
const name = "image.GIF";
test(name, () => {
expect(isImage(name)).toBe(true);
});
}
});
describe("Code block cleaner", () => {
{
const name = "salut";
test(name, () => {
expect(cleanCodeBlock(name)).toBe("`salut`");
});
}
{
const name = "<@158260864623968257> ça va ?";
test(name, () => {
expect(cleanCodeBlock(name)).toBe("<@158260864623968257>` ça va ?`");
});
}
{
const name = "t'as vu la vidéo ? https://youtu.be/dQw4w9WgXcQ";
test(name, () => {
expect(cleanCodeBlock(name)).toBe("`t'as vu la vidéo ? `https://youtu.be/dQw4w9WgXcQ");
});
}
{
const name = "t'as vu la vidéo ? https://youtu.be/dQw4w9WgXcQ elle est cool en vrai tqt";
test(name, () => {
expect(cleanCodeBlock(name)).toBe(
"`t'as vu la vidéo ? `https://youtu.be/dQw4w9WgXcQ` elle est cool en vrai tqt`",
);
});
}
});
describe("Emoji to link", () => {
{
const name = "☺️";
test(name, () => {
expect(emojiPng(name)).toBe(
"https://cdn.jsdelivr.net/gh/twitter/twemoji/assets/72x72/263a.png",
);
});
}
{
const name = "🍕";
test(name, () => {
expect(emojiPng(name)).toBe(
"https://cdn.jsdelivr.net/gh/twitter/twemoji/assets/72x72/1f355.png",
);
});
}
});

View file

@ -0,0 +1,26 @@
import { RegexC, RegExpFlags } from "../../utils/regex";
describe("Regex flags", () => {
test("One parameter", () => {
const regex = RegexC("", RegExpFlags.Global);
expect(regex.global).toBeTruthy();
});
test("All parameters", () => {
const regex = RegexC(
"",
RegExpFlags.Global |
RegExpFlags.MultiLine |
RegExpFlags.Insensitive |
RegExpFlags.Sticky |
RegExpFlags.Unicode |
RegExpFlags.SingleLine,
);
expect(regex.global).toBeTruthy();
expect(regex.multiline).toBeTruthy();
expect(regex.ignoreCase).toBeTruthy();
expect(regex.sticky).toBeTruthy();
expect(regex.unicode).toBeTruthy();
expect(regex.dotAll).toBeTruthy();
});
});

View file

@ -0,0 +1,120 @@
import {
nextTimeUnit,
showDate,
strToSeconds,
timeDeltaToString,
TimeSecond,
} from "../../utils/time";
describe("Date with correct timezone", () => {
const map = new Map([["u_time_at", "@"]]);
const date = new Date(1727434767686);
{
const name = "fr";
test(name, () => {
expect(showDate(name, map, date)).toBe("27/09/2024 @ 12:59:27");
});
}
{
const name = "en-US";
test(name, () => {
expect(showDate(name, map, date)).toBe("9/27/24, @ 1:59:27");
});
}
{
const name = "unknown";
// Depends on the system
// The important is that the date is in the correct timezone (UTC)
test(name, () => {
expect(["27/09/2024 @ 10:59:27", "9/27/24, @ 10:59:27"]).toContain(showDate(name, map, date));
});
}
{
const name = "zh-CN";
test(name, () => {
expect(showDate(name, map, date)).toBe("2024/9/27 @ 18:59:27");
});
}
});
describe("String time to seconds", () => {
{
const name = "10m30";
test(name, () => {
expect(strToSeconds(name)).toBe(630);
});
}
{
const name = "12h30";
test(name, () => {
expect(strToSeconds(name)).toBe(45000);
});
}
{
const name = "12s30";
test(name, () => {
expect(strToSeconds(name)).toBe(42);
});
}
{
const name = "1w30h20";
test(name, () => {
expect(strToSeconds(name)).toBe(714000);
});
}
});
describe("Next time unit", () => {
{
const name = TimeSecond.Minute;
test(name.toString(), () => {
expect(nextTimeUnit(name)).toBe(TimeSecond.Second);
});
}
{
const name = TimeSecond.Hour;
test(name.toString(), () => {
expect(nextTimeUnit(name)).toBe(TimeSecond.Minute);
});
}
{
const name = TimeSecond.Second;
test(name.toString(), () => {
expect(nextTimeUnit(name)).toBe(TimeSecond.Second);
});
}
{
const name = TimeSecond.Year;
test(name.toString(), () => {
expect(nextTimeUnit(name)).toBe(TimeSecond.Week);
});
}
});
describe("Relative time", () => {
// Thoses tests are based on time, we have 10s of acceptance.
{
const name = Date.now() + (10 * TimeSecond.Minute + 30) * 1000;
test(name.toString(), () => {
expect(timeDeltaToString(name)).toMatch(/10m 30s|10m 2\ds/);
});
}
{
const name = Date.now() + (12 * TimeSecond.Hour + 30 * TimeSecond.Minute) * 1000;
test(name.toString(), () => {
expect(timeDeltaToString(name)).toMatch(/12h 30m|12h 29m 5\ds/);
});
}
{
const name = Date.now() + (TimeSecond.Week + TimeSecond.Day + 6 * TimeSecond.Hour) * 1000;
test(name.toString(), () => {
expect(timeDeltaToString(name)).toMatch(/1w 1d 6h|1w 1d 5h 59m 5\ds/);
});
}
{
const name = Date.now();
test(name.toString(), () => {
expect(timeDeltaToString(name)).toMatch(/\ds/);
});
}
});

View file

@ -1,13 +1,17 @@
import { Player } from "discord-player"; import { Player } from "discord-player";
import { Client, Collection, GatewayIntentBits } from "discord.js"; import { ActivityType, Client, Collection, GatewayIntentBits } from "discord.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { Database } from "sqlite3"; import { Database } from "sqlite3";
import "../modules/client"; import "../modules/client";
import { loadLocales } from "./locales"; import { loadLocales } from "./locales";
import { YoutubeiExtractor } from "discord-player-youtubei"; import { YoutubeiExtractor } from "discord-player-youtubei";
import { readSQL } from "./db";
import { isDev } from "./misc";
/** Creation of the client and definition of its properties */ /** Creation of the client and definition of its properties */
export default async () => { export default async () => {
const activities = isDev ? [] : [{ name: "/help", type: ActivityType.Watching }];
const client: Client = new Client({ const client: Client = new Client({
shards: "auto", shards: "auto",
intents: [ intents: [
@ -16,6 +20,9 @@ export default async () => {
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildVoiceStates,
], ],
presence: {
activities,
},
}); });
client.config = { client.config = {
@ -54,7 +61,7 @@ export default async () => {
client.db = new Database(`${process.env.DOCKERIZED === "1" ? "/config" : "./config"}/db.sqlite3`); client.db = new Database(`${process.env.DOCKERIZED === "1" ? "/config" : "./config"}/db.sqlite3`);
initDatabase(client.db); client.db.run(readSQL("init"));
return client; return client;
}; };
@ -70,25 +77,3 @@ export const quit = (client: Client) => {
// Close client // Close client
client.destroy(); 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

@ -0,0 +1,73 @@
import {
APIApplicationCommandSubcommandOption,
ApplicationCommandOptionType,
Client,
Locale,
SlashCommandBuilder,
} from "discord.js";
type Data = SlashCommandBuilder | APIApplicationCommandSubcommandOption;
/**
* Find the name of the command, trying to get the correct locale
* @param data Command data
* @param locale Locale wanted
* @returns Command's name
*/
export const goodName = (data: Data, locale: Locale) =>
data.name_localizations?.[locale] ?? data.name;
/**
* Find the description of the command, trying to get the correct locale
* @param data Command data
* @param locale Locale wanted
* @returns Command's description
*/
export const goodDescription = (data: Data, locale: Locale) =>
data.description_localizations?.[locale] ?? data.description;
/**
* Aux function for Sub/NameNotLocalized
* @param cmd data
* @param command command researched
* @returns if we found or not the researched command
*/
const filterLocalizations = (cmd: Data, command: string) => {
let res = false;
for (const key in cmd?.name_localizations) {
res = res || cmd.name_localizations?.[key as Locale] === command;
}
return res;
};
/**
* Find a command based on any string, localized or not
* @param command string
* @returns the not localized corresponding string's command name
*/
export const NameNotLocalized = (client: Client, command: string): SlashCommandBuilder | null => {
const list = client.commands.list.map((cmd) => cmd.data);
return (
list.find((cmd) => cmd.name === command) ||
list.filter((cmd) => filterLocalizations(cmd, command))[0]
);
};
/**
* Find a subcommand of a command based on any string, localized or not
* @param parent command of the subcommand
* @param command string
* @returns the not localized corresponding string's subcommand name
*/
export const SubnameNotLocalized = (parent: SlashCommandBuilder, command: string) => {
const list = parent
?.toJSON()
.options?.filter((option) => option.type === ApplicationCommandOptionType.Subcommand);
return (
list?.find((cmd) => cmd?.name === command) ||
list?.filter((cmd) => filterLocalizations(cmd, command))[0]
);
};

View file

@ -1,7 +1,9 @@
import { EmbedBuilder } from "@discordjs/builders"; import { EmbedBuilder } from "@discordjs/builders";
import { GuildQueue, QueueRepeatMode, Track } from "discord-player"; import { GuildQueue, QueueRepeatMode } from "discord-player";
import { Client } from "discord.js"; import { Client } from "discord.js";
import { getLocale } from "./locales"; import { getLocale } from "../locales";
import { blank } from "../misc";
import { discord_limit_embed_field } from "../constants";
export const embedListQueue = ( export const embedListQueue = (
client: Client, client: Client,
@ -14,10 +16,9 @@ export const embedListQueue = (
const tracks = queue.tracks.toArray(); const tracks = queue.tracks.toArray();
// Add the current song at the top of the list // Add the current song at the top of the list
tracks.unshift(queue.history.currentTrack as Track); tracks.unshift(queue.history.currentTrack!);
// Limit of discord is 25 const limit_fields = discord_limit_embed_field;
const limit_fields = 25;
const pageMax = Math.ceil(tracks.length / limit_fields); const pageMax = Math.ceil(tracks.length / limit_fields);
@ -25,12 +26,12 @@ export const embedListQueue = (
embed.setFooter({ text: `${printRepeatMode(queue.repeatMode, loc)}` }); embed.setFooter({ text: `${printRepeatMode(queue.repeatMode, loc)}` });
tracks.slice((page - 1) * limit_fields, page * limit_fields).forEach((t, idx) => { tracks.slice((page - 1) * limit_fields, page * limit_fields).forEach((t, idx) => {
const now_playing = idx == 0 && page == 1; const now_playing = idx === 0 && page === 1;
const name = now_playing const name = now_playing
? loc.get("c_queue10") ? loc.get("c_queue10")
: (idx == 1 && page == 1) || (idx == 0 && page > 1) : (idx === 1 && page === 1) || (idx === 0 && page > 1)
? loc.get("c_queue11") ? loc.get("c_queue11")
: "\u200b"; : blank;
const idx_track = now_playing ? "" : `${idx + limit_fields * (page - 1)}. `; const idx_track = now_playing ? "" : `${idx + limit_fields * (page - 1)}. `;
embed.addFields({ embed.addFields({
name, name,

View file

@ -1,7 +1,9 @@
import { Client, Colors, EmbedBuilder, User } from "discord.js"; import { Client, Colors, EmbedBuilder, User } from "discord.js";
import { getLocale } from "./locales"; import { getLocale } from "../locales";
import { cleanCodeBlock } from "./misc"; import { blank, cleanCodeBlock } from "../misc";
import { showDate, strToSeconds, timeDeltaToString } from "./time"; import { showDate, strToSeconds, timeDeltaToString } from "../time";
import { RegexC, RegExpFlags } from "../regex";
import { readSQL } from "../db";
/** /**
* Option possible for reminders * Option possible for reminders
@ -45,14 +47,26 @@ export type dbReminder = {
* @param time raw text from user * @param time raw text from user
* @returns An object with the time and the option * @returns An object with the time and the option
*/ */
const splitTime = (time: string) => { export const splitTime = (time: string) => {
if (time?.endsWith("@")) { const mapping = {
return { time: time.slice(0, -1), option: OptionReminder.Mention }; [OptionReminder.DirectMessage]: "p",
} else if (time?.toLowerCase().endsWith("p")) { [OptionReminder.Mention]: "@",
return { time: time.slice(0, -1), option: OptionReminder.DirectMessage }; };
}
return { time: time, option: OptionReminder.Nothing }; const trimmed = time.replaceAll(
RegexC(Object.values(mapping).join("|"), RegExpFlags.Global | RegExpFlags.Insensitive),
"",
);
// Depending of the last character of the string
switch (time.toLowerCase().slice(-1)) {
case mapping[OptionReminder.Mention]:
return { time: trimmed, option: OptionReminder.Mention };
case mapping[OptionReminder.DirectMessage]:
return { time: trimmed, option: OptionReminder.DirectMessage };
default:
return { time: time, option: OptionReminder.Nothing };
}
}; };
/** /**
@ -64,18 +78,25 @@ const splitTime = (time: string) => {
*/ */
export const newReminder = async (client: Client, time: string, info: infoReminder) => export const newReminder = async (client: Client, time: string, info: infoReminder) =>
new Promise((ok, ko) => { new Promise((ok, ko) => {
const loc = getLocale(client, info.locale);
const data = splitTime(time); const data = splitTime(time);
const timeout = strToSeconds(data.time); const timeout = strToSeconds(data.time);
if (timeout < 0) {
ko(loc.get("c_reminder18"));
return;
}
const timeoutId = setTimeoutReminder(client, info, data.option, timeout); const timeoutId = setTimeoutReminder(client, info, data.option, timeout);
const expiration_date = info.createdAt + timeout * 1000;
// Add the remind to the db // Add the remind to the db
client.db.run( client.db.run(
"INSERT INTO reminder ( \ readSQL("reminder/add"),
data, expiration_date, option_id, channel_id, creation_date, user_id, guild_id, locale, timeout_id \
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? );",
[ [
info.message, info.message,
`${info.createdAt + timeout * 1000}`, `${expiration_date}`,
data.option.valueOf(), data.option.valueOf(),
info.channelId, info.channelId,
`${info.createdAt}`, `${info.createdAt}`,
@ -90,8 +111,7 @@ export const newReminder = async (client: Client, time: string, info: infoRemind
} }
// Send confirmation to user // Send confirmation to user
const loc = getLocale(client, info.locale); ok(`${loc.get("c_reminder1")} ${timeDeltaToString(expiration_date)}.`);
ok(`${loc.get("c_reminder1")} ${data.time}.`);
}, },
); );
}); });
@ -106,26 +126,30 @@ export const newReminder = async (client: Client, time: string, info: infoRemind
export const deleteReminder = (client: Client, createdAt: string, userId: string) => { export const deleteReminder = (client: Client, createdAt: string, userId: string) => {
// Delete the reminder for the database // Delete the reminder for the database
return new Promise((ok, ko) => { return new Promise((ok, ko) => {
// Add the remind to the db // Remove the remind to the db
client.db.run( client.db.run(readSQL("reminder/remove"), [createdAt, userId], (err) => {
"DELETE FROM reminder WHERE creation_date = ? AND user_id = ?", if (err) {
[createdAt, userId], ko(err);
(err) => { }
if (err) {
ko(err);
}
// Send confirmation to user // Send confirmation to user
ok(true); ok(true);
}, });
);
}); });
}; };
export const sendReminder = (client: Client, info: infoReminder, option: OptionReminder) => { export const sendReminder = (client: Client, info: infoReminder, option: OptionReminder) =>
new Promise((resolve, reject) => {
try {
resolve(sendReminderAux(client, info, option));
} catch (error) {
reject(error);
}
});
const sendReminderAux = (client: Client, info: infoReminder, option: OptionReminder) => {
const loc = getLocale(client, info.locale); const loc = getLocale(client, info.locale);
// Send the message in the appropriate channel // Send the message in the appropriate channel
// TODO: Embed
let message: string; let message: string;
if (info.message === null || info.message.length === 0) { if (info.message === null || info.message.length === 0) {
message = loc.get("c_reminder7"); message = loc.get("c_reminder7");
@ -160,7 +184,7 @@ export const sendReminder = (client: Client, info: infoReminder, option: OptionR
} }
} }
if (option == OptionReminder.DirectMessage || !channelOk || !guildOk) { if (option === OptionReminder.DirectMessage || !channelOk || !guildOk) {
// Direct message // Direct message
const user = client.users.cache.get(info.userId); const user = client.users.cache.get(info.userId);
if (user !== undefined) { if (user !== undefined) {
@ -170,16 +194,20 @@ export const sendReminder = (client: Client, info: infoReminder, option: OptionR
// Channel // Channel
client.channels.fetch(info.channelId!).then((channel) => { client.channels.fetch(info.channelId!).then((channel) => {
if (channel?.isSendable()) { if (channel?.isSendable()) {
let content = `<@${info.userId}>`; const author_mention = `<@${info.userId}>`;
let content = author_mention;
embed.setFooter({ embed.setFooter({
text: `${loc.get("c_reminder17")} ${timeDeltaToString(info.createdAt)}`, text: `${loc.get("c_reminder17")} ${timeDeltaToString(info.createdAt)}`,
}); });
// Mention everybody if needed // Mention everybody if needed
if (option == OptionReminder.Mention) { if (option === OptionReminder.Mention) {
(info.message?.match(/<@\d+>/g) ?? []).forEach((mention) => { [...new Set(info.message?.match(/<@\d+>/g) ?? [])]
content += " " + mention; .filter((mention) => mention !== author_mention)
}); .forEach((mention: string) => {
content += " " + mention;
});
} }
channel.send({ content, embeds: [embed] }); channel.send({ content, embeds: [embed] });
@ -202,8 +230,19 @@ export const setTimeoutReminder = (
option: OptionReminder, option: OptionReminder,
timeout: number, timeout: number,
) => { ) => {
return Number( const setChunkedTimeout = (remainingTime: number) => {
setTimeout(() => { // Maximum for setTimeout is Int32
if (remainingTime > 2147483647) {
// Schedule a 24-hour delay (24 * 60 * 60 * 1000), then check again
const dayChunk = 86400000;
return setTimeout(() => {
setChunkedTimeout(remainingTime - dayChunk);
}, dayChunk);
}
// Final timeout when remaining time is within limit
return setTimeout(() => {
deleteReminder(client, String(info.createdAt), info.userId).then((val) => { deleteReminder(client, String(info.createdAt), info.userId).then((val) => {
if (val != true) { if (val != true) {
throw val; throw val;
@ -211,8 +250,11 @@ export const setTimeoutReminder = (
sendReminder(client, info, option); sendReminder(client, info, option);
}); });
}, timeout * 1000), }, remainingTime);
); };
// Convert to milliseconds
return Number(setChunkedTimeout(timeout * 1000));
}; };
/** /**
@ -233,12 +275,7 @@ export const checkOwnershipReminder = async (
const data = (await new Promise((ok, ko) => { const data = (await new Promise((ok, ko) => {
// Check the ownership // Check the ownership
client.db.all<returnData>( client.db.all<returnData>(
"SELECT EXISTS ( \ readSQL("reminder/ownership_check"),
SELECT 1 FROM reminder \
WHERE id = ? \
AND user_id = ? \
AND (guild_id = ? OR guild_id = 0) \
)",
[id, userId, guildId], [id, userId, guildId],
(err, row) => { (err, row) => {
if (err) { if (err) {
@ -261,19 +298,14 @@ export const checkOwnershipReminder = async (
export const getReminderInfo = async (client: Client, id: number) => { export const getReminderInfo = async (client: Client, id: number) => {
return (await new Promise((ok, ko) => { return (await new Promise((ok, ko) => {
// Check the ownership // Check the ownership
client.db.all<dbReminder>( client.db.all<dbReminder>(readSQL("reminder/findById"), [id], (err, row) => {
"SELECT * FROM reminder \ if (err) {
WHERE id = ?", ko(err);
[id], }
(err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders // Send all the current reminders
ok(row[0]); ok(row[0]);
}, });
);
})) as dbReminder; })) as dbReminder;
}; };
@ -287,17 +319,7 @@ export const updateReminder = (client: Client, data: dbReminder) => {
return new Promise((ok, ko) => { return new Promise((ok, ko) => {
// Update the db // Update the db
client.db.run( client.db.run(
"UPDATE reminder \ readSQL("reminder/update"),
SET data = ?, \
expiration_date = ?, \
option_id = ?, \
channel_id = ?, \
creation_date = ?, \
user_id = ?, \
guild_id = ?, \
locale = ?, \
timeout_id = ? \
WHERE ID = ?",
[ [
data.data, data.data,
data.expiration_date, data.expiration_date,
@ -331,19 +353,14 @@ export const updateReminder = (client: Client, data: dbReminder) => {
const listReminders = async (client: Client, userId: string, guildId: string | null) => { const listReminders = async (client: Client, userId: string, guildId: string | null) => {
return (await new Promise((ok, ko) => { return (await new Promise((ok, ko) => {
// Check the ownership // Check the ownership
client.db.all<dbReminder>( client.db.all<dbReminder>(readSQL("reminder/find"), [userId, guildId ?? 0], (err, row) => {
"SELECT data, creation_date, expiration_date, id FROM reminder \ if (err) {
WHERE user_id = ? AND (guild_id = ? OR guild_id = 0)", ko(err);
[userId, guildId ?? 0], }
(err, row) => {
if (err) {
ko(err);
}
// Send all the current reminders // Send all the current reminders
ok(row); ok(row);
}, });
);
})) as dbReminder[]; })) as dbReminder[];
}; };
@ -407,7 +424,7 @@ export const embedListReminders = async (
}); });
} else { } else {
embed.addFields({ embed.addFields({
name: "\u200b", name: blank,
value: `${loc.get("c_reminder10")}${page} ${loc.get("c_reminder11")}.`, value: `${loc.get("c_reminder10")}${page} ${loc.get("c_reminder11")}.`,
}); });
} }

11
src/utils/constants.ts Normal file
View file

@ -0,0 +1,11 @@
/** Max message length */
export const discord_limit_message = 2000;
/** Max embed field an embed can have */
export const discord_limit_embed_field = 25;
/** Max element the autocompletion of slash commands can have */
export const discord_limit_autocompletion_list_length = 25;
/** Max length of an element in autocompletion of slash commands */
export const discord_limit_autocompletion_value_length = 100;

17
src/utils/db.ts Normal file
View file

@ -0,0 +1,17 @@
import fs from "node:fs";
import { isDev } from "./misc";
export const readSQL = (path: string) => {
const root = isDev ? "./src" : "./dist";
const dir = root + "/sql/";
if (!path.startsWith(dir)) {
path = dir + path;
}
const ext = ".sql";
if (!path.endsWith(ext)) {
path += ext;
}
return fs.readFileSync(path, "utf8");
};

View file

@ -0,0 +1,57 @@
import { APIEmbedField, Attachment, Collection, EmbedBuilder } from "discord.js";
import { isImage } from "../misc";
export const handleAttachments = (
loc: Map<string, string>,
embed: EmbedBuilder,
attachments: Collection<string, Attachment>,
) => {
if (attachments.size === 1 && isImage(attachments.first()!.name)) {
// Only contains one image
embed.setImage(attachments.first()!.url);
} else {
// Contains more than one image and/or other files
// We are currently losing a link to a file if the link is too long
// We could truncate the filename ?
const maxFieldValueLength = 1024;
const files = attachments
.map((file) => `[${file.name}](${file.url})`)
.filter((link) => link.length <= maxFieldValueLength);
let currentField = "";
const fields: APIEmbedField[] = [];
let multipleFields = 0;
let numberOfLinks = 0;
files.forEach((file, idx) => {
numberOfLinks++;
const fieldValue = currentField.length > 0 ? `${currentField}, ${file}` : file;
if (fieldValue.length > maxFieldValueLength) {
multipleFields = multipleFields === 0 && idx !== files.length - 1 ? 1 : multipleFields + 1;
fields.push({
name:
loc.get(
attachments.size > 1 && numberOfLinks > 1 ? "e_attachements" : "e_attachement",
) + (multipleFields ? ` (${multipleFields})` : ""),
value: currentField,
});
currentField = file;
numberOfLinks = 0;
} else {
currentField = fieldValue;
}
});
if (currentField.length > 0) {
fields.push({
name:
loc.get(attachments.size > 1 && numberOfLinks > 1 ? "e_attachements" : "e_attachement") +
(multipleFields ? ` (${multipleFields + 1})` : ""),
value: currentField,
});
}
embed.addFields(fields);
}
};

View file

@ -81,9 +81,13 @@ export const getLocalizations = (client: Client, text: string, lowercase = false
* @param lang Lang to fetch * @param lang Lang to fetch
* @returns the map with the desired languaged clogged with the default one * @returns the map with the desired languaged clogged with the default one
*/ */
export const getLocale = (client: Client, lang: string) => { export const getLocale = (client: Client, lang: string | undefined = undefined) => {
// Load default lang // Load default lang
const default_locales = client.locales.get(client.config.default_lang); const default_locales = client.locales.get(client.config.default_lang);
if (!lang) {
return default_locales!;
}
// Load desired lang // Load desired lang
const desired_locales = client.locales.get(lang); const desired_locales = client.locales.get(lang);

View file

@ -1,5 +1,8 @@
import { GuildMember } from "discord.js"; import { GuildMember } from "discord.js";
/** Check if we are in the dev environnement */
export const isDev = process.env.NODE_ENV !== "production";
/** /**
* Log module status * Log module status
* @param {string} name Module name * @param {string} name Module name
@ -28,27 +31,41 @@ export const getFilename = (path: string) => {
return removeExtension(filename_with_ext); return removeExtension(filename_with_ext);
}; };
/**
* Split a filename and his extension
* @param filename string of the filename
* @returns Object with filename and extension splitted
*/
export const splitFilenameExtensions = (filename: string) => {
if (filename.length === 0) {
return undefined;
}
// Check if the filename starts with a dot and has no other dots
if (filename.startsWith(".") && filename.indexOf(".", 1) === -1) {
return { file: filename, ext: undefined };
}
const lastDotIndex = filename.lastIndexOf(".");
// If there's no dot or the dot is at the start, treat the whole string as the filename
if (lastDotIndex <= 0) {
return { file: filename, ext: undefined };
}
const file = filename.slice(0, lastDotIndex);
const ext = filename.slice(lastDotIndex + 1);
return { file, ext };
};
/** /**
* Remove extension from a filename * Remove extension from a filename
* @param filename string of the filename with an extension * @param filename string of the filename with an extension
* @returns string of the filename without an extension * @returns string of the filename without an extension
*/ */
export const removeExtension = (filename: string) => { export const removeExtension = (filename: string) => {
const array = filename.split("."); return splitFilenameExtensions(filename)!.file;
array.pop();
return array.join(".");
};
/**
* Get extension from a filename
* @param filename string of the filename
* @returns string of the extension if it exists
*/
export const getExtension = (filename: string) => {
const array = filename.split(".");
return array.pop();
}; };
/** /**
@ -57,7 +74,11 @@ export const getExtension = (filename: string) => {
* @returns true is file is a media * @returns true is file is a media
*/ */
export const isImage = (filename: string) => { export const isImage = (filename: string) => {
return Boolean(getExtension(filename)?.match(/jpg|jpeg|png|webp|gif/)); return Boolean(
splitFilenameExtensions(filename)
?.ext?.toLowerCase()
.match(/jpg|jpeg|png|webp|gif/),
);
}; };
/** /**
@ -90,15 +111,16 @@ export const cleanCodeBlock = (text: string) => {
}); });
// Keep links // Keep links
// Reference: https://stackoverflow.com/a/3809435/15436737
text = text.replace( text = text.replace(
/(http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)/g, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&//=]*/g,
function (url: string) { function (url: string) {
return `\`${url}\``; return `\`${url}\``;
}, },
); );
// Fix issues // Fix issues
text = text.replace("``", ""); text = text.replaceAll("``", "");
return text; return text;
}; };
@ -112,3 +134,8 @@ export const emojiPng = (emoji: string) =>
`https://cdn.jsdelivr.net/gh/twitter/twemoji/assets/72x72/${emoji `https://cdn.jsdelivr.net/gh/twitter/twemoji/assets/72x72/${emoji
.codePointAt(0) .codePointAt(0)
?.toString(16)}.png`; ?.toString(16)}.png`;
/**
* Blank character
*/
export const blank = "\u200b";

30
src/utils/regex.ts Normal file
View file

@ -0,0 +1,30 @@
export enum RegExpFlags {
// Global
Global = 1 << 0,
// Multi Line
MultiLine = 1 << 1,
// Ignore Case
Insensitive = 1 << 2,
// Sticky
Sticky = 1 << 3,
// Unicode
Unicode = 1 << 4,
// Dot All
SingleLine = 1 << 6,
}
const flagsToString = (flags: number) => {
let result = "";
if (flags & RegExpFlags.Global) result += "g";
if (flags & RegExpFlags.MultiLine) result += "m";
if (flags & RegExpFlags.Insensitive) result += "i";
if (flags & RegExpFlags.Sticky) result += "y";
if (flags & RegExpFlags.Unicode) result += "u";
if (flags & RegExpFlags.SingleLine) result += "s";
return result;
};
export const RegexC = (pattern: RegExp | string, flags: number = 0) =>
new RegExp(pattern, flagsToString(flags));

View file

@ -1,58 +1,133 @@
import moment from "moment-timezone";
import { RegexC, RegExpFlags } from "./regex";
/** /**
* Parsed string adapted with TZ (locales) and format for the specified lang * Parsed string adapted with TZ (locales) and format for the specified lang
* @param tz Lang * @param lang Locale
* @param locale Locales * @param translation Translation for "at"
* @param date Date * @param date Date
* @returns String * @returns String
*/ */
export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) => { export const showDate = (lang: string, translation: Map<string, unknown>, date: Date) => {
return date.toLocaleString(tz).replace(" ", ` ${locale.get("u_time_at")} `); const localeInfo = new Intl.Locale(lang);
const intlTimezone = moment.tz.zonesForCountry(localeInfo.region ?? localeInfo.baseName);
const formattedDate = new Intl.DateTimeFormat(lang, {
timeZone: intlTimezone ? intlTimezone[0] : "Factory",
dateStyle: "short",
timeStyle: "medium",
})
.format(date)
.split(" ");
return `${formattedDate[0]} ${translation.get("u_time_at")} ${formattedDate[1]}`;
}; };
enum TimeSecond { export enum TimeSecond {
Year = 31536000, Year = 60 * 60 * 24 * 365,
Week = 604800, Week = 60 * 60 * 24 * 7,
Day = 86400, Day = 60 * 60 * 24,
Hour = 3600, Hour = 60 * 60,
Minute = 60, Minute = 60,
Second = 1, Second = 1,
} }
/**
* Get next time unit. For example the next unit after Hour is Minute
* @param currentUnit Current time unit
* @returns The next time unit
*/
export const nextTimeUnit = (currentUnit: number) => {
const units = Object.values(TimeSecond) as number[];
const index = units.indexOf(currentUnit);
return units[index + 1] || TimeSecond.Second;
};
/** /**
* Take a cooldown, for example 2min and transform it to seconds, here: 120s * Take a cooldown, for example 2min and transform it to seconds, here: 120s
* @param time time in human format * @param time time in human format
* @returns time in seconds * @returns time in seconds
*/ */
export const strToSeconds = (time: string) => { export const strToSeconds = (time: string) => {
const regex = new RegExp( if (time.length > 15) {
`(?<${TimeSecond[TimeSecond.Year]}>[0-9]+(?=[y|a]))|(?<${ // 15 is a magic value as it's weird to have time this long
TimeSecond[TimeSecond.Week] return -1;
}>[0-9]+(?=[w]))|(?<${TimeSecond[TimeSecond.Day]}>[0-9]+(?=[d|j]))|(?<${ }
TimeSecond[TimeSecond.Hour]
}>[0-9]+(?=[h]))|(?<${TimeSecond[TimeSecond.Minute]}>[0-9]+(?=[m]))|(?<${ const noUnit = "unmarked";
TimeSecond[TimeSecond.Second] const regex = RegexC(
}>[0-9]+(?=[s]?))`, `(?<${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]))|` +
`(?<${noUnit}>[0-9]+)`,
RegExpFlags.Global | RegExpFlags.Insensitive,
); );
const data = Object.assign({}, regex.exec(time)?.groups); const data = Array.from(time.matchAll(regex));
if (data.length === 0) {
// Regex returned an invalid time
return -1;
}
let res = 0; let res = 0;
Object.entries(data).forEach(([key, value]) => { let lastUnit = TimeSecond.Second;
if (value) { data.forEach((match) => {
res += +value * TimeSecond[key as keyof typeof TimeSecond]; Object.entries(match.groups!).forEach(([key, value]) => {
} if (value) {
let unit;
if (key === noUnit) {
unit = nextTimeUnit(lastUnit);
res += +value * unit;
} else {
unit = TimeSecond[key as keyof typeof TimeSecond];
res += +value * unit;
}
lastUnit = unit;
}
});
}); });
return res; return res;
}; };
/**
* Returns the time in a readable way
* @param seconds Time in milliseconds
* @returns Time as string
*/
export const timeToString = (time: number) => {
let secondsDifference = Math.abs(Math.ceil(time / 1000));
if (secondsDifference === 0) {
return "0s";
}
return Object.entries(TimeSecond)
.map(([key, value]) => ({
label: key.charAt(0).toLowerCase(),
value: value as TimeSecond,
}))
.map(({ label, value }) => {
if (secondsDifference >= value) {
const amount = Math.floor(secondsDifference / value);
secondsDifference -= amount * value;
return `${amount}${label}`;
}
return null;
})
.filter(Boolean)
.join(" ");
};
/** /**
* Calculating the difference between a date and now * Calculating the difference between a date and now
* @param time Time * @param time Time in milliseconds
* @returns Delta between the time and now * @returns Delta between the time and now
*/ */
export const timeDeltaToString = (time: number) => { export const timeDeltaToString = (time: number) => {
const now = Date.now(); const now = Date.now();
// TODO adapt the output and not always parse the time as seconds return timeToString(time - now);
return `${strToSeconds(`${(now - time) / 1000}`)} secs`;
}; };

View file

@ -102,5 +102,6 @@
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */
}, },
"include": ["./**/*.ts", "./src/locales/*.json"] "include": ["./**/*.ts", "./src/locales/*.json"],
"exclude": ["./src/tests"]
} }