- /security settings, joingate check|account-age, words add|remove|list, raid, antinuke set|limit, heat, alert-dms - Same rule as the rest of the configuration: owner or trusted, validated in the engine - schema.sql was missing security_settings.alert_dm, so the bot couldn't read security settings on a database built from it. Fixed, with an ALTER for existing installs - A test checks every command against Discord's limits Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
580 lines
19 KiB
Go
580 lines
19 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"velox-bot/internal/db/repos/securityrepo"
|
|
"velox-bot/internal/security"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
// Every handler re-checks permissions through the security engine. The
|
|
// DefaultMemberPermissions below only hide commands from the menu; server
|
|
// admins can change them, so they are never what keeps anyone out.
|
|
|
|
var (
|
|
manageGuild = int64(discordgo.PermissionManageGuild)
|
|
manageChannel = int64(discordgo.PermissionManageChannels)
|
|
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
|
|
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}
|
|
chatChannels = []discordgo.ChannelType{
|
|
discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
|
|
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice,
|
|
}
|
|
opTimeout = 3 * time.Minute
|
|
genericFailed = "Something went wrong. Check that Velox's role is high enough and has the permissions it needs, then try again."
|
|
)
|
|
|
|
var featureChoices = []*discordgo.ApplicationCommandOptionChoice{
|
|
{Name: "Join Gate", Value: securityrepo.FeatureJoinGate},
|
|
{Name: "Raid mode", Value: securityrepo.FeatureAntiRaid},
|
|
{Name: "Anti-nuke", Value: securityrepo.FeatureAntiNuke},
|
|
{Name: "Heat (anti-spam)", Value: securityrepo.FeatureHeat},
|
|
}
|
|
|
|
var Security = &discordgo.ApplicationCommand{
|
|
Name: "security",
|
|
Description: "Anti-raid and anti-nuke protection",
|
|
DefaultMemberPermissions: &manageGuild,
|
|
Contexts: guildOnly,
|
|
Options: append([]*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionString, Name: "feature", Description: "Which protection", Required: true, Choices: featureChoices},
|
|
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "on", Description: "On or off", Required: true},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "alerts", Description: "Where alerts go and which role they ping (owner or trusted)",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Alert channel", Required: true, ChannelTypes: textChannels},
|
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Role to ping for raids and nukes"},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantine-role", Description: "Role given to quarantined members (owner or trusted)",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Leave empty to use none"},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: "trust", Description: "Who is exempt from anti-nuke (owner only)",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Trust a user or a role",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
|
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Stop trusting a user or a role",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
|
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "release", Description: "Release a quarantined member (owner or trusted)",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to release", Required: true},
|
|
},
|
|
},
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantined", Description: "List quarantined members"},
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"},
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
|
|
}, tuneOptions()...),
|
|
}
|
|
|
|
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
|
|
verb := "Lock"
|
|
if unlock {
|
|
verb = "Unlock"
|
|
}
|
|
joins := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "joins", Description: verb + " joins"}
|
|
if !unlock {
|
|
joins.Description = "Kick or ban everyone who joins until unlocked"
|
|
joins.Options = []*discordgo.ApplicationCommandOption{{
|
|
Type: discordgo.ApplicationCommandOptionString, Name: "action", Description: "What happens to new members", Required: true,
|
|
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "Kick", Value: securityrepo.ActionKick}, {Name: "Ban", Value: securityrepo.ActionBan}},
|
|
}}
|
|
}
|
|
return []*discordgo.ApplicationCommandOption{
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "channel", Description: verb + " one channel",
|
|
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Defaults to this channel", ChannelTypes: chatChannels}},
|
|
},
|
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "server", Description: verb + " every channel"},
|
|
joins,
|
|
}
|
|
}
|
|
|
|
var Lockdown = &discordgo.ApplicationCommand{
|
|
Name: "lockdown", Description: "Stop members from talking, or from joining", DefaultMemberPermissions: &manageChannel,
|
|
Contexts: guildOnly, Options: lockOptions(false),
|
|
}
|
|
|
|
var Unlock = &discordgo.ApplicationCommand{
|
|
Name: "unlock", Description: "Undo a lockdown (owner or trusted)", DefaultMemberPermissions: &manageChannel,
|
|
Contexts: guildOnly, Options: lockOptions(true),
|
|
}
|
|
|
|
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
|
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
|
|
})
|
|
}
|
|
|
|
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
|
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
|
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
|
}) == nil
|
|
}
|
|
|
|
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
|
|
}
|
|
|
|
func message(where string, err error) string {
|
|
var ue security.UserError
|
|
if errors.As(err, &ue) {
|
|
return string(ue)
|
|
}
|
|
log.Printf("security command %s: %v", where, err)
|
|
return genericFailed
|
|
}
|
|
|
|
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
|
|
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
|
|
for _, x := range o {
|
|
m[x.Name] = x
|
|
}
|
|
return m
|
|
}
|
|
|
|
func perms(i *discordgo.InteractionCreate) int64 {
|
|
if i.Member == nil {
|
|
return 0
|
|
}
|
|
return i.Member.Permissions
|
|
}
|
|
|
|
func userID(i *discordgo.InteractionCreate) string {
|
|
if i.Member == nil || i.Member.User == nil {
|
|
return ""
|
|
}
|
|
return i.Member.User.ID
|
|
}
|
|
|
|
func engine(s *discordgo.Session, i *discordgo.InteractionCreate) *security.Engine {
|
|
e := security.Default()
|
|
if e == nil || i.GuildID == "" || i.Member == nil {
|
|
reply(s, i, "Security isn't available right now.")
|
|
return nil
|
|
}
|
|
return e
|
|
}
|
|
|
|
// allowedOrTrusted lets an action through when the member has one of the
|
|
// permissions, or is the owner or trusted.
|
|
func allowedOrTrusted(ctx context.Context, e *security.Engine, i *discordgo.InteractionCreate, need int64) bool {
|
|
if perms(i)&(need|discordgo.PermissionAdministrator) != 0 {
|
|
return true
|
|
}
|
|
return e.RequireTrusted(ctx, i.GuildID, i.Member) == nil
|
|
}
|
|
|
|
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|
e := engine(s, i)
|
|
if e == nil {
|
|
return
|
|
}
|
|
data := i.ApplicationCommandData()
|
|
if len(data.Options) == 0 {
|
|
return
|
|
}
|
|
sub := data.Options[0]
|
|
args := opts(sub.Options)
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
|
|
if handleTune(ctx, s, i, e, sub) {
|
|
return
|
|
}
|
|
|
|
switch sub.Name {
|
|
case "status":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
|
reply(s, i, "You need Manage Server to see the security settings.")
|
|
return
|
|
}
|
|
reply(s, i, status(ctx, e, i.GuildID))
|
|
case "toggle":
|
|
feature, on := args["feature"].StringValue(), args["on"].BoolValue()
|
|
if err := e.SetFeature(ctx, i.GuildID, i.Member, perms(i), feature, on); err != nil {
|
|
reply(s, i, message("toggle", err))
|
|
return
|
|
}
|
|
state := "off"
|
|
if on {
|
|
state = "on"
|
|
}
|
|
reply(s, i, fmt.Sprintf("%s is now **%s**.", featureName(feature), state))
|
|
case "alerts":
|
|
ch := args["channel"].ChannelValue(nil).ID
|
|
role := ""
|
|
if r, ok := args["role"]; ok {
|
|
role = r.RoleValue(nil, i.GuildID).ID
|
|
}
|
|
if err := e.SetAlerts(ctx, i.GuildID, i.Member, ch, role); err != nil {
|
|
reply(s, i, message("alerts", err))
|
|
return
|
|
}
|
|
reply(s, i, fmt.Sprintf("Alerts will go to <#%s>.", ch))
|
|
case "quarantine-role":
|
|
role := ""
|
|
if r, ok := args["role"]; ok {
|
|
role = r.RoleValue(nil, i.GuildID).ID
|
|
}
|
|
if err := e.SetQuarantineRole(ctx, i.GuildID, i.Member, role); err != nil {
|
|
reply(s, i, message("quarantine-role", err))
|
|
return
|
|
}
|
|
if role == "" {
|
|
reply(s, i, "Quarantined members won't get a role; they still lose their roles and are timed out.")
|
|
} else {
|
|
reply(s, i, fmt.Sprintf("Quarantined members will get <@&%s>.", role))
|
|
}
|
|
case "trust":
|
|
if len(sub.Options) == 0 {
|
|
return
|
|
}
|
|
handleTrust(ctx, s, i, e, sub.Options[0])
|
|
case "release":
|
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
|
reply(s, i, message("release", err))
|
|
return
|
|
}
|
|
target := args["user"].UserValue(nil)
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
if err := e.Release(ctx, i.GuildID, target.ID, userID(i)); err != nil {
|
|
edit(s, i, message("release", err))
|
|
return
|
|
}
|
|
edit(s, i, fmt.Sprintf("<@%s> was released and got their roles back.", target.ID))
|
|
case "quarantined":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
|
reply(s, i, "You need Manage Server to see this.")
|
|
return
|
|
}
|
|
qs, err := e.Quarantines(ctx, i.GuildID)
|
|
if err != nil {
|
|
reply(s, i, message("quarantined", err))
|
|
return
|
|
}
|
|
if len(qs) == 0 {
|
|
reply(s, i, "Nobody is quarantined.")
|
|
return
|
|
}
|
|
var b strings.Builder
|
|
for _, q := range qs {
|
|
fmt.Fprintf(&b, "• <@%d>, <t:%d:R>: %s\n", q.UserID, q.CreatedAt.Unix(), q.Reason)
|
|
}
|
|
reply(s, i, clip(b.String(), 1900))
|
|
case "end-raid":
|
|
endRaid(ctx, s, i, e)
|
|
case "snapshot":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
|
reply(s, i, "You need Manage Server to take a snapshot.")
|
|
return
|
|
}
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
roles, channels, err := e.TakeSnapshot(ctx, i.GuildID, userID(i))
|
|
if err != nil {
|
|
edit(s, i, message("snapshot", err))
|
|
return
|
|
}
|
|
edit(s, i, fmt.Sprintf("Snapshot saved: %d roles and %d channels. The last three are kept.", roles, channels))
|
|
case "restore":
|
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
|
reply(s, i, message("restore", err))
|
|
return
|
|
}
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
// Recreating many channels takes a while under Discord's rate limits.
|
|
res, err := e.Restore(ctx, i.GuildID, userID(i))
|
|
if err != nil {
|
|
edit(s, i, message("restore", err))
|
|
return
|
|
}
|
|
edit(s, i, fmt.Sprintf("Restored from the snapshot of <t:%d:f>: %d roles and %d channels recreated, %d roles had dangerous permissions reverted, %d steps failed.",
|
|
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed))
|
|
}
|
|
}
|
|
|
|
func handleTrust(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, sub *discordgo.ApplicationCommandInteractionDataOption) {
|
|
args := opts(sub.Options)
|
|
kind, target := "", ""
|
|
if u, ok := args["user"]; ok {
|
|
kind, target = "user", u.UserValue(nil).ID
|
|
}
|
|
if r, ok := args["role"]; ok {
|
|
if target != "" {
|
|
reply(s, i, "Pick a user or a role, not both.")
|
|
return
|
|
}
|
|
kind, target = "role", r.RoleValue(nil, i.GuildID).ID
|
|
}
|
|
if target == "" {
|
|
reply(s, i, "Pick a user or a role.")
|
|
return
|
|
}
|
|
mention := "<@" + target + ">"
|
|
if kind == "role" {
|
|
mention = "<@&" + target + ">"
|
|
}
|
|
switch sub.Name {
|
|
case "add":
|
|
if err := e.Trust(ctx, i.GuildID, i.Member, kind, target); err != nil {
|
|
reply(s, i, message("trust add", err))
|
|
return
|
|
}
|
|
reply(s, i, mention+" is now trusted: exempt from anti-nuke and able to change security settings.")
|
|
case "remove":
|
|
if err := e.Untrust(ctx, i.GuildID, i.Member, target); err != nil {
|
|
reply(s, i, message("trust remove", err))
|
|
return
|
|
}
|
|
reply(s, i, mention+" is no longer trusted.")
|
|
}
|
|
}
|
|
|
|
func endRaid(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine) {
|
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
|
reply(s, i, message("end raid", err))
|
|
return
|
|
}
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
if err := e.EndRaid(ctx, i.GuildID, userID(i)); err != nil {
|
|
edit(s, i, message("end raid", err))
|
|
return
|
|
}
|
|
edit(s, i, "Raid mode is off.")
|
|
}
|
|
|
|
func featureName(f string) string {
|
|
switch f {
|
|
case securityrepo.FeatureJoinGate:
|
|
return "Join Gate"
|
|
case securityrepo.FeatureAntiRaid:
|
|
return "Raid mode"
|
|
case securityrepo.FeatureAntiNuke:
|
|
return "Anti-nuke"
|
|
case securityrepo.FeatureHeat:
|
|
return "Heat"
|
|
}
|
|
return f
|
|
}
|
|
|
|
func onOff(b bool) string {
|
|
if b {
|
|
return "✅ on"
|
|
}
|
|
return "⬜ off"
|
|
}
|
|
|
|
func status(ctx context.Context, e *security.Engine, guildID string) string {
|
|
st, trusted, err := e.Settings(ctx, guildID)
|
|
if err != nil {
|
|
return message("status", err)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "**Join Gate:** %s\n**Raid mode:** %s (%d joins in %ds → %s)\n**Anti-nuke:** %s (auto restore %s)\n**Heat:** %s (%s)\n",
|
|
onOff(st.JoinGateEnabled), onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction,
|
|
onOff(st.AntiNukeEnabled), onOff(st.AntiNukeAutoRestore), onOff(st.HeatEnabled), st.HeatStrictness)
|
|
if e.RaidActive(guildID) {
|
|
b.WriteString("🚨 **A raid is in progress.**\n")
|
|
}
|
|
if st.JoinLock != securityrepo.ActionOff {
|
|
fmt.Fprintf(&b, "🔒 Joins are locked (%s).\n", st.JoinLock)
|
|
}
|
|
if st.AlertChannelID != 0 {
|
|
fmt.Fprintf(&b, "**Alerts:** <#%d>", st.AlertChannelID)
|
|
if st.AlertRoleID != 0 {
|
|
fmt.Fprintf(&b, ", pinging <@&%d>", st.AlertRoleID)
|
|
}
|
|
b.WriteString("\n")
|
|
} else {
|
|
b.WriteString("**Alerts:** not set. Use `/security alerts`.\n")
|
|
}
|
|
if owner, err := e.OwnerID(guildID); err == nil {
|
|
fmt.Fprintf(&b, "**Trusted:** <@%s> (owner)", owner)
|
|
}
|
|
for _, t := range trusted {
|
|
if t.Kind == "role" {
|
|
fmt.Fprintf(&b, ", <@&%d>", t.TargetID)
|
|
} else {
|
|
fmt.Fprintf(&b, ", <@%d>", t.TargetID)
|
|
}
|
|
}
|
|
b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
|
|
return b.String()
|
|
}
|
|
|
|
func clip(s string, n int) string {
|
|
if r := []rune(s); len(r) > n {
|
|
return string(r[:n-1]) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|
e := engine(s, i)
|
|
if e == nil {
|
|
return
|
|
}
|
|
data := i.ApplicationCommandData()
|
|
if len(data.Options) == 0 {
|
|
return
|
|
}
|
|
sub := data.Options[0]
|
|
args := opts(sub.Options)
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
|
|
switch sub.Name {
|
|
case "channel":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageChannels) {
|
|
reply(s, i, "You need Manage Channels to lock a channel.")
|
|
return
|
|
}
|
|
ch := i.ChannelID
|
|
if c, ok := args["channel"]; ok {
|
|
ch = c.ChannelValue(nil).ID
|
|
}
|
|
if err := e.LockChannel(ctx, i.GuildID, ch, userID(i), false); err != nil {
|
|
reply(s, i, message("lock channel", err))
|
|
return
|
|
}
|
|
reply(s, i, fmt.Sprintf("🔒 <#%s> is locked. `/unlock channel` puts it back as it was.", ch))
|
|
case "server":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
|
reply(s, i, "You need Manage Server to lock the server.")
|
|
return
|
|
}
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
locked, failed, err := e.LockServer(ctx, i.GuildID, userID(i))
|
|
if err != nil {
|
|
edit(s, i, message("lock server", err))
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("🔒 Locked %d channels.", locked)
|
|
if failed > 0 {
|
|
msg += fmt.Sprintf(" %d couldn't be locked (Velox needs Manage Channels there).", failed)
|
|
}
|
|
edit(s, i, msg+" `/unlock server` puts them back as they were.")
|
|
case "joins":
|
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
|
reply(s, i, "You need Manage Server to lock joins.")
|
|
return
|
|
}
|
|
mode := args["action"].StringValue()
|
|
if err := e.SetJoinLock(ctx, i.GuildID, mode, userID(i)); err != nil {
|
|
reply(s, i, message("lock joins", err))
|
|
return
|
|
}
|
|
reply(s, i, fmt.Sprintf("🔒 Everyone who joins will be %s until `/unlock joins`.", map[string]string{"kick": "kicked", "ban": "banned"}[mode]))
|
|
}
|
|
}
|
|
|
|
// UnlockHandler relaxes protection, so it is owner or trusted only.
|
|
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|
e := engine(s, i)
|
|
if e == nil {
|
|
return
|
|
}
|
|
data := i.ApplicationCommandData()
|
|
if len(data.Options) == 0 {
|
|
return
|
|
}
|
|
sub := data.Options[0]
|
|
args := opts(sub.Options)
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
|
reply(s, i, message("unlock", err))
|
|
return
|
|
}
|
|
|
|
switch sub.Name {
|
|
case "channel":
|
|
ch := i.ChannelID
|
|
if c, ok := args["channel"]; ok {
|
|
ch = c.ChannelValue(nil).ID
|
|
}
|
|
if err := e.UnlockChannel(ctx, i.GuildID, ch, userID(i)); err != nil {
|
|
reply(s, i, message("unlock channel", err))
|
|
return
|
|
}
|
|
reply(s, i, fmt.Sprintf("🔓 <#%s> is unlocked.", ch))
|
|
case "server":
|
|
if !deferReply(s, i) {
|
|
return
|
|
}
|
|
unlocked, failed, err := e.UnlockServer(ctx, i.GuildID, userID(i))
|
|
if err != nil {
|
|
edit(s, i, message("unlock server", err))
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("🔓 Unlocked %d channels.", unlocked)
|
|
if failed > 0 {
|
|
msg += fmt.Sprintf(" %d failed; try `/unlock server` again.", failed)
|
|
}
|
|
edit(s, i, msg)
|
|
case "joins":
|
|
if err := e.SetJoinLock(ctx, i.GuildID, securityrepo.ActionOff, userID(i)); err != nil {
|
|
reply(s, i, message("unlock joins", err))
|
|
return
|
|
}
|
|
reply(s, i, "🔓 Joins are open again.")
|
|
}
|
|
}
|
|
|
|
// HandleComponent handles the alert's "End raid" button. Anyone in the alert
|
|
// channel can press it, so it checks trust like the command.
|
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|
e := engine(s, i)
|
|
if e == nil {
|
|
return
|
|
}
|
|
if i.MessageComponentData().CustomID != security.CustomIDEndRaid {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
|
defer cancel()
|
|
endRaid(ctx, s, i, e)
|
|
}
|