Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Convene mods" command #19

Merged
merged 13 commits into from
Sep 12, 2022
3 changes: 2 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ jobs:
--from-literal=DISCORD_PUBLIC_KEY=${{ secrets.DISCORD_PUBLIC_KEY }} \
--from-literal=DISCORD_APP_ID=${{ secrets.DISCORD_APP_ID }} \
--from-literal=DISCORD_SECRET=${{ secrets.DISCORD_SECRET }} \
--from-literal=DISCORD_HASH=${{ secrets.DISCORD_HASH }}
--from-literal=DISCORD_HASH=${{ secrets.DISCORD_HASH }} \
--from-literal=DISCORD_TEST_GUILD=${{ secrets.DISCORD_TEST_GUILD }}
kubectl apply -k .

- name: Set Sentry release
Expand Down
6 changes: 3 additions & 3 deletions .husky/post-merge
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ check_run() {
echo "$changed_files" | grep --quiet "$1" && eval "$2"
}

check_run yarn.lock 'echo "Deps have changed, run `yarn`"'
check_run migrations/.* 'echo "Migrations have changed, run `yarn knex migrate:latest`"'
check_run seeds/.* 'echo "Seeds have changed, run `yarn 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 knex migrate:latest`"'
check_run seeds/.* 'echo "Seeds have changed, run `npm run knex seed:run`"'
2 changes: 1 addition & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn validate
npm run validate
158 changes: 158 additions & 0 deletions app/commands/convene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { format } from "date-fns";
import { Message } from "discord.js";
import type { MessageContextMenuInteraction, TextChannel } from "discord.js";
import { ContextMenuCommandBuilder } from "@discordjs/builders";
import { ApplicationCommandType } from "discord-api-types/v10";

import { reacord } from "~/discord/client";
import { quoteAndEscape } from "~/helpers/discord";
import { ReportReasons, reportUser } from "~/helpers/modLog";
import { resolutions } from "~/helpers/modResponse";

import { fetchSettings, SETTINGS } from "~/models/guilds.server";
import { applyRestriction, ban, kick, timeout } from "~/models/discord.server";
import { ModResponse } from "~/commands/reacord/ModResponse";

export const command = new ContextMenuCommandBuilder()
.setName("Convene mods")
.setType(ApplicationCommandType.Message);

export const handler = async (interaction: MessageContextMenuInteraction) => {
const { targetMessage: message, member, guild } = interaction;
if (!(message instanceof Message) || !member || !guild) {
return;
}

const { modLog, moderator } = await fetchSettings(guild, [
SETTINGS.modLog,
SETTINGS.moderator,
]);

const logChannel = (await guild.channels.fetch(modLog)) as TextChannel;
if (!logChannel || !logChannel.isText()) {
throw new Error("Failed to load mod channel");
}

const { message: logMessage } = await reportUser({
message,
reason: ReportReasons.mod,
extra: `‼️ <@${interaction.user.id}> requested mods respond`,
});

if (logMessage.hasThread) {
return;
}

const thread = await logMessage.startThread({
name: `${message.author.username} mod response ${format(new Date(), "P")}`,
});
const originalChannel = (await message.channel.fetch()) as TextChannel;
const instance = await reacord.send(
thread.id,
<ModResponse
modRoleId={moderator}
onResolve={async (resolution) => {
instance.deactivate();
switch (resolution) {
case resolutions.restrict:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Restricted",
});
await applyRestriction(message.member!);
message.reply(

Choose a reason for hiding this comment

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

This is only useful if they're misbehaving in a restricted channel, yeah?

Copy link
Member Author

Choose a reason for hiding this comment

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

In the immediate term sure, but I'm thinking that there will be a lively conversation in these threads before the outcome is determined. Figure this can be a replacement for us talking in private chats about if someone has earned a restriction

"After a vote by the mods, this member has had restrictions applied to them",
);
return;
case resolutions.kick:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Kicked",
});

await kick(message.member!);
message.reply(
"After a vote by the mods, this member has been kicked from the server to cool off",
);
return;
case resolutions.ban:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Banned",
});

await ban(message.member!);
message.reply(
"After a vote by the mods, this member has been permanently banned",
);
return;
case resolutions.nudge:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Nudge",
});

const thread = await originalChannel.threads.create({
name: message.author.username,
autoArchiveDuration: 60,
// TODO: This won't work in servers that aren't at boost level 2
// Maybe could create a thread and ensure the "thread created" message is removed? honestly that's pretty invisible to anyone who isn't trawling through threads proactively
type: guild.features.includes("PRIVATE_THREADS")
? "GUILD_PRIVATE_THREAD"
: "GUILD_PUBLIC_THREAD",
reason: "Private moderation thread",
});
const [{ moderator: modRoleId }] = await Promise.all([
fetchSettings(message.guild!, [SETTINGS.moderator]),
thread.members.add(message.author),
]);
await thread.send(`The <@&${modRoleId}> team has determined that the following message is not okay in the community.

This isn't a formal warning, but your message concerned the moderators enough that they felt it necessary to intervene. This message was sent by a bot, but all moderators can view this thread and are available to discuss what concerned them.

${quoteAndEscape(message.content)}`);
return;
case resolutions.warning:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Warning",
});
message.reply(
`This message resulted in a formal warning from the moderators. Please review the community rules.`,

Choose a reason for hiding this comment

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

Can we link the COC here?

Copy link
Member Author

Choose a reason for hiding this comment

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

I want to do that, but this is already used in 3 servers so I need to add a means of configuring what url to use. Not a blocker for release I think, I want to improve onboarding for the bot and that's going to be a part

);
return;

case resolutions.okay:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Determined to be okay",
});
return;

case resolutions.track:
reportUser({ reason: ReportReasons.track, message });
return;

case resolutions.timeout:
reportUser({
reason: ReportReasons.mod,
message,
extra: "✅ Timed out overnight",
});
timeout(message.member!);

return;
}
}}
/>,
);

// reply
await interaction.reply({ ephemeral: true, content: "Notification sent" });
};
62 changes: 62 additions & 0 deletions app/commands/reacord/ModResponse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Button } from "reacord";

import type { Resolution } from "~/helpers/modResponse";
import { resolutions, useVotes } from "~/helpers/modResponse";

const VOTES_TO_APPROVE = 3;

