{{ .Extra }}
+ {{ .Extra }}
+ diff --git a/src/discord/cmd/cmd.go b/src/discord/cmd/cmd.go index 3282ce56..9bdfbdab 100644 --- a/src/discord/cmd/cmd.go +++ b/src/discord/cmd/cmd.go @@ -106,10 +106,11 @@ func init() { } guildMember, err := discord.GetGuildMember(ctx, config.Config.Discord.GuildID, message.Author.ID) // NOTE(asaf): We assume we're only working with one guild, because the discord API sucks and doesn't provide the guild in the message payload. - if err != nil { + if err == nil { + message.Member = &guildMember + } else { logging.Error().Err(err).Msg("failed to get guild member for message while scraping") } - message.Member = guildMember err = discord.HandleIncomingMessage(ctx, conn, message, true) if err != nil { diff --git a/src/discord/commands.go b/src/discord/commands.go index a9ae6a98..0e0efa7e 100644 --- a/src/discord/commands.go +++ b/src/discord/commands.go @@ -126,7 +126,7 @@ func (bot *botInstance) doInteraction(ctx context.Context, i *Interaction) { if join { logging.ExtractLogger(ctx).Debug().Interface("interaction", i).Msg("Got hmh replay") - err := AddGuildMemberRole(ctx, i.Member.User.ID, config.Config.Discord.HMHReplayRoleID) + err := AddGuildMemberRole(ctx, i.Member.User.ID, config.Config.Discord.HMHReplayRoleID, "HMH Replay slash command") if err != nil { err = sendEphemeralMessageForInteraction(ctx, i, "We failed to set the role. Please inform an admin.") if err != nil { @@ -139,7 +139,7 @@ func (bot *botInstance) doInteraction(ctx context.Context, i *Interaction) { } } } else { - err := RemoveGuildMemberRole(ctx, i.Member.User.ID, config.Config.Discord.HMHReplayRoleID) + err := RemoveGuildMemberRole(ctx, i.Member.User.ID, config.Config.Discord.HMHReplayRoleID, "HMH Replay slash command") if err != nil { err = sendEphemeralMessageForInteraction(ctx, i, "We failed to remove the role. Please inform an admin.") if err != nil { @@ -188,7 +188,7 @@ func (bot *botInstance) doInteraction(ctx context.Context, i *Interaction) { // Give user the Discord role roleID := hmndata.LatestJam.DiscordRoleIDs[config.Config.Env] utils.Assert(roleID) - err = AddGuildMemberRole(ctx, i.Member.User.ID, roleID) + err = AddGuildMemberRole(ctx, i.Member.User.ID, roleID, "/joinjam slash command") if err != nil { log.Error().Err(err).Msg("failed to give user the jam role") errOccurred = true diff --git a/src/discord/gateway.go b/src/discord/gateway.go index 445563fc..3840a22f 100644 --- a/src/discord/gateway.go +++ b/src/discord/gateway.go @@ -31,8 +31,9 @@ type BotEvent struct { var botEvents = make([]BotEvent, 0, 1000) var botEventsMutex = sync.Mutex{} +var RecordAllGatewayMessages bool -func RecordBotEvent(name, extra string) { +func recordBotEvent(name, extra string) { botEventsMutex.Lock() defer botEventsMutex.Unlock() if len(botEvents) > 1000 { @@ -87,7 +88,7 @@ func RunDiscordBot(dbConn *pgxpool.Pool) *jobs.Job { if err != nil { disconnectMessage = err.Error() } - RecordBotEvent("Disconnected", disconnectMessage) + recordBotEvent("Disconnected", disconnectMessage) if err != nil { dur := boff.Duration() log.Error(). @@ -192,16 +193,14 @@ func (bot *botInstance) Run(ctx context.Context) (err error) { for { msg, err := bot.receiveGatewayMessage(ctx) - if err != nil { - if errors.Is(err, net.ErrClosed) { - // If the connection is closed, that's our cue to shut down the bot. Any errors - // related to the closure will have been logged elsewhere anyway. - return nil - } else { - // NOTE(ben): I don't know what events we might get in the future that we might - // want to handle gracefully (like above). Keep an eye out. - return oops.New(err, "failed to receive message from the gateway") - } + if errors.Is(err, net.ErrClosed) { + // If the connection is closed, that's our cue to shut down the bot. Any errors + // related to the closure will have been logged elsewhere anyway. + return nil + } else if err != nil { + // NOTE(ben): I don't know what events we might get in the future that we might + // want to handle gracefully (like above). Keep an eye out. + return oops.New(err, "failed to receive message from the gateway") } // Update the sequence number in the db @@ -227,7 +226,7 @@ func (bot *botInstance) Run(ctx context.Context) (err error) { logging.ExtractLogger(ctx).Info().Msg("Discord asked us to reconnect to the gateway") return nil case OpcodeInvalidSession: - RecordBotEvent("Failed to resume - invalid session", "") + recordBotEvent("Failed to resume - invalid session", "") // We tried to resume but the session was invalid. // Delete the session and reconnect from scratch again. _, err := bot.dbConn.Exec(ctx, `DELETE FROM discord_session`) @@ -299,9 +298,9 @@ func (bot *botInstance) connect(ctx context.Context) error { } } - RecordBotEvent("Connected", "") + recordBotEvent("Connected", "") if shouldResume { - RecordBotEvent("Resuming with session ID", session.ID) + recordBotEvent("Resuming with session ID", session.ID) // Reconnect to the previous session bot.resuming = true err := bot.sendGatewayMessage(ctx, GatewayMessage{ @@ -551,6 +550,9 @@ func (bot *botInstance) receiveGatewayMessage(ctx context.Context) (*GatewayMess if err != nil { return nil, err } + if RecordAllGatewayMessages { + recordBotEvent("Gateway message", string(msgBytes)) + } var msg GatewayMessage err = json.Unmarshal(msgBytes, &msg) @@ -583,7 +585,7 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage if msg.EventName != nil { name = *msg.EventName } - RecordBotEvent("Got event while resuming", name) + recordBotEvent("Got event while resuming", name) } switch *msg.EventName { case "RESUMED": @@ -591,19 +593,19 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session") bot.resuming = false - RecordBotEvent("Done resuming", "") + recordBotEvent("Done resuming", "") bot.createApplicationCommands(ctx) case "MESSAGE_CREATE": newMessage := *MessageFromMap(msg.Data, "") - err := bot.messageCreateOrUpdate(ctx, &newMessage) + err := bot.messageCreateOrUpdate(ctx, newMessage) if err != nil { return oops.New(err, "error on new message") } case "MESSAGE_UPDATE": newMessage := *MessageFromMap(msg.Data, "") - err := bot.messageCreateOrUpdate(ctx, &newMessage) + err := bot.messageCreateOrUpdate(ctx, newMessage) if err != nil { return oops.New(err, "error on updated message") } @@ -633,7 +635,7 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage } // Only return an error if we want to restart the bot. -func (bot *botInstance) messageCreateOrUpdate(ctx context.Context, msg *Message) error { +func (bot *botInstance) messageCreateOrUpdate(ctx context.Context, msg Message) error { if msg.OriginalHasFields("author") && msg.Author.ID == config.Config.Discord.BotUserID { // Don't process your own messages return nil diff --git a/src/discord/history.go b/src/discord/history.go index 8fcc683e..139ef350 100644 --- a/src/discord/history.go +++ b/src/discord/history.go @@ -171,10 +171,10 @@ func ScrapeRecents(ctx context.Context, dbConn *pgxpool.Pool, channelID string, logging.Error().Err(err).Msg("failed to get guild member for message while scraping") return false } - msg.Member = guildMember + msg.Member = &guildMember msg.Backfilled = true - err = HandleIncomingMessage(ctx, dbConn, &msg, false) + err = HandleIncomingMessage(ctx, dbConn, msg, false) if err != nil { errLog := logging.ExtractLogger(ctx).Error() @@ -226,9 +226,9 @@ func ScrapeAll(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earl } msg.Backfilled = true - err := TrackMessage(ctx, dbConn, &msg) + err := TrackMessage(ctx, dbConn, msg) if err == nil { - err = UpdateInternedMessage(ctx, dbConn, &msg, false, false, false) + err = UpdateInternedMessage(ctx, dbConn, msg, false, false, false) } if err != nil { diff --git a/src/discord/message_handling.go b/src/discord/message_handling.go index 07f70c76..b7de3a57 100644 --- a/src/discord/message_handling.go +++ b/src/discord/message_handling.go @@ -57,7 +57,7 @@ func shouldAutomaticallyCreateSnippet(interned *InternedMessage) bool { } } -func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, notifyUser bool) error { +func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg Message, notifyUser bool) error { var deleted bool var err error @@ -96,7 +96,7 @@ var githubRegex = regexp.MustCompile(`^(https:\/\/)?(www\.|gist\.)?github.com`) var steampoweredRegex = regexp.MustCompile(`steampowered\.com`) var steamCutoffDate = time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC) -func cleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) { +func cleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg Message) (bool, error) { if msg.ChannelID != config.Config.Discord.ShowcaseChannelID { return false, nil } @@ -155,7 +155,7 @@ func cleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (boo return false, nil } -func cleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) { +func cleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg Message) (bool, error) { if msg.ChannelID != config.Config.Discord.LibraryChannelID { return false, nil } @@ -191,7 +191,7 @@ record of the message itself. func TrackMessage( ctx context.Context, dbConn db.ConnOrTx, - msg *Message, + msg Message, ) error { if !msg.OriginalHasFields("author", "timestamp") { return errNotEnoughInfo @@ -257,7 +257,7 @@ func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) func UpdateInternedMessage( ctx context.Context, dbConn db.ConnOrTx, - msg *Message, + msg Message, messageDeleted bool, // whether the message was deleted before this update, e.g. as part of showcase cleanup canCreateSnippet bool, // if false, no snippet will be created for this message regardless of contents notifyUser bool, // whether to notify the user of any problems with their message @@ -363,7 +363,7 @@ func SaveMessageContents( ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage, - msg *Message, + msg Message, notifyUser bool, ) (contentChanged bool, err error) { if interned.DiscordUser == nil { @@ -1013,7 +1013,7 @@ func messageHasLinks(content string) bool { return false } -func messageIsSnippetable(msg *Message) bool { +func messageIsSnippetable(msg Message) bool { hasGoodContent := true if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) { hasGoodContent = false @@ -1062,7 +1062,7 @@ func SendDM(ctx context.Context, dbConn db.ConnOrTx, authorID string, text strin return nil } -func RebukeMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, noteToUser string) (deleted bool, err error) { +func RebukeMessage(ctx context.Context, dbConn db.ConnOrTx, msg Message, noteToUser string) (deleted bool, err error) { err = DeleteMessage(ctx, msg.ChannelID, msg.ID) if err != nil { return false, oops.New(err, "failed to delete message") diff --git a/src/discord/rest.go b/src/discord/rest.go index f852ec44..545915fa 100644 --- a/src/discord/rest.go +++ b/src/discord/rest.go @@ -9,9 +9,9 @@ import ( "io" "mime/multipart" "net/http" - "net/http/httputil" "net/textproto" "net/url" + "os" "strconv" "git.handmade.network/hmn/hmn/src/config" @@ -39,7 +39,11 @@ func buildUrl(path string) string { return fmt.Sprintf("%s%s", BaseUrl, path) } -func makeRequest(ctx context.Context, method string, path string, body []byte) *http.Request { +// NOTE(ben): Creates an [http.Request] to send to Discord, with the correct +// auth and user-agent headers. Also attaches the given body to the request. In +// many cases the request will need to be modified further, e.g. setting +// Content-Type or query params. +func createTypicalRequest(ctx context.Context, method string, path string, body []byte) *http.Request { var bodyReader io.Reader if body != nil { bodyReader = bytes.NewBuffer(body) @@ -55,149 +59,137 @@ func makeRequest(ctx context.Context, method string, path string, body []byte) * return req } -type GetGatewayBotResponse struct { - URL string `json:"url"` - // We don't care about shards or session limit stuff; we will never hit those limits -} - -func GetGatewayBot(ctx context.Context) (*GetGatewayBotResponse, error) { - const name = "Get Gateway Bot" +// NOTE(ben): Performs the given request with rate limiting and returns the +// response + the full response body. Returns an error if the status code is +// not in the OK range. 404s are always turned into [NotFound] errors. +// +// If you want to read the response as JSON, use [unmarshal] with the results. +// +// In most cases you can use [doSimpleGetRequest] for GET, +// [doSimplePostishRequest] for POST or PATCH etc., and [doSimpleDeleteRequest] +// for DELETE. +func doRequest(ctx context.Context, name string, getReq func(context.Context) *http.Request) (context.Context, *http.Response, []byte, error) { + l := logging.ExtractLogger(ctx).With().Str("name", name).Logger() + ctx = logging.AttachLoggerToContext(&l, ctx) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodGet, "/gateway/bot", nil) - }) + res, err := doWithRateLimiting(ctx, name, getReq) if err != nil { - return nil, err + return ctx, nil, nil, err } defer res.Body.Close() - if res.StatusCode != 200 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") + bodyBytes := utils.Must1(io.ReadAll(res.Body)) + if res.StatusCode == http.StatusNotFound { + return ctx, nil, nil, NotFound + } else if res.StatusCode >= 400 { + logErrorResponse(ctx, res, bodyBytes, "") + return ctx, nil, nil, oops.New(nil, "received error from Discord") } - body, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } + return ctx, res, bodyBytes, nil +} - var result GetGatewayBotResponse - err = json.Unmarshal(body, &result) +// NOTE(ben): Takes the result of [doRequest] and unmarshals the result +// as JSON. +func unmarshal[T any](_ context.Context, _ *http.Response, body []byte, err error) (T, error) { + var zero T if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord response") + return zero, err } - return &result, nil -} - -func GetGuildRoles(ctx context.Context, guildID string) ([]Role, error) { - const name = "Get Guild Roles" - - path := fmt.Sprintf("/guilds/%s/roles", guildID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodGet, path, nil) - }) + var payload T + err = json.Unmarshal(body, &payload) if err != nil { - return nil, err + return zero, oops.New(err, "failed to unmarshal Discord message") } - defer res.Body.Close() + return payload, nil +} - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } +// NOTE(ben): Takes the result of [doRequest] and discards everything but the +// error. +func discard(_ context.Context, _ *http.Response, _ []byte, err error) error { + return err +} - bodyBytes, err := io.ReadAll(res.Body) +// NOTE(ben): Takes the result of [doRequest] and asserts that the result +// was a 204 No Content. +func expectNoContent(ctx context.Context, res *http.Response, body []byte, err error) error { if err != nil { - panic(err) + return err } - - var roles []Role - err = json.Unmarshal(bodyBytes, &roles) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") + if res.StatusCode != http.StatusNoContent { + logErrorResponse(ctx, res, body, "") + return oops.New(nil, "expected 204 but got %d", res.StatusCode) } - - return roles, nil + return nil } -func GetGuildChannels(ctx context.Context, guildID string) ([]Channel, error) { - const name = "Get Guild Channels" +// NOTE(ben): Does a GET request to the given path and unmarshals the result. +func doSimpleGetRequest[T any](ctx context.Context, name, path string) (T, error) { + return unmarshal[T](doRequest(ctx, name, func(ctx context.Context) *http.Request { + return createTypicalRequest(ctx, http.MethodGet, path, nil) + })) +} - path := fmt.Sprintf("/guilds/%s/channels", guildID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodGet, path, nil) +// NOTE(ben): Does a request with a method, content-type, and body of your +// choice. Designed to work for POST, PATCH, and similar where you don't need +// to provide other headers. Does not do anything with the result, so consider +// using [unmarshal], [expectNoContent], etc. +func doSimplePostishRequest(ctx context.Context, name, method, path, contentType string, body []byte) (context.Context, *http.Response, []byte, error) { + return doRequest(ctx, name, func(ctx context.Context) *http.Request { + req := createTypicalRequest(ctx, method, path, body) + req.Header.Add("Content-Type", contentType) + return req }) - if err != nil { - return nil, err - } - defer res.Body.Close() +} - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } +// NOTE(ben): Does a DELETE request to the given path and expects a 204. +func doSimpleDeleteRequest(ctx context.Context, name, path string) error { + return expectNoContent(doRequest(ctx, name, func(ctx context.Context) *http.Request { + return createTypicalRequest(ctx, http.MethodDelete, path, nil) + })) +} - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } +type GetGatewayBotResponse struct { + URL string `json:"url"` + // We don't care about shards or session limit stuff; we will never hit those limits +} - var channels []Channel - err = json.Unmarshal(bodyBytes, &channels) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } +// https://docs.discord.com/developers/events/gateway#get-gateway-bot +func GetGatewayBot(ctx context.Context) (GetGatewayBotResponse, error) { + return doSimpleGetRequest[GetGatewayBotResponse](ctx, "Get Gateway Bot", "/gateway/bot") +} - return channels, nil +// https://docs.discord.com/developers/resources/guild#get-guild-roles +func GetGuildRoles(ctx context.Context, guildID string) ([]Role, error) { + path := fmt.Sprintf("/guilds/%s/roles", guildID) + return doSimpleGetRequest[[]Role](ctx, "Get Guild Roles", path) } -func GetGuildMember(ctx context.Context, guildID, userID string) (*GuildMember, error) { - const name = "Get Guild Member" +// https://docs.discord.com/developers/resources/guild#get-guild-channels +func GetGuildChannels(ctx context.Context, guildID string) ([]Channel, error) { + path := fmt.Sprintf("/guilds/%s/channels", guildID) + return doSimpleGetRequest[[]Channel](ctx, "Get Guild Channels", path) +} +// https://docs.discord.com/developers/resources/guild#get-guild-member +func GetGuildMember(ctx context.Context, guildID, userID string) (GuildMember, error) { path := fmt.Sprintf("/guilds/%s/members/%s", guildID, userID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodGet, path, nil) - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode == http.StatusNotFound { - return nil, NotFound - } else if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg GuildMember - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return &msg, nil + return doSimpleGetRequest[GuildMember](ctx, "Get Guild Member", path) } // WARNING: This function is very expensive, as it must make several paginated requests. It will // block while doing so. It will also allocate memory for all of the guild members too. Please // consider whether you actually need to do this, and take appropriate precautions if you must. func ListGuildMembers(ctx context.Context, guildID string) ([]GuildMember, error) { - const name = "List Guild Members" path := fmt.Sprintf("/guilds/%s/members", guildID) const limit = 1000 var allMembers []GuildMember var lastID string for { - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodGet, path, nil) + msg, err := unmarshal[[]GuildMember](doRequest(ctx, "List Guild Members", func(ctx context.Context) *http.Request { + req := createTypicalRequest(ctx, http.MethodGet, path, nil) q := req.URL.Query() q.Add("limit", strconv.Itoa(limit)) if lastID != "" { @@ -206,29 +198,10 @@ func ListGuildMembers(ctx context.Context, guildID string) ([]GuildMember, error req.URL.RawQuery = q.Encode() return req - }) + })) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode == http.StatusNotFound { - return nil, NotFound - } else if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg []GuildMember - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } if len(msg) > 0 { lastMember := msg[len(msg)-1] @@ -267,134 +240,34 @@ type CreateMessageRequest struct { AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"` } -func CreateMessage(ctx context.Context, channelID string, payloadJSON string, files ...FileUpload) (*Message, error) { - const name = "Create Message" - - contentType, body := makeNewMessageBody(payloadJSON, files) - +// https://docs.discord.com/developers/resources/message#create-message +func CreateMessage(ctx context.Context, channelID string, payloadJSON string, files ...FileUpload) (Message, error) { path := fmt.Sprintf("/channels/%s/messages", channelID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPost, path, body) - req.Header.Add("Content-Type", contentType) - return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } + contentType, body := makeNewMessageBody(payloadJSON, files) + return unmarshal[Message](doSimplePostishRequest(ctx, "Create Message", http.MethodPost, path, contentType, body)) // Maybe in the future we could more nicely handle errors like "bad channel", // but honestly what are the odds that we mess that up... - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg Message - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return &msg, nil } -func EditMessage(ctx context.Context, channelID string, messageID string, payloadJSON string, files ...FileUpload) (*Message, error) { - const name = "Edit Message" - - contentType, body := makeNewMessageBody(payloadJSON, files) - +// https://docs.discord.com/developers/resources/message#edit-message +func EditMessage(ctx context.Context, channelID string, messageID string, payloadJSON string, files ...FileUpload) (Message, error) { path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPatch, path, body) - req.Header.Add("Content-Type", contentType) - return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - // Maybe in the future we could more nicely handle errors like "bad channel", - // but honestly what are the odds that we mess that up... - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg Message - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return &msg, nil + contentType, body := makeNewMessageBody(payloadJSON, files) + return unmarshal[Message](doSimplePostishRequest(ctx, "Edit Message", http.MethodPatch, path, contentType, body)) } +// https://docs.discord.com/developers/resources/message#delete-message func DeleteMessage(ctx context.Context, channelID string, messageID string) error { - const name = "Delete Message" - path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodDelete, path, nil) - }) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusNoContent { - logErrorResponse(ctx, name, res, "") - return oops.New(nil, "got unexpected status code when deleting message") - } - - return nil + return doSimpleDeleteRequest(ctx, "Delete Message", path) } -func CreateDM(ctx context.Context, recipientID string) (*Channel, error) { - const name = "Create DM" - +// https://docs.discord.com/developers/resources/user#create-dm +func CreateDM(ctx context.Context, recipientID string) (Channel, error) { path := "/users/@me/channels" body := fmt.Appendf(nil, `{"recipient_id":"%s"}`, recipientID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPost, path, body) - req.Header.Add("Content-Type", "application/json") - return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var channel Channel - err = json.Unmarshal(bodyBytes, &channel) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord channel") - } - - return &channel, nil + return unmarshal[Channel](doSimplePostishRequest(ctx, "Create DM", http.MethodPost, path, "application/json", body)) } type OAuthCodeExchangeResponse struct { @@ -405,9 +278,7 @@ type OAuthCodeExchangeResponse struct { Scope string `json:"scope"` } -func ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (*OAuthCodeExchangeResponse, error) { - const name = "OAuth Code Exchange" - +func ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (OAuthCodeExchangeResponse, error) { body := make(url.Values) body.Set("client_id", config.Config.Discord.OAuthClientID) body.Set("client_secret", config.Config.Discord.OAuthClientSecret) @@ -416,40 +287,16 @@ func ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (*OAuthCod body.Set("redirect_uri", redirectURI) bodyStr := body.Encode() - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPost, "/oauth2/token", []byte(bodyStr)) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - - return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var tokenResponse OAuthCodeExchangeResponse - err = json.Unmarshal(bodyBytes, &tokenResponse) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord OAuth token") - } - - return &tokenResponse, nil + return unmarshal[OAuthCodeExchangeResponse](doSimplePostishRequest(ctx, + "OAuth Code Exchange", + http.MethodPost, "/oauth2/token", + "application/x-www-form-urlencoded", []byte(bodyStr), + )) } -func GetCurrentUserAsOAuth(ctx context.Context, accessToken string) (*User, error) { - const name = "Get Current User" - - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { +// https://docs.discord.com/developers/resources/user#get-current-user +func GetCurrentUserAsOAuth(ctx context.Context, accessToken string) (User, error) { + return unmarshal[User](doRequest(ctx, "Get Current User", func(ctx context.Context) *http.Request { req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildUrl("/users/@me"), nil) if err != nil { panic(err) @@ -458,102 +305,33 @@ func GetCurrentUserAsOAuth(ctx context.Context, accessToken string) (*User, erro req.Header.Add("User-Agent", UserAgent) return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var user User - err = json.Unmarshal(bodyBytes, &user) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord user") - } - - return &user, nil + })) } -func AddGuildMemberRole(ctx context.Context, userID, roleID string) error { - const name = "Add Guild Member Role" - +// https://docs.discord.com/developers/resources/guild#add-guild-member-role +func AddGuildMemberRole(ctx context.Context, userID, roleID, reason string) error { path := fmt.Sprintf("/guilds/%s/members/%s/roles/%s", config.Config.Discord.GuildID, userID, roleID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodPut, path, nil) - }) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusNoContent { - logErrorResponse(ctx, name, res, "") - return oops.New(nil, "got unexpected status code when adding role") - } - - return nil + return expectNoContent(doRequest(ctx, "Add Guild Member Role", func(ctx context.Context) *http.Request { + req := createTypicalRequest(ctx, http.MethodPut, path, nil) + req.Header.Add("X-Audit-Log-Reason", reason) + return req + })) } -func RemoveGuildMemberRole(ctx context.Context, userID, roleID string) error { - const name = "Remove Guild Member Role" - +// https://docs.discord.com/developers/resources/guild#remove-guild-member-role +func RemoveGuildMemberRole(ctx context.Context, userID, roleID, reason string) error { path := fmt.Sprintf("/guilds/%s/members/%s/roles/%s", config.Config.Discord.GuildID, userID, roleID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodDelete, path, nil) - }) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusNoContent { - logErrorResponse(ctx, name, res, "") - return oops.New(nil, "got unexpected status code when removing role") - } - - return nil + return expectNoContent(doRequest(ctx, "Remove Guild Member Role", func(ctx context.Context) *http.Request { + req := createTypicalRequest(ctx, http.MethodDelete, path, nil) + req.Header.Add("X-Audit-Log-Reason", reason) + return req + })) } -func GetChannelMessage(ctx context.Context, channelID, messageID string) (*Message, error) { - const name = "Get Channel Message" - +// https://docs.discord.com/developers/resources/message#get-channel-message +func GetChannelMessage(ctx context.Context, channelID, messageID string) (Message, error) { path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - return makeRequest(ctx, http.MethodGet, path, nil) - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode == http.StatusNotFound { - return nil, NotFound - } else if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg Message - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return &msg, nil + return doSimpleGetRequest[Message](ctx, "Get Channel Message", path) } type GetChannelMessagesInput struct { @@ -563,12 +341,11 @@ type GetChannelMessagesInput struct { Limit int } +// https://docs.discord.com/developers/resources/message#get-channel-messages func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMessagesInput) ([]Message, error) { - const name = "Get Channel Messages" - path := fmt.Sprintf("/channels/%s/messages", channelID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodGet, path, nil) + return unmarshal[[]Message](doRequest(ctx, "Get Channel Messages", func(ctx context.Context) *http.Request { + req := createTypicalRequest(ctx, http.MethodGet, path, nil) q := req.URL.Query() if in.Around != "" { q.Add("around", in.Around) @@ -585,29 +362,7 @@ func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMess req.URL.RawQuery = q.Encode() return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msgs []Message - err = json.Unmarshal(bodyBytes, &msgs) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return msgs, nil + })) } // See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command-json-params @@ -622,96 +377,47 @@ type CreateGuildApplicationCommandRequest struct { // See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command func CreateGuildApplicationCommand(ctx context.Context, in CreateGuildApplicationCommandRequest) error { - const name = "Create Guild Application Command" - if in.Type == 0 { in.Type = ApplicationCommandTypeChatInput } - - payloadJSON, err := json.Marshal(in) - if err != nil { - return oops.New(nil, "failed to marshal request body") - } + payloadJSON := utils.Must1(json.Marshal(in)) path := fmt.Sprintf("/applications/%s/guilds/%s/commands", config.Config.Discord.BotUserID, config.Config.Discord.GuildID) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON)) - req.Header.Add("Content-Type", "application/json") - return req - }) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return oops.New(nil, "received error from Discord") - } - - return nil + return discard(doSimplePostishRequest(ctx, + "Create Guild Application Command", + http.MethodPost, path, + "application/json", payloadJSON, + )) } +// https://docs.discord.com/developers/interactions/receiving-and-responding#create-interaction-response func CreateInteractionResponse(ctx context.Context, interactionID, interactionToken string, in InteractionResponse) error { - const name = "Create Interaction Response" - payloadJSON, err := json.Marshal(in) if err != nil { return oops.New(nil, "failed to marshal request body") } + // NOTE(ben): There is a flavor of this API now where we could get an + // Interaction Callback Response. Evidently we don't need this right now, but + // in the future it might be good to switch to that version here. path := fmt.Sprintf("/interactions/%s/%s/callback", interactionID, interactionToken) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON)) - req.Header.Add("Content-Type", "application/json") - return req - }) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return oops.New(nil, "received error from Discord") - } - - return nil + return discard(doSimplePostishRequest(ctx, + "Create Interaction Response", + http.MethodPost, path, + "application/json", payloadJSON, + )) } -func EditOriginalInteractionResponse(ctx context.Context, interactionToken string, payloadJSON string, files ...FileUpload) (*Message, error) { - const name = "Edit Original Interaction Response" - +// https://docs.discord.com/developers/interactions/receiving-and-responding#edit-original-interaction-response +func EditOriginalInteractionResponse(ctx context.Context, interactionToken string, payloadJSON string, files ...FileUpload) (Message, error) { contentType, body := makeNewMessageBody(payloadJSON, files) path := fmt.Sprintf("/webhooks/%s/%s/messages/@original", config.Config.Discord.BotUserID, interactionToken) - res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { - req := makeRequest(ctx, http.MethodPatch, path, body) - req.Header.Add("Content-Type", contentType) - return req - }) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - logErrorResponse(ctx, name, res, "") - return nil, oops.New(nil, "received error from Discord") - } - - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - var msg Message - err = json.Unmarshal(bodyBytes, &msg) - if err != nil { - return nil, oops.New(err, "failed to unmarshal Discord message") - } - - return &msg, nil + return unmarshal[Message](doSimplePostishRequest(ctx, + "Edit Original Interaction Response", + http.MethodPatch, path, + contentType, body, + )) } func GetAuthorizeUrl(state string, includeEmail bool) string { @@ -767,12 +473,8 @@ func makeNewMessageBody(payloadJSON string, files []FileUpload) (contentType str return } -func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) { - dump, err := httputil.DumpResponse(res, true) - if err != nil { - panic(err) - } - - logging.ExtractLogger(ctx).Error().Str("name", name).Msg(msg) - fmt.Println(string(dump)) +func logErrorResponse(ctx context.Context, res *http.Response, bodyBytes []byte, msg string) { + logging.ExtractLogger(ctx).Error().Msg(msg) + res.Write(os.Stderr) + os.Stderr.Write(bodyBytes) } diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 06e2c0c7..19a3130f 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -932,6 +932,12 @@ func BuildDiscordBotDebugPage() string { return Url("/discord_bot_debug", nil) } +var RegexDiscordBotDebugToggleRecordAll = regexp.MustCompile("^/discord_bot_debug/record_all$") + +func BuildDiscordBotDebugToggleRecordAll() string { + return Url("/discord_bot_debug/record_all", nil) +} + /* * API */ diff --git a/src/templates/src/discord_bot_debug.html b/src/templates/src/discord_bot_debug.html index e06ff693..f015bcec 100644 --- a/src/templates/src/discord_bot_debug.html +++ b/src/templates/src/discord_bot_debug.html @@ -1,22 +1,41 @@ -{{ template "base.html" . }} +{{ template "base-2024.html" . }} + +{{ define "extrahead" }} + +{{ end }} {{ define "content" }} -
| Timestamp | -Name | -Extras | -
| {{ rfc3339 .Timestamp }} | -{{ .Name }} | -{{ .Extra }} | -
{{ .Extra }}
+ {{ .Extra }}
+