refactor: monorepo structure

NOTE: discord bot moved to apps only
This commit is contained in:
Pihkaal
2025-12-03 14:42:35 +01:00
parent 414509dd6e
commit fd2e2ebd4b
24 changed files with 997 additions and 300 deletions

View File

@@ -0,0 +1,11 @@
WOV_CLAN_ID=
WOV_API_KEY=
WOV_FETCH_INTERVAL="14400000" # 4 hours
WOV_TRACKING_INTERVAL="43200000" # 12 hours
QUEST_REWARDS=
QUEST_EXCLUDE=
DISCORD_WEBHOOK_URL=
DISCORD_MENTION=
DISCORD_REWARDS_GIVER=

4
apps/discord-bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
node_modules
dist
.cache

View File

@@ -0,0 +1,21 @@
Copyright (c) 2025 Pihkaal <hello@pihkaal.me>
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1 @@
# @lbf/discord-bot

View File

@@ -0,0 +1,21 @@
{
"name": "@lbf/discord-bot",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "rm -rf dist && tsc --project tsconfig.build.json && tsc-alias --project tsconfig.build.json",
"dev:user": "tsx src/index.ts -- --user"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"dependencies": {
"discord.js": "^14.21.0",
"dotenv": "^17.2.3",
"zod": "^3.24.4"
}
}

View File

@@ -0,0 +1,34 @@
import { readFile, writeFile, access } from "node:fs/promises";
import { constants } from "node:fs";
const ACCOUNTS_FILE = "./.cache/accounts.json";
export const initAccounts = async (): Promise<void> => {
try {
await access(ACCOUNTS_FILE, constants.F_OK);
} catch {
await writeFile(ACCOUNTS_FILE, "{}");
}
};
export const getAccountBalance = async (playerId: string): Promise<number> => {
const content = await readFile(ACCOUNTS_FILE, "utf-8");
const accounts: Record<string, number> = JSON.parse(content);
if (accounts[playerId]) return accounts[playerId];
accounts[playerId] = 0;
await writeFile(ACCOUNTS_FILE, JSON.stringify(accounts));
return 0;
};
export const setAccountBalance = async (
playerId: string,
balance: number,
): Promise<void> => {
const content = await readFile(ACCOUNTS_FILE, "utf-8");
const accounts: Record<string, number> = JSON.parse(content);
accounts[playerId] = balance;
await writeFile(ACCOUNTS_FILE, JSON.stringify(accounts));
};

View File

