Compare commits

..

18 Commits

Author SHA1 Message Date
8420344519 fix: remove all git rev thing i'll come back to that later
All checks were successful
Build and Deploy / typecheck (push) Successful in 31s
Build and Deploy / build (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 2s
2026-06-01 22:33:47 +02:00
1c2d09cafc fix: rename workflow to deploy, and parse git rev in the dockerfile
All checks were successful
Build and Deploy / typecheck (push) Successful in 34s
Build and Deploy / build (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 2s
2026-06-01 22:23:41 +02:00
4ce3e97727 feat(discord-bot): remove old gems rewards message
Some checks failed
Build and Deploy / typecheck (push) Successful in 1m27s
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / build (push) Has been cancelled
2026-06-01 21:58:02 +02:00
4059ea1ddf chore(discord-bot): remove all migration related code
All checks were successful
Build and Deploy / typecheck (push) Successful in 27s
Build and Deploy / build (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 2s
2026-05-30 22:13:13 +02:00
82eb239f5e feat(discord-bot): remove REPORTS_JSON and implement embed migration
All checks were successful
Build and Deploy / typecheck (push) Successful in 29s
Build and Deploy / build (push) Successful in 25s
Build and Deploy / deploy (push) Successful in 4s
2026-05-30 21:58:47 +02:00
943d69472c feat(discord-bot): REPORTS_JSON for reports migration
All checks were successful
Build and Deploy / typecheck (push) Successful in 36s
Build and Deploy / build (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 1s
2026-05-30 21:15:15 +02:00
fcd85350ec feat: use portainer hook instead of pushing image to gitea package repository
All checks were successful
Build and Deploy / typecheck (push) Successful in 1m28s
Build and Deploy / build (push) Successful in 13s
Build and Deploy / deploy (push) Successful in 1s
2026-05-30 12:36:33 +02:00
dab026de99 feat(discord-bot): add separator for report messages
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 31s
Build and Push Docker Image / build (push) Successful in 52s
2026-05-29 23:55:48 +02:00
d76ac73d84 feat(discord-bot): screenshots placeholder message to edit
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 26s
Build and Push Docker Image / build (push) Successful in 35s
2026-05-12 20:01:38 +02:00
d679a63d3d fix: also delete the screenshots message
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 24s
Build and Push Docker Image / build (push) Successful in 36s
2026-05-12 19:31:14 +02:00
2f7f8689b2 fix: missing env variable in docker-compose.yml
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 25s
Build and Push Docker Image / build (push) Successful in 10s
2026-05-12 19:26:57 +02:00
ca13301263 fix(discord-bot): missing types
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 24s
Build and Push Docker Image / build (push) Successful in 34s
2026-05-12 19:22:23 +02:00
00b3ade095 feat: implement reporting system
Some checks failed
Build and Push Docker Image / typecheck (push) Failing after 24s
Build and Push Docker Image / build (push) Has been skipped
2026-05-12 19:18:02 +02:00
f5a7dbf1e8 feat(discord-bot): add fdp command
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 33s
2026-05-12 16:28:08 +02:00
69e461b54b fix: check command needs the packages to be built first
All checks were successful
Build and Push Docker Image / typecheck (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 38s
2026-05-12 16:06:18 +02:00
4b28b78800 feat(discord-bot): add tg command
Some checks failed
Build and Push Docker Image / typecheck (push) Failing after 13s
Build and Push Docker Image / build (push) Has been skipped
2026-05-12 16:04:01 +02:00
8629cf246b feat(discord-bot): change bot prefix and improve quest results interaction
Some checks failed
Build and Push Docker Image / typecheck (push) Failing after 48s
Build and Push Docker Image / build (push) Has been skipped
2026-05-12 16:01:14 +02:00
528fff3a5b refactor: improve code quality 2026-05-12 15:42:21 +02:00
59 changed files with 3480 additions and 1327 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[**/*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

View File

@@ -0,0 +1,44 @@
name: Build and Deploy
on:
push:
branches:
- main
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
run_install: true
- name: Typecheck and Lint
run: pnpm check
build:
runs-on: ubuntu-latest
needs: typecheck
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: docker build -f apps/discord-bot/Dockerfile .
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Trigger Portainer webhook
run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"

View File

@@ -1,29 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
registry: git.pihkaal.me
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push Docker image
run: |
docker build -t git.pihkaal.me/pihkaal/lbf-bot:latest -f apps/discord-bot/Dockerfile .
docker push git.pihkaal.me/pihkaal/lbf-bot:latest

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -2,10 +2,11 @@
"name": "@lbf/discord-bot", "name": "@lbf/discord-bot",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx src/index.ts", "dev": "COMMIT_SHA=$(git rev-parse --short HEAD)-dev tsx src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "rm -rf dist && tsc --project tsconfig.build.json && tsc-alias --project tsconfig.build.json", "build": "rm -rf dist && tsc --project tsconfig.build.json && tsc-alias --project tsconfig.build.json",
"dev:user": "tsx src/index.ts -- --user" "dev:user": "COMMIT_SHA=$(git rev-parse --short HEAD)-dev tsx src/index.ts -- --user",
"check": "tsc --noEmit && eslint src/"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
@@ -14,10 +15,10 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"dependencies": { "dependencies": {
"@lbf-bot/utils": "workspace:*",
"@lbf-bot/database": "workspace:*", "@lbf-bot/database": "workspace:*",
"discord.js": "^14.21.0", "@lbf-bot/utils": "workspace:*",
"dotenv": "^17.2.3", "discord.js": "^14.26.4",
"zod": "^3.24.4" "sharp": "^0.34.5",
"zod": "4.1.11"
} }
} }

View File

@@ -0,0 +1,48 @@
import { AttachmentBuilder } from "discord.js";
import sharp from "sharp";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { noMention } from "~/discord";
import type { Command } from "./index";
const POOP_PATH = join(dirname(fileURLToPath(import.meta.url)), "../../assets/poop.png");
export const fdpCommand: Command = {
help: "Essaie et tu verras",
handler: async (message) => {
const avatarUrl = message.author.displayAvatarURL({ size: 512, extension: "png" });
const avatarBuffer = Buffer.from(await (await fetch(avatarUrl)).arrayBuffer());
const { width: avatarWidth, height: avatarHeight } = await sharp(avatarBuffer).metadata();
if (!avatarWidth || !avatarHeight) return;
const poopWidth = Math.floor(avatarWidth / 2);
const poopBuffer = await sharp(POOP_PATH).resize({ width: poopWidth }).toBuffer();
const { height: poopHeight } = await sharp(poopBuffer).metadata();
if (!poopHeight) return;
const overlap = Math.floor(poopHeight / 4);
const poopLeft = Math.floor((avatarWidth - poopWidth) / 2);
const result = await sharp({
create: {
width: avatarWidth,
height: avatarHeight + poopHeight - overlap,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: avatarBuffer, top: poopHeight - overlap, left: 0 },
{ input: poopBuffer, top: 0, left: poopLeft },
])
.webp()
.toBuffer();
await message.reply({
...noMention,
files: [new AttachmentBuilder(result, { name: "fdp.webp" })],
});
},
};

View File

@@ -1,44 +1,35 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import { env } from "~/env"; import { env } from "~/env";
import type { Command } from "~/commands"; import { getClanMembers } from "~/wov";
import { getAccountBalance, setAccountBalance } from "~/services/account"; import { getAccountBalance, setAccountBalance } from "~/economy";
import { getClanMembers } from "~/services/wov"; import { replyError } from "~/discord";
import { replyError } from "~/utils/discord"; import type { Command } from "./index";
import { noMention } from "~/discord";
export const gemmesCommand: Command = async (message, args) => { export const gemmesCommand: Command = {
// retrieve player name help: "Affiche ou modifie le solde de gemmes d'un membre",
// NOTE: discord members have display name formatted like "🕸 | InGamePseudo" handler: async (message, args) => {
// discord members have display name formatted like "🕸 | InGamePseudo"
const displayName = message.member?.displayName || message.author.username; const displayName = message.member?.displayName || message.author.username;
const playerName = args[0] || displayName.replace("🕸 |", "").trim(); const playerName = args[0] || displayName.replace("🕸 |", "").trim();
// get clan member
const clanMembers = await getClanMembers(); const clanMembers = await getClanMembers();
const clanMember = clanMembers.find((x) => x.username === playerName); const clanMember = clanMembers.find((x) => x.username === playerName);
if (!clanMember) { if (!clanMember) {
await replyError( await replyError(message, `\`${playerName}\` n'est pas dans le clan (la honte).\n**Attention les majuscules sont importantes**`);
message,
`\`${playerName}\` n'est pas dans le clan (la honte).\n**Attention les majuscules sont importantes**`,
);
return; return;
} }
// handle balance modification (staff only)
if (args.length === 2) { if (args.length === 2) {
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) { if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError( await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
message,
"Tu t'es cru chez mémé ou quoi faut être staff",
);
return; return;
} }
const op = args[1][0]; const op = args[1][0];
const amount = Number(args[1].substring(1)); const amount = Number(args[1].substring(1));
if ((op !== "+" && op !== "-") || args[1].length === 1 || isNaN(amount)) { if ((op !== "+" && op !== "-") || args[1].length === 1 || isNaN(amount)) {
await replyError( await replyError(message, "Usage: `@LBF gemmes <pseudo> <+GEMMES | -GEMMES>`\nExemple: `@LBF gemmes Yuno -10000`\n**Attention les majuscules sont importantes**");
message,
"Format: `@LBF gemmes <pseudo> <+GEMMES | -GEMMES>`\nExemple: `@LBF gemmes Yuno -10000`\n**Attention les majuscules sont importantes**",
);
return; return;
} }
@@ -47,21 +38,14 @@ export const gemmesCommand: Command = async (message, args) => {
await setAccountBalance(clanMember.playerId, Math.max(0, balance + delta)); await setAccountBalance(clanMember.playerId, Math.max(0, balance + delta));
} }
// display balance
const balance = await getAccountBalance(clanMember.playerId); const balance = await getAccountBalance(clanMember.playerId);
await message.reply({ await message.reply({
options: noMention,
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setDescription( .setDescription(`### 💎 Compte de ${playerName}\n\n\nGemmes disponibles: **${balance}**\n\n-# Voir avec ${env.DISCORD_REWARDS_GIVER} pour échanger contre skin/carte etc`)
// TODO: mention here instead of in the env .setColor(0x4289c1)
`### 💎 Compte de ${playerName}\n\n\nGemmes disponibles: **${balance}**\n\n-# Voir avec ${env.DISCORD_REWARDS_GIVER} pour échanger contre skin/carte etc`,
)
.setColor(0x4289c1),
], ],
options: {
allowedMentions: {
repliedUser: false,
},
},
}); });
},
}; };

View File

@@ -1,57 +1,43 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import type { Command } from "~/commands"; import { searchPlayer, getClanInfo } from "~/wov";
import { searchPlayer, getClanInfos } from "~/services/wov"; import { replyError } from "~/discord";
import { replyError } from "~/utils/discord"; import type { Command } from "./index";
import { noMention } from "~/discord";
export const iconeCommand: Command = async (message, args) => { export const iconeCommand: Command = {
help: "Affiche l'icone et le nom du clan d'un joueur",
handler: async (message, args) => {
const playerName = args[0]; const playerName = args[0];
if (!playerName) { if (!playerName) {
await replyError( await replyError(message, "Usage:`@LBF icone NOM_JOUEUR`, exemple: `@LBF icone Yuno`.\n**Attention les majuscules sont importantes**");
message,
"Usage:`@LBF icone NOM_JOUEUR`, exemple: `@LBF icone Yuno`.\n**Attention les majuscules sont importantes**",
);
return; return;
} }
const player = await searchPlayer(playerName); const player = await searchPlayer(playerName);
if (!player) { if (!player) {
await replyError( await replyError(message, "Joueur·euse non trouvé·e.\n**Attention les majuscules sont importantes**");
message,
"Joueur·euse non trouvé·e.\n**Attention les majuscules sont importantes**",
);
return; return;
} }
if (!player.clanId) { if (!player.clanId) {
await replyError( await replyError(message, "Cette personne __n'a pas de clan__ ou __a caché son clan__.\n**Attention les majuscules sont importantes**");
message,
"Cette personne __n'a pas de clan__ ou __a caché son clan__.\n**Attention les majuscules sont importantes**",
);
return; return;
} }
const clan = await getClanInfos(player.clanId); const clan = await getClanInfo(player.clanId);
if (!clan) { if (!clan) {
await replyError( await replyError(message, "Impossible de récupérer les informations du clan.");
message,
"Impossible de récupérer les informations du clan.",
);
return; return;
} }
await message.reply({ await message.reply({
options: noMention,
content: clan.tag, content: clan.tag,
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setDescription( .setDescription(`### ✅ Informations du clan\n\n**Nom:** \`\`\`${clan.name}\`\`\`\n**Tag:** \`\`\`${clan.tag}\`\`\``)
`### ✅ Informations du clan\n\n**Nom:** \`\`\`${clan.name}\`\`\`\n**Tag:** \`\`\`${clan.tag}\`\`\``, .setColor(65280)
)
.setColor(65280),
], ],
options: {
allowedMentions: {
repliedUser: false,
},
},
}); });
},
}; };

View File

@@ -1,23 +1,34 @@
import type { Message, OmitPartialGroupDMChannel } from "discord.js"; import type { Message, OmitPartialGroupDMChannel } from "discord.js";
import { fdpCommand } from "./fdp";
import { pingCommand } from "./ping"; import { pingCommand } from "./ping";
import { trackCommand } from "./track"; import { trackCommand } from "./tracking";
import { tejtrackCommand } from "./tejtrack";
import { iconeCommand } from "./icone"; import { iconeCommand } from "./icone";
import { gemmesCommand } from "./gemmes"; import { gemmesCommand } from "./gemmes";
import { resultCommand } from "./result"; import { resultCommand } from "./result";
import { queteCommand } from "./quete"; import { queteCommand } from "./quete";
import { tgCommand } from "./tg";
import { reportmsgCommand } from "./reportmsg";
import { reportsCommand } from "./reports";
export type Command = ( export type CommandHandler = (
message: OmitPartialGroupDMChannel<Message<boolean>>, message: OmitPartialGroupDMChannel<Message<boolean>>,
args: Array<string>, args: Array<string>,
) => Promise<void> | void; ) => Promise<void> | void;
export type Command = {
help: string;
handler: CommandHandler;
};
export const commands: Record<string, Command> = { export const commands: Record<string, Command> = {
fdp: fdpCommand,
ping: pingCommand, ping: pingCommand,
track: trackCommand, track: trackCommand,
tejtrack: tejtrackCommand,
icone: iconeCommand, icone: iconeCommand,
gemmes: gemmesCommand, gemmes: gemmesCommand,
result: resultCommand,
quete: queteCommand, quete: queteCommand,
result: resultCommand,
tg: tgCommand,
reportmsg: reportmsgCommand,
reports: reportsCommand,
}; };

View File

@@ -1,12 +1,12 @@
import type { Command } from "~/commands"; import { noMention } from "~/discord";
import type { Command } from "./index";
export const pingCommand: Command = async (message, args) => { export const pingCommand: Command = {
help: "Pong",
handler: async (message) => {
await message.reply({ await message.reply({
content: "🫵 Pong", options: noMention,
options: { content: `🫵 Pong`,
allowedMentions: {
repliedUser: false,
},
},
}); });
},
}; };

View File

@@ -1,20 +1,27 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import type { Command } from "~/commands"; import { getActiveQuest, getLatestQuest } from "~/wov";
import { getActiveQuest, getLatestQuest } from "~/services/wov"; import { replyError } from "~/discord";
import type { Command } from "./index";
import { noMention } from "~/discord";
export const queteCommand: Command = async (message) => { export const queteCommand: Command = {
help: "Affiche la quête en cours ou la dernière quête",
handler: async (message) => {
const quest = (await getActiveQuest()) ?? (await getLatestQuest()); const quest = (await getActiveQuest()) ?? (await getLatestQuest());
if (!quest) {
await replyError(message, "Impossible de récupérer la quête.");
return;
}
const color = parseInt(quest.quest.promoImagePrimaryColor.substring(1), 16); const color = parseInt(quest.quest.promoImagePrimaryColor.substring(1), 16);
await message.channel.send({ await message.channel.send({
allowedMentions: { options: noMention,
repliedUser: false
},
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setTitle("Quête actuelle") .setTitle("Quête actuelle")
.setImage(quest.quest.promoImageUrl) .setImage(quest.quest.promoImageUrl)
.setColor(color), .setColor(color)
], ],
}); });
},
}; };

View File

@@ -0,0 +1,23 @@
import { ChannelType } from "discord.js";
import { sendReportEmbed } from "~/reporting";
import { replyError } from "~/discord";
import type { Command } from "./index";
import { env } from "~/env";
export const reportmsgCommand: Command = {
help: "Envoie le message de signalement dans ce salon",
handler: async (message) => {
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
return;
}
if (message.channel.type !== ChannelType.GuildText) {
await replyError(message, "Cette commande doit être utilisée dans un salon texte.");
return;
}
await message.delete();
await sendReportEmbed(message.channel);
},
};

View File

@@ -0,0 +1,50 @@
import { EmbedBuilder } from "discord.js";
import { db, tables, eq } from "@lbf-bot/database";
import { noMention, replyError } from "~/discord";
import { searchPlayer } from "~/wov";
import type { Command } from "./index";
export const reportsCommand: Command = {
help: "Liste tous les signalements d'un joueur",
handler: async (message, args) => {
const username = args.join(" ");
if (!username) {
await replyError(message, "Usage: `lbf reports <pseudo>`");
return;
}
const player = await searchPlayer(username);
if (!player) {
await replyError(message, `Aucun joueur avec le pseudo **${username}** n'a été trouvé.`);
return;
}
const reports = await db
.select()
.from(tables.reports)
.where(eq(tables.reports.playerId, player.id))
.orderBy(tables.reports.createdAt);
if (reports.length === 0) {
await message.reply({ ...noMention, content: `Aucun signalement trouvé pour **${username}**.` });
return;
}
const lines = reports.map((r, i) => {
const date = r.createdAt.toLocaleDateString("fr-FR");
const reason = r.reason.length > 100 ? `${r.reason.slice(0, 100)}` : r.reason;
const link = r.messageLink ? ` - [voir](${r.messageLink})` : "";
return `**${i + 1}.** ${date} - \`${reason}\`${link}`;
});
await message.reply({
...noMention,
embeds: [
new EmbedBuilder()
.setTitle(`Signalements de ${username}`)
.setDescription(lines.join("\n"))
.setFooter({ text: `${reports.length} signalement${reports.length > 1 ? "s" : ""}` }),
],
});
},
};

View File

@@ -1,9 +1,16 @@
import type { Command } from "~/commands"; import { getLatestQuest } from "~/wov";
import { getLatestQuest } from "~/services/wov"; import { askForGrinders } from "~/quests";
import { askForGrinders } from "~/utils/quest"; import { replyError } from "~/discord";
import type { Command } from "./index";
export const resultCommand: Command = async (message, args) => { export const resultCommand: Command = {
const client = message.client; help: "Déclenche manuellement la publication des résultats de la dernière quête",
handler: async (message) => {
const quest = await getLatestQuest(); const quest = await getLatestQuest();
await askForGrinders(quest, client); if (!quest) {
await replyError(message, "Impossible de récupérer la dernière quête.");
return;
}
await askForGrinders(quest, message.client);
},
}; };

View File

@@ -1,61 +0,0 @@
import type { Command } from "~/commands";
import { isWovPlayerTracked, untrackWovPlayer } from "~/services/tracking";
import { searchPlayer } from "~/services/wov";
import { replyError, createInfoEmbed, replySuccess } from "~/utils/discord";
import { env } from "~/env";
import { createLogger } from "@lbf-bot/utils";
const trackingLogger = createLogger({ prefix: "tracking" });
export const tejtrackCommand: Command = async (message, args) => {
// check staff permission
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
return;
}
const playerName = args[0];
if (!playerName) {
await replyError(
message,
"Usage:`@LBF untrack NOM_JOUEUR`, exemple: `@LBF untrack Yuno`.\n**Attention les majuscules sont importantes**",
);
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await replyError(
message,
"Cette personne n'existe pas.\n**Attention les majuscules sont importantes**",
);
return;
}
if (!(await isWovPlayerTracked(player.id))) {
await replyError(
message,
`Pas de tracker pour \`${playerName}\` [\`${player.id}\`]`,
);
return;
}
await untrackWovPlayer(player.id);
await replySuccess(
message,
`Tracker enlevé pour \`${playerName}\` [\`${player.id}\`]`,
);
const chan = message.client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) {
return trackingLogger.fatal("Invalid 'DISCORD_TRACKING_CHANNEL'");
}
await chan.send(
createInfoEmbed(
`### [REMOVED] \`${playerName}\` [\`${player.id}\`]`,
0xea0000,
),
);
};

View File

@@ -0,0 +1,9 @@
import { noMention } from "~/discord";
import type { Command } from "./index";
export const tgCommand: Command = {
help: "...",
handler: async (message) => {
await message.reply({ ...noMention, content: "non toi tg" });
},
};

View File

@@ -1,59 +0,0 @@
import type { Command } from "~/commands";
import { trackWovPlayer, isWovPlayerTracked } from "~/services/tracking";
import { searchPlayer } from "~/services/wov";
import { replyError, createInfoEmbed, replySuccess } from "~/utils/discord";
import { env } from "~/env";
import { createLogger } from "@lbf-bot/utils";
const trackingLogger = createLogger({ prefix: "tracking" });
export const trackCommand: Command = async (message, args) => {
// check staff permission
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
return;
}
const playerName = args[0];
if (!playerName) {
await replyError(
message,
"Usage:`@LBF track NOM_JOUEUR`, exemple: `@LBF track Yuno`.\n**Attention les majuscules sont importantes**",
);
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await replyError(
message,
"Cette personne n'existe pas.\n**Attention les majuscules sont importantes**",
);
return;
}
const alreadyTracked = await isWovPlayerTracked(player.id);
if (alreadyTracked) {
await replyError(
message,
`Tracker déjà enregistré pour \`${playerName}\` [\`${player.id}\`]`,
);
return;
}
await trackWovPlayer(player.id);
await replySuccess(
message,
`Tracker enregistré pour \`${playerName}\` [\`${player.id}\`]`,
);
const chan = message.client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) {
return trackingLogger.fatal("Invalid 'DISCORD_TRACKING_CHANNEL'");
}
await chan.send(
createInfoEmbed(`### [NEW] \`${playerName}\` [\`${player.id}\`]`),
);
};

View File

@@ -0,0 +1,67 @@
import { env } from "~/env";
import { searchPlayer } from "~/wov";
import { createInfoEmbed, replyError, replySuccess } from "~/discord";
import { isTracked, trackPlayer, untrackPlayer } from "~/tracking";
import type { Command } from "./index";
export const trackCommand: Command = {
help: "Gère le tracking d'un joueur",
handler: async (message, args) => {
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
return;
}
const [subcommand, playerName] = args;
if (!playerName) {
await replyError(message, "Usage: `@LBF track add/rm NOM_JOUEUR`. \`add\` = ajoute, \`rm\` = retirer\n**Attention les majuscules sont importantes**");
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await replyError(message, "Cette personne n'existe pas.\n**Attention les majuscules sont importantes**");
return;
}
const trackingChannel = message.client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!trackingChannel?.isSendable()) {
await replyError(message, `Impossible d'envoyer un message dans <#${env.DISCORD_TRACKING_CHANNEL}>`);
return;
}
switch (subcommand) {
case "add": {
if (await isTracked(player.id)) {
await replyError(message, `Tracker déjà enregistré pour \`${playerName}\` [\`${player.id}\`]`);
return;
}
await trackPlayer(player.id);
await replySuccess(message, `Tracker enregistré pour \`${playerName}\` [\`${player.id}\`]`);
await trackingChannel.send(createInfoEmbed(`### [NEW] \`${playerName}\` [\`${player.id}\`]`));
break;
}
case "rm":
case "remove": {
if (!(await isTracked(player.id))) {
await replyError(message, `Pas de tracker pour \`${playerName}\` [\`${player.id}\`]`);
return;
}
await untrackPlayer(player.id);
await replySuccess(message, `Tracker enlevé pour \`${playerName}\` [\`${player.id}\`]`);
await trackingChannel.send(createInfoEmbed(`### [REMOVED] \`${playerName}\` [\`${player.id}\`]`, 0xea0000));
break;
}
default: {
await replyError(message, `Sous-commande inconnue \`${subcommand}\`. Usage: \`@LBF track add/rm NOM_JOUEUR\`. \`add\` = ajoute, \`rm\` = retirer`);
}
}
},
};

View File

@@ -0,0 +1,24 @@
import type { MessageCreateOptions, Message } from "discord.js";
export const noMention = { allowedMentions: { repliedUser: false } } as const;
export const createErrorEmbed = (message: string, color = 0xea0000): MessageCreateOptions => ({
embeds: [{ description: `### Erreur\n\n\n${message}`, color }],
});
export const createSuccessEmbed = (message: string, color = 0x00ea00): MessageCreateOptions => ({
embeds: [{ description: `### ${message}`, color }],
});
export const createInfoEmbed = (message: string, color = 0x89cff0): MessageCreateOptions => ({
embeds: [{ description: message, color }],
});
export const replyError = (message: Message, text: string, color?: number) =>
message.reply(createErrorEmbed(text, color));
export const replySuccess = (message: Message, text: string, color?: number) =>
message.reply(createSuccessEmbed(text, color));
export const replyInfo = (message: Message, text: string, color?: number) =>
message.reply(createInfoEmbed(text, color));

View File

@@ -0,0 +1,23 @@
import { db, tables, eq } from "@lbf-bot/database";
export const getAccountBalance = async (playerId: string): Promise<number> => {
const account = await db.query.accounts.findFirst({
where: eq(tables.accounts.playerId, playerId),
});
if (!account) {
await setAccountBalance(playerId, 0);
return 0;
}
return account.balance;
};
export const setAccountBalance = async (playerId: string, balance: number): Promise<void> => {
await db
.insert(tables.accounts)
.values({ playerId, balance })
.onConflictDoUpdate({
target: tables.accounts.playerId,
set: { balance, updatedAt: new Date() },
});
};

View File

@@ -1,10 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import "dotenv/config"; import { parseEnv } from "@lbf-bot/utils";
import { logger } from "@lbf-bot/utils";
// TODO: use parseEnv from utils // TODO: use parseEnv from utils
const schema = z.object({ export const env = parseEnv({
DISCORD_BOT_TOKEN: z.string(), DISCORD_BOT_TOKEN: z.string(),
DISCORD_MENTION: z.string(), DISCORD_MENTION: z.string(),
DISCORD_REWARDS_GIVER: z.string(), DISCORD_REWARDS_GIVER: z.string(),
@@ -14,6 +13,7 @@ const schema = z.object({
// TODO: rename to reward ask channel or smth // TODO: rename to reward ask channel or smth
DISCORD_ADMIN_CHANNEL: z.string(), DISCORD_ADMIN_CHANNEL: z.string(),
DISCORD_TRACKING_CHANNEL: z.string(), DISCORD_TRACKING_CHANNEL: z.string(),
DISCORD_REPORT_CHANNEL: z.string(),
DISCORD_STAFF_ROLE_ID: z.string(), DISCORD_STAFF_ROLE_ID: z.string(),
WOV_API_KEY: z.string(), WOV_API_KEY: z.string(),
WOV_CLAN_ID: z.string(), WOV_CLAN_ID: z.string(),
@@ -31,16 +31,5 @@ const schema = z.object({
.string() .string()
.transform((x) => x.split(",").map((x) => x.trim())) .transform((x) => x.split(",").map((x) => x.trim()))
.optional() .optional()
.default(""), .default([]),
}); });
const result = schema.safeParse(process.env);
if (!result.success) {
logger.fatal(
`❌ Invalid environments variables:\n${result.error.errors
.map((x) => `- ${x.path.join(".")}: ${x.message}`)
.join("\n")}`,
);
}
export const env = result.data;

View File

@@ -1,17 +1,38 @@
import { logger } from "@lbf-bot/utils";
import { runMigrations } from "@lbf-bot/database";
import { env } from "~/env"; import { env } from "~/env";
import { Client, GatewayIntentBits, Partials } from "discord.js"; import { Client, GatewayIntentBits, Partials } from "discord.js";
import { setupBotMode } from "~/modes/bot"; import { setupBotMode } from "~/modes/bot";
import { setupUserMode } from "~/modes/user"; import { setupUserMode } from "~/modes/user";
import { parseArgs } from "~/utils/cli";
import { runMigrations } from "@lbf-bot/database";
import { logger } from "@lbf-bot/utils";
logger.info("Running database migrations..."); logger.info("Running database migrations...");
await runMigrations(); await runMigrations();
const mode = parseArgs(process.argv.slice(2)); type Mode = { type: "bot" } | { type: "user"; channelId: string };
let mode: Mode = { type: "bot" };
const args = process.argv.slice(2);
logger.info(`Mode: ${mode.type}`); for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "-u":
case "--as-user": {
const channelId = args[(i += 1)];
if (!channelId) {
console.error(`ERROR: ${arg} requires a channel ID`);
process.exit(1);
}
mode = { type: "user", channelId };
break;
}
default: {
console.error(`ERROR: Unrecognized argument: '${arg}'`);
process.exit(1);
}
}
}
const client = new Client({ const client = new Client({
intents: [ intents: [
@@ -23,21 +44,15 @@ const client = new Client({
partials: [Partials.Message, Partials.Channel], partials: [Partials.Message, Partials.Channel],
}); });
switch (mode.type) { logger.info(`Starting as ${mode.type}`);
case "user": {
if (mode.type === "user") {
setupUserMode(client, mode.channelId); setupUserMode(client, mode.channelId);
break; } else if (mode.type === "bot") {
}
case "bot": {
setupBotMode(client); setupBotMode(client);
break; } else {
} // @ts-expect-error -- exhaustive default for unimplemented mode types
default: {
// @ts-ignore
logger.fatal(`ERROR: Not implemented: '${mode.type}'`); logger.fatal(`ERROR: Not implemented: '${mode.type}'`);
} }
}
await client.login(env.DISCORD_BOT_TOKEN); await client.login(env.DISCORD_BOT_TOKEN);

View File

@@ -1,97 +1,67 @@
import type { Client } from "discord.js"; import type { Client, Interaction, OmitPartialGroupDMChannel, Message } from "discord.js";
import { createLogger, logger } from "@lbf-bot/utils"; import { logger } from "@lbf-bot/utils";
import { env } from "~/env"; import { env } from "~/env";
import { import { questCheckCron } from "~/quests";
listTrackedPlayers, import { trackingCron } from "~/tracking";
getTrackedPlayerUsernames,
addUsernameToHistory,
} from "~/services/tracking";
import { checkForNewQuest, getPlayer } from "~/services/wov";
import { createInfoEmbed } from "~/utils/discord";
import { askForGrinders } from "~/utils/quest";
import { commands } from "~/commands"; import { commands } from "~/commands";
import { handleReportButton, handleReportModal, handleEditButton, handleDeleteButton, handleEditModal, REPORT_BUTTON_ID, REPORT_MODAL_ID, REPORT_EDIT_BUTTON_PREFIX, REPORT_DELETE_BUTTON_PREFIX, REPORT_EDIT_MODAL_PREFIX } from "~/reporting";
const questsLogger = createLogger({ prefix: "quests" }); const onReady = async (client: Client<true>) => {
const trackingLogger = createLogger({ prefix: "tracking" });
const questCheckCron = async (client: Client) => {
questsLogger.info("Checking for new quest");
const quest = await checkForNewQuest();
if (quest) {
questsLogger.info(`New quest found: '${quest.quest.id}'`);
await askForGrinders(quest, client);
} else {
questsLogger.info("No new quest found");
}
};
const trackingCron = async (client: Client) => {
trackingLogger.info("Checking for tracked players");
const trackedPlayers = await listTrackedPlayers();
trackingLogger.info(`${trackedPlayers.length} players to check`);
for (const playerId of trackedPlayers) {
const player = await getPlayer(playerId);
if (!player) continue;
const usernames = await getTrackedPlayerUsernames(playerId);
if (usernames.includes(player.username)) continue;
await addUsernameToHistory(playerId, player.username);
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) {
return logger.fatal("Invalid 'DISCORD_TRACKING_CHANNEL'");
}
const lastUsername = usernames[usernames.length - 1];
await chan.send(
createInfoEmbed(
`### [UPDATE] \`${lastUsername}\` -> \`${player.username}\` [\`${playerId}\`]\n\n**Nouveau pseudo:** \`${player.username}\`\n**Anciens pseudos:**\n${usernames.map((x) => `- \`${x}\``).join("\n")}`,
0x00ea00,
),
);
trackingLogger.info(
`Username changed: ${lastUsername} -> ${player.username} [${playerId}]`,
);
}
};
export const setupBotMode = (client: Client) => {
client.on("clientReady", async (client) => {
logger.info(`Client ready`); logger.info(`Client ready`);
logger.info(`Connected as @${client.user.username}`); logger.info(`Connected as @${client.user.username}`);
await questCheckCron(client); await questCheckCron(client);
setInterval(() => questCheckCron(client), env.WOV_FETCH_INTERVAL); setInterval(() => void questCheckCron(client), env.WOV_FETCH_INTERVAL);
await trackingCron(client); await trackingCron(client);
setInterval(() => trackingCron(client), env.WOV_TRACKING_INTERVAL); setInterval(() => void trackingCron(client), env.WOV_TRACKING_INTERVAL);
}); };
client.on("messageCreate", async (message) => { const onMessage = async (message: OmitPartialGroupDMChannel<Message>) => {
if (message.author.bot) return; if (message.author.bot) return;
if (message.content.startsWith(`<@${client.user!.id}>`)) { const parts = message.content.trim().split(/\s+/);
const [command, ...args] = message.content if (parts[0]?.toLowerCase() === "lbf") {
.replace(`<@${client.user!.id}>`, "") const [commandName, ...args] = parts.slice(1);
.trim()
.split(" ");
const commandHandler = commands[command]; const command = commands[commandName];
if (commandHandler) { if (!command) return;
const child = logger.child(
`cmd:${command}${args.length > 0 ? " " : ""}${args.join(" ")}`, const child = logger.child(`cmd:${commandName}${args.length > 0 ? " " : ""}${args.join(" ")}`);
);
try { try {
const start = Date.now(); const start = Date.now();
await commandHandler(message, args); await command.handler(message, args);
const end = Date.now(); child.info(`Done in ${(Date.now() - start).toFixed(2)}ms`);
child.info(`Done in ${(end - start).toFixed(2)}ms`);
} catch (error: unknown) { } catch (error: unknown) {
child.error("Failed:", error); child.error("Failed:", error);
} }
} }
} };
});
const onInteraction = async (interaction: Interaction, client: Client) => {
if (interaction.isButton()) {
if (interaction.customId === REPORT_BUTTON_ID) {
await handleReportButton(interaction);
} else if (interaction.customId.startsWith(`${REPORT_EDIT_BUTTON_PREFIX}:`)) {
const reportId = interaction.customId.slice(REPORT_EDIT_BUTTON_PREFIX.length + 1);
await handleEditButton(interaction, reportId);
} else if (interaction.customId.startsWith(`${REPORT_DELETE_BUTTON_PREFIX}:`)) {
const reportId = interaction.customId.slice(REPORT_DELETE_BUTTON_PREFIX.length + 1);
await handleDeleteButton(interaction, reportId);
}
} else if (interaction.isModalSubmit()) {
if (interaction.customId === REPORT_MODAL_ID) {
await handleReportModal(interaction, client);
} else if (interaction.customId.startsWith(`${REPORT_EDIT_MODAL_PREFIX}:`)) {
const rest = interaction.customId.slice(REPORT_EDIT_MODAL_PREFIX.length + 1);
const [reportId, channelId, messageId] = rest.split(":");
await handleEditModal(interaction, client, reportId, channelId, messageId);
}
}
};
export const setupBotMode = (client: Client) => {
client.on("clientReady", (client) => { void onReady(client); });
client.on("messageCreate", (message) => { void onMessage(message); });
client.on("interactionCreate", (interaction) => { void onInteraction(interaction, client); });
}; };

View File

@@ -1,5 +1,5 @@
import { logger } from "@lbf-bot/utils"; import { logger } from "@lbf-bot/utils";
import type { Client, TextChannel } from "discord.js"; import type { Client } from "discord.js";
import { ChannelType } from "discord.js"; import { ChannelType } from "discord.js";
import * as readline from "node:readline"; import * as readline from "node:readline";
@@ -22,9 +22,9 @@ export const setupUserMode = (client: Client, channelId: string) => {
rl.prompt(); rl.prompt();
rl.on("line", async (line) => { rl.on("line", (line) => {
if (line.trim().length > 0) { if (line.trim().length > 0) {
await (chan as TextChannel).send(line); void chan.send(line);
} }
rl.prompt(); rl.prompt();
}); });

View File

@@ -0,0 +1,160 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ComponentType, EmbedBuilder, StringSelectMenuBuilder, type Client, type MessageCreateOptions } from "discord.js";
import { createLogger } from "@lbf-bot/utils";
import { env } from "~/env";
import { checkForNewQuest, type QuestResult } from "~/wov";
import { getAccountBalance, setAccountBalance } from "~/economy";
const logger = createLogger({ prefix: "quests" });
export const makeResultEmbed = async (result: QuestResult, exclude: Array<string>): Promise<MessageCreateOptions> => {
const imageUrl = result.quest.promoImageUrl;
const color = parseInt(result.quest.promoImagePrimaryColor.substring(1), 16);
const participants = result.participants.toSorted((a, b) => b.xp - a.xp);
let rewardsEmbed: EmbedBuilder | undefined;
if (env.QUEST_REWARDS) {
const rewarded = participants
.map((x) => ({ id: x.playerId, username: x.username }))
.filter((x) => !exclude.includes(x.username));
const medals = ["🥇", "🥈", "🥉"].concat(new Array(rewarded.length).fill("🏅"));
const rewards = rewarded
.slice(0, Math.min(rewarded.length, env.QUEST_REWARDS.length))
.map((x, i) => `- ${medals[i]} ${x.username} - ${env.QUEST_REWARDS![i]} ${env.QUEST_REWARDS_ARE_GEMS ? "gemmes" : ""}`);
if (env.QUEST_REWARDS_ARE_GEMS) {
const arr = rewarded.slice(0, Math.min(rewarded.length, env.QUEST_REWARDS.length));
for (let i = 0; i < arr.length; i++) {
const balance = await getAccountBalance(arr[i].id);
await setAccountBalance(arr[i].id, balance + parseInt(env.QUEST_REWARDS[i]));
}
}
rewardsEmbed = new EmbedBuilder()
.setTitle("Récompenses")
.setDescription(rewards.join("\n"))
.setColor(color);
}
return {
content: `-# ||${env.DISCORD_MENTION}||`,
embeds: [
new EmbedBuilder()
.setDescription(`# Résultats de quête\n\nMerci à toutes et à tous d'avoir participé 🫡`)
.setColor(color)
.setImage(imageUrl),
...(rewardsEmbed ? [rewardsEmbed] : []),
new EmbedBuilder()
.setTitle("Classement")
.setColor(color)
.setDescription(participants
.filter((x) => !exclude.includes(x.username) && x.xp > 0)
.filter((_, i) => i < 8)
.map((p, i) => `${i + 1}. ${p.username} - ${p.xp}xp`)
.join("\n"),
),
],
};
};
export const askForGrinders = async (quest: QuestResult, client: Client) => {
const adminChannel = await client.channels.fetch(env.DISCORD_ADMIN_CHANNEL);
if (!adminChannel || adminChannel.type !== ChannelType.GuildText) {
return logger.fatal("Invalid 'DISCORD_ADMIN_CHANNEL'");
}
let exclude: string[] = [];
if (env.QUEST_REWARDS) {
const color = parseInt(quest.quest.promoImagePrimaryColor.substring(1), 16);
const top10 = quest.participants
.filter((x) => !env.QUEST_EXCLUDE.includes(x.username) && x.xp > 0)
.sort((a, b) => b.xp - a.xp)
.slice(0, 10);
if (!top10.length) {
logger.error("No eligible participants for grinder selection");
return;
}
const selectOptions = top10
.map((p) => ({ label: `${p.username} (${p.xp}xp)`, value: p.username }));
const row1 = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId("grinders")
.setPlaceholder("Sélectionne les tricheurs")
.setMinValues(0)
.setMaxValues(selectOptions.length)
.addOptions(selectOptions),
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("validate")
.setLabel("Valider")
.setStyle(ButtonStyle.Primary),
);
const top10Text = top10
.map((p, i) => `${i + 1}. ${p.username} - ${p.xp}xp`)
.join("\n");
const msg = await adminChannel.send({
content: `-# ||${env.DISCORD_ADMIN_MENTION}||`,
embeds: [
new EmbedBuilder()
.setTitle("Quête terminée !")
.setColor(color),
new EmbedBuilder()
.setTitle("Top 10 XP")
.setDescription(top10Text)
.setColor(color),
new EmbedBuilder()
.setTitle("Qui a grind ?")
.setDescription("Sélectionne les joueurs qui ont grind (tricheurs en gros), puis clique sur **Valider**.")
.setColor(color),
],
components: [row1, row2],
});
let grinders: string[] = [];
let done = false;
while (!done) {
const interaction = await msg.awaitMessageComponent({ filter: (i) => !i.user.bot });
if (interaction.componentType === ComponentType.StringSelect) {
grinders = interaction.values;
await interaction.deferUpdate();
} else if (interaction.componentType === ComponentType.Button && interaction.customId === "validate") {
await interaction.update({ components: [] });
done = true;
}
}
exclude = grinders;
}
const embed = await makeResultEmbed(quest, [...env.QUEST_EXCLUDE, ...exclude]);
const rewardChannel = await client.channels.fetch(env.DISCORD_REWARDS_CHANNEL);
if (!rewardChannel || rewardChannel.type !== ChannelType.GuildText) {
return logger.fatal("Invalid 'DISCORD_REWARDS_CHANNEL'");
}
await rewardChannel.send(embed);
if (env.QUEST_EXCLUDE) await adminChannel.send("Envoyé !");
logger.info(`Results posted at: ${new Date().toISOString()}`);
};
export const questCheckCron = async (client: Client) => {
logger.info("Checking for new quest");
const quest = await checkForNewQuest();
if (quest) {
logger.info(`New quest found: '${quest.quest.id}'`);
await askForGrinders(quest, client);
} else {
logger.info("No new quest found");
}
};

View File

@@ -0,0 +1,299 @@
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ComponentType,
EmbedBuilder, FileUploadBuilder, LabelBuilder, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle,
type ButtonInteraction, type Client, type FileUploadModalData, type ModalSubmitInteraction, type TextChannel,
} from "discord.js";
import { createLogger } from "@lbf-bot/utils";
import { db, tables, eq } from "@lbf-bot/database";
import { env } from "~/env";
import { searchPlayer } from "~/wov";
const logger = createLogger({ prefix: "reporting" });
export const REPORT_BUTTON_ID = "report:open";
export const REPORT_MODAL_ID = "report:modal";
export const REPORT_EDIT_BUTTON_PREFIX = "report:edit";
export const REPORT_DELETE_BUTTON_PREFIX = "report:delete";
export const REPORT_EDIT_MODAL_PREFIX = "report:edit:modal";
const formatDate = (date: Date): string => {
const d = String(date.getDate()).padStart(2, "0");
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${d}/${m}/${date.getFullYear()}`;
};
const buildReportEmbed = (report: { playerName: string; playerId: string; reason: string; reporterId: string; createdAt: Date }) =>
new EmbedBuilder()
.setDescription([
`**Pseudo**: \`${report.playerName}\``,
`**ID**: \`${report.playerId}\``,
`**Date**: \`${formatDate(report.createdAt)}\``,
`**Raison**: \`\`\`${report.reason}\`\`\``,
`-# Signalé par <@${report.reporterId}>`
].join("\n"));
const reportActionRow = (reportId: string) =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${REPORT_EDIT_BUTTON_PREFIX}:${reportId}`)
.setLabel("Modifier")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId(`${REPORT_DELETE_BUTTON_PREFIX}:${reportId}`)
.setLabel("Supprimer")
.setStyle(ButtonStyle.Danger),
);
const isAuthorized = (interaction: ButtonInteraction | ModalSubmitInteraction, reporterId: string) => {
if (interaction.user.id === reporterId) return true;
const { member } = interaction;
if (!member) return false;
const roles = member.roles;
if (Array.isArray(roles)) return roles.includes(env.DISCORD_STAFF_ROLE_ID);
return "cache" in roles && roles.cache.has(env.DISCORD_STAFF_ROLE_ID);
};
const extractScreenshots = (fields: ModalSubmitInteraction["fields"]) => {
const fileField = fields.fields.get("screenshots") as FileUploadModalData | undefined;
const attachments = fileField?.type === ComponentType.FileUpload && fileField.attachments?.size > 0
? [...fileField.attachments.values()]
: null;
const screenshotUrls = attachments ? attachments.map(a => a.url).join("\n") : null;
return { attachments, screenshotUrls };
};
const buildModal = () =>
new ModalBuilder()
.setCustomId(REPORT_MODAL_ID)
.setTitle("Signaler un joueur")
.addLabelComponents(
new LabelBuilder()
.setLabel("Pseudo du joueur (les majuscules comptent !)")
.setTextInputComponent(
new TextInputBuilder()
.setCustomId("player_name")
.setStyle(TextInputStyle.Short)
.setRequired(true),
),
new LabelBuilder()
.setLabel("Raison du signalement")
.setTextInputComponent(
new TextInputBuilder()
.setCustomId("reason")
.setStyle(TextInputStyle.Paragraph)
.setRequired(true),
),
new LabelBuilder()
.setLabel("Screenshots (optionnel)")
.setFileUploadComponent(
new FileUploadBuilder()
.setCustomId("screenshots")
.setMaxValues(10)
.setRequired(false),
),
);
const buildEditModal = (reportId: string, channelId: string, messageId: string, currentReason: string) =>
new ModalBuilder()
.setCustomId(`${REPORT_EDIT_MODAL_PREFIX}:${reportId}:${channelId}:${messageId}`)
.setTitle("Modifier le signalement")
.addLabelComponents(
new LabelBuilder()
.setLabel("Raison du signalement")
.setTextInputComponent(
new TextInputBuilder()
.setCustomId("reason")
.setStyle(TextInputStyle.Paragraph)
.setValue(currentReason)
.setRequired(true),
),
new LabelBuilder()
.setLabel("Screenshots (optionnel)")
.setFileUploadComponent(
new FileUploadBuilder()
.setCustomId("screenshots")
.setMaxValues(10)
.setRequired(false),
),
);
const retryRow = () =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(REPORT_BUTTON_ID)
.setLabel("Réessayer")
.setStyle(ButtonStyle.Danger),
);
export const sendReportEmbed = async (channel: TextChannel) => {
await channel.send({
embeds: [
new EmbedBuilder()
.setTitle("🚨 Signaler un joueur")
.setDescription([
"Tu as observé un comportement toxique ou de l'anti jeu ?",
"",
"Clique sur le bouton ci-dessous pour signaler un joueur.",
"Pense à fournir le plus de détails dans 'raison', tu peux aussi ajouter des screenshots.",
"",
`-# Les signalements sont envoyés dans <#${env.DISCORD_REPORT_CHANNEL}>.`,
].join("\n"))
.setColor(0xe74c3c),
],
components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(REPORT_BUTTON_ID)
.setLabel("Signaler un joueur")
.setStyle(ButtonStyle.Danger),
),
],
});
};
export const handleReportButton = async (interaction: ButtonInteraction) => {
await interaction.showModal(buildModal());
};
export const handleReportModal = async (interaction: ModalSubmitInteraction, client: Client) => {
const playerName = interaction.fields.getTextInputValue("player_name");
const reason = interaction.fields.getTextInputValue("reason");
const { attachments, screenshotUrls } = extractScreenshots(interaction.fields);
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const player = await searchPlayer(playerName);
if (!player) {
await interaction.editReply({
content: `Aucun joueur avec le pseudo **${playerName}** n'a été trouvé. Vérifie les majuscules et réessaie.`,
components: [retryRow()],
});
return;
}
const [inserted] = await db
.insert(tables.reports)
.values({
reporterId: interaction.user.id,
playerName,
playerId: player.id,
reason,
screenshots: screenshotUrls,
})
.returning({ id: tables.reports.id });
let messageLink = "";
const reportChannel = await client.channels.fetch(env.DISCORD_REPORT_CHANNEL);
if (reportChannel?.type === ChannelType.GuildText) {
const reportMessage = await reportChannel.send({
content: "─────────────────────────────────",
embeds: [buildReportEmbed({ playerName, playerId: player.id, reason, reporterId: interaction.user.id, createdAt: new Date() })],
components: [reportActionRow(inserted.id)],
});
messageLink = `https://discord.com/channels/${reportChannel.guild.id}/${reportChannel.id}/${reportMessage.id}`;
const screenshotsMessage = attachments
? await reportChannel.send({ files: attachments.map(a => a.url) })
: await reportChannel.send({ content: "-# Pas de screenshots" });
await db.update(tables.reports)
.set({ messageLink, screenshotsMessageId: screenshotsMessage.id })
.where(eq(tables.reports.id, inserted.id));
} else {
logger.error("Invalid 'DISCORD_REPORT_CHANNEL'");
}
await interaction.editReply({
content: `Le joueur **${playerName}** a bien été signalé. Merci !${messageLink ? ` ${messageLink}` : ""}`,
});
};
export const handleEditButton = async (interaction: ButtonInteraction, reportId: string) => {
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
if (!report) {
await interaction.reply({ content: "Signalement introuvable.", flags: MessageFlags.Ephemeral });
return;
}
if (!isAuthorized(interaction, report.reporterId)) {
await interaction.reply({ content: "Tu n'as pas la permission de modifier ce signalement.", flags: MessageFlags.Ephemeral });
return;
}
await interaction.showModal(buildEditModal(reportId, interaction.channelId, interaction.message.id, report.reason));
};
export const handleDeleteButton = async (interaction: ButtonInteraction, reportId: string) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
if (!report) {
await interaction.editReply({ content: "Signalement introuvable." });
return;
}
if (!isAuthorized(interaction, report.reporterId)) {
await interaction.editReply({ content: "Tu n'as pas la permission de supprimer ce signalement." });
return;
}
await db.delete(tables.reports).where(eq(tables.reports.id, reportId));
if (report.screenshotsMessageId) {
try {
const screenshotsMsg = await interaction.message.channel.messages.fetch(report.screenshotsMessageId);
await screenshotsMsg.delete();
} catch {
// already deleted or not found
}
}
await interaction.message.delete();
await interaction.editReply({ content: "Signalement supprimé." });
};
export const handleEditModal = async (interaction: ModalSubmitInteraction, client: Client, reportId: string, channelId: string, messageId: string) => {
const reason = interaction.fields.getTextInputValue("reason");
const { attachments, screenshotUrls } = extractScreenshots(interaction.fields);
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
if (!report) {
await interaction.editReply({ content: "Signalement introuvable." });
return;
}
if (!isAuthorized(interaction, report.reporterId)) {
await interaction.editReply({ content: "Tu n'as pas la permission de modifier ce signalement." });
return;
}
const channel = await client.channels.fetch(channelId);
if (channel?.type === ChannelType.GuildText) {
try {
const message = await channel.messages.fetch(messageId);
await message.edit({
embeds: [buildReportEmbed({ ...report, reason })],
components: [reportActionRow(reportId)],
});
} catch {
logger.error("Failed to fetch/edit report message");
}
if (report.screenshotsMessageId) {
try {
const screenshotsMsg = await channel.messages.fetch(report.screenshotsMessageId);
if (attachments) {
await screenshotsMsg.edit({ content: "", files: attachments.map(a => a.url), attachments: [] });
} else {
await screenshotsMsg.edit({ content: "-# Pas de screenshots", attachments: [] });
}
} catch {
logger.error("Failed to edit screenshots message");
}
}
}
await db.update(tables.reports)
.set({ reason, screenshots: screenshotUrls })
.where(eq(tables.reports.id, reportId));
await interaction.editReply({ content: "Signalement modifié." });
};

View File

@@ -1,32 +0,0 @@
import { db, tables, eq } from "@lbf-bot/database";
export const getAccountBalance = async (playerId: string): Promise<number> => {
const account = await db.query.accounts.findFirst({
where: eq(tables.accounts.playerId, playerId),
});
if (account) return account.balance;
await db.insert(tables.accounts).values({
playerId,
balance: 0,
});
return 0;
};
export const setAccountBalance = async (
playerId: string,
balance: number,
): Promise<void> => {
await db
.insert(tables.accounts)
.values({
playerId,
balance,
})
.onConflictDoUpdate({
target: tables.accounts.playerId,
set: { balance, updatedAt: new Date() },
});
};

View File

@@ -1,74 +0,0 @@
import { getPlayer } from "~/services/wov";
import { db, tables, eq } from "@lbf-bot/database";
export async function listTrackedPlayers(): Promise<string[]> {
const players = await db.query.trackedPlayers.findMany({
columns: {
playerId: true,
},
});
return players.map((p) => p.playerId);
}
export async function isWovPlayerTracked(playerId: string): Promise<boolean> {
const player = await db.query.trackedPlayers.findFirst({
where: eq(tables.trackedPlayers.playerId, playerId),
});
return player !== undefined;
}
export async function untrackWovPlayer(playerId: string): Promise<void> {
await db
.delete(tables.trackedPlayers)
.where(eq(tables.trackedPlayers.playerId, playerId));
}
export async function trackWovPlayer(playerId: string): Promise<void> {
const alreadyTracked = await isWovPlayerTracked(playerId);
if (alreadyTracked) return;
const player = await getPlayer(playerId);
if (!player) return;
await db.insert(tables.trackedPlayers).values({
playerId,
});
await db.insert(tables.usernameHistory).values({
playerId,
username: player.username,
});
}
export async function getTrackedPlayerUsernames(
playerId: string,
): Promise<string[]> {
const tracked = await db.query.trackedPlayers.findFirst({
where: eq(tables.trackedPlayers.playerId, playerId),
with: {
usernameHistory: {
orderBy: (history, { asc }) => [asc(history.firstSeenAt)],
},
},
});
if (!tracked) return [];
return tracked.usernameHistory.map((h) => h.username);
}
export async function addUsernameToHistory(
playerId: string,
username: string,
): Promise<void> {
await db.insert(tables.usernameHistory).values({
playerId,
username,
});
await db
.update(tables.trackedPlayers)
.set({ updatedAt: new Date() })
.where(eq(tables.trackedPlayers.playerId, playerId));
}

View File

@@ -1,139 +0,0 @@
import { env } from "~/env";
import { redis } from "@lbf-bot/database";
export type QuestResult = {
quest: {
id: string;
promoImageUrl: string;
promoImagePrimaryColor: string;
};
participants: Array<QuestParticipant>;
};
export type QuestParticipant = {
playerId: string;
username: string;
xp: number;
};
export const getLatestQuest = async (): Promise<QuestResult> => {
const response = await fetch(
`https://api.wolvesville.com/clans/${env.WOV_CLAN_ID}/quests/history`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
const history = (await response.json()) as Array<QuestResult>;
return history[0];
};
export const getActiveQuest = async (): Promise<QuestResult | null> => {
const response = await fetch(
`https://api.wolvesville.com/clans/${env.WOV_CLAN_ID}/quests/active`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
if (response.status === 404) return null;
return (await response.json()) as QuestResult;
};
export const checkForNewQuest = async (): Promise<QuestResult | null> => {
const lastQuest = await getLatestQuest();
const lastId = lastQuest.quest.id;
const cachedQuestId = await redis.get("quest:last_id");
if (cachedQuestId === lastId || cachedQuestId === "IGNORE") {
return null;
}
await redis.set("quest:last_id", lastId);
return lastQuest;
};
export const getClanMembers = async (): Promise<
Array<{ playerId: string; username: string }>
> => {
const cached = await redis.get("clan:members");
if (cached) {
return JSON.parse(cached);
}
const response = await fetch(
`https://api.wolvesville.com/clans/${env.WOV_CLAN_ID}/members`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
const data = (await response.json()) as Array<{
playerId: string;
username: string;
}>;
await redis.set("clan:members", JSON.stringify(data), "EX", 60 * 60);
return data;
};
export const searchPlayer = async (username: string) => {
try {
const response = await fetch(
`https://api.wolvesville.com//players/search?username=${username}`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
if (response.status === 404) return null;
const data = (await response.json()) as {
id: string;
clanId: string | null;
};
return data;
} catch {
return null;
}
};
export const getClanInfos = async (clanId: string) => {
const response = await fetch(
`https://api.wolvesville.com/clans/${clanId}/info`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
const data = (await response.json()) as {
name: string;
tag: string;
};
return data;
};
export async function getPlayer(playerId: string) {
try {
const response = await fetch(
`https://api.wolvesville.com/players/${playerId}`,
{
method: "GET",
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
},
);
if (response.status === 404) return null;
const data = (await response.json()) as {
username: string;
};
return data;
} catch {
return null;
}
}

View File

@@ -0,0 +1,82 @@
import type { Client } from "discord.js";
import { createLogger, logger } from "@lbf-bot/utils";
import { db, tables, eq } from "@lbf-bot/database";
import { env } from "~/env";
import { getPlayer } from "~/wov";
import { createInfoEmbed } from "~/discord";
const trackingLogger = createLogger({ prefix: "tracking" });
export async function listTrackedPlayers(): Promise<string[]> {
const players = await db.query.trackedPlayers.findMany({
columns: { playerId: true },
});
return players.map((p) => p.playerId);
}
export async function isTracked(playerId: string): Promise<boolean> {
const player = await db.query.trackedPlayers.findFirst({
where: eq(tables.trackedPlayers.playerId, playerId),
});
return player !== undefined;
}
export async function trackPlayer(playerId: string): Promise<void> {
if (await isTracked(playerId)) return;
const player = await getPlayer(playerId);
if (!player) return;
await db.insert(tables.trackedPlayers).values({ playerId });
await db.insert(tables.usernameHistory).values({ playerId, username: player.username });
}
export async function untrackPlayer(playerId: string): Promise<void> {
await db.delete(tables.trackedPlayers).where(eq(tables.trackedPlayers.playerId, playerId));
}
export async function getPlayerUsernames(playerId: string): Promise<string[]> {
const tracked = await db.query.trackedPlayers.findFirst({
where: eq(tables.trackedPlayers.playerId, playerId),
with: {
usernameHistory: { orderBy: (history, { asc }) => [asc(history.firstSeenAt)] },
},
});
if (!tracked) return [];
return tracked.usernameHistory.map((h) => h.username);
}
export async function addUsernameToHistory(playerId: string, username: string): Promise<void> {
await db.insert(tables.usernameHistory).values({ playerId, username });
await db
.update(tables.trackedPlayers)
.set({ updatedAt: new Date() })
.where(eq(tables.trackedPlayers.playerId, playerId));
}
export const trackingCron = async (client: Client) => {
trackingLogger.info("Checking for tracked players");
const trackedPlayers = await listTrackedPlayers();
trackingLogger.info(`${trackedPlayers.length} players to check`);
for (const playerId of trackedPlayers) {
const player = await getPlayer(playerId);
if (!player) continue;
const usernames = await getPlayerUsernames(playerId);
if (usernames.includes(player.username)) continue;
await addUsernameToHistory(playerId, player.username);
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) {
return logger.fatal("Invalid 'DISCORD_TRACKING_CHANNEL'");
}
const lastUsername = usernames[usernames.length - 1];
const description = `### [UPDATE] \`${lastUsername}\` -> \`${player.username}\` [\`${playerId}\`]\n\n**Nouveau pseudo:** \`${player.username}\`\n**Anciens pseudos:**\n${usernames.map((x) => `- \`${x}\``).join("\n")}`;
await chan.send(createInfoEmbed(description, 0x00ea00));
trackingLogger.info(`Username changed: ${lastUsername} -> ${player.username} [${playerId}]`);
}
};

View File

@@ -1,32 +0,0 @@
type Mode = { type: "bot" } | { type: "user"; channelId: string };
export const parseArgs = (args: string[]): Mode => {
let mode: Mode = { type: "bot" };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "-u":
case "--as-user": {
const channelId = args[(i += 1)];
if (!channelId) {
console.error(`ERROR: ${arg} requires a channel ID`);
process.exit(1);
}
mode = { type: "user", channelId };
break;
}
default: {
console.error(`ERROR: Unrecognized argument: '${arg}'`);
process.exit(1);
}
}
}
return mode;
};

View File

@@ -1,118 +0,0 @@
import { getAccountBalance, setAccountBalance } from "~/services/account";
import { env } from "~/env";
import type { QuestResult } from "~/services/wov";
import type { MessageCreateOptions, APIEmbed, Message } from "discord.js";
export const makeResultEmbed = async (
result: QuestResult,
exclude: Array<string>,
): Promise<MessageCreateOptions> => {
const imageUrl = result.quest.promoImageUrl;
const color = parseInt(result.quest.promoImagePrimaryColor.substring(1), 16);
const participants = result.participants.toSorted((a, b) => b.xp - a.xp);
let rewardsEmbed: APIEmbed | undefined;
if (env.QUEST_REWARDS) {
const rewardedParticipants = participants
.map((x) => ({ id: x.playerId, username: x.username }))
.filter((x) => !exclude.includes(x.username));
const medals = ["🥇", "🥈", "🥉"].concat(
new Array(rewardedParticipants.length).fill("🏅"),
);
const rewards = rewardedParticipants
.slice(0, Math.min(rewardedParticipants.length, env.QUEST_REWARDS.length))
.map(
(x, i) =>
`- ${medals[i]} ${x.username} - ${env.QUEST_REWARDS![i]} ${env.QUEST_REWARDS_ARE_GEMS ? "gemmes" : ""}`,
);
if (env.QUEST_REWARDS_ARE_GEMS) {
const arr = rewardedParticipants.slice(
0,
Math.min(rewardedParticipants.length, env.QUEST_REWARDS.length),
);
for (let i = 0; i < arr.length; i++) {
const balance = await getAccountBalance(arr[i].id);
await setAccountBalance(
arr[i].id,
balance + parseInt(env.QUEST_REWARDS![i]),
);
}
}
rewardsEmbed = {
title: "Récompenses",
description: `${rewards.join("\n")}\n\n-# \`@LBF gemmes\` pour voir votre nombre de gemmes. Puis avec ${env.DISCORD_REWARDS_GIVER} pour échanger contre des cadeaux !`,
color,
};
}
return {
content: `-# ||${env.DISCORD_MENTION}||`,
embeds: [
{
description: `# Résultats de quête\n\nMerci à toutes et à tous d'avoir participé 🫡`,
color,
image: {
url: imageUrl,
},
},
...(rewardsEmbed ? [rewardsEmbed] : []),
{
title: "Classement",
description: participants
.filter((x) => !exclude.includes(x.username))
.filter((_, i) => i < 8)
.map((p, i) => `${i + 1}. ${p.username} - ${p.xp}xp`)
.join("\n"),
color,
},
],
};
};
export const createErrorEmbed = (
message: string,
color = 0xea0000,
): MessageCreateOptions => ({
embeds: [
{
description: `### ❌ Erreur\n\n\n${message}`,
color,
},
],
});
export const createSuccessEmbed = (
message: string,
color = 0x00ea00,
): MessageCreateOptions => ({
embeds: [
{
description: `### ✅ ${message}`,
color,
},
],
});
export const createInfoEmbed = (
message: string,
color = 0x89cff0,
): MessageCreateOptions => ({
embeds: [
{
description: message,
color,
},
],
});
export const replyError = (message: Message, text: string, color?: number) =>
message.reply(createErrorEmbed(text, color));
export const replySuccess = (message: Message, text: string, color?: number) =>
message.reply(createSuccessEmbed(text, color));
export const replyInfo = (message: Message, text: string, color?: number) =>
message.reply(createInfoEmbed(text, color));

View File

@@ -1,127 +0,0 @@
import { ChannelType, type Client, type Message } from "discord.js";
import { env } from "~/env";
import { makeResultEmbed } from "~/utils/discord";
import type { QuestResult } from "~/services/wov";
import { createLogger } from "@lbf-bot/utils";
const questLogger = createLogger({ prefix: "quests" });
export const askForGrinders = async (quest: QuestResult, client: Client) => {
const adminChannel = await client.channels.fetch(env.DISCORD_ADMIN_CHANNEL);
if (!adminChannel || adminChannel.type !== ChannelType.GuildText) {
return questLogger.fatal("Invalid 'DISCORD_ADMIN_CHANNEL'");
}
let exclude: string[] = [];
if (env.QUEST_REWARDS) {
const top10 = quest.participants
.filter((x) => !env.QUEST_EXCLUDE.includes(x.username))
.sort((a, b) => b.xp - a.xp)
.slice(0, 10)
.map((p, i) => `${i + 1}. ${p.username} - ${p.xp}xp`)
.join("\n");
const color = parseInt(quest.quest.promoImagePrimaryColor.substring(1), 16);
await adminChannel.send({
content: `-# ||${env.DISCORD_ADMIN_MENTION}||`,
embeds: [
{
title: "Quête terminée !",
color,
},
{
title: "Top 10 XP",
description: top10,
color,
},
{
title: "Qui a grind ?",
description:
"Merci d'entrer les pseudos des joueurs qui ont grind.\n\nFormat:```@LBF laulau,Yuno,...```\n**Attention les majuscules comptent**\nPour entrer la liste des joueurs, il faut __mentionner le bot__, si personne n'a grind, `@LBF tg`",
color,
},
],
});
const filter = (msg: Message) =>
msg.channel.id === adminChannel.id &&
!msg.author.bot &&
msg.content.startsWith(`<@${client.user!.id}>`);
let confirmed = false;
let answer: string | null = null;
while (!confirmed) {
const collected = await adminChannel.awaitMessages({ filter, max: 1 });
answer = collected.first()?.content || null;
if (!answer) continue;
answer = answer.replace(`<@${client.user!.id}>`, "").trim();
if (answer.toLowerCase() === "tg") {
answer = "";
break;
}
const players = answer
.split(",")
.map((x) => x.trim())
.filter(Boolean);
await adminChannel.send({
embeds: [
{
title: "Joueurs entrés",
description: players.length
? players.map((name) => `- ${name}`).join("\n")
: "*Aucun joueur entré*",
color,
},
],
content: `Est-ce correct ? (oui/non)`,
});
const confirmFilter = (msg: Message) =>
msg.channel.id === adminChannel.id &&
!msg.author.bot &&
["oui", "non", "yes", "no"].includes(msg.content.toLowerCase());
const confirmCollected = await adminChannel.awaitMessages({
filter: confirmFilter,
max: 1,
});
const confirmation = confirmCollected.first()?.content.toLowerCase();
if (confirmation === "oui" || confirmation === "yes") {
confirmed = true;
await adminChannel.send({ content: "Ok" });
} else {
await adminChannel.send({
content: "D'accord, veuillez réessayer. Qui a grind ?",
});
}
}
if (answer === null) {
return questLogger.fatal("Answer was 'null', this should be unreachable");
}
exclude = answer
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
const embed = await makeResultEmbed(quest, [
...env.QUEST_EXCLUDE,
...exclude,
]);
const rewardChannel = await client.channels.fetch(
env.DISCORD_REWARDS_CHANNEL,
);
if (rewardChannel && rewardChannel.type === ChannelType.GuildText) {
await rewardChannel.send(embed);
} else {
return questLogger.fatal("Invalid 'DISCORD_REWARDS_CHANNEL'");
}
if (env.QUEST_EXCLUDE) {
await adminChannel.send("Envoyé !");
}
questLogger.info(`Results posted at: ${new Date().toISOString()}`);
};

View File

@@ -0,0 +1,89 @@
import { env } from "~/env";
import { redis } from "@lbf-bot/database";
export type QuestParticipant = {
playerId: string;
username: string;
xp: number;
};
export type QuestResult = {
quest: {
id: string;
promoImageUrl: string;
promoImagePrimaryColor: string;
};
participants: Array<QuestParticipant>;
};
export type Player = {
id: string;
clanId: string | null;
};
export type PlayerDetails = {
username: string;
};
export type ClanMember = {
playerId: string;
username: string;
};
export type ClanInfo = {
name: string;
tag: string;
};
const BASE_URL = "https://api.wolvesville.com";
const fetchWovApi = async <T>(path: string): Promise<T | null> => {
try {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { Authorization: `Bot ${env.WOV_API_KEY}` },
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
};
export const getLatestQuest = async (): Promise<QuestResult | null> => {
const history = await fetchWovApi<Array<QuestResult>>(`/clans/${env.WOV_CLAN_ID}/quests/history`);
return history?.[0] ?? null;
};
export const getActiveQuest = async (): Promise<QuestResult | null> =>
fetchWovApi<QuestResult>(`/clans/${env.WOV_CLAN_ID}/quests/active`);
export const checkForNewQuest = async (): Promise<QuestResult | null> => {
const lastQuest = await getLatestQuest();
if (!lastQuest) return null;
const cachedQuestId = await redis.get("quest:last_id");
if (cachedQuestId === lastQuest.quest.id || cachedQuestId === "IGNORE") return null;
await redis.set("quest:last_id", lastQuest.quest.id);
return lastQuest;
};
export const getClanMembers = async (): Promise<Array<ClanMember>> => {
const cached = await redis.get("clan:members");
if (cached) return JSON.parse(cached) as Array<ClanMember>;
const data = await fetchWovApi<Array<ClanMember>>(`/clans/${env.WOV_CLAN_ID}/members`);
if (!data) return [];
await redis.set("clan:members", JSON.stringify(data), "EX", 60 * 60);
return data;
};
export const searchPlayer = async (username: string): Promise<Player | null> =>
fetchWovApi<Player>(`/players/search?username=${username}`);
export const getClanInfo = async (clanId: string): Promise<ClanInfo | null> =>
fetchWovApi<ClanInfo>(`/clans/${clanId}/info`);
export const getPlayer = async (playerId: string): Promise<PlayerDetails | null> =>
fetchWovApi<PlayerDetails>(`/players/${playerId}`);

View File

@@ -7,7 +7,6 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"], "~/*": ["./src/*"],
"~": ["./src/index"] "~": ["./src/index"]

View File

@@ -28,7 +28,9 @@ services:
discord-bot: discord-bot:
container_name: lbf-bot container_name: lbf-bot
image: git.pihkaal.me/pihkaal/lbf-bot:latest build:
context: .
dockerfile: apps/discord-bot/Dockerfile
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -47,6 +49,7 @@ services:
- DISCORD_ADMIN_MENTION - DISCORD_ADMIN_MENTION
- DISCORD_ADMIN_CHANNEL - DISCORD_ADMIN_CHANNEL
- DISCORD_TRACKING_CHANNEL - DISCORD_TRACKING_CHANNEL
- DISCORD_REPORT_CHANNEL
- DISCORD_STAFF_ROLE_ID - DISCORD_STAFF_ROLE_ID
- WOV_API_KEY - WOV_API_KEY
- WOV_CLAN_ID - WOV_CLAN_ID

25
eslint.config.mjs Normal file
View File

@@ -0,0 +1,25 @@
import tseslint from "typescript-eslint";
export default tseslint.config(
[
{
ignores: [
"**/dist/**",
"**/node_modules/**",
"**/drizzle/**",
]
},
{
files: ["**/*.ts"],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},]
);

View File

@@ -2,9 +2,10 @@
"name": "lbf-bot", "name": "lbf-bot",
"packageManager": "pnpm@10.24.0", "packageManager": "pnpm@10.24.0",
"scripts": { "scripts": {
"format": "prettier --write --cache ." "check": "pnpm --filter @lbf-bot/utils run build && pnpm --filter @lbf-bot/database run build && pnpm -r check"
}, },
"dependencies": { "devDependencies": {
"prettier": "3.6.2" "eslint": "^10.3.0",
"typescript-eslint": "^8.59.2"
} }
} }

View File

@@ -0,0 +1,10 @@
CREATE TABLE "reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"reporter_id" text NOT NULL,
"reporter_username" text NOT NULL,
"player_name" text NOT NULL,
"player_id" text NOT NULL,
"reason" text NOT NULL,
"screenshots" text,
"created_at" timestamp DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE "reports" ADD COLUMN "message_link" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "reports" ADD COLUMN "screenshots_message_id" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "reports" DROP COLUMN "reporter_username";

View File

@@ -0,0 +1,208 @@
{
"id": "4e4b7960-a3a0-4ee7-8bec-4f9379eae836",
"prevId": "80475ee2-c581-462e-8075-13b1ae696df5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reports": {
"name": "reports",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"reporter_id": {
"name": "reporter_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reporter_username": {
"name": "reporter_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_name": {
"name": "player_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"screenshots": {
"name": "screenshots",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracked_players": {
"name": "tracked_players",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.username_history": {
"name": "username_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"username_history_player_id_tracked_players_player_id_fk": {
"name": "username_history_player_id_tracked_players_player_id_fk",
"tableFrom": "username_history",
"tableTo": "tracked_players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"player_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,214 @@
{
"id": "4643e6bd-db18-44c9-893d-8f57a94e8c0a",
"prevId": "4e4b7960-a3a0-4ee7-8bec-4f9379eae836",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reports": {
"name": "reports",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"reporter_id": {
"name": "reporter_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reporter_username": {
"name": "reporter_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_name": {
"name": "player_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"screenshots": {
"name": "screenshots",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message_link": {
"name": "message_link",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracked_players": {
"name": "tracked_players",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.username_history": {
"name": "username_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"username_history_player_id_tracked_players_player_id_fk": {
"name": "username_history_player_id_tracked_players_player_id_fk",
"tableFrom": "username_history",
"tableTo": "tracked_players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"player_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,220 @@
{
"id": "a19383ff-8e7a-4da4-8c29-888e8ea9f670",
"prevId": "4643e6bd-db18-44c9-893d-8f57a94e8c0a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reports": {
"name": "reports",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"reporter_id": {
"name": "reporter_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reporter_username": {
"name": "reporter_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_name": {
"name": "player_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"screenshots": {
"name": "screenshots",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message_link": {
"name": "message_link",
"type": "text",
"primaryKey": false,
"notNull": false
},
"screenshots_message_id": {
"name": "screenshots_message_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracked_players": {
"name": "tracked_players",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.username_history": {
"name": "username_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"username_history_player_id_tracked_players_player_id_fk": {
"name": "username_history_player_id_tracked_players_player_id_fk",
"tableFrom": "username_history",
"tableTo": "tracked_players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"player_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,214 @@
{
"id": "11a9aa46-c570-4372-aa71-c5a576a930f4",
"prevId": "a19383ff-8e7a-4da4-8c29-888e8ea9f670",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reports": {
"name": "reports",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"reporter_id": {
"name": "reporter_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_name": {
"name": "player_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"screenshots": {
"name": "screenshots",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message_link": {
"name": "message_link",
"type": "text",
"primaryKey": false,
"notNull": false
},
"screenshots_message_id": {
"name": "screenshots_message_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracked_players": {
"name": "tracked_players",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.username_history": {
"name": "username_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"player_id": {
"name": "player_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"username_history_player_id_tracked_players_player_id_fk": {
"name": "username_history_player_id_tracked_players_player_id_fk",
"tableFrom": "username_history",
"tableTo": "tracked_players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"player_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,34 @@
"when": 1764882945878, "when": 1764882945878,
"tag": "0000_tan_justin_hammer", "tag": "0000_tan_justin_hammer",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1778601650406,
"tag": "0001_broken_dorian_gray",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1778605638295,
"tag": "0002_lucky_praxagora",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778607030657,
"tag": "0003_uneven_mephistopheles",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780166865311,
"tag": "0004_curved_imperial_guard",
"breakpoints": true
} }
] ]
} }

View File

@@ -17,6 +17,7 @@
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"check": "tsc --noEmit && eslint src/",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"

View File

@@ -1,5 +1,20 @@
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
/**
* REPORTING SYSTEM
*/
export const reports = pgTable("reports", {
id: uuid("id").primaryKey().defaultRandom(),
reporterId: text("reporter_id").notNull(),
playerName: text("player_name").notNull(),
playerId: text("player_id").notNull(),
reason: text("reason").notNull(),
screenshots: text("screenshots"),
messageLink: text("message_link"),
screenshotsMessageId: text("screenshots_message_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
/** /**
* ECONOMY SYSTEM * ECONOMY SYSTEM
*/ */

View File

@@ -7,7 +7,6 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"], "~/*": ["./src/*"],
"~": ["./src/index"] "~": ["./src/index"]

View File

@@ -15,7 +15,8 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json" "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"check": "tsc --noEmit && eslint src/"
}, },
"dependencies": { "dependencies": {
"dotenv": "17.2.3", "dotenv": "17.2.3",

View File

@@ -1,6 +1,6 @@
type LogLevel = "debug" | "info" | "warn" | "error"; type LogLevel = "debug" | "info" | "warn" | "error";
interface LoggerOptions { type LoggerOptions = {
prefix?: string; prefix?: string;
level?: LogLevel; level?: LogLevel;
} }
@@ -54,10 +54,7 @@ class Logger {
return arg; return arg;
}); });
console.log( console.log(`${COLORS.gray}${timestamp}${COLORS.reset} ${color}${COLORS.bold}${levelStr}${COLORS.reset} ${prefix}${message}`, ...formattedArgs);
`${COLORS.gray}${timestamp}${COLORS.reset} ${color}${COLORS.bold}${levelStr}${COLORS.reset} ${prefix}${message}`,
...formattedArgs,
);
} }
debug(message: string, ...args: unknown[]): void { debug(message: string, ...args: unknown[]): void {
@@ -87,9 +84,7 @@ class Logger {
} }
private getLevel(): LogLevel { private getLevel(): LogLevel {
const entry = Object.entries(LOG_LEVELS).find( const entry = Object.entries(LOG_LEVELS).find(([, value]) => value === this.minLevel);
([, value]) => value === this.minLevel,
);
return (entry?.[0] as LogLevel) || "info"; return (entry?.[0] as LogLevel) || "info";
} }
} }

View File

@@ -1,13 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext"], "lib": ["ESNext"],
"types": ["node"],
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"], "~/*": ["./src/*"],
"~": ["./src/index"] "~": ["./src/index"]

1101
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff