78 Commits

Author SHA1 Message Date
Tulir Asokan
69268f8d92 Bump version to v0.2.0 2023-03-16 13:04:30 +02:00
Tulir Asokan
05bc4f9312 Update dependencies 2023-03-16 12:46:59 +02:00
Tulir Asokan
f5ef87eb83 Update changelog and readme 2023-03-16 12:43:56 +02:00
Tulir Asokan
3cdf018c37 Update mautrix-go 2023-03-15 16:21:57 +02:00
Tulir Asokan
46115fafd5 Switch user and puppet files to zerolog 2023-03-12 14:25:24 +02:00
Tulir Asokan
15d4cf07f9 Fix mistake in reaction replace error handling 2023-03-11 00:07:53 +02:00
Tulir Asokan
ff052d7f18 Update portalsByMXID when manually bridging 2023-03-10 20:18:00 +02:00
Tulir Asokan
ef7e77515a Fix bugs in manual un/bridging 2023-03-10 18:41:29 +02:00
Tulir Asokan
0deec8b853 Retry on unknown errors when logging in 2023-03-10 17:39:34 +02:00
Tulir Asokan
d42c4722c9 Fill usersByID properly 2023-03-08 19:49:43 +02:00
Tulir Asokan
ee2ad7527e Add some logs when disconnecting 2023-03-08 19:21:58 +02:00
Tulir Asokan
5a40f0a2ab Fix error log 2023-03-07 21:00:29 +02:00
Tulir Asokan
c163fba712 Update mautrix-go 2023-03-05 23:51:02 +02:00
Tulir Asokan
9c87532d52 Update state cache after manaul bridging 2023-03-05 19:29:28 +02:00
Tulir Asokan
9b63defbe8 Fix order of cleanup and removing mxid 2023-03-04 14:32:55 +02:00
Tulir Asokan
4e9e50dbed Don't allow overriding set-relay without unsetting first 2023-03-04 14:28:01 +02:00
Tulir Asokan
3c52e76e15 Add bridge/unbridge/delete-portal commands
Fixes #34
2023-03-04 14:27:59 +02:00
Tulir Asokan
0e8b845014 Update changelog
[skip ci]
2023-03-01 22:50:19 +02:00
Tulir Asokan
f8bbcc9080 Update discordgo to fix some bugs
(and possibly add new bugs)
2023-03-01 22:16:42 +02:00
Tulir Asokan
febb28882e Add ping command 2023-03-01 22:05:57 +02:00
Tulir Asokan
0403a705b6 Add help sections for all commands 2023-03-01 20:43:40 +02:00
Tulir Asokan
2440ca4e83 Add unset-relay command 2023-03-01 20:36:07 +02:00
Tulir Asokan
39096c9347 Require room admin for set-relay 2023-03-01 20:35:50 +02:00
Tulir Asokan
72d4fb755b Add error status when user isn't logged in 2023-03-01 19:54:08 +02:00
Tulir Asokan
7bfa885530 Validate webhook URLs when using set-relay --url 2023-03-01 18:49:06 +02:00
Tulir Asokan
f7c8e03041 Handle redactions from webhook users 2023-03-01 18:48:40 +02:00
Tulir Asokan
d3828f2fb3 Update changelog
[skip ci]
2023-03-01 18:22:44 +02:00
Tulir Asokan
bccdc67eb2 Adjust guild info logs 2023-02-28 21:43:55 +02:00
Tulir Asokan
c625ee3ba7 Update gitignore 2023-02-28 00:44:59 +02:00
Tulir Asokan
17d4b79554 Add initial support for relay mode with webhooks 2023-02-28 00:40:53 +02:00
Tulir Asokan
6365db46cc Remove unnecessary user parameter in parseMatrixHTML 2023-02-27 22:47:45 +02:00
Tulir Asokan
af52979669 Fix attachment IDs in message converter 2023-02-27 18:51:13 +02:00
Tulir Asokan
ccd29752c7 Fetch missing channel info on message to support DMs for bots 2023-02-27 11:42:53 +02:00
Tulir Asokan
4eba894573 Fix state store not being updated on double puppet requests 2023-02-27 01:29:20 +02:00
Tulir Asokan
71d1689776 Adjust some calls for bot accounts 2023-02-27 01:19:26 +02:00
Tulir Asokan
ce4d05bb11 Don't save discord token before login is successful 2023-02-27 01:19:26 +02:00
Tulir Asokan
681a5ff2ab Create Matrix user mentions even without double puppeting. Fixes #21 2023-02-27 01:03:01 +02:00
Tulir Asokan
60c260a471 Add initial support for bot accounts. Fixes #12 2023-02-27 01:02:58 +02:00
Tulir Asokan
efd22e33b5 Delete guild portals too in delete-all-portals 2023-02-27 00:10:06 +02:00
Tulir Asokan
7b5c057dcf Refactor message handling to fully use convert pattern 2023-02-26 23:47:01 +02:00
Tulir Asokan
a0cc5ec9bc Fully qualify emojis instead of removing VS16. Fixes #58 2023-02-26 21:57:21 +02:00
Tulir Asokan
77b230f4d8 Update mautrix-go and switch to zerolog 2023-02-26 21:57:18 +02:00
Tulir Asokan
cace8b5939 Handle gif stickers 2023-02-26 20:46:12 +02:00
Nick Mills-Barrett
ac7ad471a5 Ensure room is muted before sending events to it 2023-02-24 18:31:18 +00:00
Tulir Asokan
a6c3b84db5 Fix update ghost info on reaction 2023-02-23 15:09:21 +02:00
Tulir Asokan
4676ec98c4 Add more options for guild message handling 2023-02-18 22:56:20 +02:00
Tulir Asokan
541c8e1169 Bump Go version in go.mod. Fixes #57
[skip ci]
2023-02-16 16:54:59 +02:00
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
40 changed files with 2180 additions and 1132 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

9
.gitignore vendored
View File

@@ -1,5 +1,6 @@
config.yaml *.yaml
discord !example-config.yaml
logs/ !.pre-commit-config.yaml
registration.yaml
*.db* *.db*
*.log*

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,37 @@
# v0.2.0 (2023-03-16)
* Switched to zerolog for logging.
* The basic log config will be migrated automatically, but you may want to
tweak it as the options are different.
* Added support for logging in with a bot account.
The [Authentication docs](https://docs.mau.fi/bridges/go/discord/authentication.html)
have been updated with instructions for creating a bot.
* Added support for relaying messages for unauthenticated users using a webhook.
See [docs](https://docs.mau.fi/bridges/go/discord/relay.html) for instructions.
* Added commands to bridge and unbridge channels manually.
* Added `ping` command.
* Added support for gif stickers from Discord.
* Changed mention bridging so mentions for users logged into the bridge use the
Matrix user's MXID even if double puppeting is not enabled.
* Actually fixed ghost user info not being synced when receiving reactions.
* Fixed uncommon bug with sending messages that only occurred after login
before restarting the bridge.
* Fixed guild name not being synced immediately after joining a new guild.
* Fixed variation selectors when bridging emojis to Discord.
# 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

@@ -8,7 +8,8 @@ All setup and usage instructions are located on [docs.mau.fi]. Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord) * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=discord)
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord)) (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=discord))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html) * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/discord/authentication.html),
[Relaying with webhooks](https://docs.mau.fi/bridges/go/discord/relay.html)
### Features & Roadmap ### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md) [ROADMAP.md](https://github.com/mautrix/discord/blob/main/ROADMAP.md)

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{}
if err != nil {
return nil, err 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

@@ -19,6 +19,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"html" "html"
@@ -30,10 +31,13 @@ import (
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth" "go.mau.fi/mautrix-discord/remoteauth"
) )
@@ -44,14 +48,22 @@ type WrappedCommandEvent struct {
Portal *Portal Portal *Portal
} }
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
func (br *DiscordBridge) RegisterCommands() { func (br *DiscordBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor) proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers( proc.AddHandlers(
cmdLoginToken, cmdLoginToken,
cmdLoginQR, cmdLoginQR,
cmdLogout, cmdLogout,
cmdPing,
cmdReconnect, cmdReconnect,
cmdDisconnect, cmdDisconnect,
cmdBridge,
cmdUnbridge,
cmdDeletePortal,
cmdSetRelay,
cmdUnsetRelay,
cmdGuilds, cmdGuilds,
cmdRejoinSpace, cmdRejoinSpace,
cmdDeleteAllPortals, cmdDeleteAllPortals,
@@ -78,12 +90,45 @@ var cmdLoginToken = &commands.FullHandler{
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionAuth, Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by extracting the access token manually.", Description: "Link the bridge to your Discord account by extracting the access token manually.",
Args: "<user/bot/oauth> <_token_>",
}, },
} }
const discordTokenEpoch = 1293840000
func decodeToken(token string) (userID int64, err error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
err = fmt.Errorf("invalid number of parts in token")
return
}
var userIDStr []byte
userIDStr, err = base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
err = fmt.Errorf("invalid base64 in user ID part: %w", err)
return
}
_, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
err = fmt.Errorf("invalid base64 in random part: %w", err)
return
}
_, err = base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
err = fmt.Errorf("invalid base64 in checksum part: %w", err)
return
}
userID, err = strconv.ParseInt(string(userIDStr), 10, 64)
if err != nil {
err = fmt.Errorf("invalid number in decoded user ID part: %w", err)
return
}
return
}
func fnLoginToken(ce *WrappedCommandEvent) { func fnLoginToken(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 { if len(ce.Args) != 2 {
ce.Reply("**Usage**: `$cmdprefix login-token <token>`") ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
return return
} }
ce.MarkRead() ce.MarkRead()
@@ -92,7 +137,25 @@ func fnLoginToken(ce *WrappedCommandEvent) {
ce.Reply("You're already logged in") ce.Reply("You're already logged in")
return return
} }
if err := ce.User.Login(ce.Args[0]); err != nil { token := ce.Args[1]
userID, err := decodeToken(token)
if err != nil {
ce.Reply("Invalid token")
return
}
switch strings.ToLower(ce.Args[0]) {
case "user":
// Token is used as-is
case "bot":
token = "Bot " + token
case "oauth":
token = "Bearer " + token
default:
ce.Reply("Token type must be `user`, `bot` or `oauth`")
return
}
ce.Reply("Connecting to Discord as user ID %d", userID)
if err = ce.User.Login(token); err != nil {
ce.Reply("Error connecting to Discord: %v", err) ce.Reply("Error connecting to Discord: %v", err)
return return
} }
@@ -218,7 +281,7 @@ var cmdLogout = &commands.FullHandler{
func fnLogout(ce *WrappedCommandEvent) { func fnLogout(ce *WrappedCommandEvent) {
wasLoggedIn := ce.User.DiscordID != "" wasLoggedIn := ce.User.DiscordID != ""
ce.User.Logout() ce.User.Logout(false)
if wasLoggedIn { if wasLoggedIn {
ce.Reply("Logged out successfully.") ce.Reply("Logged out successfully.")
} else { } else {
@@ -226,6 +289,29 @@ func fnLogout(ce *WrappedCommandEvent) {
} }
} }
var cmdPing = &commands.FullHandler{
Func: wrapCommand(fnPing),
Name: "ping",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Check your connection to Discord",
},
}
func fnPing(ce *WrappedCommandEvent) {
if ce.User.Session == nil {
if ce.User.DiscordToken == "" {
ce.Reply("You're not logged in")
} else {
ce.Reply("You have a Discord token stored, but are not connected for some reason 🤔")
}
} else if ce.User.wasDisconnected {
ce.Reply("You're logged in, but the Discord connection seems to be dead 💥")
} else {
ce.Reply("You're logged in as %s#%s (`%s`)", ce.User.Session.State.User.Username, ce.User.Session.State.User.Discriminator, ce.User.DiscordID)
}
}
var cmdDisconnect = &commands.FullHandler{ var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect), Func: wrapCommand(fnDisconnect),
Name: "disconnect", Name: "disconnect",
@@ -271,7 +357,7 @@ var cmdRejoinSpace = &commands.FullHandler{
Func: wrapCommand(fnRejoinSpace), Func: wrapCommand(fnRejoinSpace),
Name: "rejoin-space", Name: "rejoin-space",
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionPortalManagement,
Description: "Ask the bridge for an invite to a space you left", Description: "Ask the bridge for an invite to a space you left",
Args: "<_guild ID_/main/dms>", Args: "<_guild ID_/main/dms>",
}, },
@@ -298,25 +384,182 @@ func fnRejoinSpace(ce *WrappedCommandEvent) {
} }
} }
var roomModerator = event.Type{Type: "fi.mau.discord.admin", Class: event.StateEventType}
var cmdSetRelay = &commands.FullHandler{
Func: wrapCommand(fnSetRelay),
Name: "set-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Create or set a relay webhook for a portal",
Args: "[room ID] <--url URL> OR <--create [name]>",
},
RequiresLogin: true,
RequiresEventLevel: roomModerator,
}
const webhookURLFormat = "https://discord.com/api/webhooks/%d/%s"
const selectRelayHelp = "Usage: `$cmdprefix [room ID] <--url URL> OR <--create [name]>`"
func fnSetRelay(ce *WrappedCommandEvent) {
portal := ce.Portal
if len(ce.Args) > 0 && strings.HasPrefix(ce.Args[0], "!") {
portal = ce.Bridge.GetPortalByMXID(id.RoomID(ce.Args[0]))
if portal == nil {
ce.Reply("Portal with room ID %s not found", ce.Args[0])
return
}
if ce.User.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
levels, err := portal.MainIntent().PowerLevels(ce.RoomID)
if err != nil {
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
ce.Reply("Failed to get room power levels to see if you're allowed to use that command")
return
} else if levels.GetUserLevel(ce.User.GetMXID()) < levels.GetEventLevel(roomModerator) {
ce.Reply("You don't have admin rights in that room")
return
}
}
ce.Args = ce.Args[1:]
} else if portal == nil {
ce.Reply("You must either run the command in a portal, or specify an internal room ID as the first parameter")
return
}
log := ce.ZLog.With().Str("channel_id", portal.Key.ChannelID).Logger()
if portal.GuildID == "" {
ce.Reply("Only guild channels can have relays")
return
} else if portal.RelayWebhookID != "" {
webhookMeta, err := relayClient.WebhookWithToken(portal.RelayWebhookID, portal.RelayWebhookSecret)
if err != nil {
log.Warn().Err(err).Msg("Failed to get existing webhook info")
ce.Reply("This channel has a relay webhook set, but getting its info failed: %v", err)
return
}
ce.Reply("This channel already has a relay webhook %s (%s)", webhookMeta.Name, webhookMeta.ID)
return
} else if len(ce.Args) == 0 {
ce.Reply(selectRelayHelp)
return
}
createType := strings.ToLower(strings.TrimLeft(ce.Args[0], "-"))
var webhookMeta *discordgo.Webhook
switch createType {
case "url":
if len(ce.Args) < 2 {
ce.Reply("Usage: `$cmdprefix [room ID] --url <URL>")
return
}
ce.Redact()
var webhookID int64
var webhookSecret string
_, err := fmt.Sscanf(ce.Args[1], webhookURLFormat, &webhookID, &webhookSecret)
if err != nil {
log.Warn().Str("webhook_url", ce.Args[1]).Err(err).Msg("Failed to parse provided webhook URL")
ce.Reply("Invalid webhook URL")
return
}
webhookMeta, err = relayClient.WebhookWithToken(strconv.FormatInt(webhookID, 10), webhookSecret)
if err != nil {
log.Warn().Err(err).Msg("Failed to get webhook info")
ce.Reply("Failed to get webhook info: %v", err)
return
}
case "create":
perms, err := ce.User.Session.UserChannelPermissions(ce.User.DiscordID, portal.Key.ChannelID)
if err != nil {
log.Warn().Err(err).Msg("Failed to check user permissions")
ce.Reply("Failed to check if you have permission to create webhooks")
return
} else if perms&discordgo.PermissionManageWebhooks == 0 {
log.Debug().Int64("perms", perms).Msg("User doesn't have permissions to manage webhooks in channel")
ce.Reply("You don't have permission to manage webhooks in that channel")
return
}
name := "mautrix"
if len(ce.Args) > 1 {
name = strings.Join(ce.Args[1:], " ")
}
log.Debug().Str("webhook_name", name).Msg("Creating webhook")
webhookMeta, err = ce.User.Session.WebhookCreate(portal.Key.ChannelID, name, "")
if err != nil {
log.Warn().Err(err).Msg("Failed to create webhook")
ce.Reply("Failed to create webhook: %v", err)
return
}
default:
ce.Reply(selectRelayHelp)
return
}
if portal.Key.ChannelID != webhookMeta.ChannelID {
log.Debug().
Str("portal_channel_id", portal.Key.ChannelID).
Str("webhook_channel_id", webhookMeta.ChannelID).
Msg("Provided webhook is for wrong channel")
ce.Reply("That webhook is not for the right channel (expected %s, webhook is for %s)", portal.Key.ChannelID, webhookMeta.ChannelID)
return
}
log.Debug().Str("webhook_id", webhookMeta.ID).Msg("Setting portal relay webhook")
portal.RelayWebhookID = webhookMeta.ID
portal.RelayWebhookSecret = webhookMeta.Token
portal.Update()
ce.Reply("Saved webhook %s (%s) as portal relay webhook", webhookMeta.Name, portal.RelayWebhookID)
}
var cmdUnsetRelay = &commands.FullHandler{
Func: wrapCommand(fnUnsetRelay),
Name: "unset-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Disable the relay webhook and optionally delete it on Discord",
Args: "[--delete]",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
func fnUnsetRelay(ce *WrappedCommandEvent) {
if ce.Portal.RelayWebhookID == "" {
ce.Reply("This portal doesn't have a relay webhook")
return
}
if len(ce.Args) > 0 && ce.Args[0] == "--delete" {
err := relayClient.WebhookDeleteWithToken(ce.Portal.RelayWebhookID, ce.Portal.RelayWebhookSecret)
if err != nil {
ce.Reply("Failed to delete webhook: %v", err)
return
} else {
ce.Reply("Successfully deleted webhook")
}
} else {
ce.Reply("Relay webhook disabled")
}
ce.Portal.RelayWebhookID = ""
ce.Portal.RelayWebhookSecret = ""
ce.Portal.Update()
}
var cmdGuilds = &commands.FullHandler{ var cmdGuilds = &commands.FullHandler{
Func: wrapCommand(fnGuilds), Func: wrapCommand(fnGuilds),
Name: "guilds", Name: "guilds",
Aliases: []string{"servers", "guild", "server"}, Aliases: []string{"servers", "guild", "server"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionPortalManagement,
Description: "Guild bridging management", Description: "Guild bridging management",
Args: "<status/bridge/unbridge> [_guild ID_] [--entire]", Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
}, },
RequiresLogin: true, RequiresLogin: true,
} }
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [--entire]`" const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [...]`"
const fullGuildsHelp = smallGuildsHelp + ` const fullGuildsHelp = smallGuildsHelp + `
* **help** - View this help message. * **help** - View this help message.
* **status** - View the list of guilds and their bridging status. * **status** - View the list of guilds and their bridging status.
* **bridge <_guild ID_> [--entire]** - Enable bridging for a guild. The --entire flag auto-creates portals for all channels. * **bridge <_guild ID_> [--entire]** - Enable bridging for a guild. The --entire flag auto-creates portals for all channels.
* **bridging-mode <_guild ID_> <_mode_>** - Set the mode for bridging messages and new channels in a guild.
* **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.` * **unbridge <_guild ID_>** - Unbridge a guild and delete all channel portal rooms.`
func fnGuilds(ce *WrappedCommandEvent) { func fnGuilds(ce *WrappedCommandEvent) {
@@ -333,6 +576,8 @@ func fnGuilds(ce *WrappedCommandEvent) {
fnBridgeGuild(ce) fnBridgeGuild(ce)
case "unbridge", "delete": case "unbridge", "delete":
fnUnbridgeGuild(ce) fnUnbridgeGuild(ce)
case "bridging-mode", "mode":
fnGuildBridgingMode(ce)
case "help": case "help":
ce.Reply(fullGuildsHelp) ce.Reply(fullGuildsHelp)
default: default:
@@ -347,15 +592,11 @@ func fnListGuilds(ce *WrappedCommandEvent) {
if guild == nil { if guild == nil {
continue continue
} }
status := "not bridged"
if guild.MXID != "" {
status = "bridged"
}
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, guild.BridgingMode.Description()))
} }
if len(items) == 0 { if len(items) == 0 {
ce.Reply("No guilds found") ce.Reply("No guilds found")
@@ -384,11 +625,190 @@ func fnUnbridgeGuild(ce *WrappedCommandEvent) {
} }
} }
const availableModes = "Available modes:\n" +
"* `nothing` to never bridge any messages (default when unbridged)\n" +
"* `if-portal-exists` to bridge messages in existing portals, but drop messages in unbridged channels\n" +
"* `create-on-message` to bridge all messages and create portals if necessary on incoming messages (default after bridging)\n" +
"* `everything` to bridge all messages and create portals proactively on bridge startup (default if bridged with `--entire`)\n"
func fnGuildBridgingMode(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 || len(ce.Args) > 2 {
ce.Reply("**Usage**: `$cmdprefix guilds bridging-mode <guild ID> [mode]`\n\n" + availableModes)
return
}
guild := ce.Bridge.GetGuildByID(ce.Args[0], false)
if guild == nil {
ce.Reply("Guild not found")
return
}
if len(ce.Args) == 1 {
ce.Reply("%s (%s) is currently set to %s (`%s`)\n\n%s", guild.PlainName, guild.ID, guild.BridgingMode.Description(), guild.BridgingMode.String(), availableModes)
return
}
mode := database.ParseGuildBridgingMode(ce.Args[1])
if mode == database.GuildBridgeInvalid {
ce.Reply("Invalid guild bridging mode `%s`", ce.Args[1])
return
}
guild.BridgingMode = mode
guild.Update()
ce.Reply("Set guild bridging mode to %s", mode.Description())
}
var cmdBridge = &commands.FullHandler{
Func: wrapCommand(fnBridge),
Name: "bridge",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Bridge this room to a specific Discord channel",
Args: "[--replace[=delete]] <_channel ID_>",
},
RequiresEventLevel: roomModerator,
}
func isNumber(str string) bool {
for _, chr := range str {
if chr < '0' || chr > '9' {
return false
}
}
return true
}
func fnBridge(ce *WrappedCommandEvent) {
if ce.Portal != nil {
ce.Reply("This is already a portal room. Unbridge with `$cmdprefix unbridge` first if you want to link it to a different channel.")
return
}
var channelID string
var unbridgeOld, deleteOld bool
fail := true
for _, arg := range ce.Args {
arg = strings.ToLower(arg)
if arg == "--replace" {
unbridgeOld = true
} else if arg == "--replace=delete" {
unbridgeOld = true
deleteOld = true
} else if channelID == "" && isNumber(arg) {
channelID = arg
fail = false
} else {
fail = true
break
}
}
if fail {
ce.Reply("**Usage**: `$cmdprefix bridge [--replace[=delete]] <channel ID>`")
return
}
portal := ce.User.GetExistingPortalByID(channelID)
if portal == nil {
ce.Reply("Channel not found")
return
}
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
if portal.MXID != "" {
hasUnbridgePermission := ce.User.PermissionLevel >= bridgeconfig.PermissionLevelAdmin
if !hasUnbridgePermission {
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if errors.Is(err, mautrix.MNotFound) {
ce.ZLog.Debug().Err(err).Msg("Got M_NOT_FOUND trying to get power levels to check if user can unbridge it, assuming the room is gone")
hasUnbridgePermission = true
} else if err != nil {
ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
ce.Reply("Failed to get power levels in old room to see if you're allowed to unbridge it")
return
} else {
hasUnbridgePermission = levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(roomModerator)
}
}
if !unbridgeOld || !hasUnbridgePermission {
extraHelp := "Rerun the command with `--replace` or `--replace=delete` to unbridge the old room."
if !hasUnbridgePermission {
extraHelp = "Additionally, you do not have the permissions to unbridge the old room."
}
ce.Reply("That channel is already bridged to [%s](https://matrix.to/#/%s). %s", portal.Name, portal.MXID, extraHelp)
return
}
ce.ZLog.Debug().
Str("old_room_id", portal.MXID.String()).
Bool("delete", deleteOld).
Msg("Unbridging old room")
portal.removeFromSpace()
portal.cleanup(!deleteOld)
portal.RemoveMXID()
ce.ZLog.Info().
Str("old_room_id", portal.MXID.String()).
Bool("delete", deleteOld).
Msg("Unbridged old room to make space for new bridge")
}
if portal.Guild != nil && portal.Guild.BridgingMode < database.GuildBridgeIfPortalExists {
ce.ZLog.Debug().Str("guild_id", portal.Guild.ID).Msg("Bumping bridging mode of portal guild to if-portal-exists")
portal.Guild.BridgingMode = database.GuildBridgeIfPortalExists
portal.Guild.Update()
}
ce.ZLog.Debug().Str("channel_id", portal.Key.ChannelID).Msg("Bridging room")
portal.MXID = ce.RoomID
portal.bridge.portalsLock.Lock()
portal.bridge.portalsByMXID[portal.MXID] = portal
portal.bridge.portalsLock.Unlock()
portal.updateRoomName()
portal.updateRoomAvatar()
portal.updateRoomTopic()
portal.updateSpace()
portal.UpdateBridgeInfo()
state, err := portal.MainIntent().State(portal.MXID)
if err != nil {
ce.ZLog.Error().Err(err).Msg("Failed to update state cache for room")
} else {
encryptionEvent, isEncrypted := state[event.StateEncryption][""]
portal.Encrypted = isEncrypted && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1
}
portal.Update()
ce.Reply("Room successfully bridged")
ce.ZLog.Info().
Str("channel_id", portal.Key.ChannelID).
Bool("encrypted", portal.Encrypted).
Msg("Manual bridging complete")
}
var cmdUnbridge = &commands.FullHandler{
Func: wrapCommand(fnUnbridge),
Name: "unbridge",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Unbridge this room from the linked Discord channel",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
var cmdDeletePortal = &commands.FullHandler{
Func: wrapCommand(fnUnbridge),
Name: "delete-portal",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Unbridge this room and kick all Matrix users",
},
RequiresPortal: true,
RequiresEventLevel: roomModerator,
}
func fnUnbridge(ce *WrappedCommandEvent) {
ce.Portal.roomCreateLock.Lock()
defer ce.Portal.roomCreateLock.Unlock()
ce.Portal.removeFromSpace()
ce.Portal.cleanup(ce.Command == "unbridge")
ce.Portal.RemoveMXID()
}
var cmdDeleteAllPortals = &commands.FullHandler{ var cmdDeleteAllPortals = &commands.FullHandler{
Func: wrapCommand(fnDeleteAllPortals), Func: wrapCommand(fnDeleteAllPortals),
Name: "delete-all-portals", Name: "delete-all-portals",
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: commands.HelpSectionAdmin,
Description: "Delete all portals.", Description: "Delete all portals.",
}, },
RequiresAdmin: true, RequiresAdmin: true,
@@ -396,14 +816,15 @@ var cmdDeleteAllPortals = &commands.FullHandler{
func fnDeleteAllPortals(ce *WrappedCommandEvent) { func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortals() portals := ce.Bridge.GetAllPortals()
if len(portals) == 0 { guilds := ce.Bridge.GetAllGuilds()
if len(portals) == 0 && len(guilds) == 0 {
ce.Reply("Didn't find any portals") ce.Reply("Didn't find any portals")
return return
} }
leave := func(portal *Portal) { leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
if len(portal.MXID) > 0 { if len(mxid) > 0 {
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ _, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
Reason: "Deleting portal", Reason: "Deleting portal",
UserID: ce.User.MXID, UserID: ce.User.MXID,
}) })
@@ -412,19 +833,23 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) {
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil { if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent() intent := customPuppet.CustomIntent()
leave = func(portal *Portal) { leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
if len(portal.MXID) > 0 { if len(mxid) > 0 {
_, _ = intent.LeaveRoom(portal.MXID) _, _ = intent.LeaveRoom(mxid)
_, _ = intent.ForgetRoom(portal.MXID) _, _ = intent.ForgetRoom(mxid)
} }
} }
} }
ce.Reply("Found %d portals, deleting...", len(portals)) ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
for _, portal := range portals { for _, portal := range portals {
portal.Delete() portal.Delete()
leave(portal) leave(portal.MXID, portal.MainIntent())
} }
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") for _, guild := range guilds {
guild.Delete()
leave(guild.MXID, ce.Bot)
}
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. You'll have to restart the bridge or relogin before rooms can be bridged again.")
go func() { go func() {
for _, portal := range portals { for _, portal := range portals {

View File

@@ -28,12 +28,14 @@ import (
"maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/bridge/commands"
) )
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
var cmdCommands = &commands.FullHandler{ var cmdCommands = &commands.FullHandler{
Func: wrapCommand(fnCommands), Func: wrapCommand(fnCommands),
Name: "commands", Name: "commands",
Aliases: []string{"cmds", "cs"}, Aliases: []string{"cmds", "cs"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionDiscordBots,
Description: "View parameters of bot interaction commands on Discord", Description: "View parameters of bot interaction commands on Discord",
Args: "search <_query_> OR help <_command_>", Args: "search <_query_> OR help <_command_>",
}, },
@@ -46,7 +48,7 @@ var cmdExec = &commands.FullHandler{
Name: "exec", Name: "exec",
Aliases: []string{"command", "cmd", "c", "exec", "e"}, Aliases: []string{"command", "cmd", "c", "exec", "e"},
Help: commands.HelpMeta{ Help: commands.HelpMeta{
Section: commands.HelpSectionUnclassified, Section: HelpSectionDiscordBots,
Description: "Run bot interaction commands on Discord", Description: "Run bot interaction commands on Discord",
Args: "<_command_> [_arg=value ..._]", Args: "<_command_> [_arg=value ..._]",
}, },

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

@@ -25,6 +25,8 @@ import (
func DoUpgrade(helper *up.Helper) { func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper) bridgeconfig.Upgrader.DoUpgrade(helper)
helper.Copy(up.Str|up.Null, "homeserver", "public_address")
helper.Copy(up.Str, "bridge", "username_template") helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template") helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channel_name_template") helper.Copy(up.Str, "bridge", "channel_name_template")
@@ -45,6 +47,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

@@ -26,7 +26,7 @@ func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken strin
homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver] homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
if !found { if !found {
if homeserver == br.AS.HomeserverDomain { if homeserver == br.AS.HomeserverDomain {
homeserverURL = br.AS.HomeserverURL homeserverURL = ""
} else if br.Config.Bridge.DoublePuppetAllowDiscovery { } else if br.Config.Bridge.DoublePuppetAllowDiscovery {
resp, err := mautrix.DiscoverClientAPI(homeserver) resp, err := mautrix.DiscoverClientAPI(homeserver)
if err != nil { if err != nil {
@@ -40,16 +40,7 @@ func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken strin
} }
} }
client, err := mautrix.NewClient(homeserverURL, mxid, accessToken) return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL)
if err != nil {
return nil, err
}
client.Logger = br.AS.Log.Sub(mxid.String())
client.Client = br.AS.HTTPClient
client.DefaultHTTPRetries = br.AS.DefaultHTTPRetries
return client, nil
} }
func (puppet *Puppet) clearCustomMXID() { func (puppet *Puppet) clearCustomMXID() {
@@ -111,13 +102,17 @@ func (puppet *Puppet) tryRelogin(cause error, action string) bool {
if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) { if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
return false return false
} }
puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action) log := puppet.log.With().
AnErr("cause_error", cause).
Str("while_action", action).
Logger()
log.Debug().Msg("Trying to relogin")
accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID) accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
if err != nil { if err != nil {
puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err) log.Error().Err(err).Msg("Failed to relogin")
return false return false
} }
puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action) log.Info().Msg("Successfully relogined")
puppet.AccessToken = accessToken puppet.AccessToken = accessToken
puppet.Update() puppet.Update()
return true return true
@@ -125,7 +120,7 @@ func (puppet *Puppet) tryRelogin(cause error, action string) bool {
func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) { func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
_, homeserver, _ := mxid.Parse() _, homeserver, _ := mxid.Parse()
puppet.log.Debugfln("Logging into %s with shared secret", mxid) puppet.log.Debug().Str("user_id", mxid.String()).Msg("Logging into double puppet target with shared secret")
loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver] loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]
client, err := puppet.bridge.newDoublePuppetClient(mxid, "") client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
if err != nil { if err != nil {

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

@@ -3,6 +3,8 @@ package database
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -10,13 +12,76 @@ import (
"maunium.net/go/mautrix/util/dbutil" "maunium.net/go/mautrix/util/dbutil"
) )
type GuildBridgingMode int
const (
// GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists.
GuildBridgeNothing GuildBridgingMode = iota
// GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals.
GuildBridgeIfPortalExists
// GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received.
GuildBridgeCreateOnMessage
// GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications.
GuildBridgeEverything
GuildBridgeInvalid GuildBridgingMode = -1
)
func ParseGuildBridgingMode(str string) GuildBridgingMode {
str = strings.ToLower(str)
str = strings.ReplaceAll(str, "-", "")
str = strings.ReplaceAll(str, "_", "")
switch str {
case "nothing", "0":
return GuildBridgeNothing
case "ifportalexists", "1":
return GuildBridgeIfPortalExists
case "createonmessage", "2":
return GuildBridgeCreateOnMessage
case "everything", "3":
return GuildBridgeEverything
default:
return GuildBridgeInvalid
}
}
func (gbm GuildBridgingMode) String() string {
switch gbm {
case GuildBridgeNothing:
return "nothing"
case GuildBridgeIfPortalExists:
return "if-portal-exists"
case GuildBridgeCreateOnMessage:
return "create-on-message"
case GuildBridgeEverything:
return "everything"
default:
return ""
}
}
func (gbm GuildBridgingMode) Description() string {
switch gbm {
case GuildBridgeNothing:
return "never bridge messages"
case GuildBridgeIfPortalExists:
return "bridge messages in existing portals"
case GuildBridgeCreateOnMessage:
return "bridge all messages and create portals on first message"
case GuildBridgeEverything:
return "bridge all messages and create portals proactively"
default:
return ""
}
}
type GuildQuery struct { type GuildQuery struct {
db *Database db *Database
log log.Logger log log.Logger
} }
const ( const (
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, auto_bridge_channels FROM guild" guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
) )
func (gq *GuildQuery) New() *Guild { func (gq *GuildQuery) New() *Guild {
@@ -67,13 +132,13 @@ type Guild struct {
AvatarURL id.ContentURI AvatarURL id.ContentURI
AvatarSet bool AvatarSet bool
AutoBridgeChannels bool BridgingMode GuildBridgingMode
} }
func (g *Guild) Scan(row dbutil.Scannable) *Guild { func (g *Guild) Scan(row dbutil.Scannable) *Guild {
var mxid sql.NullString var mxid sql.NullString
var avatarURL string var avatarURL string
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.AutoBridgeChannels) err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
g.log.Errorln("Database scan failed:", err) g.log.Errorln("Database scan failed:", err)
@@ -82,6 +147,9 @@ func (g *Guild) Scan(row dbutil.Scannable) *Guild {
return nil return nil
} }
if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything {
panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID))
}
g.MXID = id.RoomID(mxid.String) g.MXID = id.RoomID(mxid.String)
g.AvatarURL, _ = id.ParseContentURI(avatarURL) g.AvatarURL, _ = id.ParseContentURI(avatarURL)
return g return g
@@ -96,10 +164,10 @@ func (g *Guild) mxidPtr() *id.RoomID {
func (g *Guild) Insert() { func (g *Guild) Insert() {
query := ` query := `
INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, auto_bridge_channels) INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
` `
_, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.AutoBridgeChannels) _, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode)
if err != nil { if err != nil {
g.log.Warnfln("Failed to insert %s: %v", g.ID, err) g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
panic(err) panic(err)
@@ -108,10 +176,10 @@ func (g *Guild) Insert() {
func (g *Guild) Update() { func (g *Guild) Update() {
query := ` query := `
UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, auto_bridge_channels=$8 UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8
WHERE dcid=$9 WHERE dcid=$9
` `
_, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.AutoBridgeChannels, g.ID) _, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID)
if err != nil { if err != nil {
g.log.Warnfln("Failed to update %s: %v", g.ID, err) g.log.Warnfln("Failed to update %s: %v", g.ID, err)
panic(err) panic(err)

View File

@@ -16,7 +16,7 @@ const (
portalSelect = ` portalSelect = `
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set, plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal FROM portal
` `
) )
@@ -121,16 +121,19 @@ type Portal struct {
InSpace id.RoomID InSpace id.RoomID
FirstEventID id.EventID FirstEventID id.EventID
RelayWebhookID string
RelayWebhookSecret string
} }
func (p *Portal) Scan(row dbutil.Scannable) *Portal { func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var otherUserID, guildID, parentID, mxid, firstEventID sql.NullString var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
var chanType int32 var chanType int32
var avatarURL string var avatarURL string
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID, err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet, &mxid, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID) &p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil { if err != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
@@ -148,6 +151,8 @@ func (p *Portal) Scan(row dbutil.Scannable) *Portal {
p.Type = discordgo.ChannelType(chanType) p.Type = discordgo.ChannelType(chanType)
p.FirstEventID = id.EventID(firstEventID.String) p.FirstEventID = id.EventID(firstEventID.String)
p.AvatarURL, _ = id.ParseContentURI(avatarURL) p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.RelayWebhookID = relayWebhookID.String
p.RelayWebhookSecret = relayWebhookSecret.String
return p return p
} }
@@ -156,13 +161,13 @@ func (p *Portal) Insert() {
query := ` query := `
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid, INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set, plain_name, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id) encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
` `
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type, _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String()) p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
if err != nil { if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.Key, err) p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
@@ -175,13 +180,13 @@ func (p *Portal) Update() {
UPDATE portal UPDATE portal
SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5, SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
plain_name=$6, name=$7, name_set=$8, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13, plain_name=$6, name=$7, name_set=$8, topic=$9, topic_set=$10, avatar=$11, avatar_url=$12, avatar_set=$13,
encrypted=$14, in_space=$15, first_event_id=$16 encrypted=$14, in_space=$15, first_event_id=$16, relay_webhook_id=$17, relay_webhook_secret=$18
WHERE dcid=$17 AND receiver=$18 WHERE dcid=$19 AND receiver=$20
` `
_, err := p.db.Exec(query, _, err := p.db.Exec(query,
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)), p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
p.Key.ChannelID, p.Key.Receiver) p.Key.ChannelID, p.Key.Receiver)
if err != nil { if err != nil {

View File

@@ -1,4 +1,4 @@
-- v0 -> v12: Latest revision -- v0 -> v15: Latest revision
CREATE TABLE guild ( CREATE TABLE guild (
dcid TEXT PRIMARY KEY, dcid TEXT PRIMARY KEY,
@@ -10,7 +10,7 @@ CREATE TABLE guild (
avatar_url TEXT NOT NULL, avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL, avatar_set BOOLEAN NOT NULL,
auto_bridge_channels BOOLEAN NOT NULL bridging_mode INTEGER NOT NULL
); );
CREATE TABLE portal ( CREATE TABLE portal (
@@ -39,6 +39,9 @@ CREATE TABLE portal (
first_event_id TEXT NOT NULL, first_event_id TEXT NOT NULL,
relay_webhook_id TEXT,
relay_webhook_secret TEXT,
PRIMARY KEY (dcid, receiver), PRIMARY KEY (dcid, receiver),
CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE, CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE,
CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
@@ -126,12 +129,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,18 +151,17 @@ 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,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
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

@@ -0,0 +1,7 @@
-- v14: Add more modes of bridging guilds
ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0;
UPDATE guild SET bridging_mode=2 WHERE mxid<>'';
UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true;
ALTER TABLE guild DROP COLUMN auto_bridge_channels;
-- only: postgres
ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT;

View File

@@ -0,0 +1,3 @@
-- v15: Store relay webhook URL for portals
ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT;
ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT;

View File

@@ -18,30 +18,35 @@ func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
return false return false
} }
log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger()
member, err := user.Session.State.Member(channel.GuildID, user.DiscordID) member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
if errors.Is(err, discordgo.ErrStateNotFound) { if errors.Is(err, discordgo.ErrStateNotFound) {
user.log.Debugfln("Fetching own membership in %s to check own roles", channel.GuildID) log.Debug().Msg("Fetching own membership in guild to check roles")
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID) member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
if err != nil { if err != nil {
user.log.Warnfln("Failed to get own membership in %s from server to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err) log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else { } else {
err = user.Session.State.MemberAdd(member) err = user.Session.State.MemberAdd(member)
if err != nil { if err != nil {
user.log.Warnfln("Failed to add own membership in %s to cache: %v", channel.GuildID, err) log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
} }
} }
} else if err != nil { } else if err != nil {
user.log.Warnfln("Failed to get own membership in %s from cache to determine own roles for bridging %s: %v", channel.GuildID, channel.ID, err) log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
} }
err = user.Session.State.ChannelAdd(channel) err = user.Session.State.ChannelAdd(channel)
if err != nil { if err != nil {
user.log.Warnfln("Failed to add channel %s/%s to cache: %v", channel.GuildID, channel.ID, err) log.Warn().Err(err).Msg("Failed to add channel to cache")
} }
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID) perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
if err != nil { if err != nil {
user.log.Warnfln("Failed to get permissions in %s/%s to determine if it's bridgeable: %v", channel.GuildID, channel.ID, err) log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true return true
} }
user.log.Debugfln("Computed permissions in %s/%s: %d (view channel: %t)", channel.GuildID, channel.ID, perms, perms&discordgo.PermissionViewChannel > 0) log.Debug().
Int64("permissions", perms).
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
Msg("Computed permissions in channel")
return perms&discordgo.PermissionViewChannel > 0 return perms&discordgo.PermissionViewChannel > 0
} }

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

@@ -2,6 +2,9 @@
homeserver: homeserver:
# The address that this appservice can use to connect to the homeserver. # The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com address: https://matrix.example.com
# Publicly accessible base URL for media, used for avatars in relay mode.
# If not set, the connection address above will be used.
public_address: null
# The domain of the homeserver (also known as server_name, used for MXIDs, etc). # The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com domain: example.com
@@ -140,6 +143,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
@@ -240,12 +257,15 @@ bridge:
"example.com": user "example.com": user
"@admin:example.com": admin "@admin:example.com": admin
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging: logging:
directory: ./logs min_level: debug
file_name_format: '{{.Date}}-{{.Index}}.log' writers:
file_date_format: "2006-01-02" - type: stdout
file_mode: 384 format: pretty-colored
timestamp_format: Jan _2, 2006 15:04:05 - type: file
print_level: debug format: json
print_json: false filename: ./logs/mautrix-discord.log
file_json: false max_size: 100
max_backups: 10
compress: true

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
@@ -21,8 +21,11 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/bwmarrin/discordgo"
"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,47 +34,88 @@ 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).
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), type indentableParagraphParser struct {
)), parser.BlockParser
format.Extensions, format.HTMLOptions, discordExtensions, }
goldmark.WithExtensions(&DiscordTag{portal}),
) 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 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))
} }
return format.UnwrapSingleParagraph(buf.String()) return format.UnwrapSingleParagraph(buf.String())
} }
const formatterContextUserKey = "fi.mau.discord.user"
const formatterContextPortalKey = "fi.mau.discord.portal" const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
func pillConverter(displayname, mxid, eventID string, ctx format.Context) string { func appendIfNotContains(arr []string, newItem string) []string {
for _, item := range arr {
if item == newItem {
return arr
}
}
return append(arr, newItem)
}
func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
if len(mxid) == 0 { if len(mxid) == 0 {
return displayname return displayname
} }
user := ctx.ReturnData[formatterContextUserKey].(*User)
if mxid[0] == '#' { if mxid[0] == '#' {
alias, err := user.bridge.Bot.ResolveAlias(id.RoomAlias(mxid)) alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
if err != nil { if err != nil {
return displayname return displayname
} }
mxid = alias.RoomID.String() mxid = alias.RoomID.String()
} }
if mxid[0] == '!' { if mxid[0] == '!' {
portal := user.bridge.GetPortalByMXID(id.RoomID(mxid)) portal := br.GetPortalByMXID(id.RoomID(mxid))
if portal != nil { if portal != nil {
if eventID == "" { if eventID == "" {
//currentPortal := ctx[formatterContextPortalKey].(*Portal) //currentPortal := ctx[formatterContextPortalKey].(*Portal)
@@ -82,7 +126,7 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
//} else { //} else {
// // TODO is mentioning private channels possible at all? // // TODO is mentioning private channels possible at all?
//} //}
} else if msg := user.bridge.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil { } else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
guildID := portal.GuildID guildID := portal.GuildID
if guildID == "" { if guildID == "" {
guildID = "@me" guildID = "@me"
@@ -91,12 +135,15 @@ func pillConverter(displayname, mxid, eventID string, ctx format.Context) string
} }
} }
} else if mxid[0] == '@' { } else if mxid[0] == '@' {
parsedID, ok := user.bridge.ParsePuppetMXID(id.UserID(mxid)) mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
if ok { if ok {
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
return fmt.Sprintf("<@%s>", parsedID) return fmt.Sprintf("<@%s>", parsedID)
} }
mentionedUser := user.bridge.GetUserByMXID(id.UserID(mxid)) mentionedUser := br.GetUserByMXID(id.UserID(mxid))
if mentionedUser != nil && mentionedUser.DiscordID != "" { if mentionedUser != nil && mentionedUser.DiscordID != "" {
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID) return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
} }
} }
@@ -162,17 +209,18 @@ var matrixHTMLParser = &format.HTMLParser{
}, },
} }
func init() { func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
matrixHTMLParser.PillConverter = pillConverter allowedMentions := &discordgo.MessageAllowedMentions{
} Parse: []discordgo.AllowedMentionType{},
Users: []string{},
func (portal *Portal) parseMatrixHTML(user *User, content *event.MessageEventContent) string { RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext() ctx := format.NewContext()
ctx.ReturnData[formatterContextUserKey] = user
ctx.ReturnData[formatterContextPortalKey] = portal ctx.ReturnData[formatterContextPortalKey] = portal
return variationselector.Remove(matrixHTMLParser.Parse(content.FormattedBody, ctx)) ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else { } else {
return variationselector.Remove(escapeDiscordMarkdown(content.Body)) return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
} }
} }

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

@@ -30,14 +30,14 @@ import (
"github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"maunium.net/go/mautrix"
"go.mau.fi/mautrix-discord/database" "go.mau.fi/mautrix-discord/database"
) )
type astDiscordTag struct { type astDiscordTag struct {
ast.BaseInline ast.BaseInline
id int64 portal *Portal
id int64
} }
var _ ast.Node = (*astDiscordTag)(nil) var _ ast.Node = (*astDiscordTag)(nil)
@@ -143,7 +143,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 +160,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 +202,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 +262,20 @@ 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)) if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Name) _, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%[1]s">%[1]s</a>`, user.MXID)
} else if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
_, _ = 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 +288,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
@@ -301,7 +307,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format()) fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted) _, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted)
} }
stringifiable, ok := n.(mautrix.Stringifiable) stringifiable, ok := n.(fmt.Stringer)
if ok { if ok {
_, _ = w.WriteString(stringifiable.String()) _, _ = w.WriteString(stringifiable.String())
} else { } else {
@@ -310,15 +316,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),
)) ))
} }

27
go.mod
View File

@@ -1,37 +1,40 @@
module go.mau.fi/mautrix-discord module go.mau.fi/mautrix-discord
go 1.18 go 1.19
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.2
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
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.7 github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.29.0
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.2
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.4.1
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 maunium.net/go/mautrix v0.15.0
) )
require ( require (
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
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/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 go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/net v0.5.0 // indirect golang.org/x/crypto v0.6.0 // indirect
golang.org/x/sys v0.4.0 // indirect golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.6.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // 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-20230301201402-cf4c62e5f53d

50
go.sum
View File

@@ -1,12 +1,13 @@
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-20230301201402-cf4c62e5f53d h1:xo6A9gSSu7mnxIXHBD1EPDyKEQFlI0N8r57Yf0gWiy8=
github.com/beeper/discordgo v0.0.0-20230129125832-37978ff8e399/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/beeper/discordgo v0.0.0-20230301201402-cf4c62e5f53d/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 h1:rtAn27wIbmOGUs7RIbVgPEjb31ehTVniDwPGXyMxm5U=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -27,8 +28,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=
@@ -36,8 +37,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -47,35 +48,34 @@ 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=
go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
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.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
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-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.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= 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.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52 h1:7KoJL/7eozYlu4GW2jADHO+Qhm8WL45Afcm7A45BivM= maunium.net/go/mautrix v0.15.0 h1:gkK9HXc1SSPwY7qOAqchzj2xxYqiOYeee8lr28A2g/o=
maunium.net/go/mautrix v0.13.1-0.20230129131014-888cfabd8a52/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM= maunium.net/go/mautrix v0.15.0/go.mod h1:1v8QVDd7q/eJ+eg4sgeOSEafBAFhkt4ab2i97M3IkNQ=

View File

@@ -226,6 +226,7 @@ func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild { func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
if meta.Unavailable { if meta.Unavailable {
guild.log.Debugfln("Ignoring unavailable guild update")
return meta return meta
} }
changed := false changed := false
@@ -312,6 +313,17 @@ func (guild *Guild) RemoveMXID() {
guild.MXID = "" guild.MXID = ""
guild.AvatarSet = false guild.AvatarSet = false
guild.NameSet = false guild.NameSet = false
guild.AutoBridgeChannels = false guild.BridgingMode = database.GuildBridgeNothing
guild.Update() guild.Update()
} }
func (guild *Guild) Delete() {
guild.Guild.Delete()
guild.bridge.guildsLock.Lock()
delete(guild.bridge.guildsByID, guild.ID)
if guild.MXID != "" {
delete(guild.bridge.guildsByMXID, guild.MXID)
}
guild.bridge.guildsLock.Unlock()
}

26
main.go
View File

@@ -18,11 +18,17 @@ package main
import ( import (
_ "embed" _ "embed"
"fmt"
"runtime"
"strings"
"sync" "sync"
"github.com/rs/zerolog"
"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 +77,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 {
@@ -89,8 +97,20 @@ func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge) br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands() br.RegisterCommands()
matrixHTMLParser.PillConverter = br.pillConverter
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
discordLog = br.Log.Sub("Discord") discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
// TODO move this to mautrix-go?
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
files := strings.Split(file, "/")
file = files[len(files)-1]
name := runtime.FuncForPC(pc).Name()
fns := strings.Split(name, ".")
name = fns[len(fns)-1]
return fmt.Sprintf("%s:%d:%s()", file, line, name)
}
} }
func (br *DiscordBridge) Start() { func (br *DiscordBridge) Start() {
@@ -163,12 +183,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.2.0",
ProtocolName: "Discord", ProtocolName: "Discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp", CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",

817
portal.go

File diff suppressed because it is too large Load Diff

529
portal_convert.go Normal file
View File

@@ -0,0 +1,529 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// 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
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
)
type ConvertedMessage struct {
AttachmentID string
Type event.Type
Content *event.MessageEventContent
Extra map[string]any
}
func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
return &event.MessageEventContent{
Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
MsgType: event.MsgNotice,
}
}
const DiscordStickerSize = 160
func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
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 {
portal.log.Errorfln("Error copying attachment %s to Matrix: %v", id, err)
return portal.createMediaFailedMessage(err)
}
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,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
if typeName == "sticker" && (content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize) {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
} else if content.Info.Width < content.Info.Height {
content.Info.Width /= content.Info.Height / DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
}
return content
}
func (portal *Portal) convertDiscordSticker(intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
var mime string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
default:
portal.log.Warnfln("Unknown sticker format %d in %s", sticker.FormatType, sticker.ID)
}
return &ConvertedMessage{
AttachmentID: sticker.ID,
Type: event.EventSticker,
Content: portal.convertDiscordFile("sticker", intent, sticker.ID, sticker.URL(), &event.MessageEventContent{
Body: sticker.Name, // TODO find description from somewhere?
Info: &event.FileInfo{
MimeType: mime,
},
}),
}
}
func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
content := &event.MessageEventContent{
Body: att.Filename,
Info: &event.FileInfo{
Height: att.Height,
MimeType: att.ContentType,
Width: att.Width,
// This gets overwritten later after the file is uploaded to the homeserver
Size: att.Size,
},
}
if att.Description != "" {
content.Body = att.Description
content.FileName = att.Filename
}
switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
default:
content.MsgType = event.MsgFile
}
content = portal.convertDiscordFile("attachment", intent, att.ID, att.URL, content)
return &ConvertedMessage{
AttachmentID: att.ID,
Type: event.EventMessage,
Content: content,
}
}
func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
attachmentID := fmt.Sprintf("video_%s", embed.URL)
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta)
if err != nil {
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: portal.createMediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
MsgType: event.MsgVideo,
Body: embed.URL,
Info: &event.FileInfo{
Width: embed.Video.Width,
Height: embed.Video.Height,
MimeType: dbFile.MimeType,
Size: dbFile.Size,
},
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = dbFile.Width
content.Info.Height = dbFile.Height
}
if dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
extra := map[string]any{}
if embed.Type == discordgo.EmbedTypeGifv {
extra["info"] = map[string]any{
"fi.mau.discord.gifv": true,
"fi.mau.loop": true,
"fi.mau.autoplay": true,
"fi.mau.hide_controls": true,
"fi.mau.no_audio": true,
}
}
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
predictedLength := len(msg.Attachments) + len(msg.StickerItems)
if msg.Content != "" {
predictedLength++
}
parts := make([]*ConvertedMessage, 0, predictedLength)
if textPart := portal.convertDiscordTextMessage(intent, msg); textPart != nil {
parts = append(parts, textPart)
}
handledIDs := make(map[string]struct{})
for _, att := range msg.Attachments {
if _, handled := handledIDs[att.ID]; handled {
continue
}
handledIDs[att.ID] = struct{}{}
if part := portal.convertDiscordAttachment(intent, att); part != nil {
parts = append(parts, part)
}
}
for _, sticker := range msg.StickerItems {
if _, handled := handledIDs[sticker.ID]; handled {
continue
}
handledIDs[sticker.ID] = struct{}{}
if part := portal.convertDiscordSticker(intent, sticker); part != nil {
parts = append(parts, part)
}
}
for _, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if getEmbedType(embed) != EmbedVideo {
continue
}
// Discord deduplicates embeds by URL. It makes things easier for us too.
if _, handled := handledIDs[embed.URL]; handled {
continue
}
handledIDs[embed.URL] = struct{}{}
part := portal.convertDiscordVideoEmbed(intent, embed)
if part != nil {
parts = append(parts, part)
}
}
return parts
}
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 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>`
embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
embedHTMLFieldName = `<th>%s</th>`
embedHTMLFieldValue = `<td>%s</td>`
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 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>`
embedFooterDateSeparator = ``
)
func (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
var htmlParts []string
if embed.Author != nil {
var authorHTML string
authorNameHTML := html.EscapeString(embed.Author.Name)
if embed.Author.URL != "" {
authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
}
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
if embed.Author.ProxyIconURL != "" {
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 {
authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
}
}
htmlParts = append(htmlParts, authorHTML)
}
if embed.Title != "" {
var titleHTML string
baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else {
titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
}
htmlParts = append(htmlParts, titleHTML)
}
if 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]
if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
splitItems := []*discordgo.MessageEmbedField{item}
if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
splitItems = append(splitItems, embed.Fields[i+1])
i++
}
}
headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems {
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, false),
portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
))
}
}
if embed.Image != nil {
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 {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
}
}
var embedDateHTML string
if embed.Timestamp != "" {
formattedTime := embed.Timestamp
parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
if err != nil {
portal.log.Warnfln("Failed to parse timestamp in embed #%d of message %s: %v", index+1, msgID, err)
} else {
formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
}
embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
}
if embed.Footer != nil {
var footerHTML string
var datePart string
if embedDateHTML != "" {
datePart = embedFooterDateSeparator + embedDateHTML
}
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
if embed.Footer.ProxyIconURL != "" {
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 {
footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
}
}
htmlParts = append(htmlParts, footerHTML)
} else if embed.Timestamp != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
}
if len(htmlParts) == 0 {
return ""
}
compiledHTML := strings.Join(htmlParts, "")
if embed.Color != 0 {
compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
} else {
compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
}
return compiledHTML
}
type BeeperLinkPreview struct {
mautrix.RespPreviewURL
MatchedURL string `json:"matched_url"`
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
}
func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
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 {
if width != 0 || height != 0 {
preview.ImageWidth = width
preview.ImageHeight = height
} else {
preview.ImageWidth = dbFile.Width
preview.ImageHeight = dbFile.Height
}
preview.ImageSize = dbFile.Size
preview.ImageType = dbFile.MimeType
if dbFile.Encrypted {
preview.ImageEncryption = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
preview.ImageURL = dbFile.MXC.CUString()
}
}
}
func (portal *Portal) convertDiscordLinkEmbedToBeeper(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
var preview BeeperLinkPreview
preview.MatchedURL = embed.URL
preview.Title = embed.Title
preview.Description = embed.Description
if embed.Image != nil {
portal.convertDiscordLinkEmbedImage(intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
portal.convertDiscordLinkEmbedImage(intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
}
return &preview
}
const msgInteractionTemplateHTML = `<blockquote>
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
</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 isPlainGifMessage(msg *discordgo.Message) bool {
return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv
}
func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
if msg.Type == discordgo.MessageTypeCall {
return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "started a call",
}}
} else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
return &ConvertedMessage{Type: event.EventMessage, 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)
puppet.UpdateInfo(nil, msg.Interaction.User)
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, false))
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
switch getEmbedType(embed) {
case EmbedRich:
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i))
case EmbedLinkPreview:
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed))
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)
}
}
if len(msg.Components) > 0 {
htmlParts = append(htmlParts, msgComponentTemplateHTML)
}
if len(htmlParts) == 0 {
return nil
}
fullHTML := strings.Join(htmlParts, "\n")
if !msg.MentionEveryone {
fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
}
content := format.HTMLToContent(fullHTML)
extraContent := map[string]any{
"com.beeper.linkpreviews": previews,
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

View File

@@ -18,6 +18,7 @@ import (
"maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth" "go.mau.fi/mautrix-discord/remoteauth"
) )
@@ -226,7 +227,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
} else { } else {
msg = "User wasn't logged in." msg = "User wasn't logged in."
} }
user.Logout() user.Logout(false)
jsonResponse(w, http.StatusOK, Response{true, msg}) jsonResponse(w, http.StatusOK, Response{true, msg})
} }
@@ -327,8 +328,6 @@ func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
} }
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID) log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
user.DiscordID = discordUser.UserID
user.Update()
if err = user.Login(discordUser.Token); err != nil { if err = user.Login(discordUser.Token); err != nil {
log.Errorln("Failed to connect after logging in:", err) log.Errorln("Failed to connect after logging in:", err)
@@ -429,11 +428,12 @@ func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
} }
type guildEntry struct { type guildEntry struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL id.ContentURI `json:"avatar_url"` AvatarURL id.ContentURI `json:"avatar_url"`
MXID id.RoomID `json:"mxid"` MXID id.RoomID `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"` AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
} }
type respGuildsList struct { type respGuildsList struct {
@@ -451,11 +451,12 @@ func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
continue continue
} }
resp.Guilds = append(resp.Guilds, guildEntry{ resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID, ID: guild.ID,
Name: guild.PlainName, Name: guild.PlainName,
AvatarURL: guild.AvatarURL, AvatarURL: guild.AvatarURL,
MXID: guild.MXID, MXID: guild.MXID,
AutoBridge: guild.AutoBridgeChannels, AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
BridgingMode: guild.BridgingMode.String(),
}) })
} }
@@ -526,7 +527,7 @@ func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request)
Error: "Guild not found", Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode, ErrCode: mautrix.MNotFound.ErrCode,
}) })
} else if !guild.AutoBridgeChannels && guild.MXID == "" { } else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
jsonResponse(w, http.StatusNotFound, Error{ jsonResponse(w, http.StatusNotFound, Error{
Error: "That guild is not bridged", Error: "That guild is not bridged",
ErrCode: ErrCodeGuildNotBridged, ErrCode: ErrCodeGuildNotBridged,

View File

@@ -5,9 +5,8 @@ import (
"regexp" "regexp"
"sync" "sync"
log "maunium.net/go/maulogger/v2"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge"
@@ -20,7 +19,7 @@ type Puppet struct {
*database.Puppet *database.Puppet
bridge *DiscordBridge bridge *DiscordBridge
log log.Logger log zerolog.Logger
MXID id.UserID MXID id.UserID
@@ -43,7 +42,7 @@ func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{ return &Puppet{
Puppet: dbPuppet, Puppet: dbPuppet,
bridge: br, bridge: br,
log: br.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)), log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(),
MXID: br.FormatPuppetMXID(dbPuppet.ID), MXID: br.FormatPuppetMXID(dbPuppet.ID),
} }
@@ -202,7 +201,7 @@ func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
puppet.NameSet = false puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(newName) err := puppet.DefaultIntent().SetDisplayName(newName)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to update displayname:", err) puppet.log.Warn().Err(err).Msg("Failed to update displayname")
} else { } else {
go puppet.updatePortalMeta(func(portal *Portal) { go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateNameDirect(puppet.Name) { if portal.UpdateNameDirect(puppet.Name) {
@@ -228,7 +227,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) { if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
url, err := uploadAvatar(puppet.DefaultIntent(), info.AvatarURL("")) url, err := uploadAvatar(puppet.DefaultIntent(), info.AvatarURL(""))
if err != nil { if err != nil {
puppet.log.Warnfln("Failed to reupload user avatar %s: %v", puppet.Avatar, err) puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
return true return true
} }
puppet.AvatarURL = url puppet.AvatarURL = url
@@ -236,7 +235,7 @@ func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to update avatar:", err) puppet.log.Warn().Err(err).Msg("Failed to update avatar")
} else { } else {
go puppet.updatePortalMeta(func(portal *Portal) { go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateAvatarFromPuppet(puppet) { if portal.UpdateAvatarFromPuppet(puppet) {
@@ -258,17 +257,17 @@ func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User) {
return return
} }
var err error var err error
puppet.log.Debugfln("Fetching info through %s to update", source.DiscordID) puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet")
info, err = source.Session.User(puppet.ID) info, err = source.Session.User(puppet.ID)
if err != nil { if err != nil {
puppet.log.Errorfln("Failed to fetch info through %s: %v", source.DiscordID, err) puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user")
return return
} }
} }
err := puppet.DefaultIntent().EnsureRegistered() err := puppet.DefaultIntent().EnsureRegistered()
if err != nil { if err != nil {
puppet.log.Errorln("Failed to ensure registered:", err) puppet.log.Error().Err(err).Msg("Failed to ensure registered")
} }
changed := false changed := false

View File

@@ -78,10 +78,16 @@ func (thread *Thread) Join(user *User) {
if user.IsInPortal(thread.ID) { if user.IsInPortal(thread.ID) {
return return
} }
user.log.Debugfln("Joining thread %s@%s", thread.ID, thread.ParentID) log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
err := user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu) log.Debug().Msg("Joining thread")
var err error
if user.Session.IsUser {
err = user.Session.ThreadJoinWithLocation(thread.ID, discordgo.ThreadJoinLocationContextMenu)
} else {
err = user.Session.ThreadJoin(thread.ID)
}
if err != nil { if err != nil {
user.log.Errorfln("Error joining thread %s@%s: %v", thread.ID, thread.ParentID, err) log.Error().Err(err).Msg("Error joining thread")
} else { } else {
user.MarkInPortal(database.UserPortal{ user.MarkInPortal(database.UserPortal{
DiscordID: thread.ID, DiscordID: thread.ID,

407
user.go
View File

@@ -6,17 +6,15 @@ import (
"math/rand" "math/rand"
"net/http" "net/http"
"os" "os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/gorilla/websocket"
log "maunium.net/go/maulogger/v2"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
@@ -41,7 +39,7 @@ type User struct {
sync.Mutex sync.Mutex
bridge *DiscordBridge bridge *DiscordBridge
log log.Logger log zerolog.Logger
PermissionLevel bridgeconfig.PermissionLevel PermissionLevel bridgeconfig.PermissionLevel
@@ -76,34 +74,22 @@ func (user *User) GetRemoteName() string {
return user.DiscordID return user.DiscordID
} }
var discordLog log.Logger var discordLog zerolog.Logger
func init() { func init() {
discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) { discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) {
pc, file, line, _ := runtime.Caller(caller + 1) var level zerolog.Level
files := strings.Split(file, "/")
file = files[len(files)-1]
name := runtime.FuncForPC(pc).Name()
fns := strings.Split(name, ".")
name = fns[len(fns)-1]
msg := fmt.Sprintf(format, a...)
var level log.Level
switch msgL { switch msgL {
case discordgo.LogError: case discordgo.LogError:
level = log.LevelError level = zerolog.ErrorLevel
case discordgo.LogWarning: case discordgo.LogWarning:
level = log.LevelWarn level = zerolog.WarnLevel
case discordgo.LogInformational: case discordgo.LogInformational:
level = log.LevelInfo level = zerolog.InfoLevel
case discordgo.LogDebug: case discordgo.LogDebug:
level = log.LevelDebug level = zerolog.DebugLevel
} }
discordLog.WithLevel(level).Caller(caller+1).Msgf(strings.TrimSpace(format), a...)
discordLog.Logfln(level, "%s:%d:%s() %s", file, line, name, msg)
} }
} }
@@ -196,7 +182,7 @@ func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
user := &User{ user := &User{
User: dbUser, User: dbUser,
bridge: br, bridge: br,
log: br.Log.Sub("User").Sub(string(dbUser.MXID)), log: br.ZLog.With().Str("user_id", string(dbUser.MXID)).Logger(),
markedOpened: make(map[string]time.Time), markedOpened: make(map[string]time.Time),
PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID), PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID),
@@ -204,7 +190,7 @@ func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
pendingInteractions: make(map[string]*WrappedCommandEvent), pendingInteractions: make(map[string]*WrappedCommandEvent),
} }
user.nextDiscordUploadID.Store(rand.Int31n(100)) user.nextDiscordUploadID.Store(rand.Int31n(100))
user.BridgeState = br.NewBridgeStateQueue(user, user.log) user.BridgeState = br.NewBridgeStateQueue(user)
return user return user
} }
@@ -226,7 +212,7 @@ func (br *DiscordBridge) getAllUsersWithToken() []*User {
} }
func (br *DiscordBridge) startUsers() { func (br *DiscordBridge) startUsers() {
br.Log.Debugln("Starting users") br.ZLog.Debug().Msg("Starting users")
usersWithToken := br.getAllUsersWithToken() usersWithToken := br.getAllUsersWithToken()
for _, u := range usersWithToken { for _, u := range usersWithToken {
@@ -236,13 +222,13 @@ func (br *DiscordBridge) startUsers() {
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
} }
br.Log.Debugln("Starting custom puppets") br.ZLog.Debug().Msg("Starting custom puppets")
for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() { for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() {
go func(puppet *Puppet) { go func(puppet *Puppet) {
br.Log.Debugln("Starting custom puppet", puppet.CustomMXID) br.ZLog.Debug().Str("user_id", puppet.CustomMXID.String()).Msg("Starting custom puppet")
if err := puppet.StartCustomMXID(true); err != nil { if err := puppet.StartCustomMXID(true); err != nil {
puppet.log.Errorln("Failed to start custom puppet:", err) puppet.log.Error().Err(err).Msg("Failed to start custom puppet")
} }
}(customPuppet) }(customPuppet)
} }
@@ -252,14 +238,14 @@ func (user *User) startupTryConnect(retryCount int) {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
err := user.Connect() err := user.Connect()
if err != nil { if err != nil {
user.log.Errorfln("Error connecting: %v", err) user.log.Error().Err(err).Msg("Error connecting on startup")
closeErr := &websocket.CloseError{} closeErr := &websocket.CloseError{}
if errors.As(err, &closeErr) && closeErr.Code == 4004 { if errors.As(err, &closeErr) && closeErr.Code == 4004 {
user.invalidAuthHandler(nil, nil) user.invalidAuthHandler(nil, nil)
} else if retryCount < 6 { } else if retryCount < 6 {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-unknown-websocket-error", Message: err.Error()}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-unknown-websocket-error", Message: err.Error()})
retryInSeconds := 2 << retryCount retryInSeconds := 2 << retryCount
user.log.Debugfln("Retrying connection in %d seconds", retryInSeconds) user.log.Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
time.Sleep(time.Duration(retryInSeconds) * time.Second) time.Sleep(time.Duration(retryInSeconds) * time.Second)
user.startupTryConnect(retryCount + 1) user.startupTryConnect(retryCount + 1)
} else { } else {
@@ -333,7 +319,7 @@ func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.Roo
}) })
if err != nil { if err != nil {
user.log.Errorln("Failed to auto-create space room:", err) user.log.Error().Err(err).Msg("Failed to auto-create space room")
} else { } else {
*ptr = resp.RoomID *ptr = resp.RoomID
user.Update() user.Update()
@@ -345,7 +331,10 @@ func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.Roo
Order: " 0000", Order: " 0000",
}) })
if err != nil { if err != nil {
user.log.Errorfln("Failed to add space room %s to parent space %s: %v", resp.RoomID, parent, err) user.log.Error().Err(err).
Str("created_space_id", resp.RoomID.String()).
Str("parent_space_id", parent.String()).
Msg("Failed to add created space room to parent space")
} }
} }
} }
@@ -368,34 +357,31 @@ func (user *User) tryAutomaticDoublePuppeting() {
return return
} }
user.log.Debugln("Checking if double puppeting needs to be enabled") user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetByID(user.DiscordID) puppet := user.bridge.GetPuppetByID(user.DiscordID)
if puppet.CustomMXID != "" { if puppet.CustomMXID != "" {
user.log.Debugln("User already has double-puppeting enabled") user.log.Debug().Msg("User already has double-puppeting enabled")
return return
} }
accessToken, err := puppet.loginWithSharedSecret(user.MXID) accessToken, err := puppet.loginWithSharedSecret(user.MXID)
if err != nil { if err != nil {
user.log.Warnln("Failed to login with shared secret:", err) user.log.Warn().Err(err).Msg("Failed to login with shared secret")
return return
} }
err = puppet.SwitchCustomMXID(accessToken, user.MXID) err = puppet.SwitchCustomMXID(accessToken, user.MXID)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err) puppet.log.Warn().Err(err).Msg("Failed to switch to auto-logined custom puppet")
return return
} }
user.log.Infoln("Successfully automatically enabled custom puppet") user.log.Info().Msg("Successfully automatically enabled custom puppet")
} }
func (user *User) ViewingChannel(portal *Portal) bool { func (user *User) ViewingChannel(portal *Portal) bool {
if portal.GuildID != "" { if portal.GuildID != "" || !user.Session.IsUser {
return false return false
} }
user.markedOpenedLock.Lock() user.markedOpenedLock.Lock()
@@ -406,7 +392,9 @@ func (user *User) ViewingChannel(portal *Portal) bool {
user.markedOpened[portal.Key.ChannelID] = time.Now() user.markedOpened[portal.Key.ChannelID] = time.Now()
err := user.Session.MarkViewing(portal.Key.ChannelID) err := user.Session.MarkViewing(portal.Key.ChannelID)
if err != nil { if err != nil {
user.log.Errorfln("Failed to mark user as viewing %s: %v", portal.Key.ChannelID, err) user.log.Error().Err(err).
Str("channel_id", portal.Key.ChannelID).
Msg("Failed to mark user as viewing channel")
} }
return true return true
} }
@@ -419,16 +407,18 @@ func (user *User) mutePortal(intent *appservice.IntentAPI, portal *Portal, unmut
} }
var err error var err error
if unmute { if unmute {
user.log.Debugfln("Unmuting portal %s", portal.MXID) user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Unmuting portal")
err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID)) err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID))
} else { } else {
user.log.Debugfln("Muting portal %s", portal.MXID) user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Muting portal")
err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{
Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, Actions: []pushrules.PushActionType{pushrules.ActionDontNotify},
}) })
} }
if err != nil && !errors.Is(err, mautrix.MNotFound) { if err != nil && !errors.Is(err, mautrix.MNotFound) {
user.log.Warnfln("Failed to update push rule for %s through double puppet: %v", portal.MXID, err) user.log.Warn().Err(err).
Str("room_id", portal.MXID.String()).
Msg("Failed to update push rule through double puppet")
} }
} }
@@ -440,7 +430,7 @@ func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool)
// TODO sync mute status properly // TODO sync mute status properly
if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate { if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate {
go user.mutePortal(doublePuppetIntent, portal, false) user.mutePortal(doublePuppetIntent, portal, false)
} }
} }
@@ -454,8 +444,31 @@ func (user *User) Login(token string) error {
user.wasLoggedOut = false user.wasLoggedOut = false
user.bridgeStateLock.Unlock() user.bridgeStateLock.Unlock()
user.DiscordToken = token user.DiscordToken = token
user.Update() var err error
return user.Connect() const maxRetries = 3
Loop:
for i := 0; i < maxRetries; i++ {
err = user.Connect()
if err == nil {
user.Update()
return nil
}
user.log.Error().Err(err).Msg("Error connecting for login")
closeErr := &websocket.CloseError{}
errors.As(err, &closeErr)
switch closeErr.Code {
case 4004, 4010, 4011, 4012, 4013, 4014:
break Loop
case 4000:
fallthrough
default:
if i < maxRetries-1 {
time.Sleep(time.Duration(i+1) * 2 * time.Second)
}
}
}
user.DiscordToken = ""
return err
} }
func (user *User) IsLoggedIn() bool { func (user *User) IsLoggedIn() bool {
@@ -465,7 +478,7 @@ func (user *User) IsLoggedIn() bool {
return user.DiscordToken != "" return user.DiscordToken != ""
} }
func (user *User) Logout() { func (user *User) Logout(isOverwriting bool) {
user.Lock() user.Lock()
defer user.Unlock() defer user.Unlock()
@@ -474,22 +487,30 @@ func (user *User) Logout() {
if puppet.CustomMXID != "" { if puppet.CustomMXID != "" {
err := puppet.SwitchCustomMXID("", "") err := puppet.SwitchCustomMXID("", "")
if err != nil { if err != nil {
user.log.Warnln("Failed to logout-matrix while logging out of Discord:", err) user.log.Warn().Err(err).Msg("Failed to disable custom puppet while logging out of Discord")
} }
} }
} }
if user.Session != nil { if user.Session != nil {
if err := user.Session.Close(); err != nil { if err := user.Session.Close(); err != nil {
user.log.Warnln("Error closing session:", err) user.log.Warn().Err(err).Msg("Error closing session")
} }
} }
user.Session = nil user.Session = nil
user.DiscordID = ""
user.DiscordToken = "" user.DiscordToken = ""
user.ReadStateVersion = 0 user.ReadStateVersion = 0
if !isOverwriting {
user.bridge.usersLock.Lock()
if user.bridge.usersByID[user.DiscordID] == user {
delete(user.bridge.usersByID, user.DiscordID)
}
user.bridge.usersLock.Unlock()
}
user.DiscordID = ""
user.Update() user.Update()
user.log.Info().Msg("User logged out")
} }
func (user *User) Connected() bool { func (user *User) Connected() bool {
@@ -499,6 +520,24 @@ func (user *User) Connected() bool {
return user.Session != nil return user.Session != nil
} }
const BotIntents = discordgo.IntentGuilds |
discordgo.IntentGuildMessages |
discordgo.IntentGuildMessageReactions |
discordgo.IntentGuildMessageTyping |
discordgo.IntentGuildBans |
discordgo.IntentGuildEmojis |
discordgo.IntentGuildIntegrations |
discordgo.IntentGuildInvites |
//discordgo.IntentGuildVoiceStates |
//discordgo.IntentGuildScheduledEvents |
discordgo.IntentDirectMessages |
discordgo.IntentDirectMessageTyping |
discordgo.IntentDirectMessageTyping |
// Privileged intents
discordgo.IntentMessageContent |
//discordgo.IntentGuildPresences |
discordgo.IntentGuildMembers
func (user *User) Connect() error { func (user *User) Connect() error {
user.Lock() user.Lock()
defer user.Unlock() defer user.Unlock()
@@ -507,7 +546,7 @@ func (user *User) Connect() error {
return ErrNotLoggedIn return ErrNotLoggedIn
} }
user.log.Debugln("Connecting to discord") user.log.Debug().Msg("Connecting to discord")
session, err := discordgo.New(user.DiscordToken) session, err := discordgo.New(user.DiscordToken)
if err != nil { if err != nil {
@@ -517,10 +556,14 @@ func (user *User) Connect() error {
if os.Getenv("DISCORD_DEBUG") == "1" { if os.Getenv("DISCORD_DEBUG") == "1" {
session.LogLevel = discordgo.LogDebug session.LogLevel = discordgo.LogDebug
} }
if !session.IsUser {
session.Identify.Intents = BotIntents
}
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)
@@ -547,8 +590,6 @@ func (user *User) Connect() error {
user.Session.AddHandler(user.interactionSuccessHandler) user.Session.AddHandler(user.interactionSuccessHandler)
user.Session.Identify.Presence.Status = "online"
return user.Session.Open() return user.Session.Open()
} }
@@ -559,6 +600,7 @@ func (user *User) Disconnect() error {
return ErrNotConnected return ErrNotConnected
} }
user.log.Info().Msg("Disconnecting session manually")
if err := user.Session.Close(); err != nil { if err := user.Session.Close(); err != nil {
return err return err
} }
@@ -566,22 +608,35 @@ func (user *User) Disconnect() error {
return nil return nil
} }
func (user *User) bridgeMessage(guildID string) bool { func (user *User) getGuildBridgingMode(guildID string) database.GuildBridgingMode {
if guildID == "" { if guildID == "" {
return true return database.GuildBridgeEverything
} }
guild := user.bridge.GetGuildByID(guildID, false) guild := user.bridge.GetGuildByID(guildID, false)
return guild != nil && guild.MXID != "" if guild == nil {
return database.GuildBridgeNothing
}
return guild.BridgingMode
} }
func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) { func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
user.log.Debugln("Discord connection ready") user.log.Debug().Msg("Discord connection ready")
user.bridgeStateLock.Lock() user.bridgeStateLock.Lock()
user.wasLoggedOut = false user.wasLoggedOut = false
user.bridgeStateLock.Unlock() user.bridgeStateLock.Unlock()
if user.DiscordID != r.User.ID { if user.DiscordID != r.User.ID {
user.bridge.usersLock.Lock()
user.DiscordID = r.User.ID user.DiscordID = r.User.ID
if previousUser, ok := user.bridge.usersByID[user.DiscordID]; ok && previousUser != user {
user.log.Warn().
Str("previous_user_id", previousUser.MXID.String()).
Msg("Another user is logged in with same Discord ID, logging them out")
// TODO send notice?
previousUser.Logout(true)
}
user.bridge.usersByID[user.DiscordID] = user
user.bridge.usersLock.Unlock()
user.Update() user.Update()
} }
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling})
@@ -601,7 +656,7 @@ func (user *User) readyHandler(_ *discordgo.Session, r *discordgo.Ready) {
} }
user.PrunePortalList(updateTS) user.PrunePortalList(updateTS)
if r.ReadState.Version > user.ReadStateVersion { if r.ReadState != nil && r.ReadState.Version > user.ReadStateVersion {
// TODO can we figure out which read states are actually new? // TODO can we figure out which read states are actually new?
for _, entry := range r.ReadState.Entries { for _, entry := range r.ReadState.Entries {
user.messageAckHandler(nil, &discordgo.MessageAck{ user.messageAckHandler(nil, &discordgo.MessageAck{
@@ -613,9 +668,39 @@ 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) {
if !user.Session.IsUser {
return
}
for _, guildMeta := range user.Session.State.Guilds {
guild := user.bridge.GetGuildByID(guildMeta.ID, false)
if guild != nil && guild.MXID != "" {
user.log.Debug().Str("guild_id", guild.ID).Msg("Subscribing to guild")
dat := discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
}
err := user.Session.SubscribeGuild(dat)
if err != nil {
user.log.Warn().Err(err).Str("guild_id", guild.ID).Msg("Failed to subscribe to guild")
}
time.Sleep(delay)
}
}
}
func (user *User) resumeHandler(_ *discordgo.Session, r *discordgo.Resumed) {
user.log.Debug().Msg("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
@@ -624,7 +709,9 @@ func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
Via: []string{user.bridge.AS.HomeserverDomain}, Via: []string{user.bridge.AS.HomeserverDomain},
}) })
if err != nil { if err != nil {
user.log.Errorfln("Failed to add DM room %s to user DM space: %v", portal.MXID, err) user.log.Error().Err(err).
Str("room_id", portal.MXID.String()).
Msg("Failed to add DMM room to user DM space")
return false return false
} else { } else {
return true return true
@@ -635,7 +722,9 @@ func (user *User) handlePrivateChannel(portal *Portal, meta *discordgo.Channel,
if create && portal.MXID == "" { if create && portal.MXID == "" {
err := portal.CreateMatrixRoom(user, meta) err := portal.CreateMatrixRoom(user, meta)
if err != nil { if err != nil {
user.log.Errorfln("Failed to create portal for private channel %s in initial sync: %v", portal.Key.ChannelID, err) user.log.Error().Err(err).
Str("channel_id", portal.Key.ChannelID).
Msg("Failed to create portal for private channel in create handler")
} }
} else { } else {
portal.UpdateInfo(user, meta) portal.UpdateInfo(user, meta)
@@ -654,7 +743,9 @@ func (user *User) addGuildToSpace(guild *Guild, isInSpace bool, timestamp time.T
Via: []string{user.bridge.AS.HomeserverDomain}, Via: []string{user.bridge.AS.HomeserverDomain},
}) })
if err != nil { if err != nil {
user.log.Errorfln("Failed to add guild space %s to user space: %v", guild.MXID, err) user.log.Error().Err(err).
Str("guild_space_id", guild.MXID.String()).
Msg("Failed to add guild space to user space")
} else { } else {
isInSpace = true isInSpace = true
} }
@@ -697,7 +788,7 @@ func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
} }
txn, err := user.bridge.DB.Begin() txn, err := user.bridge.DB.Begin()
if err != nil { if err != nil {
user.log.Errorln("Failed to start transaction for guild role sync:", err) user.log.Error().Err(err).Msg("Failed to start transaction for guild role sync")
panic(err) panic(err)
} }
for _, role := range newRoles { for _, role := range newRoles {
@@ -712,10 +803,10 @@ func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
} }
err = txn.Commit() err = txn.Commit()
if err != nil { if err != nil {
user.log.Errorln("Failed to commit guild role sync:", err) user.log.Error().Err(err).Msg("Failed to commit guild role sync transaction")
rollbackErr := txn.Rollback() rollbackErr := txn.Rollback()
if rollbackErr != nil { if rollbackErr != nil {
user.log.Errorln("Failed to rollback errored guild role sync:", rollbackErr) user.log.Error().Err(rollbackErr).Msg("Failed to rollback errored guild role sync transaction")
} }
panic(err) panic(err)
} }
@@ -741,10 +832,13 @@ func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSp
if len(meta.Channels) > 0 { if len(meta.Channels) > 0 {
for _, ch := range meta.Channels { for _, ch := range meta.Channels {
portal := user.GetPortalByMeta(ch) portal := user.GetPortalByMeta(ch)
if guild.AutoBridgeChannels && portal.MXID == "" && user.channelIsBridgeable(ch) { if guild.BridgingMode >= database.GuildBridgeEverything && portal.MXID == "" && user.channelIsBridgeable(ch) {
err := portal.CreateMatrixRoom(user, ch) err := portal.CreateMatrixRoom(user, ch)
if err != nil { if err != nil {
user.log.Errorfln("Failed to create portal for guild channel %s/%s in initial sync: %v", guild.ID, ch.ID, err) user.log.Error().Err(err).
Str("guild_id", guild.ID).
Str("channel_id", ch.ID).
Msg("Failed to create portal for guild channel in guild handler")
} }
} else { } else {
portal.UpdateInfo(user, ch) portal.UpdateInfo(user, ch)
@@ -760,7 +854,7 @@ func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSp
func (user *User) connectedHandler(_ *discordgo.Session, _ *discordgo.Connect) { func (user *User) connectedHandler(_ *discordgo.Session, _ *discordgo.Connect) {
user.bridgeStateLock.Lock() user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock() defer user.bridgeStateLock.Unlock()
user.log.Debugln("Connected to Discord") user.log.Debug().Msg("Connected to Discord")
if user.wasDisconnected { if user.wasDisconnected {
user.wasDisconnected = false user.wasDisconnected = false
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
@@ -771,10 +865,10 @@ func (user *User) disconnectedHandler(_ *discordgo.Session, _ *discordgo.Disconn
user.bridgeStateLock.Lock() user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock() defer user.bridgeStateLock.Unlock()
if user.wasLoggedOut { if user.wasLoggedOut {
user.log.Debugln("Disconnected from Discord (not updating bridge state as user was just logged out)") user.log.Debug().Msg("Disconnected from Discord (not updating bridge state as user was just logged out)")
return return
} }
user.log.Debugln("Disconnected from Discord") user.log.Debug().Msg("Disconnected from Discord")
user.wasDisconnected = true user.wasDisconnected = true
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-transient-disconnect", Message: "Temporarily disconnected from Discord, trying to reconnect"}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-transient-disconnect", Message: "Temporarily disconnected from Discord, trying to reconnect"})
} }
@@ -782,44 +876,52 @@ func (user *User) disconnectedHandler(_ *discordgo.Session, _ *discordgo.Disconn
func (user *User) invalidAuthHandler(_ *discordgo.Session, _ *discordgo.InvalidAuth) { func (user *User) invalidAuthHandler(_ *discordgo.Session, _ *discordgo.InvalidAuth) {
user.bridgeStateLock.Lock() user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock() defer user.bridgeStateLock.Unlock()
user.log.Debugln("Got logged out from Discord") user.log.Info().Msg("Got logged out from Discord due to invalid token")
user.wasLoggedOut = true user.wasLoggedOut = true
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-websocket-disconnect-4004", Message: "Discord access token is no longer valid, please log in again"}) user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-websocket-disconnect-4004", Message: "Discord access token is no longer valid, please log in again"})
go user.Logout() go user.Logout(false)
} }
func (user *User) guildCreateHandler(_ *discordgo.Session, g *discordgo.GuildCreate) { func (user *User) guildCreateHandler(_ *discordgo.Session, g *discordgo.GuildCreate) {
user.log.Infoln("Got guild create event for", g.ID) user.log.Info().
Str("guild_id", g.ID).
Str("name", g.Name).
Bool("unavailable", g.Unavailable).
Msg("Got guild create event")
user.handleGuild(g.Guild, time.Now(), false) user.handleGuild(g.Guild, time.Now(), false)
} }
func (user *User) guildDeleteHandler(_ *discordgo.Session, g *discordgo.GuildDelete) { func (user *User) guildDeleteHandler(_ *discordgo.Session, g *discordgo.GuildDelete) {
user.log.Infoln("Got guild delete event for", g.ID) user.log.Info().Str("guild_id", g.ID).Msg("Got guild delete event")
user.MarkNotInPortal(g.ID) user.MarkNotInPortal(g.ID)
guild := user.bridge.GetGuildByID(g.ID, false) guild := user.bridge.GetGuildByID(g.ID, false)
if guild == nil || guild.MXID == "" { if guild == nil || guild.MXID == "" {
return return
} }
if user.bridge.Config.Bridge.DeleteGuildOnLeave && !user.PortalHasOtherUsers(g.ID) { if user.bridge.Config.Bridge.DeleteGuildOnLeave && !user.PortalHasOtherUsers(g.ID) {
user.log.Debugfln("No other users in %s, cleaning up all portals", g.ID) user.log.Debug().Str("guild_id", g.ID).Msg("No other users in guild, cleaning up all portals")
err := user.unbridgeGuild(g.ID) err := user.unbridgeGuild(g.ID)
if err != nil { if err != nil {
user.log.Warnfln("Failed to unbridge guild that was deleted: %v", err) user.log.Warn().Err(err).Msg("Failed to unbridge guild that was deleted")
} }
} }
} }
func (user *User) guildUpdateHandler(_ *discordgo.Session, g *discordgo.GuildUpdate) { func (user *User) guildUpdateHandler(_ *discordgo.Session, g *discordgo.GuildUpdate) {
user.log.Debugln("Got guild update event for", g.ID) user.log.Debug().Str("guild_id", g.ID).Msg("Got guild update event")
user.handleGuild(g.Guild, time.Now(), user.IsInSpace(g.ID)) user.handleGuild(g.Guild, time.Now(), user.IsInSpace(g.ID))
} }
func (user *User) channelCreateHandler(_ *discordgo.Session, c *discordgo.ChannelCreate) { func (user *User) channelCreateHandler(_ *discordgo.Session, c *discordgo.ChannelCreate) {
if !user.bridgeMessage(c.GuildID) { if user.getGuildBridgingMode(c.GuildID) < database.GuildBridgeEverything {
user.log.Debugfln("Ignoring channel create event in unbridged guild %s/%s", c.GuildID, c.ID) user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Ignoring channel create event in unbridged guild")
return return
} }
user.log.Infofln("Got channel create event for %s/%s", c.GuildID, c.ID) user.log.Info().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel create event")
portal := user.GetPortalByMeta(c.Channel) portal := user.GetPortalByMeta(c.Channel)
if portal.MXID != "" { if portal.MXID != "" {
return return
@@ -829,30 +931,40 @@ func (user *User) channelCreateHandler(_ *discordgo.Session, c *discordgo.Channe
} else if user.channelIsBridgeable(c.Channel) { } else if user.channelIsBridgeable(c.Channel) {
err := portal.CreateMatrixRoom(user, c.Channel) err := portal.CreateMatrixRoom(user, c.Channel)
if err != nil { if err != nil {
user.log.Errorfln("Error creating Matrix room for %s on channel create event: %v", c.ID, err) user.log.Error().Err(err).
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Error creating Matrix room after channel create event")
} }
} else { } else {
user.log.Debugfln("Got channel create event for %s, but it's not bridgeable, ignoring", c.ID) user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel create event, but it's not bridgeable, ignoring")
} }
} }
func (user *User) channelDeleteHandler(_ *discordgo.Session, c *discordgo.ChannelDelete) { func (user *User) channelDeleteHandler(_ *discordgo.Session, c *discordgo.ChannelDelete) {
portal := user.GetExistingPortalByID(c.ID) portal := user.GetExistingPortalByID(c.ID)
if portal == nil { if portal == nil {
user.log.Debugfln("Ignoring delete of unknown channel %s/%s", c.GuildID, c.ID) user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Ignoring channel delete event of unknown channel")
return return
} }
user.log.Infofln("Got channel delete event for %s/%s, cleaning up portal", c.GuildID, c.ID) user.log.Info().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel delete event, cleaning up portal")
portal.Delete() portal.Delete()
portal.cleanup(!user.bridge.Config.Bridge.DeletePortalOnChannelDelete) portal.cleanup(!user.bridge.Config.Bridge.DeletePortalOnChannelDelete)
if c.GuildID == "" { if c.GuildID == "" {
user.MarkNotInPortal(portal.Key.ChannelID) user.MarkNotInPortal(portal.Key.ChannelID)
} }
user.log.Debugfln("Completed cleaning up %s/%s", c.GuildID, c.ID) user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Completed cleaning up channel")
} }
func (user *User) channelPinsUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelPinsUpdate) { func (user *User) channelPinsUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
user.log.Debugln("channel pins update") user.log.Debug().Msg("channel pins update")
} }
func (user *User) channelUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelUpdate) { func (user *User) channelUpdateHandler(_ *discordgo.Session, c *discordgo.ChannelUpdate) {
@@ -864,20 +976,61 @@ func (user *User) channelUpdateHandler(_ *discordgo.Session, c *discordgo.Channe
} }
} }
func (user *User) findPortal(channelID string) (*Portal, *Thread) {
portal := user.GetExistingPortalByID(channelID)
if portal != nil {
return portal, nil
}
thread := user.bridge.GetThreadByID(channelID, nil)
if thread != nil && thread.Parent != nil {
return thread.Parent, thread
}
if !user.Session.IsUser {
channel, _ := user.Session.State.Channel(channelID)
if channel == nil {
user.log.Debug().Str("channel_id", channelID).Msg("Fetching info of unknown channel to handle message")
var err error
channel, err = user.Session.Channel(channelID)
if err != nil {
user.log.Warn().Err(err).Str("channel_id", channelID).Msg("Failed to get info of unknown channel")
} else {
user.log.Debug().Str("channel_id", channelID).Msg("Got info for channel to handle message")
_ = user.Session.State.ChannelAdd(channel)
}
}
if channel != nil && user.channelIsBridgeable(channel) {
user.log.Debug().Str("channel_id", channelID).Msg("Creating portal and updating info to handle message")
portal = user.GetPortalByMeta(channel)
if channel.GuildID == "" {
user.handlePrivateChannel(portal, channel, time.Now(), false, false)
} else {
user.log.Warn().
Str("channel_id", channel.ID).Str("guild_id", channel.GuildID).
Msg("Unexpected unknown guild channel")
}
return portal, nil
}
}
return nil, nil
}
func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildID string) { func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildID string) {
if !user.bridgeMessage(guildID) { if user.getGuildBridgingMode(guildID) <= database.GuildBridgeNothing {
// If guild bridging mode is nothing, don't even check if the portal exists
return return
} }
portal := user.GetExistingPortalByID(channelID) portal, thread := user.findPortal(channelID)
var thread *Thread
if portal == nil { if portal == nil {
thread = user.bridge.GetThreadByID(channelID, nil) user.log.Debug().
if thread == nil || thread.Parent == nil { Str("discord_event", typeName).
user.log.Debugfln("Dropping %s in unknown channel %s/%s", typeName, guildID, channelID) Str("guild_id", guildID).
return Str("channel_id", channelID).
} Msg("Dropping event in unknown channel")
portal = thread.Parent return
}
if mode := user.getGuildBridgingMode(portal.GuildID); mode <= database.GuildBridgeNothing || (portal.MXID == "" && mode <= database.GuildBridgeIfPortalExists) {
return
} }
portal.discordMessages <- portalDiscordMessage{ portal.discordMessages <- portalDiscordMessage{
@@ -942,14 +1095,20 @@ func (user *User) messageAckHandler(_ *discordgo.Session, m *discordgo.MessageAc
} }
msg := user.bridge.DB.Message.GetLastByDiscordID(portal.Key, m.MessageID) msg := user.bridge.DB.Message.GetLastByDiscordID(portal.Key, m.MessageID)
if msg == nil { if msg == nil {
user.log.Debugfln("Dropping message ack event for unknown message %s/%s", m.ChannelID, m.MessageID) user.log.Debug().
Str("channel_id", m.ChannelID).Str("message_id", m.MessageID).
Msg("Dropping message ack event for unknown message")
return return
} }
err := dp.CustomIntent().SetReadMarkers(portal.MXID, user.makeReadMarkerContent(msg.MXID)) err := dp.CustomIntent().SetReadMarkers(portal.MXID, user.makeReadMarkerContent(msg.MXID))
if err != nil { if err != nil {
user.log.Warnfln("Failed to mark %s/%s as read: %v", msg.MXID, msg.DiscordID, err) user.log.Error().Err(err).
Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
Msg("Failed to mark event as read")
} else { } else {
user.log.Debugfln("Marked %s/%s as read after Discord message ack event", msg.MXID, msg.DiscordID) user.log.Debug().
Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
Msg("Marked event as read after Discord message ack")
if user.ReadStateVersion < m.Version { if user.ReadStateVersion < m.Version {
user.ReadStateVersion = m.Version user.ReadStateVersion = m.Version
// TODO maybe don't update every time? // TODO maybe don't update every time?
@@ -963,11 +1122,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) {
@@ -975,9 +1130,9 @@ func (user *User) interactionSuccessHandler(_ *discordgo.Session, s *discordgo.I
defer user.pendingInteractionsLock.Unlock() defer user.pendingInteractionsLock.Unlock()
ce, ok := user.pendingInteractions[s.Nonce] ce, ok := user.pendingInteractions[s.Nonce]
if !ok { if !ok {
user.log.Debugfln("Got interaction success for unknown interaction %s/%s", s.Nonce, s.ID) user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for unknown interaction")
} else { } else {
user.log.Infofln("Got interaction success for pending interaction %s/%s", s.Nonce, s.ID) user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for pending interaction")
ce.React("✅") ce.React("✅")
delete(user.pendingInteractions, s.Nonce) delete(user.pendingInteractions, s.Nonce)
} }
@@ -1009,7 +1164,7 @@ func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID,
user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin) user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin)
ret = true ret = true
} else if err != nil { } else if err != nil {
user.log.Warnfln("Failed to invite user to %s: %v", roomID, err) user.log.Error().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room")
} else { } else {
ret = true ret = true
} }
@@ -1017,7 +1172,7 @@ func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID,
if customPuppet != nil && customPuppet.CustomIntent() != nil { if customPuppet != nil && customPuppet.CustomIntent() != nil {
err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
if err != nil { if err != nil {
user.log.Warnfln("Failed to auto-join %s: %v", roomID, err) user.log.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room")
ret = false ret = false
} else { } else {
ret = true ret = true
@@ -1063,7 +1218,7 @@ func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
method = http.MethodPut method = http.MethodPut
} }
user.log.Debugln("Updating m.direct list on homeserver") user.log.Debug().Msg("Updating m.direct list on homeserver")
var err error var err error
if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux { if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux {
@@ -1079,8 +1234,7 @@ func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
if err != nil { if err != nil {
user.log.Warnln("Failed to get m.direct list to update it:", err) user.log.Warn().Err(err).Msg("Failed to get m.direct event to update it")
return return
} }
@@ -1098,7 +1252,7 @@ func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
} }
if err != nil { if err != nil {
user.log.Warnln("Failed to update m.direct list:", err) user.log.Warn().Err(err).Msg("Failed to update m.direct event")
} }
} }
@@ -1112,19 +1266,36 @@ func (user *User) bridgeGuild(guildID string, everything bool) error {
if err != nil { if err != nil {
return err return err
} }
log := user.log.With().Str("guild_id", guild.ID).Logger()
user.addGuildToSpace(guild, false, time.Now()) user.addGuildToSpace(guild, false, time.Now())
for _, ch := range meta.Channels { for _, ch := range meta.Channels {
portal := user.GetPortalByMeta(ch) portal := user.GetPortalByMeta(ch)
if (everything && user.channelIsBridgeable(ch)) || ch.Type == discordgo.ChannelTypeGuildCategory { if (everything && user.channelIsBridgeable(ch)) || ch.Type == discordgo.ChannelTypeGuildCategory {
err = portal.CreateMatrixRoom(user, ch) err = portal.CreateMatrixRoom(user, ch)
if err != nil { if err != nil {
user.log.Warnfln("Error creating room for guild channel %s: %v", ch.ID, err) log.Error().Err(err).Str("channel_id", ch.ID).
Msg("Failed to create room for guild channel while bridging guild")
} }
} }
} }
guild.AutoBridgeChannels = everything if everything {
guild.BridgingMode = database.GuildBridgeEverything
}
guild.Update() guild.Update()
if user.Session.IsUser {
log.Debug().Msg("Subscribing to guild after bridging")
err = user.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild")
}
}
return nil return nil
} }
@@ -1138,10 +1309,10 @@ func (user *User) unbridgeGuild(guildID string) error {
} }
guild.roomCreateLock.Lock() guild.roomCreateLock.Lock()
defer guild.roomCreateLock.Unlock() defer guild.roomCreateLock.Unlock()
if !guild.AutoBridgeChannels && guild.MXID == "" { if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
return errors.New("that guild is not bridged") return errors.New("that guild is not bridged")
} }
guild.AutoBridgeChannels = false guild.BridgingMode = database.GuildBridgeNothing
guild.Update() guild.Update()
for _, portal := range user.bridge.GetAllPortalsInGuild(guild.ID) { for _, portal := range user.bridge.GetAllPortalsInGuild(guild.ID) {
portal.cleanup(false) portal.cleanup(false)