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
!LICENSE
!tsconfig.json
src/tests

View file

@ -1,4 +1,4 @@
name: Lint and Format Check
name: PR Check
on:
pull_request:
@ -21,3 +21,6 @@ jobs:
- name: 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
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)
- [Soumettre ses modifications](#soumettre-ses-modifications)
- [Gestion du dépôt](#gestion-du-dépôt)
- [Tester son code](#tester-son-code)
## 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).
- De préférences, suivre [ces conventions](https://www.conventionalcommits.org/fr/v1.0.0/)
(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",
"lint": "npx eslint src",
"format-check": "npx prettier --check src",
"format-write": "npx prettier --write src"
"format-write": "npx prettier --write src",
"test": "npx jest"
},
"repository": {
"type": "git",
@ -25,15 +26,22 @@
"discord-player": "^6.7.1",
"discord.js": "^14.16.2",
"mediaplex": "^0.0.9",
"moment-timezone": "^0.5.45",
"sqlite3": "^5.1.7",
"typescript": "^5.6.2",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/jest": "~29.5.13",
"@typescript-eslint/eslint-plugin": "~8.6.0",
"@typescript-eslint/parser": "~8.6.0",
"dotenv": "~16.4.5",
"jest": "~29.7.0",
"prettier-eslint": "~16.3.0",
"ts-jest": "~29.2.5",
"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 { isImage, userWithNickname } from "../../utils/misc";
import { showDate } from "../../utils/time";
import { RegexC, RegExpFlags } from "../../utils/regex";
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-messageCreate */
export default async (message: Message, client: Client) => {
@ -24,7 +25,7 @@ export default async (message: Message, client: Client) => {
/* Citation */
const regex =
/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
if (!urls) {
@ -42,7 +43,7 @@ export default async (message: Message, client: Client) => {
}[] = [],
match,
) => {
const [, guild_id, channel_id, message_id] = new RegExp(regex).exec(
const [, guild_id, channel_id, message_id] = RegexC(regex).exec(
match,
) as RegExpExecArray;
@ -178,7 +179,7 @@ export default async (message: Message, client: Client) => {
// Delete source message if no content when removing links
if (
!message.content.replace(new RegExp(regex, "g"), "").trim() &&
!message.content.replace(RegexC(regex, RegExpFlags.Global), "").trim() &&
messages.length === urls.length &&
!message.mentions.repliedUser &&
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 { cleanCodeBlock } from "./misc";
import { showDate, strToSeconds, timeDeltaToString } from "./time";
import { RegexC, RegExpFlags } from "./regex";
/**
* Option possible for reminders
@ -45,14 +46,26 @@ export type dbReminder = {
* @param time raw text from user
* @returns An object with the time and the option
*/
const splitTime = (time: string) => {
if (time?.endsWith("@")) {
return { time: time.slice(0, -1), option: OptionReminder.Mention };
} else if (time?.toLowerCase().endsWith("p")) {
return { time: time.slice(0, -1), option: OptionReminder.DirectMessage };
}
export const splitTime = (time: string) => {
const mapping = {
[OptionReminder.DirectMessage]: "p",
[OptionReminder.Mention]: "@",
};
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
* @param tz Lang
@ -6,7 +9,10 @@
* @returns String
*/
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, {
timeZone: intlTimezone ? intlTimezone[0] : "Factory",
dateStyle: "short",
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]}`;
};
enum TimeSecond {
export enum TimeSecond {
Year = 31536000,
Week = 604800,
Day = 86400,
@ -25,6 +31,18 @@ enum TimeSecond {
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
* @param time time in human format
@ -32,32 +50,44 @@ enum TimeSecond {
*/
export const strToSeconds = (time: string) => {
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;
}
const regex = new RegExp(
`(?<${TimeSecond[TimeSecond.Year]}>[0-9]+(?=[y|a]))|(?<${
TimeSecond[TimeSecond.Week]
}>[0-9]+(?=[w]))|(?<${TimeSecond[TimeSecond.Day]}>[0-9]+(?=[d|j]))|(?<${
TimeSecond[TimeSecond.Hour]
}>[0-9]+(?=[h]))|(?<${TimeSecond[TimeSecond.Minute]}>[0-9]+(?=[m]))|(?<${
TimeSecond[TimeSecond.Second]
}>[0-9]+(?=[s]?))`,
const noUnit = "unmarked";
const regex = RegexC(
`(?<${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.toLowerCase())?.groups);
if (Object.keys(data).length === 0) {
const data = Array.from(time.matchAll(regex));
if (data.length === 0) {
// Regex returned an invalid time
return -1;
}
let res = 0;
Object.entries(data).forEach(([key, value]) => {
let lastUnit = TimeSecond.Second;
data.forEach((match) => {
Object.entries(match.groups!).forEach(([key, value]) => {
if (value) {
res += +value * TimeSecond[key as keyof typeof TimeSecond];
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;