all: init v2 and delete old bridge

This commit is contained in:
Tulir Asokan 2024-08-15 16:43:13 +03:00
parent 64c92ca783
commit 0a7b8bf41b
87 changed files with 458 additions and 13224 deletions

View file

@ -8,8 +8,8 @@ jobs:
strategy:
fail-fast: false
matrix:
go-version: ["1.21", "1.22"]
name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }}
go-version: ["1.22", "1.23"]
name: Lint ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v4
@ -23,9 +23,10 @@ jobs:
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Install goimports
- name: Install dependencies
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
- name: Install pre-commit

View file

@ -1,3 +1,3 @@
include:
- project: 'mautrix/ci'
file: '/go.yml'
file: '/gov2-as-default.yml'

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@ -12,9 +12,15 @@ repos:
rev: v1.0.0-rc.1
hooks:
- id: go-imports-repo
args:
- "-local"
- "go.mau.fi/mautrix-discord"
- "-w"
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.3.1
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View file

@ -1,3 +1,11 @@
# v0.8.0 (unreleased)
* Bumped minimum Go version to 1.22.
* Rewrote bridge using bridgev2 architecture.
* It is recommended to check the config file after upgrading. If you have
prevented the bridge from writing to the config, you should update it
manually.
# v0.7.0 (2024-07-16)
* Bumped minimum Go version to 1.21.

View file

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

View file

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

View file

@ -1,18 +0,0 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 AS lottie
FROM golang:1-alpine3.18 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl \
zlib libpng giflib libstdc++ libgcc
COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/
COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter
COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-discord
# Setup development stack using gow
RUN go install github.com/mitranim/gow@latest
RUN echo 'gow run /build $@' > /usr/bin/mautrix-discord \
&& chmod +x /usr/bin/mautrix-discord
VOLUME /data

12
LICENSE.exceptions Normal file
View file

@ -0,0 +1,12 @@
The mautrix-discord developers grant the following special exceptions:
* to Beeper the right to embed the program in the Beeper clients and servers,
and use and distribute the collective work without applying the license to
the whole.
* to Element the right to distribute compiled binaries of the program as a part
of the Element Server Suite and other server bundles without applying the
license.
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-discord remain publicly available under the terms
of the GNU AGPL version 3 or later.

View file

@ -1,24 +1,24 @@
# Features & roadmap
* Matrix → Discord
* [ ] Message content
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Custom emojis
* [x] Message redactions
* [x] Reactions
* [x] Unicode emojis
* [ ] Message redactions
* [ ] Reactions
* [ ] Unicode emojis
* [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works)
* [ ] Executing Discord bot commands
* [x] Basic arguments and subcommands
* [ ] Basic arguments and subcommands
* [ ] Subcommand groups
* [ ] Mention arguments
* [ ] Attachment arguments
* [ ] Presence
* [x] Typing notifications
* [x] Own read status
* [ ] Typing notifications
* [ ] Own read status
* [ ] Power level
* [ ] Membership actions
* [ ] Invite
@ -31,37 +31,37 @@
* [ ] Initial room metadata
* Discord → Matrix
* [ ] Message content
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [x] Auto-joining threads when opening
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Auto-joining threads when opening
* [ ] Backfilling threads after joining
* [x] Custom emojis
* [x] Embeds
* [ ] Custom emojis
* [ ] Embeds
* [ ] Interactive components
* [x] Interactions (commands)
* [x] @everyone/@here mentions into @room
* [x] Message deletions
* [x] Reactions
* [x] Unicode emojis
* [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [x] Avatars
* [ ] Interactions (commands)
* [ ] @everyone/@here mentions into @room
* [ ] Message deletions
* [ ] Reactions
* [ ] Unicode emojis
* [ ] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [ ] Avatars
* [ ] Presence
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
* [x] Own read status
* [ ] Own read status
* [ ] Role permissions
* [ ] Membership actions
* [ ] Invite
* [ ] Join
* [ ] Leave
* [ ] Kick
* [x] Channel/group DM metadata changes
* [x] Title
* [x] Avatar
* [x] Description
* [x] Initial channel/group DM metadata
* [ ] Channel/group DM metadata changes
* [ ] Title
* [ ] Avatar
* [ ] Description
* [ ] Initial channel/group DM metadata
* [ ] User metadata changes
* [ ] Display name
* [ ] Avatar
@ -69,11 +69,12 @@
* [ ] Display name
* [ ] Avatar
* Misc
* [x] Login methods
* [x] QR scan from mobile
* [x] Manually providing access token
* [x] Automatic portal creation
* [x] After login
* [x] When receiving DM
* [ ] Login methods
* [ ] QR scan from mobile
* [ ] Username/password
* [ ] Manually providing access token
* [ ] Automatic portal creation
* [ ] After login
* [ ] When receiving DM
* [ ] Private chat creation by inviting Matrix puppet of Discord user to new room
* [x] Option to use own Matrix account for messages sent from other Discord clients
* [ ] Option to use own Matrix account for messages sent from other Discord clients

View file

@ -1,348 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for key, value := range discordgo.DroidDownloadHeaders {
req.Header.Set(key, value)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
}
if resp.Header.Get("Content-Length") != "" {
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse content length: %w", err)
} else if length > maxSize {
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
}
return io.ReadAll(resp.Body)
} else {
var mbe *http.MaxBytesError
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
if err != nil && errors.As(err, &mbe) {
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
}
return data, err
}
}
func uploadDiscordAttachment(url string, data []byte) error {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return err
}
for key, value := range discordgo.DroidFetchHeaders {
req.Header.Set(key, value)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
respData, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData)
}
return nil
}
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
if content.File != nil {
file = content.File
rawMXC = file.URL
}
mxc, err := rawMXC.Parse()
if err != nil {
return nil, err
}
data, err := intent.DownloadBytes(mxc)
if err != nil {
return nil, err
}
if file != nil {
err = file.DecryptInPlace(data)
if err != nil {
return nil, err
}
}
return data, nil
}
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now()
dbFile.URL = url
dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String()
if meta.MimeType == "" {
meta.MimeType = dbFile.MimeType
}
if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width
dbFile.Height = cfg.Height
}
uploadMime := meta.MimeType
if encrypt {
dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
dbFile.DecryptionInfo.EncryptInPlace(data)
uploadMime = "application/octet-stream"
}
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: uploadMime,
}
if br.Config.Homeserver.AsyncMedia {
resp, err := intent.CreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.MXC = resp.ContentURI
req.UnstableUploadURL = resp.UnstableUploadURL
semaWg.Add(1)
go func() {
defer semaWg.Done()
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
dbFile.Delete()
}
}()
} else {
uploaded, err := intent.UploadMedia(req)
if err != nil {
return nil, err
}
dbFile.MXC = uploaded.ContentURI
}
return dbFile, nil
}
type AttachmentMeta struct {
AttachmentID string
MimeType string
EmojiName string
CopyIfMissing bool
Converter func([]byte) ([]byte, string, error)
}
var NoMeta = AttachmentMeta{}
type attachmentKey struct {
URL string
Encrypt bool
}
func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
fps := br.Config.Bridge.AnimatedSticker.Args.FPS
width := br.Config.Bridge.AnimatedSticker.Args.Width
height := br.Config.Bridge.AnimatedSticker.Args.Height
target := br.Config.Bridge.AnimatedSticker.Target
var lottieTarget, outputMime string
switch target {
case "png":
lottieTarget = "png"
outputMime = "image/png"
fps = 1
case "gif":
lottieTarget = "gif"
outputMime = "image/gif"
case "webm":
lottieTarget = "pngs"
outputMime = "video/webm"
case "webp":
lottieTarget = "pngs"
outputMime = "image/webp"
case "disable":
return data, "application/json", nil
default:
return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target)
}
ctx := context.Background()
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
if err != nil {
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 := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.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
}
}
const attachmentSizeVal = 1
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
cancel()
if onceErr != nil {
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
onceErr = fmt.Errorf("reuploading timed out")
return
}
var semaWg sync.WaitGroup
semaWg.Add(1)
defer semaWg.Done()
go func() {
semaWg.Wait()
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
}()
var data []byte
data, onceErr = downloadDiscordAttachment(url, br.MediaConfig.UploadSize)
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, &semaWg)
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 {
mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated)
if !mxc.IsEmpty() {
return mxc
}
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.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
return id.ContentURI{}
}
return dbFile.MXC
}

View file

@ -1,380 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"sort"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) {
log := portal.log
defer func() {
log.Debug().Msg("Forward backfill finished, unlocking lock")
portal.forwardBackfillLock.Unlock()
}()
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
if portal.forwardBackfillLock.TryLock() {
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread
thread.initialBackfillAttempted = true
}
}
if limit == 0 {
return
}
with := log.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log = with.Logger()
portal.backfillLimited(log, source, limit, "", thread)
}
func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) {
if portal.MXID == "" {
return
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread
}
}
if limit == 0 {
return
}
with := portal.log.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log := with.Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
var lastMessage *database.Message
if thread != nil {
lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
} else {
lastMessage = portal.bridge.DB.Message.GetLast(portal.Key)
}
if lastMessage == nil || serverLastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Not backfilling, last message in database is newer than last message in metadata")
return
}
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "")
if err != nil {
return nil, false, err
}
if until != "" {
for i, msg := range newMessages {
if compareMessageIDs(msg.ID, until) <= 0 {
log.Debug().
Str("message_id", msg.ID).
Str("until_id", until).
Msg("Found message that was already bridged")
newMessages = newMessages[:i]
foundAll = true
break
}
}
}
messages = append(messages, newMessages...)
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
break
}
before = newMessages[len(newMessages)-1].ID
}
if len(messages) > limit {
foundAll = false
messages = messages[:limit]
}
return messages, foundAll, nil
}
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread)
if err != nil {
log.Err(err).Msg("Error collecting messages to forward backfill")
return
}
log.Info().
Int("count", len(messages)).
Bool("found_all", foundAll).
Msg("Collected messages to backfill")
sort.Sort(MessageSlice(messages))
if !foundAll && after != "" {
_, err = portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Some messages may have been missed here while the bridge was offline.",
}, nil, 0)
if err != nil {
log.Warn().Err(err).Msg("Failed to send missed message warning")
} else {
log.Debug().Msg("Sent warning about possibly missed messages")
}
}
portal.sendBackfillBatch(log, source, messages, thread)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) {
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "")
if err != nil {
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
return
}
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
sort.Sort(MessageSlice(messages))
portal.sendBackfillBatch(log, source, messages, thread)
if len(messages) < messageFetchChunkSize {
// Assume that was all the missing messages
log.Debug().Msg("Chunk had less than 50 messages, stopping backfill")
return
}
after = messages[len(messages)-1].ID
}
}
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages, thread)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, thread)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread)
if len(evts) == 0 {
log.Warn().Msg("Didn't get any events to backfill")
return
}
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{
Forward: true,
Events: evts,
})
if err != nil {
log.Err(err).Msg("Error sending backfill batch")
return
}
for i, evtID := range resp.EventIDs {
dbMessages[i].MXID = evtID
if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread {
// TODO proper context
ctx := log.WithContext(context.Background())
portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread)
}
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
}
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) {
var discordThreadID string
var threadRootEvent, lastThreadEvent id.EventID
if thread != nil {
discordThreadID = thread.ID
threadRootEvent = thread.RootMXID
lastThreadEvent = threadRootEvent
lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
if lastInThread != nil {
lastThreadEvent = lastInThread.MXID
}
}
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.Message, 0, len(messages))
metas := make([]*discordgo.Message, 0, len(messages))
ctx := context.Background()
for _, msg := range messages {
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
puppet.UpdateInfo(nil, mention, nil)
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author, msg)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true)
mentions := portal.convertDiscordMentions(msg, false)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
log := log.With().
Str("message_id", msg.ID).
Int("message_type", int(msg.Type)).
Str("author_id", msg.Author.ID).
Logger()
parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg)
for i, part := range parts {
if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
part.Content.RelatesTo = &event.RelatesTo{}
}
if threadRootEvent != "" {
part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
}
if replyTo != nil {
part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
// Only set reply for first event
replyTo = nil
}
part.Content.Mentions = mentions
// Only set mentions for first event, but keep empty object for rest
mentions = &event.Mentions{}
partName := part.AttachmentID
// Always use blank part name for first part so that replies and other things
// can reference it without knowing about attachments.
if i == 0 {
partName = ""
}
evt := &event.Event{
ID: portal.deterministicEventID(msg.ID, partName),
Type: part.Type,
Sender: intent.UserID,
Timestamp: ts.UnixMilli(),
Content: event.Content{
Parsed: part.Content,
Raw: part.Extra,
},
}
var err error
evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type)
if err != nil {
log.Err(err).Msg("Failed to encrypt event")
continue
}
intent.AddDoublePuppetValue(&evt.Content)
evts = append(evts, evt)
dbMessages = append(dbMessages, database.Message{
Channel: portal.Key,
DiscordID: msg.ID,
SenderID: msg.Author.ID,
Timestamp: ts,
AttachmentID: part.AttachmentID,
SenderMXID: intent.UserID,
})
if i == 0 {
metas = append(metas, msg)
} else {
metas = append(metas, nil)
}
lastThreadEvent = evt.ID
}
}
return evts, metas, dbMessages
}
func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID {
data := fmt.Sprintf("%s/discord/%s/%s", portal.MXID, messageID, partName)
sum := sha256.Sum256([]byte(data))
return id.EventID(fmt.Sprintf("$%s:discord.com", base64.RawURLEncoding.EncodeToString(sum[:])))
}
// compareMessageIDs compares two Discord message IDs.
//
// If the first ID is lower, -1 is returned.
// If the second ID is lower, 1 is returned.
// If the IDs are equal, 0 is returned.
func compareMessageIDs(id1, id2 string) int {
if id1 == id2 {
return 0
}
if len(id1) < len(id2) {
return -1
} else if len(id2) < len(id1) {
return 1
}
if id1 < id2 {
return -1
}
return 1
}
func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool {
return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1
}
type MessageSlice []*discordgo.Message
var _ sort.Interface = (MessageSlice)(nil)
func (a MessageSlice) Len() int {
return len(a)
}
func (a MessageSlice) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a MessageSlice) Less(i, j int) bool {
return compareMessageIDs(a[i].ID, a[j].ID) == -1
}

View file

@ -1,2 +1,4 @@
#!/bin/sh
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@"

View file

@ -0,0 +1,43 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 (
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"go.mau.fi/mautrix-discord/pkg/connector"
)
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var c = &connector.DiscordConnector{}
var m = mxmain.BridgeMain{
Name: "mautrix-discord",
Description: "A Matrix-Discord puppeting bridge",
URL: "https://github.com/mautrix/discord",
Version: "0.8.0",
Connector: c,
}
func main() {
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}

View file

@ -1,900 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"html"
"net/http"
"strconv"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/skip2/go-qrcode"
"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/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth"
)
type WrappedCommandEvent struct {
*commands.Event
Bridge *DiscordBridge
User *User
Portal *Portal
}
var HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
func (br *DiscordBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers(
cmdLoginToken,
cmdLoginQR,
cmdLogout,
cmdPing,
cmdReconnect,
cmdDisconnect,
cmdBridge,
cmdUnbridge,
cmdDeletePortal,
cmdCreatePortal,
cmdSetRelay,
cmdUnsetRelay,
cmdGuilds,
cmdRejoinSpace,
cmdDeleteAllPortals,
cmdExec,
cmdCommands,
)
}
func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) {
return func(ce *commands.Event) {
user := ce.User.(*User)
var portal *Portal
if ce.Portal != nil {
portal = ce.Portal.(*Portal)
}
br := ce.Bridge.Child.(*DiscordBridge)
handler(&WrappedCommandEvent{ce, br, user, portal})
}
}
var cmdLoginToken = &commands.FullHandler{
Func: wrapCommand(fnLoginToken),
Name: "login-token",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
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) {
if len(ce.Args) != 2 {
ce.Reply("**Usage**: `$cmdprefix login-token <user/bot/oauth> <token>`")
return
}
ce.MarkRead()
defer ce.Redact()
if ce.User.IsLoggedIn() {
ce.Reply("You're already logged in")
return
}
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)
return
}
ce.Reply("Successfully logged in as @%s", ce.User.Session.State.User.Username)
}
var cmdLoginQR = &commands.FullHandler{
Func: wrapCommand(fnLoginQR),
Name: "login-qr",
Aliases: []string{"login"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Discord account by scanning a QR code.",
},
}
func fnLoginQR(ce *WrappedCommandEvent) {
if ce.User.IsLoggedIn() {
ce.Reply("You're already logged in")
return
}
client, err := remoteauth.New()
if err != nil {
ce.Reply("Failed to prepare login: %v", err)
return
}
qrChan := make(chan string)
doneChan := make(chan struct{})
var qrCodeEvent id.EventID
go func() {
code := <-qrChan
resp := sendQRCode(ce, code)
qrCodeEvent = resp
}()
ctx := context.Background()
if err = client.Dial(ctx, qrChan, doneChan); err != nil {
close(qrChan)
close(doneChan)
ce.Reply("Error connecting to login websocket: %v", err)
return
}
<-doneChan
if qrCodeEvent != "" {
_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)
}
user, err := client.Result()
if err != nil || len(user.Token) == 0 {
if restErr := (&discordgo.RESTError{}); errors.As(err, &restErr) &&
restErr.Response.StatusCode == http.StatusBadRequest &&
bytes.Contains(restErr.ResponseBody, []byte("captcha-required")) {
ce.Reply("Error logging in: %v\n\nCAPTCHAs are currently not supported - use token login instead", err)
} else {
ce.Reply("Error logging in: %v", err)
}
return
} else if err = ce.User.Login(user.Token); err != nil {
ce.Reply("Error connecting after login: %v", err)
return
}
ce.User.Lock()
ce.User.DiscordID = user.UserID
ce.User.Update()
ce.User.Unlock()
ce.Reply("Successfully logged in as @%s", user.Username)
}
func sendQRCode(ce *WrappedCommandEvent, code string) id.EventID {
url, ok := uploadQRCode(ce, code)
if !ok {
return ""
}
content := event.MessageEventContent{
MsgType: event.MsgImage,
Body: code,
URL: url.CUString(),
}
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
if err != nil {
ce.Log.Errorfln("Failed to send QR code: %v", err)
return ""
}
return resp.EventID
}
func uploadQRCode(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
if err != nil {
ce.Log.Errorln("Failed to encode QR code:", err)
ce.Reply("Failed to encode QR code: %v", err)
return id.ContentURI{}, false
}
resp, err := ce.Bot.UploadBytes(qrCode, "image/png")
if err != nil {
ce.Log.Errorln("Failed to upload QR code:", err)
ce.Reply("Failed to upload QR code: %v", err)
return id.ContentURI{}, false
}
return resp.ContentURI, true
}
var cmdLogout = &commands.FullHandler{
Func: wrapCommand(fnLogout),
Name: "logout",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Forget the stored Discord auth token.",
},
}
func fnLogout(ce *WrappedCommandEvent) {
wasLoggedIn := ce.User.DiscordID != ""
ce.User.Logout(false)
if wasLoggedIn {
ce.Reply("Logged out successfully.")
} else {
ce.Reply("You weren't logged in, but data was re-cleared just to be safe.")
}
}
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`)", ce.User.Session.State.User.Username, ce.User.DiscordID)
}
}
var cmdDisconnect = &commands.FullHandler{
Func: wrapCommand(fnDisconnect),
Name: "disconnect",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Disconnect from Discord (without logging out)",
},
RequiresLogin: true,
}
func fnDisconnect(ce *WrappedCommandEvent) {
if !ce.User.Connected() {
ce.Reply("You're already not connected")
} else if err := ce.User.Disconnect(); err != nil {
ce.Reply("Error while disconnecting: %v", err)
} else {
ce.Reply("Successfully disconnected")
}
}
var cmdReconnect = &commands.FullHandler{
Func: wrapCommand(fnReconnect),
Name: "reconnect",
Aliases: []string{"connect"},
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Reconnect to Discord after disconnecting",
},
RequiresLogin: true,
}
func fnReconnect(ce *WrappedCommandEvent) {
if ce.User.Connected() {
ce.Reply("You're already connected")
} else if err := ce.User.Connect(); err != nil {
ce.Reply("Error while reconnecting: %v", err)
} else {
ce.Reply("Successfully reconnected")
}
}
var cmdRejoinSpace = &commands.FullHandler{
Func: wrapCommand(fnRejoinSpace),
Name: "rejoin-space",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Ask the bridge for an invite to a space you left",
Args: "<_guild ID_/main/dms>",
},
RequiresLogin: true,
}
func fnRejoinSpace(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
return
}
user := ce.User
if ce.Args[0] == "main" {
user.ensureInvited(nil, user.GetSpaceRoom(), false, true)
ce.Reply("Invited you to your main space ([link](%s))", user.GetSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if ce.Args[0] == "dms" {
user.ensureInvited(nil, user.GetDMSpaceRoom(), false, true)
ce.Reply("Invited you to your DM space ([link](%s))", user.GetDMSpaceRoom().URI(ce.Bridge.AS.HomeserverDomain).MatrixToURL())
} else if _, err := strconv.Atoi(ce.Args[0]); err == nil {
ce.Reply("Rejoining guild spaces is not yet implemented")
} else {
ce.Reply("**Usage**: `$cmdprefix rejoin-space <guild ID/main/dms>`")
return
}
}
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{
Func: wrapCommand(fnGuilds),
Name: "guilds",
Aliases: []string{"servers", "guild", "server"},
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Guild bridging management",
Args: "<status/bridge/unbridge/bridging-mode> [_guild ID_] [...]",
},
RequiresLogin: true,
}
const smallGuildsHelp = "**Usage**: `$cmdprefix guilds <help/status/bridge/unbridge> [guild ID] [...]`"
const fullGuildsHelp = smallGuildsHelp + `
* **help** - View this help message.
* **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.
* **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.`
func fnGuilds(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply(fullGuildsHelp)
return
}
subcommand := strings.ToLower(ce.Args[0])
ce.Args = ce.Args[1:]
switch subcommand {
case "status", "list":
fnListGuilds(ce)
case "bridge":
fnBridgeGuild(ce)
case "unbridge", "delete":
fnUnbridgeGuild(ce)
case "bridging-mode", "mode":
fnGuildBridgingMode(ce)
case "help":
ce.Reply(fullGuildsHelp)
default:
ce.Reply("Unknown subcommand `%s`\n\n"+smallGuildsHelp, subcommand)
}
}
func fnListGuilds(ce *WrappedCommandEvent) {
var items []string
for _, userGuild := range ce.User.GetPortals() {
guild := ce.Bridge.GetGuildByID(userGuild.DiscordID, false)
if guild == nil {
continue
}
var avatarHTML string
if !guild.AvatarURL.IsEmpty() {
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, guild.BridgingMode.Description()))
}
if len(items) == 0 {
ce.Reply("No guilds found")
} else {
ce.ReplyAdvanced(fmt.Sprintf("<p>List of guilds:</p><ul>%s</ul>", strings.Join(items, "")), false, true)
}
}
func fnBridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 || len(ce.Args) > 2 {
ce.Reply("**Usage**: `$cmdprefix guilds bridge <guild ID> [--entire]")
} else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
ce.Reply("Error bridging guild: %v", err)
} else {
ce.Reply("Successfully bridged guild")
}
}
func fnUnbridgeGuild(ce *WrappedCommandEvent) {
if len(ce.Args) != 1 {
ce.Reply("**Usage**: `$cmdprefix guilds unbridge <guild ID>")
} else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
ce.Reply("Error unbridging guild: %v", err)
} else {
ce.Reply("Successfully unbridged guild")
}
}
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(ce.User)
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 cmdCreatePortal = &commands.FullHandler{
Func: wrapCommand(fnCreatePortal),
Name: "create-portal",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Create a portal for a specific channel",
Args: "<_channel ID_>",
},
RequiresLogin: true,
}
func fnCreatePortal(ce *WrappedCommandEvent) {
meta, err := ce.User.Session.Channel(ce.Args[0])
if err != nil {
ce.Reply("Failed to get channel info: %v", err)
return
} else if meta == nil {
ce.Reply("Channel not found")
return
} else if !ce.User.channelIsBridgeable(meta) {
ce.Reply("That channel can't be bridged")
return
}
portal := ce.User.GetPortalByMeta(meta)
if portal.Guild != nil && portal.Guild.BridgingMode == database.GuildBridgeNothing {
ce.Reply("That guild is set to not bridge any messages. Bridge the guild with `$cmdprefix guilds bridge %s` first", portal.Guild.ID)
return
} else if portal.MXID != "" {
ce.Reply("That channel is already bridged: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
return
}
err = portal.CreateMatrixRoom(ce.User, meta)
if err != nil {
ce.Reply("Failed to create portal: %v", err)
} else {
ce.Reply("Portal created: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
}
}
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{
Func: wrapCommand(fnDeleteAllPortals),
Name: "delete-all-portals",
Help: commands.HelpMeta{
Section: commands.HelpSectionAdmin,
Description: "Delete all portals.",
},
RequiresAdmin: true,
}
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortals()
guilds := ce.Bridge.GetAllGuilds()
if len(portals) == 0 && len(guilds) == 0 {
ce.Reply("Didn't find any portals")
return
}
leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
Reason: "Deleting portal",
UserID: ce.User.MXID,
})
}
}
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent()
leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
if len(mxid) > 0 {
_, _ = intent.LeaveRoom(mxid)
_, _ = intent.ForgetRoom(mxid)
}
}
}
ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
for _, portal := range portals {
portal.Delete()
leave(portal.MXID, portal.MainIntent())
}
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() {
for _, portal := range portals {
portal.cleanup(false)
}
ce.Reply("Finished background cleanup of deleted portal rooms.")
}()
}

