Implement attachments for Discord -> Matrix

This commit is contained in:
Gary Kramlich
2022-02-11 04:04:07 -06:00
parent 6a688f01b7
commit 7f99dc4a9e
9 changed files with 413 additions and 39 deletions

60
bridge/attachments.go Normal file
View File

@@ -0,0 +1,60 @@
package bridge
import (
"bytes"
"image"
"io/ioutil"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
)
func (p *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
// We might want to make this save to disk in the future. Discord defaults
// to 8mb for all attachments to a messages for non-nitro users and
// non-boosted servers.
//
// If the user has nitro classic, their limit goes up to 50mb but if a user
// has regular nitro the limit is increased to 100mb.
//
// Servers boosted to level 2 will have the limit bumped to 50mb.
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", discordgo.DroidBrowserUserAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func (p *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
uploaded, err := intent.UploadBytes(data, content.Info.MimeType)
if err != nil {
return err
}
content.URL = uploaded.ContentURI.CUString()
content.Info.Size = len(data)
if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
content.Info.Width = cfg.Width
content.Info.Height = cfg.Height
}
return nil
}

View File

@@ -2,6 +2,7 @@ package bridge
import (
"fmt"
"strings"
"sync"
"time"
@@ -303,6 +304,79 @@ func (p *Portal) markMessageHandled(msg *database.Message, discordID string, mxi
return msg
}
func (p *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr error) {
content := &event.MessageEventContent{
Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
MsgType: event.MsgNotice,
}
_, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
if err != nil {
p.log.Warnfln("failed to send error message to matrix: %v", err)
}
}
func (p *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, msgID string, attachment *discordgo.MessageAttachment) {
// var captionContent *event.MessageEventContent
// if attachment.Description != "" {
// captionContent = &event.MessageEventContent{
// Body: attachment.Description,
// MsgType: event.MsgNotice,
// }
// }
// p.log.Debugfln("captionContent: %#v", captionContent)
content := &event.MessageEventContent{
Body: attachment.Filename,
Info: &event.FileInfo{
Height: attachment.Height,
MimeType: attachment.ContentType,
Width: attachment.Width,
// This gets overwritten later after the file is uploaded to the homeserver
Size: attachment.Size,
},
}
switch strings.ToLower(strings.Split(attachment.ContentType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
default:
content.MsgType = event.MsgFile
}
data, err := p.downloadDiscordAttachment(attachment.URL)
if err != nil {
p.sendMediaFailedMessage(intent, err)
return
}
err = p.uploadMatrixAttachment(intent, data, content)
if err != nil {
p.sendMediaFailedMessage(intent, err)
return
}
resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
if err != nil {
p.log.Warnfln("failed to send media message to matrix: %v", err)
}
dbAttachment := p.bridge.db.Attachment.New()
dbAttachment.Channel = p.Key
dbAttachment.DiscordMessageID = msgID
dbAttachment.DiscordAttachmentID = attachment.ID
dbAttachment.MatrixEventID = resp.EventID
dbAttachment.Insert()
}
func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message) {
if msg.Author != nil && user.ID == msg.Author.ID {
return
@@ -321,22 +395,29 @@ func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message)
return
}
content := &event.MessageEventContent{
Body: msg.Content,
MsgType: event.MsgText,
}
intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
if err != nil {
p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
if msg.Content != "" {
content := &event.MessageEventContent{
Body: msg.Content,
MsgType: event.MsgText,
}
return
resp, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
if err != nil {
p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
return
}
ts, _ := msg.Timestamp.Parse()
p.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
}
ts, _ := msg.Timestamp.Parse()
p.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
// now run through any attachments the message has
for _, attachment := range msg.Attachments {
p.handleDiscordAttachment(intent, msg.ID, attachment)
}
}
func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message) {
@@ -350,9 +431,45 @@ func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message)
return
}
intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
if existing == nil {
p.log.Debugln("failed to find previous message to update", msg.ID)
// Due to the differences in Discord and Matrix attachment handling,
// existing will return nil if the original message was empty as we
// don't store/save those messages so we can determine when we're
// working against an attachment and do the attachment lookup instead.
// Find all the existing attachments and drop them in a map so we can
// figure out which, if any have been deleted and clean them up on the
// matrix side.
attachmentMap := map[string]*database.Attachment{}
attachments := p.bridge.db.Attachment.GetAllByDiscordMessageID(p.Key, msg.ID)
for _, attachment := range attachments {
attachmentMap[attachment.DiscordAttachmentID] = attachment
}
// Now run through the list of attachments on this message and remove
// them from the map.
for _, attachment := range msg.Attachments {
if _, found := attachmentMap[attachment.ID]; found {
delete(attachmentMap, attachment.ID)
}
}
// Finally run through any attachments still in the map and delete them
// on the matrix side and our database.
for _, attachment := range attachmentMap {
_, err := intent.RedactEvent(p.MXID, attachment.MatrixEventID)
if err != nil {
p.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
}
attachment.Delete()
}
return
}
content := &event.MessageEventContent{
@@ -362,8 +479,6 @@ func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message)
content.SetEdit(existing.MatrixID)
intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
_, err := intent.SendMessageEvent(p.MXID, event.EventMessage, content)
if err != nil {
p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
@@ -384,13 +499,9 @@ func (p *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message)
// add guild message support, but we'll cross that bridge when we get
// there.
// Find the message that we're working with.
// Find the message that we're working with. This could correctly return
// nil if the message was just one or more attachments.
existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
if existing == nil {
p.log.Debugfln("failed to find message", msg.ID)
return
}
var intent *appservice.IntentAPI
@@ -400,12 +511,25 @@ func (p *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message)
p.log.Errorfln("no guilds yet...")
}
_, err := intent.RedactEvent(p.MXID, existing.MatrixID)
if err != nil {
p.log.Warnfln("Failed to remove message %s: %v", existing.MatrixID, err)
if existing != nil {
_, err := intent.RedactEvent(p.MXID, existing.MatrixID)
if err != nil {
p.log.Warnfln("Failed to remove message %s: %v", existing.MatrixID, err)
}
existing.Delete()
}
existing.Delete()
// Now delete all of the existing attachments.
attachments := p.bridge.db.Attachment.GetAllByDiscordMessageID(p.Key, msg.ID)
for _, attachment := range attachments {
_, err := intent.RedactEvent(p.MXID, attachment.MatrixEventID)
if err != nil {
p.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
}
attachment.Delete()
}
}
func (p *Portal) syncParticipants(source *User, participants []*discordgo.User) {
@@ -615,16 +739,38 @@ func (p *Portal) handleMatrixReaction(evt *event.Event) {
return
}
msg := p.bridge.db.Message.GetByMatrixID(p.Key, reaction.RelatesTo.EventID)
if msg.DiscordID == "" {
p.log.Debugf("Message %s has not yet been sent to discord", reaction.RelatesTo.EventID)
var discordID string
return
msg := p.bridge.db.Message.GetByMatrixID(p.Key, reaction.RelatesTo.EventID)
// Due to the differences in attachments between Discord and Matrix, if a
// user reacts to a media message on discord our lookup above will fail
// because the relation of matrix media messages to attachments in handled
// in the attachments table instead of messages so we need to check that
// before continuing.
//
// This also leads to interesting problems when a Discord message comes in
// with multiple attachments. A user can react to each one individually on
// Matrix, which will cause us to send it twice. Discord tends to ignore
// this, but if the user removes one of them, discord removes it and now
// they're out of sync. Perhaps we should add a counter to the reactions
// table to keep them in sync and to avoid sending duplicates to Discord.
if msg == nil {
attachment := p.bridge.db.Attachment.GetByMatrixID(p.Key, reaction.RelatesTo.EventID)
discordID = attachment.DiscordMessageID
} else {
if msg.DiscordID == "" {
p.log.Debugf("Message %s has not yet been sent to discord", reaction.RelatesTo.EventID)
return
}
discordID = msg.DiscordID
}
err := user.Session.MessageReactionAdd(p.Key.ChannelID, msg.DiscordID, reaction.RelatesTo.Key)
err := user.Session.MessageReactionAdd(p.Key.ChannelID, discordID, reaction.RelatesTo.Key)
if err != nil {
p.log.Debugf("Failed to send reaction %s@%s: %v", p.Key, msg.DiscordID, err)
p.log.Debugf("Failed to send reaction %s@%s: %v", p.Key, discordID, err)
return
}
@@ -633,7 +779,7 @@ func (p *Portal) handleMatrixReaction(evt *event.Event) {
dbReaction.Channel.ChannelID = p.Key.ChannelID
dbReaction.Channel.Receiver = p.Key.Receiver
dbReaction.MatrixEventID = evt.ID
dbReaction.DiscordMessageID = msg.DiscordID
dbReaction.DiscordMessageID = discordID
dbReaction.AuthorID = user.ID
dbReaction.MatrixName = reaction.RelatesTo.Key
dbReaction.DiscordID = reaction.RelatesTo.Key

70
database/attachment.go Normal file
View File

@@ -0,0 +1,70 @@
package database
import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type Attachment struct {
db *Database
log log.Logger
Channel PortalKey
DiscordMessageID string
DiscordAttachmentID string
MatrixEventID id.EventID
}
func (a *Attachment) Scan(row Scannable) *Attachment {
err := row.Scan(
&a.Channel.ChannelID, &a.Channel.Receiver,
&a.DiscordMessageID, &a.DiscordAttachmentID,
&a.MatrixEventID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
a.log.Errorln("Database scan failed:", err)
}
return nil
}
return a
}
func (a *Attachment) Insert() {
query := "INSERT INTO attachment" +
" (channel_id, receiver, discord_message_id, discord_attachment_id, " +
" matrix_event_id) VALUES ($1, $2, $3, $4, $5);"
_, err := a.db.Exec(
query,
a.Channel.ChannelID, a.Channel.Receiver,
a.DiscordMessageID, a.DiscordAttachmentID,
a.MatrixEventID,
)
if err != nil {
a.log.Warnfln("Failed to insert attachment for %s@%s: %v", a.Channel, a.DiscordMessageID, err)
}
}
func (a *Attachment) Delete() {
query := "DELETE FROM attachment WHERE" +
" channel_id=$1 AND receiver=$2 AND discord_attachment_id=$3 AND" +
" matrix_event_id=$4"
_, err := a.db.Exec(
query,
a.Channel.ChannelID, a.Channel.Receiver,
a.DiscordAttachmentID, a.MatrixEventID,
)
if err != nil {
a.log.Warnfln("Failed to delete attachment for %s@%s: %v", a.Channel, a.DiscordAttachmentID, err)
}
}

View File

@@ -0,0 +1,73 @@
package database
import (
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type AttachmentQuery struct {
db *Database
log log.Logger
}
const (
attachmentSelect = "SELECT channel_id, receiver, discord_message_id," +
" discord_attachment_id, matrix_event_id FROM attachment"
)
func (aq *AttachmentQuery) New() *Attachment {
return &Attachment{
db: aq.db,
log: aq.log,
}
}
func (aq *AttachmentQuery) GetAllByDiscordMessageID(key PortalKey, discordMessageID string) []*Attachment {
query := attachmentSelect + " WHERE channel_id=$1 AND receiver=$2 AND" +
" discord_message_id=$3"
return aq.getAll(query, key.ChannelID, key.Receiver, discordMessageID)
}
func (aq *AttachmentQuery) getAll(query string, args ...interface{}) []*Attachment {
rows, err := aq.db.Query(query, args...)
if err != nil {
aq.log.Debugfln("getAll failed: %v", err)
return nil
}
if rows == nil {
return nil
}
attachments := []*Attachment{}
for rows.Next() {
attachments = append(attachments, aq.New().Scan(rows))
}
return attachments
}
func (aq *AttachmentQuery) GetByDiscordAttachmentID(key PortalKey, discordMessageID, discordID string) *Attachment {
query := attachmentSelect + " WHERE channel_id=$1 AND receiver=$2" +
" AND discord_message_id=$3 AND discord_id=$4"
return aq.get(query, key.ChannelID, key.Receiver, discordMessageID, discordID)
}
func (aq *AttachmentQuery) GetByMatrixID(key PortalKey, matrixEventID id.EventID) *Attachment {
query := attachmentSelect + " WHERE channel_id=$1 AND receiver=$2" +
" AND matrix_event_id=$3"
return aq.get(query, key.ChannelID, key.Receiver, matrixEventID)
}
func (aq *AttachmentQuery) get(query string, args ...interface{}) *Attachment {
row := aq.db.QueryRow(query, args...)
if row == nil {
return nil
}
return aq.New().Scan(row)
}

View File

@@ -16,11 +16,12 @@ type Database struct {
log log.Logger
dialect string
User *UserQuery
Portal *PortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Reaction *ReactionQuery
User *UserQuery
Portal *PortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Reaction *ReactionQuery
Attachment *AttachmentQuery
}
func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger) (*Database, error) {
@@ -73,5 +74,10 @@ func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger)
log: db.log.Sub("Reaction"),
}
db.Attachment = &AttachmentQuery{
db: db,
log: db.log.Sub("Attachment"),
}
return db, nil
}

