Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/pen-fun/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import (
"github.com/Neon-Genesis-Linux/pen-bot/internal/community"
"github.com/Neon-Genesis-Linux/pen-bot/internal/core"
_ "github.com/Neon-Genesis-Linux/pen-bot/internal/logger"
"github.com/Neon-Genesis-Linux/pen-bot/internal/moderation"
)

func main() {
community.Register()
moderation.Register()

guildID := snowflake.GetEnv("GUILD_ID")
var guildIDs []snowflake.ID
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.0
require github.com/disgoorg/disgo v0.19.6

require (
github.com/disgoorg/omit v1.0.0
github.com/disgoorg/snowflake/v2 v2.0.3
github.com/go-resty/resty/v2 v2.17.2
github.com/lib/pq v1.12.3
Expand All @@ -13,7 +14,6 @@ require (
require (
github.com/disgoorg/godave v0.3.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
Expand Down
10 changes: 10 additions & 0 deletions internal/core/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package core

import "github.com/disgoorg/snowflake/v2"

// IsGuild reports whether the interaction that produced e originated in a
// guild. GuildID is nil for direct messages, where guild-scoped commands and
// REST calls (member updates, moderative actions) are unavailable.
func IsGuild[T interface{ GuildID() *snowflake.ID }](e T) bool {
return e.GuildID() != nil
}
3 changes: 2 additions & 1 deletion internal/moderation/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ package moderation

// Register registers moderation commands with the bot
func Register() {
// TODO: implement moderation commands
// Not moderation per se, but as noted in issue #26, it must be in here for permission reasons
registerSelfTimeoutCommand()
}
262 changes: 262 additions & 0 deletions internal/moderation/self_timeout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package moderation

import (
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"strconv"
"time"

"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/handler"
"github.com/disgoorg/disgo/rest"
"github.com/disgoorg/omit"

"github.com/Neon-Genesis-Linux/pen-bot/internal/core"
)
Comment thread
Uniolo1 marked this conversation as resolved.

const (
selfTimeoutCommand = "self-timeout"

// Discord allows timeouts up to 28 days; self-timeout is capped well below that.
// parseSelfTimeoutDuration already understands "d" and "w" terms, so raising this
// bound is the only change needed to permit week-long self timeouts.
selfTimeoutLower = 10 * time.Second
selfTimeoutUpper = 3 * 24 * time.Hour
)

func selfTimeoutCommandEmbed(title string, desc string) discord.Embed {
return discord.NewEmbed().
WithTitle(title).
WithDescription(desc).
WithColor(0xffc34d)
}

func selfTimeoutCommandErrorEmbed(title string, desc string) discord.Embed {
return discord.NewEmbed().
WithTitle(title).
WithDescription(desc).
WithColor(0xff0000)
}

// sendEmbed replaces the deferred interaction response with a single embed, optionally
// carrying a row of buttons. Passing a nil buttons leaves the response component-free.
func sendEmbed(e *handler.CommandEvent, embed discord.Embed, buttons *[2]discord.ButtonComponent) error {
update := discord.MessageUpdate{
Embeds: &[]discord.Embed{embed},
}

// handle buttons
if buttons != nil {
row := make([]discord.InteractiveComponent, len(buttons))
for i, btn := range buttons {
row[i] = btn
}

update.Components = &[]discord.LayoutComponent{
discord.ActionRowComponent{Components: row},
}
}

_, err := e.UpdateInteractionResponse(update)
return err
}

// Helper to update the embed, and clear buttons (used in button handlers).
func updateEmbed(e *handler.ComponentEvent, embed discord.Embed) error {
return e.UpdateMessage(discord.MessageUpdate{
Embeds: &[]discord.Embed{embed},
Components: &[]discord.LayoutComponent{},
})
}

func handleSelfTimeoutCommandGoBack(_ discord.ButtonInteractionData, e *handler.ComponentEvent) error {
// overwrite the original message
embed := selfTimeoutCommandEmbed("Timeout Cancelled", "See you around!")
return updateEmbed(e, embed)
}

// longTerm matches the week and day units, which time.ParseDuration does not support.
// A duration string is otherwise a sequence of <number><unit> terms, and no unit Go
// does understand contains "w" or "d", so this cannot match a fragment of "ns", "us",
// "ms" or "s". The leading group forces the term to start the string or follow a
// non-numeric character: without it, "1.5.5d" would match the "5.5d" tail and expand
// to "1.132h", which parses as 1.132 hours.
var longTerm = regexp.MustCompile(`(^|[^0-9.])(\d+(?:\.\d+)?)([wd])`)

// hoursPerLongUnit is how many hours each unit expandLongUnits rewrites into. Discord's
// own /timeout takes day and week suffixes, but Go's parser stops at "h".
var hoursPerLongUnit = map[byte]float64{
'w': 7 * 24,
'd': 24,
}

// expandLongUnits rewrites week and day terms into hour terms, so that
// time.ParseDuration accepts them ("1d" becomes "24h", "2w" "336h"). Duplicate units
// are fine: ParseDuration sums them, so "1d12h" becomes "24h12h" and still totals 36h.
func expandLongUnits(s string) string {
// Iterate to a fixpoint. One pass is not always enough: "1w2d" becomes "168h2d" on
// the first, and the trailing "2d" only becomes matchable once "1w" has stopped
// occupying the prefix slot the pattern needs. Every match consumes one unit
// character and emits none, so each pass makes progress and this terminates.
for replaced := true; replaced; {
replaced = false
s = longTerm.ReplaceAllStringFunc(s, func(term string) string {
replaced = true
groups := longTerm.FindStringSubmatch(term)
value, _ := strconv.ParseFloat(groups[2], 64)
return groups[1] + strconv.FormatFloat(value*hoursPerLongUnit[groups[3][0]], 'f', -1, 64) + "h"
})
}
return s
}

// parseSelfTimeoutDuration turns a user-supplied duration string into the value
// that will actually be applied. It is the single validation point: it runs once,
// on slash command invocation, and the value it returns is the only thing that
// can reach the confirmation button (see registerSelfTimeoutCommand).
func parseSelfTimeoutDuration(s string) (time.Duration, error) {
d, err := time.ParseDuration(expandLongUnits(s))
if err != nil {
return 0, fmt.Errorf("'%s' is not a valid duration", s)
}

// Truncate millisecond/nanosecond precision, so that Duration.String() — which is
// what the confirmation prompt and the button custom ID both render — is an exact
// description of the timeout that gets applied.
d = d.Round(time.Second)

if d < selfTimeoutLower {
return 0, fmt.Errorf("'%s' is shorter than the %g second minimum", s, selfTimeoutLower.Seconds())
}

if d > selfTimeoutUpper {
return 0, fmt.Errorf("'%s' is longer than the %g hour maximum", s, selfTimeoutUpper.Hours())
}

return d, nil
}

func handleSelfTimeoutCommandConfirm(_ discord.ButtonInteractionData, e *handler.ComponentEvent) error {
duration, err := time.ParseDuration(e.Vars["duration"])
if err != nil {
// NOTE: unreachable via the button; kept as the guard against a custom ID that
// did not come from parseSelfTimeoutDuration.
embed := selfTimeoutCommandErrorEmbed("Invalid duration!", "The specified duration is invalid!\n"+
"This error should have been caught earlier...")
return updateEmbed(e, embed)
}

if !core.IsGuild(e) {
// NOTE: should have been caught earlier
embed := selfTimeoutCommandErrorEmbed("Command unavailable!", "This command can only be used in servers!")
return updateEmbed(e, embed)
}

// No range check here: the custom ID carries a duration that parseSelfTimeoutDuration
// already accepted, and Duration.String() round-trips exactly, so re-deriving the
// bounds could only ever disagree with what the user confirmed.

// actually time out the user
until := time.Now().Add(duration)
GuildID := e.GuildID()
userID := e.Member().User.ID
_, err = e.Client().Rest.UpdateMember(*GuildID, userID, discord.MemberUpdate{
CommunicationDisabledUntil: omit.New(&until),
}, rest.WithReason("self-timeout"))

if err != nil {
slog.Error("self-timeout: member update failed",
slog.String("guild", GuildID.String()),
slog.String("user", userID.String()),
slog.Any("error", err),
)

desc := fmt.Sprintf("The bot was unable to timeout <@%s>.\nPlease try again later.", userID)

// A 403 means Discord refused: the bot lacks Moderate Members, or its highest
// role sits below the member's. Match on the status, not the body — the error code
// here is 50013 ("Missing Permissions"), which Discord may omit entirely, and
// 50013 is client-defined so its meaning is not stable across endpoints.
var restErr *rest.Error
if errors.As(err, &restErr) && restErr.Response != nil && restErr.Response.StatusCode == http.StatusForbidden {
desc = fmt.Sprintf("The bot was unable to timeout <@%s>.\n"+
"It needs the *Time out members* permission and a role above yours.", userID)
}

embed := selfTimeoutCommandErrorEmbed("Unable to timeout user!", desc)
return updateEmbed(e, embed)
}
Comment on lines +170 to +191

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The error is discarded and misdiagnosed. Every UpdateMember failure renders as "Invalid permissions!". A 404 unknown-member, a 429, a 5xx, or a network blip all tell the user to check their permissions.

And because you return updateEmbed's error rather than the REST error, disgo's default error handler logs only the edit failure; the timeout failure itself leaves no trace. Use rest.IsJSONErrorCode(err, rest.JSONErrorCodeMissingAccess) / errors.As for the 403 branch and slog.Error the rest, the way tldr does.


// TODO: log the self-timeout in a configured staff channel once logging feature is added

embed := selfTimeoutCommandEmbed("Successfully timed out", "See you *later*!")
return updateEmbed(e, embed)
}

func handleSelfTimeoutCommand(interaction discord.SlashCommandInteractionData, e *handler.CommandEvent) error {
if err := e.DeferCreateMessage(true); err != nil {
return err
}

if !core.IsGuild(e) {
return sendEmbed(e, selfTimeoutCommandErrorEmbed("Command unavailable!", "This command can only be used in servers!"), nil)
}

durationString, ok := interaction.OptString("duration")
if !ok {
return sendEmbed(e, selfTimeoutCommandErrorEmbed("Unset duration", "Duration was not set!"), nil)
}

duration, err := parseSelfTimeoutDuration(durationString)
if err != nil {
return sendEmbed(e, selfTimeoutCommandErrorEmbed("Invalid duration", err.Error()), nil)
}

// One value for the prompt and the custom ID, so what the user confirms is exactly
// what gets applied. Duration.String() is canonical and round-trips exactly.
effective := duration.String()

embed := selfTimeoutCommandEmbed("Confirm timeout", fmt.Sprintf(
"You are about to time yourself out, **this action is irreversible**, "+
"there is *no provision* for this to be undone by moderation!"+
"\nConfirm `%s` timeout?",
effective))

buttons := [2]discord.ButtonComponent{
discord.NewDangerButton("I understand", "/"+selfTimeoutCommand+"/Confirm/"+effective),
discord.NewSecondaryButton("Cancel", "/"+selfTimeoutCommand+"/GoBack/"),
}

return sendEmbed(e, embed, &buttons)
}

func registerSelfTimeoutCommand() {
core.RegisterCommands(
discord.SlashCommandCreate{
Name: selfTimeoutCommand,
Description: "Time yourself out for a set period of time.",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "duration",
// Discord caps option descriptions at 100 characters and silently
// truncates past that, so keep this short if the wording changes.
Description: fmt.Sprintf("How long the self timeout should last (e.g. 30m, 1h10m, 1d, 1w)\n"+
"Accepted range: [%s, %gh]", selfTimeoutLower, selfTimeoutUpper.Hours()),
Required: true,
},
},
},
)

// {duration} is filled from a value parseSelfTimeoutDuration already accepted, and
// Duration.String() emits only [0-9hmsµun.] — no '/', so the segment cannot escape
// into another route. handleSelfTimeoutCommandConfirm therefore trusts it and does
// not re-check the bounds.
h := core.Mux()
h.ButtonComponent("/"+selfTimeoutCommand+"/Confirm/{duration}", handleSelfTimeoutCommandConfirm)
h.ButtonComponent("/"+selfTimeoutCommand+"/GoBack/", handleSelfTimeoutCommandGoBack)
h.SlashCommand("/"+selfTimeoutCommand, handleSelfTimeoutCommand)
}