Compare commits

...

10 Commits

Author SHA1 Message Date
Pihkaal
1cbd752977 feat(utils): don't asks for grinders if rewards are disabled
Some checks failed
Build and Push Docker Image / build (push) Failing after 18s
2025-12-21 11:49:48 +01:00
Pihkaal
d3561c019d fix(discord-bot): fix typos in tracking logging 2025-12-05 21:05:05 +01:00
Pihkaal
2024d830bb fix(docker): add missing env variable for the discord bot 2025-12-05 21:01:00 +01:00
Pihkaal
ade59d6101 chore: lbf -> lbf-bot 2025-12-05 20:56:50 +01:00
Pihkaal
a984daddfe fix(discord-bot): remove unexpected top level return 2025-12-05 20:48:18 +01:00
Pihkaal
30cc00efa8 feat(database): use new logger 2025-12-05 20:47:43 +01:00
Pihkaal
cf6cc7ff7b feat(discord-bot): use new logger 2025-12-05 20:24:17 +01:00
Pihkaal
a541b82404 feat(utils): implement simple logger 2025-12-05 20:22:20 +01:00
Pihkaal
8c78725e65 feat(discord-bot): improve 'track', 'tejtrack' commands and tracking service 2025-12-05 19:42:02 +01:00
Pihkaal
66474dc813 feat(discord-bot): suppress mention reply on success 2025-12-05 17:29:04 +01:00
19 changed files with 399 additions and 259 deletions

View File

@@ -14,6 +14,7 @@
"typescript": "^5.7.2"
},
"dependencies": {
"@lbf-bot/utils": "workspace:*",
"@lbf-bot/database": "workspace:*",
"discord.js": "^14.21.0",
"dotenv": "^17.2.3",

View File

@@ -58,5 +58,10 @@ export const gemmesCommand: Command = async (message, args) => {
)
.setColor(0x4289c1),
],
options: {
allowedMentions: {
repliedUser: false,
},
},
});
};

View File

@@ -47,5 +47,10 @@ export const iconeCommand: Command = async (message, args) => {
)
.setColor(65280),
],
options: {
allowedMentions: {
repliedUser: false,
},
},
});
};

View File

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

View File

