From 694d262c8c836a42add8cf9ca5e9007674c5e706 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Fri, 23 Oct 2026 11:38:45 +0100 Subject: [PATCH 1/3] Help: compact inline columns, wide instead of long, with a link to the docs Co-Authored-By: Claude Sonnet 5.5 --- internal/commands/help/help.go | 178 +++++++--------------------- internal/commands/help/help_test.go | 17 +++ 2 files changed, 62 insertions(+), 133 deletions(-) diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 5b7bf6d..8795221 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -20,141 +20,53 @@ const ( maxFieldChars = 1024 ) +// docsURL is where each command is explained in full, with its options and +// who may use it. +const docsURL = "https://velox.fernandovideira.com/docs/commands" + +// col is one short column of the help: a list of commands, no descriptions. +// Fields are inline, so Discord lays three of them side by side and the help +// is wide and short instead of narrow and long. +func col(name string, lines ...string) *discordgo.MessageEmbedField { + v := "" + for i, l := range lines { + if i > 0 { + v += "\n" + } + v += l + } + return &discordgo.MessageEmbedField{Name: name, Value: v, Inline: true} +} + func fields() []*discordgo.MessageEmbedField { return []*discordgo.MessageEmbedField{ + col("Fun", + "`/ping` `/joke` `/coinflip`", "`/dice` `/rps` `/rpsstats`", "`/rpsleaderboard`"), + col("Music (DJ role)", + "`/play` `/queue` `/volume`", "`/music skip` `pause` `resume`", "`/music stop` `repeat`", "`/music remove` `move`", "`/music toggle` (Manage Server)"), + col("Sessions and time", + "`/schedule create`", "`/schedule list`", "`/schedule reschedule`", "`/timezone set` `show`"), + col("Tickets", + "Open one from the panel.", "`/ticket add` `close`", "`/ticket claim` `remove` (staff)", "`/tickets setup` `settings`", "`/tickets category` `panel` `list`"), + col("Security", + "`/security status` `settings` `history`", "`/security toggle`", "`/security joingate` `words` `raid`", "`/security antinuke` `heat` `alert-dms`", "`/security trust` `release` `end-raid`", "`/security alerts` `quarantine-role`", "`/security snapshot` `restore`", "`/lockdown` `/unlock`"), + col("Moderation", + "`/moderation purge`", "`/moderation timeout` `untimeout`", "`/moderation kick` `ban` `unban`", "`/moderation slowmode`", "`/moderation bans` `timeouts`"), + col("Meeting rooms", + "`/meeting add-lobby`", "`/meeting remove-lobby` `lobbies`", "`/meeting lock` `lock-private`", "`/meeting unlock`", "`/meeting invite` `uninvite`"), + col("Leveling", + "`/rank` `/leaderboard` `/rewards`", "`/slvl toggle` `status`", "`/slvl set` `set-channel`", "`/slvl set-message`", "`/slvl set-reward` `remove-reward`"), + col("Projects", + "`/projects list`", "`/projects create`", "`/projects add-helper`", "`/projects close`"), + col("Welcome and roles", + "`/config setwelcomechannel`", "`/config setwelcomemessage`", "`/config setwelcomedm`", "`/config setwelcomegif`", "`/config setdefaultrole`"), + col("Logging and Twitch", + "`/config setlogchannel`", "`/config enablelogging` `disablelogging`", "`/config addstreamer` `removestreamer`", "`/config streamers`", "`/config toggletwitch`", "`/config settwitchnotificationchannel`"), + col("Settings and beta", + "`/config view` `clear`", "`/config beta-features` (server owner)", "Bot owner, in a DM with me:", "`/beta set` `off` `status`"), { - Name: "Fun", - Value: "" + - "`/ping` — Pong!\n" + - "`/joke [type]` — Random joke (optional category)\n" + - "`/coinflip` — Flip a coin\n" + - "`/dice ` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" + - "`/rps ` — Rock Paper Scissors (server only)\n" + - "`/rpsstats` — Your RPS score (server only)\n" + - "`/rpsleaderboard` — Top RPS scores (server only)", - }, - { - Name: "Music (requires **DJ** role)", - Value: "" + - "`/play ` — Play a song (search or URL)\n" + - "`/queue` — Show the current queue\n" + - "`/volume <0-150>` — Set playback volume\n" + - "`/music skip|pause|resume|stop` — Control playback\n" + - "`/music repeat `, `/music remove `, `/music move ` — Repeat and reorder\n" + - "`/music toggle` — Turn music on or off (**Manage Server**)", - }, - { - Name: "Scheduling (1:1 sessions)", - Value: "" + - "`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" + - "`/schedule list` — List your upcoming sessions\n" + - "`/schedule reschedule id: datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" + - "Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.", - }, - { - Name: "Timezone", - Value: "" + - "`/timezone set ` — Set your timezone (e.g. `Europe/Lisbon`)\n" + - "`/timezone show` — Show your timezone", - }, - { - Name: "Projects", - Value: "" + - "`/projects list` — List active projects\n" + - "`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" + - "`/projects add-helper project-id: user:@someone` — Add helper (**Manage Server**)", - }, - { - Name: "Tickets", - Value: "" + - "Open a ticket from the panel: you get a private thread with the staff.\n" + - "`/ticket add user:@someone` — Add someone to your ticket\n" + - "`/ticket close [reason]` — Close the ticket (opener or staff)\n" + - "`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" + - "`/tickets setup`, `/tickets settings`, `/tickets category add|edit|remove|list`, `/tickets panel`, `/tickets list` — Set up (**Manage Server**)", - }, - { - Name: "Security (anti-raid & anti-nuke)", - Value: "" + - "`/security status`, `/security settings`, `/security history` — What's on, every setting, what happened\n" + - "`/security toggle` — Join Gate, raid mode, anti-nuke, heat on/off (off: owner or trusted)\n" + - "`/security joingate`, `words`, `raid`, `antinuke`, `heat`, `alert-dms` — Tune thresholds and filters (owner or trusted)\n" + - "`/security alerts`, `/security quarantine-role`, `/security release`, `/security end-raid` — Owner or trusted\n" + - "`/security trust add|remove` — Owner only\n" + - "`/security snapshot` — Save roles and channels; `/security restore` — Rebuild what a nuke deleted (owner or trusted)\n" + - "`/lockdown channel|server|joins`, `/unlock …` (unlock: owner or trusted)", - }, - { - Name: "Meetings (**Manage Server**)", - Value: "" + - "`/meeting add-lobby channel:#voice` — Add a voice lobby that creates meetings (up to 10)\n" + - "`/meeting remove-lobby channel:#voice` — Stop using a voice lobby\n" + - "`/meeting lobbies` — List the voice lobbies\n" + - "`/meeting lock` — Lock your meeting room\n" + - "`/meeting lock-private` — Lock + hide your meeting room\n" + - "`/meeting unlock` — Unlock your meeting room\n" + - "`/meeting invite user:@someone` — Allow a user to join\n" + - "`/meeting uninvite user:@someone` — Remove a user's permission", - }, - { - Name: "Moderation (focused tools)", - Value: "" + - "`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" + - "`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" + - "`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" + - "`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" + - "`/moderation unban user_id: [reason]` — Unban by user ID (**Ban Members**)\n" + - "`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" + - "`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)\n" + - "`/moderation bans`, `/moderation timeouts` — Who is banned, who is timed out", - }, - { - Name: "Leveling", - Value: "" + - "`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" + - "`/slvl set user:@someone level:` — Set user level (**Manage Server**)\n" + - "`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" + - "`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" + - "`/slvl set-reward level: role:@role`, `/slvl remove-reward level:` — Role rewards (**Manage Server**)\n" + - "`/slvl status` — Show the leveling settings (**Manage Server**)\n" + - "`/rank [user]` — Your rank card (server only)\n" + - "`/leaderboard` — Top levels (server only)\n" + - "`/rewards` — List configured rewards (server only)", - }, - { - Name: "Twitch live notifications (**Manage Server**)", - Value: "" + - "`/config addstreamer username:` — Add a streamer\n" + - "`/config removestreamer username:` — Remove a streamer\n" + - "`/config settwitchnotificationchannel channel:#channel` — Set notification channel\n" + - "`/config toggletwitch enabled:`, `/config streamers` — Turn alerts on or off, list streamers", - }, - { - Name: "Welcome / onboarding (**Manage Server**)", - Value: "" + - "`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" + - "`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" + - "`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" + - "`/config setwelcomegif url:` — GIF/image used in welcome embed\n" + - "`/config setdefaultrole role:@role` — Role granted to new members\n" + - "`/config view`, `/config clear setting:<...>` — See or clear these settings", - }, - { - Name: "Logging (**Manage Server**)", - Value: "" + - "`/config setlogchannel channel:#channel` — Set log output channel\n" + - "`/config enablelogging` — Enable event/moderation logging\n" + - "`/config disablelogging` — Disable event/moderation logging", - }, - { - Name: "Beta features", - Value: "" + - "`/config beta-features [enabled]` — Let testers use beta features here (server owner only)\n" + - "Bot owner: `/beta` in a DM with the bot — set the beta server and role, list or remove servers", - }, - { - Name: "Other", - Value: "`/help` — Show this help in DMs", + Name: "Everything in detail", + Value: "Options, permissions and what each command does: " + docsURL + "\nMost setup commands need **Manage Server**. `/help` shows this list again.", }, } } @@ -168,7 +80,7 @@ func embeds(author *discordgo.MessageEmbedAuthor) []*discordgo.MessageEmbed { var out []*discordgo.MessageEmbed cur := &discordgo.MessageEmbed{ Title: "Velox Bot Help", - Description: "Command reference. Some commands require server permissions or roles.", + Description: "Every command, by area. Some need server permissions or a role.", Color: 0xFFA500, Author: author, } diff --git a/internal/commands/help/help_test.go b/internal/commands/help/help_test.go index 35d83ad..eb5424b 100644 --- a/internal/commands/help/help_test.go +++ b/internal/commands/help/help_test.go @@ -38,3 +38,20 @@ func TestHelpFitsDiscordsLimits(t *testing.T) { t.Errorf("%d fields shown of %d", seen, len(fields())) } } + +// The help is meant to be wide and short: inline fields, so Discord puts +// three side by side, and it should fit in one message. +func TestHelpIsCompactAndInline(t *testing.T) { + if n := len(embeds(&discordgo.MessageEmbedAuthor{Name: "Velox Bot"})); n != 1 { + t.Errorf("the help should fit in one message, it takes %d", n) + } + inline := 0 + for _, f := range fields() { + if f.Inline { + inline++ + } + } + if inline < len(fields())-2 { + t.Errorf("only %d of %d fields are inline", inline, len(fields())) + } +} -- 2.54.0 From 63f17365fcfe61ded48de2cc0a71a25a2b2a1ce4 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Fri, 23 Oct 2026 11:50:44 +0100 Subject: [PATCH 2/3] Help: show it in the channel when the DM can't be sent, and log where the command came from Co-Authored-By: Claude Sonnet 5.5 --- docs/commands.md | 2 +- docs/features.md | 2 +- internal/commands/help/help.go | 82 +++++++++++++++++++---------- internal/commands/help/help_test.go | 75 ++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 29 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index f99895a..3115f80 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,7 +35,7 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers | Command | What it does | | --- | --- | -| `/help` | DMs you a command reference | +| `/help` | DMs you a command reference, or shows it to you in the channel if DMs are off | ## Leveling diff --git a/docs/features.md b/docs/features.md index f1d0436..555b794 100644 --- a/docs/features.md +++ b/docs/features.md @@ -117,4 +117,4 @@ When the bot is added to a server, it applies safe defaults (modules off) and DM ## Help -`/help` DMs a command reference. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command. +`/help` DMs a command reference. If the member has DMs off, or Discord refuses the DM, it shows the same help as a message only they can see. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command. diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 5b7bf6d..efe66bc 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -185,39 +185,67 @@ func embeds(author *discordgo.MessageEmbedAuthor) []*discordgo.MessageEmbed { return append(out, cur) } +// HelpHandler sends the help by direct message. If that isn't possible (the +// member has DMs off, or shares no server with the bot), it shows the help to +// them in the channel instead, as a message only they can see. func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Check your DMs for help.", - Flags: discordgo.MessageFlagsEphemeral, - }, - }) - - userID := "" - - // check if command is run in a guild - if i.Member != nil { - userID = i.Member.User.ID - } else { - userID = i.Interaction.User.ID - } - - dmChannel, err := s.UserChannelCreate(userID) - if err != nil { - log.Printf("Error creating DM channel: %v", err) - return - } - author := discordgo.MessageEmbedAuthor{ Name: "Velox Bot", URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot", } + list := embeds(&author) - for _, e := range embeds(&author) { - if _, err := s.ChannelMessageSendEmbed(dmChannel.ID, e); err != nil { - log.Printf("Error sending help: %v", err) - return + // Used in a DM with the bot, there is nowhere else to send it. + if i.GuildID == "" { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Embeds: list}, + }) + return + } + + // Answer first (sending the DM can take a moment), and edit the answer + // once we know where the help went. + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }); err != nil { + log.Printf("help: cannot answer: %v", err) + return + } + + userID := "" + if i.Member != nil && i.Member.User != nil { + userID = i.Member.User.ID + } else if i.User != nil { + userID = i.User.ID + } + + if err := sendByDM(s, userID, list); err != nil { + // Typically "Cannot send messages to this user": DMs are off. + // The context says where the command came from, which is what to look at + // when "no mutual guilds" appears for someone who is in a server with + // the bot: a command run through a user install, or in a server the bot + // isn't actually a member of, has no shared server for Discord to see. + log.Printf("help: DM not possible, showing it here (guild %q, context %d, installs %v): %v", + i.GuildID, i.Context, i.AuthorizingIntegrationOwners, err) + note := "I couldn't DM you (your DMs may be off), so here it is." + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: ¬e, Embeds: &list}) + return + } + done := "Check your DMs for help." + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &done}) +} + +func sendByDM(s *discordgo.Session, userID string, list []*discordgo.MessageEmbed) error { + dm, err := s.UserChannelCreate(userID) + if err != nil { + return err + } + for _, e := range list { + if _, err := s.ChannelMessageSendEmbed(dm.ID, e); err != nil { + return err } } + return nil } diff --git a/internal/commands/help/help_test.go b/internal/commands/help/help_test.go index 35d83ad..831b698 100644 --- a/internal/commands/help/help_test.go +++ b/internal/commands/help/help_test.go @@ -1,6 +1,9 @@ package help import ( + "io" + "net/http" + "strings" "testing" "unicode/utf8" @@ -38,3 +41,75 @@ func TestHelpFitsDiscordsLimits(t *testing.T) { t.Errorf("%d fields shown of %d", seen, len(fields())) } } + +// fakeDiscord answers the calls /help makes and records what the member was +// shown. dm decides whether the direct message works. +type fakeDiscord struct { + dm bool + edited []byte + callbacks int + sent int +} + +func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) { + reply := func(code int, body string) (*http.Response, error) { + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil + } + switch { + case strings.HasSuffix(r.URL.Path, "/callback"): + f.callbacks++ + return reply(204, "") + case strings.HasSuffix(r.URL.Path, "/users/@me/channels"): + if !f.dm { + return reply(403, `{"message":"Cannot send messages to this user","code":50007}`) + } + return reply(200, `{"id":"900","type":1}`) + case strings.Contains(r.URL.Path, "/channels/900/messages"): + f.sent++ + return reply(200, `{"id":"1"}`) + case strings.Contains(r.URL.Path, "/messages/@original"): + f.edited, _ = io.ReadAll(r.Body) + return reply(200, `{"id":"2"}`) + } + return reply(404, `{"message":"unexpected `+r.URL.Path+`"}`) +} + +func run(t *testing.T, f *fakeDiscord, guild string) { + t.Helper() + s, err := discordgo.New("Bot x") + if err != nil { + t.Fatal(err) + } + s.Client = &http.Client{Transport: f} + s.MaxRestRetries = 0 + i := &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{ + ID: "1", Token: "tok", AppID: "100", GuildID: guild, + Member: &discordgo.Member{User: &discordgo.User{ID: "42"}}, + }} + HelpHandler(s, i) +} + +func TestHelpGoesToDMs(t *testing.T) { + f := &fakeDiscord{dm: true} + run(t, f, "5") + if f.sent == 0 || !strings.Contains(string(f.edited), "Check your DMs") || strings.Contains(string(f.edited), `"embeds"`) { + t.Errorf("sent=%d edited=%s", f.sent, f.edited) + } +} + +func TestHelpFallsBackToTheChannelWhenDMsAreOff(t *testing.T) { + f := &fakeDiscord{dm: false} + run(t, f, "5") + got := string(f.edited) + if f.sent != 0 || !strings.Contains(got, "couldn't DM you") || !strings.Contains(got, `"embeds"`) || !strings.Contains(got, "Velox Bot Help") { + t.Errorf("the help should be shown in the reply: sent=%d edited=%s", f.sent, got) + } +} + +func TestHelpInADMIsShownDirectly(t *testing.T) { + f := &fakeDiscord{dm: true} + run(t, f, "") + if f.callbacks != 1 || f.sent != 0 || f.edited != nil { + t.Errorf("one direct answer, no DM: %+v", f) + } +} -- 2.54.0 From 5bc2da4ad80cccab14c8944ecf44351a341620b6 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Fri, 23 Oct 2026 11:57:30 +0100 Subject: [PATCH 3/3] Help: log whether the bot is a member of the server when the DM fails Co-Authored-By: Claude Sonnet 5.5 --- internal/commands/help/help.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 8a34e28..b8bad42 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -1,6 +1,7 @@ package help import ( + "fmt" "log" "unicode/utf8" @@ -139,8 +140,8 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { // when "no mutual guilds" appears for someone who is in a server with // the bot: a command run through a user install, or in a server the bot // isn't actually a member of, has no shared server for Discord to see. - log.Printf("help: DM not possible, showing it here (guild %q, context %d, installs %v): %v", - i.GuildID, i.Context, i.AuthorizingIntegrationOwners, err) + log.Printf("help: DM not possible, showing it here (guild %q, context %d, installs %v, %s): %v", + i.GuildID, i.Context, i.AuthorizingIntegrationOwners, botMembership(s, i.GuildID), err) note := "I couldn't DM you (your DMs may be off), so here it is." _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: ¬e, Embeds: &list}) return @@ -161,3 +162,21 @@ func sendByDM(s *discordgo.Session, userID string, list []*discordgo.MessageEmbe } return nil } + +// botMembership says whether the bot is really a member of the server, which +// is what Discord means by "mutual guilds". A server where the app was added +// with only the applications.commands scope runs slash commands fine but has +// no bot user in it. +func botMembership(s *discordgo.Session, guildID string) string { + if s == nil || s.State == nil || s.State.User == nil { + return "bot user unknown" + } + inCache := false + if g, err := s.State.Guild(guildID); err == nil && g != nil { + inCache = true + } + if _, err := s.GuildMember(guildID, s.State.User.ID); err != nil { + return fmt.Sprintf("bot is NOT a member of that server (in gateway cache: %v): %v", inCache, err) + } + return fmt.Sprintf("bot is a member of that server (in gateway cache: %v)", inCache) +} -- 2.54.0