-
Notifications
You must be signed in to change notification settings - Fork 3
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
Changes from all commits
a4b8ab2
347fd5d
555eaa0
9303553
4206590
6c28d4b
174db34
c2a951c
0bab52a
f51a702
fad399b
7220c43
e774923
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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( | ||
"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.`, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we link the COC here? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" }); | ||
}; |
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")} | ||
</> | ||
); | ||
}; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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