@@ -1,65 +1,61 @@
import type { Command } from "~/commands";
import { untrackWovPlayer } from "~/services/tracking";
import { isWovPlayerTracked, untrackWovPlayer } from "~/services/tracking";
import { searchPlayer } from "~/services/wov";
import { createErrorEmbed, createInfoEmbed } from "~/utils/discord";
import { replyError, createInfoEmbed, replySuccess } from "~/utils/discord";
import { env } from "~/env";
import { createLogger } from "@lbf-bot/utils";
const STAFF_ROLE_ID = "1147963065640439900";
const trackingLogger = createLogger({ prefix: "tracking" });
export const tejtrackCommand: Command = async (message, args) => {
const client = message.client;
if (!message.member) return;
if (!message.member.roles.cache.has(STAFF_ROLE_ID)) {
await message.reply(
createErrorEmbed("Tu t'es cru chez mémé ou quoi faut être staff"),
);
// 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;
}
let playerName = args[0];
const playerName = args[0];
if (!playerName) {
await message.reply(
createErrorEmbed(
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 message.reply(
createErrorEmbed(
await replyError(
message,
"Cette personne n'existe pas.\n**Attention les majuscules sont importantes**",
),
);
return;
}
const res = await untrackWovPlayer(player.id);
switch (res.event) {
case "notTracked": {
await message.reply(
createInfoEmbed(
if (!(await isWovPlayerTracked(player.id))) {
await replyError(
message,
`Pas de tracker pour \`${playerName}\` [\`${player.id}\`]`,
),
);
break;
return;
}
case "trackerRemoved": {
await message.reply(
createInfoEmbed(
await untrackWovPlayer(player.id);
await replySuccess(
message,
`Tracker enlevé pour \`${playerName}\` [\`${player.id}\`]`,
),
);
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) throw "Invalid tracking channel";
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}\`]`),
createInfoEmbed(
`### [REMOVED] \`${playerName}\` [\`${player.id}\`]`,
0xea0000,
),
);
break;
}
}
};

View File

@@ -1,78 +1,59 @@
import type { Command } from "~/commands";
import { trackWovPlayer } from "~/services/tracking";
import { trackWovPlayer, isWovPlayerTracked } from "~/services/tracking";
import { searchPlayer } from "~/services/wov";
import { createErrorEmbed, createInfoEmbed } from "~/utils/discord";
import { replyError, createInfoEmbed, replySuccess } from "~/utils/discord";
import { env } from "~/env";
import { createLogger } from "@lbf-bot/utils";
const STAFF_ROLE_ID = "1147963065640439900";
const trackingLogger = createLogger({ prefix: "tracking" });
export const trackCommand: Command = async (message, args) => {
const client = message.client;
if (!message.member) return;
if (!message.member.roles.cache.has(STAFF_ROLE_ID)) {
await message.reply(
createErrorEmbed("Tu t'es cru chez mémé ou quoi faut être staff"),
);
// 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;
}
let playerName = args[0];
const playerName = args[0];
if (!playerName) {
await message.reply(
createErrorEmbed(
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 message.reply(
createErrorEmbed(
await replyError(
message,
"Cette personne n'existe pas.\n**Attention les majuscules sont importantes**",
),
);
return;
}
const res = await trackWovPlayer(player.id);
switch (res.event) {
case "notFound": {
await message.reply(
createErrorEmbed(
"Cette personne n'existe pas.\n**Attention les majuscules sont importantes**",
),
const alreadyTracked = await isWovPlayerTracked(player.id);
if (alreadyTracked) {
await replyError(
message,
`Tracker déjà enregistré pour \`${playerName}\` [\`${player.id}\`]`,
);
return;
}
case "registered": {
await message.reply(
createInfoEmbed(
await trackWovPlayer(player.id);
await replySuccess(
message,
`Tracker enregistré pour \`${playerName}\` [\`${player.id}\`]`,
),
);
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) throw "Invalid tracking channel";
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}\`]`),
);
return;
}
case "none": {
await message.reply(
createInfoEmbed(
`Tracker déjà enregistré pour \`${playerName}\` [\`${player.id}\`]`,
),
);
return;
}
case "changed": {
// ignored
break;
}
}
};

View File

@@ -1,5 +1,8 @@
import { z } from "zod";
import "dotenv/config";
import { logger } from "@lbf-bot/utils";
// TODO: use parseEnv from utils
const schema = z.object({
DISCORD_BOT_TOKEN: z.string(),
@@ -29,13 +32,11 @@ const schema = z.object({
const result = schema.safeParse(process.env);
if (!result.success) {
console.log("❌ Invalid environments variables:");
console.log(
result.error.errors
logger.fatal(
`❌ Invalid environments variables:\n${result.error.errors
.map((x) => `- ${x.path.join(".")}: ${x.message}`)
.join("\n"),
.join("\n")}`,
);
process.exit(1);
}
export const env = result.data;

View File

@@ -4,13 +4,14 @@ import { setupBotMode } from "~/modes/bot";
import { setupUserMode } from "~/modes/user";
import { parseArgs } from "~/utils/cli";
import { runMigrations } from "@lbf-bot/database";
import { logger } from "@lbf-bot/utils";
console.log("Running database migrations...");
logger.info("Running database migrations...");
await runMigrations();
const mode = parseArgs(process.argv.slice(2));
console.log(`Mode: ${mode.type}`);
logger.info(`Mode: ${mode.type}`);
const client = new Client({
intents: [
@@ -35,8 +36,7 @@ switch (mode.type) {
default: {
// @ts-ignore
console.error(`ERROR: Not implemented: '${mode.type}'`);
process.exit(1);
logger.fatal(`ERROR: Not implemented: '${mode.type}'`);
}
}

View File

@@ -1,40 +1,66 @@
import type { Client } from "discord.js";
import { createLogger, logger } from "@lbf-bot/utils";
import { env } from "~/env";
import { listTrackedPlayers, trackWovPlayer } from "~/services/tracking";
import { checkForNewQuest } from "~/services/wov";
import {
listTrackedPlayers,
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";
const questsLogger = createLogger({ prefix: "quests" });
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 res = await trackWovPlayer(playerId);
if (res.event !== "changed") return;
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()) throw "Invalid tracking channel";
const lastUsername = res.oldUsernames[res.oldUsernames.length - 1];
if (!chan?.isSendable()) {
return logger.fatal("Invalid 'DISCORD_TRACKING_CHANNEL'");
}
const lastUsername = usernames[usernames.length - 1];
await chan.send(
createInfoEmbed(
`### [UPDATE] \`${lastUsername}\` -> \`${res.newUsername}\` [\`${playerId}\`]\n\n**Nouveau pseudo:** \`${res.newUsername}\`\n**Anciens pseudos:**\n${res.oldUsernames.map((x) => `- \`${x}\``).join("\n")}`,
`### [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) => {
console.log(`Logged in as ${client.user.username}`);
logger.info(`Client ready`);
logger.info(`Connected as @${client.user.username}`);
await questCheckCron(client);
setInterval(() => questCheckCron(client), env.WOV_FETCH_INTERVAL);
@@ -54,7 +80,17 @@ export const setupBotMode = (client: Client) => {
const commandHandler = commands[command];
if (commandHandler) {
const child = logger.child(
`cmd:${command}${args.length > 0 ? " " : ""}${args.join(" ")}`,
);
try {
const start = Date.now();
await commandHandler(message, args);
const end = Date.now();
child.info(`Done in ${(end - start).toFixed(2)}ms`);
} catch (error: unknown) {
child.error("Failed:", error);
}
}
}
});

View File

@@ -1,10 +1,12 @@
import { logger } from "@lbf-bot/utils";
import type { Client, TextChannel } from "discord.js";
import { ChannelType } from "discord.js";
import * as readline from "node:readline";
export const setupUserMode = (client: Client, channelId: string) => {
client.on("clientReady", (client) => {
console.log(`Logged in as ${client.user.username}`);
logger.info(`Client ready`);
logger.info(`Connected as @${client.user.username}`);
const chan = client.channels.cache.get(channelId);
if (chan?.type !== ChannelType.GuildText) {

View File

@@ -11,33 +11,40 @@ export async function listTrackedPlayers(): Promise<string[]> {
return players.map((p) => p.playerId);
}
export async function untrackWovPlayer(
playerId: string,
): Promise<{ event: "notTracked" } | { event: "trackerRemoved" }> {
export async function isWovPlayerTracked(playerId: string): Promise<boolean> {
const player = await db.query.trackedPlayers.findFirst({
where: eq(tables.trackedPlayers.playerId, playerId),
});
if (!player) return { event: "notTracked" };
return player !== undefined;
}
export async function untrackWovPlayer(playerId: string): Promise<void> {
await db
.delete(tables.trackedPlayers)
.where(eq(tables.trackedPlayers.playerId, playerId));
return { event: "trackerRemoved" };
}
export async function trackWovPlayer(playerId: string): Promise<
| { event: "notFound" }
| {
event: "registered";
}
| { event: "changed"; oldUsernames: string[]; newUsername: string }
| { event: "none" }
> {
export async function trackWovPlayer(playerId: string): Promise<void> {
const alreadyTracked = await isWovPlayerTracked(playerId);
if (alreadyTracked) return;
const player = await getPlayer(playerId);
if (!player) return { event: "notFound" };
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: {
@@ -47,40 +54,21 @@ export async function trackWovPlayer(playerId: string): Promise<
},
});
if (tracked) {
const currentUsernames = tracked.usernameHistory.map((h) => h.username);
if (!tracked) return [];
return tracked.usernameHistory.map((h) => h.username);
}
if (!currentUsernames.includes(player.username)) {
export async function addUsernameToHistory(
playerId: string,
username: string,
): Promise<void> {
await db.insert(tables.usernameHistory).values({
playerId,
username: player.username,
username,
});
await db
.update(tables.trackedPlayers)
.set({ updatedAt: new Date() })
.where(eq(tables.trackedPlayers.playerId, playerId));
return {
event: "changed",
oldUsernames: currentUsernames,
newUsername: player.username,
};
} else {
return {
event: "none",
};
}
} else {
await db.insert(tables.trackedPlayers).values({
playerId,
});
await db.insert(tables.usernameHistory).values({
playerId,
username: player.username,
});
return { event: "registered" };
}
}

View File

@@ -72,7 +72,7 @@ export const makeResultEmbed = async (
export const createErrorEmbed = (
message: string,
color = 15335424,
color = 0xea0000,
): MessageCreateOptions => ({
embeds: [
{
@@ -84,7 +84,7 @@ export const createErrorEmbed = (
export const createSuccessEmbed = (
message: string,
color = 65280,
color = 0x00ea00,
): MessageCreateOptions => ({
embeds: [
{

View File

@@ -2,12 +2,18 @@ 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)
throw "Invalid admin channel provided";
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)
@@ -91,12 +97,16 @@ export const askForGrinders = async (quest: QuestResult, client: Client) => {
}
}
if (answer === null) throw "unreachable";
if (answer === null) {
return questLogger.fatal("Answer was 'null', this should be unreachable");
}
const exclude = answer
exclude = answer
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
const embed = await makeResultEmbed(quest, [
...env.QUEST_EXCLUDE,
...exclude,
@@ -107,9 +117,11 @@ export const askForGrinders = async (quest: QuestResult, client: Client) => {
if (rewardChannel && rewardChannel.type === ChannelType.GuildText) {
await rewardChannel.send(embed);
} else {
throw "Invalid reward channel";
return questLogger.fatal("Invalid 'DISCORD_REWARDS_CHANNEL'");
}
if (env.QUEST_EXCLUDE) {
await adminChannel.send("Envoyé !");
console.log(`Quest result posted at: ${new Date().toISOString()}`);
}
questLogger.info(`Results posted at: ${new Date().toISOString()}`);
};

View File

@@ -44,6 +44,7 @@ services:
- DISCORD_ADMIN_MENTION
- DISCORD_ADMIN_CHANNEL
- DISCORD_TRACKING_CHANNEL
- DISCORD_STAFF_ROLE_ID
- WOV_API_KEY
- WOV_CLAN_ID
- WOV_FETCH_INTERVAL

View File

@@ -1,5 +1,5 @@
{
"name": "lbf",
"name": "lbf-bot",
"packageManager": "pnpm@10.24.0",
"scripts": {
"format": "prettier --write --cache ."

View File

@@ -1,20 +1,20 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { env } from "~/env";
import { createLogger } from "@lbf-bot/utils";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { env } from "~/env";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbLogger = createLogger({ prefix: "db" });
export async function runMigrations() {
console.log("Connecting to database...");
const db = drizzle(env.DATABASE_URL);
const migrationsFolder = join(__dirname, "..", "drizzle");
console.log(`Running migrations from: ${migrationsFolder}`);
dbLogger.info(`Running migrations`);
await migrate(db, { migrationsFolder });
console.log("✅ Database migrations completed");
dbLogger.info("Migrations completed");
}

View File

@@ -1 +1,2 @@
export * from "./env";
export * from "./logger";

View File

@@ -0,0 +1,101 @@
type LogLevel = "debug" | "info" | "warn" | "error";
interface LoggerOptions {
prefix?: string;
level?: LogLevel;
}
const LOG_LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3,
} as const satisfies Record<LogLevel, number>;
const COLORS = {
debug: "\x1b[36m", // cyan
info: "\x1b[32m", // green
warn: "\x1b[33m", // yellow
error: "\x1b[31m", // red
reset: "\x1b[0m",
gray: "\x1b[90m",
bold: "\x1b[1m",
} as const;
class Logger {
private prefix: string;
private minLevel: number;
constructor(options: LoggerOptions = {}) {
this.prefix = options.prefix || "";
this.minLevel = LOG_LEVELS[options.level || "info"];
}
private formatTimestamp(): string {
const now = new Date();
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
private log(level: LogLevel, message: string, ...args: unknown[]): void {
if (LOG_LEVELS[level] < this.minLevel) return;
const timestamp = this.formatTimestamp();
const color = COLORS[level];
const levelStr = level.toUpperCase().padEnd(5);
const prefix = this.prefix ? `[${this.prefix}] ` : "";
const formattedArgs = args.map((arg) => {
if (arg instanceof Error) {
return arg;
}
return arg;
});
console.log(
`${COLORS.gray}${timestamp}${COLORS.reset} ${color}${COLORS.bold}${levelStr}${COLORS.reset} ${prefix}${message}`,
...formattedArgs,
);
}
debug(message: string, ...args: unknown[]): void {
this.log("debug", message, ...args);
}
info(message: string, ...args: unknown[]): void {
this.log("info", message, ...args);
}
warn(message: string, ...args: unknown[]): void {
this.log("warn", message, ...args);
}
error(message: string, ...args: unknown[]): void {
this.log("error", message, ...args);
}
fatal(message: string, ...args: unknown[]): never {
this.log("error", message, ...args);
process.exit(1);
}
child(prefix: string): Logger {
const childPrefix = this.prefix ? `${this.prefix}:${prefix}` : prefix;
return new Logger({ prefix: childPrefix, level: this.getLevel() });
}
private getLevel(): LogLevel {
const entry = Object.entries(LOG_LEVELS).find(
([, value]) => value === this.minLevel,
);
return (entry?.[0] as LogLevel) || "info";
}
}
export const createLogger = (options?: LoggerOptions): Logger => {
return new Logger(options);
};
export const logger = createLogger();

3
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@lbf-bot/database':
specifier: workspace:*
version: link:../../packages/database
'@lbf-bot/utils':
specifier: workspace:*
version: link:../../packages/utils
discord.js:
specifier: ^14.21.0
version: 14.25.1