@@ -0,0 +1,84 @@
import { getAccountBalance, setAccountBalance } from "./account";
import { env } from "./env";
import type { QuestResult } from "./wov";
export type DiscordMessage = {
content: string;
embeds: Array<DiscordEmbed>;
};
export type DiscordEmbed = {
title?: string;
description: string;
image?: {
url: string;
};
color: number;
};
export const makeResultEmbed = async (
result: QuestResult,
exclude: Array<string>,
): Promise<DiscordMessage> => {
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: DiscordEmbed | 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]} gemmes`,
);
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,
},
],
};
};

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import "dotenv/config";
const schema = z.object({
DISCORD_BOT_TOKEN: z.string(),
DISCORD_MENTION: z.string(),
DISCORD_REWARDS_GIVER: z.string(),
DISCORD_REWARDS_CHANNEL: z.string(),
DISCORD_ADMIN_MENTION: z.string(),
DISCORD_ADMIN_CHANNEL: z.string(),
DISCORD_TRACKING_CHANNEL: z.string(),
WOV_API_KEY: z.string(),
WOV_CLAN_ID: z.string(),
WOV_FETCH_INTERVAL: z.coerce.number(),
WOV_TRACKING_INTERVAL: z.coerce.number(),
QUEST_REWARDS: z
.string()
.transform((x) => x.split(",").map((x) => x.trim()))
.optional(),
QUEST_EXCLUDE: z
.string()
.transform((x) => x.split(",").map((x) => x.trim()))
.optional()
.default(""),
});
const result = schema.safeParse(process.env);
if (!result.success) {
console.log("❌ Invalid environments variables:");
console.log(
result.error.errors
.map((x) => `- ${x.path.join(".")}: ${x.message}`)
.join("\n"),
);
process.exit(1);
}
export const env = result.data;

View File

@@ -0,0 +1,562 @@
import { getAccountBalance, initAccounts, setAccountBalance } from "./account";
import { makeResultEmbed } from "./discordUtils";
import { env } from "./env";
import {
initTracking,
listTrackedPlayers,
trackWovPlayer,
untrackWovPlayer,
} from "./tracking";
import {
checkForNewQuest,
getClanInfos,
getClanMembers,
getLatestQuest,
searchPlayer,
type QuestResult,
} from "./wov";
import {
ChannelType,
Client,
EmbedBuilder,
GatewayIntentBits,
Message,
Partials,
} from "discord.js";
import * as readline from "node:readline";
// user mode = write in console, send in channel
const flagIndex = process.argv.indexOf("--user");
let userMode: { enabled: true; channelId: string } | { enabled: false } = {
enabled: false,
};
if (flagIndex !== -1) {
const channelId = process.argv[flagIndex + 1];
if (channelId === undefined) {
console.error("ERROR: --user expects channelId as a paramater");
process.exit(1);
}
userMode = { enabled: true, channelId };
}
console.log(`User mode: ${userMode.enabled ? "enabled" : "disabled"}`);
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
partials: [Partials.Message, Partials.Channel],
});
const askForGrinders = async (quest: QuestResult) => {
const adminChannel = await client.channels.fetch(env.DISCORD_ADMIN_CHANNEL);
if (!adminChannel || adminChannel.type !== ChannelType.GuildText)
throw "Invalid admin channel provided";
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) throw "unreachable";
const 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 {
throw "Invalid reward channel";
}
await adminChannel.send("Envoyé !");
console.log(`Quest result posted at: ${new Date().toISOString()}`);
};
const fn = async () => {
const quest = await checkForNewQuest();
if (quest) {
await askForGrinders(quest);
}
};
const trackingCron = async () => {
const trackedPlayers = await listTrackedPlayers();
for (const playerId of trackedPlayers) {
const res = await trackWovPlayer(playerId);
if (res.event !== "changed") return;
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];
await chan.send({
embeds: [
{
description: `### [UPDATE] \`${lastUsername}\` -> \`${res.newUsername}\` [\`${playerId}\`]`,
fields: [
{ name: "Nouveau pseudo", value: `\`${res.newUsername}\`` },
{
name: "Anciens pseudos",
value: res.oldUsernames.map((x) => `- \`${x}\``).join("\n"),
},
],
color: 0x89cff0,
},
],
});
}
};
client.on("ready", async (client) => {
console.log(`Logged in as ${client.user.username}`);
if (userMode.enabled) {
const chan = client.channels.cache.get(userMode.channelId);
if (chan?.type !== ChannelType.GuildText) {
console.error("ERROR: invalid channel");
process.exit(1);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: `${chan.name} ~ `,
});
rl.prompt();
rl.on("line", async (line) => {
await chan.send(line);
rl.prompt();
});
rl.on("close", () => {
process.exit(0);
});
} else {
await initAccounts();
await initTracking();
await fn();
setInterval(fn, env.WOV_FETCH_INTERVAL);
await trackingCron();
setInterval(trackingCron, env.WOV_TRACKING_INTERVAL);
}
});
client.on("messageCreate", async (message) => {
if (message.author.bot || userMode.enabled) return;
const displayName = message.member?.displayName || message.author.username;
if (message.content.startsWith(`<@${client.user!.id}>`)) {
const [command, ...args] = message.content
.replace(`<@${client.user!.id}>`, "")
.trim()
.split(" ");
if (command === "ping") {
await message.reply("pong");
} else if (command === "tejtrack") {
if (!message.member) return;
if (!message.member.roles.cache.has("1147963065640439900")) {
await message.reply({
embeds: [
{
description:
"### ❌ Erreur\n\n\nTu t'es cru chez mémé ou quoi faut être staff",
color: 15335424,
},
],
});
return;
}
let playerName = args[0];
if (!playerName) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nUsage:\`@LBF untrack NOM_JOUEUR\`, exemple: \`@LBF untrack Yuno\`.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nCette personne n'existe pas.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const res = await untrackWovPlayer(player.id);
switch (res.event) {
case "notTracked": {
await message.reply({
embeds: [
{
description: `Pas de tracker pour \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
break;
}
case "trackerRemoved": {
await message.reply({
embeds: [
{
description: `Tracker enlevé pour \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) throw "Invalid tracking channel";
await chan.send({
embeds: [
{
description: `### [REMOVED] \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
break;
}
}
} else if (command === "track") {
if (!message.member) return;
if (!message.member.roles.cache.has("1147963065640439900")) {
await message.reply({
embeds: [
{
description:
"### ❌ Erreur\n\n\nTu t'es cru chez mémé ou quoi faut être staff",
color: 15335424,
},
],
});
return;
}
let playerName = args[0];
if (!playerName) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nUsage:\`@LBF track NOM_JOUEUR\`, exemple: \`@LBF track Yuno\`.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nCette personne n'existe pas.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const res = await trackWovPlayer(player.id);
switch (res.event) {
case "notFound": {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nCette personne n'existe pas.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
case "registered": {
await message.reply({
embeds: [
{
description: `Tracker enregistré pour \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
const chan = client.channels.cache.get(env.DISCORD_TRACKING_CHANNEL);
if (!chan?.isSendable()) throw "Invalid tracking channel";
await chan.send({
embeds: [
{
description: `### [NEW] \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
return;
}
case "none": {
await message.reply({
embeds: [
{
description: `Tracker déjà enregistré pour \`${playerName}\` [\`${player.id}\`]`,
color: 0x89cff0,
},
],
});
return;
}
case "changed": {
// ignored
break;
}
}
} else if (command === "icone") {
let playerName = args[0];
if (!playerName) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nUsage:\`@LBF icone NOM_JOUEUR\`, exemple: \`@LBF icone Yuno\`.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const player = await searchPlayer(playerName);
if (!player) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nJoueur·euse non trouvé·e.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
if (!player.clanId) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nCette personne __n'a pas de clan__ ou __a caché son clan__.\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
return;
}
const clan = await getClanInfos(player.clanId);
if (!clan) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\nImpossible de récupérer les informations du clan.`,
color: 15335424,
},
],
});
return;
}
await message.reply({
embeds: [
{
description: `### ✅ Informations du clan\n\n**Nom:** \`\`\`${clan.name}\`\`\`\n**Tag:** \`\`\`${clan.tag}\`\`\``,
color: 65280,
},
],
});
} else if (command === "result") {
const quest = await getLatestQuest();
await askForGrinders(quest);
} else if (command === "gemmes") {
let playerName = displayName.replace("🕸 |", "").trim();
if (args.length >= 1) {
playerName = args[0];
}
const clanMembers = await getClanMembers();
let clanMember = clanMembers.find((x) => x.username === playerName);
if (!clanMember) {
await message.reply({
embeds: [
{
description: `### ❌ Erreur\n\n\n\`${playerName}\` n'est pas dans le clan (la honte).\n**Attention les majuscules sont importantes**`,
color: 15335424,
},
],
});
} else {
if (args.length === 2 && message.member) {
if (!message.member.roles.cache.has("1147963065640439900")) {
await message.reply({
embeds: [
{
description:
"### ❌ Erreur\n\n\nTu t'es cru chez mémé ou quoi faut être staff",
color: 15335424,
},
],
});
return;
}
if (
(args[1][0] !== "+" && args[1][0] !== "-") ||
!args[1] ||
isNaN(Number(args[1].substring(1)))
) {
await message.reply({
embeds: [
{
description:
"### ❌ Erreur\n\n\nFormat: \`@LBF gemmes <pseudo> <+GEMMES|-GEMMES>\`\nExemple:\`@LBF gemmes Yuno -10000\`\n**Attention les majuscules sont importantes**",
color: 15335424,
},
],
});
return;
}
const mult = args[1][0] === "+" ? 1 : -1;
const delta = Number(args[1].substring(1)) * mult;
const balance = await getAccountBalance(clanMember.playerId);
await setAccountBalance(
clanMember.playerId,
Math.max(0, balance + delta),
);
}
const balance = await getAccountBalance(clanMember.playerId);
// await message.reply(`Gemmes accumulées par ${playerName}: ${balance}`);
await message.reply({
embeds: [
{
description: `### 💎 Compte de ${playerName}\n\n\nGemmes disponibles: **${balance}**\n\n-# Voir avec <@294871767820795904> pour échanger contre skin/carte etc`,
color: 4360641,
},
],
});
}
}
}
});
await client.login(env.DISCORD_BOT_TOKEN);