View file

@ -1,318 +0,0 @@
// 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"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/google/shlex"
"maunium.net/go/mautrix/bridge/commands"
)
var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
var cmdCommands = &commands.FullHandler{
Func: wrapCommand(fnCommands),
Name: "commands",
Aliases: []string{"cmds", "cs"},
Help: commands.HelpMeta{
Section: HelpSectionDiscordBots,
Description: "View parameters of bot interaction commands on Discord",
Args: "search <_query_> OR help <_command_>",
},
RequiresPortal: true,
RequiresLogin: true,
}
var cmdExec = &commands.FullHandler{
Func: wrapCommand(fnExec),
Name: "exec",
Aliases: []string{"command", "cmd", "c", "exec", "e"},
Help: commands.HelpMeta{
Section: HelpSectionDiscordBots,
Description: "Run bot interaction commands on Discord",
Args: "<_command_> [_arg=value ..._]",
},
RequiresLogin: true,
RequiresPortal: true,
}
func (portal *Portal) getCommand(user *User, command string) (*discordgo.ApplicationCommand, error) {
portal.commandsLock.Lock()
defer portal.commandsLock.Unlock()
cmd, ok := portal.commands[command]
if !ok {
results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command)
if err != nil {
return nil, err
}
for _, result := range results {
if result.Name == command {
portal.commands[result.Name] = result
cmd = result
break
}
}
if cmd == nil {
return nil, nil
}
}
return cmd, nil
}
func getCommandOptionTypeName(optType discordgo.ApplicationCommandOptionType) string {
switch optType {
case discordgo.ApplicationCommandOptionSubCommand:
return "subcommand"
case discordgo.ApplicationCommandOptionSubCommandGroup:
return "subcommand group (unsupported)"
case discordgo.ApplicationCommandOptionString:
return "string"
case discordgo.ApplicationCommandOptionInteger:
return "integer"
case discordgo.ApplicationCommandOptionBoolean:
return "boolean"
case discordgo.ApplicationCommandOptionUser:
return "user (unsupported)"
case discordgo.ApplicationCommandOptionChannel:
return "channel (unsupported)"
case discordgo.ApplicationCommandOptionRole:
return "role (unsupported)"
case discordgo.ApplicationCommandOptionMentionable:
return "mentionable (unsupported)"
case discordgo.ApplicationCommandOptionNumber:
return "number"
case discordgo.ApplicationCommandOptionAttachment:
return "attachment (unsupported)"
default:
return fmt.Sprintf("unknown type %d", optType)
}
}
func parseCommandOptionValue(optType discordgo.ApplicationCommandOptionType, value string) (any, error) {
switch optType {
case discordgo.ApplicationCommandOptionSubCommandGroup:
return nil, fmt.Errorf("subcommand groups aren't supported")
case discordgo.ApplicationCommandOptionString:
return value, nil
case discordgo.ApplicationCommandOptionInteger:
return strconv.ParseInt(value, 10, 64)
case discordgo.ApplicationCommandOptionBoolean:
return strconv.ParseBool(value)
case discordgo.ApplicationCommandOptionUser:
return nil, fmt.Errorf("user options aren't supported")
case discordgo.ApplicationCommandOptionChannel:
return nil, fmt.Errorf("channel options aren't supported")
case discordgo.ApplicationCommandOptionRole:
return nil, fmt.Errorf("role options aren't supported")
case discordgo.ApplicationCommandOptionMentionable:
return nil, fmt.Errorf("mentionable options aren't supported")
case discordgo.ApplicationCommandOptionNumber:
return strconv.ParseFloat(value, 64)
case discordgo.ApplicationCommandOptionAttachment:
return nil, fmt.Errorf("attachment options aren't supported")
default:
return nil, fmt.Errorf("unknown option type %d", optType)
}
}
func indent(text, with string) string {
split := strings.Split(text, "\n")
for i, part := range split {
split[i] = with + part
}
return strings.Join(split, "\n")
}
func formatOption(opt *discordgo.ApplicationCommandOption) string {
argText := fmt.Sprintf("* `%s`: %s", opt.Name, getCommandOptionTypeName(opt.Type))
if strings.ToLower(opt.Description) != opt.Name {
argText += fmt.Sprintf(" - %s", opt.Description)
}
if opt.Required {
argText += " (required)"
}
if len(opt.Options) > 0 {
subopts := make([]string, len(opt.Options))
for i, subopt := range opt.Options {
subopts[i] = indent(formatOption(subopt), " ")
}
argText += "\n" + strings.Join(subopts, "\n")
}
return argText
}
func formatCommand(cmd *discordgo.ApplicationCommand) string {
baseText := fmt.Sprintf("$cmdprefix exec %s", cmd.Name)
if len(cmd.Options) > 0 {
args := make([]string, len(cmd.Options))
argPlaceholder := "[arg=value ...]"
for i, opt := range cmd.Options {
args[i] = formatOption(opt)
if opt.Required {
argPlaceholder = "<arg=value ...>"
}
}
baseText = fmt.Sprintf("`%s %s` - %s\n%s", baseText, argPlaceholder, cmd.Description, strings.Join(args, "\n"))
} else {
baseText = fmt.Sprintf("`%s` - %s", baseText, cmd.Description)
}
return baseText
}
func parseCommandOptions(opts []*discordgo.ApplicationCommandOption, subcommands []string, namedArgs map[string]string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
subcommandDone := false
for _, opt := range opts {
optRes := &discordgo.ApplicationCommandOptionInput{
Type: opt.Type,
Name: opt.Name,
}
if opt.Type == discordgo.ApplicationCommandOptionSubCommand {
if !subcommandDone && len(subcommands) > 0 && subcommands[0] == opt.Name {
subcommandDone = true
optRes.Options, err = parseCommandOptions(opt.Options, subcommands[1:], namedArgs)
if err != nil {
err = fmt.Errorf("error parsing subcommand %s: %v", opt.Name, err)
break
}
subcommands = subcommands[1:]
} else {
continue
}
} else if argVal, ok := namedArgs[opt.Name]; ok {
optRes.Value, err = parseCommandOptionValue(opt.Type, argVal)
if err != nil {
err = fmt.Errorf("error parsing parameter %s: %v", opt.Name, err)
break
}
} else if opt.Required {
switch opt.Type {
case discordgo.ApplicationCommandOptionSubCommandGroup, discordgo.ApplicationCommandOptionUser,
discordgo.ApplicationCommandOptionChannel, discordgo.ApplicationCommandOptionRole,
discordgo.ApplicationCommandOptionMentionable, discordgo.ApplicationCommandOptionAttachment:
err = fmt.Errorf("missing required parameter %s (which is not supported by the bridge)", opt.Name)
default:
err = fmt.Errorf("missing required parameter %s", opt.Name)
}
break
} else {
continue
}
res = append(res, optRes)
}
if len(subcommands) > 0 {
err = fmt.Errorf("unparsed subcommands left over (did you forget quoting for parameters with spaces?)")
}
return
}
func executeCommand(cmd *discordgo.ApplicationCommand, args []string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
namedArgs := map[string]string{}
n := 0
for _, arg := range args {
name, value, isNamed := strings.Cut(arg, "=")
if isNamed {
namedArgs[name] = value
} else {
args[n] = arg
n++
}
}
return parseCommandOptions(cmd.Options, args[:n], namedArgs)
}
func fnCommands(ce *WrappedCommandEvent) {
if len(ce.Args) < 2 {
ce.Reply("**Usage**: `$cmdprefix commands search <_query_>` OR `$cmdprefix commands help <_command_>`")
return
}
subcmd := strings.ToLower(ce.Args[0])
if subcmd == "search" {
results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1])
if err != nil {
ce.Reply("Error searching for commands: %v", err)
return
}
formatted := make([]string, len(results))
ce.Portal.commandsLock.Lock()
for i, result := range results {
ce.Portal.commands[result.Name] = result
formatted[i] = indent(formatCommand(result), " ")
formatted[i] = "*" + formatted[i][1:]
}
ce.Portal.commandsLock.Unlock()
ce.Reply("Found results:\n" + strings.Join(formatted, "\n"))
} else if subcmd == "help" {
command := strings.ToLower(ce.Args[1])
cmd, err := ce.Portal.getCommand(ce.User, command)
if err != nil {
ce.Reply("Error searching for commands: %v", err)
} else if cmd == nil {
ce.Reply("Command %q not found", command)
} else {
ce.Reply(formatCommand(cmd))
}
}
}
func fnExec(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage**: `$cmdprefix exec <command> [arg=value ...]`")
return
}
args, err := shlex.Split(ce.RawArgs)
if err != nil {
ce.Reply("Error parsing args with shlex: %v", err)
return
}
command := strings.ToLower(args[0])
cmd, err := ce.Portal.getCommand(ce.User, command)
if err != nil {
ce.Reply("Error searching for commands: %v", err)
} else if cmd == nil {
ce.Reply("Command %q not found", command)
} else if options, err := executeCommand(cmd, args[1:]); err != nil {
ce.Reply("Error parsing arguments: %v\n\n**Usage:** "+formatCommand(cmd), err)
} else {
nonce := generateNonce()
ce.User.pendingInteractionsLock.Lock()
ce.User.pendingInteractions[nonce] = ce
ce.User.pendingInteractionsLock.Unlock()
err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce)
if err != nil {
ce.Reply("Error sending interaction: %v", err)
ce.User.pendingInteractionsLock.Lock()
delete(ce.User.pendingInteractions, nonce)
ce.User.pendingInteractionsLock.Unlock()
} else {
go func() {
time.Sleep(10 * time.Second)
ce.User.pendingInteractionsLock.Lock()
if _, stillWaiting := ce.User.pendingInteractions[nonce]; stillWaiting {
delete(ce.User.pendingInteractions, nonce)
ce.Reply("Timed out waiting for interaction success")
}
ce.User.pendingInteractionsLock.Unlock()
}()
}
}
}

View file

@ -1,237 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 config
import (
"errors"
"fmt"
"strings"
"text/template"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridge/bridgeconfig"
)
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
ChannelNameTemplate string `yaml:"channel_name_template"`
GuildNameTemplate string `yaml:"guild_name_template"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
PublicAddress string `yaml:"public_address"`
AvatarProxyKey string `yaml:"avatar_proxy_key"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
RestrictedRooms bool `yaml:"restricted_rooms"`
AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"`
EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"`
MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
CustomEmojiReactions bool `yaml:"custom_emoji_reactions"`
DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
FederateRooms bool `yaml:"federate_rooms"`
PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
CacheMedia string `yaml:"cache_media"`
DirectMedia DirectMedia `yaml:"direct_media"`
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"`
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
Backfill struct {
Limits struct {
Initial BackfillLimitPart `yaml:"initial"`
Missed BackfillLimitPart `yaml:"missed"`
} `yaml:"forward_limits"`
MaxGuildMembers int `yaml:"max_guild_members"`
} `yaml:"backfill"`
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
channelNameTemplate *template.Template `yaml:"-"`
guildNameTemplate *template.Template `yaml:"-"`
}
type DirectMedia struct {
Enabled bool `yaml:"enabled"`
ServerName string `yaml:"server_name"`
WellKnownResponse string `yaml:"well_known_response"`
AllowProxy bool `yaml:"allow_proxy"`
ServerKey string `yaml:"server_key"`
}
type BackfillLimitPart struct {
DM int `yaml:"dm"`
Channel int `yaml:"channel"`
Thread int `yaml:"thread"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
return bc.MessageStatusEvents
}
func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
return bc.MessageErrorNotices
}
func boolToInt(val bool) int {
if val {
return 1
}
return 0
}
func (bc *BridgeConfig) Validate() error {
_, hasWildcard := bc.Permissions["*"]
_, hasExampleDomain := bc.Permissions["example.com"]
_, hasExampleUser := bc.Permissions["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
if len(bc.Permissions) <= exampleLen {
return errors.New("bridge.permissions not configured")
}
return nil
}
type umBridgeConfig BridgeConfig
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
return fmt.Errorf("username template is missing user ID placeholder")
}
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate)
if err != nil {
return err
}
bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate)
if err != nil {
return err
}
return nil
}
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
return bc.DoublePuppetConfig
}
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) FormatUsername(userID string) string {
var buffer strings.Builder
_ = bc.usernameTemplate.Execute(&buffer, userID)
return buffer.String()
}
type DisplaynameParams struct {
*discordgo.User
Webhook bool
Application bool
}
func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
User: user,
Webhook: webhook,
Application: application,
})
return buffer.String()
}
type ChannelNameParams struct {
Name string
ParentName string
GuildName string
NSFW bool
Type discordgo.ChannelType
}
func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string {
var buffer strings.Builder
_ = bc.channelNameTemplate.Execute(&buffer, params)
return buffer.String()
}
type GuildNameParams struct {
Name string
}
func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string {
var buffer strings.Builder
_ = bc.guildNameTemplate.Execute(&buffer, params)
return buffer.String()
}

View file

@ -1,149 +0,0 @@
// 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 config
import (
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/federation"
)
func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Str, "bridge", "channel_name_template")
helper.Copy(up.Str, "bridge", "guild_name_template")
if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
updatedPrivateChatPortalMeta := "default"
if legacyPrivateChatPortalMeta == "true" {
updatedPrivateChatPortalMeta = "always"
}
helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
} else {
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
}
helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit")
helper.Copy(up.Str|up.Null, "bridge", "public_address")
if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" {
helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key")
} else {
helper.Copy(up.Str, "bridge", "avatar_proxy_key")
}
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "delivery_receipts")
helper.Copy(up.Bool, "bridge", "message_status_events")
helper.Copy(up.Bool, "bridge", "message_error_notices")
helper.Copy(up.Bool, "bridge", "restricted_rooms")
helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open")
helper.Copy(up.Bool, "bridge", "embed_fields_as_tables")
helper.Copy(up.Bool, "bridge", "mute_channels_on_create")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "custom_emoji_reactions")
helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Bool, "bridge", "prefix_webhook_messages")
helper.Copy(up.Bool, "bridge", "enable_webhook_avatars")
helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload")
helper.Copy(up.Str, "bridge", "cache_media")
helper.Copy(up.Bool, "bridge", "direct_media", "enabled")
helper.Copy(up.Str, "bridge", "direct_media", "server_name")
helper.Copy(up.Str|up.Null, "bridge", "direct_media", "well_known_response")
helper.Copy(up.Bool, "bridge", "direct_media", "allow_proxy")
if serverKey, ok := helper.Get(up.Str, "bridge", "direct_media", "server_key"); !ok || serverKey == "generate" {
serverKey = federation.GenerateSigningKey().SynapseString()
helper.Set(up.Str, serverKey, "bridge", "direct_media", "server_key")
} else {
helper.Copy(up.Str, "bridge", "direct_media", "server_key")
}
helper.Copy(up.Str, "bridge", "animated_sticker", "target")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "backfill", "enabled")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread")
helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
helper.Copy(up.Map, "bridge", "permissions")
//helper.Copy(up.Bool, "bridge", "relay", "enabled")
//helper.Copy(up.Bool, "bridge", "relay", "admin_only")
//helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
var SpacedBlocks = [][]string{
{"homeserver", "software"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"bridge"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
//{"bridge", "relay"},
{"logging"},
}

View file

@ -1,72 +0,0 @@
package main
import (
"maunium.net/go/mautrix/id"
)
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
puppet.CustomMXID = mxid
puppet.AccessToken = accessToken
puppet.Update()
err := puppet.StartCustomMXID(false)
if err != nil {
return err
}
// TODO leave rooms with default puppet
return nil
}
func (puppet *Puppet) ClearCustomMXID() {
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
puppet.bridge.puppetsLock.Lock()
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
}
puppet.bridge.puppetsLock.Unlock()
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
if save {
puppet.Update()
}
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
if err != nil {
puppet.ClearCustomMXID()
return err
}
puppet.bridge.puppetsLock.Lock()
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
puppet.bridge.puppetsLock.Unlock()
if puppet.AccessToken != newAccessToken {
puppet.AccessToken = newAccessToken
puppet.Update()
}
puppet.customIntent = newIntent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
return nil
}
func (user *User) tryAutomaticDoublePuppeting() {
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
return
}
user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetByID(user.DiscordID)
if len(puppet.CustomMXID) > 0 {
user.log.Debug().Msg("User already has double-puppeting enabled")
// Custom puppet already enabled
return
}
puppet.CustomMXID = user.MXID
err := puppet.StartCustomMXID(true)
if err != nil {
user.log.Warn().Err(err).Msg("Failed to login with shared secret")
} else {
// TODO leave rooms with default puppet
user.log.Debug().Msg("Successfully automatically enabled custom puppet")
}
}

View file

@ -1,76 +0,0 @@
package database
import (
_ "embed"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"maunium.net/go/maulogger/v2"
"go.mau.fi/mautrix-discord/database/upgrades"
)
type Database struct {
*dbutil.Database
User *UserQuery
Portal *PortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Thread *ThreadQuery
Reaction *ReactionQuery
Guild *GuildQuery
Role *RoleQuery
File *FileQuery
}
func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
db := &Database{Database: baseDB}
db.UpgradeTable = upgrades.Table
db.User = &UserQuery{
db: db,
log: log.Sub("User"),
}
db.Portal = &PortalQuery{
db: db,
log: log.Sub("Portal"),
}
db.Puppet = &PuppetQuery{
db: db,
log: log.Sub("Puppet"),
}
db.Message = &MessageQuery{
db: db,
log: log.Sub("Message"),
}
db.Thread = &ThreadQuery{
db: db,
log: log.Sub("Thread"),
}
db.Reaction = &ReactionQuery{
db: db,
log: log.Sub("Reaction"),
}
db.Guild = &GuildQuery{
db: db,
log: log.Sub("Guild"),
}
db.Role = &RoleQuery{
db: db,
log: log.Sub("Role"),
}
db.File = &FileQuery{
db: db,
log: log.Sub("File"),
}
return db
}
func strPtr[T ~string](val T) *string {
if val == "" {
return nil
}
valStr := string(val)
return &valStr
}

View file

@ -1,138 +0,0 @@
package database
import (
"database/sql"
"encoding/json"
"errors"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
)
type FileQuery struct {
db *Database
log log.Logger
}
// language=postgresql
const (
fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
fileInsert = `
INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
)
func (fq *FileQuery) New() *File {
return &File{
db: fq.db,
log: fq.log,
}
}
func (fq *FileQuery) Get(url string, encrypted bool) *File {
query := fileSelect + " WHERE url=$1 AND encrypted=$2"
return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
}
func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1"
return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
}
type File struct {
db *Database
log log.Logger
URL string
Encrypted bool
MXC id.ContentURI
ID string
EmojiName string
Size int
Width int
Height int
MimeType string
DecryptionInfo *attachment.EncryptedFile
Timestamp time.Time
}
func (f *File) Scan(row dbutil.Scannable) *File {
var fileID, emojiName, decryptionInfo sql.NullString
var width, height sql.NullInt32
var timestamp int64
var mxc string
err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, &timestamp)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
f.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
f.ID = fileID.String
f.EmojiName = emojiName.String
f.Timestamp = time.UnixMilli(timestamp).UTC()
f.Width = int(width.Int32)
f.Height = int(height.Int32)
f.MXC, err = id.ParseContentURI(mxc)
if err != nil {
f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err)
panic(err)
}
if decryptionInfo.Valid {
err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo)
if err != nil {
f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err)
panic(err)
}
}
return f
}
func positiveIntToNullInt32(val int) (ptr sql.NullInt32) {
if val > 0 {
ptr.Valid = true
ptr.Int32 = int32(val)
}
return
}
func (f *File) Insert(txn dbutil.Execable) {
if txn == nil {
txn = f.db
}
var decryptionInfoStr sql.NullString
if f.DecryptionInfo != nil {
decryptionInfo, err := json.Marshal(f.DecryptionInfo)
if err != nil {
f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err)
panic(err)
}
decryptionInfoStr.Valid = true
decryptionInfoStr.String = string(decryptionInfo)
}
_, err := txn.Exec(fileInsert,
f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
decryptionInfoStr, f.Timestamp.UnixMilli(),
)
if err != nil {
f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err)
panic(err)
}
}
func (f *File) Delete() {
_, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted)
if err != nil {
f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err)
panic(err)
}
}

