Compare commits
4 commits
release-ta
...
main
Author | SHA1 | Date | |
---|---|---|---|
23446eb399 | |||
10f5bf65b3 | |||
82e2f5a209 | |||
74fdfb7626 |
52 changed files with 1208 additions and 777 deletions
|
@ -5,5 +5,3 @@
|
|||
!package-lock.json
|
||||
!LICENSE
|
||||
!tsconfig.json
|
||||
|
||||
src/tests/
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
dist
|
||||
tests
|
||||
|
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
|
|
@ -2,8 +2,7 @@ name: Publish latest version
|
|||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -11,7 +11,7 @@ docker-compose.yml
|
|||
dist/
|
||||
|
||||
# Databse
|
||||
*.sqlite3
|
||||
*.sqlite3*
|
||||
|
||||
# Debug file
|
||||
src/events/player/debug.ts
|
||||
|
|
|
@ -14,7 +14,7 @@ COPY --chown=node:node . .
|
|||
|
||||
ENV NODE_ENV=production
|
||||
RUN npm ci --omit=dev && \
|
||||
npx tsc && \
|
||||
npm run compile && \
|
||||
rm -r src/ tsconfig.json && \
|
||||
npm uninstall typescript @types/sqlite3 && \
|
||||
npm cache clean --force
|
||||
|
|
|
@ -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)
|
||||
|
||||
[**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
|
||||
|
||||
### Avec docker-compose (recommandé)
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
botanique:
|
||||
image: git.mylloon.fr/confreriedukassoulait/botanique:latest
|
||||
|
|
889
package-lock.json
generated
889
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
@ -4,7 +4,8 @@
|
|||
"description": "Bot discord",
|
||||
"main": "src/index.js",
|
||||
"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",
|
||||
"lint": "npx eslint src",
|
||||
"format-check": "npx prettier --check src",
|
||||
|
@ -20,21 +21,19 @@
|
|||
"dependencies": {
|
||||
"@discord-player/extractor": "^4.5.1",
|
||||
"@discordjs/rest": "^2.4.0",
|
||||
"@distube/ytdl-core": "^4.14.4",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"discord-player": "^6.7.1",
|
||||
"discord.js": "^14.16.2",
|
||||
"discord-player-youtubei": "^1.3.4",
|
||||
"discord.js": "^14.16.3",
|
||||
"mediaplex": "^0.0.9",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0"
|
||||
"typescript": "^5.6.3",
|
||||
"uuid": "^11.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "~29.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "~8.6.0",
|
||||
"@typescript-eslint/parser": "~8.6.0",
|
||||
"@types/jest": "~29.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "~8.12.2",
|
||||
"@typescript-eslint/parser": "~8.12.2",
|
||||
"dotenv": "~16.4.5",
|
||||
"jest": "~29.7.0",
|
||||
"prettier-eslint": "~16.3.0",
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getLocale } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { embedListReminders } from "../../utils/reminder";
|
||||
import { embedListReminders } from "../../utils/commands/reminder";
|
||||
import { collect } from "../loader";
|
||||
|
||||
export default {
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getLocale } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { embedListReminders } from "../../utils/reminder";
|
||||
import { embedListReminders } from "../../utils/commands/reminder";
|
||||
import { collect } from "../loader";
|
||||
|
||||
export default {
|
||||
|
|
|
@ -10,8 +10,8 @@ import {
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getLocale } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { embedListQueue } from "../../utils/music";
|
||||
import { collect } from "../loader";
|
||||
import { embedListQueue } from "../../utils/commands/music";
|
||||
|
||||
export default {
|
||||
data: {
|
||||
|
|
|
@ -10,8 +10,8 @@ import {
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getLocale } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { embedListQueue } from "../../utils/music";
|
||||
import { collect } from "../loader";
|
||||
import { embedListQueue } from "../../utils/commands/music";
|
||||
|
||||
export default {
|
||||
data: {
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
import { SlashCommandBuilder } from "@discordjs/builders";
|
||||
import { ChatInputCommandInteraction, Client, Colors, EmbedBuilder } from "discord.js";
|
||||
import {
|
||||
ApplicationCommandOptionType,
|
||||
ChatInputCommandInteraction,
|
||||
Client,
|
||||
Colors,
|
||||
EmbedBuilder,
|
||||
} from "discord.js";
|
||||
import "../../modules/string";
|
||||
import { getLocale, getLocalizations } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import {
|
||||
goodDescription,
|
||||
goodName,
|
||||
NameNotLocalized,
|
||||
SubnameNotLocalized,
|
||||
} from "../../utils/commands/help";
|
||||
|
||||
export default {
|
||||
scope: () => [],
|
||||
|
@ -47,16 +59,35 @@ export default {
|
|||
}[] = [];
|
||||
|
||||
// Load all the command per categories
|
||||
// TODO: Check if the command exist in the context (guild)
|
||||
// TODO: List subcommands too
|
||||
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/47
|
||||
client.commands.categories.forEach((commands_name, category) => {
|
||||
const commands = commands_name.reduce((data, command_name) => {
|
||||
return data + `\`/${command_name}\`, `;
|
||||
}, "");
|
||||
// Check if the command exist in the context (guild)
|
||||
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({
|
||||
name: category.capitalize() + ` (${commands_name.length})`,
|
||||
name: category.capitalize() + ` (${all_commands.length})`,
|
||||
value: commands.slice(0, -2),
|
||||
});
|
||||
});
|
||||
|
@ -73,30 +104,49 @@ export default {
|
|||
});
|
||||
}
|
||||
|
||||
// If a command is specified
|
||||
// TODO: Check if the command exist in the context (guild)
|
||||
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/187
|
||||
const command = client.commands.list.get(desired_command);
|
||||
const error = `${loc.get("c_help3")} \`${desired_command}\``;
|
||||
|
||||
const [possible_command, possible_subcommand] = desired_command.split(" ");
|
||||
|
||||
const command = NameNotLocalized(client, possible_command);
|
||||
if (!command) {
|
||||
// Command don't exist
|
||||
return interaction.reply({
|
||||
content: `${loc.get("c_help3")} \`${desired_command}\``,
|
||||
ephemeral: true,
|
||||
});
|
||||
return interaction.reply({ content: error, 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
|
||||
return interaction.reply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(Colors.Blurple)
|
||||
.setTitle("`/" + command.data.name + "`")
|
||||
.setDescription(
|
||||
// Loads the description
|
||||
// according to the user's locals
|
||||
command.data.description_localizations?.[interaction.locale] ??
|
||||
command.data.description,
|
||||
),
|
||||
.setTitle("`/" + requestedName + "`")
|
||||
.setDescription(requestedDesc),
|
||||
],
|
||||
});
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
embedListReminders,
|
||||
getReminderInfo,
|
||||
newReminder,
|
||||
} from "../../utils/reminder";
|
||||
} from "../../utils/commands/reminder";
|
||||
|
||||
export default {
|
||||
scope: () => [],
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { SlashCommandBuilder } from "@discordjs/builders";
|
||||
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 { getFilename } from "../../utils/misc";
|
||||
import { discord_limit_message } from "../../utils/constants";
|
||||
|
||||
export default {
|
||||
scope: () => [],
|
||||
|
@ -57,7 +58,7 @@ export default {
|
|||
),
|
||||
)
|
||||
|
||||
// Synced
|
||||
// Synced start
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName(loc_default.get(`c_${filename}_sub3_name`)!.toLowerCase())
|
||||
|
@ -65,6 +66,15 @@ export default {
|
|||
.setNameLocalizations(getLocalizations(client, `c_${filename}_sub3_name`, true))
|
||||
.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`)),
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
|
@ -123,30 +133,69 @@ export default {
|
|||
}
|
||||
|
||||
// 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) => {
|
||||
const content = `[${data[0].trackName}]: ${lyrics}`;
|
||||
if (interaction.channel?.isSendable()) {
|
||||
await interaction.channel?.send({
|
||||
content,
|
||||
});
|
||||
if (message) {
|
||||
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 {
|
||||
await interaction.followUp({
|
||||
content,
|
||||
});
|
||||
await interaction.followUp(loc.get("c_lyrics5"));
|
||||
}
|
||||
});
|
||||
|
||||
// Live update
|
||||
syncedLyrics.subscribe();
|
||||
|
||||
syncedLyrics.onUnsubscribe(() => {
|
||||
queue.syncedLyricsMemory = undefined;
|
||||
});
|
||||
|
||||
return await interaction.followUp({
|
||||
content: `🎤 | ${loc.get("c_lyrics4")}`,
|
||||
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) {
|
||||
const title = data[0];
|
||||
const limit_desc = 4096;
|
||||
|
|
|
@ -9,6 +9,11 @@ import {
|
|||
import { getLocale, getLocalizations } from "../../utils/locales";
|
||||
import { Metadata } from "../../utils/metadata";
|
||||
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 {
|
||||
scope: () => [],
|
||||
|
@ -175,12 +180,29 @@ export default {
|
|||
queue.node.play();
|
||||
}
|
||||
|
||||
// TODO: When added to an existing queue (size of queue > 0):
|
||||
// - Add position in queue
|
||||
// - Add estimated time until playing
|
||||
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/184
|
||||
const positionEstimation = () => {
|
||||
const pos = queue.node.getTrackPosition(result.tracks[0]) + 1;
|
||||
|
||||
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({
|
||||
content: `⏱️ | \`${title}\` ${loc.get("c_play5")}.`,
|
||||
content: `⏱️ | \`${title}\` ${loc.get("c_play5")}, ${loc.get("c_play12")} ${positionEstimation()}.`,
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -192,7 +214,10 @@ export default {
|
|||
const player = useMainPlayer();
|
||||
const query = interaction.options.getString(loc_default!.get(`c_${filename}_opt1_name`)!, 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) {
|
||||
/* Since Discord wanna receive a response within 3 secs and results is async
|
||||
|
@ -208,7 +233,7 @@ export default {
|
|||
|
||||
/* Create a race between a timeout and the search
|
||||
* At the end, Discord will always receive a response */
|
||||
let tracks = await Promise.race([
|
||||
const tracks = await Promise.race([
|
||||
delay,
|
||||
player.search(query, {
|
||||
requestedBy: interaction.user,
|
||||
|
@ -225,23 +250,18 @@ export default {
|
|||
|
||||
// If tracks found
|
||||
if (tracks.length > 0) {
|
||||
if (tracks.length > 25) {
|
||||
tracks = tracks
|
||||
// Assure that URL is under the limit of Discord
|
||||
.filter((v) => v.url.length < limit_value_discord)
|
||||
// Slice the list if needed to the 25 first results
|
||||
.slice(0, 25);
|
||||
}
|
||||
|
||||
// Returns a list of songs with their title and author
|
||||
return interaction.respond(
|
||||
tracks.map((t) => {
|
||||
const payload = tracks
|
||||
// Assure that URL is under the limit of Discord
|
||||
.filter((v) => v.url.length < limit_value_discord)
|
||||
// Slice the list to respect the limit of Discord
|
||||
.slice(0, limit_element_discord - 1)
|
||||
.map((t) => {
|
||||
let title = t.title;
|
||||
let author = t.author;
|
||||
let name = `${title} • ${author}`;
|
||||
|
||||
// Slice returned data if needed to not exceed the length limit (100)
|
||||
if (name.length > 100) {
|
||||
// Slice returned data if needed to not exceed the length limit
|
||||
if (name.length > limit_value_discord) {
|
||||
const newTitle = title.substring(0, 40);
|
||||
if (title.length != newTitle.length) {
|
||||
title = `${newTitle}...`;
|
||||
|
@ -257,12 +277,18 @@ export default {
|
|||
name,
|
||||
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 }]);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||
import { collect } from "../../buttons/loader";
|
||||
import { getLocale, getLocalizations } from "../../utils/locales";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { embedListQueue } from "../../utils/music";
|
||||
import { embedListQueue } from "../../utils/commands/music";
|
||||
|
||||
export default {
|
||||
scope: () => [],
|
||||
|
|
|
@ -8,7 +8,8 @@ import {
|
|||
sendReminder,
|
||||
setTimeoutReminder,
|
||||
updateReminder,
|
||||
} from "../../utils/reminder";
|
||||
} from "../../utils/commands/reminder";
|
||||
import { readSQL } from "../../utils/db";
|
||||
|
||||
export const once = true;
|
||||
|
||||
|
@ -19,7 +20,7 @@ export default async (client: Client) => {
|
|||
// Restart all the timeout about reminders here
|
||||
new Promise((ok, ko) => {
|
||||
// Fetch all reminders
|
||||
client.db.all("SELECT * FROM reminder", [], (err, row) => {
|
||||
client.db.all(readSQL("reminder/select"), [], (err, row) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
|
@ -41,14 +42,18 @@ export default async (client: Client) => {
|
|||
} as infoReminder;
|
||||
|
||||
if (element.expiration_date <= now) {
|
||||
// Reminder expired
|
||||
deleteReminder(client, element.creation_date, `${element.user_id}`).then((res) => {
|
||||
if (res != true) {
|
||||
throw res;
|
||||
}
|
||||
});
|
||||
|
||||
sendReminder(client, info, element.option_id as OptionReminder);
|
||||
sendReminder(client, info, element.option_id as OptionReminder)
|
||||
.then(() =>
|
||||
// Reminder expired
|
||||
deleteReminder(client, element.creation_date, `${element.user_id}`).then((res) => {
|
||||
if (res != true) {
|
||||
throw res;
|
||||
}
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
// Restart timeout
|
||||
const timeoutId = setTimeoutReminder(
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { PlayerEvents, useMainPlayer } from "discord-player";
|
||||
import { Client } from "discord.js";
|
||||
import { readdir } from "fs/promises";
|
||||
import { splitFilenameExtensions } from "../utils/misc";
|
||||
import { isDev, splitFilenameExtensions } from "../utils/misc";
|
||||
|
||||
/** Load all the events */
|
||||
export default async (client: Client, isDev: boolean) => {
|
||||
export default async (client: Client) => {
|
||||
const events_categories = (await readdir(__dirname, { withFileTypes: true }))
|
||||
.filter((element) => element.isDirectory())
|
||||
.map((element) => element.name);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Client, EmbedBuilder, Message, TextBasedChannel } from "discord.js";
|
||||
import { getLocale } from "../../utils/locales";
|
||||
import { isImage, userWithNickname } from "../../utils/misc";
|
||||
import { userWithNickname } from "../../utils/misc";
|
||||
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 */
|
||||
export default async (message: Message, client: Client) => {
|
||||
|
@ -96,7 +97,7 @@ export default async (message: Message, client: Client) => {
|
|||
// Remove undefined elements
|
||||
.filter(Boolean);
|
||||
|
||||
const loc = getLocale(client, client.config.default_lang);
|
||||
const loc = getLocale(client);
|
||||
|
||||
// Remove duplicates then map the quoted posts
|
||||
[...new Set(messages)]
|
||||
|
@ -109,24 +110,7 @@ export default async (message: Message, client: Client) => {
|
|||
|
||||
// Handle attachments
|
||||
if (quoted_post.attachments.size !== 0) {
|
||||
if (quoted_post.attachments.size === 1 && isImage(quoted_post.attachments.first()!.name)) {
|
||||
// Only contains one image
|
||||
embed.setImage(quoted_post.attachments.first()!.url);
|
||||
} else {
|
||||
// Contains more than one image and/or other files
|
||||
let files = "";
|
||||
quoted_post.attachments.forEach((file) => (files += `[${file.name}](${file.url}), `));
|
||||
embed.addFields({
|
||||
// TODO: Don't pluralize when there is only one file.
|
||||
|
||||
// TODO: Locales
|
||||
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/188
|
||||
name: "Fichiers joints",
|
||||
// TODO: Check if don't exceed char limit, if yes, split
|
||||
// files into multiples field.
|
||||
value: `${files.slice(0, -2)}.`,
|
||||
});
|
||||
}
|
||||
handleAttachments(loc, embed, quoted_post.attachments);
|
||||
}
|
||||
|
||||
// Description as post content
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
const isDev = process.env.NODE_ENV !== "production";
|
||||
import { isDev } from "./utils/misc";
|
||||
|
||||
/** Load the 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
|
||||
|
|
18
src/load.ts
18
src/load.ts
|
@ -4,15 +4,15 @@ import loadEvents from "./events/loader";
|
|||
import loadModals from "./modals/loader";
|
||||
import loadClient, { quit } from "./utils/client";
|
||||
|
||||
import { logStart } from "./utils/misc";
|
||||
import { isDev, logStart } from "./utils/misc";
|
||||
|
||||
/** Run the bot */
|
||||
export const run = async (isDev: boolean) => {
|
||||
export const run = async () => {
|
||||
console.log("Starting Botanique...");
|
||||
|
||||
// Client Discord.JS
|
||||
const client_name = "Client";
|
||||
await loadClient(isDev)
|
||||
await loadClient()
|
||||
.then(async (client) => {
|
||||
if (isDev) {
|
||||
// Attach debugging listeners
|
||||
|
@ -21,7 +21,7 @@ export const run = async (isDev: boolean) => {
|
|||
|
||||
// Events Discord.JS and Player
|
||||
const events_name = "Events";
|
||||
await loadEvents(client, isDev)
|
||||
await loadEvents(client)
|
||||
.then(() => console.log(logStart(events_name, true)))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
@ -61,11 +61,11 @@ export const run = async (isDev: boolean) => {
|
|||
console.log(logStart(client_name, true));
|
||||
console.log(`Botanique "${client.user?.username}" v${client.config.version} started!`);
|
||||
|
||||
// ^C
|
||||
process.on("SIGINT", () => quit(client));
|
||||
|
||||
// Container force closed
|
||||
process.on("SIGTERM", () => quit(client));
|
||||
// Handle quit
|
||||
process.on("exit", () => quit(client));
|
||||
process.on("SIGHUP", () => process.exit(128 + 1));
|
||||
process.on("SIGINT", () => process.exit(128 + 2));
|
||||
process.on("SIGTERM", () => process.exit(128 + 15));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
|
|
@ -96,6 +96,9 @@
|
|||
"c_play7": "Currently playing",
|
||||
"c_play8": "Asked by",
|
||||
"c_play9": "No results were found",
|
||||
"c_play10": "in position",
|
||||
"c_play11": "estimation",
|
||||
"c_play12": "play",
|
||||
|
||||
"c_stop_name": "stop",
|
||||
"c_stop_desc": "Stop the music",
|
||||
|
@ -148,12 +151,19 @@
|
|||
"c_lyrics_sub2_desc": "Search for romanized lyrics (e.g., hangul → Latin)",
|
||||
"c_lyrics_sub3_name": "synced",
|
||||
"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_desc": "Wanted song",
|
||||
"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_lyrics3": "Unable to find synchronized lyrics for",
|
||||
"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_desc": "Command for the type of music repetition",
|
||||
|
@ -173,5 +183,8 @@
|
|||
"c_repeat6": "enabled",
|
||||
|
||||
"e_trackstart1": "Asked by",
|
||||
"e_trackstart2": "Duration :"
|
||||
"e_trackstart2": "Duration :",
|
||||
|
||||
"e_attachement": "Attachement",
|
||||
"e_attachements": "Attachements"
|
||||
}
|
||||
|
|
|
@ -96,6 +96,9 @@
|
|||
"c_play7": "Joue actuellement",
|
||||
"c_play8": "Demandé par",
|
||||
"c_play9": "Aucun résultat trouvé",
|
||||
"c_play10": "en position",
|
||||
"c_play11": "estimation",
|
||||
"c_play12": "joue",
|
||||
|
||||
"c_stop_name": "stop",
|
||||
"c_stop_desc": "Stop la musique",
|
||||
|
@ -148,12 +151,19 @@
|
|||
"c_lyrics_sub2_desc": "Recherche de paroles romanisées (ex: hangul → latin)",
|
||||
"c_lyrics_sub3_name": "synced",
|
||||
"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_desc": "Chanson recherché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_lyrics3": "Impossible de trouver les paroles synchronisées pour",
|
||||
"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_desc": "Commande relative à la répétition des musiques",
|
||||
|
@ -173,5 +183,8 @@
|
|||
"c_repeat6": "activé",
|
||||
|
||||
"e_trackstart1": "Demandé par",
|
||||
"e_trackstart2": "Durée :"
|
||||
"e_trackstart2": "Durée :",
|
||||
|
||||
"e_attachement": "Fichier joint",
|
||||
"e_attachements": "Fichiers joint"
|
||||
}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { Client, ModalSubmitInteraction } from "discord.js";
|
||||
import { getFilename } from "../../utils/misc";
|
||||
import { newReminder } from "../../utils/reminder";
|
||||
import { newReminder } from "../../utils/commands/reminder";
|
||||
|
||||
export default {
|
||||
data: {
|
||||
name: getFilename(__filename),
|
||||
},
|
||||
interaction: async (interaction: ModalSubmitInteraction, client: Client) =>
|
||||
newReminder(client, interaction.fields.fields.get("reminderGUI-time")!.value, {
|
||||
interaction: async (interaction: ModalSubmitInteraction, client: Client) => {
|
||||
const message = interaction.fields.fields.get("reminderGUI-message")?.value;
|
||||
|
||||
return newReminder(client, interaction.fields.fields.get("reminderGUI-time")!.value, {
|
||||
locale: interaction.locale,
|
||||
message: interaction.fields.fields.get("reminderGUI-message")?.value ?? null,
|
||||
message: message ? (message.length > 0 ? message : null) : null,
|
||||
createdAt: interaction.createdAt.getTime(),
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
|
@ -19,5 +21,6 @@ export default {
|
|||
content: msg as string,
|
||||
ephemeral: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -75,6 +75,8 @@ declare module "discord.js" {
|
|||
string,
|
||||
/** Command itself */
|
||||
{
|
||||
/** Guilds where the command is active */
|
||||
scope: () => string[];
|
||||
/** Data about the command */
|
||||
data: SlashCommandBuilder;
|
||||
/** How the command interact */
|
||||
|
|
12
src/modules/player.ts
Normal file
12
src/modules/player.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export {};
|
||||
|
||||
declare module "discord-player" {
|
||||
export interface GuildQueue {
|
||||
syncedLyricsMemory:
|
||||
| {
|
||||
isSubscribed: () => unknown;
|
||||
unsubscribe: () => unknown;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
}
|
|
@ -12,5 +12,9 @@ declare global {
|
|||
|
||||
/** Capitalize definition */
|
||||
String.prototype.capitalize = function (this: string) {
|
||||
if (this.length === 0) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return this[0].toUpperCase() + this.substring(1);
|
||||
};
|
||||
|
|
13
src/sql/init.sql
Normal file
13
src/sql/init.sql
Normal 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
14
src/sql/reminder/add.sql
Normal 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
13
src/sql/reminder/find.sql
Normal file
|
@ -0,0 +1,13 @@
|
|||
SELECT
|
||||
data,
|
||||
creation_date,
|
||||
expiration_date,
|
||||
id
|
||||
FROM
|
||||
reminder
|
||||
WHERE
|
||||
user_id = ?
|
||||
AND (
|
||||
guild_id = ?
|
||||
OR guild_id = 0
|
||||
)
|
6
src/sql/reminder/findById.sql
Normal file
6
src/sql/reminder/findById.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
SELECT
|
||||
*
|
||||
FROM
|
||||
reminder
|
||||
WHERE
|
||||
id = ?
|
14
src/sql/reminder/ownership_check.sql
Normal file
14
src/sql/reminder/ownership_check.sql
Normal file
|
@ -0,0 +1,14 @@
|
|||
SELECT
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
reminder
|
||||
WHERE
|
||||
id = ?
|
||||
AND user_id = ?
|
||||
AND (
|
||||
guild_id = ?
|
||||
OR guild_id = 0
|
||||
)
|
||||
)
|
4
src/sql/reminder/remove.sql
Normal file
4
src/sql/reminder/remove.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
DELETE FROM reminder
|
||||
WHERE
|
||||
creation_date = ?
|
||||
AND user_id = ?
|
4
src/sql/reminder/select.sql
Normal file
4
src/sql/reminder/select.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
SELECT
|
||||
*
|
||||
FROM
|
||||
reminder
|
13
src/sql/reminder/update.sql
Normal file
13
src/sql/reminder/update.sql
Normal 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 = ?
|
|
@ -25,4 +25,10 @@ describe("Capitalize", () => {
|
|||
expect(name.capitalize()).toBe("Super");
|
||||
});
|
||||
}
|
||||
{
|
||||
const name = "";
|
||||
test(name, () => {
|
||||
expect(name.capitalize()).toBe("");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { OptionReminder, splitTime } from "../../utils/reminder";
|
||||
import { OptionReminder, splitTime } from "../../../utils/commands/reminder";
|
||||
|
||||
describe("Time splitter", () => {
|
||||
{
|
149
src/tests/utils/events/citation.test.ts
Normal file
149
src/tests/utils/events/citation.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,4 +1,10 @@
|
|||
import { nextTimeUnit, showDate, strToSeconds, TimeSecond } from "../../utils/time";
|
||||
import {
|
||||
nextTimeUnit,
|
||||
showDate,
|
||||
strToSeconds,
|
||||
timeDeltaToString,
|
||||
TimeSecond,
|
||||
} from "../../utils/time";
|
||||
|
||||
describe("Date with correct timezone", () => {
|
||||
const map = new Map([["u_time_at", "@"]]);
|
||||
|
@ -84,3 +90,31 @@ describe("Next time unit", () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -4,9 +4,12 @@ import { readFileSync } from "fs";
|
|||
import { Database } from "sqlite3";
|
||||
import "../modules/client";
|
||||
import { loadLocales } from "./locales";
|
||||
import { YoutubeiExtractor } from "discord-player-youtubei";
|
||||
import { readSQL } from "./db";
|
||||
import { isDev } from "./misc";
|
||||
|
||||
/** Creation of the client and definition of its properties */
|
||||
export default async (isDev: boolean) => {
|
||||
export default async () => {
|
||||
const activities = isDev ? [] : [{ name: "/help", type: ActivityType.Watching }];
|
||||
|
||||
const client: Client = new Client({
|
||||
|
@ -50,14 +53,15 @@ export default async (isDev: boolean) => {
|
|||
quality: "highestaudio",
|
||||
},
|
||||
});
|
||||
await player.extractors.loadDefault();
|
||||
await player.extractors.loadDefault((ext) => ext !== "YouTubeExtractor");
|
||||
await player.extractors.register(YoutubeiExtractor, {});
|
||||
|
||||
console.log("Translations progression :");
|
||||
client.locales = await loadLocales(client.config.default_lang);
|
||||
|
||||
client.db = new Database(`${process.env.DOCKERIZED === "1" ? "/config" : "./config"}/db.sqlite3`);
|
||||
|
||||
initDatabase(client.db);
|
||||
client.db.run(readSQL("init"));
|
||||
|
||||
return client;
|
||||
};
|
||||
|
@ -73,25 +77,3 @@ export const quit = (client: Client) => {
|
|||
// Close client
|
||||
client.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initalize the database
|
||||
* @param db Database
|
||||
*/
|
||||
const initDatabase = (db: Database) => {
|
||||
// Table for reminders
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS reminder ( \
|
||||
id INTEGER PRIMARY KEY, \
|
||||
data TEXT, \
|
||||
expiration_date TEXT, \
|
||||
option_id INTEGER, \
|
||||
channel_id TEXT, \
|
||||
creation_date TEXT, \
|
||||
user_id TEXT, \
|
||||
guild_id TEXT, \
|
||||
locale TEXT, \
|
||||
timeout_id TEXT \
|
||||
);",
|
||||
);
|
||||
};
|
||||
|
|
73
src/utils/commands/help.ts
Normal file
73
src/utils/commands/help.ts
Normal 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]
|
||||
);
|
||||
};
|
|
@ -1,7 +1,9 @@
|
|||
import { EmbedBuilder } from "@discordjs/builders";
|
||||
import { GuildQueue, QueueRepeatMode } from "discord-player";
|
||||
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 = (
|
||||
client: Client,
|
||||
|
@ -16,8 +18,7 @@ export const embedListQueue = (
|
|||
// Add the current song at the top of the list
|
||||
tracks.unshift(queue.history.currentTrack!);
|
||||
|
||||
// Limit of discord is 25
|
||||
const limit_fields = 25;
|
||||
const limit_fields = discord_limit_embed_field;
|
||||
|
||||
const pageMax = Math.ceil(tracks.length / limit_fields);
|
||||
|
||||
|
@ -30,7 +31,7 @@ export const embedListQueue = (
|
|||
? loc.get("c_queue10")
|
||||
: (idx === 1 && page === 1) || (idx === 0 && page > 1)
|
||||
? loc.get("c_queue11")
|
||||
: "\u200b";
|
||||
: blank;
|
||||
const idx_track = now_playing ? "" : `${idx + limit_fields * (page - 1)}. `;
|
||||
embed.addFields({
|
||||
name,
|
|
@ -1,8 +1,9 @@
|
|||
import { Client, Colors, EmbedBuilder, User } from "discord.js";
|
||||
import { getLocale } from "./locales";
|
||||
import { cleanCodeBlock } from "./misc";
|
||||
import { showDate, strToSeconds, timeDeltaToString } from "./time";
|
||||
import { RegexC, RegExpFlags } from "./regex";
|
||||
import { getLocale } from "../locales";
|
||||
import { blank, cleanCodeBlock } from "../misc";
|
||||
import { showDate, strToSeconds, timeDeltaToString } from "../time";
|
||||
import { RegexC, RegExpFlags } from "../regex";
|
||||
import { readSQL } from "../db";
|
||||
|
||||
/**
|
||||
* Option possible for reminders
|
||||
|
@ -88,14 +89,14 @@ export const newReminder = async (client: Client, time: string, info: infoRemind
|
|||
|
||||
const timeoutId = setTimeoutReminder(client, info, data.option, timeout);
|
||||
|
||||
const expiration_date = info.createdAt + timeout * 1000;
|
||||
|
||||
// Add the remind to the db
|
||||
client.db.run(
|
||||
"INSERT INTO reminder ( \
|
||||
data, expiration_date, option_id, channel_id, creation_date, user_id, guild_id, locale, timeout_id \
|
||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? );",
|
||||
readSQL("reminder/add"),
|
||||
[
|
||||
info.message,
|
||||
`${info.createdAt + timeout * 1000}`,
|
||||
`${expiration_date}`,
|
||||
data.option.valueOf(),
|
||||
info.channelId,
|
||||
`${info.createdAt}`,
|
||||
|
@ -110,7 +111,7 @@ export const newReminder = async (client: Client, time: string, info: infoRemind
|
|||
}
|
||||
|
||||
// Send confirmation to user
|
||||
ok(`${loc.get("c_reminder1")} ${data.time}.`);
|
||||
ok(`${loc.get("c_reminder1")} ${timeDeltaToString(expiration_date)}.`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
@ -125,26 +126,30 @@ export const newReminder = async (client: Client, time: string, info: infoRemind
|
|||
export const deleteReminder = (client: Client, createdAt: string, userId: string) => {
|
||||
// Delete the reminder for the database
|
||||
return new Promise((ok, ko) => {
|
||||
// Add the remind to the db
|
||||
client.db.run(
|
||||
"DELETE FROM reminder WHERE creation_date = ? AND user_id = ?",
|
||||
[createdAt, userId],
|
||||
(err) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
// Remove the remind to the db
|
||||
client.db.run(readSQL("reminder/remove"), [createdAt, userId], (err) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
|
||||
// Send confirmation to user
|
||||
ok(true);
|
||||
},
|
||||
);
|
||||
// Send confirmation to user
|
||||
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);
|
||||
// Send the message in the appropriate channel
|
||||
// TODO: Embed
|
||||
let message: string;
|
||||
if (info.message === null || info.message.length === 0) {
|
||||
message = loc.get("c_reminder7");
|
||||
|
@ -189,16 +194,20 @@ export const sendReminder = (client: Client, info: infoReminder, option: OptionR
|
|||
// Channel
|
||||
client.channels.fetch(info.channelId!).then((channel) => {
|
||||
if (channel?.isSendable()) {
|
||||
let content = `<@${info.userId}>`;
|
||||
const author_mention = `<@${info.userId}>`;
|
||||
|
||||
let content = author_mention;
|
||||
embed.setFooter({
|
||||
text: `${loc.get("c_reminder17")} ${timeDeltaToString(info.createdAt)}`,
|
||||
});
|
||||
|
||||
// Mention everybody if needed
|
||||
if (option === OptionReminder.Mention) {
|
||||
(info.message?.match(/<@\d+>/g) ?? []).forEach((mention) => {
|
||||
content += " " + mention;
|
||||
});
|
||||
[...new Set(info.message?.match(/<@\d+>/g) ?? [])]
|
||||
.filter((mention) => mention !== author_mention)
|
||||
.forEach((mention: string) => {
|
||||
content += " " + mention;
|
||||
});
|
||||
}
|
||||
|
||||
channel.send({ content, embeds: [embed] });
|
||||
|
@ -266,12 +275,7 @@ export const checkOwnershipReminder = async (
|
|||
const data = (await new Promise((ok, ko) => {
|
||||
// Check the ownership
|
||||
client.db.all<returnData>(
|
||||
"SELECT EXISTS ( \
|
||||
SELECT 1 FROM reminder \
|
||||
WHERE id = ? \
|
||||
AND user_id = ? \
|
||||
AND (guild_id = ? OR guild_id = 0) \
|
||||
)",
|
||||
readSQL("reminder/ownership_check"),
|
||||
[id, userId, guildId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
|
@ -294,19 +298,14 @@ export const checkOwnershipReminder = async (
|
|||
export const getReminderInfo = async (client: Client, id: number) => {
|
||||
return (await new Promise((ok, ko) => {
|
||||
// Check the ownership
|
||||
client.db.all<dbReminder>(
|
||||
"SELECT * FROM reminder \
|
||||
WHERE id = ?",
|
||||
[id],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
client.db.all<dbReminder>(readSQL("reminder/findById"), [id], (err, row) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
|
||||
// Send all the current reminders
|
||||
ok(row[0]);
|
||||
},
|
||||
);
|
||||
// Send all the current reminders
|
||||
ok(row[0]);
|
||||
});
|
||||
})) as dbReminder;
|
||||
};
|
||||
|
||||
|
@ -320,17 +319,7 @@ export const updateReminder = (client: Client, data: dbReminder) => {
|
|||
return new Promise((ok, ko) => {
|
||||
// Update the db
|
||||
client.db.run(
|
||||
"UPDATE reminder \
|
||||
SET data = ?, \
|
||||
expiration_date = ?, \
|
||||
option_id = ?, \
|
||||
channel_id = ?, \
|
||||
creation_date = ?, \
|
||||
user_id = ?, \
|
||||
guild_id = ?, \
|
||||
locale = ?, \
|
||||
timeout_id = ? \
|
||||
WHERE ID = ?",
|
||||
readSQL("reminder/update"),
|
||||
[
|
||||
data.data,
|
||||
data.expiration_date,
|
||||
|
@ -364,19 +353,14 @@ export const updateReminder = (client: Client, data: dbReminder) => {
|
|||
const listReminders = async (client: Client, userId: string, guildId: string | null) => {
|
||||
return (await new Promise((ok, ko) => {
|
||||
// Check the ownership
|
||||
client.db.all<dbReminder>(
|
||||
"SELECT data, creation_date, expiration_date, id FROM reminder \
|
||||
WHERE user_id = ? AND (guild_id = ? OR guild_id = 0)",
|
||||
[userId, guildId ?? 0],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
client.db.all<dbReminder>(readSQL("reminder/find"), [userId, guildId ?? 0], (err, row) => {
|
||||
if (err) {
|
||||
ko(err);
|
||||
}
|
||||
|
||||
// Send all the current reminders
|
||||
ok(row);
|
||||
},
|
||||
);
|
||||
// Send all the current reminders
|
||||
ok(row);
|
||||
});
|
||||
})) as dbReminder[];
|
||||
};
|
||||
|
||||
|
@ -440,7 +424,7 @@ export const embedListReminders = async (
|
|||
});
|
||||
} else {
|
||||
embed.addFields({
|
||||
name: "\u200b",
|
||||
name: blank,
|
||||
value: `${loc.get("c_reminder10")}${page} ${loc.get("c_reminder11")}.`,
|
||||
});
|
||||
}
|
11
src/utils/constants.ts
Normal file
11
src/utils/constants.ts
Normal 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
17
src/utils/db.ts
Normal 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");
|
||||
};
|
57
src/utils/events/citation.ts
Normal file
57
src/utils/events/citation.ts
Normal 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);
|
||||
}
|
||||
};
|
|
@ -81,9 +81,13 @@ export const getLocalizations = (client: Client, text: string, lowercase = false
|
|||
* @param lang Lang to fetch
|
||||
* @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
|
||||
const default_locales = client.locales.get(client.config.default_lang);
|
||||
if (!lang) {
|
||||
return default_locales!;
|
||||
}
|
||||
|
||||
// Load desired lang
|
||||
const desired_locales = client.locales.get(lang);
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { GuildMember } from "discord.js";
|
||||
|
||||
/** Check if we are in the dev environnement */
|
||||
export const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
/**
|
||||
* Log module status
|
||||
* @param {string} name Module name
|
||||
|
@ -131,3 +134,8 @@ export const emojiPng = (emoji: string) =>
|
|||
`https://cdn.jsdelivr.net/gh/twitter/twemoji/assets/72x72/${emoji
|
||||
.codePointAt(0)
|
||||
?.toString(16)}.png`;
|
||||
|
||||
/**
|
||||
* Blank character
|
||||
*/
|
||||
export const blank = "\u200b";
|
||||
|
|
|
@ -3,15 +3,15 @@ import { RegexC, RegExpFlags } from "./regex";
|
|||
|
||||
/**
|
||||
* Parsed string adapted with TZ (locales) and format for the specified lang
|
||||
* @param tz Lang
|
||||
* @param locale Locales
|
||||
* @param lang Locale
|
||||
* @param translation Translation for "at"
|
||||
* @param date Date
|
||||
* @returns String
|
||||
*/
|
||||
export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) => {
|
||||
const localeInfo = new Intl.Locale(tz);
|
||||
export const showDate = (lang: string, translation: Map<string, unknown>, date: Date) => {
|
||||
const localeInfo = new Intl.Locale(lang);
|
||||
const intlTimezone = moment.tz.zonesForCountry(localeInfo.region ?? localeInfo.baseName);
|
||||
const formattedDate = new Intl.DateTimeFormat(tz, {
|
||||
const formattedDate = new Intl.DateTimeFormat(lang, {
|
||||
timeZone: intlTimezone ? intlTimezone[0] : "Factory",
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
|
@ -19,14 +19,14 @@ export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) =
|
|||
.format(date)
|
||||
.split(" ");
|
||||
|
||||
return `${formattedDate[0]} ${locale.get("u_time_at")} ${formattedDate[1]}`;
|
||||
return `${formattedDate[0]} ${translation.get("u_time_at")} ${formattedDate[1]}`;
|
||||
};
|
||||
|
||||
export enum TimeSecond {
|
||||
Year = 31536000,
|
||||
Week = 604800,
|
||||
Day = 86400,
|
||||
Hour = 3600,
|
||||
Year = 60 * 60 * 24 * 365,
|
||||
Week = 60 * 60 * 24 * 7,
|
||||
Day = 60 * 60 * 24,
|
||||
Hour = 60 * 60,
|
||||
Minute = 60,
|
||||
Second = 1,
|
||||
}
|
||||
|
@ -93,15 +93,41 @@ export const strToSeconds = (time: string) => {
|
|||
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
|
||||
* @param time Time
|
||||
* @param time Time in milliseconds
|
||||
* @returns Delta between the time and now
|
||||
*/
|
||||
export const timeDeltaToString = (time: number) => {
|
||||
const now = Date.now();
|
||||
// TODO: adapt the output and not always parse the time as seconds
|
||||
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/189
|
||||
// Use Intl.RelativeTimeFormat ?
|
||||
return `${strToSeconds(`${(now - time) / 1000}`)} secs`;
|
||||
return timeToString(time - now);
|
||||
};
|
||||
|
|
|
@ -102,5 +102,6 @@
|
|||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["./**/*.ts", "./src/locales/*.json"]
|
||||
"include": ["./**/*.ts", "./src/locales/*.json"],
|
||||
"exclude": ["./src/tests"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue