31 Commits

Author SHA1 Message Date
Tulir Asokan
69f1793e24 Bump version to v0.1.1 2023-02-16 12:50:42 +02:00
Tulir Asokan
eab19f6679 Update mautrix-go 2023-02-16 12:48:33 +02:00
Tulir Asokan
839933005c Remove lottie conversion temp dir after converting 2023-02-15 22:19:31 +02:00
Tulir Asokan
a28735beb7 Update discordgo 2023-02-15 22:19:28 +02:00
Tulir Asokan
5d7a6e7088 Update changelog and dependencies 2023-02-13 15:40:13 +02:00
Tulir Asokan
f9ba906bbd Update ghost info on incoming reactions 2023-02-13 11:53:00 +02:00
Tulir Asokan
41d51ec992 Handle guild join messages 2023-02-13 00:25:23 +02:00
Tulir Asokan
6ccf87bc0a Handle call start messages
Closes #53
2023-02-13 00:14:05 +02:00
Tulir Asokan
011c60610a Adjust github action 2023-02-13 00:01:12 +02:00
Tulir Asokan
669964272e Fix typo
[skip cd]
2023-02-12 12:25:42 +02:00
Tulir Asokan
943f2dd6f0 Update linters
[skip cd]
2023-02-12 12:24:52 +02:00
Tulir Asokan
3e5baa502e Update discordgo to fix handling guilds in ready event 2023-02-04 16:33:40 +02:00
Tulir Asokan
c336804c7e Fix sticker sizes 2023-02-04 16:17:17 +02:00
Tulir Asokan
2421cd7817 Specify lottieconverter docker tag 2023-02-04 16:13:02 +02:00
Tulir Asokan
a7864c28d8 Add support for converting lottie stickers 2023-02-04 16:10:03 +02:00
Tulir Asokan
0dba4fbdd4 Fix typo in initial db migration 2023-02-04 15:58:22 +02:00
Tulir Asokan
fac7d79c5e Subscribe to guild when bridging it 2023-02-04 14:49:10 +02:00
Tulir Asokan
f32fd8d904 Update changelog and dependencies 2023-02-04 14:27:23 +02:00
Tulir Asokan
1e81fc6a02 Improve typing notification handling 2023-02-04 14:17:59 +02:00
Tulir Asokan
80f8bed9b9 Subscribe to bridged guilds on connect 2023-02-04 14:17:56 +02:00
Tulir Asokan
7cdd1bb9e4 Double check bridging status before handling message
Some webhook messages don't seem to have the guild ID specified
2023-02-04 13:45:50 +02:00
Tulir Asokan
a2121347e8 Don't set extra data in edit fallbacks 2023-02-02 22:23:51 +02:00
Tulir Asokan
85395c0230 Bridge youtube embeds as link previews 2023-02-02 22:23:34 +02:00
Tulir Asokan
787ce75dde Fix transferring same attachment multiple times in parallel 2023-01-31 13:11:02 +02:00
Tulir Asokan
5b715cd9e2 Allow inline links in Discord embed descriptions 2023-01-30 18:35:17 +02:00
Tulir Asokan
a9e03f092c Fix removing custom emoji reactions from Matrix 2023-01-30 01:48:43 +02:00
Tulir Asokan
466139164c Merge emoji and discord_file tables
Also fix duplicate reaction when reacting with custom emoji from Matrix
2023-01-30 01:35:22 +02:00
Tulir Asokan
e183f5cffa Disable caching reuploaded encrypted files 2023-01-30 01:01:10 +02:00
Tulir Asokan
e7615ef4be Refactor tag rendering to avoid recreating goldmark instance for each message 2023-01-30 00:44:06 +02:00
Tulir Asokan
694733a4e9 Don't specify width in inline images
They don't necessarily need to be square, so only specify height
and let clients make the width fit automatically.
2023-01-30 00:15:57 +02:00
Tulir Asokan
6f4c51852c Disable more unsupported features in discord markdown parser 2023-01-30 00:15:54 +02:00
26 changed files with 538 additions and 332 deletions

View File

@@ -5,17 +5,13 @@ on: [push, pull_request]
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: [1.19]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go-version }} - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: ${{ matrix.go-version }} go-version: "1.20"
- name: Install libolm - name: Install libolm
run: sudo apt-get install libolm-dev libolm3 run: sudo apt-get install libolm-dev libolm3

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0 rev: v4.4.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
@@ -9,7 +9,7 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-beta.5 rev: v1.0.0-rc.1
hooks: hooks:
- id: go-imports-repo - id: go-imports-repo
- id: go-vet-repo-mod - id: go-vet-repo-mod

View File

@@ -1,3 +1,16 @@
# v0.1.1 (2023-02-16)
* Started automatically subscribing to bridged guilds. This fixes two problems:
* Typing notifications should now work automatically in guilds.
* Huge guilds now actually get messages bridged.
* Added support for converting animated lottie stickers to raster formats using
[lottieconverter](https://github.com/sot-tech/LottieConverter).
* Added basic bridging for call start and guild join messages.
* Improved markdown parsing to disable more features that don't exist on Discord.
* Removed width from inline images (e.g. in the `guilds status` output) to
handle non-square images properly.
* Fixed ghost user info not being synced when receiving reactions.
# v0.1.0 (2023-01-29) # v0.1.0 (2023-01-29)
Initial release. Initial release.

View File

@@ -1,3 +1,5 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM golang:1-alpine3.17 AS builder FROM golang:1-alpine3.17 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@@ -11,8 +13,11 @@ FROM alpine:3.17
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY --from=builder /build/docker-run.sh /docker-run.sh COPY --from=builder /build/docker-run.sh /docker-run.sh

View File

@@ -1,10 +1,15 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM alpine:3.17 FROM alpine:3.17
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
ARG EXECUTABLE=./mautrix-discord ARG EXECUTABLE=./mautrix-discord
COPY $EXECUTABLE /usr/bin/mautrix-discord COPY $EXECUTABLE /usr/bin/mautrix-discord
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml

View File

@@ -1,7 +1,12 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 AS lottie
FROM golang:1-alpine3.17 AS builder FROM golang:1-alpine3.17 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
COPY . /build COPY . /build
WORKDIR /build WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord RUN go build -o /usr/bin/mautrix-discord

View File

@@ -2,10 +2,15 @@ package main
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"image" "image"
"io" "io"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -16,6 +21,9 @@ import (
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/ffmpeg"
"go.mau.fi/mautrix-discord/database" "go.mau.fi/mautrix-discord/database"
) )
@@ -62,7 +70,7 @@ func uploadDiscordAttachment(url string, data []byte) error {
return nil return nil
} }
func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventContent) ([]byte, error) { func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo var file *event.EncryptedFileInfo
rawMXC := content.URL rawMXC := content.URL
@@ -76,7 +84,7 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
return nil, err return nil, err
} }
data, err := portal.MainIntent().DownloadBytes(mxc) data, err := intent.DownloadBytes(mxc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -91,23 +99,24 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
return data, nil return data, nil
} }
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, attachmentID, mime string) (*database.File, error) { func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta) (*database.File, error) {
dbFile := br.DB.File.New() dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now() dbFile.Timestamp = time.Now()
dbFile.URL = url dbFile.URL = url
dbFile.ID = attachmentID dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data) dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String() dbFile.MimeType = mimetype.Detect(data).String()
if mime == "" { if meta.MimeType == "" {
mime = dbFile.MimeType meta.MimeType = dbFile.MimeType
} }
if strings.HasPrefix(mime, "image/") { if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width dbFile.Width = cfg.Width
dbFile.Height = cfg.Height dbFile.Height = cfg.Height
} }
uploadMime := mime uploadMime := meta.MimeType
if encrypt { if encrypt {
dbFile.Encrypted = true dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile() dbFile.DecryptionInfo = attachment.NewEncryptedFile()
@@ -140,22 +149,161 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
} }
dbFile.MXC = uploaded.ContentURI dbFile.MXC = uploaded.ContentURI
} }
dbFile.Insert(nil)
return dbFile, nil return dbFile, nil
} }
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, attachmentID, mime string) (*database.File, error) { type AttachmentMeta struct {
dbFile := br.DB.File.Get(url, encrypt) AttachmentID string
if dbFile == nil { MimeType string
data, err := downloadDiscordAttachment(url) EmojiName string
if err != nil { CopyIfMissing bool
return nil, err Converter func([]byte) ([]byte, string, error)
} }
dbFile, err = br.uploadMatrixAttachment(intent, data, url, encrypt, attachmentID, mime) var NoMeta = AttachmentMeta{}
type attachmentKey struct {
URL string
Encrypt bool
}
func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
fps := br.Config.Bridge.AnimatedSticker.Args.FPS
width := br.Config.Bridge.AnimatedSticker.Args.Width
height := br.Config.Bridge.AnimatedSticker.Args.Height
target := br.Config.Bridge.AnimatedSticker.Target
var lottieTarget, outputMime string
switch target {
case "png":
lottieTarget = "png"
outputMime = "image/png"
fps = 1
case "gif":
lottieTarget = "gif"
outputMime = "image/gif"
case "webm":
lottieTarget = "pngs"
outputMime = "video/webm"
case "webp":
lottieTarget = "pngs"
outputMime = "image/webp"
case "disable":
return data, "application/json", nil
default:
return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target)
}
ctx := context.Background()
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
if err != nil { if err != nil {
return nil, err return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() {
removErr := os.RemoveAll(tempdir)
if removErr != nil {
br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr)
}
}()
lottieOutput := filepath.Join(tempdir, "out_")
if lottieTarget != "pngs" {
lottieOutput = filepath.Join(tempdir, "output."+lottieTarget)
}
cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps))
cmd.Stdin = bytes.NewReader(data)
err = cmd.Run()
if err != nil {
return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err)
}
var path string
if lottieTarget == "pngs" {
var videoCodec string
outputExtension := "." + target
if target == "webm" {
videoCodec = "libvpx-vp9"
} else if target == "webp" {
videoCodec = "libwebp_anim"
} else {
panic(fmt.Errorf("impossible case: unknown target %q", target))
}
path, err = ffmpeg.ConvertPath(
ctx, lottieOutput+"*.png", outputExtension,
[]string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"},
[]string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target},
false,
)
if err != nil {
return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err)
}
} else {
path = lottieOutput
}
data, err = os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("failed to read converted file: %w", err)
}
return data, outputMime, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
isCacheable := !encrypt
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &util.ReturnableOnce[*database.File]{})
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
if isCacheable {
onceDBFile = br.DB.File.Get(url, encrypt)
if onceDBFile != nil {
return
} }
} }
return dbFile, nil
var data []byte
data, onceErr = downloadDiscordAttachment(url)
if onceErr != nil {
return
}
if meta.Converter != nil {
data, meta.MimeType, onceErr = meta.Converter(data)
if onceErr != nil {
onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr)
return
}
}
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta)
if onceErr != nil {
return
}
if isCacheable {
onceDBFile.Insert(nil)
}
br.attachmentTransfers.Delete(transferKey)
return
})
}
return
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
var url, mimeType string
if animated {
url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,
MimeType: mimeType,
EmojiName: name,
})
if err != nil {
portal.log.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err)
return id.ContentURI{}
}
return dbFile.MXC
} }

View File