View File

@@ -0,0 +1,12 @@
CREATE TABLE attachment (
channel_id TEXT NOT NULL,
receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
discord_attachment_id TEXT NOT NULL,
matrix_event_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(discord_attachment_id, matrix_event_id),
FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);

View File

@@ -40,6 +40,7 @@ func Run(db *sql.DB, baseLog log.Logger) error {
migrator.WithLogger(logger),
migrator.Migrations(
migrationFromFile("01-initial.sql"),
migrationFromFile("02-attachments.sql"),
),
)
if err != nil {

6
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/alecthomas/kong v0.2.18
github.com/bwmarrin/discordgo v0.23.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.4.2
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.9.0
github.com/lopezator/migrator v0.3.0
github.com/mattn/go-sqlite3 v1.14.10
@@ -21,9 +21,9 @@ require (
github.com/gorilla/mux v1.8.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
golang.org/x/crypto v0.0.0-20220209195652-db638375bc3a // indirect
golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
)
replace github.com/bwmarrin/discordgo v0.23.2 => gitlab.com/beeper/discordgo v0.23.3-0.20220210113317-784a5c1cfaa2
replace github.com/bwmarrin/discordgo v0.23.2 => gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7

6
go.sum
View File

@@ -26,6 +26,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
@@ -57,6 +59,8 @@ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
gitlab.com/beeper/discordgo v0.23.3-0.20220210113317-784a5c1cfaa2 h1:CK9faDZlCY4rbxpqPArNdMy1kOsIrVHDEAVJcgarnrg=
gitlab.com/beeper/discordgo v0.23.3-0.20220210113317-784a5c1cfaa2/go.mod h1:Hwfv4M8yP/MDh47BN+4Z1WItJ1umLKUyplCH5KcQPgE=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7 h1:8ieR27GadHnShqhsvPrDzL1/ZOntavGGt4TXqafncYE=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7/go.mod h1:Hwfv4M8yP/MDh47BN+4Z1WItJ1umLKUyplCH5KcQPgE=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -64,6 +68,8 @@ golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220209195652-db638375bc3a h1:atOEWVSedO4ksXBe/UrlbSLVxQQ9RxM/tT2Jy10IaHo=
golang.org/x/crypto v0.0.0-20220209195652-db638375bc3a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=