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 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 11:50:44 +01:00
1 parent 85bd89a3f6
commit 63f17365fc
4 files changed
+132 -29

No files matched your search

+55 -27
View File
@@ -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: &note, 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
}
+75
View File
@@ -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)
}
}