@@ -353,7 +353,7 @@ func fnListGuilds(ce *WrappedCommandEvent) {
} }
var avatarHTML string var avatarHTML string
if !guild.AvatarURL.IsEmpty() { if !guild.AvatarURL.IsEmpty() {
avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" width="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String()) avatarHTML = fmt.Sprintf(`<img data-mx-emoticon height="24" src="%s" alt="" title="Guild avatar"> `, guild.AvatarURL.String())
} }
items = append(items, fmt.Sprintf("<li>%s%s (<code>%s</code>) - %s</li>", avatarHTML, html.EscapeString(guild.Name), guild.ID, status)) items = append(items, fmt.Sprintf("<li>%s%s (<code>%s</code>) - %s</li>", avatarHTML, html.EscapeString(guild.Name), guild.ID, status))
} }

View File

@@ -50,6 +50,14 @@ type BridgeConfig struct {
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"` DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"` DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
FederateRooms bool `yaml:"federate_rooms"` FederateRooms bool `yaml:"federate_rooms"`
AnimatedSticker struct {
Target string `yaml:"target"`
Args struct {
Width int `yaml:"width"`
Height int `yaml:"height"`
FPS int `yaml:"fps"`
} `yaml:"args"`
} `yaml:"animated_sticker"`
DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"` DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"`
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"` DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`

View File

@@ -45,6 +45,10 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete") helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave") helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
helper.Copy(up.Bool, "bridge", "federate_rooms") helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Str, "bridge", "animated_sticker", "target")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps")
helper.Copy(up.Map, "bridge", "double_puppet_server_map") helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery") helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map") helper.Copy(up.Map, "bridge", "login_shared_secret_map")

View File

@@ -21,7 +21,6 @@ type Database struct {
Message *MessageQuery Message *MessageQuery
Thread *ThreadQuery Thread *ThreadQuery
Reaction *ReactionQuery Reaction *ReactionQuery
Emoji *EmojiQuery
Guild *GuildQuery Guild *GuildQuery
Role *RoleQuery Role *RoleQuery
File *FileQuery File *FileQuery
@@ -54,10 +53,6 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
db: db, db: db,
log: log.Sub("Reaction"), log: log.Sub("Reaction"),
} }
db.Emoji = &EmojiQuery{
db: db,
log: log.Sub("Emoji"),
}
db.Guild = &GuildQuery{ db.Guild = &GuildQuery{
db: db, db: db,
log: log.Sub("Guild"), log: log.Sub("Guild"),

View File

@@ -1,99 +0,0 @@
package database
import (
"database/sql"
"errors"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util/dbutil"
)
type EmojiQuery struct {
db *Database
log log.Logger
}
const (
emojiSelect = "SELECT discord_id, discord_name, matrix_url FROM emoji"
)
func (eq *EmojiQuery) New() *Emoji {
return &Emoji{
db: eq.db,
log: eq.log,
}
}
func (eq *EmojiQuery) GetByDiscordID(discordID string) *Emoji {
query := emojiSelect + " WHERE discord_id=$1"
return eq.get(query, discordID)
}
func (eq *EmojiQuery) GetByMatrixURL(matrixURL id.ContentURI) *Emoji {
query := emojiSelect + " WHERE matrix_url=$1"
return eq.get(query, matrixURL.String())
}
func (eq *EmojiQuery) get(query string, args ...interface{}) *Emoji {
return eq.New().Scan(eq.db.QueryRow(query, args...))
}
type Emoji struct {
db *Database
log log.Logger
DiscordID string
DiscordName string
MatrixURL id.ContentURI
}
func (e *Emoji) Scan(row dbutil.Scannable) *Emoji {
var matrixURL sql.NullString
err := row.Scan(&e.DiscordID, &e.DiscordName, &matrixURL)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
e.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
e.MatrixURL, _ = id.ParseContentURI(matrixURL.String)
return e
}
func (e *Emoji) Insert() {
query := "INSERT INTO emoji" +
" (discord_id, discord_name, matrix_url)" +
" VALUES ($1, $2, $3);"
_, err := e.db.Exec(query, e.DiscordID, e.DiscordName, e.MatrixURL.String())
if err != nil {
e.log.Warnfln("Failed to insert emoji %s: %v", e.DiscordID, err)
panic(err)
}
}
func (e *Emoji) Delete() {
query := "DELETE FROM emoji WHERE discord_id=$1"
_, err := e.db.Exec(query, e.DiscordID)
if err != nil {
e.log.Warnfln("Failed to delete emoji %s: %v", e.DiscordID, err)
panic(err)
}
}
func (e *Emoji) APIName() string {
if e.DiscordID != "" && e.DiscordName != "" {
return e.DiscordName + ":" + e.DiscordID
} else if e.DiscordName != "" {
return e.DiscordName
}
return e.DiscordID
}

View File

@@ -20,10 +20,10 @@ type FileQuery struct {
// language=postgresql // language=postgresql
const ( const (
fileSelect = "SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file" fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
fileInsert = ` fileInsert = `
INSERT INTO discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp) INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
` `
) )
@@ -39,15 +39,21 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted)) return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
} }
func (fq *FileQuery) GetByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1"
return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
}
type File struct { type File struct {
db *Database db *Database
log log.Logger log log.Logger
URL string URL string
Encrypted bool Encrypted bool
MXC id.ContentURI
ID string ID string
MXC id.ContentURI EmojiName string
Size int Size int
Width int Width int
@@ -55,16 +61,15 @@ type File struct {
MimeType string MimeType string
DecryptionInfo *attachment.EncryptedFile DecryptionInfo *attachment.EncryptedFile
Timestamp time.Time Timestamp time.Time
} }
func (f *File) Scan(row dbutil.Scannable) *File { func (f *File) Scan(row dbutil.Scannable) *File {
var fileID, decryptionInfo sql.NullString var fileID, emojiName, decryptionInfo sql.NullString
var width, height sql.NullInt32 var width, height sql.NullInt32
var timestamp int64 var timestamp int64
var mxc string var mxc string
err := row.Scan(&f.URL, &f.Encrypted, &fileID, &mxc, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, &timestamp) err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, &timestamp)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
f.log.Errorln("Database scan failed:", err) f.log.Errorln("Database scan failed:", err)
@@ -73,6 +78,7 @@ func (f *File) Scan(row dbutil.Scannable) *File {
return nil return nil
} }
f.ID = fileID.String f.ID = fileID.String
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp) f.Timestamp = time.UnixMilli(timestamp)
f.Width = int(width.Int32) f.Width = int(width.Int32)
f.Height = int(height.Int32) f.Height = int(height.Int32)
@@ -114,7 +120,7 @@ func (f *File) Insert(txn dbutil.Execable) {
decryptionInfoStr.String = string(decryptionInfo) decryptionInfoStr.String = string(decryptionInfo)
} }
_, err := txn.Exec(fileInsert, _, err := txn.Exec(fileInsert,
f.URL, f.Encrypted, strPtr(f.ID), f.MXC.String(), f.Size, f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType, positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
decryptionInfoStr, f.Timestamp.UnixMilli(), decryptionInfoStr, f.Timestamp.UnixMilli(),
) )