export const ModResponse = ({
votesRequired = VOTES_TO_APPROVE,
onResolve,
modRoleId,
}: {
votesRequired?: number;
onResolve: (result: Resolution) => void;
modRoleId: string;
}) => {
const { votes, recordVote } = useVotes();

const renderButton = (
votes: Record<Resolution, string[]>,
resolution: Resolution,
label: string,
style: "secondary" | "primary" | "success" | "danger" = "secondary",
) => (
<Button
label={label}
style={style}
onClick={(event) => {
const { leader, voteCount } = recordVote(
votes,
resolution,
event.user.id,
);

if (leader && voteCount >= votesRequired) {
onResolve(leader);
}
}}
/>
);

return (
<>
{`<@&${modRoleId}> after ${votesRequired} or more votes, the leading resolution will be automatically enforced.
${Object.entries(votes)
.map(
([resolution, voterIds]) =>
`${resolution}: ${voterIds.map((id) => `<@${id}>`)}`,
)
.join("\n")}`}
{/* TODO: show vote in progress, reveal votes and unvoted mods */}
{renderButton(votes, resolutions.okay, "Okay", "success")}
{renderButton(votes, resolutions.track, "Track")}
{renderButton(votes, resolutions.timeout, "Timeout")}
{renderButton(votes, resolutions.nudge, "Nudge", "primary")}
{renderButton(votes, resolutions.warning, "Formal Warning")}
{renderButton(votes, resolutions.restrict, "Restrict")}
{renderButton(votes, resolutions.kick, "Kick")}
{renderButton(votes, resolutions.ban, "Ban", "danger")}
</>
);
};
2 changes: 1 addition & 1 deletion app/discord/automod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default async (bot: Client) => {
if (isSpam(msg.content)) {
msg.delete();

const warnings = await reportUser({
const { warnings } = await reportUser({
reason: ReportReasons.spam,
message: msg,
});
Expand Down
3 changes: 3 additions & 0 deletions app/discord/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Intents, Client } from "discord.js";
import { ReacordDiscordJs } from "reacord";

export const client = new Client({
intents: [
Expand All @@ -13,6 +14,8 @@ export const client = new Client({
partials: ["MESSAGE", "CHANNEL", "REACTION"],
});

export const reacord = new ReacordDiscordJs(client);

export const login = () => {
console.log("INI", "Bootstrap starting…");
client
Expand Down
2 changes: 2 additions & 0 deletions app/discord/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
import automod from "~/discord/automod";
import onboardGuild from "~/discord/onboardGuild";

import * as convene from "~/commands/convene";
import * as setup from "~/commands/setup";
import * as report from "~/commands/report";
import * as track from "~/commands/track";

registerCommand(convene);
registerCommand(setup);
registerCommand(report);
registerCommand(track);
Expand Down
21 changes: 17 additions & 4 deletions app/helpers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ const enum ENVIONMENTS {
production = "production",
}

export const applicationKey = process.env.DISCORD_PUBLIC_KEY ?? "";
export const applicationId = process.env.DISCORD_APP_ID ?? "";
export const discordToken = process.env.DISCORD_HASH ?? "";
let ok = true;
const getEnv = (key: string, optional = false) => {
const value = process.env[key];
if (!value && !optional) {
console.log(`Add a ${key} value to .env`);
ok = false;
return "";
}
return value ?? "";
};

export const isProd = () => process.env.ENVIRONMENT === ENVIONMENTS.production;

console.log("Running as", isProd() ? "PRODUCTION" : "TEST", "environment");

export const applicationKey = getEnv("DISCORD_PUBLIC_KEY");
export const applicationId = getEnv("DISCORD_APP_ID");
export const discordToken = getEnv("DISCORD_HASH");
export const testGuild = getEnv("DISCORD_TEST_GUILD");

if (!ok) throw new Error("Environment misconfigured");
45 changes: 23 additions & 22 deletions app/helpers/modLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,56 +52,57 @@ export const reportUser = async ({
)}`;
const cached = warningMessages.get(simplifiedContent);

const { moderator: moderatorId } = await fetchSettings(guild, [
SETTINGS.moderator,
]);

const staffRole = (await guild.roles.fetch(moderatorId)) as Role;

const logBody = constructLog({
const logBody = await constructLog({
reason,
message,
staffRole,
extra,
staff,
members,
});

if (cached) {
// If we already logged for ~ this message, edit the log
const { message, warnings: oldWarnings } = cached;
const { message: cachedMessage, warnings: oldWarnings } = cached;
const warnings = oldWarnings + 1;

const finalLog =
logBody.content?.replace(/warned \d times/, `warned ${warnings} times`) ||
"";

message.edit(finalLog);
warningMessages.set(simplifiedContent, { warnings, message });
return warnings;
cachedMessage.edit(finalLog);
warningMessages.set(simplifiedContent, {
warnings,
message: cachedMessage,
});
return { warnings, message: cachedMessage };
} else {
// If this is new, send a new message
const { modLog: modLogId } = await fetchSettings(guild, [SETTINGS.modLog]);

const modLog = (await guild.channels.fetch(modLogId)) as TextChannel;
modLog.send(logBody).then((warningMessage) => {
warningMessages.set(simplifiedContent, {
warnings: 1,
message: warningMessage,
});

const warningMessage = await modLog.send(logBody);

warningMessages.set(simplifiedContent, {
warnings: 1,
message: warningMessage,
});
return 1;
return { warnings: 1, message: warningMessage };
}
};

const constructLog = ({
export const constructLog = async ({
reason,
message,
staffRole,
extra: origExtra = "",
staff = [],
members = [],
}: Report & { staffRole: Role }): MessageOptions => {
}: Report): Promise<MessageOptions> => {
const { moderator: moderatorId } = await fetchSettings(message.guild!, [
SETTINGS.moderator,
]);

const staffRole = (await message.guild!.roles.fetch(moderatorId)) as Role;

const modAlert = `<@${staffRole.id}>`;
const preface = `<@${message.author.id}> in <#${message.channel.id}> warned 1 times`;
const extra = origExtra ? `\n${origExtra}\n` : "";
Expand Down