View file

@ -1,194 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"strings"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
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 {
db *Database
log log.Logger
}
const (
guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
)
func (gq *GuildQuery) New() *Guild {
return &Guild{
db: gq.db,
log: gq.log,
}
}
func (gq *GuildQuery) GetByID(dcid string) *Guild {
query := guildSelect + " WHERE dcid=$1"
return gq.New().Scan(gq.db.QueryRow(query, dcid))
}
func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild {
query := guildSelect + " WHERE mxid=$1"
return gq.New().Scan(gq.db.QueryRow(query, mxid))
}
func (gq *GuildQuery) GetAll() []*Guild {
rows, err := gq.db.Query(guildSelect)
if err != nil {
gq.log.Errorln("Failed to query guilds:", err)
return nil
}
var guilds []*Guild
for rows.Next() {
guild := gq.New().Scan(rows)
if guild != nil {
guilds = append(guilds, guild)
}
}
return guilds
}
type Guild struct {
db *Database
log log.Logger
ID string
MXID id.RoomID
PlainName string
Name string
NameSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
BridgingMode GuildBridgingMode
}
func (g *Guild) Scan(row dbutil.Scannable) *Guild {
var mxid sql.NullString
var avatarURL string
err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
g.log.Errorln("Database scan failed:", err)
panic(err)
}
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.AvatarURL, _ = id.ParseContentURI(avatarURL)
return g
}
func (g *Guild) mxidPtr() *id.RoomID {
if g.MXID != "" {
return &g.MXID
}
return nil
}
func (g *Guild) Insert() {
query := `
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)
`
_, 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 {
g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
panic(err)
}
}
func (g *Guild) Update() {
query := `
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
`
_, 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 {
g.log.Warnfln("Failed to update %s: %v", g.ID, err)
panic(err)
}
}
func (g *Guild) Delete() {
_, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID)
if err != nil {
g.log.Warnfln("Failed to delete %s: %v", g.ID, err)
panic(err)
}
}

View file

@ -1,258 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type MessageQuery struct {
db *Database
log log.Logger
}
const (
messageSelect = "SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid FROM message"
)
func (mq *MessageQuery) New() *Message {
return &Message{
db: mq.db,
log: mq.log,
}
}
func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
if err != nil {
mq.log.Warnfln("Failed to query many messages: %v", err)
panic(err)
} else if rows == nil {
return nil
}
var messages []*Message
for rows.Next() {
messages = append(messages, mq.New().Scan(rows))
}
return messages
}
func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC"
return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
}
func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND timestamp<=$4 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli()))
}
func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID))
}
func (mq *MessageQuery) GetLast(key PortalKey) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 ORDER BY timestamp DESC LIMIT 1"
return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver))
}
func (mq *MessageQuery) DeleteAll(key PortalKey) {
query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
_, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
if err != nil {
mq.log.Warnfln("Failed to delete messages of %s: %v", key, err)
panic(err)
}
}
func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3"
row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid)
if row == nil {
return nil
}
return mq.New().Scan(row)
}
func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)"
if mq.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 2+len(msgs)*8)
placeholders := make([]string, len(msgs))
params[0] = key.ChannelID
params[1] = key.Receiver
for i, msg := range msgs {
baseIndex := 2 + i*8
params[baseIndex] = msg.DiscordID
params[baseIndex+1] = msg.AttachmentID
params[baseIndex+2] = msg.SenderID
params[baseIndex+3] = msg.Timestamp.UnixMilli()
params[baseIndex+4] = msg.editTimestampVal()
params[baseIndex+5] = msg.ThreadID
params[baseIndex+6] = msg.MXID
params[baseIndex+7] = msg.SenderMXID.String()
placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8)
}
_, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil {
mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err)
panic(err)
}
}
type Message struct {
db *Database
log log.Logger
DiscordID string
AttachmentID string
Channel PortalKey
SenderID string
Timestamp time.Time
EditTimestamp time.Time
ThreadID string
MXID id.EventID
SenderMXID id.UserID
}
func (m *Message) DiscordProtoChannelID() string {
if m.ThreadID != "" {
return m.ThreadID
} else {
return m.Channel.ChannelID
}
}
func (m *Message) Scan(row dbutil.Scannable) *Message {
var ts, editTS int64
err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &editTS, &m.ThreadID, &m.MXID, &m.SenderMXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
m.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
if ts != 0 {
m.Timestamp = time.UnixMilli(ts).UTC()
}
if editTS != 0 {
m.EditTimestamp = time.Unix(0, editTS).UTC()
}
return m
}
const messageInsertQuery = `
INSERT INTO message (
dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1)
type MessagePart struct {
AttachmentID string
MXID id.EventID
}
func (m *Message) editTimestampVal() int64 {
if m.EditTimestamp.IsZero() {
return 0
}
return m.EditTimestamp.UnixNano()
}
func (m *Message) MassInsertParts(msgs []MessagePart) {
if len(msgs) == 0 {
return
}
valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)"
if m.db.Dialect == dbutil.SQLite {
valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
}
params := make([]interface{}, 8+len(msgs)*2)
placeholders := make([]string, len(msgs))
params[0] = m.DiscordID
params[1] = m.Channel.ChannelID
params[2] = m.Channel.Receiver
params[3] = m.SenderID
params[4] = m.Timestamp.UnixMilli()
params[5] = m.editTimestampVal()
params[6] = m.ThreadID
params[7] = m.SenderMXID.String()
for i, msg := range msgs {
params[8+i*2] = msg.AttachmentID
params[8+i*2+1] = msg.MXID
placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
}
_, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
if err != nil {
m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err)
panic(err)
}
}
func (m *Message) Insert() {
_, err := m.db.Exec(messageInsertQuery,
m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
if err != nil {
m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
panic(err)
}
}
const editUpdateQuery = `
UPDATE message
SET dc_edit_timestamp=$1
WHERE dcid=$2 AND dc_attachment_id=$3 AND dc_chan_id=$4 AND dc_chan_receiver=$5 AND dc_edit_timestamp<$1
`
func (m *Message) UpdateEditTimestamp(ts time.Time) {
_, err := m.db.Exec(editUpdateQuery, ts.UnixNano(), m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver)
if err != nil {
m.log.Warnfln("Failed to update edit timestamp of %s@%s: %v", m.DiscordID, m.Channel, err)
panic(err)
}
}
func (m *Message) Delete() {
query := "DELETE FROM message WHERE dcid=$1 AND dc_chan_id=$2 AND dc_chan_receiver=$3 AND dc_attachment_id=$4"
_, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)
if err != nil {
m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err)
panic(err)
}
}

View file

@ -1,210 +0,0 @@
package database
import (
"database/sql"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
// language=postgresql
const (
portalSelect = `
SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
FROM portal
`
)
type PortalKey struct {
ChannelID string
Receiver string
}
func NewPortalKey(channelID, receiver string) PortalKey {
return PortalKey{
ChannelID: channelID,
Receiver: receiver,
}
}
func (key PortalKey) String() string {
if key.Receiver == "" {
return key.ChannelID
}
return key.ChannelID + "-" + key.Receiver
}
type PortalQuery struct {
db *Database
log log.Logger
}
func (pq *PortalQuery) New() *Portal {
return &Portal{
db: pq.db,
log: pq.log,
}
}
func (pq *PortalQuery) GetAll() []*Portal {
return pq.getAll(portalSelect)
}
func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal {
return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID)
}
func (pq *PortalQuery) GetByID(key PortalKey) *Portal {
return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver)
}
func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
return pq.get(portalSelect+" WHERE mxid=$1", mxid)
}
func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal {
return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal {
query := portalSelect + " portal WHERE receiver=$1 AND type=$2;"
return pq.getAll(query, receiver, discordgo.ChannelTypeDM)
}
func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {
rows, err := pq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
defer rows.Close()
var portals []*Portal
for rows.Next() {
portals = append(portals, pq.New().Scan(rows))
}
return portals
}
func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
return pq.New().Scan(pq.db.QueryRow(query, args...))
}
type Portal struct {
db *Database
log log.Logger
Key PortalKey
Type discordgo.ChannelType
OtherUserID string
ParentID string
GuildID string
MXID id.RoomID
PlainName string
Name string
NameSet bool
FriendNick bool
Topic string
TopicSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Encrypted bool
InSpace id.RoomID
FirstEventID id.EventID
RelayWebhookID string
RelayWebhookSecret string
}
func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
var chanType int32
var avatarURL string
err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
&mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
&p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
if err != nil {
if err != sql.ErrNoRows {
p.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
p.MXID = id.RoomID(mxid.String)
p.OtherUserID = otherUserID.String
p.GuildID = guildID.String
p.ParentID = parentID.String
p.Type = discordgo.ChannelType(chanType)
p.FirstEventID = id.EventID(firstEventID.String)
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.RelayWebhookID = relayWebhookID.String
p.RelayWebhookSecret = relayWebhookSecret.String
return p
}
func (p *Portal) Insert() {
query := `
INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
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, $19, $20, $21)
`
_, 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)),
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
panic(err)
}
}
func (p *Portal) Update() {
query := `
UPDATE portal
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, friend_nick=$9, topic=$10, topic_set=$11,
avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
relay_webhook_id=$18, relay_webhook_secret=$19
WHERE dcid=$20 AND receiver=$21
`
_, err := p.db.Exec(query,
p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.Encrypted, p.InSpace, p.FirstEventID.String(),
strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
p.Key.ChannelID, p.Key.Receiver)
if err != nil {
p.log.Warnfln("Failed to update %s: %v", p.Key, err)
panic(err)
}
}
func (p *Portal) Delete() {
query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2"
_, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver)
if err != nil {
p.log.Warnfln("Failed to delete %s: %v", p.Key, err)
panic(err)
}
}

View file

@ -1,151 +0,0 @@
package database
import (
"database/sql"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
const (
puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
" contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" +
" FROM puppet "
)
type PuppetQuery struct {
db *Database
log log.Logger
}
func (pq *PuppetQuery) New() *Puppet {
return &Puppet{
db: pq.db,
log: pq.log,
}
}
func (pq *PuppetQuery) Get(id string) *Puppet {
return pq.get(puppetSelect+" WHERE id=$1", id)
}
func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid)
}
func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet {
return pq.New().Scan(pq.db.QueryRow(query, args...))
}
func (pq *PuppetQuery) GetAll() []*Puppet {
return pq.getAll(puppetSelect)
}
func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet {
return pq.getAll(puppetSelect + " WHERE custom_mxid<>''")
}
func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet {
rows, err := pq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
defer rows.Close()
var puppets []*Puppet
for rows.Next() {
puppets = append(puppets, pq.New().Scan(rows))
}
return puppets
}
type Puppet struct {
db *Database
log log.Logger
ID string
Name string
NameSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
ContactInfoSet bool
GlobalName string
Username string
Discriminator string
IsBot bool
IsWebhook bool
IsApplication bool
CustomMXID id.UserID
AccessToken string
NextBatch string
}
func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
var avatarURL string
var customMXID, accessToken, nextBatch sql.NullString
err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet,
&p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch)
if err != nil {
if err != sql.ErrNoRows {
p.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
p.AvatarURL, _ = id.ParseContentURI(avatarURL)
p.CustomMXID = id.UserID(customMXID.String)
p.AccessToken = accessToken.String
p.NextBatch = nextBatch.String
return p
}
func (p *Puppet) Insert() {
query := `
INSERT INTO puppet (
id, name, name_set, avatar, avatar_url, avatar_set, contact_info_set,
global_name, username, discriminator, is_bot, is_webhook, is_application,
custom_mxid, access_token, next_batch
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`
_, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch))
if err != nil {
p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
panic(err)
}
}
func (p *Puppet) Update() {
query := `
UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6,
global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12,
custom_mxid=$13, access_token=$14, next_batch=$15
WHERE id=$16
`
_, err := p.db.Exec(
query,
p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch),
p.ID,
)
if err != nil {
p.log.Warnfln("Failed to update %s: %v", p.ID, err)
panic(err)
}
}

View file

@ -1,124 +0,0 @@
package database
import (
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type ReactionQuery struct {
db *Database
log log.Logger
}
const (
reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction"
)
func (rq *ReactionQuery) New() *Reaction {
return &Reaction{
db: rq.db,
log: rq.log,
}
}
func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction {
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3"
return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID)
}
func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction {
rows, err := rq.db.Query(query, args...)
if err != nil || rows == nil {
return nil
}
var reactions []*Reaction
for rows.Next() {
reactions = append(reactions, rq.New().Scan(rows))
}
return reactions
}
func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction {
query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3 AND dc_sender=$4 AND dc_emoji_name=$5"
return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName)
}
func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction {
query := reactionSelect + " WHERE mxid=$1"
return rq.get(query, mxid)
}
func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction {
row := rq.db.QueryRow(query, args...)
if row == nil {
return nil
}
return rq.New().Scan(row)
}
type Reaction struct {
db *Database
log log.Logger
Channel PortalKey
MessageID string
Sender string
EmojiName string
ThreadID string
MXID id.EventID
FirstAttachmentID string
}
func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
r.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
return r
}
func (r *Reaction) DiscordProtoChannelID() string {
if r.ThreadID != "" {
return r.ThreadID
} else {
return r.Channel.ChannelID
}
}
func (r *Reaction) Insert() {
query := `
INSERT INTO reaction (dc_msg_id, dc_first_attachment_id, dc_sender, dc_emoji_name, dc_chan_id, dc_chan_receiver, dc_thread_id, mxid)
VALUES($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID)
if err != nil {
r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err)
panic(err)
}
}
func (r *Reaction) Delete() {
query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3"
_, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName)
if err != nil {
r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err)
panic(err)
}
}

View file

@ -1,112 +0,0 @@
package database
import (
"database/sql"
"errors"
"github.com/bwmarrin/discordgo"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
)
type RoleQuery struct {
db *Database
log log.Logger
}
// language=postgresql
const (
roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role"
roleUpsert = `
INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (dc_guild_id, dcid) DO UPDATE
SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed,
hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions
`
roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2"
)
func (rq *RoleQuery) New() *Role {
return &Role{
db: rq.db,
log: rq.log,
}
}
func (rq *RoleQuery) GetByID(guildID, dcid string) *Role {
query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2"
return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid))
}
func (rq *RoleQuery) DeleteByID(guildID, dcid string) {
_, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid)
if err != nil {
rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err)
panic(err)
}
}
func (rq *RoleQuery) GetAll(guildID string) []*Role {
rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID)
if err != nil {
rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err)
return nil
}
var roles []*Role
for rows.Next() {
role := rq.New().Scan(rows)
if role != nil {
roles = append(roles, role)
}
}
return roles
}
type Role struct {
db *Database
log log.Logger
GuildID string
discordgo.Role
}
func (r *Role) Scan(row dbutil.Scannable) *Role {
var icon sql.NullString
err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
r.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
r.Icon = icon.String
return r
}
func (r *Role) Upsert(txn dbutil.Execable) {
if txn == nil {
txn = r.db
}
_, err := txn.Exec(roleUpsert, r.GuildID, r.ID, r.Name, strPtr(r.Icon), r.Mentionable, r.Managed, r.Hoist, r.Color, r.Position, r.Permissions)
if err != nil {
r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err)
panic(err)
}
}
func (r *Role) Delete(txn dbutil.Execable) {
if txn == nil {
txn = r.db
}
_, err := txn.Exec(roleDelete, r.GuildID, r.Icon)
if err != nil {
r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err)
panic(err)
}
}

View file

