mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
- Updated .env.example to use more secure database credentials. - Modified Dockerfile to remove unnecessary data copy. - Enhanced README with additional setup instructions and security notes. - Updated docker-compose files to improve database connection handling and security. - Added error messages for invalid channel and role resolutions in locales. - Introduced new scripts for code quality checks in package.json. - Refactored presence management logic to improve state handling and timer management. - Added tests for argument tokenizer, command definition, presence types, and template variables.
267 lines
8.0 KiB
TypeScript
267 lines
8.0 KiB
TypeScript
import type { ChatInputCommandInteraction, GuildBasedChannel, Message } from "discord.js";
|
|
|
|
import type { CommandArgValue, CommandArgument, TranslationVars } from "../types/command.js";
|
|
|
|
export interface ArgumentParseError {
|
|
key: string;
|
|
vars?: TranslationVars;
|
|
}
|
|
|
|
export interface ParsedArgumentsResult {
|
|
values: Record<string, CommandArgValue>;
|
|
errors: ArgumentParseError[];
|
|
}
|
|
|
|
const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/;
|
|
const CHANNEL_MENTION_PATTERN = /^<#(\d{16,22})>$/;
|
|
const ROLE_MENTION_PATTERN = /^<@&(\d{16,22})>$/;
|
|
const SNOWFLAKE_ID_PATTERN = /^(\d{16,22})$/;
|
|
|
|
const BOOLEAN_TRUE = new Set(["true", "1", "yes", "y", "on"]);
|
|
const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]);
|
|
|
|
const isGuildBasedChannel = (value: unknown): value is GuildBasedChannel => {
|
|
return Boolean(value) && typeof value === "object" && "guild" in (value as Record<string, unknown>);
|
|
};
|
|
|
|
export const tokenizePrefixInput = (raw: string): string[] => {
|
|
const tokens: string[] = [];
|
|
const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(raw)) !== null) {
|
|
tokens.push((match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["'])/g, "$1"));
|
|
}
|
|
|
|
return tokens;
|
|
};
|
|
|
|
export const parsePrefixArgs = async (
|
|
message: Message,
|
|
definitions: CommandArgument[],
|
|
rawInput: string,
|
|
): Promise<ParsedArgumentsResult> => {
|
|
const tokens = tokenizePrefixInput(rawInput);
|
|
const values: Record<string, CommandArgValue> = {};
|
|
const errors: ArgumentParseError[] = [];
|
|
|
|
for (const definition of definitions) {
|
|
const token = tokens.shift();
|
|
|
|
if (!token) {
|
|
if (definition.required) {
|
|
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
|
|
}
|
|
values[definition.name] = undefined;
|
|
continue;
|
|
}
|
|
|
|
const parsed = await parseByTypeFromPrefix(message, definition.type, token);
|
|
if (parsed.ok) {
|
|
values[definition.name] = parsed.value;
|
|
continue;
|
|
}
|
|
|
|
errors.push(parsed.error);
|
|
}
|
|
|
|
if (tokens.length > 0) {
|
|
errors.push({
|
|
key: "errors.args.tooMany",
|
|
vars: {
|
|
count: tokens.length,
|
|
extras: tokens.join(" "),
|
|
},
|
|
});
|
|
}
|
|
|
|
return { values, errors };
|
|
};
|
|
|
|
export const parseSlashArgs = (
|
|
interaction: ChatInputCommandInteraction,
|
|
definitions: CommandArgument[],
|
|
): ParsedArgumentsResult => {
|
|
const values: Record<string, CommandArgValue> = {};
|
|
const errors: ArgumentParseError[] = [];
|
|
|
|
for (const definition of definitions) {
|
|
try {
|
|
values[definition.name] = parseByTypeFromSlash(interaction, definition);
|
|
} catch {
|
|
if (definition.required) {
|
|
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
|
|
}
|
|
values[definition.name] = undefined;
|
|
}
|
|
}
|
|
|
|
return { values, errors };
|
|
};
|
|
|
|
const parseByTypeFromPrefix = async (
|
|
message: Message,
|
|
type: CommandArgument["type"],
|
|
token: string,
|
|
): Promise<{ ok: true; value: CommandArgValue } | { ok: false; error: ArgumentParseError }> => {
|
|
switch (type) {
|
|
case "string":
|
|
return { ok: true, value: token };
|
|
|
|
case "int": {
|
|
if (!/^-?\d+$/.test(token)) {
|
|
return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } };
|
|
}
|
|
|
|
const value = Number.parseInt(token, 10);
|
|
return { ok: true, value };
|
|
}
|
|
|
|
case "number": {
|
|
const value = Number(token);
|
|
if (!Number.isFinite(value)) {
|
|
return { ok: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } };
|
|
}
|
|
return { ok: true, value };
|
|
}
|
|
|
|
case "boolean": {
|
|
const normalized = token.toLowerCase();
|
|
if (BOOLEAN_TRUE.has(normalized)) {
|
|
return { ok: true, value: true };
|
|
}
|
|
if (BOOLEAN_FALSE.has(normalized)) {
|
|
return { ok: true, value: false };
|
|
}
|
|
return { ok: false, error: { key: "errors.args.invalidBoolean", vars: { value: token } } };
|
|
}
|
|
|
|
case "user": {
|
|
const mentionMatch = token.match(USER_MENTION_PATTERN);
|
|
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
|
|
const userId = mentionMatch?.[1] ?? idMatch?.[1];
|
|
|
|
if (!userId) {
|
|
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
|
|
}
|
|
|
|
const fromMention = message.mentions.users.get(userId);
|
|
if (fromMention) {
|
|
return { ok: true, value: fromMention };
|
|
}
|
|
|
|
try {
|
|
const fetched = await message.client.users.fetch(userId);
|
|
return { ok: true, value: fetched };
|
|
} catch {
|
|
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
|
|
}
|
|
}
|
|
|
|
case "channel": {
|
|
const mentionMatch = token.match(CHANNEL_MENTION_PATTERN);
|
|
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
|
|
const channelId = mentionMatch?.[1] ?? idMatch?.[1];
|
|
|
|
if (!channelId) {
|
|
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
|
|
}
|
|
|
|
const fromMention = message.mentions.channels.get(channelId);
|
|
if (fromMention && isGuildBasedChannel(fromMention)) {
|
|
return { ok: true, value: fromMention };
|
|
}
|
|
|
|
const fromGuildCache = message.guild?.channels.cache.get(channelId);
|
|
if (fromGuildCache) {
|
|
return { ok: true, value: fromGuildCache };
|
|
}
|
|
|
|
try {
|
|
const fetchedFromGuild = await message.guild?.channels.fetch(channelId);
|
|
if (fetchedFromGuild) {
|
|
return { ok: true, value: fetchedFromGuild };
|
|
}
|
|
} catch {
|
|
// Try the global cache/fetch fallback below.
|
|
}
|
|
|
|
try {
|
|
const fetched = await message.client.channels.fetch(channelId);
|
|
if (fetched && isGuildBasedChannel(fetched)) {
|
|
return { ok: true, value: fetched };
|
|
}
|
|
} catch {
|
|
// Falls through to invalid channel error.
|
|
}
|
|
|
|
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
|
|
}
|
|
|
|
case "role": {
|
|
const mentionMatch = token.match(ROLE_MENTION_PATTERN);
|
|
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
|
|
const roleId = mentionMatch?.[1] ?? idMatch?.[1];
|
|
|
|
if (!roleId || !message.guild) {
|
|
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
|
|
}
|
|
|
|
const fromMention = message.mentions.roles.get(roleId);
|
|
if (fromMention) {
|
|
return { ok: true, value: fromMention };
|
|
}
|
|
|
|
const fromCache = message.guild.roles.cache.get(roleId);
|
|
if (fromCache) {
|
|
return { ok: true, value: fromCache };
|
|
}
|
|
|
|
try {
|
|
const fetched = await message.guild.roles.fetch(roleId);
|
|
if (fetched) {
|
|
return { ok: true, value: fetched };
|
|
}
|
|
} catch {
|
|
// Falls through to invalid role error.
|
|
}
|
|
|
|
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
|
|
}
|
|
|
|
default:
|
|
return { ok: true, value: token };
|
|
}
|
|
};
|
|
|
|
const parseByTypeFromSlash = (
|
|
interaction: ChatInputCommandInteraction,
|
|
definition: CommandArgument,
|
|
): CommandArgValue => {
|
|
switch (definition.type) {
|
|
case "string":
|
|
return interaction.options.getString(definition.name, definition.required) ?? undefined;
|
|
|
|
case "int":
|
|
return interaction.options.getInteger(definition.name, definition.required) ?? undefined;
|
|
|
|
case "number":
|
|
return interaction.options.getNumber(definition.name, definition.required) ?? undefined;
|
|
|
|
case "boolean":
|
|
return interaction.options.getBoolean(definition.name, definition.required) ?? undefined;
|
|
|
|
case "user":
|
|
return interaction.options.getUser(definition.name, definition.required) ?? undefined;
|
|
|
|
case "channel":
|
|
return (interaction.options.getChannel(definition.name, definition.required) ?? undefined) as CommandArgValue;
|
|
|
|
case "role":
|
|
return (interaction.options.getRole(definition.name, definition.required) ?? undefined) as CommandArgValue;
|
|
|
|
default:
|
|
return interaction.options.getString(definition.name, definition.required) ?? undefined;
|
|
}
|
|
};
|