hmn/src/discord/showcase.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
}