422 lines
11 KiB
Go
422 lines
11 KiB
Go
package discord
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"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/rs/zerolog/log"
|
|
)
|
|
|
|
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
|
|
|
|
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
|
|
|
|
// For any persisted Discord message, attempt to create or update a snippet.
|
|
func makeSnippet(
|
|
ctx context.Context,
|
|
dbConn db.ConnOrTx,
|
|
discordMsg *Message,
|
|
hmnMsg *models.DiscordMessage,
|
|
) error {
|
|
if discordMsg.ChannelID != config.Config.Discord.ShowcaseChannelID {
|
|
return nil
|
|
}
|
|
|
|
switch discordMsg.Type {
|
|
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
tx, err := dbConn.Begin(ctx)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// ...and maybe make a snippet too, if the user wants us to
|
|
duser, err := hmndata.FetchDiscordUser(ctx, tx, hmnMsg.UserID)
|
|
if err == nil && duser.HMNUser.DiscordSaveShowcase {
|
|
err = CreateMessageSnippets(ctx, tx, hmnMsg.UserID, discordMsg.ID)
|
|
if err != nil {
|
|
return oops.New(err, "failed to create snippet in gateway")
|
|
}
|
|
} else {
|
|
if err == db.NotFound {
|
|
// this is fine, just don't create a snippet
|
|
} else {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err = tx.Commit(ctx)
|
|
if err != nil {
|
|
return oops.New(err, "failed to commit Discord message updates")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func maybeDeleteShowcaseMsg(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (didDelete bool, err error) {
|
|
// Ensure basics about the message
|
|
if msg.ChannelID != config.Config.Discord.ShowcaseChannelID {
|
|
return false, nil
|
|
}
|
|
switch msg.Type {
|
|
case MessageTypeDefault, MessageTypeReply:
|
|
// proceed
|
|
default:
|
|
return false, nil
|
|
}
|
|
|
|
// Check message content and stuff
|
|
hasGoodContent := true
|
|
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
|
hasGoodContent = false
|
|
}
|
|
|
|
hasGoodAttachments := true
|
|
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
|
hasGoodAttachments = false
|
|
}
|
|
|
|
didDelete = false
|
|
if !hasGoodContent && !hasGoodAttachments {
|
|
didDelete = true
|
|
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
|
if err != nil {
|
|
return false, oops.New(err, "failed to delete message")
|
|
}
|
|
|
|
if !msg.Author.IsBot {
|
|
channel, err := CreateDM(ctx, msg.Author.ID)
|
|
if err != nil {
|
|
return false, 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 false, oops.New(err, "failed to send showcase warning message")
|
|
}
|
|
}
|
|
}
|
|
|
|
return didDelete, nil
|
|
}
|
|
|
|
// For any Discord message, attempt to delete any snippets.
|
|
func deleteSnippet(ctx context.Context, dbConn db.ConnOrTx, msgDelete MessageDelete) {
|
|
s, err := hmndata.FetchSnippetForDiscordMessage(ctx, dbConn, nil, msgDelete.ID, hmndata.SnippetQuery{})
|
|
if errors.Is(err, db.NotFound) {
|
|
return
|
|
} else if err != nil {
|
|
logging.ExtractLogger(ctx).Error().Err(err).Msg("")
|
|
return
|
|
}
|
|
|
|
shouldDeleteSnippet := s.Owner != nil && s.Owner.DiscordDeleteSnippetOnMessageDelete
|
|
if shouldDeleteSnippet {
|
|
logging.ExtractLogger(ctx).Debug().
|
|
Int("snippet_id", s.Snippet.ID).
|
|
Int("user_id", s.Owner.ID).
|
|
Msg("deleting snippet from Discord message")
|
|
_, err = dbConn.Exec(ctx,
|
|
`
|
|
DELETE FROM handmade_snippet
|
|
WHERE id = $1
|
|
`,
|
|
s.Snippet.ID,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to delete snippet")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
Checks settings and permissions to decide whether we are allowed to create
|
|
snippets for a user.
|
|
*/
|
|
func AllowedToCreateMessageSnippets(ctx context.Context, tx db.ConnOrTx, discordUserId string) (bool, error) {
|
|
u, err := hmndata.FetchDiscordUser(ctx, tx, discordUserId)
|
|
if errors.Is(err, db.NotFound) {
|
|
return false, nil
|
|
} else if err != nil {
|
|
return false, oops.New(err, "failed to check if we can save Discord message")
|
|
}
|
|
|
|
return u.HMNUser.DiscordSaveShowcase, nil
|
|
}
|
|
|
|
/*
|
|
Attempts to create snippets from Discord messages. If a snippet already exists
|
|
for any message, no new snippet will be created.
|
|
|
|
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. However, 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 CreateMessageSnippets(ctx context.Context, dbConn db.ConnOrTx, msgIDs ...string) error {
|
|
tx, err := dbConn.Begin(ctx)
|
|
if err != nil {
|
|
return oops.New(err, "failed to begin transaction")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
for _, msgID := range msgIDs {
|
|
// Check for existing snippet
|
|
type existingSnippetResult struct {
|
|
Message models.DiscordMessage `db:"msg"`
|
|
MessageContent *models.DiscordMessageContent `db:"c"`
|
|
Snippet *models.Snippet `db:"snippet"`
|
|
DiscordUser *models.DiscordUser `db:"duser"`
|
|
}
|
|
iexisting, err := db.QueryOne(ctx, tx, existingSnippetResult{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_discordmessage AS msg
|
|
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
|
|
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
|
|
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
|
|
WHERE
|
|
msg.id = $1
|
|
`,
|
|
msgID,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to check for existing snippet for message %s", msgID)
|
|
}
|
|
existing := iexisting.(*existingSnippetResult)
|
|
|
|
if existing.Snippet != nil {
|
|
// A snippet already exists - maybe update its content.
|
|
if existing.MessageContent != nil && !existing.Snippet.EditedOnWebsite {
|
|
contentMarkdown := existing.MessageContent.LastContent
|
|
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
|
|
|
_, err := tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_snippet
|
|
SET
|
|
description = $1,
|
|
_description_html = $2
|
|
WHERE id = $3
|
|
`,
|
|
contentMarkdown,
|
|
contentHTML,
|
|
existing.Snippet.ID,
|
|
)
|
|
if err != nil {
|
|
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to update content of snippet on message edit")
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
if existing.Message.SnippetCreated {
|
|
// A snippet once existed but no longer does
|
|
// (we do not create another one in this case)
|
|
return nil
|
|
}
|
|
|
|
if existing.MessageContent == nil || existing.DiscordUser == nil {
|
|
return nil
|
|
}
|
|
|
|
// Get an asset ID or URL to make a snippet from
|
|
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &existing.Message)
|
|
if assetId == nil && url == nil {
|
|
// Nothing to make a snippet from!
|
|
return nil
|
|
}
|
|
|
|
contentMarkdown := existing.MessageContent.LastContent
|
|
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
|
|
|
// TODO(db): Insert
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
INSERT INTO handmade_snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
`,
|
|
url,
|
|
existing.Message.SentAt,
|
|
contentMarkdown,
|
|
contentHTML,
|
|
assetId,
|
|
msgID,
|
|
existing.DiscordUser.HMNUserId,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to create snippet from attachment")
|
|
}
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
UPDATE handmade_discordmessage
|
|
SET snippet_created = TRUE
|
|
WHERE id = $1
|
|
`,
|
|
msgID,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to mark message as having snippet")
|
|
}
|
|
}
|
|
|
|
err = tx.Commit(ctx)
|
|
if err != nil {
|
|
return oops.New(err, "failed to commit transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
Associates any Discord tags with website tags for projects. Idempotent; will
|
|
clear out any existing project tags and then add new ones.
|
|
|
|
If no Discord user is linked, or no snippet exists, or whatever, this will do
|
|
nothing and return no error.
|
|
*/
|
|
func UpdateSnippetTagsIfAny(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
|
tx, err := dbConn.Begin(ctx)
|
|
if err != nil {
|
|
return oops.New(err, "failed to start transaction")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Fetch the Discord user; we only process messages for users with linked
|
|
// Discord accounts
|
|
u, err := hmndata.FetchDiscordUser(ctx, tx, msg.Author.ID)
|
|
if err == db.NotFound {
|
|
return nil
|
|
} else if err != nil {
|
|
return oops.New(err, "failed to look up HMN user information from Discord user")
|
|
}
|
|
|
|
// Fetch the s associated with this Discord message (if any). If the
|
|
// s has already been edited on the website we'll skip it.
|
|
s, err := hmndata.FetchSnippetForDiscordMessage(ctx, tx, &u.HMNUser, msg.ID, hmndata.SnippetQuery{})
|
|
if err == db.NotFound {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fetch projects so we know what tags the user can apply to their snippet.
|
|
projects, err := hmndata.FetchProjects(ctx, tx, &u.HMNUser, hmndata.ProjectsQuery{
|
|
OwnerIDs: []int{u.HMNUser.ID},
|
|
})
|
|
if err != nil {
|
|
return oops.New(err, "failed to look up user projects")
|
|
}
|
|
projectIDs := make([]int, len(projects))
|
|
for i, p := range projects {
|
|
projectIDs[i] = p.Project.ID
|
|
}
|
|
|
|
// Delete any existing project tags for this snippet. We don't want to
|
|
// delete other tags in case in the future we have manual tagging on the
|
|
// website or whatever, and this would clear those out.
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
DELETE FROM snippet_tags
|
|
WHERE
|
|
snippet_id = $1
|
|
AND tag_id IN (
|
|
SELECT tag FROM handmade_project
|
|
)
|
|
`,
|
|
s.Snippet.ID,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to delete existing snippet 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(s.Snippet.Description)
|
|
type tagsRow struct {
|
|
Tag models.Tag `db:"tags"`
|
|
}
|
|
iUserTags, err := db.Query(ctx, tx, tagsRow{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
tags
|
|
JOIN handmade_project AS project ON project.tag = tags.id
|
|
JOIN handmade_user_projects AS user_project ON user_project.project_id = project.id
|
|
WHERE
|
|
project.id = ANY ($1)
|
|
`,
|
|
projectIDs,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to fetch tags for user projects")
|
|
}
|
|
|
|
var tagIDs []int
|
|
for _, itag := range iUserTags {
|
|
tag := itag.(*tagsRow).Tag
|
|
for _, messageTag := range messageTags {
|
|
if tag.Text == messageTag {
|
|
tagIDs = append(tagIDs, tag.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, tagID := range tagIDs {
|
|
_, err = tx.Exec(ctx,
|
|
`
|
|
INSERT INTO snippet_tags (snippet_id, tag_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
`,
|
|
s.Snippet.ID,
|
|
tagID,
|
|
)
|
|
if err != nil {
|
|
return oops.New(err, "failed to add tag to snippet")
|
|
}
|
|
}
|
|
|
|
err = tx.Commit(ctx)
|
|
if err != nil {
|
|
return oops.New(err, "failed to commit transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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] = strings.ToLower(m[1])
|
|
}
|
|
return result
|
|
}
|