Send bans/unbans via webhook

This commit is contained in:
Kalle 2026-07-21 18:14:39 +03:00
parent f33c35c105
commit a66df3c993
5 changed files with 217 additions and 79 deletions

View File

@ -27,8 +27,8 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
// Discord webhook new user reports are posted to (skipped when unset)
USER_REPORT_DISCORD_WEBHOOK_URL=
// Discord webhook mod events (user reports, bans) are posted to (skipped when unset)
MOD_DISCORD_WEBHOOK_URL=
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
SKALOP_TOKEN=secret

View File

@ -8,12 +8,17 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import {
errorToast,
notFoundIfFalsy,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { _action, actualNumber, friendCode } from "~/utils/zod";
import {
sendUserBannedWebhook,
sendUserUnbannedWebhook,
} from "../core/discord-webhook.server";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
export const action = async ({ request }: ActionFunctionArgs) => {
@ -124,21 +129,37 @@ export const action = async ({ request }: ActionFunctionArgs) => {
case "BAN_USER": {
requireRole("STAFF");
const bannedUser = notFoundIfFalsy(
await UserRepository.findLeanById(data.user),
);
const banExpiresAt = data.duration ? new Date(data.duration) : null;
await AdminRepository.banUser({
bannedReason: data.reason ?? null,
userId: data.user,
banned: data.duration ? new Date(data.duration) : 1,
banned: banExpiresAt ?? 1,
bannedByUserId: user.id,
});
await refreshBannedCache();
sendUserBannedWebhook({
bannedUser,
bannedBy: user,
reason: data.reason ?? null,
expiresAt: banExpiresAt,
});
message = "User banned";
break;
}
case "UNBAN_USER": {
requireRole("STAFF");
const unbannedUser = notFoundIfFalsy(
await UserRepository.findLeanById(data.user),
);
await AdminRepository.unbanUser({
userId: data.user,
unbannedByUserId: user.id,
@ -146,6 +167,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await refreshBannedCache();
sendUserUnbannedWebhook({
unbannedUser,
unbannedBy: user,
});
message = "User unbanned";
break;
}

View File

@ -0,0 +1,70 @@
import {
sendModDiscordWebhook,
truncateEmbedValue,
userAdminPageLink,
userPageLink,
type WebhookUser,
} from "~/modules/discord-webhook.server";
/**
* Posts a rich embed about a user getting banned to the mod channel Discord webhook.
* Fire-and-forget (see `sendModDiscordWebhook`).
*/
export function sendUserBannedWebhook(args: {
bannedUser: WebhookUser;
bannedBy: WebhookUser;
reason: string | null;
/** When the ban ends, null when the ban has no end date */
expiresAt: Date | null;
}) {
sendModDiscordWebhook({
title: "User banned",
fields: [
{
name: "Banned user",
value: userAdminPageLink(args.bannedUser),
},
{
name: "Banned by",
value: userPageLink(args.bannedBy),
},
{
name: "Expires",
value: args.expiresAt
? `<t:${Math.floor(args.expiresAt.getTime() / 1000)}:f>`
: "No end date",
},
...(args.reason
? [
{
name: "Reason",
value: truncateEmbedValue(args.reason),
},
]
: []),
],
});
}
/**
* Posts a rich embed about a user getting unbanned to the mod channel Discord webhook.
* Fire-and-forget (see `sendModDiscordWebhook`).
*/
export function sendUserUnbannedWebhook(args: {
unbannedUser: WebhookUser;
unbannedBy: WebhookUser;
}) {
sendModDiscordWebhook({
title: "User unbanned",
fields: [
{
name: "Unbanned user",
value: userAdminPageLink(args.unbannedUser),
},
{
name: "Unbanned by",
value: userPageLink(args.unbannedBy),
},
],
});
}

View File

@ -1,93 +1,58 @@
import type { UserReportCategory } from "~/db/tables";
import { logger } from "~/utils/logger";
import { SENDOU_INK_BASE_URL, sendouQMatchPage, userPage } from "~/utils/urls";
import {
sendModDiscordWebhook,
truncateEmbedValue,
userAdminPageLink,
userPageLink,
type WebhookUser,
} from "~/modules/discord-webhook.server";
import { SENDOU_INK_BASE_URL, sendouQMatchPage } from "~/utils/urls";
import { USER_REPORT_CATEGORY_LABELS } from "../user-report-constants";
const EMBED_DESCRIPTION_MAX_LENGTH = 1000;
/**
* Posts a rich embed about a new/updated user report to the mod channel Discord webhook.
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
* line when `USER_REPORT_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
* Fire-and-forget (see `sendModDiscordWebhook`).
*/
export function sendUserReportWebhook(args: {
reportedUser: { id: number; username: string };
reporter: { username: string; discordId: string; customUrl: string | null };
reportedUser: WebhookUser;
reporter: WebhookUser;
category: UserReportCategory;
description: string;
matchId: number | null;
isUpdate: boolean;
reportCounts: { lastMonth: number; lastYear: number };
}) {
const webhookUrl = process.env.USER_REPORT_DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
logger.info(
"USER_REPORT_DISCORD_WEBHOOK_URL not set, skipping user report webhook",
);
return;
}
const reportedUserAdminUrl = `${SENDOU_INK_BASE_URL}/u/${args.reportedUser.id}/admin`;
const reporterUrl = `${SENDOU_INK_BASE_URL}${userPage(args.reporter)}`;
const body = {
embeds: [
sendModDiscordWebhook({
title: args.isUpdate ? "User report updated" : "New user report",
fields: [
{
title: args.isUpdate ? "User report updated" : "New user report",
fields: [
{
name: "Reported user",
value: `[${args.reportedUser.username}](${reportedUserAdminUrl})`,
},
{
name: "Reporter",
value: `[${args.reporter.username}](${reporterUrl})`,
},
{
name: "Category",
value: USER_REPORT_CATEGORY_LABELS[args.category],
},
{
name: "Description",
value: truncate(args.description),
},
...(args.matchId !== null
? [
{
name: "SendouQ match",
value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`,
},
]
: []),
{
name: "Reports against this user",
value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`,
},
],
timestamp: new Date().toISOString(),
name: "Reported user",
value: userAdminPageLink(args.reportedUser),
},
{
name: "Reporter",
value: userPageLink(args.reporter),
},
{
name: "Category",
value: USER_REPORT_CATEGORY_LABELS[args.category],
},
{
name: "Description",
value: truncateEmbedValue(args.description),
},
...(args.matchId !== null
? [
{
name: "SendouQ match",
value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`,
},
]
: []),
{
name: "Reports against this user",
value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`,
},
],
};
fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((response) => {
if (!response.ok) {
logger.error(
`User report webhook responded with status ${response.status}`,
);
}
})
.catch((error) => {
logger.error("Failed to send user report webhook", error);
});
}
function truncate(description: string) {
if (description.length <= EMBED_DESCRIPTION_MAX_LENGTH) return description;
return `${description.slice(0, EMBED_DESCRIPTION_MAX_LENGTH)}`;
});
}

View File

@ -0,0 +1,77 @@
import { logger } from "~/utils/logger";
import { SENDOU_INK_BASE_URL, userAdminPage, userPage } from "~/utils/urls";
const EMBED_FIELD_VALUE_MAX_LENGTH = 1000;
interface ModWebhookEmbed {
title: string;
fields: Array<{ name: string; value: string }>;
}
export interface WebhookUser {
username: string;
discordId: string;
customUrl: string | null;
}
/**
* Posts a rich embed to the mod channel Discord webhook.
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
* line when `MOD_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
*/
export function sendModDiscordWebhook(embed: ModWebhookEmbed) {
const webhookUrl = process.env.MOD_DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
logger.info(
"MOD_DISCORD_WEBHOOK_URL not set, skipping mod Discord webhook",
);
return;
}
const body = {
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
allowed_mentions: { parse: [] },
};
fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((response) => {
if (!response.ok) {
logger.error(
`Mod Discord webhook responded with status ${response.status}`,
);
}
})
.catch((error) => {
logger.error("Failed to send mod Discord webhook", error);
});
}
/**
* Escapes then truncates free-text user input so it fits inside a Discord embed field
* value without breaking the embed's markdown formatting.
*/
export function truncateEmbedValue(text: string) {
const escaped = escapeMarkdown(text);
if (escaped.length <= EMBED_FIELD_VALUE_MAX_LENGTH) return escaped;
return `${escaped.slice(0, EMBED_FIELD_VALUE_MAX_LENGTH)}`;
}
/** Markdown link to the user's admin tab (moderation actions & notes). */
export function userAdminPageLink(user: WebhookUser) {
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userAdminPage(user)})`;
}
/** Markdown link to the user's profile page. */
export function userPageLink(user: WebhookUser) {
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userPage(user)})`;
}
/** Backslash-escapes Discord markdown so user text can't forge links or break formatting. */
function escapeMarkdown(text: string) {
return text.replace(/[\\`*_~|()[\]]/g, "\\$&");
}