diff --git a/.gitignore b/.gitignore index 1824790..fb23086 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ tsconfig.tsbuildinfo .react-router tailwind.css userInfoCache.json -vite.config.ts* +*timestamp* diff --git a/.husky/post-merge b/.husky/post-merge index 3094e9a..3d3c2bc 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -7,6 +7,6 @@ check_run() { echo "$changed_files" | grep --quiet "$1" && eval "$2" } -check_run package-lock.json 'echo "Deps have changed, run `npm i`"' -check_run migrations/.* 'echo "Migrations have changed, run `npm run knex migrate:latest`"' -check_run seeds/.* 'echo "Seeds have changed, run `npm run knex seed:run`"' +check_run package-lock.json 'echo "Deps have changed, run \`npm i\`"' +check_run migrations/.* 'echo "Migrations have changed, run \`npm run start:migrate\`"' +check_run seeds/.* 'echo "Seeds have changed, run \`npm run kysely:seed\`"' diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 34658d3..67eeb81 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -1,8 +1,5 @@ export default { - "**/*.[tj]s?(x)": [ - "eslint --no-warn-ignored --fix --max-warnings=0", - "prettier --check", - ], + "**/*.[tj]s?(x)": ["npm run format:fix", "npm run lint:fix"], "migrations/*.[tj]s": [ "npm run start:migrate", "npm run generate:db-types", diff --git a/README.md b/README.md index 2b1dad9..5dee18e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ npm i npm run dev ``` +## Development + +The dev server uses Vite for intelligent hot reloading: + +- **Frontend changes** (React components, routes, styles): Vite HMR updates instantly (<1s) without restarting the server +- **Server changes** (Discord bot, commands, helpers, models): Vite automatically reloads the server module to apply changes + +Just run `npm run dev` and edit any file - the right reload strategy is applied automatically! + ## Tech - [Remix](https://remix.run/docs/en/v1) diff --git a/app/basics/login.tsx b/app/basics/login.tsx index 8b4b0b3..6dc968e 100644 --- a/app/basics/login.tsx +++ b/app/basics/login.tsx @@ -1,8 +1,8 @@ -import { Form } from "react-router"; import type { ButtonHTMLAttributes } from "react"; +import { Form } from "react-router"; interface LoginProps extends ButtonHTMLAttributes { - errors?: { [k: string]: string }; + errors?: Record; redirectTo?: string; } diff --git a/app/basics/logout.tsx b/app/basics/logout.tsx index b4e71a4..a1cf729 100644 --- a/app/basics/logout.tsx +++ b/app/basics/logout.tsx @@ -1,5 +1,5 @@ -import { Form } from "react-router"; import type { ButtonHTMLAttributes } from "react"; +import { Form } from "react-router"; type LoginProps = ButtonHTMLAttributes; diff --git a/app/commands/demo.ts b/app/commands/demo.ts index 0176544..719b525 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -1,6 +1,9 @@ -import type { CommandInteraction } from "discord.js"; -import { ContextMenuCommandBuilder, SlashCommandBuilder } from "discord.js"; import { ApplicationCommandType } from "discord-api-types/v10"; +import { + ContextMenuCommandBuilder, + SlashCommandBuilder, + type CommandInteraction, +} from "discord.js"; export const command = new SlashCommandBuilder() .setName("demo") diff --git a/app/commands/escalationControls.ts b/app/commands/escalationControls.ts index b67849e..e5c8d15 100644 --- a/app/commands/escalationControls.ts +++ b/app/commands/escalationControls.ts @@ -1,8 +1,9 @@ import { InteractionType, PermissionsBitField } from "discord.js"; + import type { MessageComponentCommand } from "#~/helpers/discord"; +import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; import { deleteAllReportedForUser } from "#~/models/reportedMessages.server"; -import { timeout, ban, kick, applyRestriction } from "#~/models/discord.server"; export const EscalationCommands = [ { @@ -259,4 +260,4 @@ export const EscalationCommands = [ } }, }, -] as Array; +] as MessageComponentCommand[]; diff --git a/app/commands/force-ban.ts b/app/commands/force-ban.ts index cae243e..36c44f4 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -1,9 +1,12 @@ -import { UserContextMenuCommandInteraction } from "discord.js"; - -import { PermissionFlagsBits, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType } from "discord-api-types/v10"; -import { log, trackPerformance } from "#~/helpers/observability"; +import { + ContextMenuCommandBuilder, + PermissionFlagsBits, + type UserContextMenuCommandInteraction, +} from "discord.js"; + import { commandStats } from "#~/helpers/metrics"; +import { log, trackPerformance } from "#~/helpers/observability"; const command = new ContextMenuCommandBuilder() .setName("Force Ban") diff --git a/app/commands/reacord/ModResponse.tsx b/app/commands/reacord/ModResponse.tsx index 906a0b8..f4b0499 100644 --- a/app/commands/reacord/ModResponse.tsx +++ b/app/commands/reacord/ModResponse.tsx @@ -1,11 +1,15 @@ -import type { ComponentEventUser, ComponentEvent } from "reacord"; -import { Button, ActionRow } from "reacord"; +import { + ActionRow, + Button, + type ComponentEvent, + type ComponentEventUser, +} from "reacord"; -import type { Resolution } from "#~/helpers/modResponse"; import { humanReadableResolutions, resolutions, useVotes, + type Resolution, } from "#~/helpers/modResponse"; const VOTES_TO_APPROVE = 3; @@ -33,7 +37,7 @@ export const ModResponse = ({ label={label} style={style} onClick={async (event) => { - if (!event.guild?.member.roles?.includes(modRoleId)) { + if (!event.guild?.member.roles.includes(modRoleId)) { return; } try { @@ -47,6 +51,7 @@ export const ModResponse = ({ resolution, event.user.id, ); + console.log( `recording vote for ${resolution} from ${event.user.username}. ${leader} leads with ${voteCount} (needs ${votesRequired})`, ); diff --git a/app/commands/report.ts b/app/commands/report.ts index e252170..9800ca7 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -1,10 +1,14 @@ -import type { MessageContextMenuCommandInteraction } from "discord.js"; -import { PermissionFlagsBits, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType } from "discord-api-types/v10"; +import { + ContextMenuCommandBuilder, + PermissionFlagsBits, + type MessageContextMenuCommandInteraction, +} from "discord.js"; + +import { commandStats } from "#~/helpers/metrics"; import { reportUser } from "#~/helpers/modLog"; -import { ReportReasons } from "#~/models/reportedMessages.server"; import { log, trackPerformance } from "#~/helpers/observability"; -import { commandStats } from "#~/helpers/metrics"; +import { ReportReasons } from "#~/models/reportedMessages.server"; const command = new ContextMenuCommandBuilder() .setName("Report") @@ -20,7 +24,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { log("info", "Commands", "Report command executed", { guildId: interaction.guildId, reporterUserId: interaction.user.id, - targetUserId: message.author?.id, + targetUserId: message.author.id, targetMessageId: message.id, channelId: interaction.channelId, }); @@ -35,16 +39,13 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { log("info", "Commands", "Report submitted successfully", { guildId: interaction.guildId, reporterUserId: interaction.user.id, - targetUserId: message.author?.id, + targetUserId: message.author.id, targetMessageId: message.id, reason: ReportReasons.anonReport, }); // Track successful report in business analytics - commandStats.reportSubmitted( - interaction, - message.author?.id ?? "unknown", - ); + commandStats.reportSubmitted(interaction, message.author.id); // Track command success commandStats.commandExecuted(interaction, "report", true); @@ -59,7 +60,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { log("error", "Commands", "Report command failed", { guildId: interaction.guildId, reporterUserId: interaction.user.id, - targetUserId: message.author?.id, + targetUserId: message.author.id, targetMessageId: message.id, error: err.message, stack: err.stack, @@ -78,7 +79,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { commandName: "report", guildId: interaction.guildId, reporterUserId: interaction.user.id, - targetUserId: interaction.targetMessage.author?.id, + targetUserId: interaction.targetMessage.author.id, }, ); }; diff --git a/app/commands/setup.ts b/app/commands/setup.ts index 5b5a81c..e530070 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -1,9 +1,12 @@ -import type { ChatInputCommandInteraction } from "discord.js"; -import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { + PermissionFlagsBits, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; -import { SETTINGS, setSettings, registerGuild } from "#~/models/guilds.server"; -import { log, trackPerformance } from "#~/helpers/observability"; import { commandStats } from "#~/helpers/metrics"; +import { log, trackPerformance } from "#~/helpers/observability"; +import { registerGuild, setSettings, SETTINGS } from "#~/models/guilds.server"; const command = new SlashCommandBuilder() .setName("setup") diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index b5d7d18..9ba605a 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -1,31 +1,32 @@ -import { TextChannel, type ChatInputCommandInteraction } from "discord.js"; +import { format } from "date-fns"; +import { Routes, TextInputStyle } from "discord-api-types/v10"; import { - ChannelType, - ComponentType, ActionRowBuilder, ButtonBuilder, ButtonStyle, - PermissionFlagsBits, - SlashCommandBuilder, - MessageFlags, + ChannelType, + ComponentType, InteractionType, + MessageFlags, ModalBuilder, + PermissionFlagsBits, + SlashCommandBuilder, TextInputBuilder, + type ChatInputCommandInteraction, } from "discord.js"; + import { REST } from "@discordjs/rest"; -import { Routes, TextInputStyle } from "discord-api-types/v10"; -import { discordToken } from "#~/helpers/env.server"; -import { SETTINGS, fetchSettings } from "#~/models/guilds.server"; -import { format } from "date-fns"; -import type { - AnyCommand, - MessageComponentCommand, - ModalCommand, - SlashCommand, -} from "#~/helpers/discord"; -import { quoteMessageContent } from "#~/helpers/discord"; import db from "#~/db.server.js"; +import { + quoteMessageContent, + type AnyCommand, + type MessageComponentCommand, + type ModalCommand, + type SlashCommand, +} from "#~/helpers/discord"; +import { discordToken } from "#~/helpers/env.server"; +import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; const rest = new REST({ version: "10" }).setToken(discordToken); @@ -70,7 +71,7 @@ export const Command = [ const pingableRole = interaction.options.getRole("role"); const ticketChannel = interaction.options.getChannel("channel"); const buttonText = - interaction.options.getString("button-text") || DEFAULT_BUTTON_TEXT; + interaction.options.getString("button-text") ?? DEFAULT_BUTTON_TEXT; if (ticketChannel && ticketChannel.type !== ChannelType.GuildText) { await interaction.reply({ @@ -146,7 +147,6 @@ export const Command = [ if ( !interaction.channel || interaction.channel.type !== ChannelType.GuildText || - !interaction.user || !interaction.guild || !interaction.message ) { @@ -157,7 +157,7 @@ export const Command = [ return; } const { channel, fields, user } = interaction; - const concern = fields.getField("concern").value; + const concern = fields.getTextInputValue("concern"); let config = await db .selectFrom("tickets_config") @@ -180,12 +180,22 @@ export const Command = [ } } + // If channel_id is configured but fetch returns null (channel deleted), + // this will error, which is intended - the configured channel is invalid const ticketsChannel = config.channel_id - ? ((await interaction.guild.channels.fetch( - config.channel_id, - )) as TextChannel) || channel + ? await interaction.guild.channels.fetch(config.channel_id) : channel; + if ( + !ticketsChannel?.isTextBased() || + ticketsChannel.type !== ChannelType.GuildText + ) { + void interaction.reply( + "Couldn’t make a ticket! Tell the admins that their ticket channel is misconfigured.", + ); + return; + } + const thread = await ticketsChannel.threads.create({ name: `${user.username} – ${format(new Date(), "PP kk:mmX")}`, autoArchiveDuration: 60 * 24 * 7, @@ -218,7 +228,7 @@ ${quoteMessageContent(concern)}`); ], }); - interaction.reply({ + void interaction.reply({ content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, ephemeral: true, }); @@ -267,4 +277,4 @@ ${quoteMessageContent(concern)}`); return; }, } as MessageComponentCommand, -] as Array; +] as AnyCommand[]; diff --git a/app/commands/track.tsx b/app/commands/track.tsx index 05813b9..09875a9 100644 --- a/app/commands/track.tsx +++ b/app/commands/track.tsx @@ -1,13 +1,16 @@ -import type { MessageContextMenuCommandInteraction } from "discord.js"; -import { PermissionFlagsBits, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType } from "discord-api-types/v10"; +import { + ContextMenuCommandBuilder, + PermissionFlagsBits, + type MessageContextMenuCommandInteraction, +} from "discord.js"; import { Button } from "reacord"; -import { reacord } from "#~/discord/client.server"; +import { reacord } from "#~/discord/client.server"; import { reportUser } from "#~/helpers/modLog"; import { - ReportReasons, markMessageAsDeleted, + ReportReasons, } from "#~/models/reportedMessages.server"; const command = new ContextMenuCommandBuilder() @@ -24,7 +27,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { staff: user, }); - const instance = await reacord.ephemeralReply( + const instance = reacord.ephemeralReply( interaction, <> Tracked @@ -46,7 +49,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { content: `deleted by ${user.username}`, }), ]); - instance.render(`Tracked ${thread ? `<#${thread.id}>` : ""}`); + instance.render(`Tracked <#${thread.id}>`); }} /> , diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index c70bf85..c35ac35 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -1,18 +1,19 @@ import { useState } from "react"; import { Link, useLocation, useParams } from "react-router"; -import { useUser } from "#~/utils"; + import { Logout } from "#~/basics/logout"; +import { useUser } from "#~/utils"; interface DiscordLayoutProps { children: React.ReactNode; rightPanel?: React.ReactNode; - guilds: Array<{ + guilds: { id: string; name: string; icon?: string; hasBot: boolean; authz: string[]; - }>; + }[]; } export function DiscordLayout({ diff --git a/app/components/GuildSettingsForm.tsx b/app/components/GuildSettingsForm.tsx index b8e46ae..59768f6 100644 --- a/app/components/GuildSettingsForm.tsx +++ b/app/components/GuildSettingsForm.tsx @@ -1,4 +1,5 @@ import { Form } from "react-router"; + import type { GuildRole, ProcessedChannel } from "#~/helpers/guildData.server"; export function GuildSettingsForm({ @@ -34,7 +35,7 @@ export function GuildSettingsForm({ id="moderator_role" name="moderator_role" required - defaultValue={defaultValues?.moderatorRole || ""} + defaultValue={defaultValues?.moderatorRole ?? ""} className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-black shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" > @@ -72,7 +73,7 @@ export function GuildSettingsForm({ id="mod_log_channel" name="mod_log_channel" required - defaultValue={defaultValues?.modLogChannel || ""} + defaultValue={defaultValues?.modLogChannel ?? ""} className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-black shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" > @@ -121,7 +122,7 @@ export function GuildSettingsForm({