hmn/src/discord/message_handling.go

915 lines
24 KiB
Go

package discord
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"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/hmndata"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/parsing"
"github.com/google/uuid"
)
func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
deleted := false
var err error
// NOTE(asaf): All functions called here should verify that the message applies to them.
if !deleted && err == nil {
deleted, err = CleanUpLibrary(ctx, dbConn, msg)
}
if !deleted && err == nil {
deleted, err = CleanUpShowcase(ctx, dbConn, msg)
}
if !deleted && err == nil {
err = MaybeInternMessage(ctx, dbConn, msg)
}
if err == nil {
err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
}
return err
}
func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
deleted := false
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
switch msg.Type {
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
default:
return deleted, nil
}
hasGoodContent := true
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
hasGoodContent = false
}
hasGoodAttachments := true
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
hasGoodAttachments = false
}
if !hasGoodContent && !hasGoodAttachments {
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
if err != nil {
return deleted, oops.New(err, "failed to delete message")
}
deleted = true
if !msg.Author.IsBot {
channel, err := CreateDM(ctx, msg.Author.ID)
if err != nil {
return deleted, oops.New(err, "failed to create DM channel")
}
err = SendMessages(ctx, dbConn, MessageToSend{
ChannelID: channel.ID,
Req: CreateMessageRequest{
Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
},
})
if err != nil {
return deleted, oops.New(err, "failed to send showcase warning message")
}
}
}
}
return deleted, nil
}
func CleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
deleted := false
if msg.ChannelID == config.Config.Discord.LibraryChannelID {
switch msg.Type {
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
default:
return deleted, nil
}
if !msg.OriginalHasFields("content") {
return deleted, nil
}
if !messageHasLinks(msg.Content) {
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
if err != nil {
return deleted, oops.New(err, "failed to delete message")
}
deleted = true
if !msg.Author.IsBot {
channel, err := CreateDM(ctx, msg.Author.ID)
if err != nil {
return deleted, oops.New(err, "failed to create DM channel")
}
err = SendMessages(ctx, dbConn, MessageToSend{
ChannelID: channel.ID,
Req: CreateMessageRequest{
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
},
})
if err != nil {
return deleted, oops.New(err, "failed to send showcase warning message")
}
}
}
}
return deleted, nil
}
func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
err := InternMessage(ctx, dbConn, msg)
if errors.Is(err, errNotEnoughInfo) {
logging.ExtractLogger(ctx).Warn().
Interface("msg", msg).
Msg("didn't have enough info to intern Discord message")
} else if err != nil {
return err
}
}
return nil
}
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
/*
Ensures that a Discord message is stored in the database. This function is
idempotent and can be called regardless of whether the item already exists in
the database.
This does not create snippets or save content or do anything besides save the message itself.
*/
func InternMessage(
ctx context.Context,
dbConn db.ConnOrTx,
msg *Message,
) error {
_, err := db.QueryOne[models.DiscordMessage](ctx, dbConn,
`
SELECT $columns
FROM discord_message
WHERE id = $1
`,
msg.ID,
)
if errors.Is(err, db.NotFound) {
if !msg.OriginalHasFields("author", "timestamp") {
return 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
}
_, err = dbConn.Exec(ctx,
`
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
msg.ID,
msg.ChannelID,
*guildID,
msg.JumpURL(),
msg.Author.ID,
msg.Time(),
false,
)
if err != nil {
return oops.New(err, "failed to save new discord message")
}
} else if err != nil {
return oops.New(err, "failed to check for existing Discord message")
}
return nil
}
type InternedMessage struct {
Message models.DiscordMessage `db:"message"`
MessageContent *models.DiscordMessageContent `db:"content"`
HMNUser *models.User `db:"hmnuser"`
DiscordUser *models.DiscordUser `db:"duser"`
}
func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) (*InternedMessage, error) {
interned, err := db.QueryOne[InternedMessage](ctx, dbConn,
`
SELECT $columns
FROM
discord_message AS message
LEFT JOIN discord_message_content AS content ON content.message_id = message.id
LEFT JOIN discord_user AS duser ON duser.userid = message.user_id
LEFT JOIN hmn_user AS hmnuser ON hmnuser.id = duser.hmn_user_id
WHERE message.id = $1
`,
msgId,
)
if err != nil {
return nil, err
}
return interned, nil
}
// Checks if a message is interned and handles it to the extent possible:
// 1. Saves/updates content
// 2. Saves/updates snippet
// 3. Deletes content/snippet
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, deleted bool, createSnippet bool) error {
tx, err := dbConn.Begin(ctx)
if err != nil {
return oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
interned, err := FetchInternedMessage(ctx, tx, msg.ID)
if err != nil && !errors.Is(err, db.NotFound) {
return err
} else if err == nil {
if !deleted {
err = SaveMessageContents(ctx, tx, interned, msg)
if err != nil {
return err
}
if createSnippet {
err = HandleSnippetForInternedMessage(ctx, tx, interned, false)
if err != nil {
return err
}
}
} else {
err = DeleteInternedMessage(ctx, tx, interned)
if err != nil {
return err
}
}
}
err = tx.Commit(ctx)
if err != nil {
return oops.New(err, "failed to commit Discord message updates")
}
return nil
}
func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage) error {
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
`
SELECT $columns
FROM snippet
WHERE discord_message_id = $1
`,
interned.Message.ID,
)
if err != nil && !errors.Is(err, db.NotFound) {
return oops.New(err, "failed to fetch snippet for discord message")
}
// NOTE(asaf): Also deletes the following through a db cascade:
// * discord_message_attachment
// * discord_message_content
// * discord_message_embed
// DOES NOT DELETE ASSETS FOR CONTENT/EMBEDS
_, err = dbConn.Exec(ctx,
`
DELETE FROM discord_message
WHERE id = $1
`,
interned.Message.ID,
)
if snippet != nil {
userApprovesDeletion := interned.HMNUser != nil && snippet.OwnerID == interned.HMNUser.ID && interned.HMNUser.DiscordDeleteSnippetOnMessageDelete
if !snippet.EditedOnWebsite && userApprovesDeletion {
// NOTE(asaf): Does not delete asset!
_, err = dbConn.Exec(ctx,
`
DELETE FROM snippet
WHERE id = $1
`,
snippet.ID,
)
if err != nil {
return oops.New(err, "failed to delete snippet")
}
}
}
return nil
}
/*
Processes a single Discord message, saving as much of the message's content
and attachments as allowed by our rules and user settings. Does NOT create
snippets.
Idempotent; can be called any time whether the contents exist or not.
NOTE!!: Replaces interned.MessageContent if it was created or updated!!
*/
func SaveMessageContents(
ctx context.Context,
dbConn db.ConnOrTx,
interned *InternedMessage,
msg *Message,
) error {
if interned.DiscordUser != nil {
// We have a linked Discord account, so save the message contents (regardless of
// whether we create a snippet or not).
if msg.OriginalHasFields("content") {
_, err := dbConn.Exec(ctx,
`
INSERT INTO discord_message_content (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
`,
interned.Message.ID,
interned.DiscordUser.ID,
CleanUpMarkdown(ctx, msg.Content),
)
if err != nil {
return oops.New(err, "failed to create or update message contents")
}
content, err := db.QueryOne[models.DiscordMessageContent](ctx, dbConn,
`
SELECT $columns
FROM
discord_message_content
WHERE
discord_message_content.message_id = $1
`,
interned.Message.ID,
)
if err != nil {
return oops.New(err, "failed to fetch message contents")
}
interned.MessageContent = content
}
// Save attachments
if msg.OriginalHasFields("attachments") {
for _, attachment := range msg.Attachments {
_, err := saveAttachment(ctx, dbConn, &attachment, interned.DiscordUser.HMNUserId, msg.ID)
if err != nil {
return oops.New(err, "failed to save attachment")
}
}
}
// Save / delete embeds
if msg.OriginalHasFields("embeds") {
numSavedEmbeds, err := db.QueryOneScalar[int](ctx, dbConn,
`
SELECT COUNT(*)
FROM discord_message_embed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return 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, dbConn, &embed, interned.DiscordUser.HMNUserId, msg.ID)
if err != nil {
return oops.New(err, "failed to save embed")
}
}
} else if len(msg.Embeds) > 0 {
// Embeds were removed from the message
_, err := dbConn.Exec(ctx,
`
DELETE FROM discord_message_embed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return oops.New(err, "failed to delete embeds")
}
}
}
}
return 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 {
logging.ExtractLogger(ctx).Error().Str("Url", url).Msg("failed to download resource")
return nil, "", err
}
return content, res.Header.Get("Content-Type"), 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) {
existing, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
`
SELECT $columns
FROM discord_message_attachment
WHERE id = $1
`,
attachment.ID,
)
if err == nil {
return existing, 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")
}
// TODO(db): RETURNING plz thanks
_, err = tx.Exec(ctx,
`
INSERT INTO discord_message_attachment (id, asset_id, message_id)
VALUES ($1, $2, $3)
`,
attachment.ID,
asset.ID,
discordMessageID,
)
if err != nil {
return nil, oops.New(err, "failed to save Discord attachment data")
}
discordAttachment, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
`
SELECT $columns
FROM discord_message_attachment
WHERE id = $1
`,
attachment.ID,
)
if err != nil {
return nil, oops.New(err, "failed to fetch new Discord attachment data")
}
return discordAttachment, 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
// TODO(db): Insert, RETURNING
var savedEmbedId int
err = tx.QueryRow(ctx,
`
INSERT INTO discord_message_embed (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,
).Scan(&savedEmbedId)
if err != nil {
return nil, oops.New(err, "failed to insert new embed")
}
discordEmbed, err := db.QueryOne[models.DiscordMessageEmbed](ctx, tx,
`
SELECT $columns
FROM discord_message_embed
WHERE id = $1
`,
savedEmbedId,
)
if err != nil {
return nil, oops.New(err, "failed to fetch new Discord embed data")
}
return discordEmbed, nil
}
func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
`
SELECT $columns
FROM snippet
WHERE discord_message_id = $1
`,
msgID,
)
if err != nil {
if errors.Is(err, db.NotFound) {
return nil, nil
} else {
return nil, oops.New(err, "failed to fetch existing snippet for message %s", msgID)
}
}
return snippet, nil
}
/*
Potentially creates or updates a snippet for the given interned message.
It uses the content saved in the database to do this. If we do not have any
content saved, nothing will happen.
If a user does not have their Discord account linked, this function will
naturally do nothing because we have no message content saved.
If forceCreate is true, it does not check any user settings such as automatically creating snippets from
#project-showcase. If we have the content, it will make a snippet for it, no
questions asked. Bear that in mind.
*/
func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage, forceCreate bool) error {
if interned.HMNUser == nil {
// NOTE(asaf): Can't handle snippets when there's no linked user
return nil
}
if interned.MessageContent == nil {
// NOTE(asaf): Can't have a snippet without content
// NOTE(asaf): Messages that only have an attachment also have blank content
// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
return nil
}
tx, err := dbConn.Begin(ctx)
if err != nil {
oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
existingSnippet, err := FetchSnippetForMessage(ctx, tx, interned.Message.ID)
if err != nil {
return oops.New(err, "failed to check for existing snippet for message %s", interned.Message.ID)
}
if existingSnippet != nil {
// TODO(asaf): We're not handling the case where embeds were removed or modified.
// Also not handling the case where a message had both an attachment and an embed
// and the attachment was removed (leaving only the embed).
linkedUserIsSnippetOwner := existingSnippet.OwnerID == interned.DiscordUser.HMNUserId
if linkedUserIsSnippetOwner && !existingSnippet.EditedOnWebsite {
contentMarkdown := interned.MessageContent.LastContent
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
_, err := tx.Exec(ctx,
`
UPDATE snippet
SET
description = $1,
_description_html = $2
WHERE id = $3
`,
contentMarkdown,
contentHTML,
existingSnippet.ID,
)
if err != nil {
return oops.New(err, "failed to update content of snippet on message edit")
}
existingSnippet.Description = contentMarkdown
existingSnippet.DescriptionHtml = contentHTML
}
} else {
userAllowsSnippet := interned.HMNUser.DiscordSaveShowcase || forceCreate
shouldCreate := !interned.Message.SnippetCreated && userAllowsSnippet
if shouldCreate {
// Get an asset ID or URL to make a snippet from
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
if assetId != nil || url != nil {
contentMarkdown := interned.MessageContent.LastContent
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
_, err = tx.Exec(ctx,
`
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
url,
interned.Message.SentAt,
contentMarkdown,
contentHTML,
assetId,
interned.Message.ID,
interned.HMNUser.ID,
)
if err != nil {
return oops.New(err, "failed to create snippet from attachment")
}
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
if err != nil {
return oops.New(err, "failed to fetch newly-created snippet")
}
_, err = tx.Exec(ctx,
`
UPDATE discord_message
SET snippet_created = TRUE
WHERE id = $1
`,
interned.Message.ID,
)
if err != nil {
return oops.New(err, "failed to mark message as having snippet")
}
}
}
}
if existingSnippet != nil {
// Update tags
// Try to associate tags in the message with project tags in HMN.
// Match only tags for projects in which the current user is a collaborator.
messageTags := getDiscordTags(existingSnippet.Description)
// Fetch projects so we know what tags the user can apply to their snippet.
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
OwnerIDs: []int{interned.HMNUser.ID},
})
if err != nil {
return oops.New(err, "failed to look up user projects")
}
_, err = tx.Exec(ctx,
`
DELETE FROM snippet_project
WHERE
snippet_id = $1
AND kind = $2
`,
existingSnippet.ID,
models.SnippetProjectKindDiscord,
)
if err != nil {
return oops.New(err, "failed to clear project association for snippet")
}
for _, p := range projects {
if p.Tag != nil {
for _, messageTag := range messageTags {
if strings.EqualFold(p.Tag.Text, messageTag) {
_, err = tx.Exec(ctx,
`
INSERT INTO snippet_project (project_id, snippet_id, kind)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`,
p.Project.ID,
existingSnippet.ID,
models.SnippetProjectKindDiscord,
)
if err != nil {
return oops.New(err, "failed to associate snippet with project")
}
break
}
}
}
}
}
err = tx.Commit(ctx)
if err != nil {
return oops.New(err, "failed to commit transaction")
}
return nil
}
// TODO(asaf): I believe this will also match https://example.com?hello=1&whatever=5
// Probably need to add word boundaries.
var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`)
func getDiscordTags(content string) []string {
matches := REDiscordTag.FindAllStringSubmatch(content, -1)
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m[1]
}
return result
}
// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
// do we actually want to reuse those, or should we keep them separate?
// TODO(asaf): Centralize this
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
// Check attachments
attachments, err := db.Query[models.DiscordMessageAttachment](ctx, tx,
`
SELECT $columns
FROM discord_message_attachment
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, nil, oops.New(err, "failed to fetch message attachments")
}
for _, attachment := range attachments {
return &attachment.AssetID, nil, nil
}
// Check embeds
embeds, err := db.Query[models.DiscordMessageEmbed](ctx, tx,
`
SELECT $columns
FROM discord_message_embed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, nil, oops.New(err, "failed to fetch discord embeds")
}
for _, embed := range embeds {
if embed.VideoID != nil {
return embed.VideoID, nil, nil
} else if embed.ImageID != nil {
return embed.ImageID, nil, nil
} else if embed.URL != nil {
if RESnippetableUrl.MatchString(*embed.URL) {
return nil, embed.URL, nil
}
}
}
return nil, nil, nil
}
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
func messageHasLinks(content string) bool {
links := reDiscordMessageLink.FindAllString(content, -1)
for _, link := range links {
_, err := url.Parse(strings.TrimSpace(link))
if err == nil {
return true
}
}
return false
}