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:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: [1.19]
steps:
- uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go-version }}
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
go-version: "1.20"
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
rev: v4.4.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@@ -9,7 +9,7 @@ repos:
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-beta.5
rev: v1.0.0-rc.1
hooks:
- id: go-imports-repo
- 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)
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
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 \
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 /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
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
ENV UID=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
COPY $EXECUTABLE /usr/bin/mautrix-discord
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
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
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord

View File

@@ -2,10 +2,15 @@ package main
import (
"bytes"
"context"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@@ -16,6 +21,9 @@ import (
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"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"
)
@@ -62,7 +70,7 @@ func uploadDiscordAttachment(url string, data []byte) error {
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
rawMXC := content.URL
@@ -76,7 +84,7 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
return nil, err
}
data, err := portal.MainIntent().DownloadBytes(mxc)
data, err := intent.DownloadBytes(mxc)
if err != nil {
return nil, err
}
@@ -91,23 +99,24 @@ func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventConten
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.Timestamp = time.Now()
dbFile.URL = url
dbFile.ID = attachmentID
dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String()
if mime == "" {
mime = dbFile.MimeType
if meta.MimeType == "" {
meta.MimeType = dbFile.MimeType
}
if strings.HasPrefix(mime, "image/") {
if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width
dbFile.Height = cfg.Height
}
uploadMime := mime
uploadMime := meta.MimeType
if encrypt {
dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
@@ -140,22 +149,161 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da
}
dbFile.MXC = uploaded.ContentURI
}
dbFile.Insert(nil)
return dbFile, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, attachmentID, mime string) (*database.File, error) {
dbFile := br.DB.File.Get(url, encrypt)
if dbFile == nil {
data, err := downloadDiscordAttachment(url)
if err != nil {
return nil, err
}
type AttachmentMeta struct {
AttachmentID string
MimeType string
EmojiName string
CopyIfMissing bool
Converter func([]byte) ([]byte, string, error)
}
dbFile, err = br.uploadMatrixAttachment(intent, data, url, encrypt, attachmentID, mime)
if err != nil {
return nil, err
}
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)
}
return dbFile, nil
ctx := context.Background()
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
if err != nil {
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
}
}
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
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))
}

View File

