Skip to content
31 changes: 30 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
51 changes: 32 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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('<a:crossmark:1454281378295451648> **Unable to verify permissions.**');
if (!guildId) return c.res('<a:crossmark:1454281378295451648> **Guild not found.**');
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The language loading happens after the first guild check. If guildId is null at line 22, the function returns before the language is loaded. Move the guildId null check after the language loading, or handle the error message using a default language. Currently, line 22 returns a hardcoded English error message even if the server uses French.

Copilot uses AI. Check for mistakes.
let lang: FileLanguage = getFileFromLanguage('en') as FileLanguage;
await getLanguageFromServer(guildId, c).then((language) => {
lang = getFileFromLanguage(language) as FileLanguage;
});
Comment on lines +24 to +26
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .then() is unnecessary here since the code already uses await. You can simplify this to: const language = await getLanguageFromServer(guildId, c); lang = getFileFromLanguage(language) as FileLanguage;. This makes the code more readable and consistent with the async/await pattern used elsewhere.

Copilot uses AI. Check for mistakes.
if (!memberPermissions) return c.res(lang.PERMISSIONS_ERROR);

const hasMentionEveryonePermission = BigInt(memberPermissions) & BigInt(0x20000);
if (!hasMentionEveryonePermission) {
return c.res('<a:crossmark:1454281378295451648> **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('<a:crossmark:1454281378295451648> **Guild not found.**');
if (!guildId) return c.res(lang.GUILD_NOT_FOUND);
Comment on lines 25 to +35
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guildId is checked and returned early on line 22, making this duplicate check on line 35 redundant and unreachable. The code comment "get guild id" is also misleading since the guildId was already retrieved earlier. Remove this redundant check.

Copilot uses AI. Check for mistakes.

const env = c.env as { DISCORD_TOKEN?: string };
const token = env.DISCORD_TOKEN || process.env.BOT_TOKEN;
if (!token) return c.res('<a:crossmark:1454281378295451648> **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}`);
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error responses should also be localized. This hardcoded error message "Failed to fetch members: {status} {statusText}" will always be in English, even if the server uses French. Consider adding this error message to the localization files.

Suggested change
if (!resp.ok) return c.res(`Failed to fetch members: ${resp.status} ${resp.statusText}`);
if (!resp.ok) return c.res(`${resp.status} ${resp.statusText}`);

Copilot uses AI. Check for mistakes.
Expand All @@ -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('<a:crossmark:1454281378295451648> **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;
Expand All @@ -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('<a:crossmark:1454281378295451648> **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('<a:crossmark:1454281378295451648> **User ID not found.**');
if (!userId) return c.res(lang.USER_ID_NOT_FOUND_ERROR);

return c.res(`**<a:confetti:1437507874614939789> <@${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;
});
Comment on lines +79 to +83
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The language loading pattern is duplicated in three command handlers. Consider extracting this logic into a helper function or middleware to avoid code duplication and ensure consistent language loading behavior across all commands.

Copilot uses AI. Check for mistakes.
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(`**<a:sparkles:1454282222210125959> Pong!** \n-# Latency: ${latency}ms`);
return c.res(lang.PING.replace('{{LATENCY}}', latency.toString()));
});

export default app;
18 changes: 18 additions & 0 deletions src/locales/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
PERMISSIONS_ERROR: '<a:crossmark:1454281378295451648> **Unable to verify permissions.**'
MENTION_EVERYONE_PERMISSION_MISSING: '<a:crossmark:1454281378295451648> **You need the Mention Everyone permission to use this command.**'
GUILD_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **Guild not found.**'
BOT_TOKEN_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **Bot token not found in .env**'
NO_MEMBERS_ERROR: '<a:crossmark:1454281378295451648> **No members found.**'
NO_MEMBERS_MATCH_FILTER_ERROR: '<a:crossmark:1454281378295451648> **No members match the filter (all results were bots or yourself).**'
USER_ID_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **User ID not found.**'
YOU_HAVE_BEEN_CHOSEN: '**<a:confetti:1437507874614939789> {{USER_ID}}, you have been chosen!**'
PING: "**<a:sparkles:1454282222210125959> Pong!** \n-# Latency: {{LATENCY}}ms"
GUILD_NOT_FOUND: '<a:crossmark:1454281378295451648> **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).
18 changes: 18 additions & 0 deletions src/locales/fr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
PERMISSIONS_ERROR: '<a:crossmark:1454281378295451648> **Impossible de vérifier les permissions.**'
MENTION_EVERYONE_PERMISSION_MISSING: '<a:crossmark:1454281378295451648> **Vous devez avoir la permission Mentionner tout le monde pour utiliser cette commande.**'
GUILD_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **Serveur introuvable.**'
BOT_TOKEN_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **Jeton du bot introuvable dans .env**'
NO_MEMBERS_ERROR: '<a:crossmark:1454281378295451648> **Aucun membre trouvé.**'
NO_MEMBERS_MATCH_FILTER_ERROR: '<a:crossmark:1454281378295451648> **Aucun membre ne correspond au filtre (tous les résultats étaient des bots ou vous-même).**'
USER_ID_NOT_FOUND_ERROR: '<a:crossmark:1454281378295451648> **ID utilisateur introuvable.**'
YOU_HAVE_BEEN_CHOSEN: '**<a:confetti:1437507874614939789> {{USER_ID}}, vous avez été choisi !**'
PING: "**<a:sparkles:1454282222210125959> Pong !** \n-# Latence : {{LATENCY}}ms"
GUILD_NOT_FOUND: '<a:crossmark:1454281378295451648> **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).
13 changes: 13 additions & 0 deletions src/types/fileLanguage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions src/utils/getFileFromLanguage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/utils/getLanguageFromServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CommandContext } from 'discord-hono';
import ky from 'ky';

export default async function getLanguageFromServer(serverId: string, c: CommandContext): Promise<string> {
// 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';
}
10 changes: 8 additions & 2 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -12,6 +12,12 @@ declare namespace Cloudflare {
}
}
interface Env extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, 'DISCORD_APPLICATION_ID' | 'DISCORD_PUBLIC_KEY' | 'DISCORD_TOKEN'>> {}
}

// Begin runtime types
/*! *****************************************************************************
Expand Down
1 change: 1 addition & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"observability": {
"enabled": true,
},
"compatibility_flags": ["nodejs_compat"],
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
Expand Down