@ -1,111 +0,0 @@
package database
import (
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type ThreadQuery struct {
db *Database
log log.Logger
}
const (
threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread"
)
func (tq *ThreadQuery) New() *Thread {
return &Thread{
db: tq.db,
log: tq.log,
}
}
func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread {
query := threadSelect + " WHERE dcid=$1"
row := tq.db.QueryRow(query, discordID)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread {
query := threadSelect + " WHERE root_msg_mxid=$1"
row := tq.db.QueryRow(query, mxid)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread {
query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1"
row := tq.db.QueryRow(query, mxid)
if row == nil {
return nil
}
return tq.New().Scan(row)
}
type Thread struct {
db *Database
log log.Logger
ID string
ParentID string
RootDiscordID string
RootMXID id.EventID
CreationNoticeMXID id.EventID
}
func (t *Thread) Scan(row dbutil.Scannable) *Thread {
err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
t.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
return t
}
func (t *Thread) Insert() {
query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)"
_, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID)
if err != nil {
t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}
func (t *Thread) Update() {
query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1"
_, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID)
if err != nil {
t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}
func (t *Thread) Delete() {
query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2"
_, err := t.db.Exec(query, t.ID, t.ParentID)
if err != nil {
t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err)
panic(err)
}
}

View file

@ -1,179 +0,0 @@
-- v0 -> v23 (compatible with v19+): Latest revision
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
mxid TEXT UNIQUE,
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
bridging_mode INTEGER NOT NULL
);
CREATE TABLE portal (
dcid TEXT,
receiver TEXT,
other_user_id TEXT,
type INTEGER NOT NULL,
dc_guild_id TEXT,
dc_parent_id TEXT,
-- This is not accessed by the bridge, it's only used for the portal parent foreign key.
-- Only guild channels have parents, but only DMs have a receiver field.
dc_parent_receiver TEXT NOT NULL DEFAULT '',
mxid TEXT UNIQUE,
plain_name TEXT NOT NULL,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
friend_nick BOOLEAN NOT NULL,
topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
encrypted BOOLEAN NOT NULL,
in_space TEXT NOT NULL,
first_event_id TEXT NOT NULL,
relay_webhook_id TEXT,
relay_webhook_secret TEXT,
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_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
);
CREATE TABLE thread (
dcid TEXT PRIMARY KEY,
parent_chan_id TEXT NOT NULL,
root_msg_dcid TEXT NOT NULL,
root_msg_mxid TEXT NOT NULL,
creation_notice_mxid TEXT NOT NULL,
-- This is also not accessed by the bridge.
receiver TEXT NOT NULL DEFAULT '',
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE puppet (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
global_name TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
discriminator TEXT NOT NULL DEFAULT '',
is_bot BOOLEAN NOT NULL DEFAULT false,
is_webhook BOOLEAN NOT NULL DEFAULT false,
is_application BOOLEAN NOT NULL DEFAULT false,
custom_mxid TEXT,
access_token TEXT,
next_batch TEXT
);
CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
dcid TEXT UNIQUE,
discord_token TEXT,
management_room TEXT,
space_room TEXT,
dm_space_room TEXT,
read_state_version INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE user_portal (
discord_id TEXT,
user_mxid TEXT,
type TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY (discord_id, user_mxid),
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
);
CREATE TABLE message (
dcid TEXT,
dc_attachment_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
sender_mxid TEXT NOT NULL DEFAULT '',
PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
CREATE TABLE reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL,
dc_first_attachment_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
CREATE TABLE role (
dc_guild_id TEXT,
dcid TEXT,
name TEXT NOT NULL,
icon TEXT,
mentionable BOOLEAN NOT NULL,
managed BOOLEAN NOT NULL,
hoist BOOLEAN NOT NULL,
color INTEGER NOT NULL,
position INTEGER NOT NULL,
permissions BIGINT NOT NULL,
PRIMARY KEY (dc_guild_id, dcid),
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
);
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View file

@ -1,53 +0,0 @@
-- v2: Rename columns in message-related tables
ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id;
ALTER TABLE portal RENAME COLUMN channel_id TO dcid;
ALTER TABLE "user" RENAME COLUMN id TO dcid;
ALTER TABLE puppet DROP COLUMN enable_presence;
ALTER TABLE puppet DROP COLUMN enable_receipts;
DROP TABLE message;
DROP TABLE reaction;
DROP TABLE attachment;
CREATE TABLE message (
dcid TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
CREATE TABLE reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
CREATE TABLE attachment (
dcid TEXT,
dc_msg_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver),
CONSTRAINT attachment_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
UPDATE portal SET receiver='' WHERE type<>1;

View file

@ -1,73 +0,0 @@
-- v3: Store portal parent metadata for spaces
DROP TABLE guild;
CREATE TABLE guild (
dcid TEXT PRIMARY KEY,
mxid TEXT UNIQUE,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL,
avatar TEXT NOT NULL,
avatar_url TEXT NOT NULL,
avatar_set BOOLEAN NOT NULL,
auto_bridge_channels BOOLEAN NOT NULL
);
CREATE TABLE user_portal (
discord_id TEXT,
user_mxid TEXT,
type TEXT NOT NULL,
in_space BOOLEAN NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY (discord_id, user_mxid),
CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
);
ALTER TABLE portal ADD COLUMN dc_guild_id TEXT;
ALTER TABLE portal ADD COLUMN dc_parent_id TEXT;
ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT '';
ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE;
ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE;
DELETE FROM portal WHERE type IS NULL;
-- only: postgres
ALTER TABLE portal ALTER COLUMN type SET NOT NULL;
ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT '';
ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
-- only: postgres for next 5 lines
ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT;
ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT;
ALTER TABLE puppet RENAME COLUMN display_name TO name;
ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
-- only: postgres for next 2 lines
ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT;
ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT;
ALTER TABLE "user" ADD COLUMN space_room TEXT;
ALTER TABLE "user" ADD COLUMN dm_space_room TEXT;
ALTER TABLE "user" RENAME COLUMN token TO discord_token;
UPDATE message SET timestamp=timestamp*1000;
CREATE TABLE thread (
dcid TEXT PRIMARY KEY,
parent_chan_id TEXT NOT NULL,
root_msg_dcid TEXT NOT NULL,
root_msg_mxid TEXT NOT NULL,
-- This is also not accessed by the bridge.
receiver TEXT NOT NULL DEFAULT '',
CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
);
ALTER TABLE message ADD COLUMN dc_thread_id TEXT;
ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT;
ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT;

View file

@ -1,20 +0,0 @@
-- v4: Fix storing attachments
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey;
ALTER TABLE message DROP CONSTRAINT message_pkey;
ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT '';
ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0;
ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT;
ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT;
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
DROP TABLE attachment;
ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT '';
ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT;
ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0;
ALTER TABLE reaction ADD 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);

View file

@ -1,45 +0,0 @@
-- v4: Fix storing attachments
CREATE TABLE new_message (
dcid TEXT,
dc_attachment_id TEXT,
dc_edit_index INTEGER,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_thread_id TEXT,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message;
INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
DROP TABLE attachment;
DROP TABLE message;
ALTER TABLE new_message RENAME TO message;
CREATE TABLE new_reaction (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT,
dc_first_attachment_id TEXT NOT NULL,
_dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
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
);
INSERT INTO new_reaction (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction;
DROP TABLE reaction;
ALTER TABLE new_reaction RENAME TO reaction;

View file

@ -1,8 +0,0 @@
-- v5: Fix foreign key broken in v4
-- only: postgres
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE reaction ADD 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;

View file

@ -1,2 +0,0 @@
-- v6: Store user read state version
ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0;

View file

@ -1,19 +0,0 @@
-- v7: Store role info
CREATE TABLE role (
dc_guild_id TEXT,
dcid TEXT,
name TEXT NOT NULL,
icon TEXT,
mentionable BOOLEAN NOT NULL,
managed BOOLEAN NOT NULL,
hoist BOOLEAN NOT NULL,
color INTEGER NOT NULL,
position INTEGER NOT NULL,
permissions BIGINT NOT NULL,
PRIMARY KEY (dc_guild_id, dcid),
CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
);

View file

@ -1,9 +0,0 @@
-- v8: Store plain name of channels and guilds
ALTER TABLE guild ADD COLUMN plain_name TEXT;
ALTER TABLE portal ADD COLUMN plain_name TEXT;
UPDATE guild SET plain_name=name;
UPDATE portal SET plain_name=name;
UPDATE portal SET plain_name='' WHERE type=1;
-- only: postgres for next 2 lines
ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL;
ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL;

View file

@ -1,9 +0,0 @@
-- v9: Store more info for proper thread support
ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT '';
UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL;
UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL;
-- only: postgres for next 3 lines
ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT;
ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL;
ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL;

View file

@ -1,2 +0,0 @@
-- v10: Remove double puppet ghosts added while there was a bug in the bridge
DELETE FROM puppet WHERE id='';

View file

@ -1,18 +0,0 @@
-- v11: Cache files copied from Discord to Matrix
CREATE TABLE discord_file (
url TEXT,
encrypted BOOLEAN,
id TEXT,
mxc TEXT NOT NULL,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);

View file

@ -1,4 +0,0 @@
-- v12: Cache mime type for reuploaded files
ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT '';
-- only: postgres
ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT;

View file

@ -1,4 +0,0 @@
-- 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

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

View file

@ -1,7 +0,0 @@
-- 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

@ -1,3 +0,0 @@
-- 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

@ -1,3 +0,0 @@
-- v16: Store whether custom contact info has been set for the puppet
ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,2 +0,0 @@
-- v17: Store whether DM portal name is a friend nickname
ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,4 +0,0 @@
-- v18 (compatible with v15+): Store additional metadata for ghosts
ALTER TABLE puppet ADD COLUMN username TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN discriminator TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,15 +0,0 @@
-- v19: Replace dc_edit_index with dc_edit_timestamp
-- transaction: off
BEGIN;
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE message DROP CONSTRAINT message_pkey;
ALTER TABLE message DROP COLUMN dc_edit_index;
ALTER TABLE reaction DROP COLUMN _dc_first_edit_index;
ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver);
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE;
ALTER TABLE message ADD COLUMN dc_edit_timestamp BIGINT NOT NULL DEFAULT 0;
ALTER TABLE message ALTER COLUMN dc_edit_timestamp DROP DEFAULT;
COMMIT;

View file

@ -1,48 +0,0 @@
-- v19: Replace dc_edit_index with dc_edit_timestamp
-- transaction: off
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
dcid TEXT,
dc_attachment_id TEXT,
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_sender TEXT NOT NULL,
timestamp BIGINT NOT NULL,
dc_edit_timestamp BIGINT NOT NULL,
dc_thread_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
);
INSERT INTO message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid)
SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message;
DROP TABLE message;
ALTER TABLE message_new RENAME TO message;
CREATE TABLE reaction_new (
dc_chan_id TEXT,
dc_chan_receiver TEXT,
dc_msg_id TEXT,
dc_sender TEXT,
dc_emoji_name TEXT,
dc_thread_id TEXT NOT NULL,
dc_first_attachment_id TEXT NOT NULL,
mxid TEXT NOT NULL UNIQUE,
PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
);
INSERT INTO reaction_new (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, COALESCE(dc_thread_id, ''), dc_first_attachment_id, mxid FROM reaction;
DROP TABLE reaction;
ALTER TABLE reaction_new RENAME TO reaction;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View file

@ -1,2 +0,0 @@
-- v20 (compatible with v19+): Store message sender Matrix user ID
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';

View file

@ -1,3 +0,0 @@
-- v21 (compatible with v19+): Store global displayname and is webhook status for puppets
ALTER TABLE puppet ADD COLUMN global_name TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN is_webhook BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,26 +0,0 @@
-- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache
CREATE TABLE new_discord_file (
url TEXT,
encrypted BOOLEAN,
mxc TEXT NOT NULL,
id TEXT,
emoji_name TEXT,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
mime_type TEXT NOT NULL,
decryption_info jsonb,
timestamp BIGINT NOT NULL,
PRIMARY KEY (url, encrypted)
);
INSERT INTO new_discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
DROP TABLE discord_file;
ALTER TABLE new_discord_file RENAME TO discord_file;
CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);

View file

@ -1,2 +0,0 @@
-- v23 (compatible with v19+): Store is application status for puppets
ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,101 +0,0 @@
package database
import (
"database/sql"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
type UserQuery struct {
db *Database
log log.Logger
}
func (uq *UserQuery) New() *User {
return &User{
db: uq.db,
log: uq.log,
}
}
func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE mxid=$1`
return uq.New().Scan(uq.db.QueryRow(query, userID))
}
func (uq *UserQuery) GetByID(id string) *User {
query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version FROM "user" WHERE dcid=$1`
return uq.New().Scan(uq.db.QueryRow(query, id))
}
func (uq *UserQuery) GetAllWithToken() []*User {
query := `
SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version
FROM "user" WHERE discord_token IS NOT NULL
`
rows, err := uq.db.Query(query)
if err != nil || rows == nil {
return nil
}
var users []*User
for rows.Next() {
user := uq.New().Scan(rows)
if user != nil {
users = append(users, user)
}
}
return users
}
type User struct {
db *Database
log log.Logger
MXID id.UserID
DiscordID string
DiscordToken string
ManagementRoom id.RoomID
SpaceRoom id.RoomID
DMSpaceRoom id.RoomID
ReadStateVersion int
}
func (u *User) Scan(row dbutil.Scannable) *User {
var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion)
if err != nil {
if err != sql.ErrNoRows {
u.log.Errorln("Database scan failed:", err)
panic(err)
}
return nil
}
u.DiscordID = discordID.String
u.DiscordToken = discordToken.String
u.ManagementRoom = id.RoomID(managementRoom.String)
u.SpaceRoom = id.RoomID(spaceRoom.String)
u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String)
return u
}
func (u *User) Insert() {
query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version) VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion)
if err != nil {
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
panic(err)
}
}
func (u *User) Update() {
query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6 WHERE mxid=$7`
_, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, u.MXID)
if err != nil {
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
panic(err)
}
}

View file

@ -1,140 +0,0 @@
package database
import (
"database/sql"
"errors"
"time"
"go.mau.fi/util/dbutil"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
)
const (
UserPortalTypeDM = "dm"
UserPortalTypeGuild = "guild"
UserPortalTypeThread = "thread"
)
type UserPortal struct {
DiscordID string
Type string
Timestamp time.Time
InSpace bool
}
func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
var ts int64
err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace)
if err != nil {
l.Errorln("Error scanning user portal:", err)
panic(err)
}
up.Timestamp = time.UnixMilli(ts).UTC()
return &up
}
func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
var ups []UserPortal
for rows.Next() {
up := UserPortal{}.Scan(u.log, rows)
if up != nil {
ups = append(ups, *up)
}
}
return ups
}
func (db *Database) GetUsersInPortal(channelID string) []id.UserID {
rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID)
if err != nil {
db.Portal.log.Errorln("Failed to get users in portal:", err)
}
var users []id.UserID
for rows.Next() {
var mxid id.UserID
err = rows.Scan(&mxid)
if err != nil {
db.Portal.log.Errorln("Failed to scan user in portal:", err)
} else {
users = append(users, mxid)
}
}
return users
}
func (u *User) GetPortals() []UserPortal {
rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
if err != nil {
u.log.Errorln("Failed to get portals:", err)
panic(err)
}
return u.scanUserPortals(rows)
}
func (u *User) IsInSpace(discordID string) (isIn bool) {
query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
return
}
func (u *User) IsInPortal(discordID string) (isIn bool) {
query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
return
}
func (u *User) MarkInPortal(portal UserPortal) {
query := `
INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (discord_id, user_mxid) DO UPDATE
SET timestamp=excluded.timestamp, in_space=excluded.in_space
`
_, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace)
if err != nil {
u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err)
panic(err)
}
}
func (u *User) MarkNotInPortal(discordID string) {
query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
_, err := u.db.Exec(query, u.MXID, discordID)
if err != nil {
u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err)
panic(err)
}
}
func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) {
query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2`
err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers)
if err != nil {
u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err)
panic(err)
}
return
}
func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal {
query := `
DELETE FROM user_portal
WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild')
RETURNING discord_id, type, timestamp, in_space
`
rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli())
if err != nil {
u.log.Errorln("Failed to prune user guild list:", err)
panic(err)
}
return u.scanUserPortals(rows)
}

View file

@ -1,658 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/federation"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
)
type DirectMediaAPI struct {
bridge *DiscordBridge
ks *federation.KeyServer
cfg config.DirectMedia
log zerolog.Logger
proxy http.Client
signatureKey [32]byte
attachmentCache map[AttachmentCacheKey]AttachmentCacheValue
attachmentCacheLock sync.Mutex
}
type AttachmentCacheKey struct {
ChannelID uint64
AttachmentID uint64
}
type AttachmentCacheValue struct {
URL string
Expiry time.Time
}
func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI {
if !br.Config.Bridge.DirectMedia.Enabled {
return nil
}
dma := &DirectMediaAPI{
bridge: br,
cfg: br.Config.Bridge.DirectMedia,
log: br.ZLog.With().Str("component", "direct media").Logger(),
proxy: http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: false,
},
Timeout: 60 * time.Second,
},
attachmentCache: make(map[AttachmentCacheKey]AttachmentCacheValue),
}
r := br.AS.Router
parsed, err := federation.ParseSynapseKey(dma.cfg.ServerKey)
if err != nil {
dma.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to parse server key")
os.Exit(11)
return nil
}
dma.signatureKey = sha256.Sum256(parsed.Priv.Seed())
dma.ks = &federation.KeyServer{
KeyProvider: &federation.StaticServerKey{
ServerName: dma.cfg.ServerName,
Key: parsed,
},
WellKnownTarget: dma.cfg.WellKnownResponse,
Version: federation.ServerVersion{
Name: br.Name,
Version: br.Version,
},
}
if dma.ks.WellKnownTarget == "" {
dma.ks.WellKnownTarget = fmt.Sprintf("%s:443", dma.cfg.ServerName)
}
federationRouter := r.PathPrefix("/_matrix/federation").Subrouter()
mediaRouter := r.PathPrefix("/_matrix/media").Subrouter()
clientMediaRouter := r.PathPrefix("/_matrix/client/v1/media").Subrouter()
var reqIDCounter atomic.Uint64
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization")
log := dma.log.With().
Str("remote_addr", r.RemoteAddr).
Str("request_path", r.URL.Path).
Uint64("req_id", reqIDCounter.Add(1)).
Logger()
next.ServeHTTP(w, r.WithContext(log.WithContext(r.Context())))
})
}
mediaRouter.Use(middleware)
federationRouter.Use(middleware)
clientMediaRouter.Use(middleware)
addRoutes := func(version string) {
mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
mediaRouter.HandleFunc("/"+version+"/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
mediaRouter.HandleFunc("/"+version+"/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
mediaRouter.HandleFunc("/"+version+"/upload", dma.UploadNotSupported).Methods(http.MethodPost)
mediaRouter.HandleFunc("/"+version+"/create", dma.UploadNotSupported).Methods(http.MethodPost)
mediaRouter.HandleFunc("/"+version+"/config", dma.UploadNotSupported).Methods(http.MethodGet)
mediaRouter.HandleFunc("/"+version+"/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
}
clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
clientMediaRouter.HandleFunc("/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
clientMediaRouter.HandleFunc("/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
clientMediaRouter.HandleFunc("/upload", dma.UploadNotSupported).Methods(http.MethodPost)
clientMediaRouter.HandleFunc("/create", dma.UploadNotSupported).Methods(http.MethodPost)
clientMediaRouter.HandleFunc("/config", dma.UploadNotSupported).Methods(http.MethodGet)
clientMediaRouter.HandleFunc("/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
addRoutes("v3")
addRoutes("r0")
addRoutes("v1")
federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet)
mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
federationRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
federationRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
dma.ks.Register(r)
return dma
}
func (dma *DirectMediaAPI) makeMXC(data MediaIDData) id.ContentURI {
return id.ContentURI{
Homeserver: dma.cfg.ServerName,
FileID: data.Wrap().SignedString(dma.signatureKey),
}
}
func parseExpiryTS(addr string) time.Time {
parsedURL, err := url.Parse(addr)
if err != nil {
return time.Time{}
}
tsBytes, err := hex.DecodeString(parsedURL.Query().Get("ex"))
if err != nil || len(tsBytes) != 4 {
return time.Time{}
}
parsedTS := int64(binary.BigEndian.Uint32(tsBytes))
if parsedTS > time.Now().Unix() && parsedTS < time.Now().Add(365*24*time.Hour).Unix() {
return time.Unix(parsedTS, 0)
}
return time.Time{}
}
func (dma *DirectMediaAPI) addAttachmentToCache(channelID uint64, att *discordgo.MessageAttachment) time.Time {
attachmentID, err := strconv.ParseUint(att.ID, 10, 64)
if err != nil {
return time.Time{}
}
expiry := parseExpiryTS(att.URL)
if expiry.IsZero() {
expiry = time.Now().Add(24 * time.Hour)
}
dma.attachmentCache[AttachmentCacheKey{
ChannelID: channelID,
AttachmentID: attachmentID,
}] = AttachmentCacheValue{
URL: att.URL,
Expiry: expiry,
}
return expiry
}
func (dma *DirectMediaAPI) AttachmentMXC(channelID, messageID string, att *discordgo.MessageAttachment) (mxc id.ContentURI) {
if dma == nil {
return
}
channelIDInt, err := strconv.ParseUint(channelID, 10, 64)
if err != nil {
dma.log.Warn().Str("channel_id", channelID).Msg("Got non-integer channel ID")
return
}
messageIDInt, err := strconv.ParseUint(messageID, 10, 64)
if err != nil {
dma.log.Warn().Str("message_id", messageID).Msg("Got non-integer message ID")
return
}
attachmentIDInt, err := strconv.ParseUint(att.ID, 10, 64)
if err != nil {
dma.log.Warn().Str("attachment_id", att.ID).Msg("Got non-integer attachment ID")
return
}
dma.attachmentCacheLock.Lock()
dma.addAttachmentToCache(channelIDInt, att)
dma.attachmentCacheLock.Unlock()
return dma.makeMXC(&AttachmentMediaData{
ChannelID: channelIDInt,
MessageID: messageIDInt,
AttachmentID: attachmentIDInt,
})
}
func (dma *DirectMediaAPI) EmojiMXC(emojiID, name string, animated bool) (mxc id.ContentURI) {
if dma == nil {
return
}
emojiIDInt, err := strconv.ParseUint(emojiID, 10, 64)
if err != nil {
dma.log.Warn().Str("emoji_id", emojiID).Msg("Got non-integer emoji ID")
return
}
return dma.makeMXC(&EmojiMediaData{
EmojiMediaDataInner: EmojiMediaDataInner{
EmojiID: emojiIDInt,
Animated: animated,
},
Name: name,
})
}
func (dma *DirectMediaAPI) StickerMXC(stickerID string, format discordgo.StickerFormat) (mxc id.ContentURI) {
if dma == nil {
return
}
stickerIDInt, err := strconv.ParseUint(stickerID, 10, 64)
if err != nil {
dma.log.Warn().Str("sticker_id", stickerID).Msg("Got non-integer sticker ID")
return
} else if format > 255 || format < 0 {
dma.log.Warn().Int("format", int(format)).Msg("Got invalid sticker format")
return
}
return dma.makeMXC(&StickerMediaData{
StickerID: stickerIDInt,
Format: byte(format),
})
}
func (dma *DirectMediaAPI) AvatarMXC(guildID, userID, avatarID string) (mxc id.ContentURI) {
if dma == nil {
return
}
animated := strings.HasPrefix(avatarID, "a_")
avatarIDBytes, err := hex.DecodeString(strings.TrimPrefix(avatarID, "a_"))
if err != nil {
dma.log.Warn().Str("avatar_id", avatarID).Msg("Got non-hex avatar ID")
return
} else if len(avatarIDBytes) != 16 {
dma.log.Warn().Str("avatar_id", avatarID).Msg("Got invalid avatar ID length")
return
}
avatarIDArray := [16]byte(avatarIDBytes)
userIDInt, err := strconv.ParseUint(userID, 10, 64)
if err != nil {
dma.log.Warn().Str("user_id", userID).Msg("Got non-integer user ID")
return
}
if guildID != "" {
guildIDInt, err := strconv.ParseUint(guildID, 10, 64)
if err != nil {
dma.log.Warn().Str("guild_id", guildID).Msg("Got non-integer guild ID")
return
}
return dma.makeMXC(&GuildMemberAvatarMediaData{
GuildID: guildIDInt,
UserID: userIDInt,
AvatarID: avatarIDArray,
Animated: animated,
})
} else {
return dma.makeMXC(&UserAvatarMediaData{
UserID: userIDInt,
AvatarID: avatarIDArray,
Animated: animated,
})
}
}
type RespError struct {
Code string
Message string
Status int
}
func (re *RespError) Error() string {
return re.Message
}
var ErrNoUsersWithAccessFound = errors.New("no users found to fetch message")
var ErrAttachmentNotFound = errors.New("attachment not found")
func (dma *DirectMediaAPI) fetchNewAttachmentURL(ctx context.Context, meta *AttachmentMediaData) (string, time.Time, error) {
var client *discordgo.Session
channelIDStr := strconv.FormatUint(meta.ChannelID, 10)
portal := dma.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: channelIDStr})
var users []id.UserID
if portal != nil && portal.GuildID != "" {
users = dma.bridge.DB.GetUsersInPortal(portal.GuildID)
} else {
users = dma.bridge.DB.GetUsersInPortal(channelIDStr)
}
for _, userID := range users {
user := dma.bridge.GetCachedUserByMXID(userID)
if user == nil || user.Session == nil {
continue
}
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channelIDStr)
if err == nil && perms&discordgo.PermissionViewChannel == 0 {
continue
}
if client == nil || err == nil {
client = user.Session
if !client.IsUser {
break
}
}
}
if client == nil {
return "", time.Time{}, ErrNoUsersWithAccessFound
}
var msgs []*discordgo.Message
var err error
messageIDStr := strconv.FormatUint(meta.MessageID, 10)
if client.IsUser {
msgs, err = client.ChannelMessages(channelIDStr, 5, "", "", messageIDStr)
} else {
var msg *discordgo.Message
msg, err = client.ChannelMessage(channelIDStr, messageIDStr)
msgs = []*discordgo.Message{msg}
}
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to fetch message: %w", err)
}
attachmentIDStr := strconv.FormatUint(meta.AttachmentID, 10)
var url string
var expiry time.Time
for _, item := range msgs {
for _, att := range item.Attachments {
thisExpiry := dma.addAttachmentToCache(meta.ChannelID, att)
if att.ID == attachmentIDStr {
url = att.URL
expiry = thisExpiry
}
}
}
if url == "" {
return "", time.Time{}, ErrAttachmentNotFound
}
return url, expiry, nil
}
func (dma *DirectMediaAPI) GetEmojiInfo(contentURI id.ContentURI) *EmojiMediaData {
if dma == nil || contentURI.IsEmpty() || contentURI.Homeserver != dma.cfg.ServerName {
return nil
}
mediaID, err := ParseMediaID(contentURI.FileID, dma.signatureKey)
if err != nil {
return nil
}
emojiData, ok := mediaID.Data.(*EmojiMediaData)
if !ok {
return nil
}
return emojiData
}
func (dma *DirectMediaAPI) getMediaURL(ctx context.Context, encodedMediaID string) (url string, expiry time.Time, err error) {
var mediaID *MediaID
mediaID, err = ParseMediaID(encodedMediaID, dma.signatureKey)
if err != nil {
err = &RespError{
Code: mautrix.MNotFound.ErrCode,
Message: err.Error(),
Status: http.StatusNotFound,
}
return
}
switch mediaData := mediaID.Data.(type) {
case *AttachmentMediaData:
dma.attachmentCacheLock.Lock()
defer dma.attachmentCacheLock.Unlock()
cached, ok := dma.attachmentCache[mediaData.CacheKey()]
if ok && time.Until(cached.Expiry) > 5*time.Minute {
return cached.URL, cached.Expiry, nil
}
zerolog.Ctx(ctx).Debug().
Uint64("channel_id", mediaData.ChannelID).
Uint64("message_id", mediaData.MessageID).
Uint64("attachment_id", mediaData.AttachmentID).
Msg("Refreshing attachment URL")
url, expiry, err = dma.fetchNewAttachmentURL(ctx, mediaData)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to refresh attachment URL")
msg := "Failed to refresh attachment URL"
if errors.Is(err, ErrNoUsersWithAccessFound) {
msg = "No users found with access to the channel"
} else if errors.Is(err, ErrAttachmentNotFound) {
msg = "Attachment not found in message. Perhaps it was deleted?"
}
err = &RespError{
Code: mautrix.MNotFound.ErrCode,
Message: msg,
Status: http.StatusNotFound,
}
} else {
zerolog.Ctx(ctx).Debug().Time("expiry", expiry).Msg("Successfully refreshed attachment URL")
}
case *EmojiMediaData:
if mediaData.Animated {
url = discordgo.EndpointEmojiAnimated(strconv.FormatUint(mediaData.EmojiID, 10))
} else {
url = discordgo.EndpointEmoji(strconv.FormatUint(mediaData.EmojiID, 10))
}
case *StickerMediaData:
url = discordgo.EndpointStickerImage(
strconv.FormatUint(mediaData.StickerID, 10),
discordgo.StickerFormat(mediaData.Format),
)
case *UserAvatarMediaData:
if mediaData.Animated {
url = discordgo.EndpointUserAvatarAnimated(
strconv.FormatUint(mediaData.UserID, 10),
fmt.Sprintf("a_%x", mediaData.AvatarID),
)
} else {
url = discordgo.EndpointUserAvatar(
strconv.FormatUint(mediaData.UserID, 10),
fmt.Sprintf("%x", mediaData.AvatarID),
)
}
case *GuildMemberAvatarMediaData:
if mediaData.Animated {
url = discordgo.EndpointGuildMemberAvatarAnimated(
strconv.FormatUint(mediaData.GuildID, 10),
strconv.FormatUint(mediaData.UserID, 10),
fmt.Sprintf("a_%x", mediaData.AvatarID),
)
} else {
url = discordgo.EndpointGuildMemberAvatar(
strconv.FormatUint(mediaData.GuildID, 10),
strconv.FormatUint(mediaData.UserID, 10),
fmt.Sprintf("%x", mediaData.AvatarID),
)
}
default:
zerolog.Ctx(ctx).Error().Type("media_data_type", mediaData).Msg("Unrecognized media data struct")
err = &RespError{
Code: "M_UNKNOWN",
Message: "Unrecognized media data struct",
Status: http.StatusInternalServerError,
}
}
return
}
func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWriter, url, fileName string) {
log := zerolog.Ctx(ctx)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Err(err).Str("url", url).Msg("Failed to create proxy request")
jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
ErrCode: "M_UNKNOWN",
Err: "Failed to create proxy request",
})
return
}
for key, val := range discordgo.DroidDownloadHeaders {
req.Header.Set(key, val)
}
resp, err := dma.proxy.Do(req)
defer func() {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
}()
if err != nil {
log.Err(err).Str("url", url).Msg("Failed to proxy download")
jsonResponse(w, http.StatusServiceUnavailable, &mautrix.RespError{
ErrCode: "M_UNKNOWN",
Err: "Failed to proxy download",
})
return
} else if resp.StatusCode != http.StatusOK {
log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("Unexpected status code proxying download")
jsonResponse(w, resp.StatusCode, &mautrix.RespError{
ErrCode: "M_UNKNOWN",
Err: "Unexpected status code proxying download",
})
return
}
w.Header()["Content-Type"] = resp.Header["Content-Type"]
w.Header()["Content-Length"] = resp.Header["Content-Length"]
w.Header()["Last-Modified"] = resp.Header["Last-Modified"]
w.Header()["Cache-Control"] = resp.Header["Cache-Control"]
contentDisposition := "attachment"
switch resp.Header.Get("Content-Type") {
case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif",
"image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime",
"audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav",
"audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf":
contentDisposition = "inline"
}
if fileName != "" {
contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{
"filename": fileName,
})
}
w.Header().Set("Content-Disposition", contentDisposition)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, resp.Body)
if err != nil {
log.Debug().Err(err).Msg("Failed to write proxy response")
}
}
func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := zerolog.Ctx(ctx)
isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/download/")
vars := mux.Vars(r)
if !isNewFederation && vars["serverName"] != dma.cfg.ServerName {
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
ErrCode: mautrix.MNotFound.ErrCode,
Err: fmt.Sprintf("This is a Discord media proxy for %q, other media downloads are not available here", dma.cfg.ServerName),
})
return
}
// TODO check destination header in X-Matrix auth when isNewFederation
url, expiresAt, err := dma.getMediaURL(ctx, vars["mediaID"])
if err != nil {
var respError *RespError
if errors.As(err, &respError) {
jsonResponse(w, respError.Status, &mautrix.RespError{
ErrCode: respError.Code,
Err: respError.Message,
})
} else {
log.Err(err).Str("media_id", vars["mediaID"]).Msg("Failed to get media URL")
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
ErrCode: mautrix.MNotFound.ErrCode,
Err: "Media not found",
})
}
return
}
if isNewFederation {
mp := multipart.NewWriter(w)
w.Header().Set("Content-Type", strings.Replace(mp.FormDataContentType(), "form-data", "mixed", 1))
var metaPart io.Writer
metaPart, err = mp.CreatePart(textproto.MIMEHeader{
"Content-Type": {"application/json"},
})
if err != nil {
log.Err(err).Msg("Failed to create multipart metadata field")
return
}
_, err = metaPart.Write([]byte(`{}`))
if err != nil {
log.Err(err).Msg("Failed to write multipart metadata field")
return
}
_, err = mp.CreatePart(textproto.MIMEHeader{
"Location": {url},
})
if err != nil {
log.Err(err).Msg("Failed to create multipart redirect field")
return
}
err = mp.Close()
if err != nil {
log.Err(err).Msg("Failed to close multipart writer")
return
}
return
}
// Proxy if the config allows proxying and the request doesn't allow redirects.
// In any other case, redirect to the Discord CDN.
if dma.cfg.AllowProxy && r.URL.Query().Get("allow_redirect") != "true" {
dma.proxyDownload(ctx, w, url, vars["fileName"])
return
}
w.Header().Set("Location", url)
expirySeconds := (time.Until(expiresAt) - 5*time.Minute).Seconds()
if expiresAt.IsZero() {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else if expirySeconds > 0 {
cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds))
w.Header().Set("Cache-Control", cacheControl)
} else {
w.Header().Set("Cache-Control", "no-store")
}
w.WriteHeader(http.StatusTemporaryRedirect)
}
func (dma *DirectMediaAPI) UploadNotSupported(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
ErrCode: mautrix.MUnrecognized.ErrCode,
Err: "This bridge only supports proxying Discord media downloads and does not support media uploads.",
})
}
func (dma *DirectMediaAPI) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
ErrCode: mautrix.MUnrecognized.ErrCode,
Err: "This bridge only supports proxying Discord media downloads and does not support URL previews.",
})
}
func (dma *DirectMediaAPI) UnknownEndpoint(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
ErrCode: mautrix.MUnrecognized.ErrCode,
Err: "Unrecognized endpoint",
})
}
func (dma *DirectMediaAPI) UnsupportedMethod(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{
ErrCode: mautrix.MUnrecognized.ErrCode,
Err: "Invalid method for endpoint",
})
}

