diff --git a/bridge/attachments.go b/bridge/attachments.go new file mode 100644 index 0000000..47e6452 --- /dev/null +++ b/bridge/attachments.go @@ -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 +} diff --git a/bridge/portal.go b/bridge/portal.go index 46c09c8..896d1d7 100644 --- a/bridge/portal.go +++ b/bridge/portal.go @@ -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 diff --git a/database/attachment.go b/database/attachment.go new file mode 100644 index 0000000..b00e28f --- /dev/null +++ b/database/attachment.go @@ -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) + } +} diff --git a/database/attachmentquery.go b/database/attachmentquery.go new file mode 100644 index 0000000..efd9bb8 --- /dev/null +++ b/database/attachmentquery.go @@ -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) +} diff --git a/database/database.go b/database/database.go index 37d6aac..62a9bda 100644 --- a/database/database.go +++ b/database/database.go @@ -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 } diff --git a/database/migrations/02-attachments.sql b/database/migrations/02-attachments.sql new file mode 100644 index 0000000..f36a85c --- /dev/null +++ b/database/migrations/02-attachments.sql @@ -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 +); diff --git a/database/migrations/migrations.go b/database/migrations/migrations.go index ab72c17..c2147c7 100644 --- a/database/migrations/migrations.go +++ b/database/migrations/migrations.go @@ -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 { diff --git a/go.mod b/go.mod index 6be6ab9..c2552ab 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f8d01db..263d3fd 100644 --- a/go.sum +++ b/go.sum @@ -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=