View File

@@ -0,0 +1,75 @@
import { getPlayer } from "./wov";
import { readFile, writeFile, access } from "node:fs/promises";
import { constants } from "node:fs";
const TRACKED_PLAYER_FILE = "./.cache/tracked.json";
type TrackedPlayers = Record<string, string[]>;
export async function initTracking(): Promise<void> {
try {
await access(TRACKED_PLAYER_FILE, constants.F_OK);
} catch {
await writeFile(TRACKED_PLAYER_FILE, "{}");
}
}
export async function listTrackedPlayers(): Promise<string[]> {
const content = await readFile(TRACKED_PLAYER_FILE, "utf-8");
const trackedPlayers: TrackedPlayers = JSON.parse(content);
return Object.keys(trackedPlayers);
}
export async function untrackWovPlayer(
playerId: string,
): Promise<{ event: "notTracked" } | { event: "trackerRemoved" }> {
const content = await readFile(TRACKED_PLAYER_FILE, "utf-8");
const trackedPlayers: TrackedPlayers = JSON.parse(content);
if (!trackedPlayers[playerId]) return { event: "notTracked" };
delete trackedPlayers[playerId];
await writeFile(TRACKED_PLAYER_FILE, JSON.stringify(trackedPlayers));
return { event: "trackerRemoved" };
}
export async function trackWovPlayer(playerId: string): Promise<
| { event: "notFound" }
| {
event: "registered";
}
| { event: "changed"; oldUsernames: string[]; newUsername: string }
| { event: "none" }
> {
const content = await readFile(TRACKED_PLAYER_FILE, "utf-8");
const trackedPlayers: TrackedPlayers = JSON.parse(content);
const player = await getPlayer(playerId);
if (!player) return { event: "notFound" };
const currentUsernames = trackedPlayers[playerId];
if (currentUsernames) {
const oldUsernames = [...currentUsernames];
if (!currentUsernames.includes(player.username)) {
currentUsernames.push(player.username);
await writeFile(TRACKED_PLAYER_FILE, JSON.stringify(trackedPlayers));
return {
event: "changed",
oldUsernames,
newUsername: player.username,
};
} else {
return {
event: "none",
};
}
} else {
trackedPlayers[playerId] = [player.username];
await writeFile(TRACKED_PLAYER_FILE, JSON.stringify(trackedPlayers));
return { event: "registered" };
}
}