View file

@ -1,287 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
)
const MediaIDPrefix = "\U0001F408DISCORD"
const MediaIDVersion = 1
type MediaIDClass uint8
const (
MediaIDClassAttachment MediaIDClass = 1
MediaIDClassEmoji MediaIDClass = 2
MediaIDClassSticker MediaIDClass = 3
MediaIDClassUserAvatar MediaIDClass = 4
MediaIDClassGuildMemberAvatar MediaIDClass = 5
)
type MediaIDData interface {
Write(to io.Writer)
Read(from io.Reader) error
Size() int
Wrap() *MediaID
}
type MediaID struct {
Version uint8
TypeClass MediaIDClass
Data MediaIDData
}
func ParseMediaID(id string, key [32]byte) (*MediaID, error) {
data, err := base64.RawURLEncoding.DecodeString(id)
if err != nil {
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
hasher := hmac.New(sha256.New, key[:])
checksum := data[len(data)-TruncatedHashLength:]
data = data[:len(data)-TruncatedHashLength]
hasher.Write(data)
if !hmac.Equal(checksum, hasher.Sum(nil)[:TruncatedHashLength]) {
return nil, ErrMediaIDChecksumMismatch
}
mid := &MediaID{}
err = mid.Read(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to parse media ID: %w", err)
}
return mid, nil
}
const TruncatedHashLength = 16
func (mid *MediaID) SignedString(key [32]byte) string {
buf := bytes.NewBuffer(make([]byte, 0, mid.Size()))
mid.Write(buf)
hasher := hmac.New(sha256.New, key[:])
hasher.Write(buf.Bytes())
buf.Write(hasher.Sum(nil)[:TruncatedHashLength])
return base64.RawURLEncoding.EncodeToString(buf.Bytes())
}
func (mid *MediaID) Write(to io.Writer) {
_, _ = to.Write([]byte(MediaIDPrefix))
_ = binary.Write(to, binary.BigEndian, mid.Version)
_ = binary.Write(to, binary.BigEndian, mid.TypeClass)
mid.Data.Write(to)
}
func (mid *MediaID) Size() int {
return len(MediaIDPrefix) + 2 + mid.Data.Size() + TruncatedHashLength
}
var (
ErrInvalidMediaID = errors.New("invalid media ID")
ErrMediaIDChecksumMismatch = errors.New("invalid checksum in media ID")
ErrUnsupportedMediaID = errors.New("unsupported media ID")
)
func (mid *MediaID) Read(from io.Reader) error {
prefix := make([]byte, len(MediaIDPrefix))
_, err := io.ReadFull(from, prefix)
if err != nil || !bytes.Equal(prefix, []byte(MediaIDPrefix)) {
return fmt.Errorf("%w: prefix not found", ErrInvalidMediaID)
}
versionAndClass := make([]byte, 2)
_, err = io.ReadFull(from, versionAndClass)
if err != nil {
return fmt.Errorf("%w: version and class not found", ErrInvalidMediaID)
} else if versionAndClass[0] != MediaIDVersion {
return fmt.Errorf("%w: unknown version %d", ErrUnsupportedMediaID, versionAndClass[0])
}
switch MediaIDClass(versionAndClass[1]) {
case MediaIDClassAttachment:
mid.Data = &AttachmentMediaData{}
case MediaIDClassEmoji:
mid.Data = &EmojiMediaData{}
case MediaIDClassSticker:
mid.Data = &StickerMediaData{}
case MediaIDClassUserAvatar:
mid.Data = &UserAvatarMediaData{}
case MediaIDClassGuildMemberAvatar:
mid.Data = &GuildMemberAvatarMediaData{}
default:
return fmt.Errorf("%w: unrecognized type class %d", ErrUnsupportedMediaID, versionAndClass[1])
}
err = mid.Data.Read(from)
if err != nil {
return fmt.Errorf("failed to parse media ID data: %w", err)
}
return nil
}
type AttachmentMediaData struct {
ChannelID uint64
MessageID uint64
AttachmentID uint64
}
func (amd *AttachmentMediaData) Write(to io.Writer) {
_ = binary.Write(to, binary.BigEndian, amd)
}
func (amd *AttachmentMediaData) Read(from io.Reader) (err error) {
return binary.Read(from, binary.BigEndian, amd)
}
func (amd *AttachmentMediaData) Size() int {
return binary.Size(amd)
}
func (amd *AttachmentMediaData) Wrap() *MediaID {
return &MediaID{
Version: MediaIDVersion,
TypeClass: MediaIDClassAttachment,
Data: amd,
}
}
func (amd *AttachmentMediaData) CacheKey() AttachmentCacheKey {
return AttachmentCacheKey{
ChannelID: amd.ChannelID,
AttachmentID: amd.AttachmentID,
}
}
type StickerMediaData struct {
StickerID uint64
Format uint8
}
func (smd *StickerMediaData) Write(to io.Writer) {
_ = binary.Write(to, binary.BigEndian, smd)
}
func (smd *StickerMediaData) Read(from io.Reader) error {
return binary.Read(from, binary.BigEndian, smd)
}
func (smd *StickerMediaData) Size() int {
return binary.Size(smd)
}
func (smd *StickerMediaData) Wrap() *MediaID {
return &MediaID{
Version: MediaIDVersion,
TypeClass: MediaIDClassSticker,
Data: smd,
}
}
type EmojiMediaDataInner struct {
EmojiID uint64
Animated bool
}
type EmojiMediaData struct {
EmojiMediaDataInner
Name string
}
func (emd *EmojiMediaData) Write(to io.Writer) {
_ = binary.Write(to, binary.BigEndian, &emd.EmojiMediaDataInner)
_, _ = to.Write([]byte(emd.Name))
}
func (emd *EmojiMediaData) Read(from io.Reader) (err error) {
err = binary.Read(from, binary.BigEndian, &emd.EmojiMediaDataInner)
if err != nil {
return
}
name, err := io.ReadAll(from)
if err != nil {
return
}
emd.Name = string(name)
return
}
func (emd *EmojiMediaData) Size() int {
return binary.Size(&emd.EmojiMediaDataInner) + len(emd.Name)
}
func (emd *EmojiMediaData) Wrap() *MediaID {
return &MediaID{
Version: MediaIDVersion,
TypeClass: MediaIDClassEmoji,
Data: emd,
}
}
type UserAvatarMediaData struct {
UserID uint64
Animated bool
AvatarID [16]byte
}
func (uamd *UserAvatarMediaData) Write(to io.Writer) {
_ = binary.Write(to, binary.BigEndian, uamd)
}
func (uamd *UserAvatarMediaData) Read(from io.Reader) error {
return binary.Read(from, binary.BigEndian, uamd)
}
func (uamd *UserAvatarMediaData) Size() int {
return binary.Size(uamd)
}
func (uamd *UserAvatarMediaData) Wrap() *MediaID {
return &MediaID{
Version: MediaIDVersion,
TypeClass: MediaIDClassUserAvatar,
Data: uamd,
}
}
type GuildMemberAvatarMediaData struct {
GuildID uint64
UserID uint64
Animated bool
AvatarID [16]byte
}
func (guamd *GuildMemberAvatarMediaData) Write(to io.Writer) {
_ = binary.Write(to, binary.BigEndian, guamd)
}
func (guamd *GuildMemberAvatarMediaData) Read(from io.Reader) error {
return binary.Read(from, binary.BigEndian, guamd)
}
func (guamd *GuildMemberAvatarMediaData) Size() int {
return binary.Size(guamd)
}
func (guamd *GuildMemberAvatarMediaData) Wrap() *MediaID {
return &MediaID{
Version: MediaIDVersion,
TypeClass: MediaIDClassGuildMemberAvatar,
Data: guamd,
}
}

View file

@ -1,52 +0,0 @@
package main
import (
"errors"
"github.com/bwmarrin/discordgo"
)
func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
switch channel.Type {
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
// allowed
case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
// DMs are always bridgeable, no need for permission checks
return true
default:
// everything else is not allowed
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)
if errors.Is(err, discordgo.ErrStateNotFound) {
log.Debug().Msg("Fetching own membership in guild to check roles")
member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else {
err = user.Session.State.MemberAdd(member)
if err != nil {
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
}
}
} else if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
}
err = user.Session.State.ChannelAdd(channel)
if err != nil {
log.Warn().Err(err).Msg("Failed to add channel to cache")
}
perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true
}
log.Debug().
Int64("permissions", perms).
Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
Msg("Computed permissions in channel")
return perms&discordgo.PermissionViewChannel > 0
}

View file

@ -15,7 +15,7 @@ function fixperms {
}
if [[ ! -f /data/config.yaml ]]; then
cp /opt/mautrix-discord/example-config.yaml /data/config.yaml
/usr/bin/mautrix-discord -c /data/config.yaml -e
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."

View file

@ -1,370 +0,0 @@
# Homeserver details.
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29334
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29334
# Database config.
database:
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
type: postgres
# The database URI.
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
# https://github.com/mattn/go-sqlite3#connection-string
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
uri: postgres://user:password@host/database?sslmode=disable
# Maximum number of connections. Mostly relevant for Postgres.
max_open_conns: 20
max_idle_conns: 2
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
# Parsed with https://pkg.go.dev/time#ParseDuration
max_conn_idle_time: null
max_conn_lifetime: null
# The unique ID of this appservice.
id: discord
# Appservice bot details.
bot:
# Username of the appservice bot.
username: discordbot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
displayname: Discord bridge bot
avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
ephemeral_events: true
# Should incoming events be handled asynchronously?
# This may be necessary for large public instances with lots of messages going through.
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
async_transactions: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Bridge config
bridge:
# Localpart template of MXIDs for Discord users.
# {{.}} is replaced with the internal ID of the Discord user.
username_template: discord_{{.}}
# Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# Available variables:
# .ID - Internal user ID
# .Username - Legacy display/username on Discord
# .GlobalName - New displayname on Discord
# .Discriminator - The 4 numbers after the name on Discord
# .Bot - Whether the user is a bot
# .System - Whether the user is an official system user
# .Webhook - Whether the user is a webhook and is not an application
# .Application - Whether the user is an application
displayname_template: '{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}'
# Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
# Available variables:
# .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
# .ParentName - Parent channel name (used for categories).
# .GuildName - Guild name.
# .NSFW - Whether the channel is marked as NSFW.
# .Type - Channel type (see values at https://github.com/bwmarrin/discordgo/blob/v0.25.0/structs.go#L251-L267)
channel_name_template: '{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}'
# Displayname template for Discord guilds (bridged as spaces).
# Available variables:
# .Name - Guild name
guild_name_template: '{{.Name}}'
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode.
# If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address.
# This should not have a trailing slash, the endpoint above will be appended to the provided address.
public_address: null
# A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature.
avatar_proxy_key: generate
portal_message_buffer: 128
# Number of private channel portals to create on bridge startup.
# Other portals will be created when receiving messages.
startup_private_channel_create_limit: 5
# Should the bridge send a read receipt from the bridge bot when a message has been sent to Discord?
delivery_receipts: false
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
message_status_events: false
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
message_error_notices: true
# Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
# This can avoid unnecessary invite events in guild rooms when members are synced in.
restricted_rooms: true
# Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
# This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
autojoin_thread_on_open: true
# Should inline fields in Discord embeds be bridged as HTML tables to Matrix?
# Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI.
embed_fields_as_tables: true
# Should guild channels be muted when the portal is created? This only meant for single-user instances,
# it won't mute it for all users if there are multiple Matrix users in the same Discord guild.
mute_channels_on_create: false
# Should the bridge update the m.direct account data event when double puppeting is enabled.
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
# This field will automatically be changed back to false after it, except if the config file is not writable.
resend_bridge_info: false
# Should incoming custom emoji reactions be bridged as mxc:// URIs?
# If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available.
custom_emoji_reactions: true
# Should the bridge attempt to completely delete portal rooms when a channel is deleted on Discord?
# If true, the bridge will try to kick Matrix users from the room. Otherwise, the bridge only makes ghosts leave.
delete_portal_on_channel_delete: false
# Should the bridge delete all portal rooms when you leave a guild on Discord?
# This only applies if the guild has no other Matrix users on this bridge instance.
delete_guild_on_leave: true
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Prefix messages from webhooks with the profile info? This can be used along with a custom displayname_template
# to better handle webhooks that change their name all the time (like ones used by bridges).
prefix_webhook_messages: false
# Bridge webhook avatars?
enable_webhook_avatars: true
# Should the bridge upload media to the Discord CDN directly before sending the message when using a user token,
# like the official client does? The other option is sending the media in the message send request as a form part
# (which is always used by bots and webhooks).
use_discord_cdn_upload: true
# Should mxc uris copied from Discord be cached?
# This can be `never` to never cache, `unencrypted` to only cache unencrypted mxc uris, or `always` to cache everything.
# If you have a media repo that generates non-unique mxc uris, you should set this to never.
cache_media: unencrypted
# Settings for converting Discord media to custom mxc:// URIs instead of reuploading.
# More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
direct_media:
# Should custom mxc:// URIs be used instead of reuploading media?
enabled: false
# The server name to use for the custom mxc:// URIs.
# This server name will effectively be a real Matrix server, it just won't implement anything other than media.
# You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge.
server_name: discord-media.example.com
# Optionally a custom .well-known response. This defaults to `server_name:443`
well_known_response:
# The bridge supports MSC3860 media download redirects and will use them if the requester supports it.
# Optionally, you can force redirects and not allow proxying at all by setting this to false.
allow_proxy: true
# Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file.
# This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them.
server_key: generate
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (lottie JSON)
# png - converts to non-animated png (fastest)
# gif - converts to animated gif
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
target: webp
# Arguments for converter. All converters take width and height.
args:
width: 320
height: 320
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, double puppeting will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
login_shared_secret_map:
example.com: foobar
# The prefix for commands. Only required in non-management rooms.
command_prefix: '!discord'
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a Discord bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
welcome_unconnected: "Use `help` for help or `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# Settings for backfilling messages.
backfill:
# Limits for forward backfilling.
forward_limits:
# Initial backfill (when creating portal). 0 means backfill is disabled.
# A special unlimited value is not supported, you must set a limit. Initial backfill will
# fetch all messages first before backfilling anything, so high limits can take a lot of time.
initial:
dm: 0
channel: 0
thread: 0
# Missed message backfill (on startup).
# 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
# When using unlimited backfill (-1), messages are backfilled as they are fetched.
# With limits, all messages up to the limit are fetched first and backfilled afterwards.
missed:
dm: 0
channel: 0
thread: 0
# Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
# This can be used as a rough heuristic to disable backfilling in channels that are too active.
# Currently only applies to missed message backfill.
max_guild_members: -1
# End-to-bridge encryption support options.
#
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
appservice: false
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Should users mentions be in the event wire content to enable the server to send push notifications?
plaintext_mentions: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
# unverified - Send keys to all device in the room.
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
# Note that creating user signatures from the bridge bot is not currently possible.
# verified - Require manual per-device verification
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
verification_levels:
# Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
receive: unverified
# Minimum level that the bridge should accept for incoming Matrix messages.
send: unverified
# Minimum level that the bridge should require for accepting key requests.
share: cross-signed-tofu
# Options for Megolm room key rotation. These options allow you to
# configure the m.room.encryption event content. See:
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
# more information about that event.
rotation:
# Enable custom Megolm room key rotation settings. Note that these
# settings will only apply to rooms created after this option is
# set.
enable_custom: false
# The maximum number of milliseconds a session should be used
# before changing it. The Matrix spec recommends 604800000 (a week)
# as the default.
milliseconds: 604800000
# The maximum number of messages that should be sent with a given a
# session before changing it. The Matrix spec recommends 100 as the
# default.
messages: 100
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Enable debug API at /debug with provisioning authentication.
debug_endpoints: false
# Permissions for using the bridge.
# Permitted values:
# relay - Talk through the relaybot (if enabled), no access otherwise
# user - Access to use the bridge to chat with a Discord account.
# admin - User level and some additional administration tools
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": relay
"example.com": user
"@admin:example.com": admin
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug
writers:
- type: stdout
format: pretty-colored
- type: file
format: json
filename: ./logs/mautrix-discord.log
max_size: 100
max_backups: 10
compress: true

