2022-01-31 06:46:43 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-02-07 12:21:40 +00:00
|
|
|
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
|
|
|
|
|
2022-01-31 06:46:43 +00:00
|
|
|
/*
|
|
|
|
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 {
|
2022-04-16 17:49:29 +00:00
|
|
|
_, err := db.QueryOne[models.DiscordMessage](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
2022-01-31 06:46:43 +00:00
|
|
|
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) {
|
2022-04-16 17:49:29 +00:00
|
|
|
interned, err := db.QueryOne[InternedMessage](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
|
|
|
FROM
|
2022-05-07 13:11:05 +00:00
|
|
|
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
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE message.id = $1
|
|
|
|
`,
|
|
|
|
msgId,
|
|
|
|
)
|
|
|
|
if err != nil {
|
2022-02-07 12:21:40 +00:00
|
|
|
return nil, err
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
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)
|
2022-02-07 12:21:40 +00:00
|
|
|
if err != nil && !errors.Is(err, db.NotFound) {
|
2022-01-31 06:46:43 +00:00
|
|
|
return err
|
2022-02-07 12:21:40 +00:00
|
|
|
} else if err == nil {
|
2022-01-31 06:46:43 +00:00
|
|
|
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 {
|
2022-04-16 17:49:29 +00:00
|
|
|
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM snippet
|
2022-01-31 06:46:43 +00:00
|
|
|
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:
|
2022-05-07 13:11:05 +00:00
|
|
|
// * discord_message_attachment
|
|
|
|
// * discord_message_content
|
|
|
|
// * discord_message_embed
|
2022-01-31 06:46:43 +00:00
|
|
|
// DOES NOT DELETE ASSETS FOR CONTENT/EMBEDS
|
|
|
|
_, err = dbConn.Exec(ctx,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
DELETE FROM discord_message
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
DELETE FROM snippet
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO discord_message_content (message_id, discord_id, last_content)
|
2022-01-31 06:51:34 +00:00
|
|
|
VALUES ($1, $2, $3)
|
|
|
|
ON CONFLICT (message_id) DO UPDATE SET
|
|
|
|
discord_id = EXCLUDED.discord_id,
|
|
|
|
last_content = EXCLUDED.last_content
|
|
|
|
`,
|
2022-01-31 06:46:43 +00:00
|
|
|
interned.Message.ID,
|
|
|
|
interned.DiscordUser.ID,
|
|
|
|
CleanUpMarkdown(ctx, msg.Content),
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return oops.New(err, "failed to create or update message contents")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
content, err := db.QueryOne[models.DiscordMessageContent](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
|
|
|
FROM
|
2022-05-07 13:11:05 +00:00
|
|
|
discord_message_content
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE
|
2022-05-07 13:11:05 +00:00
|
|
|
discord_message_content.message_id = $1
|
2022-01-31 06:46:43 +00:00
|
|
|
`,
|
|
|
|
interned.Message.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return oops.New(err, "failed to fetch message contents")
|
|
|
|
}
|
2022-04-16 17:49:29 +00:00
|
|
|
interned.MessageContent = content
|
2022-01-31 06:52:56 +00:00
|
|
|
}
|
2022-01-31 06:46:43 +00:00
|
|
|
|
|
|
|
// 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") {
|
2022-04-16 17:49:29 +00:00
|
|
|
numSavedEmbeds, err := db.QueryOneScalar[int](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
2022-04-16 17:49:29 +00:00
|
|
|
SELECT COUNT(*)
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_embed
|
2022-04-16 17:49:29 +00:00
|
|
|
WHERE message_id = $1
|
|
|
|
`,
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
DELETE FROM discord_message_embed
|
2022-01-31 06:46:43 +00:00
|
|
|
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
|
|
|
|
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
func DownloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
|
2022-01-31 06:46:43 +00:00
|
|
|
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 {
|
2022-05-30 18:56:09 +00:00
|
|
|
logging.ExtractLogger(ctx).Error().Str("Url", url).Msg("failed to download resource")
|
2022-05-30 18:52:13 +00:00
|
|
|
return nil, "", err
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2022-04-16 17:49:29 +00:00
|
|
|
existing, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_attachment
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE id = $1
|
|
|
|
`,
|
|
|
|
attachment.ID,
|
|
|
|
)
|
|
|
|
if err == nil {
|
2022-04-16 17:49:29 +00:00
|
|
|
return existing, nil
|
2022-01-31 06:46:43 +00:00
|
|
|
} 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
|
|
|
|
}
|
|
|
|
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
content, _, err := DownloadDiscordResource(ctx, attachment.Url)
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO discord_message_attachment (id, asset_id, message_id)
|
2022-01-31 06:46:43 +00:00
|
|
|
VALUES ($1, $2, $3)
|
|
|
|
`,
|
|
|
|
attachment.ID,
|
|
|
|
asset.ID,
|
|
|
|
discordMessageID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to save Discord attachment data")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
discordAttachment, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_attachment
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE id = $1
|
|
|
|
`,
|
|
|
|
attachment.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to fetch new Discord attachment data")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
return discordAttachment, nil
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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) {
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
content, contentType, err := DownloadDiscordResource(ctx, *i.Url)
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO discord_message_embed (title, description, url, message_id, image_id, video_id)
|
2022-01-31 06:46:43 +00:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
discordEmbed, err := db.QueryOne[models.DiscordMessageEmbed](ctx, tx,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_embed
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE id = $1
|
|
|
|
`,
|
|
|
|
savedEmbedId,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to fetch new Discord embed data")
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
return discordEmbed, nil
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
|
2022-04-16 17:49:29 +00:00
|
|
|
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM snippet
|
2022-01-31 06:46:43 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-16 17:49:29 +00:00
|
|
|
return snippet, nil
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
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).
|
2022-02-07 12:21:40 +00:00
|
|
|
linkedUserIsSnippetOwner := existingSnippet.OwnerID == interned.DiscordUser.HMNUserId
|
|
|
|
if linkedUserIsSnippetOwner && !existingSnippet.EditedOnWebsite {
|
2022-01-31 06:46:43 +00:00
|
|
|
contentMarkdown := interned.MessageContent.LastContent
|
|
|
|
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
|
|
|
|
|
|
|
_, err := tx.Exec(ctx,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
UPDATE snippet
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
2022-01-31 06:46:43 +00:00
|
|
|
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,
|
|
|
|
`
|
2022-05-07 13:11:05 +00:00
|
|
|
UPDATE discord_message
|
2022-01-31 06:46:43 +00:00
|
|
|
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)
|
|
|
|
|
2022-01-31 08:22:25 +00:00
|
|
|
// 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")
|
|
|
|
}
|
2022-01-31 06:46:43 +00:00
|
|
|
|
|
|
|
_, err = tx.Exec(ctx,
|
|
|
|
`
|
2022-08-05 04:03:45 +00:00
|
|
|
DELETE FROM snippet_project
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE
|
|
|
|
snippet_id = $1
|
2022-08-05 04:03:45 +00:00
|
|
|
AND kind = $2
|
2022-01-31 06:46:43 +00:00
|
|
|
`,
|
|
|
|
existingSnippet.ID,
|
2022-08-05 04:03:45 +00:00
|
|
|
models.SnippetProjectKindDiscord,
|
2022-01-31 06:46:43 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2022-08-05 04:03:45 +00:00
|
|
|
return oops.New(err, "failed to clear project association for snippet")
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
|
2022-08-05 04:03:45 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
2022-01-31 06:46:43 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
Add Discord login (#106)
This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.
When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.
Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.
Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.
If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.
(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)
Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.
Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
|
|
|
//
|
|
|
|
// Probably need to add word boundaries.
|
2022-01-31 06:46:43 +00:00
|
|
|
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?
|
2022-08-05 04:03:45 +00:00
|
|
|
// TODO(asaf): Centralize this
|
2022-01-31 06:46:43 +00:00
|
|
|
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
|
2022-04-16 17:49:29 +00:00
|
|
|
attachments, err := db.Query[models.DiscordMessageAttachment](ctx, tx,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_attachment
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE message_id = $1
|
|
|
|
`,
|
|
|
|
msg.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, oops.New(err, "failed to fetch message attachments")
|
|
|
|
}
|
2022-04-16 17:49:29 +00:00
|
|
|
for _, attachment := range attachments {
|
2022-01-31 06:46:43 +00:00
|
|
|
return &attachment.AssetID, nil, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check embeds
|
2022-04-16 17:49:29 +00:00
|
|
|
embeds, err := db.Query[models.DiscordMessageEmbed](ctx, tx,
|
2022-01-31 06:46:43 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2022-05-07 13:11:05 +00:00
|
|
|
FROM discord_message_embed
|
2022-01-31 06:46:43 +00:00
|
|
|
WHERE message_id = $1
|
|
|
|
`,
|
|
|
|
msg.ID,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, oops.New(err, "failed to fetch discord embeds")
|
|
|
|
}
|
2022-04-16 17:49:29 +00:00
|
|
|
for _, embed := range embeds {
|
2022-01-31 06:46:43 +00:00
|
|
|
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
|
|
|
|
}
|