@@ -50,6 +50,14 @@ type BridgeConfig struct {
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
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"`
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_guild_on_leave")
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.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")

View File

@@ -21,7 +21,6 @@ type Database struct {
Message *MessageQuery
Thread *ThreadQuery
Reaction *ReactionQuery
Emoji *EmojiQuery
Guild *GuildQuery
Role *RoleQuery
File *FileQuery
@@ -54,10 +53,6 @@ func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
db: db,
log: log.Sub("Reaction"),
}
db.Emoji = &EmojiQuery{
db: db,
log: log.Sub("Emoji"),
}
db.Guild = &GuildQuery{
db: db,
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
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 = `
INSERT INTO discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
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, $11)
`
)
@@ -39,15 +39,21 @@ func (fq *FileQuery) Get(url string, encrypted bool) *File {
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 {
db *Database
log log.Logger
URL string
Encrypted bool
MXC id.ContentURI
ID string
MXC id.ContentURI
ID string
EmojiName string
Size int
Width int
@@ -55,16 +61,15 @@ type File struct {
MimeType string
DecryptionInfo *attachment.EncryptedFile
Timestamp time.Time
Timestamp time.Time
}
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 timestamp int64
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 !errors.Is(err, sql.ErrNoRows) {
f.log.Errorln("Database scan failed:", err)
@@ -73,6 +78,7 @@ func (f *File) Scan(row dbutil.Scannable) *File {
return nil
}
f.ID = fileID.String
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp)
f.Width = int(width.Int32)
f.Height = int(height.Int32)
@@ -114,7 +120,7 @@ func (f *File) Insert(txn dbutil.Execable) {
decryptionInfoStr.String = string(decryptionInfo)
}
_, 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,
decryptionInfoStr, f.Timestamp.UnixMilli(),
)

View File

@@ -1,4 +1,4 @@
-- v0 -> v12: Latest revision
-- v0 -> v13: Latest revision
CREATE TABLE guild (
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
);
CREATE TABLE emoji (
discord_id TEXT PRIMARY KEY,
discord_name TEXT,
matrix_url TEXT
);
CREATE TABLE role (
dc_guild_id TEXT,
dcid TEXT,
@@ -154,18 +148,17 @@ CREATE TABLE role (
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL UNIQUE,
id TEXT,
mxc TEXT NOT NULL,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
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,
timestamp BIGINT NOT NULL,
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.
# If false, created portal rooms will never be federated.
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
double_puppet_server_map:
example.com: https://example.com

View File

@@ -1,5 +1,5 @@
// 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
// it under the terms of the GNU Affero General Public License as published by
@@ -22,7 +22,9 @@ import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
@@ -31,24 +33,57 @@ import (
"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(`\\(__[^_]|\*\*[^*])`)
func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string) string {
text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string {
return s[:2] + `\` + s[2:]
})
func escapeReplacement(s string) string {
return s[:2] + `\` + s[2:]
}
mdRenderer := goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
)),
format.Extensions, format.HTMLOptions, discordExtensions,
goldmark.WithExtensions(&DiscordTag{portal}),
)
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
// 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.NewSetextHeadingParser(), parser.NewATXHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(),
}
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
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 {
panic(fmt.Errorf("markdown parser errored: %w", err))
}

View File

@@ -96,9 +96,11 @@ func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, so
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(
util.Prioritized(defaultDiscordEveryoneParser, 600),
))

View File

@@ -37,7 +37,8 @@ import (
type astDiscordTag struct {
ast.BaseInline
id int64
portal *Portal
id int64
}
var _ ast.Node = (*astDiscordTag)(nil)
@@ -143,7 +144,10 @@ func (s *discordTagParser) Trigger() []byte {
return []byte{'<'}
}
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
portal := pc.Get(parserContextPortal).(*Portal)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
@@ -157,7 +161,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
if err != nil {
return nil
}
tag := astDiscordTag{id: id}
tag := astDiscordTag{id: id, portal: portal}
tagName := string(match[1])
switch {
case tagName == "@":
@@ -199,9 +203,9 @@ func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordTagHTMLRenderer struct {
portal *Portal
}
type discordTagHTMLRenderer struct{}
var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordTag, r.renderDiscordMention)
@@ -259,17 +263,17 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
}
switch node := n.(type) {
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)
return
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 {
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return
}
case *astDiscordChannelMention:
portal := r.portal.bridge.GetExistingPortalByID(database.PortalKey{
portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
ChannelID: strconv.FormatInt(node.id, 10),
Receiver: "",
})
@@ -282,7 +286,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return
}
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() {
_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
return
@@ -310,15 +314,15 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return
}
type DiscordTag struct {
Portal *Portal
}
type discordTag struct{}
func (e *DiscordTag) Extend(m goldmark.Markdown) {
var ExtDiscordTag = &discordTag{}
func (e *discordTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordTagParser, 600),
))
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
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/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
@@ -12,9 +12,9 @@ require (
github.com/mattn/go-sqlite3 v1.14.16
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
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/mautrix v0.13.1-0.20230129131014-888cfabd8a52
maunium.net/go/mautrix v0.14.0
)
require (
@@ -22,16 +22,16 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // 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/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.6.0 // indirect
golang.org/x/sys v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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/beeper/discordgo v0.0.0-20230129125832-37978ff8e399 h1:3GZhhiyeXo/r40NmaQddBpCfosSSIrSrqZBLXJWrtYc=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/beeper/discordgo v0.0.0-20230215201850-32771907474d h1:PndQKe7wiuQuVIWepQksfaRWUxZcoh6GWLXfWbdAN3g=
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/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=
@@ -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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
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/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
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/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
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.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
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.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
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-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
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-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-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.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
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.13.1-0.20230129131014-888cfabd8a52/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM=
maunium.net/go/mautrix v0.14.0 h1:kdQ06HzmMaLGZqmSh/ykDhp5C2gIREQL9TS8hY+FqLs=
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/commands"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
@@ -71,6 +72,8 @@ type DiscordBridge struct {
puppets map[string]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
attachmentTransfers *util.SyncMap[attachmentKey, *util.ReturnableOnce[*database.File]]
}
func (br *DiscordBridge) GetExampleConfig() string {
@@ -163,12 +166,14 @@ func main() {
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: util.NewSyncMap[attachmentKey, *util.ReturnableOnce[*database.File]](),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.1.0",
Version: "0.1.1",
ProtocolName: "Discord",
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
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 {
errorEventID := portal.sendMediaFailedMessage(intent, err)
if errorEventID != "" {
@@ -566,11 +570,18 @@ func (portal *Portal) handleDiscordFile(typeName string, intent *appservice.Inte
}
return nil
}
if typeName == "sticker" && content.Info.MimeType == "application/json" {
content.Info.MimeType = dbFile.MimeType
}
content.Info.Size = dbFile.Size
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = dbFile.Width
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 {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
@@ -675,7 +686,7 @@ type ConvertedMessage struct {
}
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 {
return &ConvertedMessage{Content: portal.createMediaFailedMessage(err)}
}
@@ -740,7 +751,7 @@ func (portal *Portal) handleDiscordVideoEmbed(intent *appservice.IntentAPI, embe
const (
embedHTMLWrapper = `<blockquote class="discord-embed">%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>`
embedHTMLAuthorLink = `<a href="%s">%s</a>`
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>`
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>`
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>`
embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
embedHTMLDate = `<time datetime="%s">%s</time>`
@@ -768,7 +779,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
}
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
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 {
portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err)
} else {
@@ -779,7 +790,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
}
if embed.Title != "" {
var titleHTML string
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title)
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else {
@@ -788,7 +799,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
htmlParts = append(htmlParts, titleHTML)
}
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++ {
item := embed.Fields[i]
@@ -805,20 +816,20 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems {
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value))
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
}
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline),
portal.renderDiscordMarkdownOnlyHTML(item.Name),
portal.renderDiscordMarkdownOnlyHTML(item.Value),
portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
))
}
}
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 {
portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err)
} else {
@@ -844,7 +855,7 @@ func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embe
}
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
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 {
portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err)
} else {
@@ -876,7 +887,7 @@ type BeeperLinkPreview struct {
}
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 {
portal.log.Warnfln("Failed to copy image in URL preview: %v", err)
} 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>`
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 {
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
if msg.Interaction != nil {
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))
}
if msg.Content != "" && !isPlainGifMessage(msg) {
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content))
htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false))
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
switch embed.Type {
case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage:
switch getEmbedType(embed) {
case EmbedRich:
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i))
case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
case EmbedLinkPreview:
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed))
case discordgo.EmbedTypeVideo, discordgo.EmbedTypeGifv:
case EmbedVideo:
// Ignore video embeds, they're handled as separate messages
default:
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{})
for i, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if embed.Type != discordgo.EmbedTypeVideo && embed.Type != discordgo.EmbedTypeGifv {
if getEmbedType(embed) != EmbedVideo {
continue
}
// 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 {
portal.log.Debugfln("Dropping edit with no author of non-recent message %s", msg.ID)
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)
if len(msg.Embeds) > 0 {
@@ -1181,7 +1239,7 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess
}
for _, remainingEmbed := range msg.Embeds {
// 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
}
embedID := "video_" + remainingEmbed.URL
@@ -1210,12 +1268,11 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess
return
}
converted.Content.SetEdit(existing[0].MXID)
extraContentCopy := map[string]any{}
for key, value := range converted.Extra {
extraContentCopy[key] = value
if converted.Extra != nil {
converted.Extra = map[string]any{
"m.new_content": converted.Extra,
}
}
extraContentCopy["m.new_content"] = converted.Extra
converted.Extra = extraContentCopy
var editTS int64
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) {
for _, participant := range participants {
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)
case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
data, err := portal.downloadMatrixAttachment(content)
data, err := downloadMatrixAttachment(portal.MainIntent(), content)
if err != nil {
go portal.sendMessageMetrics(evt, err, "Error downloading media in")
return
@@ -1821,13 +1896,13 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) {
emojiID := reaction.RelatesTo.Key
if strings.HasPrefix(emojiID, "mxc://") {
uri, _ := id.ParseContentURI(emojiID)
emoji := portal.bridge.DB.Emoji.GetByMatrixURL(uri)
if emoji == nil {
emojiFile := portal.bridge.DB.File.GetByMXC(uri)
if emojiFile == nil || emojiFile.ID == "" || emojiFile.EmojiName == "" {
go portal.sendMessageMetrics(evt, fmt.Errorf("%w %s", errUnknownEmoji, emojiID), "Ignoring")
return
}
emojiID = emoji.APIName()
emojiID = fmt.Sprintf("%s:%s", emojiFile.EmojiName, emojiFile.ID)
} else {
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) {
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 matrixReaction string
@@ -1866,7 +1945,7 @@ func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.Mess
return
}
matrixReaction = reactionMXC.String()
discordID = reaction.Emoji.ID
discordID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID)
} else {
discordID = 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.AddHandler(user.readyHandler)
user.Session.AddHandler(user.resumeHandler)
user.Session.AddHandler(user.connectedHandler)
user.Session.AddHandler(user.disconnectedHandler)
user.Session.AddHandler(user.invalidAuthHandler)
@@ -613,9 +614,36 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
user.Update()
}
go user.subscribeGuilds(2 * time.Second)
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 {
if portal.MXID == "" {
return false
@@ -879,6 +907,10 @@ func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildI
}
portal = thread.Parent
}
// Double check because some messages don't have the guild ID specified.
if !user.bridgeMessage(portal.GuildID) {
return
}
portal.discordMessages <- portalDiscordMessage{
msg: msg,
@@ -963,11 +995,7 @@ func (user *User) typingStartHandler(_ *discordgo.Session, t *discordgo.TypingSt
if portal == nil || portal.MXID == "" {
return
}
puppet := user.bridge.GetPuppetByID(t.UserID)
_, 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)
}
portal.handleDiscordTyping(t)
}
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.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
}