View file

@ -1,246 +0,0 @@
// 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"
"regexp"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"go.mau.fi/util/variationselector"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
"maunium.net/go/mautrix/id"
)
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
//
// Discord allows escaping with just one backslash, e.g. \__a__,
// but standard markdown requires both to be escaped (\_\_a__)
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
func escapeReplacement(s string) string {
return s[:2] + `\` + s[2:]
}
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
type indentableParagraphParser struct {
parser.BlockParser
}
var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
return true
}
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
parser.NewSetextHeadingParser(), parser.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
ctx := parser.NewContext()
ctx.Set(parserContextPortal, portal)
renderer := discordRenderer
if allowInlineLinks {
renderer = discordRendererWithInlineLinks
}
err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err))
}
return format.UnwrapSingleParagraph(buf.String())
}
const formatterContextPortalKey = "fi.mau.discord.portal"
const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
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 {
return displayname
}
if mxid[0] == '#' {
alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
if err != nil {
return displayname
}
mxid = alias.RoomID.String()
}
if mxid[0] == '!' {
portal := br.GetPortalByMXID(id.RoomID(mxid))
if portal != nil {
if eventID == "" {
//currentPortal := ctx[formatterContextPortalKey].(*Portal)
return fmt.Sprintf("<#%s>", portal.Key.ChannelID)
//if currentPortal.GuildID == portal.GuildID {
//} else if portal.GuildID != "" {
// return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name)
//} else {
// // TODO is mentioning private channels possible at all?
//}
} else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
guildID := portal.GuildID
if guildID == "" {
guildID = "@me"
}
return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID)
}
}
} else if mxid[0] == '@' {
allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID)
if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) {
return displayname
}
mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
if ok {
mentions.Users = appendIfNotContains(mentions.Users, parsedID)
return fmt.Sprintf("<@%s>", parsedID)
}
mentionedUser := br.GetUserByMXID(id.UserID(mxid))
if mentionedUser != nil && mentionedUser.DiscordID != "" {
mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
}
}
return displayname
}
const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
// Discord links start with http:// or https://, contain at least two characters afterwards,
// don't contain < or whitespace anywhere, and don't end with "'),.:;]
//
// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
var discordMarkdownEscaper = strings.NewReplacer(
`\`, `\\`,
`_`, `\_`,
`*`, `\*`,
`~`, `\~`,
"`", "\\`",
`|`, `\|`,
`<`, `\<`,
`#`, `\#`,
)
func escapeDiscordMarkdown(s string) string {
submatches := discordLinkRegex.FindAllStringIndex(s, -1)
if submatches == nil {
return discordMarkdownEscaper.Replace(s)
}
var builder strings.Builder
offset := 0
for _, match := range submatches {
start := match[0]
end := match[1]
builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start]))
builder.WriteString(s[start:end])
offset = end
}
builder.WriteString(discordMarkdownEscaper.Replace(s[offset:]))
return builder.String()
}
var matrixHTMLParser = &format.HTMLParser{
TabsToSpaces: 4,
Newline: "\n",
HorizontalLine: "\n---\n",
ItalicConverter: func(s string, ctx format.Context) string {
return fmt.Sprintf("*%s*", s)
},
UnderlineConverter: func(s string, ctx format.Context) string {
return fmt.Sprintf("__%s__", s)
},
TextConverter: func(s string, ctx format.Context) string {
if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") {
// If we're in a code block, don't escape markdown
return s
}
return escapeDiscordMarkdown(s)
},
SpoilerConverter: func(text, reason string, ctx format.Context) string {
if reason != "" {
return fmt.Sprintf("(%s) ||%s||", reason, text)
}
return fmt.Sprintf("||%s||", text)
},
LinkConverter: func(text, href string, ctx format.Context) string {
if text == href {
return text
} else if !discordLinkRegexFull.MatchString(href) {
return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href))
}
return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
},
}
func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext()
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil {
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
}
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else {
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
}
}

View file

@ -1,110 +0,0 @@
// 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"
"regexp"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type astDiscordEveryone struct {
ast.BaseInline
onlyHere bool
}
var _ ast.Node = (*astDiscordEveryone)(nil)
var astKindDiscordEveryone = ast.NewNodeKind("DiscordEveryone")
func (n *astDiscordEveryone) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
func (n *astDiscordEveryone) Kind() ast.NodeKind {
return astKindDiscordEveryone
}
func (n *astDiscordEveryone) String() string {
if n.onlyHere {
return "@here"
}
return "@everyone"
}
type discordEveryoneParser struct{}
var discordEveryoneRegex = regexp.MustCompile(`@(everyone|here)`)
var defaultDiscordEveryoneParser = &discordEveryoneParser{}
func (s *discordEveryoneParser) Trigger() []byte {
return []byte{'@'}
}
func (s *discordEveryoneParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
match := discordEveryoneRegex.FindSubmatch(line)
if match == nil {
return nil
}
block.Advance(len(match[0]))
return &astDiscordEveryone{
onlyHere: string(match[1]) == "here",
}
}
func (s *discordEveryoneParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordEveryoneHTMLRenderer struct{}
func (r *discordEveryoneHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordEveryone, r.renderDiscordEveryone)
}
func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
status = ast.WalkContinue
if !entering {
return
}
mention, _ := n.(*astDiscordEveryone)
class := "everyone"
if mention != nil && mention.onlyHere {
class = "here"
}
_, _ = fmt.Fprintf(w, `<span class="discord-mention-%s">@room</span>`, class)
return
}
type discordEveryone struct{}
var ExtDiscordEveryone = &discordEveryone{}
func (e *discordEveryone) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordEveryoneParser, 600),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&discordEveryoneHTMLRenderer{}, 600),
))
}

View file

@ -1,343 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
type astDiscordTag struct {
ast.BaseInline
portal *Portal
id int64
}
var _ ast.Node = (*astDiscordTag)(nil)
var astKindDiscordTag = ast.NewNodeKind("DiscordTag")
func (n *astDiscordTag) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
func (n *astDiscordTag) Kind() ast.NodeKind {
return astKindDiscordTag
}
type astDiscordUserMention struct {
astDiscordTag
hasNick bool
}
func (n *astDiscordUserMention) String() string {
if n.hasNick {
return fmt.Sprintf("<@!%d>", n.id)
}
return fmt.Sprintf("<@%d>", n.id)
}
type astDiscordRoleMention struct {
astDiscordTag
}
func (n *astDiscordRoleMention) String() string {
return fmt.Sprintf("<@&%d>", n.id)
}
type astDiscordChannelMention struct {
astDiscordTag
guildID int64
name string
}
func (n *astDiscordChannelMention) String() string {
if n.guildID != 0 {
return fmt.Sprintf("<#%d:%d:%s>", n.id, n.guildID, n.name)
}
return fmt.Sprintf("<#%d>", n.id)
}
type discordTimestampStyle rune
func (dts discordTimestampStyle) Format() string {
switch dts {
case 't':
return "15:04 MST"
case 'T':
return "15:04:05 MST"
case 'd':
return "2006-01-02 MST"
case 'D':
return "2 January 2006 MST"
case 'F':
return "Monday, 2 January 2006 15:04 MST"
case 'f':
fallthrough
default:
return "2 January 2006 15:04 MST"
}
}
type astDiscordTimestamp struct {
astDiscordTag
timestamp int64
style discordTimestampStyle
}
func (n *astDiscordTimestamp) String() string {
if n.style == 'f' {
return fmt.Sprintf("<t:%d>", n.timestamp)
}
return fmt.Sprintf("<t:%d:%c>", n.timestamp, n.style)
}
type astDiscordCustomEmoji struct {
astDiscordTag
name string
animated bool
}
func (n *astDiscordCustomEmoji) String() string {
if n.animated {
return fmt.Sprintf("<a%s%d>", n.name, n.id)
}
return fmt.Sprintf("<%s%d>", n.name, n.id)
}
type discordTagParser struct{}
// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting
var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`)
var defaultDiscordTagParser = &discordTagParser{}
func (s *discordTagParser) Trigger() []byte {
return []byte{'<'}
}
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
portal := pc.Get(parserContextPortal).(*Portal)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
if match == nil {
return nil
}
//seg := segment.WithStop(segment.Start + len(match[0]))
block.Advance(len(match[0]))
id, err := strconv.ParseInt(string(match[2]), 10, 64)
if err != nil {
return nil
}
tag := astDiscordTag{id: id, portal: portal}
tagName := string(match[1])
switch {
case tagName == "@":
return &astDiscordUserMention{astDiscordTag: tag}
case tagName == "@!":
return &astDiscordUserMention{astDiscordTag: tag, hasNick: true}
case tagName == "@&":
return &astDiscordRoleMention{astDiscordTag: tag}
case tagName == "#":
var guildID int64
var channelName string
if len(match[4]) > 0 && len(match[5]) > 0 {
guildID, _ = strconv.ParseInt(string(match[4]), 10, 64)
channelName = string(match[5])
}
return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName}
case tagName == "t:":
var style discordTimestampStyle
if len(match[3]) == 0 {
style = 'f'
} else {
style = discordTimestampStyle(match[3][0])
}
return &astDiscordTimestamp{
astDiscordTag: tag,
timestamp: id,
style: style,
}
case strings.HasPrefix(tagName, ":"):
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
case strings.HasPrefix(tagName, "a:"):
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
default:
return nil
}
}
func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordTagHTMLRenderer struct{}
var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordTag, r.renderDiscordMention)
}
func relativeTimeFormat(ts time.Time) string {
now := time.Now()
if ts.Year() >= 2262 {
return "date out of range for relative format"
}
duration := ts.Sub(now)
word := "in %s"
if duration < 0 {
duration = -duration
word = "%s ago"
}
var count int
var unit string
switch {
case duration < time.Second:
count = int(duration.Milliseconds())
unit = "millisecond"
case duration < time.Minute:
count = int(math.Round(duration.Seconds()))
unit = "second"
case duration < time.Hour:
count = int(math.Round(duration.Minutes()))
unit = "minute"
case duration < 24*time.Hour:
count = int(math.Round(duration.Hours()))
unit = "hour"
case duration < 30*24*time.Hour:
count = int(math.Round(duration.Hours() / 24))
unit = "day"
case duration < 365*24*time.Hour:
count = int(math.Round(duration.Hours() / 24 / 30))
unit = "month"
default:
count = int(math.Round(duration.Hours() / 24 / 365))
unit = "year"
}
var diff string
if count == 1 {
diff = fmt.Sprintf("a %s", unit)
} else {
diff = fmt.Sprintf("%d %ss", count, unit)
}
return fmt.Sprintf(word, diff)
}
func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
status = ast.WalkContinue
if !entering {
return
}
switch node := n.(type) {
case *astDiscordUserMention:
var mxid id.UserID
var name string
if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
mxid = puppet.MXID
name = puppet.Name
}
if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
mxid = user.MXID
if name == "" {
name = user.MXID.Localpart()
}
}
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), name)
return
case *astDiscordRoleMention:
role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
if role != nil {
_, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return
}
case *astDiscordChannelMention:
portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
ChannelID: strconv.FormatInt(node.id, 10),
Receiver: "",
})
if portal != nil {
if portal.MXID != "" {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
return
}
case *astDiscordCustomEmoji:
reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
if !reactionMXC.IsEmpty() {
attrs := "data-mx-emoticon"
if node.animated {
attrs += " data-mau-animated-emoji"
}
_, _ = fmt.Fprintf(w, `<img %[3]s src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name, attrs)
return
}
case *astDiscordTimestamp:
ts := time.Unix(node.timestamp, 0).UTC()
var formatted string
if node.style == 'R' {
formatted = relativeTimeFormat(ts)
} else {
formatted = ts.Format(node.style.Format())
}
// https://github.com/matrix-org/matrix-spec-proposals/pull/3160
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
fullRFC := ts.Format(fullDatetimeFormat)
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s" data-discord-style="%c"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, node.style, formatted)
}
stringifiable, ok := n.(fmt.Stringer)
if ok {
_, _ = w.WriteString(stringifiable.String())
} else {
_, _ = w.Write(source)
}
return
}
type discordTag struct{}
var ExtDiscordTag = &discordTag{}
func (e *discordTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordTagParser, 600),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(defaultDiscordTagHTMLRenderer, 600),
))
}

View file

@ -1,57 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEscapeDiscordMarkdown(t *testing.T) {
type escapeTest struct {
name string
input string
expected string
}
tests := []escapeTest{
{"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."},
{"Backslash", `foo\bar`, `foo\\bar`},
{"Underscore", `foo_bar`, `foo\_bar`},
{"Asterisk", `foo*bar`, `foo\*bar`},
{"Tilde", `foo~bar`, `foo\~bar`},
{"Backtick", "foo`bar", "foo\\`bar"},
{"Forward tick", `foo´bar`, `foo´bar`},
{"Pipe", `foo|bar`, `foo\|bar`},
{"Less than", `foo<bar`, `foo\<bar`},
{"Greater than", `foo>bar`, `foo>bar`},
{"Multiple things", `\_*~|`, `\\\_\*\~\|`},
{"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`},
{"Multiple URLs", `hello_world https://example.com/foo_bar *testing* https://a_b_c/*def*`, `hello\_world https://example.com/foo_bar \*testing\* https://a_b_c/*def*`},
{"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"},
{"URL ends with less than", `https://example.com<foo_bar`, `https://example.com<foo\_bar`},
{"Short URL", `https://_`, `https://_`},
{"Insecure URL", `http://example.com/foo_bar`, `http://example.com/foo_bar`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, escapeDiscordMarkdown(test.input))
})
}
}

40
go.mod
View file

@ -1,40 +1,36 @@
module go.mau.fi/mautrix-discord
go 1.21
go 1.22
require (
github.com/bwmarrin/discordgo v0.27.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.22
github.com/rs/zerolog v1.31.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.6.0
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848
golang.org/x/sync v0.5.0
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c
go.mau.fi/util v0.6.1-0.20240815104112-77362c9b05dd
maunium.net/go/mautrix v0.19.1-0.20240815131027-e50a705cec6d
)
require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/crypto v0.15.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.14.0 // indirect
github.com/yuin/goldmark v1.7.4 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect

61
go.sum
View file

@ -1,16 +1,12 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beeper/discordgo v0.0.0-20231013182643-f333f2578a3c h1:WaJ9eX8eyOBHD8te5t7xzm27uwhfaN94o8vUVFXliyA=
github.com/beeper/discordgo v0.0.0-20231013182643-f333f2578a3c/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
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/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@ -24,44 +20,47 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs=
go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw=
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.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.6.1-0.20240815104112-77362c9b05dd h1:rDu4R3axIbNzv/c2Izri81dMcDXOklQil7tUGivvfNs=
go.mau.fi/util v0.6.1-0.20240815104112-77362c9b05dd/go.mod h1:bWYreIoTULL/UiRbZdfddPh7uWDFW5yX4YCv5FB0eE0=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
@ -70,7 +69,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c h1:LHjqti3fFzrC8LXkkxxKYlLbuI/CJcwa2JN4Ppg2GK0=
maunium.net/go/mautrix v0.16.3-0.20240712164054-e6046fbf432c/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
maunium.net/go/mautrix v0.19.1-0.20240815131027-e50a705cec6d h1:W6pKdBjZQVH+SpPiM6VSMUaZdjxIOchs+njx1lmDMfs=
maunium.net/go/mautrix v0.19.1-0.20240815131027-e50a705cec6d/go.mod h1:BGpUGMF+TTNyJ7s6hyZzyNYIAedqIooxHn4+YUi11Ts=

View file

@ -1,334 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
"errors"
"fmt"
"sync"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/maulogger/v2/maulogadapt"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/bwmarrin/discordgo"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
)
type Guild struct {
*database.Guild
bridge *DiscordBridge
log log.Logger
roomCreateLock sync.Mutex
}
func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild {
if dbGuild == nil {
if id == "" || !createIfNotExist {
return nil
}
dbGuild = br.DB.Guild.New()
dbGuild.ID = id
dbGuild.Insert()
}
guild := br.NewGuild(dbGuild)
br.guildsByID[guild.ID] = guild
if guild.MXID != "" {
br.guildsByMXID[guild.MXID] = guild
}
return guild
}
func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
portal, ok := br.guildsByMXID[mxid]
if !ok {
return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false)
}
return portal
}
func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
guild, ok := br.guildsByID[id]
if !ok {
return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist)
}
return guild
}
func (br *DiscordBridge) GetAllGuilds() []*Guild {
return br.dbGuildsToGuilds(br.DB.Guild.GetAll())
}
func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild {
br.guildsLock.Lock()
defer br.guildsLock.Unlock()
output := make([]*Guild, len(dbGuilds))
for index, dbGuild := range dbGuilds {
if dbGuild == nil {
continue
}
guild, ok := br.guildsByID[dbGuild.ID]
if !ok {
guild = br.loadGuild(dbGuild, "", false)
}
output[index] = guild
}
return output
}
func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild {
guild := &Guild{
Guild: dbGuild,
bridge: br,
log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)),
}
return guild
}
func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) {
bridgeInfo := event.BridgeEventContent{
BridgeBot: guild.bridge.Bot.UserID,
Creator: guild.bridge.Bot.UserID,
Protocol: event.BridgeInfoSection{
ID: "discordgo",
DisplayName: "Discord",
AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
ExternalURL: "https://discord.com/",
},
Channel: event.BridgeInfoSection{
ID: guild.ID,
DisplayName: guild.Name,
AvatarURL: guild.AvatarURL.CUString(),
},
}
bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID)
return bridgeInfoStateKey, bridgeInfo
}
func (guild *Guild) UpdateBridgeInfo() {
if len(guild.MXID) == 0 {
guild.log.Debugln("Not updating bridge info: no Matrix room created")
return
}
guild.log.Debugln("Updating bridge info...")
stateKey, content := guild.getBridgeInfo()
_, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content)
if err != nil {
guild.log.Warnln("Failed to update m.bridge:", err)
}
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
_, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content)
if err != nil {
guild.log.Warnln("Failed to update uk.half-shot.bridge:", err)
}
}
func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
guild.roomCreateLock.Lock()
defer guild.roomCreateLock.Unlock()
if guild.MXID != "" {
return nil
}
guild.log.Infoln("Creating Matrix room for guild")
guild.UpdateInfo(user, meta)
bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo()
initialState := []*event.Event{{
Type: event.StateBridge,
Content: event.Content{Parsed: bridgeInfo},
StateKey: &bridgeInfoStateKey,
}, {
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
Type: event.StateHalfShotBridge,
Content: event.Content{Parsed: bridgeInfo},
StateKey: &bridgeInfoStateKey,
}}
if !guild.AvatarURL.IsEmpty() {
initialState = append(initialState, &event.Event{
Type: event.StateRoomAvatar,
Content: event.Content{Parsed: &event.RoomAvatarEventContent{
URL: guild.AvatarURL,
}},
})
}
creationContent := map[string]interface{}{
"type": event.RoomTypeSpace,
}
if !guild.bridge.Config.Bridge.FederateRooms {
creationContent["m.federate"] = false
}
resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
Visibility: "private",
Name: guild.Name,
Preset: "private_chat",
InitialState: initialState,
CreationContent: creationContent,
})
if err != nil {
guild.log.Warnln("Failed to create room:", err)
return err
}
guild.MXID = resp.RoomID
guild.NameSet = true
guild.AvatarSet = !guild.AvatarURL.IsEmpty()
guild.Update()
guild.bridge.guildsLock.Lock()
guild.bridge.guildsByMXID[guild.MXID] = guild
guild.bridge.guildsLock.Unlock()
guild.log.Infoln("Matrix room created:", guild.MXID)
user.ensureInvited(nil, guild.MXID, false, true)
return nil
}
func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
if meta.Unavailable {
guild.log.Debugfln("Ignoring unavailable guild update")
return meta
}
changed := false
changed = guild.UpdateName(meta) || changed
changed = guild.UpdateAvatar(meta.Icon) || changed
if changed {
guild.UpdateBridgeInfo()
guild.Update()
}
source.ensureInvited(nil, guild.MXID, false, false)
return meta
}
func (guild *Guild) UpdateName(meta *discordgo.Guild) bool {
name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{
Name: meta.Name,
})
if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") {
return false
}
guild.log.Debugfln("Updating name %q -> %q", guild.Name, name)
guild.Name = name
guild.PlainName = meta.Name
guild.NameSet = false
if guild.MXID != "" {
_, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name)
if err != nil {
guild.log.Warnln("Failed to update room name: %s", err)
} else {
guild.NameSet = true
}
}
return true
}
func (guild *Guild) UpdateAvatar(iconID string) bool {
if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") {
return false
}
guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID)
guild.AvatarSet = false
guild.Avatar = iconID
guild.AvatarURL = id.ContentURI{}
if guild.Avatar != "" {
// TODO direct media support
copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{
AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID),
})
if err != nil {
guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
return true
}
guild.AvatarURL = copied.MXC
}
if guild.MXID != "" {
_, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
if err != nil {
guild.log.Warnln("Failed to update room avatar:", err)
} else {
guild.AvatarSet = true
}
}
return true
}
func (guild *Guild) cleanup() {
if guild.MXID == "" {
return
}
intent := guild.bridge.Bot
if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
err := intent.BeeperDeleteRoom(guild.MXID)
if err != nil && !errors.Is(err, mautrix.MNotFound) {
guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
}
return
}
guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
}
func (guild *Guild) RemoveMXID() {
guild.bridge.guildsLock.Lock()
defer guild.bridge.guildsLock.Unlock()
if guild.MXID == "" {
return
}
delete(guild.bridge.guildsByMXID, guild.MXID)
guild.MXID = ""
guild.AvatarSet = false
guild.NameSet = false
guild.BridgingMode = database.GuildBridgeNothing
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()
}

206
main.go
View file

@ -1,206 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 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 (
_ "embed"
"net/http"
"sync"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/exsync"
"golang.org/x/sync/semaphore"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/config"
"go.mau.fi/mautrix-discord/database"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
//go:embed example-config.yaml
var ExampleConfig string
type DiscordBridge struct {
bridge.Bridge
Config *config.Config
DB *database.Database
DMA *DirectMediaAPI
provisioning *ProvisioningAPI
usersByMXID map[id.UserID]*User
usersByID map[string]*User
usersLock sync.Mutex
managementRooms map[id.RoomID]*User
managementRoomsLock sync.Mutex
portalsByMXID map[id.RoomID]*Portal
portalsByID map[database.PortalKey]*Portal
portalsLock sync.Mutex
threadsByID map[string]*Thread
threadsByRootMXID map[id.EventID]*Thread
threadsByCreationNoticeMXID map[id.EventID]*Thread
threadsLock sync.Mutex
guildsByMXID map[id.RoomID]*Guild
guildsByID map[string]*Guild
guildsLock sync.Mutex
puppets map[string]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
parallelAttachmentSemaphore *semaphore.Weighted
}
func (br *DiscordBridge) GetExampleConfig() string {
return ExampleConfig
}
func (br *DiscordBridge) GetConfigPtr() interface{} {
br.Config = &config.Config{
BaseConfig: &br.Bridge.Config,
}
br.Config.BaseConfig.Bridge = &br.Config.Bridge
return br.Config
}
func (br *DiscordBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
matrixHTMLParser.PillConverter = br.pillConverter
br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
}
func (br *DiscordBridge) Start() {
if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
br.provisioning = newProvisioningAPI(br)
}
if br.Config.Bridge.PublicAddress != "" {
br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet)
}
br.DMA = newDirectMediaAPI(br)
br.WaitWebsocketConnected()
go br.startUsers()
}
func (br *DiscordBridge) Stop() {
for _, user := range br.usersByMXID {
if user.Session == nil {
continue
}
br.Log.Debugln("Disconnecting", user.MXID)
user.Session.Close()
}
}
func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
p := br.GetPortalByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
p := br.GetUserByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
_, isGhost := br.ParsePuppetMXID(mxid)
return isGhost
}
func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
p := br.GetPuppetByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
//TODO implement
}
func main() {
br := &DiscordBridge{
usersByMXID: make(map[id.UserID]*User),
usersByID: make(map[string]*User),
managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal),
portalsByID: make(map[database.PortalKey]*Portal),
threadsByID: make(map[string]*Thread),
threadsByRootMXID: make(map[id.EventID]*Thread),
threadsByCreationNoticeMXID: make(map[id.EventID]*Thread),
guildsByID: make(map[string]*Guild),
guildsByMXID: make(map[id.RoomID]*Guild),
puppets: make(map[string]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
parallelAttachmentSemaphore: semaphore.NewWeighted(3),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-discord",
URL: "https://github.com/mautrix/discord",
Description: "A Matrix-Discord puppeting bridge.",
Version: "0.7.0",
ProtocolName: "Discord",
BeeperServiceName: "discordgo",
BeeperNetworkName: "discord",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
ConfigUpgrader: &configupgrade.StructUpgrader{
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
Blocks: config.SpacedBlocks,
Base: ExampleConfig,
},
Child: br,
}
br.InitVersion(Tag, Commit, BuildTime)
br.Main()
}

28
pkg/connector/chatinfo.go Normal file
View file

@ -0,0 +1,28 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"context"
"maunium.net/go/mautrix/bridgev2"
)
func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
//TODO implement me
panic("implement me")
}

58
pkg/connector/client.go Normal file
View file

@ -0,0 +1,58 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"context"
"maunium.net/go/mautrix/bridgev2"
)
type DiscordClient struct {
}
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
//TODO implement me
panic("implement me")
}
var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
func (d *DiscordClient) Connect(ctx context.Context) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) Disconnect() {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) IsLoggedIn() bool {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) LogoutRemote(ctx context.Context) {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities {
//TODO implement me
panic("implement me")
}

26
pkg/connector/config.go Normal file
View file

@ -0,0 +1,26 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"go.mau.fi/util/configupgrade"
)
func (d *DiscordConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) {
//TODO implement me
panic("implement me")
}

View file

@ -0,0 +1,48 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"context"
"maunium.net/go/mautrix/bridgev2"
)
type DiscordConnector struct {
}
var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
//TODO implement me
panic("implement me")
}
func (d *DiscordConnector) Start(ctx context.Context) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordConnector) GetName() bridgev2.BridgeName {
//TODO implement me
panic("implement me")
}
func (d *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
//TODO implement me
panic("implement me")
}

View file

@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// Copyright (C) 2024 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
@ -14,19 +14,13 @@
// 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 upgrades
package connector
import (
"embed"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/database"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.RegisterFS(rawUpgrades)
func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes {
//TODO implement me
panic("implement me")
}

View file

@ -0,0 +1,72 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"context"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
)
var (
_ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil)
)
func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
//TODO implement me
panic("implement me")
}

View file

@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
// Copyright (C) 2024 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
@ -14,22 +14,20 @@
// 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 config
package connector
import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
"context"
"maunium.net/go/mautrix/bridgev2"
)
type Config struct {
*bridgeconfig.BaseConfig `yaml:",inline"`
Bridge BridgeConfig `yaml:"bridge"`
func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow {
//TODO implement me
panic("implement me")
}
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
return hasSecret
func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
//TODO implement me
panic("implement me")
}

34
pkg/connector/userinfo.go Normal file
View file

@ -0,0 +1,34 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2024 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 connector
import (
"context"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
)
func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
//TODO implement me
panic("implement me")
}
func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
//TODO implement me
panic("implement me")
}

View file

@ -103,6 +103,7 @@ func (h *serverHello) process(client *Client) error {
ticker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
go func() {
defer ticker.Stop()
//lint:ignore S1000 -
for {
select {
// case <-client.ctx.Done():
@ -126,7 +127,7 @@ func (h *serverHello) process(client *Client) error {
<-time.After(duration)
client.Lock()
client.err = fmt.Errorf("Timed out after %s", duration)
client.err = fmt.Errorf("timed out after %s", duration)
client.close()
client.Unlock()
}()

2494
portal.go

File diff suppressed because it is too large Load diff

View file

@ -1,732 +0,0 @@
// 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 (
"context"
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
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(ctx context.Context, 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 {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
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 dbFile.DecryptionInfo != nil {
content.File = &event.EncryptedFileInfo{
EncryptedFile: *dbFile.DecryptionInfo,
URL: dbFile.MXC.CUString(),
}
} else {
content.URL = dbFile.MXC.CUString()
}
return content
}
func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
if content.Info == nil {
return
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else if 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
}
}
}
func (portal *Portal) convertDiscordSticker(ctx context.Context, 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:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
Str("sticker_id", sticker.ID).
Msg("Unknown sticker format")
}
content := &event.MessageEventContent{
Body: sticker.Name, // TODO find description from somewhere?
Info: &event.FileInfo{
MimeType: mime,
},
}
mxc := portal.bridge.DMA.StickerMXC(sticker.ID, sticker.FormatType)
// TODO add config option to use direct media even for lottie stickers
if mxc.IsEmpty() && mime != "application/json" {
content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
} else {
content.URL = mxc.CUString()
}
portal.cleanupConvertedStickerInfo(content)
return &ConvertedMessage{
AttachmentID: sticker.ID,
Type: event.EventSticker,
Content: content,
}
}
func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, messageID string, 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
}
var extra map[string]any
switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
case "audio":
content.MsgType = event.MsgAudio
if att.Waveform != nil {
// TODO convert waveform
extra = map[string]any{
"org.matrix.1767.audio": map[string]any{
"duration": int(att.DurationSeconds * 1000),
},
"org.matrix.msc3245.voice": map[string]any{},
}
}
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
default:
content.MsgType = event.MsgFile
}
mxc := portal.bridge.DMA.AttachmentMXC(portal.Key.ChannelID, messageID, att)
if mxc.IsEmpty() {
content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
} else {
content.URL = mxc.CUString()
}
return &ConvertedMessage{
AttachmentID: att.ID,
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
attachmentID := fmt.Sprintf("video_%s", embed.URL)
var proxyURL string
if embed.Video != nil {
proxyURL = embed.Video.ProxyURL
} else if embed.Thumbnail != nil {
proxyURL = embed.Thumbnail.ProxyURL
} else {
zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: &event.MessageEventContent{
Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
MsgType: event.MsgNotice,
},
}
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
return &ConvertedMessage{
AttachmentID: attachmentID,
Type: event.EventMessage,
Content: portal.createMediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: embed.URL,
Info: &event.FileInfo{
MimeType: dbFile.MimeType,
Size: dbFile.Size,
},
}
if embed.Video != nil {
content.MsgType = event.MsgVideo
content.Info.Width = embed.Video.Width
content.Info.Height = embed.Video.Height
} else {
content.MsgType = event.MsgImage
content.Info.Width = embed.Thumbnail.Width
content.Info.Height = embed.Thumbnail.Height
}
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 content.MsgType == event.MsgVideo && 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(ctx context.Context, puppet *Puppet, 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(ctx, intent, msg); textPart != nil {
parts = append(parts, textPart)
}
log := zerolog.Ctx(ctx)
handledIDs := make(map[string]struct{})
for _, att := range msg.Attachments {
if _, handled := handledIDs[att.ID]; handled {
continue
}
handledIDs[att.ID] = struct{}{}
log := log.With().Str("attachment_id", att.ID).Logger()
if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil {
parts = append(parts, part)
}
}
for _, sticker := range msg.StickerItems {
if _, handled := handledIDs[sticker.ID]; handled {
continue
}
handledIDs[sticker.ID] = struct{}{}
log := log.With().Str("sticker_id", sticker.ID).Logger()
if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
parts = append(parts, part)
}
}
for i, embed := range msg.Embeds {
// Ignore non-video embeds, they're handled in convertDiscordTextMessage
if getEmbedType(msg, 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{}{}
log := log.With().
Str("computed_embed_type", "video").
Str("embed_type", string(embed.Type)).
Int("embed_index", i).
Logger()
part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
if part != nil {
parts = append(parts, part)
}
}
if len(parts) == 0 && msg.Thread != nil {
parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
}})
}
for _, part := range parts {
puppet.addWebhookMeta(part, msg)
puppet.addMemberMeta(part, msg)
}
return parts
}
func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.Member == nil {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
var discordAvatarURL string
if msg.Member.Avatar != "" {
var err error
avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload guild user avatar")
}
}
part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
"nick": msg.Member.Nick,
"avatar_id": msg.Member.Avatar,
"avatar_url": discordAvatarURL,
"avatar_mxc": avatarURL.String(),
}
if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
perMessageProfile := map[string]any{
"is_multiple_users": false,
"displayname": msg.Member.Nick,
"avatar_url": avatarURL.String(),
}
if msg.Member.Nick == "" {
perMessageProfile["displayname"] = puppet.Name
}
if avatarURL.IsEmpty() {
perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
}
part.Extra["com.beeper.per_message_profile"] = perMessageProfile
}
}
func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
if msg.WebhookID == "" {
return
}
if part.Extra == nil {
part.Extra = make(map[string]any)
}
var avatarURL id.ContentURI
if msg.Author.Avatar != "" {
var err error
avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
if err != nil {
puppet.log.Warn().Err(err).
Str("avatar_id", msg.Author.Avatar).
Msg("Failed to reupload webhook avatar")
}
}
part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
"id": msg.WebhookID,
"name": msg.Author.Username,
"avatar_id": msg.Author.Avatar,
"avatar_url": msg.Author.AvatarURL(""),
"avatar_mxc": avatarURL.String(),
}
part.Extra["com.beeper.per_message_profile"] = map[string]any{
"is_multiple_users": true,
"avatar_url": avatarURL.String(),
"displayname": msg.Author.Username,
}
}
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(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
log := zerolog.Ctx(ctx)
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 {
log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
} 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 {
log.Warn().Err(err).Msg("Failed to reupload image in embed")
} 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 {
log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
} 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 {
log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
} 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(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
return
}
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(ctx context.Context, 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(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
} else if embed.Thumbnail != nil {
portal.convertDiscordLinkEmbedImage(ctx, 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(msg *discordgo.Message, 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.EmbedTypeImage:
if msg != nil && isPlainGifMessage(msg) {
return EmbedVideo
} else if embed.Image == nil && embed.Thumbnail != nil {
return EmbedLinkPreview
}
return EmbedRich
case discordgo.EmbedTypeRich:
return EmbedRich
default:
return EmbedUnknown
}
}
func isPlainGifMessage(msg *discordgo.Message) bool {
if len(msg.Embeds) != 1 {
return false
}
embed := msg.Embeds[0]
isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil
contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
return contentIsOnlyURL && (isGifVideo || isGifImage)
}
func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
var matrixMentions event.Mentions
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
if syncGhosts {
puppet.UpdateInfo(nil, mention, nil)
}
user := portal.bridge.GetUserByID(mention.ID)
if user != nil {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
} else {
matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
}
}
slices.Sort(matrixMentions.UserIDs)
matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
if msg.MentionEveryone {
matrixMentions.Room = true
}
return &matrixMentions
}
func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
log := zerolog.Ctx(ctx)
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, nil)
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, true))
}
previews := make([]*BeeperLinkPreview, 0)
for i, embed := range msg.Embeds {
if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
continue
}
with := log.With().
Str("embed_type", string(embed.Type)).
Int("embed_index", i)
switch getEmbedType(msg, embed) {
case EmbedRich:
log := with.Str("computed_embed_type", "rich").Logger()
htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
case EmbedLinkPreview:
log := with.Str("computed_embed_type", "link preview").Logger()
previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
case EmbedVideo:
// Ignore video embeds, they're handled as separate messages
default:
log := with.Logger()
log.Warn().Msg("Unknown embed type in message")
}
}
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,
}
if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
content.EnsureHasHTML()
content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
}
return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
}

View file