151
apps/discord-bot/src/wov.ts Normal file
View File

@@ -0,0 +1,151 @@
import { env } from "./env";
import { mkdir, readFile, writeFile, access } from "node:fs/promises";
import { constants } from "node:fs";
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 checkForNewQuest = async (): Promise<QuestResult | null> => {
const lastQuest = await getLatestQuest();
const lastId = lastQuest.quest.id;
const cacheFilePath = ".cache/.quest_cache";
await mkdir(".cache", { recursive: true });
try {
await access(cacheFilePath, constants.F_OK);
const cachedQuestId = await readFile(cacheFilePath, "utf-8");
if (cachedQuestId === lastId || cachedQuestId === "IGNORE") {
return null;
}
} catch {
// File doesn't exist, continue
}
await writeFile(cacheFilePath, lastId);
return lastQuest;
};
export const getClanMembers = async (): Promise<
Array<{ playerId: string; username: string }>
> => {
const cacheFilePath = ".clan_members_cache";
await mkdir(".cache", { recursive: true });
let cached: {
timestamp: number;
data: Array<{ playerId: string; username: string }>;
} | null = null;
try {
await access(cacheFilePath, constants.F_OK);
const content = await readFile(cacheFilePath, "utf-8");
cached = JSON.parse(content);
if (cached && Date.now() - cached.timestamp < 60 * 60 * 1000) {
return cached.data;
}
} catch {
// File doesn't exist or is invalid, continue
}
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 writeFile(
cacheFilePath,
JSON.stringify({ timestamp: Date.now(), data }),
);
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,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"noEmitOnError": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
}
}