diff --git a/package-lock.json b/package-lock.json index 25c863e..5e1c78b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ "discord-hono": "^0.20.1", "eslint": "^9.39.2", "jiti": "^2.6.1", + "ky": "^1.14.2", "prettier": "^3.7.4", - "typescript-eslint": "^8.51.0" + "typescript-eslint": "^8.51.0", + "yaml": "^2.8.2" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.11.1", @@ -3337,6 +3339,18 @@ "node": ">=6" } }, + "node_modules/ky": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.2.tgz", + "integrity": "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4815,6 +4829,21 @@ } } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index f8e4792..50dca63 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "discord-hono": "^0.20.1", "eslint": "^9.39.2", "jiti": "^2.6.1", + "ky": "^1.14.2", "prettier": "^3.7.4", - "typescript-eslint": "^8.51.0" + "typescript-eslint": "^8.51.0", + "yaml": "^2.8.2" } } diff --git a/src/index.ts b/src/index.ts index 8524be8..07eeea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,46 @@ import { DiscordHono } from 'discord-hono'; +import ky from 'ky'; +import getLanguageFromServer from './utils/getLanguageFromServer'; +import getFileFromLanguage from './utils/getFileFromLanguage'; +import type { FileLanguage } from './types/fileLanguage'; const app = new DiscordHono() .command('help', async (c) => { - const helpMessage = `**Available Commands:** -- \`/someone [ignore-bots]\`: Ping a random member from the server. Optionally ignore bot users. -- \`/ping\`: Replies with the current ping. -- \`/help\`: Provides help information for available commands. - -To use a command, type \`/\` followed by the command name. For example, to ping a random member, type \`/someone\`. You can add the optional parameter \`ignore-bots\` to exclude bot users from being selected. --# Source code is available on [GitHub](https://github.com/notthebestdev/someoneback).`; + const guildId = c.interaction.guild?.id as string; + let lang: FileLanguage = getFileFromLanguage('en') as FileLanguage; + await getLanguageFromServer(guildId, c).then((language) => { + lang = getFileFromLanguage(language) as FileLanguage; + }); + const helpMessage = lang.HELP_MESSAGE; return c.res(helpMessage); }) .command('someone', async (c) => { // check if user has permission to mention everyone + const guildId = c.interaction.guild?.id; const memberPermissions = c.interaction.member?.permissions; - if (!memberPermissions) return c.res(' **Unable to verify permissions.**'); + if (!guildId) return c.res(' **Guild not found.**'); + let lang: FileLanguage = getFileFromLanguage('en') as FileLanguage; + await getLanguageFromServer(guildId, c).then((language) => { + lang = getFileFromLanguage(language) as FileLanguage; + }); + if (!memberPermissions) return c.res(lang.PERMISSIONS_ERROR); const hasMentionEveryonePermission = BigInt(memberPermissions) & BigInt(0x20000); if (!hasMentionEveryonePermission) { - return c.res(' **You need the Mention Everyone permission to use this command.**'); + return c.res(lang.MENTION_EVERYONE_PERMISSION_MISSING); } // get guild id - const guildId = c.interaction.guild?.id; - if (!guildId) return c.res(' **Guild not found.**'); + if (!guildId) return c.res(lang.GUILD_NOT_FOUND); const env = c.env as { DISCORD_TOKEN?: string }; const token = env.DISCORD_TOKEN || process.env.BOT_TOKEN; - if (!token) return c.res(' **Bot token not found in env.**'); + if (!token) return c.res(lang.BOT_TOKEN_NOT_FOUND_ERROR); // fetch members, limit to 1000 due to discord api limitation - const resp = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members?limit=1000`, { + const resp = await ky.get(`https://discord.com/api/v10/guilds/${guildId}/members`, { + searchParams: { limit: '1000' }, headers: { Authorization: `Bot ${token}` }, }); if (!resp.ok) return c.res(`Failed to fetch members: ${resp.status} ${resp.statusText}`); @@ -45,7 +54,7 @@ To use a command, type \`/\` followed by the command name. For example, to ping } } const allMembers = (await resp.json()) as Array<{ user?: { id?: string; username?: string; bot?: boolean } }>; - if (allMembers.length === 0) return c.res(' **No members found.**'); + if (allMembers.length === 0) return c.res(lang.NO_MEMBERS_ERROR); // apply bot filter if requested and always exclude self const selfId = c.interaction.member?.user?.id; @@ -57,22 +66,26 @@ To use a command, type \`/\` followed by the command name. For example, to ping return true; }); - if (filtered.length === 0) - return c.res(' **No members match the filter (all results were bots or yourself).**'); + if (filtered.length === 0) return c.res(lang.NO_MEMBERS_MATCH_FILTER_ERROR); // pick a random member const randomMember = filtered[Math.floor(Math.random() * filtered.length)]; const userId = randomMember.user?.id; - if (!userId) return c.res(' **User ID not found.**'); + if (!userId) return c.res(lang.USER_ID_NOT_FOUND_ERROR); - return c.res(`** <@${userId}>, you have been chosen!**`); + return c.res(lang.YOU_HAVE_BEEN_CHOSEN.replace('{{USER_ID}}', `<@${userId}>`)); }) .command('ping', async (c) => { + let lang: FileLanguage = getFileFromLanguage('en') as FileLanguage; + const guildId = c.interaction.guild?.id as string; + await getLanguageFromServer(guildId, c).then((language) => { + lang = getFileFromLanguage(language) as FileLanguage; + }); const start = Date.now(); await fetch('https://discord.com/api/v10/users/@me'); // yes, that was the only way I found to get a measurable latency, dont judge me please :D const end = Date.now(); const latency = end - start; - return c.res(`** Pong!** \n-# Latency: ${latency}ms`); + return c.res(lang.PING.replace('{{LATENCY}}', latency.toString())); }); export default app; diff --git a/src/locales/en.yml b/src/locales/en.yml new file mode 100644 index 0000000..2dc9c04 --- /dev/null +++ b/src/locales/en.yml @@ -0,0 +1,18 @@ +PERMISSIONS_ERROR: ' **Unable to verify permissions.**' +MENTION_EVERYONE_PERMISSION_MISSING: ' **You need the Mention Everyone permission to use this command.**' +GUILD_NOT_FOUND_ERROR: ' **Guild not found.**' +BOT_TOKEN_NOT_FOUND_ERROR: ' **Bot token not found in .env**' +NO_MEMBERS_ERROR: ' **No members found.**' +NO_MEMBERS_MATCH_FILTER_ERROR: ' **No members match the filter (all results were bots or yourself).**' +USER_ID_NOT_FOUND_ERROR: ' **User ID not found.**' +YOU_HAVE_BEEN_CHOSEN: '** {{USER_ID}}, you have been chosen!**' +PING: "** Pong!** \n-# Latency: {{LATENCY}}ms" +GUILD_NOT_FOUND: ' **Guild not found.**' +HELP_MESSAGE: | + **Available Commands:** + - `/someone [ignore-bots]`: Ping a random member from the server. Optionally ignore bot users. + - `/ping`: Replies with the current ping. + - `/help`: Provides help information for available commands. + + To use a command, type `/` followed by the command name. For example, to ping a random member, type `/someone`. You can add the optional parameter `ignore-bots` to exclude bot users from being selected. + - Source code is available on [GitHub](https://github.com/notthebestdev/someoneback). diff --git a/src/locales/fr.yml b/src/locales/fr.yml new file mode 100644 index 0000000..2b4b3e0 --- /dev/null +++ b/src/locales/fr.yml @@ -0,0 +1,18 @@ +PERMISSIONS_ERROR: ' **Impossible de vérifier les permissions.**' +MENTION_EVERYONE_PERMISSION_MISSING: ' **Vous devez avoir la permission Mentionner tout le monde pour utiliser cette commande.**' +GUILD_NOT_FOUND_ERROR: ' **Serveur introuvable.**' +BOT_TOKEN_NOT_FOUND_ERROR: ' **Jeton du bot introuvable dans .env**' +NO_MEMBERS_ERROR: ' **Aucun membre trouvé.**' +NO_MEMBERS_MATCH_FILTER_ERROR: ' **Aucun membre ne correspond au filtre (tous les résultats étaient des bots ou vous-même).**' +USER_ID_NOT_FOUND_ERROR: ' **ID utilisateur introuvable.**' +YOU_HAVE_BEEN_CHOSEN: '** {{USER_ID}}, vous avez été choisi !**' +PING: "** Pong !** \n-# Latence : {{LATENCY}}ms" +GUILD_NOT_FOUND: ' **Serveur introuvable.**' +HELP_MESSAGE: | + **Commandes disponibles :** + - `/someone [ignore-bots]` : Mentionne un membre aléatoire du serveur. Optionnellement, ignore les utilisateurs bots. + - `/ping` : Répond avec la latence actuelle. + - `/help` : Fournit des informations d'aide sur les commandes disponibles. + + Pour utiliser une commande, tapez `/` suivi du nom de la commande. Par exemple, pour mentionner un membre aléatoire, tapez `/someone`. Vous pouvez ajouter le paramètre optionnel `ignore-bots` pour exclure les bots de la sélection. + - Le code source est disponible sur [GitHub](https://github.com/notthebestdev/someoneback). diff --git a/src/types/fileLanguage.ts b/src/types/fileLanguage.ts new file mode 100644 index 0000000..8460e8b --- /dev/null +++ b/src/types/fileLanguage.ts @@ -0,0 +1,13 @@ +export interface FileLanguage { + PERMISSIONS_ERROR: string; + MENTION_EVERYONE_PERMISSION_MISSING: string; + GUILD_NOT_FOUND_ERROR: string; + BOT_TOKEN_NOT_FOUND_ERROR: string; + NO_MEMBERS_ERROR: string; + NO_MEMBERS_MATCH_FILTER_ERROR: string; + USER_ID_NOT_FOUND_ERROR: string; + YOU_HAVE_BEEN_CHOSEN: string; + PING: string; + GUILD_NOT_FOUND: string; + HELP_MESSAGE: string; +} diff --git a/src/utils/getFileFromLanguage.ts b/src/utils/getFileFromLanguage.ts new file mode 100644 index 0000000..88bc720 --- /dev/null +++ b/src/utils/getFileFromLanguage.ts @@ -0,0 +1,18 @@ +import yaml from 'yaml'; +import fs from 'fs'; +import path from 'path'; +import type { FileLanguage } from '../types/fileLanguage'; + +export default function getFileFromLanguage(language: string): FileLanguage { + let fileName: string; + switch (language) { + case 'fr': + fileName = 'fr.yml'; + break; + default: + fileName = 'en.yml'; + } + const filePath = path.resolve(__dirname, 'locales', fileName); + const fileContents = fs.readFileSync(filePath, 'utf8'); + return yaml.parse(fileContents) as FileLanguage; +} diff --git a/src/utils/getLanguageFromServer.ts b/src/utils/getLanguageFromServer.ts new file mode 100644 index 0000000..0a3f140 --- /dev/null +++ b/src/utils/getLanguageFromServer.ts @@ -0,0 +1,21 @@ +import type { CommandContext } from 'discord-hono'; +import ky from 'ky'; + +export default async function getLanguageFromServer(serverId: string, c: CommandContext): Promise { + // use ky as a workaround for custom endpoints not supported by client.rest + const guild = await ky.get(`https://discord.com/api/v9/guilds/templates/${serverId}`, { + headers: { + Authorization: `Bot ${c.env.DISCORD_TOKEN || process.env.DISCORD_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + + if (!guild.ok) { + // request failed, fall back to default language + return 'en'; + } + + // get serialized_source_guild.preferred_locale from successful response + const data = (await guid.json()) as { serialized_source_guild?: { preferred_locale?: string } }; + return data?.serialized_source_guild?.preferred_locale || 'en'; +} diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index fa368a3..7fc1bcc 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 7941bebd71489c99a416797df3953c9e) -// Runtime types generated with workerd@1.20251217.0 2025-10-08 +// Generated by Wrangler by running `wrangler types` (hash: 381a3acd1c0f34f3a378298d677f7c3e) +// Runtime types generated with workerd@1.20251217.0 2025-10-08 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import('./src/index'); @@ -12,6 +12,12 @@ declare namespace Cloudflare { } } interface Env extends Cloudflare.Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} // Begin runtime types /*! ***************************************************************************** diff --git a/wrangler.jsonc b/wrangler.jsonc index 3371096..72b1a7f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,6 +10,7 @@ "observability": { "enabled": true, }, + "compatibility_flags": ["nodejs_compat"], /** * Smart Placement * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement