mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-26 05:06:03 -05:00
Send bans/unbans via webhook
This commit is contained in:
parent
f33c35c105
commit
a66df3c993
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
70
app/features/admin/core/discord-webhook.server.ts
Normal file
70
app/features/admin/core/discord-webhook.server.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
@ -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)}…`;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
77
app/modules/discord-webhook.server.ts
Normal file
77
app/modules/discord-webhook.server.ts
Normal 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, "\\$&");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user