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>
This commit is contained in:
Mylloon 2024-09-27 20:49:36 +02:00 committed by Mylloon
parent a08d0c0e9b
commit 2cc6c0bd74
Signed by: Forgejo
GPG key ID: E72245C752A07631
15 changed files with 3792 additions and 66 deletions

View file

@ -5,3 +5,5 @@
!package-lock.json !package-lock.json
!LICENSE !LICENSE
!tsconfig.json !tsconfig.json
src/tests

View file

@ -1,4 +1,4 @@
name: Lint and Format Check name: PR Check
on: on:
pull_request: pull_request:
@ -21,3 +21,6 @@ jobs:
- 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

3
.gitignore vendored
View file

@ -15,3 +15,6 @@ dist/
# 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 */);
});
}
});
```

3373
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@
"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",
@ -25,15 +26,22 @@
"discord-player": "^6.7.1", "discord-player": "^6.7.1",
"discord.js": "^14.16.2", "discord.js": "^14.16.2",
"mediaplex": "^0.0.9", "mediaplex": "^0.0.9",
"moment-timezone": "^0.5.45",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"uuid": "^10.0.0" "uuid": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "~29.5.13",
"@typescript-eslint/eslint-plugin": "~8.6.0", "@typescript-eslint/eslint-plugin": "~8.6.0",
"@typescript-eslint/parser": "~8.6.0", "@typescript-eslint/parser": "~8.6.0",
"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

@ -2,6 +2,7 @@ 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 { isImage, userWithNickname } from "../../utils/misc";
import { showDate } from "../../utils/time"; import { showDate } from "../../utils/time";
import { RegexC, RegExpFlags } from "../../utils/regex";
/** 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 +25,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 +43,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;
@ -178,7 +179,7 @@ export default async (message: Message, client: Client) => {
// Delete source message if no content when removing links // Delete source message if no content when removing links
if ( if (
!message.content.replace(new RegExp(regex, "g"), "").trim() && !message.content.replace(RegexC(regex, RegExpFlags.Global), "").trim() &&
messages.length === urls.length && messages.length === urls.length &&
!message.mentions.repliedUser && !message.mentions.repliedUser &&
message.channel.isSendable() message.channel.isSendable()

View file

@ -0,0 +1,28 @@
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");
});
}
});

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,28 @@
import { OptionReminder, splitTime } from "../../utils/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,86 @@
import { nextTimeUnit, showDate, strToSeconds, 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);
});
}
});

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

@ -2,6 +2,7 @@ import { Client, Colors, EmbedBuilder, User } from "discord.js";
import { getLocale } from "./locales"; import { getLocale } from "./locales";
import { cleanCodeBlock } from "./misc"; import { cleanCodeBlock } from "./misc";
import { showDate, strToSeconds, timeDeltaToString } from "./time"; import { showDate, strToSeconds, timeDeltaToString } from "./time";
import { RegexC, RegExpFlags } from "./regex";
/** /**
* Option possible for reminders * Option possible for reminders
@ -45,14 +46,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 };
}
}; };
/** /**

View file

@ -1,3 +1,6 @@
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 tz Lang
@ -6,7 +9,10 @@
* @returns String * @returns String
*/ */
export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) => { export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) => {
const localeInfo = new Intl.Locale(tz);
const intlTimezone = moment.tz.zonesForCountry(localeInfo.region ?? localeInfo.baseName);
const formattedDate = new Intl.DateTimeFormat(tz, { const formattedDate = new Intl.DateTimeFormat(tz, {
timeZone: intlTimezone ? intlTimezone[0] : "Factory",
dateStyle: "short", dateStyle: "short",
timeStyle: "medium", timeStyle: "medium",
}) })
@ -16,7 +22,7 @@ export const showDate = (tz: string, locale: Map<string, unknown>, date: Date) =
return `${formattedDate[0]} ${locale.get("u_time_at")} ${formattedDate[1]}`; return `${formattedDate[0]} ${locale.get("u_time_at")} ${formattedDate[1]}`;
}; };
enum TimeSecond { export enum TimeSecond {
Year = 31536000, Year = 31536000,
Week = 604800, Week = 604800,
Day = 86400, Day = 86400,
@ -25,6 +31,18 @@ enum TimeSecond {
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
@ -32,32 +50,44 @@ enum TimeSecond {
*/ */
export const strToSeconds = (time: string) => { export const strToSeconds = (time: string) => {
if (time.length > 15) { if (time.length > 15) {
// 15 is a magic value, weird to be this long // 15 is a magic value as it's weird to have time this long
return -1; return -1;
} }
const regex = new RegExp( const noUnit = "unmarked";
`(?<${TimeSecond[TimeSecond.Year]}>[0-9]+(?=[y|a]))|(?<${ const regex = RegexC(
TimeSecond[TimeSecond.Week] `(?<${TimeSecond[TimeSecond.Year]}>[0-9]+(?=[y|a]))|` +
}>[0-9]+(?=[w]))|(?<${TimeSecond[TimeSecond.Day]}>[0-9]+(?=[d|j]))|(?<${ `(?<${TimeSecond[TimeSecond.Week]}>[0-9]+(?=[w]))|` +
TimeSecond[TimeSecond.Hour] `(?<${TimeSecond[TimeSecond.Day]}>[0-9]+(?=[d|j]))|` +
}>[0-9]+(?=[h]))|(?<${TimeSecond[TimeSecond.Minute]}>[0-9]+(?=[m]))|(?<${ `(?<${TimeSecond[TimeSecond.Hour]}>[0-9]+(?=[h]))|` +
TimeSecond[TimeSecond.Second] `(?<${TimeSecond[TimeSecond.Minute]}>[0-9]+(?=[m]))|` +
}>[0-9]+(?=[s]?))`, `(?<${TimeSecond[TimeSecond.Second]}>[0-9]+(?=[s]))|` +
`(?<${noUnit}>[0-9]+)`,
RegExpFlags.Global | RegExpFlags.Insensitive,
); );
const data = Object.assign({}, regex.exec(time.toLowerCase())?.groups); const data = Array.from(time.matchAll(regex));
if (data.length === 0) {
if (Object.keys(data).length === 0) {
// Regex returned an invalid time // Regex returned an invalid time
return -1; 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;