package discord import ( "context" "errors" "fmt" "io" "net/http" "strings" "time" "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "github.com/google/uuid" ) /* Ensures that a Discord message is stored in the database. It will also store the message's contents, if allowed. This function is idempotent and can be called regardless of whether the item already exists in the database. This does not create snippets or do anything besides save the message itself. */ func PersistMessage( ctx context.Context, dbConn db.ConnOrTx, msg *Message, ) (*models.DiscordMessage, error) { tx, err := dbConn.Begin(ctx) if err != nil { return nil, oops.New(err, "failed to start transaction") } defer tx.Rollback(ctx) // For every single Discord message on the server, we store a lightweight // record of its existence. This allows us to efficiently retrieve message // contents when someone links their Discord account. Naturally, we do this // regardless of whether a user has linked their Discord account. // // Unless a user has linked their Discord account, no message content is // ever saved. iDiscordMessage, err := db.QueryOne(ctx, tx, models.DiscordMessage{}, ` SELECT $columns FROM handmade_discordmessage WHERE id = $1 `, msg.ID, ) if errors.Is(err, db.NotFound) { if !msg.OriginalHasFields("author", "timestamp") { return nil, errNotEnoughInfo } guildID := msg.GuildID if guildID == nil { /* This is weird, but it can happen when we fetch messages from history instead of receiving it from the gateway. In this case we just assume it's from the HMN server. */ guildID = &config.Config.Discord.GuildID } iDiscordMessage, err = db.QueryOne(ctx, tx, models.DiscordMessage{}, ` INSERT INTO handmade_discordmessage (id, channel_id, guild_id, url, user_id, sent_at, snippet_created) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING $columns `, msg.ID, msg.ChannelID, *guildID, msg.JumpURL(), msg.Author.ID, msg.Time(), false, ) if err != nil { return nil, oops.New(err, "failed to save new discord message") } } else if err != nil { return nil, oops.New(err, "failed to check for existing Discord message") } discordMessage := iDiscordMessage.(*models.DiscordMessage) // // Time to save content! // { // Check for linked Discord user iDiscordUser, err := db.QueryOne(ctx, tx, models.DiscordUser{}, ` SELECT $columns FROM handmade_discorduser WHERE userid = $1 `, discordMessage.UserID, ) if errors.Is(err, db.NotFound) { goto cancelSavingContent // considered harmful?? } else if err != nil { return nil, oops.New(err, "failed to look up linked Discord user") } discordUser := iDiscordUser.(*models.DiscordUser) // Save message text if msg.OriginalHasFields("content") { _, err = tx.Exec(ctx, ` INSERT INTO handmade_discordmessagecontent (message_id, discord_id, last_content) VALUES ($1, $2, $3) ON CONFLICT (message_id) DO UPDATE SET discord_id = EXCLUDED.discord_id, last_content = EXCLUDED.last_content `, discordMessage.ID, discordUser.ID, CleanUpMarkdown(ctx, msg.Content), ) } // Save attachments if msg.OriginalHasFields("attachments") { for _, attachment := range msg.Attachments { _, err := saveAttachment(ctx, tx, &attachment, discordUser.HMNUserId, msg.ID) if err != nil { return nil, oops.New(err, "failed to save attachment") } } } // Save / delete embeds if msg.OriginalHasFields("embeds") { numSavedEmbeds, err := db.QueryInt(ctx, tx, ` SELECT COUNT(*) FROM handmade_discordmessageembed WHERE message_id = $1 `, msg.ID, ) if err != nil { return nil, oops.New(err, "failed to count existing embeds") } if numSavedEmbeds == 0 { // No embeds yet, so save new ones for _, embed := range msg.Embeds { _, err := saveEmbed(ctx, tx, &embed, discordUser.HMNUserId, msg.ID) if err != nil { return nil, oops.New(err, "failed to save embed") } } } else if len(msg.Embeds) > 0 { // Embeds were removed from the message _, err := tx.Exec(ctx, ` DELETE FROM handmade_discordmessageembed WHERE message_id = $1 `, msg.ID, ) if err != nil { return nil, oops.New(err, "failed to delete embeds") } } } } cancelSavingContent: err = tx.Commit(ctx) if err != nil { return nil, oops.New(err, "failed to commit transaction") } return discordMessage, nil } /* Saves a Discord attachment as an HMN asset. Idempotent; will not create an attachment that already exists. */ func saveAttachment( ctx context.Context, tx db.ConnOrTx, attachment *Attachment, hmnUserID int, discordMessageID string, ) (*models.DiscordMessageAttachment, error) { iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{}, ` SELECT $columns FROM handmade_discordmessageattachment WHERE id = $1 `, attachment.ID, ) if err == nil { return iexisting.(*models.DiscordMessageAttachment), nil } else if errors.Is(err, db.NotFound) { // this is fine, just create it } else { return nil, oops.New(err, "failed to check for existing attachment") } width := 0 height := 0 if attachment.Width != nil { width = *attachment.Width } if attachment.Height != nil { height = *attachment.Height } content, _, err := downloadDiscordResource(ctx, attachment.Url) if err != nil { return nil, oops.New(err, "failed to download Discord attachment") } contentType := "application/octet-stream" if attachment.ContentType != nil { contentType = *attachment.ContentType } asset, err := assets.Create(ctx, tx, assets.CreateInput{ Content: content, Filename: attachment.Filename, ContentType: contentType, UploaderID: &hmnUserID, Width: width, Height: height, }) if err != nil { return nil, oops.New(err, "failed to save asset for Discord attachment") } iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{}, ` INSERT INTO handmade_discordmessageattachment (id, asset_id, message_id) VALUES ($1, $2, $3) RETURNING $columns `, attachment.ID, asset.ID, discordMessageID, ) if err != nil { return nil, oops.New(err, "failed to save Discord attachment data") } return iDiscordAttachment.(*models.DiscordMessageAttachment), nil } // Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it // if you do not have any embeds saved for this message yet. func saveEmbed( ctx context.Context, tx db.ConnOrTx, embed *Embed, hmnUserID int, discordMessageID string, ) (*models.DiscordMessageEmbed, error) { isOkImageType := func(contentType string) bool { return strings.HasPrefix(contentType, "image/") } isOkVideoType := func(contentType string) bool { return strings.HasPrefix(contentType, "video/") } maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) { content, contentType, err := downloadDiscordResource(ctx, *i.Url) if err != nil { var statusError DiscordResourceBadStatusCode if errors.As(err, &statusError) { return nil, nil } else { return nil, oops.New(err, "failed to save Discord embed") } } if contentTypeCheck(contentType) { in := assets.CreateInput{ Content: content, Filename: "embed", ContentType: contentType, UploaderID: &hmnUserID, } if i.Width != nil { in.Width = *i.Width } if i.Height != nil { in.Height = *i.Height } asset, err := assets.Create(ctx, tx, in) if err != nil { return nil, oops.New(err, "failed to create asset from embed") } return &asset.ID, nil } return nil, nil } var imageAssetId *uuid.UUID var videoAssetId *uuid.UUID var err error if embed.Video != nil && embed.Video.Url != nil { videoAssetId, err = maybeSaveImageish(embed.Video.EmbedImageish, isOkVideoType) } else if embed.Image != nil && embed.Image.Url != nil { imageAssetId, err = maybeSaveImageish(embed.Image.EmbedImageish, isOkImageType) } else if embed.Thumbnail != nil && embed.Thumbnail.Url != nil { imageAssetId, err = maybeSaveImageish(embed.Thumbnail.EmbedImageish, isOkImageType) } if err != nil { return nil, err } // Save the embed into the db iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{}, ` INSERT INTO handmade_discordmessageembed (title, description, url, message_id, image_id, video_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, embed.Title, embed.Description, embed.Url, discordMessageID, imageAssetId, videoAssetId, ) if err != nil { return nil, oops.New(err, "failed to insert new embed") } return iDiscordEmbed.(*models.DiscordMessageEmbed), nil } var discordDownloadClient = &http.Client{ Timeout: 10 * time.Second, } type DiscordResourceBadStatusCode error func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, "", oops.New(err, "failed to make Discord download request") } res, err := discordDownloadClient.Do(req) if err != nil { return nil, "", oops.New(err, "failed to fetch Discord resource data") } defer res.Body.Close() if res.StatusCode < 200 || 299 < res.StatusCode { return nil, "", DiscordResourceBadStatusCode(fmt.Errorf("status code %d from Discord resource: %s", res.StatusCode, url)) } content, err := io.ReadAll(res.Body) if err != nil { panic(err) } return content, res.Header.Get("Content-Type"), nil }