@ -1,552 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"net/http"
_ "net/http/pprof"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
"go.mau.fi/mautrix-discord/remoteauth"
)
const (
SecWebSocketProtocol = "com.gitlab.beeper.discord"
)
const (
ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
ErrCodeGuildBridgeFailed = "M_UNKNOWN"
ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
)
type ProvisioningAPI struct {
bridge *DiscordBridge
log log.Logger
}
func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
p := &ProvisioningAPI{
bridge: br,
log: br.Log.Sub("Provisioning"),
}
prefix := br.Config.Bridge.Provisioning.Prefix
p.log.Debugln("Enabling provisioning API at", prefix)
r := br.AS.Router.PathPrefix(prefix).Subrouter()
r.Use(p.authMiddleware)
r.HandleFunc("/v1/disconnect", p.disconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/ping", p.ping).Methods(http.MethodGet)
r.HandleFunc("/v1/login/qr", p.qrLogin).Methods(http.MethodGet)
r.HandleFunc("/v1/login/token", p.tokenLogin).Methods(http.MethodPost)
r.HandleFunc("/v1/logout", p.logout).Methods(http.MethodPost)
r.HandleFunc("/v1/reconnect", p.reconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/guilds", p.guildsList).Methods(http.MethodGet)
r.HandleFunc("/v1/guilds/{guildID}", p.guildsBridge).Methods(http.MethodPost)
r.HandleFunc("/v1/guilds/{guildID}", p.guildsUnbridge).Methods(http.MethodDelete)
if p.bridge.Config.Bridge.Provisioning.DebugEndpoints {
p.log.Debugln("Enabling debug API at /debug")
r := p.bridge.AS.Router.PathPrefix("/debug").Subrouter()
r.Use(p.authMiddleware)
r.PathPrefix("/pprof").Handler(http.DefaultServeMux)
}
return p
}
func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
// Response structs
type Response struct {
Success bool `json:"success"`
Status string `json:"status"`
}
type Error struct {
Success bool `json:"success"`
Error string `json:"error"`
ErrCode string `json:"errcode"`
}
// Wrapped http.ResponseWriter to capture the status code
type responseWrap struct {
http.ResponseWriter
statusCode int
}
var _ http.Hijacker = (*responseWrap)(nil)
func (rw *responseWrap) WriteHeader(statusCode int) {
rw.ResponseWriter.WriteHeader(statusCode)
rw.statusCode = statusCode
}
func (rw *responseWrap) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := rw.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("response does not implement http.Hijacker")
}
return hijacker.Hijack()
}
// Middleware
func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
// Special case the login endpoint to use the discord qrcode auth
if auth == "" && strings.HasSuffix(r.URL.Path, "/login") {
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
for _, part := range authParts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, SecWebSocketProtocol+"-") {
auth = part[len(SecWebSocketProtocol+"-"):]
break
}
}
} else if strings.HasPrefix(auth, "Bearer ") {
auth = auth[len("Bearer "):]
}
if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret {
jsonResponse(w, http.StatusUnauthorized, map[string]interface{}{
"error": "Invalid auth token",
"errcode": mautrix.MUnknownToken.ErrCode,
})
return
}
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
start := time.Now()
wWrap := &responseWrap{w, 200}
h.ServeHTTP(wWrap, r.WithContext(context.WithValue(r.Context(), "user", user)))
duration := time.Now().Sub(start).Seconds()
p.log.Infofln("%s %s from %s took %.2f seconds and returned status %d", r.Method, r.URL.Path, user.MXID, duration, wWrap.statusCode)
})
}
// websocket upgrader
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{SecWebSocketProtocol},
}
// Handlers
func (p *ProvisioningAPI) disconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if !user.Connected() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're not connected to discord",
ErrCode: ErrCodeNotConnected,
})
return
}
if err := user.Disconnect(); err != nil {
p.log.Errorfln("Failed to disconnect %s: %v", user.MXID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to disconnect from discord",
ErrCode: ErrCodeDisconnectFailed,
})
} else {
jsonResponse(w, http.StatusOK, Response{
Success: true,
Status: "Disconnected from Discord",
})
}
}
type respPing struct {
Discord struct {
ID string `json:"id,omitempty"`
LoggedIn bool `json:"logged_in"`
Connected bool `json:"connected"`
Conn struct {
LastHeartbeatAck int64 `json:"last_heartbeat_ack,omitempty"`
LastHeartbeatSent int64 `json:"last_heartbeat_sent,omitempty"`
} `json:"conn"`
}
MXID id.UserID `json:"mxid"`
ManagementRoom id.RoomID `json:"management_room"`
}
func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
resp := respPing{
MXID: user.MXID,
ManagementRoom: user.ManagementRoom,
}
resp.Discord.LoggedIn = user.IsLoggedIn()
resp.Discord.Connected = user.Connected()
resp.Discord.ID = user.DiscordID
if user.Session != nil {
resp.Discord.Conn.LastHeartbeatAck = user.Session.LastHeartbeatAck.UnixMilli()
resp.Discord.Conn.LastHeartbeatSent = user.Session.LastHeartbeatSent.UnixMilli()
}
jsonResponse(w, http.StatusOK, resp)
}
func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
var msg string
if user.DiscordID != "" {
msg = "Logged out successfully."
} else {
msg = "User wasn't logged in."
}
user.Logout(false)
jsonResponse(w, http.StatusOK, Response{true, msg})
}
func (p *ProvisioningAPI) qrLogin(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
p.log.Errorln("Failed to upgrade connection to websocket:", err)
return
}
log := p.log.Sub("QRLogin").Sub(user.MXID.String())
defer func() {
err := c.Close()
if err != nil {
log.Debugln("Error closing websocket:", err)
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err := c.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
c.SetCloseHandler(func(code int, text string) error {
log.Debugfln("Login websocket closed (%d), cancelling login", code)
cancel()
return nil
})
if user.IsLoggedIn() {
_ = c.WriteJSON(Error{
Error: "You're already logged into Discord",
ErrCode: ErrCodeAlreadyLoggedIn,
})
return
}
client, err := remoteauth.New()
if err != nil {
log.Errorln("Failed to prepare login:", err)
_ = c.WriteJSON(Error{
Error: "Failed to prepare login",
ErrCode: ErrCodeLoginPrepareFailed,
})
return
}
qrChan := make(chan string)
doneChan := make(chan struct{})
log.Debugln("Started login via provisioning API")
err = client.Dial(ctx, qrChan, doneChan)
if err != nil {
log.Errorln("Failed to connect to Discord login websocket:", err)
close(qrChan)
close(doneChan)
_ = c.WriteJSON(Error{
Error: "Failed to connect to Discord login websocket",
ErrCode: ErrCodeLoginConnectionFailed,
})
return
}
for {
select {
case qrCode, ok := <-qrChan:
if !ok {
continue
}
err = c.WriteJSON(map[string]interface{}{
"code": qrCode,
"timeout": 120, // TODO: move this to the library or something
})
if err != nil {
log.Errorln("Failed to write QR code to websocket:", err)
}
case <-doneChan:
var discordUser remoteauth.User
discordUser, err = client.Result()
if err != nil {
log.Errorln("Discord login websocket returned error:", err)
_ = c.WriteJSON(Error{
Error: "Failed to log in",
ErrCode: ErrCodeLoginFailed,
})
return
}
log.Infofln("Logged in as %s#%s (%s)", discordUser.Username, discordUser.Discriminator, discordUser.UserID)
if err = user.Login(discordUser.Token); err != nil {
log.Errorln("Failed to connect after logging in:", err)
_ = c.WriteJSON(Error{
Error: "Failed to connect to Discord after logging in",
ErrCode: ErrCodePostLoginConnFailed,
})
return
}
err = c.WriteJSON(respLogin{
Success: true,
ID: user.DiscordID,
Username: discordUser.Username,
Discriminator: discordUser.Discriminator,
})
if err != nil {
log.Errorln("Failed to write login success to websocket:", err)
}
return
case <-ctx.Done():
return
}
}
}
type respLogin struct {
Success bool `json:"success"`
ID string `json:"id"`
Username string `json:"username"`
Discriminator string `json:"discriminator"`
}
type reqTokenLogin struct {
Token string `json:"token"`
}
func (p *ProvisioningAPI) tokenLogin(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user := p.bridge.GetUserByMXID(id.UserID(userID))
log := p.log.Sub("TokenLogin").Sub(user.MXID.String())
if user.IsLoggedIn() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're already logged into Discord",
ErrCode: ErrCodeAlreadyLoggedIn,
})
return
}
var body reqTokenLogin
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Errorln("Failed to parse login request:", err)
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Failed to parse request body",
ErrCode: mautrix.MBadJSON.ErrCode,
})
return
}
if err := user.Login(body.Token); err != nil {
log.Errorln("Failed to connect with provided token:", err)
jsonResponse(w, http.StatusUnauthorized, Error{
Error: "Failed to connect to Discord",
ErrCode: ErrCodePostLoginConnFailed,
})
return
}
log.Infoln("Successfully logged in")
jsonResponse(w, http.StatusOK, respLogin{
Success: true,
ID: user.DiscordID,
Username: user.Session.State.User.Username,
Discriminator: user.Session.State.User.Discriminator,
})
}
func (p *ProvisioningAPI) reconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Connected() {
jsonResponse(w, http.StatusConflict, Error{
Error: "You're already connected to discord",
ErrCode: ErrCodeAlreadyConnected,
})
return
}
if err := user.Connect(); err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to connect to discord",
ErrCode: ErrCodeConnectFailed,
})
} else {
jsonResponse(w, http.StatusOK, Response{
Success: true,
Status: "Connected to Discord",
})
}
}
type guildEntry struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarURL id.ContentURI `json:"avatar_url"`
MXID id.RoomID `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
}
type respGuildsList struct {
Guilds []guildEntry `json:"guilds"`
}
func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
var resp respGuildsList
resp.Guilds = []guildEntry{}
for _, userGuild := range user.GetPortals() {
guild := p.bridge.GetGuildByID(userGuild.DiscordID, false)
if guild == nil {
continue
}
resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID,
Name: guild.PlainName,
AvatarURL: guild.AvatarURL,
MXID: guild.MXID,
AutoBridge: guild.BridgingMode == database.GuildBridgeEverything,
BridgingMode: guild.BridgingMode.String(),
})
}
jsonResponse(w, http.StatusOK, resp)
}
type reqBridgeGuild struct {
AutoCreateChannels bool `json:"auto_create_channels"`
}
type respBridgeGuild struct {
Success bool `json:"success"`
MXID id.RoomID `json:"mxid"`
}
func (p *ProvisioningAPI) guildsBridge(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
guildID := mux.Vars(r)["guildID"]
var body reqBridgeGuild
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
p.log.Errorln("Failed to parse bridge request:", err)
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Failed to parse request body",
ErrCode: mautrix.MBadJSON.ErrCode,
})
return
}
guild := user.bridge.GetGuildByID(guildID, false)
if guild == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
return
}
alreadyExists := guild.MXID == ""
if err := user.bridgeGuild(guildID, body.AutoCreateChannels); err != nil {
p.log.Errorfln("Error bridging %s: %v", guildID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal error while trying to bridge guild",
ErrCode: ErrCodeGuildBridgeFailed,
})
} else if alreadyExists {
jsonResponse(w, http.StatusOK, respBridgeGuild{
Success: true,
MXID: guild.MXID,
})
} else {
jsonResponse(w, http.StatusCreated, respBridgeGuild{
Success: true,
MXID: guild.MXID,
})
}
}
func (p *ProvisioningAPI) guildsUnbridge(w http.ResponseWriter, r *http.Request) {
guildID := mux.Vars(r)["guildID"]
user := r.Context().Value("user").(*User)
if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin {
jsonResponse(w, http.StatusForbidden, Error{
Error: "Only bridge admins can unbridge guilds",
ErrCode: mautrix.MForbidden.ErrCode,
})
} else if guild := user.bridge.GetGuildByID(guildID, false); guild == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "Guild not found",
ErrCode: mautrix.MNotFound.ErrCode,
})
} else if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
jsonResponse(w, http.StatusNotFound, Error{
Error: "That guild is not bridged",
ErrCode: ErrCodeGuildNotBridged,
})
} else if err := user.unbridgeGuild(guildID); err != nil {
p.log.Errorfln("Error unbridging %s: %v", guildID, err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal error while trying to unbridge guild",
ErrCode: ErrCodeGuildUnbridgeFailed,
})
} else {
w.WriteHeader(http.StatusNoContent)
}
}

386
puppet.go
View file

@ -1,386 +0,0 @@
package main
import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
type Puppet struct {
*database.Puppet
bridge *DiscordBridge
log zerolog.Logger
MXID id.UserID
customIntent *appservice.IntentAPI
customUser *User
syncLock sync.Mutex
}
var _ bridge.Ghost = (*Puppet)(nil)
var _ bridge.GhostWithProfile = (*Puppet)(nil)
func (puppet *Puppet) GetMXID() id.UserID {
return puppet.MXID
}
var userIDRegex *regexp.Regexp
func (br *DiscordBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{
Puppet: dbPuppet,
bridge: br,
log: br.ZLog.With().Str("discord_user_id", dbPuppet.ID).Logger(),
MXID: br.FormatPuppetMXID(dbPuppet.ID),
}
}
func (br *DiscordBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) {
if userIDRegex == nil {
pattern := fmt.Sprintf(
"^@%s:%s$",
br.Config.Bridge.FormatUsername("([0-9]+)"),
br.Config.Homeserver.Domain,
)
userIDRegex = regexp.MustCompile(pattern)
}
match := userIDRegex.FindStringSubmatch(string(mxid))
if len(match) == 2 {
return match[1], true
}
return "", false
}
func (br *DiscordBridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
discordID, ok := br.ParsePuppetMXID(mxid)
if !ok {
return nil
}
return br.GetPuppetByID(discordID)
}
func (br *DiscordBridge) GetPuppetByID(id string) *Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppets[id]
if !ok {
dbPuppet := br.DB.Puppet.Get(id)
if dbPuppet == nil {
dbPuppet = br.DB.Puppet.New()
dbPuppet.ID = id
dbPuppet.Insert()
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.ID] = puppet
}
return puppet
}
func (br *DiscordBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppetsByCustomMXID[mxid]
if !ok {
dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid)
if dbPuppet == nil {
return nil
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.ID] = puppet
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
return puppet
}
func (br *DiscordBridge) GetAllPuppetsWithCustomMXID() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID())
}
func (br *DiscordBridge) GetAllPuppets() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll())
}
func (br *DiscordBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
output := make([]*Puppet, len(dbPuppets))
for index, dbPuppet := range dbPuppets {
if dbPuppet == nil {
continue
}
puppet, ok := br.puppets[dbPuppet.ID]
if !ok {
puppet = br.NewPuppet(dbPuppet)
br.puppets[dbPuppet.ID] = puppet
if dbPuppet.CustomMXID != "" {
br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
}
}
output[index] = puppet
}
return output
}
func (br *DiscordBridge) FormatPuppetMXID(did string) id.UserID {
return id.NewUserID(
br.Config.Bridge.FormatUsername(did),
br.Config.Homeserver.Domain,
)
}
func (puppet *Puppet) GetDisplayname() string {
return puppet.Name
}
func (puppet *Puppet) GetAvatarURL() id.ContentURI {
return puppet.AvatarURL
}
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
return puppet.bridge.AS.Intent(puppet.MXID)
}
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
if puppet.customIntent == nil || (portal.Key.Receiver != "" && portal.Key.Receiver != puppet.ID) {
return puppet.DefaultIntent()
}
return puppet.customIntent
}
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
if puppet == nil {
return nil
}
return puppet.customIntent
}
func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.ID) {
// Get room create lock to prevent races between receiving contact info and room creation.
portal.roomCreateLock.Lock()
meta(portal)
portal.roomCreateLock.Unlock()
}
}
func (puppet *Puppet) UpdateName(info *discordgo.User) bool {
newName := puppet.bridge.Config.Bridge.FormatDisplayname(info, puppet.IsWebhook, puppet.IsApplication)
if puppet.Name == newName && puppet.NameSet {
return false
}
puppet.Name = newName
puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(newName)
if err != nil {
puppet.log.Warn().Err(err).Msg("Failed to update displayname")
} else {
go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateNameDirect(puppet.Name, false) {
portal.Update()
portal.UpdateBridgeInfo()
}
})
puppet.NameSet = true
}
return true
}
func (br *DiscordBridge) reuploadUserAvatar(intent *appservice.IntentAPI, guildID, userID, avatarID string) (id.ContentURI, string, error) {
var downloadURL string
if guildID == "" {
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointUserAvatarAnimated(userID, avatarID)
} else {
downloadURL = discordgo.EndpointUserAvatar(userID, avatarID)
}
} else {
if strings.HasPrefix(avatarID, "a_") {
downloadURL = discordgo.EndpointGuildMemberAvatarAnimated(guildID, userID, avatarID)
} else {
downloadURL = discordgo.EndpointGuildMemberAvatar(guildID, userID, avatarID)
}
}
url := br.DMA.AvatarMXC(guildID, userID, avatarID)
if !url.IsEmpty() {
return url, downloadURL, nil
}
copied, err := br.copyAttachmentToMatrix(intent, downloadURL, false, AttachmentMeta{
AttachmentID: fmt.Sprintf("avatar/%s/%s/%s", guildID, userID, avatarID),
})
if err != nil {
return id.ContentURI{}, downloadURL, err
}
return copied.MXC, downloadURL, nil
}
func (puppet *Puppet) UpdateAvatar(info *discordgo.User) bool {
avatarID := info.Avatar
if puppet.IsWebhook && !puppet.bridge.Config.Bridge.EnableWebhookAvatars {
avatarID = ""
}
if puppet.Avatar == avatarID && puppet.AvatarSet {
return false
}
avatarChanged := avatarID != puppet.Avatar
puppet.Avatar = avatarID
puppet.AvatarSet = false
puppet.AvatarURL = id.ContentURI{}
if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) {
url, _, err := puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", info.ID, puppet.Avatar)
if err != nil {
puppet.log.Warn().Err(err).Str("avatar_id", puppet.Avatar).Msg("Failed to reupload user avatar")
return true
}
puppet.AvatarURL = url
}
err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil {
puppet.log.Warn().Err(err).Msg("Failed to update avatar")
} else {
go puppet.updatePortalMeta(func(portal *Portal) {
if portal.UpdateAvatarFromPuppet(puppet) {
portal.Update()
portal.UpdateBridgeInfo()
}
})
puppet.AvatarSet = true
}
return true
}
func (puppet *Puppet) UpdateInfo(source *User, info *discordgo.User, message *discordgo.Message) {
puppet.syncLock.Lock()
defer puppet.syncLock.Unlock()
if info == nil || len(info.Username) == 0 || len(info.Discriminator) == 0 {
if puppet.Name != "" || source == nil {
return
}
var err error
puppet.log.Debug().Str("source_user", source.DiscordID).Msg("Fetching info through user to update puppet")
info, err = source.Session.User(puppet.ID)
if err != nil {
puppet.log.Error().Err(err).Str("source_user", source.DiscordID).Msg("Failed to fetch info through user")
return
}
}
err := puppet.DefaultIntent().EnsureRegistered()
if err != nil {
puppet.log.Error().Err(err).Msg("Failed to ensure registered")
}
changed := false
if message != nil {
if message.WebhookID != "" && message.ApplicationID == "" && !puppet.IsWebhook {
puppet.log.Debug().
Str("message_id", message.ID).
Str("webhook_id", message.WebhookID).
Msg("Found webhook ID in message, marking ghost as a webhook")
puppet.IsWebhook = true
changed = true
}
if message.ApplicationID != "" && !puppet.IsApplication {
puppet.log.Debug().
Str("message_id", message.ID).
Str("application_id", message.ApplicationID).
Msg("Found application ID in message, marking ghost as an application")
puppet.IsApplication = true
puppet.IsWebhook = false
changed = true
}
}
changed = puppet.UpdateContactInfo(info) || changed
changed = puppet.UpdateName(info) || changed
changed = puppet.UpdateAvatar(info) || changed
if changed {
puppet.Update()
}
}
func (puppet *Puppet) UpdateContactInfo(info *discordgo.User) bool {
changed := false
if puppet.Username != info.Username {
puppet.Username = info.Username
changed = true
}
if puppet.GlobalName != info.GlobalName {
puppet.GlobalName = info.GlobalName
changed = true
}
if puppet.Discriminator != info.Discriminator {
puppet.Discriminator = info.Discriminator
changed = true
}
if puppet.IsBot != info.Bot {
puppet.IsBot = info.Bot
changed = true
}
if (changed && !puppet.IsWebhook) || !puppet.ContactInfoSet {
puppet.ContactInfoSet = false
puppet.ResendContactInfo()
return true
}
return false
}
func (puppet *Puppet) ResendContactInfo() {
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
return
}
discordUsername := puppet.Username
if puppet.Discriminator != "0" {
discordUsername += "#" + puppet.Discriminator
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": []string{
fmt.Sprintf("discord:%s", discordUsername),
},
"com.beeper.bridge.remote_id": puppet.ID,
"com.beeper.bridge.service": puppet.bridge.BeeperServiceName,
"com.beeper.bridge.network": puppet.bridge.BeeperNetworkName,
"com.beeper.bridge.is_network_bot": puppet.IsBot,
}
if puppet.IsWebhook {
contactInfo["com.beeper.bridge.identifiers"] = []string{}
}
err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo)
if err != nil {
puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile")
} else {
puppet.ContactInfoSet = true
}
}

157
thread.go
View file

@ -1,157 +0,0 @@
package main
import (
"context"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
type Thread struct {
*database.Thread
Parent *Portal
creationNoticeLock sync.Mutex
initialBackfillAttempted bool
}
func (br *DiscordBridge) GetThreadByID(id string, root *database.Message) *Thread {
br.threadsLock.Lock()
defer br.threadsLock.Unlock()
thread, ok := br.threadsByID[id]
if !ok {
return br.loadThread(br.DB.Thread.GetByDiscordID(id), id, root)
}
return thread
}
func (br *DiscordBridge) GetThreadByRootMXID(mxid id.EventID) *Thread {
br.threadsLock.Lock()
defer br.threadsLock.Unlock()
thread, ok := br.threadsByRootMXID[mxid]
if !ok {
return br.loadThread(br.DB.Thread.GetByMatrixRootMsg(mxid), "", nil)
}
return thread
}
func (br *DiscordBridge) GetThreadByRootOrCreationNoticeMXID(mxid id.EventID) *Thread {
br.threadsLock.Lock()
defer br.threadsLock.Unlock()
thread, ok := br.threadsByRootMXID[mxid]
if !ok {
thread, ok = br.threadsByCreationNoticeMXID[mxid]
if !ok {
return br.loadThread(br.DB.Thread.GetByMatrixRootOrCreationNoticeMsg(mxid), "", nil)
}
}
return thread
}
func (br *DiscordBridge) loadThread(dbThread *database.Thread, id string, root *database.Message) *Thread {
if dbThread == nil {
if root == nil {
return nil
}
dbThread = br.DB.Thread.New()
dbThread.ID = id
dbThread.RootDiscordID = root.DiscordID
dbThread.RootMXID = root.MXID
dbThread.ParentID = root.Channel.ChannelID
dbThread.Insert()
}
thread := &Thread{
Thread: dbThread,
}
thread.Parent = br.GetExistingPortalByID(database.NewPortalKey(thread.ParentID, ""))
br.threadsByID[thread.ID] = thread
br.threadsByRootMXID[thread.RootMXID] = thread
if thread.CreationNoticeMXID != "" {
br.threadsByCreationNoticeMXID[thread.CreationNoticeMXID] = thread
}
return thread
}
func (br *DiscordBridge) threadFound(ctx context.Context, source *User, rootMessage *database.Message, id string, metadata *discordgo.Channel) {
thread := br.GetThreadByID(id, rootMessage)
log := zerolog.Ctx(ctx)
log.Debug().Msg("Marked message as thread root")
if thread.CreationNoticeMXID == "" {
thread.Parent.sendThreadCreationNotice(ctx, thread)
}
// TODO member_ids_preview is probably not guaranteed to contain the source user
if source != nil && metadata != nil && slices.Contains(metadata.MemberIDsPreview, source.DiscordID) && !source.IsInPortal(thread.ID) {
source.MarkInPortal(database.UserPortal{
DiscordID: thread.ID,
Type: database.UserPortalTypeThread,
Timestamp: time.Now(),
})
if metadata.MessageCount > 0 {
go thread.maybeInitialBackfill(source)
} else {
thread.initialBackfillAttempted = true
}
}
}
func (thread *Thread) maybeInitialBackfill(source *User) {
if thread.initialBackfillAttempted || thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread == 0 {
return
}
thread.Parent.forwardBackfillLock.Lock()
if thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID) != nil {
thread.Parent.forwardBackfillLock.Unlock()
return
}
thread.Parent.forwardBackfillInitial(source, thread)
}
func (thread *Thread) Join(user *User) {
if user.IsInPortal(thread.ID) {
return
}
log := user.log.With().Str("thread_id", thread.ID).Str("channel_id", thread.ParentID).Logger()
log.Debug().Msg("Joining thread")
var doBackfill, backfillStarted bool
if !thread.initialBackfillAttempted && thread.Parent.bridge.Config.Bridge.Backfill.Limits.Initial.Thread > 0 {
thread.Parent.forwardBackfillLock.Lock()
lastMessage := thread.Parent.bridge.DB.Message.GetLastInThread(thread.Parent.Key, thread.ID)
if lastMessage != nil {
thread.Parent.forwardBackfillLock.Unlock()
} else {
doBackfill = true
defer func() {
if !backfillStarted {
thread.Parent.forwardBackfillLock.Unlock()
}
}()
}
}
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 {
log.Error().Err(err).Msg("Error joining thread")
} else {
user.MarkInPortal(database.UserPortal{
DiscordID: thread.ID,
Type: database.UserPortalTypeThread,
Timestamp: time.Now(),
})
if doBackfill {
go thread.Parent.forwardBackfillInitial(user, thread)
backfillStarted = true
}
}
}

1484
user.go

File diff suppressed because it is too large Load diff