View File

@@ -1,4 +1,4 @@
-- v0 -> v12: Latest revision -- v0 -> v13: Latest revision
CREATE TABLE guild ( CREATE TABLE guild (
dcid TEXT PRIMARY KEY, dcid TEXT PRIMARY KEY,
@@ -126,12 +126,6 @@ CREATE TABLE reaction (
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
); );
CREATE TABLE emoji (
discord_id TEXT PRIMARY KEY,
discord_name TEXT,
matrix_url TEXT
);
CREATE TABLE role ( CREATE TABLE role (
dc_guild_id TEXT, dc_guild_id TEXT,
dcid TEXT, dcid TEXT,
@@ -154,17 +148,16 @@ CREATE TABLE role (
CREATE TABLE discord_file ( CREATE TABLE discord_file (
url TEXT, url TEXT,
encrypted BOOLEAN, encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
id TEXT, id TEXT,
mxc TEXT NOT NULL, emoji_name TEXT,
size BIGINT NOT NULL, size BIGINT NOT NULL,
width INTEGER, width INTEGER,
height INTEGER, height INTEGER,
mime_type TEXT NOT NULL, mime_type TEXT NOT NULL,
decryption_info jsonb, decryption_info jsonb,
timestamp BIGINT NOT NULL, timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted) PRIMARY KEY (url, encrypted)

View File

@@ -0,0 +1,4 @@
-- v13: Merge tables used for cached custom emojis and attachments
ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc);
ALTER TABLE discord_file ADD COLUMN emoji_name TEXT;
DROP TABLE emoji;

View File

@@ -0,0 +1,24 @@
-- v13: Merge tables used for cached custom emojis and attachments
CREATE TABLE new_discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);
INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
DROP TABLE discord_file;
ALTER TABLE new_discord_file RENAME TO discord_file;

View File

@@ -1,79 +0,0 @@
package main
import (
"io/ioutil"
"net/http"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id"
)
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
dbEmoji := portal.bridge.DB.Emoji.GetByDiscordID(emojiID)
if dbEmoji == nil {
data, mimeType, err := portal.downloadDiscordEmoji(emojiID, animated)
if err != nil {
portal.log.Warnfln("Failed to download emoji %s from discord: %v", emojiID, err)
return id.ContentURI{}
}
uri, err := portal.uploadMatrixEmoji(portal.MainIntent(), data, mimeType)
if err != nil {
portal.log.Warnfln("Failed to upload discord emoji %s to homeserver: %v", emojiID, err)
return id.ContentURI{}
}
dbEmoji = portal.bridge.DB.Emoji.New()
dbEmoji.DiscordID = emojiID
dbEmoji.DiscordName = name
dbEmoji.MatrixURL = uri
dbEmoji.Insert()
}
return dbEmoji.MatrixURL
}
func (portal *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
var url string
var mimeType string
if animated {
// This url requests a gif, so that's what we set the mimetype to.
url = discordgo.EndpointEmojiAnimated(id)
mimeType = "image/gif"
} else {
// This url requests a png, so that's what we set the mimetype to.
url = discordgo.EndpointEmoji(id)
mimeType = "image/png"
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, mimeType, err
}
req.Header.Set("User-Agent", discordgo.DroidBrowserUserAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, mimeType, err
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
return data, mimeType, err
}
func (portal *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
uploaded, err := intent.UploadBytes(data, mimeType)
if err != nil {
return id.ContentURI{}, err
}
return uploaded.ContentURI, nil
}

View File

@@ -140,6 +140,20 @@ bridge:
# Whether or not created rooms should have federation enabled. # Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated. # If false, created portal rooms will never be federated.
federate_rooms: true federate_rooms: true
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (lottie JSON)
# png - converts to non-animated png (fastest)
# gif - converts to animated gif
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
target: webp
# Arguments for converter. All converters take width and height.
args:
width: 320
height: 320
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
# Servers to always allow double puppeting from # Servers to always allow double puppeting from
double_puppet_server_map: double_puppet_server_map:
example.com: https://example.com example.com: https://example.com

View File

@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge. // mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan // Copyright (C) 2023 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@@ -22,7 +22,9 @@ import (
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@@ -31,24 +33,57 @@ import (
"maunium.net/go/mautrix/util/variationselector" "maunium.net/go/mautrix/util/variationselector"
) )
var discordExtensions = goldmark.WithExtensions(mdext.SimpleSpoiler, mdext.DiscordUnderline, &DiscordEveryone{}) // escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
//
// Discord allows escaping with just one backslash, e.g. \__a__,
// but standard markdown requires both to be escaped (\_\_a__)
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string) string { func escapeReplacement(s string) string {
text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string {
return s[:2] + `\` + s[2:] return s[:2] + `\` + s[2:]
}) }
mdRenderer := goldmark.New( // indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
goldmark.WithParser(mdext.ParserWithoutFeatures( // Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
type indentableParagraphParser struct {
parser.BlockParser
}
var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
return true
}
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
)), parser.NewSetextHeadingParser(), parser.NewATXHeadingParser(), parser.NewThematicBreakParser(),
format.Extensions, format.HTMLOptions, discordExtensions, parser.NewCodeBlockParser(),
goldmark.WithExtensions(&DiscordTag{portal}), }
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
var discordRenderer = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
var discordRendererWithInlineLinks = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
) )
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder var buf strings.Builder
err := mdRenderer.Convert([]byte(text), &buf) ctx := parser.NewContext()
ctx.Set(parserContextPortal, portal)
renderer := discordRenderer
if allowInlineLinks {
renderer = discordRendererWithInlineLinks
}
err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
if err != nil { if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err)) panic(fmt.Errorf("markdown parser errored: %w", err))
} }

View File

@@ -96,9 +96,11 @@ func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, so
return return
} }
type DiscordEveryone struct{} type discordEveryone struct{}
func (e *DiscordEveryone) Extend(m goldmark.Markdown) { var ExtDiscordEveryone = &discordEveryone{}
func (e *discordEveryone) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers( m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordEveryoneParser, 600), util.Prioritized(defaultDiscordEveryoneParser, 600),
)) ))

View File

@@ -37,6 +37,7 @@ import (
type astDiscordTag struct { type astDiscordTag struct {
ast.BaseInline ast.BaseInline
portal *Portal
id int64 id int64
} }
@@ -143,7 +144,10 @@ func (s *discordTagParser) Trigger() []byte {
return []byte{'<'} return []byte{'<'}
} }
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
portal := pc.Get(parserContextPortal).(*Portal)
//before := block.PrecendingCharacter() //before := block.PrecendingCharacter()
line, _ := block.PeekLine() line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line) match := discordTagRegex.FindSubmatch(line)
@@ -157,7 +161,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
if err != nil { if err != nil {
return nil return nil
} }
tag := astDiscordTag{id: id} tag := astDiscordTag{id: id, portal: portal}
tagName := string(match[1]) tagName := string(match[1])
switch { switch {
case tagName == "@": case tagName == "@":
@@ -199,9 +203,9 @@ func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do // nothing to do
} }
type discordTagHTMLRenderer struct { type discordTagHTMLRenderer struct{}
portal *Portal
} var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordTag, r.renderDiscordMention) reg.Register(astKindDiscordTag, r.renderDiscordMention)
@@ -259,17 +263,17 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
} }
switch node := n.(type) { switch node := n.(type) {
case *astDiscordUserMention: case *astDiscordUserMention:
puppet := r.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)) puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10))
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name) _, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name)
return return
case *astDiscordRoleMention: case *astDiscordRoleMention:
role := r.portal.bridge.DB.Role.GetByID(r.portal.GuildID, strconv.FormatInt(node.id, 10)) role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
if role != nil { if role != nil {
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name) _, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return return
} }
case *astDiscordChannelMention: case *astDiscordChannelMention:
portal := r.portal.bridge.GetExistingPortalByID(database.PortalKey{ portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
ChannelID: strconv.FormatInt(node.id, 10), ChannelID: strconv.FormatInt(node.id, 10),
Receiver: "", Receiver: "",
}) })
@@ -282,7 +286,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return return
} }
case *astDiscordCustomEmoji: case *astDiscordCustomEmoji:
reactionMXC := r.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated) reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
if !reactionMXC.IsEmpty() { if !reactionMXC.IsEmpty() {
_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name) _, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
return return
@@ -310,15 +314,15 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return return
} }
type DiscordTag struct { type discordTag struct{}
Portal *Portal
}
func (e *DiscordTag) Extend(m goldmark.Markdown) { var ExtDiscordTag = &discordTag{}
func (e *discordTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers( m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordTagParser, 600), util.Prioritized(defaultDiscordTagParser, 600),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&discordTagHTMLRenderer{e.Portal}, 600), util.Prioritized(defaultDiscordTagHTMLRenderer, 600),
)) ))
} }

16
go.mod
View File

@@ -3,7 +3,7 @@ module go.mau.fi/mautrix-discord
go 1.18 go 1.18
require ( require (
github.com/bwmarrin/discordgo v0.26.1 github.com/bwmarrin/discordgo v0.27.0
github.com/gabriel-vasile/mimetype v1.4.1 github.com/gabriel-vasile/mimetype v1.4.1
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
@@ -12,9 +12,9 @@ require (
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.16
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.1
github.com/yuin/goldmark v1.5.3 github.com/yuin/goldmark v1.5.4
maunium.net/go/maulogger/v2 v2.3.2 maunium.net/go/maulogger/v2 v2.3.2
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 maunium.net/go/mautrix v0.14.0
) )
require ( require (
@@ -22,16 +22,16 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.28.0 // indirect github.com/rs/zerolog v1.29.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.5.0 // indirect golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.5.0 // indirect golang.org/x/net v0.6.0 // indirect
golang.org/x/sys v0.4.0 // indirect golang.org/x/sys v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect
) )
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399 replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d

28
go.sum
View File

@@ -1,6 +1,6 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399 h1:3GZhhiyeXo/r40NmaQddBpCfosSSIrSrqZBLXJWrtYc= github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d h1:PndQKe7wiuQuVIWepQksfaRWUxZcoh6GWLXfWbdAN3g=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -27,8 +27,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -47,22 +47,22 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M= github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -77,5 +77,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0= maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 h1:7KoJL/7eozYlu4GW2jADHO+Qhm8WL45Afcm7A45BivM= maunium.net/go/mautrix v0.14.0 h1:kdQ06HzmMaLGZqmSh/ykDhp5C2gIREQL9TS8hY+FqLs=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM= maunium.net/go/mautrix v0.14.0/go.mod h1:voJPvnTkA60rxBl6mvdPxcP7y7iY5w3d/K55IoX+2oY=

View File

@@ -23,6 +23,7 @@ import (
"maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/configupgrade" "maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config" "go.mau.fi/mautrix-discord/config"
@@ -71,6 +72,8 @@ type DiscordBridge struct {
puppets map[string]*Puppet puppets map[string]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex puppetsLock sync.Mutex
attachmentTransfers *util.SyncMap[attachmentKey, *util.ReturnableOnce[*database.File]]
} }
func (br *DiscordBridge) GetExampleConfig() string { func (br *DiscordBridge) GetExampleConfig() string {
@@ -163,12 +166,14 @@ func main() {
puppets: make(map[string]*Puppet), puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet), puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](),
} }
br.Bridge = bridge.Bridge{ br.Bridge = bridge.Bridge{
Name: "mautrix-discord", Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord", URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.", Description: "A Matrix-Discord puppeting bridge.",
Version: "0.1.0", Version: "0.1.1",
ProtocolName: "Discord", ProtocolName: "Discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp", CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",

143
portal.go
View File

@@ -555,7 +555,11 @@ func (portal *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridg
const DiscordStickerSize = 160 const DiscordStickerSize = 160
func (portal *Portal) handleDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { func (portal *Portal) handleDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, id, content.Info.MimeType) meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
if typeName == "sticker" && content.Info.MimeType == "application/json" {
meta.Converter = portal.bridge.convertLottie
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
if err != nil { if err != nil {
errorEventID := portal.sendMediaFailedMessage(intent, err) errorEventID := portal.sendMediaFailedMessage(intent, err)
if errorEventID != "" { if errorEventID != "" {
@@ -566,11 +570,18 @@ func (portal *Portal) handleDiscordFile(typeName string, intent *appservice.Inte
} }
return nil return nil
} }
if typeName == "sticker" && content.Info.MimeType == "application/json" {
content.Info.MimeType = dbFile.MimeType
}
content.Info.Size = dbFile.Size content.Info.Size = dbFile.Size
if content.Info.Width == 0 && content.Info.Height == 0 { if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = dbFile.Width content.Info.Width = dbFile.Width
content.Info.Height = dbFile.Height content.Info.Height = dbFile.Height
} }
if content.Info.Width == 0 && content.Info.Height == 0 && typeName == "sticker" {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
if dbFile.DecryptionInfo != nil { if dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{ content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo, EncryptedFile: *dbFile.DecryptionInfo,
@@ -675,7 +686,7 @@ type ConvertedMessage struct {
} }
func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage { func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, "", "") dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta)
if err != nil { if err != nil {
return &ConvertedMessage{Content: portal.createMediaFailedMessage(err)} return &ConvertedMessage{Content: portal.createMediaFailedMessage(err)}
} }
@@ -740,7 +751,7 @@ func (portal *Portal) handleDiscordVideoEmbed(intent *appservice.IntentAPI, embe
const ( const (
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>` embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>` embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon width="24" height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>` embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>`
embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>` embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
embedHTMLAuthorLink = `<a href="%s">%s</a>` embedHTMLAuthorLink = `<a href="%s">%s</a>`
embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>` embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
@@ -751,7 +762,7 @@ const (
embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>` embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>` embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>` embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon width="20" height="20" src="%s" title="Footer icon" alt="">&nbsp;<span>%s</span>%s</sub></p>` embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt="">&nbsp;<span>%s</span>%s</sub></p>`
embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>` embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>` embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
embedHTMLDate = `<time datetime="%s">%s</time>` embedHTMLDate = `<time datetime="%s">%s</time>`
@@ -768,7 +779,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
} }
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
if embed.Author.ProxyIconURL != "" { if embed.Author.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, "", "") dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
if err != nil { if err != nil {
portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err) portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err)
} else { } else {
@@ -779,7 +790,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
} }
if embed.Title != "" { if embed.Title != "" {
var titleHTML string var titleHTML string
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title) baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
if embed.URL != "" { if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else { } else {
@@ -788,7 +799,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
htmlParts = append(htmlParts, titleHTML) htmlParts = append(htmlParts, titleHTML)
} }
if embed.Description != "" { if embed.Description != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description))) htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
} }
for i := 0; i < len(embed.Fields); i++ { for i := 0; i < len(embed.Fields); i++ {
item := embed.Fields[i] item := embed.Fields[i]
@@ -805,20 +816,20 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
headerParts := make([]string, len(splitItems)) headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems)) contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems { for j, splitItem := range splitItems {
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name)) headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value)) contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
} }
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else { } else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline), strconv.FormatBool(item.Inline),
portal.renderDiscordMarkdownOnlyHTML(item.Name), portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
portal.renderDiscordMarkdownOnlyHTML(item.Value), portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
)) ))
} }
} }
if embed.Image != nil { if embed.Image != nil {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, "", "") dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
if err != nil { if err != nil {
portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err) portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err)
} else { } else {
@@ -844,7 +855,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
} }
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
if embed.Footer.ProxyIconURL != "" { if embed.Footer.ProxyIconURL != "" {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, "", "") dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
if err != nil { if err != nil {
portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err) portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err)
} else { } else {
@@ -876,7 +887,7 @@ type BeeperLinkPreview struct {
} }
func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) { func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, "", "") dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
if err != nil { if err != nil {
portal.log.Warnfln("Failed to copy image in URL preview: %v", err) portal.log.Warnfln("Failed to copy image in URL preview: %v", err)
} else { } else {
@@ -919,7 +930,51 @@ const msgInteractionTemplateHTML = `<blockquote>
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>` const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
type BridgeEmbedType int
const (
EmbedUnknown BridgeEmbedType = iota
EmbedRich
EmbedLinkPreview
EmbedVideo
)
func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
// Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
// so this is a hacky way to detect those.
return embed.Video != nil && embed.Video.ProxyURL == ""
}
func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
switch embed.Type {
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
return EmbedLinkPreview
case discordgo.EmbedTypeVideo:
if isActuallyLinkPreview(embed) {
return EmbedLinkPreview
}
return EmbedVideo
case discordgo.EmbedTypeGifv:
return EmbedVideo
case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage:
return EmbedRich
default:
return EmbedUnknown
}
}
func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message, relation *event.RelatesTo, isEdit bool) *ConvertedMessage { func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message, relation *event.RelatesTo, isEdit bool) *ConvertedMessage {
if msg.Type == discordgo.MessageTypeCall {
return &ConvertedMessage{Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "started a call",
}}
} else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
return &ConvertedMessage{Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "joined the server",
}}
}
var htmlParts []string var htmlParts []string
if msg.Interaction != nil { if msg.Interaction != nil {
puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID) puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
@@ -927,16 +982,16 @@ func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, ms
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name)) htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
} }
if msg.Content != "" && !isPlainGifMessage(msg) { if msg.Content != "" && !isPlainGifMessage(msg) {
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content)) htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false))
} }
previews := make([]*BeeperLinkPreview, 0) previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds { for i, embed := range msg.Embeds {
switch embed.Type { switch getEmbedType(embed) {
case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage: case EmbedRich:
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i)) htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i))
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle: case EmbedLinkPreview:
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed)) previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed))
case discordgo.EmbedTypeVideo, discordgo.EmbedTypeGifv: case EmbedVideo:
// Ignore video embeds, they're handled as separate messages // Ignore video embeds, they're handled as separate messages
default: default:
portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID) portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID)
@@ -1057,7 +1112,7 @@ func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Mess
handledURLs := make(map[string]struct{}) handledURLs := make(map[string]struct{})
for i, embed := range msg.Embeds { for i, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage // Ignore non-video embeds, they're handled in convertDiscordTextMessage
if embed.Type != discordgo.EmbedTypeVideo && embed.Type != discordgo.EmbedTypeGifv { if getEmbedType(embed) != EmbedVideo {
continue continue
} }
// Discord deduplicates embeds by URL. It makes things easier for us too. // Discord deduplicates embeds by URL. It makes things easier for us too.
@@ -1144,6 +1199,9 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess
if !ok { if !ok {
portal.log.Debugfln("Dropping edit with no author of non-recent message %s", msg.ID) portal.log.Debugfln("Dropping edit with no author of non-recent message %s", msg.ID)
return return
} else if creationMessage.Type == discordgo.MessageTypeCall {
portal.log.Debugfln("Dropping edit of call message %s", msg.ID)
return
} }
portal.log.Debugfln("Found original message %s in cache for edit without author", msg.ID) portal.log.Debugfln("Found original message %s in cache for edit without author", msg.ID)
if len(msg.Embeds) > 0 { if len(msg.Embeds) > 0 {
@@ -1181,7 +1239,7 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess
} }
for _, remainingEmbed := range msg.Embeds { for _, remainingEmbed := range msg.Embeds {
// Other types of embeds are sent inline with the text message part // Other types of embeds are sent inline with the text message part
if remainingEmbed.Type != discordgo.EmbedTypeVideo && remainingEmbed.Type != discordgo.EmbedTypeGifv { if getEmbedType(remainingEmbed) != EmbedVideo {
continue continue
} }
embedID := "video_" + remainingEmbed.URL embedID := "video_" + remainingEmbed.URL
@@ -1210,12 +1268,11 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess
return return
} }
converted.Content.SetEdit(existing[0].MXID) converted.Content.SetEdit(existing[0].MXID)
extraContentCopy := map[string]any{} if converted.Extra != nil {
for key, value := range converted.Extra { converted.Extra = map[string]any{
extraContentCopy[key] = value "m.new_content": converted.Extra,
}
} }
extraContentCopy["m.new_content"] = converted.Extra
converted.Extra = extraContentCopy
var editTS int64 var editTS int64
if msg.EditedTimestamp != nil { if msg.EditedTimestamp != nil {
@@ -1252,6 +1309,24 @@ func (portal *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Mess
} }
} }
func (portal *Portal) handleDiscordTyping(evt *discordgo.TypingStart) {
puppet := portal.bridge.GetPuppetByID(evt.UserID)
if puppet.Name == "" {
// Puppet hasn't been synced yet
return
}
intent := puppet.IntentFor(portal)
err := intent.EnsureJoined(portal.MXID)
if err != nil {
portal.log.Warnfln("Failed to ensure %s is joined for typing notification: %v", puppet.MXID, portal.MXID, err)
return
}
_, err = intent.UserTyping(portal.MXID, true, 12*time.Second)
if err != nil {
portal.log.Warnfln("Failed to mark %s as typing: %v", puppet.MXID, portal.MXID, err)
}
}
func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) { func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) {
for _, participant := range participants { for _, participant := range participants {
puppet := portal.bridge.GetPuppetByID(participant.ID) puppet := portal.bridge.GetPuppetByID(participant.ID)
@@ -1576,7 +1651,7 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
} }
sendReq.Content = portal.parseMatrixHTML(sender, content) sendReq.Content = portal.parseMatrixHTML(sender, content)
case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
data, err := portal.downloadMatrixAttachment(content) data, err := downloadMatrixAttachment(portal.MainIntent(), content)
if err != nil { if err != nil {
go portal.sendMessageMetrics(evt, err, "Error downloading media in") go portal.sendMessageMetrics(evt, err, "Error downloading media in")
return return
@@ -1821,13 +1896,13 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
emojiID := reaction.RelatesTo.Key emojiID := reaction.RelatesTo.Key
if strings.HasPrefix(emojiID, "mxc://") { if strings.HasPrefix(emojiID, "mxc://") {
uri, _ := id.ParseContentURI(emojiID) uri, _ := id.ParseContentURI(emojiID)
emoji := portal.bridge.DB.Emoji.GetByMatrixURL(uri) emojiFile := portal.bridge.DB.File.GetByMXC(uri)
if emoji == nil { if emojiFile == nil || emojiFile.ID == "" || emojiFile.EmojiName == "" {
go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring") go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring")
return return
} }
emojiID = emoji.APIName() emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID)
} else { } else {
emojiID = variationselector.Remove(emojiID) emojiID = variationselector.Remove(emojiID)
} }
@@ -1855,7 +1930,11 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
} }
func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool, thread *Thread) { func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool, thread *Thread) {
intent := portal.bridge.GetPuppetByID(reaction.UserID).IntentFor(portal) puppet := portal.bridge.GetPuppetByID(reaction.UserID)
if reaction.Member != nil && reaction.Member.User != nil {
puppet.UpdateInfo(nil, reaction.Member.User)
}
intent := puppet.IntentFor(portal)
var discordID string var discordID string
var matrixReaction string var matrixReaction string
@@ -1866,7 +1945,7 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess
return return
} }
matrixReaction = reactionMXC.String() matrixReaction = reactionMXC.String()
discordID = reaction.Emoji.ID discordID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID)
} else { } else {
discordID = reaction.Emoji.Name discordID = reaction.Emoji.Name
matrixReaction = variationselector.Add(reaction.Emoji.Name) matrixReaction = variationselector.Add(reaction.Emoji.Name)

49
user.go
View File

@@ -521,6 +521,7 @@ func (user *User) Connect() error {
user.Session = session user.Session = session
user.Session.AddHandler(user.readyHandler) user.Session.AddHandler(user.readyHandler)
user.Session.AddHandler(user.resumeHandler)
user.Session.AddHandler(user.connectedHandler) user.Session.AddHandler(user.connectedHandler)
user.Session.AddHandler(user.disconnectedHandler) user.Session.AddHandler(user.disconnectedHandler)
user.Session.AddHandler(user.invalidAuthHandler) user.Session.AddHandler(user.invalidAuthHandler)
@@ -613,9 +614,36 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
user.Update() user.Update()
} }
go user.subscribeGuilds(2 * time.Second)
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
} }
func (user *User) subscribeGuilds(delay time.Duration) {
for _, guildMeta := range user.Session.State.Guilds {
guild := user.bridge.GetGuildByID(guildMeta.ID, false)
if guild != nil && guild.MXID != "" {
user.log.Debugfln("Subscribing to guild %s", guild.ID)
dat := discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
}
err := user.Session.SubscribeGuild(dat)
if err != nil {
user.log.Warnfln("Failed to subscribe to %s: %v", guild.ID, err)
}
time.Sleep(delay)
}
}
}
func (user *User) resumeHandler(_ *discordgo.Session, r *discordgo.Resumed) {
user.log.Debugln("Discord connection resumed")
user.subscribeGuilds(0 * time.Second)
}
func (user *User) addPrivateChannelToSpace(portal *Portal) bool { func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
if portal.MXID == "" { if portal.MXID == "" {
return false return false
@@ -879,6 +907,10 @@ func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildI
} }
portal = thread.Parent portal = thread.Parent
} }
// Double check because some messages don't have the guild ID specified.
if !user.bridgeMessage(portal.GuildID) {
return
}
portal.discordMessages <- portalDiscordMessage{ portal.discordMessages <- portalDiscordMessage{
msg: msg, msg: msg,
@@ -963,11 +995,7 @@ func (user *User) typingStartHandler(_ *discordgo.Session, t *discordgo.TypingSt
if portal == nil || portal.MXID == "" { if portal == nil || portal.MXID == "" {
return return
} }
puppet := user.bridge.GetPuppetByID(t.UserID) portal.handleDiscordTyping(t)
_, err := puppet.IntentFor(portal).UserTyping(portal.MXID, true, 12*time.Second)
if err != nil {
user.log.Warnfln("Failed to mark %s as typing in %s: %v", puppet.MXID, portal.MXID, err)
}
} }
func (user *User) interactionSuccessHandler(_ *discordgo.Session, s *discordgo.InteractionSuccess) { func (user *User) interactionSuccessHandler(_ *discordgo.Session, s *discordgo.InteractionSuccess) {
@@ -1125,6 +1153,17 @@ func (user *User) bridgeGuild(guildID string, everything bool) error {
guild.AutoBridgeChannels = everything guild.AutoBridgeChannels = everything
guild.Update() guild.Update()
user.log.Debugfln("Subscribing to guild %s after bridging", guild.ID)
err = user.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
user.log.Warnfln("Failed to subscribe to %s: %v", guild.ID, err)
}
return nil return nil
} }