374 lines
9.6 KiB
Go
374 lines
9.6 KiB
Go
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
|
|
}
|