diff --git a/bridge/attachments.go b/attachments.go
similarity index 73%
rename from bridge/attachments.go
rename to attachments.go
index c3e8569..b0de607 100644
--- a/bridge/attachments.go
+++ b/attachments.go
@@ -1,4 +1,4 @@
-package bridge
+package main
import (
"bytes"
@@ -15,7 +15,7 @@ import (
"maunium.net/go/mautrix/id"
)
-func (p *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
+func (portal *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
// We might want to make this save to disk in the future. Discord defaults
// to 8mb for all attachments to a messages for non-nitro users and
// non-boosted servers.
@@ -42,7 +42,7 @@ func (p *Portal) downloadDiscordAttachment(url string) ([]byte, error) {
return ioutil.ReadAll(resp.Body)
}
-func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.MessageEventContent) ([]byte, error) {
+func (portal *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
@@ -53,22 +53,22 @@ func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.Mes
mxc, err := rawMXC.Parse()
if err != nil {
- p.log.Errorln("Malformed content URL in %s: %v", eventID, err)
+ portal.log.Errorln("Malformed content URL in %s: %v", eventID, err)
return nil, err
}
- data, err := p.MainIntent().DownloadBytes(mxc)
+ data, err := portal.MainIntent().DownloadBytes(mxc)
if err != nil {
- p.log.Errorfln("Failed to download media in %s: %v", eventID, err)
+ portal.log.Errorfln("Failed to download media in %s: %v", eventID, err)
return nil, err
}
if file != nil {
- data, err = file.Decrypt(data)
+ err = file.DecryptInPlace(data)
if err != nil {
- p.log.Errorfln("Failed to decrypt media in %s: %v", eventID, err)
+ portal.log.Errorfln("Failed to decrypt media in %s: %v", eventID, err)
return nil, err
}
}
@@ -76,13 +76,13 @@ func (p *Portal) downloadMatrixAttachment(eventID id.EventID, content *event.Mes
return data, nil
}
-func (p *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
+func (portal *Portal) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: content.Info.MimeType,
}
var mxc id.ContentURI
- if p.bridge.Config.Homeserver.AsyncMedia {
+ if portal.bridge.Config.Homeserver.AsyncMedia {
uploaded, err := intent.UnstableUploadAsync(req)
if err != nil {
return err
diff --git a/bridge/avatar.go b/avatar.go
similarity index 97%
rename from bridge/avatar.go
rename to avatar.go
index 94af2a9..272c038 100644
--- a/bridge/avatar.go
+++ b/avatar.go
@@ -1,4 +1,4 @@
-package bridge
+package main
import (
"fmt"
diff --git a/bridge/bot.go b/bridge/bot.go
deleted file mode 100644
index e5f3914..0000000
--- a/bridge/bot.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package bridge
-
-import (
- "maunium.net/go/mautrix/id"
-)
-
-func (b *Bridge) updateBotProfile() {
- cfg := b.Config.Appservice.Bot
-
- // Set the bot's avatar.
- if cfg.Avatar != "" {
- var err error
- var mxc id.ContentURI
-
- if cfg.Avatar == "remove" {
- err = b.bot.SetAvatarURL(mxc)
- } else {
- mxc, err = id.ParseContentURI(cfg.Avatar)
- if err == nil {
- err = b.bot.SetAvatarURL(mxc)
- }
- }
- if err != nil {
- b.log.Warnln("failed to update the bot's avatar: ", err)
- }
- }
-
- // Update the bot's display name.
- if cfg.Displayname != "" {
- var err error
-
- if cfg.Displayname == "remove" {
- err = b.bot.SetDisplayName("")
- } else {
- err = b.bot.SetDisplayName(cfg.Displayname)
- }
-
- if err != nil {
- b.log.Warnln("failed to update the bot's display name", err)
- }
- }
-}
diff --git a/bridge/bridge.go b/bridge/bridge.go
deleted file mode 100644
index 720ea83..0000000
--- a/bridge/bridge.go
+++ /dev/null
@@ -1,203 +0,0 @@
-package bridge
-
-import (
- "errors"
- "fmt"
- "sync"
- "time"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
- "go.mau.fi/mautrix-discord/version"
-)
-
-const (
- reconnectDelay = 10 * time.Second
-)
-
-type Bridge struct {
- Config *config.Config
-
- log log.Logger
-
- as *appservice.AppService
- db *database.Database
- eventProcessor *appservice.EventProcessor
- matrixHandler *matrixHandler
- bot *appservice.IntentAPI
- 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
-
- puppets map[string]*Puppet
- puppetsByCustomMXID map[id.UserID]*Puppet
- puppetsLock sync.Mutex
-
- StateStore *database.SQLStateStore
-
- crypto Crypto
-}
-
-func New(cfg *config.Config) (*Bridge, error) {
- // Create the logger.
- logger, err := cfg.CreateLogger()
- if err != nil {
- return nil, err
- }
-
- logger.Infoln("Initializing version", version.String)
-
- // Create and initialize the app service.
- appservice, err := cfg.CreateAppService()
- if err != nil {
- return nil, err
- }
- appservice.Log = log.Sub("matrix")
-
- appservice.Init()
-
- // Create the bot.
- bot := appservice.BotIntent()
-
- // Setup the database.
- db, err := cfg.CreateDatabase(logger)
- if err != nil {
- return nil, err
- }
-
- // Create the state store
- logger.Debugln("Initializing state store")
- stateStore := database.NewSQLStateStore(db)
- appservice.StateStore = stateStore
-
- // Create the bridge.
- bridge := &Bridge{
- as: appservice,
- db: db,
- bot: bot,
- Config: cfg,
- log: logger,
-
- 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),
-
- puppets: make(map[string]*Puppet),
- puppetsByCustomMXID: make(map[id.UserID]*Puppet),
-
- StateStore: stateStore,
- }
-
- bridge.crypto = NewCryptoHelper(bridge)
-
- if cfg.Appservice.Provisioning.Enabled() {
- bridge.provisioning = newProvisioningAPI(bridge)
- }
-
- // Setup the event processors
- bridge.setupEvents()
-
- return bridge, nil
-}
-
-func (b *Bridge) connect() error {
- b.log.Debugln("Checking connection to homeserver")
-
- for {
- resp, err := b.bot.Whoami()
- if err != nil {
- if errors.Is(err, mautrix.MUnknownToken) {
- b.log.Fatalln("Access token invalid. Is the registration installed in your homeserver correctly?")
-
- return fmt.Errorf("invalid access token")
- }
-
- b.log.Errorfln("Failed to connect to homeserver : %v", err)
- b.log.Errorfln("reconnecting in %s", reconnectDelay)
-
- time.Sleep(reconnectDelay)
- } else if resp.UserID != b.bot.UserID {
- b.log.Fatalln("Unexpected user ID in whoami call: got %s, expected %s", resp.UserID, b.bot.UserID)
-
- return fmt.Errorf("expected user id %q but got %q", b.bot.UserID, resp.UserID)
- } else {
- break
- }
- }
-
- b.log.Debugln("Connected to homeserver")
-
- return nil
-}
-
-func (b *Bridge) Start() error {
- b.log.Infoln("Bridge started")
-
- if err := b.connect(); err != nil {
- return err
- }
-
- if b.crypto != nil {
- if err := b.crypto.Init(); err != nil {
- b.log.Fatalln("Error initializing end-to-bridge encryption:", err)
- return err
- }
- }
-
- b.log.Debugln("Starting application service HTTP server")
- go b.as.Start()
-
- b.log.Debugln("Starting event processor")
- go b.eventProcessor.Start()
-
- go b.updateBotProfile()
-
- if b.crypto != nil {
- go b.crypto.Start()
- }
-
- go b.startUsers()
-
- // Finally tell the appservice we're ready
- b.as.Ready = true
-
- return nil
-}
-
-func (b *Bridge) Stop() {
- if b.crypto != nil {
- b.crypto.Stop()
- }
-
- b.as.Stop()
- b.eventProcessor.Stop()
-
- for _, user := range b.usersByMXID {
- if user.Session == nil {
- continue
- }
-
- b.log.Debugln("Disconnecting", user.MXID)
- user.Session.Close()
- }
-
- b.log.Infoln("Bridge stopped")
-}
diff --git a/bridge/commandhandler.go b/bridge/commandhandler.go
deleted file mode 100644
index 30cecc6..0000000
--- a/bridge/commandhandler.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package bridge
-
-import (
- "fmt"
- "strings"
-
- "github.com/alecthomas/kong"
- "github.com/google/shlex"
-
- "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type commandHandler struct {
- bridge *Bridge
- log maulogger.Logger
-}
-
-func newCommandHandler(bridge *Bridge) *commandHandler {
- return &commandHandler{
- bridge: bridge,
- log: bridge.log.Sub("Commands"),
- }
-}
-
-func commandsHelpPrinter(options kong.HelpOptions, ctx *kong.Context) error {
- selected := ctx.Selected()
-
- if selected == nil {
- for _, cmd := range ctx.Model.Leaves(true) {
- fmt.Fprintf(ctx.Stdout, " * %s - %s\n", cmd.Path(), cmd.Help)
- }
- } else {
- fmt.Fprintf(ctx.Stdout, "%s - %s\n", selected.Path(), selected.Help)
- if selected.Detail != "" {
- fmt.Fprintf(ctx.Stdout, "\n%s\n", selected.Detail)
- }
- if len(selected.Positional) > 0 {
- fmt.Fprintf(ctx.Stdout, "\nArguments:\n")
- for _, arg := range selected.Positional {
- fmt.Fprintf(ctx.Stdout, "%s %s\n", arg.Summary(), arg.Help)
- }
- }
- }
-
- return nil
-}
-
-func (h *commandHandler) handle(roomID id.RoomID, user *User, message string, replyTo id.EventID) {
- cmd := commands{
- globals: globals{
- bot: h.bridge.bot,
- bridge: h.bridge,
- portal: h.bridge.GetPortalByMXID(roomID),
- handler: h,
- roomID: roomID,
- user: user,
- replyTo: replyTo,
- },
- }
-
- buf := &strings.Builder{}
-
- parse, err := kong.New(
- &cmd,
- kong.Exit(func(int) {}),
- kong.NoDefaultHelp(),
- kong.Writers(buf, buf),
- kong.Help(commandsHelpPrinter),
- )
-
- if err != nil {
- h.log.Warnf("Failed to create argument parser for %q: %v", roomID, err)
-
- cmd.globals.reply("unexpected error, please try again shortly")
-
- return
- }
-
- args, err := shlex.Split(message)
- if err != nil {
- h.log.Warnf("Failed to split message %q: %v", message, err)
-
- cmd.globals.reply("failed to process the command")
-
- return
- }
-
- ctx, err := parse.Parse(args)
- if err != nil {
- h.log.Warnf("Failed to parse command %q: %v", message, err)
-
- cmd.globals.reply(fmt.Sprintf("failed to process the command: %v", err))
-
- return
- }
-
- cmd.globals.context = ctx
-
- err = ctx.Run(&cmd.globals)
- if err != nil {
- h.log.Warnf("Command %q failed: %v", message, err)
-
- output := buf.String()
- if output != "" {
- cmd.globals.reply(output)
- } else {
- cmd.globals.reply("unexpected failure")
- }
-
- return
- }
-
- if buf.Len() > 0 {
- cmd.globals.reply(buf.String())
- }
-}
diff --git a/bridge/commands.go b/bridge/commands.go
deleted file mode 100644
index 25b707e..0000000
--- a/bridge/commands.go
+++ /dev/null
@@ -1,360 +0,0 @@
-package bridge
-
-import (
- "context"
- "fmt"
-
- "github.com/alecthomas/kong"
-
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/consts"
- "go.mau.fi/mautrix-discord/remoteauth"
- "go.mau.fi/mautrix-discord/version"
-)
-
-type globals struct {
- context *kong.Context
-
- bridge *Bridge
- bot *appservice.IntentAPI
- portal *Portal
- handler *commandHandler
- roomID id.RoomID
- user *User
- replyTo id.EventID
-}
-
-func (g *globals) reply(msg string) {
- content := format.RenderMarkdown(msg, true, false)
- content.MsgType = event.MsgNotice
- intent := g.bot
-
- if g.portal != nil && g.portal.IsPrivateChat() {
- intent = g.portal.MainIntent()
- }
-
- _, err := intent.SendMessageEvent(g.roomID, event.EventMessage, content)
- if err != nil {
- g.handler.log.Warnfln("Failed to reply to command from %q: %v", g.user.MXID, err)
- }
-}
-
-type commands struct {
- globals
-
- Disconnect disconnectCmd `kong:"cmd,help='Disconnect from Discord'"`
- Help helpCmd `kong:"cmd,help='Displays this message.'"`
- Login loginCmd `kong:"cmd,help='Log in to Discord.'"`
- Logout logoutCmd `kong:"cmd,help='Log out of Discord.'"`
- Reconnect reconnectCmd `kong:"cmd,help='Reconnect to Discord'"`
- Version versionCmd `kong:"cmd,help='Displays the version of the bridge.'"`
-
- Guilds guildsCmd `kong:"cmd,help='Guild bridging management.'"`
-
- LoginMatrix loginMatrixCmd `kong:"cmd,help='Replace the puppet for your Discord account with your real Matrix account.'"`
- LogoutMatrix logoutMatrixCmd `kong:"cmd,help='Switch the puppet for your Discord account back to the default one.'"`
- PingMatrix pingMatrixCmd `kong:"cmd,help='check if your double puppet is working properly'"`
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Help Command
-///////////////////////////////////////////////////////////////////////////////
-type helpCmd struct {
- Command []string `kong:"arg,optional,help='The command to get help on.'"`
-}
-
-func (c *helpCmd) Run(g *globals) error {
- ctx, err := kong.Trace(g.context.Kong, c.Command)
- if err != nil {
- return err
- }
-
- if ctx.Error != nil {
- return err
- }
-
- err = ctx.PrintUsage(true)
- if err != nil {
- return err
- }
-
- fmt.Fprintln(g.context.Stdout)
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Version Command
-///////////////////////////////////////////////////////////////////////////////
-type versionCmd struct{}
-
-func (c *versionCmd) Run(g *globals) error {
- fmt.Fprintln(g.context.Stdout, consts.Name, version.String)
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Login Command
-///////////////////////////////////////////////////////////////////////////////
-type loginCmd struct{}
-
-func (l *loginCmd) Run(g *globals) error {
- if g.user.LoggedIn() {
- fmt.Fprintf(g.context.Stdout, "You are already logged in")
-
- return fmt.Errorf("user already logged in")
- }
-
- client, err := remoteauth.New()
- if err != nil {
- return err
- }
-
- qrChan := make(chan string)
- doneChan := make(chan struct{})
-
- var qrCodeEvent id.EventID
-
- go func() {
- code := <-qrChan
-
- resp, err := g.user.sendQRCode(g.bot, g.roomID, code)
- if err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to generate the qrcode")
-
- return
- }
-
- qrCodeEvent = resp
- }()
-
- ctx := context.Background()
-
- if err := client.Dial(ctx, qrChan, doneChan); err != nil {
- close(qrChan)
- close(doneChan)
-
- return err
- }
-
- <-doneChan
-
- if qrCodeEvent != "" {
- _, err := g.bot.RedactEvent(g.roomID, qrCodeEvent)
- if err != nil {
- fmt.Errorf("Failed to redact the qrcode: %v", err)
- }
- }
-
- user, err := client.Result()
- if err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to log in")
-
- return err
- }
-
- if err := g.user.Login(user.Token); err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to login", err)
-
- return err
- }
-
- g.user.Lock()
- g.user.ID = user.UserID
- g.user.Update()
- g.user.Unlock()
-
- fmt.Fprintln(g.context.Stdout, "Successfully logged in")
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Logout Command
-///////////////////////////////////////////////////////////////////////////////
-type logoutCmd struct{}
-
-func (l *logoutCmd) Run(g *globals) error {
- if !g.user.LoggedIn() {
- fmt.Fprintln(g.context.Stdout, "You are not logged in")
-
- return fmt.Errorf("user is not logged in")
- }
-
- err := g.user.Logout()
- if err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to log out")
-
- return err
- }
-
- fmt.Fprintln(g.context.Stdout, "Successfully logged out")
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Disconnect Command
-///////////////////////////////////////////////////////////////////////////////
-type disconnectCmd struct{}
-
-func (d *disconnectCmd) Run(g *globals) error {
- if !g.user.Connected() {
- fmt.Fprintln(g.context.Stdout, "You are not connected")
-
- return fmt.Errorf("user is not connected")
- }
-
- if err := g.user.Disconnect(); err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to disconnect")
-
- return err
- }
-
- fmt.Fprintln(g.context.Stdout, "Successfully disconnected")
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Reconnect Command
-///////////////////////////////////////////////////////////////////////////////
-type reconnectCmd struct{}
-
-func (r *reconnectCmd) Run(g *globals) error {
- if g.user.Connected() {
- fmt.Fprintln(g.context.Stdout, "You are already connected")
-
- return fmt.Errorf("user is already connected")
- }
-
- if err := g.user.Connect(); err != nil {
- fmt.Fprintln(g.context.Stdout, "Failed to connect")
-
- return err
- }
-
- fmt.Fprintln(g.context.Stdout, "Successfully connected")
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// LoginMatrix Command
-///////////////////////////////////////////////////////////////////////////////
-type loginMatrixCmd struct {
- AccessToken string `kong:"arg,help='The shared secret to use the bridge'"`
-}
-
-func (m *loginMatrixCmd) Run(g *globals) error {
- puppet := g.bridge.GetPuppetByID(g.user.ID)
-
- err := puppet.SwitchCustomMXID(m.AccessToken, g.user.MXID)
- if err != nil {
- fmt.Fprintf(g.context.Stdout, "Failed to switch puppet: %v", err)
-
- return err
- }
-
- fmt.Fprintf(g.context.Stdout, "Successfully switched puppet")
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// LogoutMatrix Command
-///////////////////////////////////////////////////////////////////////////////
-type logoutMatrixCmd struct{}
-
-func (m *logoutMatrixCmd) Run(g *globals) error {
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// PingMatrix Command
-///////////////////////////////////////////////////////////////////////////////
-type pingMatrixCmd struct{}
-
-func (m *pingMatrixCmd) Run(g *globals) error {
- puppet := g.bridge.GetPuppetByCustomMXID(g.user.MXID)
- if puppet == nil || puppet.CustomIntent() == nil {
- fmt.Fprintf(g.context.Stdout, "You have not changed your Discord account's Matrix puppet.")
-
- return fmt.Errorf("double puppet not configured")
- }
-
- resp, err := puppet.CustomIntent().Whoami()
- if err != nil {
- fmt.Fprintf(g.context.Stdout, "Failed to validate Matrix login: %v", err)
-
- return err
- }
-
- fmt.Fprintf(g.context.Stdout, "Confirmed valid access token for %s / %s", resp.UserID, resp.DeviceID)
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// Guilds Commands
-///////////////////////////////////////////////////////////////////////////////
-type guildsCmd struct {
- Status guildStatusCmd `kong:"cmd,help='Show the bridge status for the guilds you are in'"`
- Bridge guildBridgeCmd `kong:"cmd,help='Bridge a guild'"`
- Unbridge guildUnbridgeCmd `kong:"cmd,help='Unbridge a guild'"`
-}
-
-type guildStatusCmd struct{}
-
-func (c *guildStatusCmd) Run(g *globals) error {
- g.user.guildsLock.Lock()
- defer g.user.guildsLock.Unlock()
-
- if len(g.user.guilds) == 0 {
- fmt.Fprintf(g.context.Stdout, "you haven't joined any guilds.")
- } else {
- for _, guild := range g.user.guilds {
- status := "not bridged"
- if guild.Bridge {
- status = "bridged"
- }
- fmt.Fprintf(g.context.Stdout, "%s %s %s\n", guild.GuildName, guild.GuildID, status)
- }
- }
-
- return nil
-}
-
-type guildBridgeCmd struct {
- GuildID string `kong:"arg,help='the id of the guild to unbridge'"`
- Entire bool `kong:"flag,help='whether or not to bridge all channels'"`
-}
-
-func (c *guildBridgeCmd) Run(g *globals) error {
- if err := g.user.bridgeGuild(c.GuildID, c.Entire); err != nil {
- return err
- }
-
- fmt.Fprintf(g.context.Stdout, "Successfully bridged guild %s", c.GuildID)
-
- return nil
-}
-
-type guildUnbridgeCmd struct {
- GuildID string `kong:"arg,help='the id of the guild to unbridge'"`
-}
-
-func (c *guildUnbridgeCmd) Run(g *globals) error {
- if err := g.user.unbridgeGuild(c.GuildID); err != nil {
- return err
- }
-
- fmt.Fprintf(g.context.Stdout, "Successfully unbridged guild %s", c.GuildID)
-
- return nil
-}
diff --git a/bridge/crypto.go b/bridge/crypto.go
deleted file mode 100644
index 343eedb..0000000
--- a/bridge/crypto.go
+++ /dev/null
@@ -1,339 +0,0 @@
-package bridge
-
-import (
- "fmt"
- "runtime/debug"
- "time"
-
- "maunium.net/go/maulogger/v2"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/crypto"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-var NoSessionFound = crypto.NoSessionFound
-
-var levelTrace = maulogger.Level{
- Name: "TRACE",
- Severity: -10,
- Color: -1,
-}
-
-type Crypto interface {
- HandleMemberEvent(*event.Event)
- Decrypt(*event.Event) (*event.Event, error)
- Encrypt(id.RoomID, event.Type, event.Content) (*event.EncryptedEventContent, error)
- WaitForSession(id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
- RequestSession(id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
- ResetSession(id.RoomID)
- Init() error
- Start()
- Stop()
-}
-
-type CryptoHelper struct {
- bridge *Bridge
- client *mautrix.Client
- mach *crypto.OlmMachine
- store *database.SQLCryptoStore
- log maulogger.Logger
- baseLog maulogger.Logger
-}
-
-func NewCryptoHelper(bridge *Bridge) Crypto {
- if !bridge.Config.Bridge.Encryption.Allow {
- bridge.log.Debugln("Bridge built with end-to-bridge encryption, but disabled in config")
- return nil
- }
-
- baseLog := bridge.log.Sub("Crypto")
- return &CryptoHelper{
- bridge: bridge,
- log: baseLog.Sub("Helper"),
- baseLog: baseLog,
- }
-}
-
-func (helper *CryptoHelper) Init() error {
- helper.log.Debugln("Initializing end-to-bridge encryption...")
-
- helper.store = database.NewSQLCryptoStore(helper.bridge.db, helper.bridge.as.BotMXID(),
- fmt.Sprintf("@%s:%s", helper.bridge.Config.Bridge.FormatUsername("%"), helper.bridge.as.HomeserverDomain))
-
- var err error
- helper.client, err = helper.loginBot()
- if err != nil {
- return err
- }
-
- helper.log.Debugln("Logged in as bridge bot with device ID", helper.client.DeviceID)
-
- logger := &cryptoLogger{helper.baseLog}
- stateStore := &cryptoStateStore{helper.bridge}
- helper.mach = crypto.NewOlmMachine(helper.client, logger, helper.store, stateStore)
- helper.mach.AllowKeyShare = helper.allowKeyShare
-
- helper.client.Syncer = &cryptoSyncer{helper.mach}
- helper.client.Store = &cryptoClientStore{helper.store}
-
- return helper.mach.Load()
-}
-
-func (helper *CryptoHelper) allowKeyShare(device *crypto.DeviceIdentity, info event.RequestedKeyInfo) *crypto.KeyShareRejection {
- cfg := helper.bridge.Config.Bridge.Encryption.KeySharing
- if !cfg.Allow {
- return &crypto.KeyShareRejectNoResponse
- } else if device.Trust == crypto.TrustStateBlacklisted {
- return &crypto.KeyShareRejectBlacklisted
- } else if device.Trust == crypto.TrustStateVerified || !cfg.RequireVerification {
- portal := helper.bridge.GetPortalByMXID(info.RoomID)
- if portal == nil {
- helper.log.Debugfln("Rejecting key request for %s from %s/%s: room is not a portal", info.SessionID, device.UserID, device.DeviceID)
-
- return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
- }
- user := helper.bridge.GetUserByMXID(device.UserID)
- // FIXME reimplement IsInPortal
- if !user.Admin /*&& !user.IsInPortal(portal.Key)*/ {
- helper.log.Debugfln("Rejecting key request for %s from %s/%s: user is not in portal", info.SessionID, device.UserID, device.DeviceID)
-
- return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
- }
- helper.log.Debugfln("Accepting key request for %s from %s/%s", info.SessionID, device.UserID, device.DeviceID)
-
- return nil
- }
-
- return &crypto.KeyShareRejectUnverified
-}
-
-func (helper *CryptoHelper) loginBot() (*mautrix.Client, error) {
- deviceID := helper.store.FindDeviceID()
- if len(deviceID) > 0 {
- helper.log.Debugln("Found existing device ID for bot in database:", deviceID)
- }
-
- client, err := mautrix.NewClient(helper.bridge.as.HomeserverURL, "", "")
- if err != nil {
- return nil, fmt.Errorf("failed to initialize client: %w", err)
- }
-
- client.Logger = helper.baseLog.Sub("Bot")
- client.Client = helper.bridge.as.HTTPClient
- client.DefaultHTTPRetries = helper.bridge.as.DefaultHTTPRetries
- flows, err := client.GetLoginFlows()
- if err != nil {
- return nil, fmt.Errorf("failed to get supported login flows: %w", err)
- }
-
- flow := flows.FirstFlowOfType(mautrix.AuthTypeAppservice, mautrix.AuthTypeHalfyAppservice)
- if flow == nil {
- return nil, fmt.Errorf("homeserver does not support appservice login")
- }
-
- // We set the API token to the AS token here to authenticate the appservice login
- // It'll get overridden after the login
- client.AccessToken = helper.bridge.as.Registration.AppToken
- resp, err := client.Login(&mautrix.ReqLogin{
- Type: flow.Type,
- Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(helper.bridge.as.BotMXID())},
- DeviceID: deviceID,
- InitialDeviceDisplayName: "Discord Bridge",
- StoreCredentials: true,
- })
- if err != nil {
- return nil, fmt.Errorf("failed to log in as bridge bot: %w", err)
- }
-
- helper.store.DeviceID = resp.DeviceID
-
- return client, nil
-}
-
-func (helper *CryptoHelper) Start() {
- helper.log.Debugln("Starting syncer for receiving to-device messages")
-
- err := helper.client.Sync()
- if err != nil {
- helper.log.Errorln("Fatal error syncing:", err)
- } else {
- helper.log.Infoln("Bridge bot to-device syncer stopped without error")
- }
-}
-
-func (helper *CryptoHelper) Stop() {
- helper.log.Debugln("CryptoHelper.Stop() called, stopping bridge bot sync")
- helper.client.StopSync()
-}
-
-func (helper *CryptoHelper) Decrypt(evt *event.Event) (*event.Event, error) {
- return helper.mach.DecryptMegolmEvent(evt)
-}
-
-func (helper *CryptoHelper) Encrypt(roomID id.RoomID, evtType event.Type, content event.Content) (*event.EncryptedEventContent, error) {
- encrypted, err := helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
-
- if err != nil {
- if err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession {
- return nil, err
- }
-
- helper.log.Debugfln("Got %v while encrypting event for %s, sharing group session and trying again...", err, roomID)
- users, err := helper.store.GetRoomMembers(roomID)
- if err != nil {
- return nil, fmt.Errorf("failed to get room member list: %w", err)
- }
-
- err = helper.mach.ShareGroupSession(roomID, users)
- if err != nil {
- return nil, fmt.Errorf("failed to share group session: %w", err)
- }
-
- encrypted, err = helper.mach.EncryptMegolmEvent(roomID, evtType, &content)
- if err != nil {
- return nil, fmt.Errorf("failed to encrypt event after re-sharing group session: %w", err)
- }
- }
-
- return encrypted, nil
-}
-
-func (helper *CryptoHelper) WaitForSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
- return helper.mach.WaitForSession(roomID, senderKey, sessionID, timeout)
-}
-
-func (helper *CryptoHelper) RequestSession(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
- err := helper.mach.SendRoomKeyRequest(roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{userID: {deviceID}})
- if err != nil {
- helper.log.Warnfln("Failed to send key request to %s/%s for %s in %s: %v", userID, deviceID, sessionID, roomID, err)
- } else {
- helper.log.Debugfln("Sent key request to %s/%s for %s in %s", userID, deviceID, sessionID, roomID)
- }
-}
-
-func (helper *CryptoHelper) ResetSession(roomID id.RoomID) {
- err := helper.mach.CryptoStore.RemoveOutboundGroupSession(roomID)
- if err != nil {
- helper.log.Debugfln("Error manually removing outbound group session in %s: %v", roomID, err)
- }
-}
-
-func (helper *CryptoHelper) HandleMemberEvent(evt *event.Event) {
- helper.mach.HandleMemberEvent(evt)
-}
-
-type cryptoSyncer struct {
- *crypto.OlmMachine
-}
-
-func (syncer *cryptoSyncer) ProcessResponse(resp *mautrix.RespSync, since string) error {
- done := make(chan struct{})
- go func() {
- defer func() {
- if err := recover(); err != nil {
- syncer.Log.Error("Processing sync response (%s) panicked: %v\n%s", since, err, debug.Stack())
- }
- done <- struct{}{}
- }()
- syncer.Log.Trace("Starting sync response handling (%s)", since)
- syncer.ProcessSyncResponse(resp, since)
- syncer.Log.Trace("Successfully handled sync response (%s)", since)
- }()
-
- select {
- case <-done:
- case <-time.After(30 * time.Second):
- syncer.Log.Warn("Handling sync response (%s) is taking unusually long", since)
- }
-
- return nil
-}
-
-func (syncer *cryptoSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
- syncer.Log.Error("Error /syncing, waiting 10 seconds: %v", err)
-
- return 10 * time.Second, nil
-}
-
-func (syncer *cryptoSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
- everything := []event.Type{{Type: "*"}}
-
- return &mautrix.Filter{
- Presence: mautrix.FilterPart{NotTypes: everything},
- AccountData: mautrix.FilterPart{NotTypes: everything},
- Room: mautrix.RoomFilter{
- IncludeLeave: false,
- Ephemeral: mautrix.FilterPart{NotTypes: everything},
- AccountData: mautrix.FilterPart{NotTypes: everything},
- State: mautrix.FilterPart{NotTypes: everything},
- Timeline: mautrix.FilterPart{NotTypes: everything},
- },
- }
-}
-
-type cryptoLogger struct {
- int maulogger.Logger
-}
-
-func (c *cryptoLogger) Error(message string, args ...interface{}) {
- c.int.Errorfln(message, args...)
-}
-
-func (c *cryptoLogger) Warn(message string, args ...interface{}) {
- c.int.Warnfln(message, args...)
-}
-
-func (c *cryptoLogger) Debug(message string, args ...interface{}) {
- c.int.Debugfln(message, args...)
-}
-
-func (c *cryptoLogger) Trace(message string, args ...interface{}) {
- c.int.Logfln(levelTrace, message, args...)
-}
-
-type cryptoClientStore struct {
- int *database.SQLCryptoStore
-}
-
-func (c cryptoClientStore) SaveFilterID(_ id.UserID, _ string) {}
-func (c cryptoClientStore) LoadFilterID(_ id.UserID) string { return "" }
-func (c cryptoClientStore) SaveRoom(_ *mautrix.Room) {}
-func (c cryptoClientStore) LoadRoom(_ id.RoomID) *mautrix.Room { return nil }
-
-func (c cryptoClientStore) SaveNextBatch(_ id.UserID, nextBatchToken string) {
- c.int.PutNextBatch(nextBatchToken)
-}
-
-func (c cryptoClientStore) LoadNextBatch(_ id.UserID) string {
- return c.int.GetNextBatch()
-}
-
-var _ mautrix.Storer = (*cryptoClientStore)(nil)
-
-type cryptoStateStore struct {
- bridge *Bridge
-}
-
-var _ crypto.StateStore = (*cryptoStateStore)(nil)
-
-func (c *cryptoStateStore) IsEncrypted(id id.RoomID) bool {
- portal := c.bridge.GetPortalByMXID(id)
- if portal != nil {
- return portal.Encrypted
- }
-
- return false
-}
-
-func (c *cryptoStateStore) FindSharedRooms(id id.UserID) []id.RoomID {
- return c.bridge.StateStore.FindSharedRooms(id)
-}
-
-func (c *cryptoStateStore) GetEncryptionEvent(id.RoomID) *event.EncryptionEventContent {
- // TODO implement
- return nil
-}
diff --git a/bridge/custompuppet.go b/bridge/custompuppet.go
deleted file mode 100644
index afe009e..0000000
--- a/bridge/custompuppet.go
+++ /dev/null
@@ -1,337 +0,0 @@
-package bridge
-
-import (
- "crypto/hmac"
- "crypto/sha512"
- "encoding/hex"
- "errors"
- "fmt"
- "time"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-)
-
-var (
- ErrNoCustomMXID = errors.New("no custom mxid set")
- ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
-)
-
-///////////////////////////////////////////////////////////////////////////////
-// additional bridge api
-///////////////////////////////////////////////////////////////////////////////
-func (b *Bridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
- _, homeserver, err := mxid.Parse()
- if err != nil {
- return nil, err
- }
-
- homeserverURL, found := b.Config.Bridge.DoublePuppetServerMap[homeserver]
- if !found {
- if homeserver == b.as.HomeserverDomain {
- homeserverURL = b.as.HomeserverURL
- } else if b.Config.Bridge.DoublePuppetAllowDiscovery {
- resp, err := mautrix.DiscoverClientAPI(homeserver)
- if err != nil {
- return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
- }
-
- homeserverURL = resp.Homeserver.BaseURL
- b.log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
- } else {
- return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
- }
- }
-
- client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
- if err != nil {
- return nil, err
- }
-
- client.Logger = b.as.Log.Sub(mxid.String())
- client.Client = b.as.HTTPClient
- client.DefaultHTTPRetries = b.as.DefaultHTTPRetries
-
- return client, nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// mautrix.Syncer implementation
-///////////////////////////////////////////////////////////////////////////////
-func (p *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter {
- everything := []event.Type{{Type: "*"}}
- return &mautrix.Filter{
- Presence: mautrix.FilterPart{
- Senders: []id.UserID{p.CustomMXID},
- Types: []event.Type{event.EphemeralEventPresence},
- },
- AccountData: mautrix.FilterPart{NotTypes: everything},
- Room: mautrix.RoomFilter{
- Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}},
- IncludeLeave: false,
- AccountData: mautrix.FilterPart{NotTypes: everything},
- State: mautrix.FilterPart{NotTypes: everything},
- Timeline: mautrix.FilterPart{NotTypes: everything},
- },
- }
-}
-
-func (p *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
- p.log.Warnln("Sync error:", err)
- if errors.Is(err, mautrix.MUnknownToken) {
- if !p.tryRelogin(err, "syncing") {
- return 0, err
- }
-
- p.customIntent.AccessToken = p.AccessToken
-
- return 0, nil
- }
-
- return 10 * time.Second, nil
-}
-
-func (p *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error {
- if !p.customUser.LoggedIn() {
- p.log.Debugln("Skipping sync processing: custom user not connected to discord")
-
- return nil
- }
-
- // for roomID, events := range resp.Rooms.Join {
- // for _, evt := range events.Ephemeral.Events {
- // evt.RoomID = roomID
- // err := evt.Content.ParseRaw(evt.Type)
- // if err != nil {
- // continue
- // }
-
- // switch evt.Type {
- // case event.EphemeralEventReceipt:
- // if p.EnableReceipts {
- // go p.bridge.matrixHandler.HandleReceipt(evt)
- // }
- // case event.EphemeralEventTyping:
- // go p.bridge.matrixHandler.HandleTyping(evt)
- // }
- // }
- // }
-
- // if p.EnablePresence {
- // for _, evt := range resp.Presence.Events {
- // if evt.Sender != p.CustomMXID {
- // continue
- // }
-
- // err := evt.Content.ParseRaw(evt.Type)
- // if err != nil {
- // continue
- // }
-
- // go p.bridge.matrixHandler.HandlePresence(evt)
- // }
- // }
-
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// mautrix.Storer implementation
-///////////////////////////////////////////////////////////////////////////////
-func (p *Puppet) SaveFilterID(_ id.UserID, _ string) {
-}
-
-func (p *Puppet) SaveNextBatch(_ id.UserID, nbt string) {
- p.NextBatch = nbt
- p.Update()
-}
-
-func (p *Puppet) SaveRoom(_ *mautrix.Room) {
-}
-
-func (p *Puppet) LoadFilterID(_ id.UserID) string {
- return ""
-}
-
-func (p *Puppet) LoadNextBatch(_ id.UserID) string {
- return p.NextBatch
-}
-
-func (p *Puppet) LoadRoom(_ id.RoomID) *mautrix.Room {
- return nil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-// additional puppet api
-///////////////////////////////////////////////////////////////////////////////
-func (p *Puppet) clearCustomMXID() {
- p.CustomMXID = ""
- p.AccessToken = ""
- p.customIntent = nil
- p.customUser = nil
-}
-
-func (p *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
- if p.CustomMXID == "" {
- return nil, ErrNoCustomMXID
- }
-
- client, err := p.bridge.newDoublePuppetClient(p.CustomMXID, p.AccessToken)
- if err != nil {
- return nil, err
- }
-
- client.Syncer = p
- client.Store = p
-
- ia := p.bridge.as.NewIntentAPI("custom")
- ia.Client = client
- ia.Localpart, _, _ = p.CustomMXID.Parse()
- ia.UserID = p.CustomMXID
- ia.IsCustomPuppet = true
-
- return ia, nil
-}
-
-func (p *Puppet) StartCustomMXID(reloginOnFail bool) error {
- if p.CustomMXID == "" {
- p.clearCustomMXID()
-
- return nil
- }
-
- intent, err := p.newCustomIntent()
- if err != nil {
- p.clearCustomMXID()
-
- return err
- }
-
- resp, err := intent.Whoami()
- if err != nil {
- if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !p.tryRelogin(err, "initializing double puppeting")) {
- p.clearCustomMXID()
-
- return err
- }
-
- intent.AccessToken = p.AccessToken
- } else if resp.UserID != p.CustomMXID {
- p.clearCustomMXID()
-
- return ErrMismatchingMXID
- }
-
- p.customIntent = intent
- p.customUser = p.bridge.GetUserByMXID(p.CustomMXID)
- p.startSyncing()
-
- return nil
-}
-
-func (p *Puppet) tryRelogin(cause error, action string) bool {
- if !p.bridge.Config.CanAutoDoublePuppet(p.CustomMXID) {
- return false
- }
-
- p.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
-
- accessToken, err := p.loginWithSharedSecret(p.CustomMXID)
- if err != nil {
- p.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
-
- return false
- }
-
- p.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
- p.AccessToken = accessToken
-
- return true
-}
-
-func (p *Puppet) startSyncing() {
- if !p.bridge.Config.Bridge.SyncWithCustomPuppets {
- return
- }
-
- go func() {
- p.log.Debugln("Starting syncing...")
- p.customIntent.SyncPresence = "offline"
-
- err := p.customIntent.Sync()
- if err != nil {
- p.log.Errorln("Fatal error syncing:", err)
- }
- }()
-}
-
-func (p *Puppet) stopSyncing() {
- if !p.bridge.Config.Bridge.SyncWithCustomPuppets {
- return
- }
-
- p.customIntent.StopSync()
-}
-
-func (p *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
- _, homeserver, _ := mxid.Parse()
-
- p.log.Debugfln("Logging into %s with shared secret", mxid)
-
- mac := hmac.New(sha512.New, []byte(p.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]))
- mac.Write([]byte(mxid))
-
- client, err := p.bridge.newDoublePuppetClient(mxid, "")
- if err != nil {
- return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
- }
-
- resp, err := client.Login(&mautrix.ReqLogin{
- Type: mautrix.AuthTypePassword,
- Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
- Password: hex.EncodeToString(mac.Sum(nil)),
- DeviceID: "Discord Bridge",
- InitialDeviceDisplayName: "Discord Bridge",
- })
- if err != nil {
- return "", err
- }
-
- return resp.AccessToken, nil
-}
-
-func (p *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
- prevCustomMXID := p.CustomMXID
- if p.customIntent != nil {
- p.stopSyncing()
- }
-
- p.CustomMXID = mxid
- p.AccessToken = accessToken
-
- err := p.StartCustomMXID(false)
- if err != nil {
- return err
- }
-
- if prevCustomMXID != "" {
- delete(p.bridge.puppetsByCustomMXID, prevCustomMXID)
- }
-
- if p.CustomMXID != "" {
- p.bridge.puppetsByCustomMXID[p.CustomMXID] = p
- }
-
- p.EnablePresence = p.bridge.Config.Bridge.DefaultBridgePresence
- p.EnableReceipts = p.bridge.Config.Bridge.DefaultBridgeReceipts
-
- p.bridge.as.StateStore.MarkRegistered(p.CustomMXID)
-
- p.Update()
-
- // TODO leave rooms with default puppet
-
- return nil
-}
diff --git a/bridge/matrix.go b/bridge/matrix.go
deleted file mode 100644
index 536c35a..0000000
--- a/bridge/matrix.go
+++ /dev/null
@@ -1,376 +0,0 @@
-package bridge
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
-
- "maunium.net/go/maulogger/v2"
- "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 matrixHandler struct {
- as *appservice.AppService
- bridge *Bridge
- log maulogger.Logger
- cmd *commandHandler
-}
-
-func (b *Bridge) setupEvents() {
- b.eventProcessor = appservice.NewEventProcessor(b.as)
-
- b.matrixHandler = &matrixHandler{
- as: b.as,
- bridge: b,
- log: b.log.Sub("Matrix"),
- cmd: newCommandHandler(b),
- }
-
- b.eventProcessor.On(event.EventMessage, b.matrixHandler.handleMessage)
- b.eventProcessor.On(event.EventEncrypted, b.matrixHandler.handleEncrypted)
- b.eventProcessor.On(event.EventReaction, b.matrixHandler.handleReaction)
- b.eventProcessor.On(event.EventRedaction, b.matrixHandler.handleRedaction)
- b.eventProcessor.On(event.StateMember, b.matrixHandler.handleMembership)
- b.eventProcessor.On(event.StateEncryption, b.matrixHandler.handleEncryption)
-}
-
-func (mh *matrixHandler) join(evt *event.Event, intent *appservice.IntentAPI) *mautrix.RespJoinedMembers {
- resp, err := intent.JoinRoomByID(evt.RoomID)
- if err != nil {
- mh.log.Debugfln("Failed to join room %s as %s with invite from %s: %v", evt.RoomID, intent.UserID, evt.Sender, err)
-
- return nil
- }
-
- members, err := intent.JoinedMembers(resp.RoomID)
- if err != nil {
- intent.LeaveRoom(resp.RoomID)
-
- mh.log.Debugfln("Failed to get members in room %s after accepting invite from %s as %s: %v", resp.RoomID, evt.Sender, intent.UserID, err)
-
- return nil
- }
-
- if len(members.Joined) < 2 {
- intent.LeaveRoom(resp.RoomID)
-
- mh.log.Debugln("Leaving empty room", resp.RoomID, "after accepting invite from", evt.Sender, "as", intent.UserID)
-
- return nil
- }
-
- return members
-}
-
-func (mh *matrixHandler) ignoreEvent(evt *event.Event) bool {
- return false
-}
-
-func (mh *matrixHandler) handleMessage(evt *event.Event) {
- if mh.ignoreEvent(evt) {
- return
- }
-
- user := mh.bridge.GetUserByMXID(evt.Sender)
- if user == nil {
- mh.log.Debugln("unknown user", evt.Sender)
- return
- }
-
- content := evt.Content.AsMessage()
- content.RemoveReplyFallback()
-
- if content.MsgType == event.MsgText {
- prefix := mh.bridge.Config.Bridge.CommandPrefix
-
- hasPrefix := strings.HasPrefix(content.Body, prefix)
- if hasPrefix {
- content.Body = strings.TrimLeft(content.Body[len(prefix):], " ")
- }
-
- if hasPrefix || evt.RoomID == user.ManagementRoom {
- mh.cmd.handle(evt.RoomID, user, content.Body, content.GetReplyTo())
- return
- }
- }
-
- portal := mh.bridge.GetPortalByMXID(evt.RoomID)
- if portal != nil {
- portal.matrixMessages <- portalMatrixMessage{user: user, evt: evt}
- }
-
-}
-
-func (mh *matrixHandler) joinAndCheckMembers(evt *event.Event, intent *appservice.IntentAPI) int {
- resp, err := intent.JoinRoomByID(evt.RoomID)
- if err != nil {
- mh.log.Debugfln("Failed to join room %q as %q with invite from %q: %v", evt.RoomID, intent.UserID, evt.Sender, err)
-
- return 0
- }
-
- members, err := intent.Members(resp.RoomID)
- if err != nil {
- mh.log.Debugfln("Failed to get members in room %q with invite from %q as %q: %v", resp.RoomID, evt.Sender, intent.UserID, err)
-
- return 0
- }
-
- if len(members.Chunk) < 2 {
- mh.log.Debugfln("Leaving empty room %q with invite from %q as %q", resp.RoomID, evt.Sender, intent.UserID)
-
- intent.LeaveRoom(resp.RoomID)
-
- return 0
- }
-
- return len(members.Chunk)
-}
-
-func (mh *matrixHandler) sendNoticeWithmarkdown(roomID id.RoomID, message string) (*mautrix.RespSendEvent, error) {
- intent := mh.as.BotIntent()
- content := format.RenderMarkdown(message, true, false)
- content.MsgType = event.MsgNotice
-
- return intent.SendMessageEvent(roomID, event.EventMessage, content)
-}
-
-func (mh *matrixHandler) handleBotInvite(evt *event.Event) {
- intent := mh.as.BotIntent()
-
- user := mh.bridge.GetUserByMXID(evt.Sender)
- if user == nil {
- return
- }
-
- members := mh.joinAndCheckMembers(evt, intent)
- if members == 0 {
- return
- }
-
- // If this is a DM and the user doesn't have a management room, make this
- // the management room.
- if members == 2 && (user.ManagementRoom == "" || evt.Content.AsMember().IsDirect) {
- user.SetManagementRoom(evt.RoomID)
-
- intent.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room")
- mh.log.Debugfln("%q registered as management room with %q", evt.RoomID, evt.Sender)
- }
-
- if evt.RoomID == user.ManagementRoom {
- // Wait to send the welcome message until we're sure we're not in an empty
- // room.
- mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Welcome)
-
- if user.Connected() {
- mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.Connected)
- } else {
- mh.sendNoticeWithmarkdown(evt.RoomID, mh.bridge.Config.Bridge.ManagementRoomText.NotConnected)
- }
-
- additionalHelp := mh.bridge.Config.Bridge.ManagementRoomText.AdditionalHelp
- if additionalHelp != "" {
- mh.sendNoticeWithmarkdown(evt.RoomID, additionalHelp)
- }
- }
-}
-
-func (mh *matrixHandler) handlePuppetInvite(evt *event.Event, inviter *User, puppet *Puppet) {
- mh.log.Warnln("handling puppet invite!")
-}
-
-func (mh *matrixHandler) handleMembership(evt *event.Event) {
- // Return early if we're supposed to ignore the event.
- if mh.ignoreEvent(evt) {
- return
- }
-
- if mh.bridge.crypto != nil {
- mh.bridge.crypto.HandleMemberEvent(evt)
- }
-
- // Grab the content of the event.
- content := evt.Content.AsMember()
-
- // Check if this is a new conversation from a matrix user to the bot
- if content.Membership == event.MembershipInvite && id.UserID(evt.GetStateKey()) == mh.as.BotMXID() {
- mh.handleBotInvite(evt)
-
- return
- }
-
- // Load or create a new user.
- user := mh.bridge.GetUserByMXID(evt.Sender)
- if user == nil {
- return
- }
-
- puppet := mh.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
-
- // Load or create a new portal.
- portal := mh.bridge.GetPortalByMXID(evt.RoomID)
- if portal == nil {
- if content.Membership == event.MembershipInvite && puppet != nil {
- mh.handlePuppetInvite(evt, user, puppet)
- }
-
- return
- }
-
- isSelf := id.UserID(evt.GetStateKey()) == evt.Sender
-
- if content.Membership == event.MembershipLeave {
- if evt.Unsigned.PrevContent != nil {
- _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
- prevContent, ok := evt.Unsigned.PrevContent.Parsed.(*event.MemberEventContent)
- if ok && prevContent.Membership != "join" {
- return
- }
- }
-
- if isSelf {
- portal.handleMatrixLeave(user)
- } else if puppet != nil {
- portal.handleMatrixKick(user, puppet)
- }
- } else if content.Membership == event.MembershipInvite {
- portal.handleMatrixInvite(user, evt)
- }
-}
-
-func (mh *matrixHandler) handleReaction(evt *event.Event) {
- if mh.ignoreEvent(evt) {
- return
- }
-
- portal := mh.bridge.GetPortalByMXID(evt.RoomID)
- if portal != nil {
- portal.handleMatrixReaction(evt)
- }
-}
-
-func (mh *matrixHandler) handleRedaction(evt *event.Event) {
- if mh.ignoreEvent(evt) {
- return
- }
-
- portal := mh.bridge.GetPortalByMXID(evt.RoomID)
- if portal != nil {
- portal.handleMatrixRedaction(evt)
- }
-}
-
-func (mh *matrixHandler) handleEncryption(evt *event.Event) {
- if evt.Content.AsEncryption().Algorithm != id.AlgorithmMegolmV1 {
- return
- }
-
- portal := mh.bridge.GetPortalByMXID(evt.RoomID)
- if portal != nil && !portal.Encrypted {
- mh.log.Debugfln("%s enabled encryption in %s", evt.Sender, evt.RoomID)
- portal.Encrypted = true
- portal.Update()
- }
-}
-
-const sessionWaitTimeout = 5 * time.Second
-
-func (mh *matrixHandler) handleEncrypted(evt *event.Event) {
- if mh.ignoreEvent(evt) || mh.bridge.crypto == nil {
- return
- }
-
- decrypted, err := mh.bridge.crypto.Decrypt(evt)
- decryptionRetryCount := 0
- if errors.Is(err, NoSessionFound) {
- content := evt.Content.AsEncrypted()
- mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d seconds...", content.SessionID, evt.ID, int(sessionWaitTimeout.Seconds()))
- mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, false, decryptionRetryCount)
- decryptionRetryCount++
-
- if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, sessionWaitTimeout) {
- mh.log.Debugfln("Got session %s after waiting, trying to decrypt %s again", content.SessionID, evt.ID)
- decrypted, err = mh.bridge.crypto.Decrypt(evt)
- } else {
- mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), false, decryptionRetryCount)
-
- go mh.waitLongerForSession(evt)
-
- return
- }
- }
-
- if err != nil {
- mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, decryptionRetryCount)
-
- mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
- _, _ = mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
- "\u26a0 Your message was not bridged: %v", err))
-
- return
- }
-
- mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, decryptionRetryCount)
- mh.bridge.eventProcessor.Dispatch(decrypted)
-}
-
-func (mh *matrixHandler) waitLongerForSession(evt *event.Event) {
- const extendedTimeout = sessionWaitTimeout * 3
-
- content := evt.Content.AsEncrypted()
- mh.log.Debugfln("Couldn't find session %s trying to decrypt %s, waiting %d more seconds...",
- content.SessionID, evt.ID, int(extendedTimeout.Seconds()))
-
- go mh.bridge.crypto.RequestSession(evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
-
- resp, err := mh.bridge.bot.SendNotice(evt.RoomID, fmt.Sprintf(
- "\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. "+
- "The bridge will retry for %d seconds. If this error keeps happening, try restarting your client.",
- int(extendedTimeout.Seconds())))
- if err != nil {
- mh.log.Errorfln("Failed to send decryption error to %s: %v", evt.RoomID, err)
- }
-
- update := event.MessageEventContent{MsgType: event.MsgNotice}
-
- if mh.bridge.crypto.WaitForSession(evt.RoomID, content.SenderKey, content.SessionID, extendedTimeout) {
- mh.log.Debugfln("Got session %s after waiting more, trying to decrypt %s again", content.SessionID, evt.ID)
-
- decrypted, err := mh.bridge.crypto.Decrypt(evt)
- if err == nil {
- mh.as.SendMessageSendCheckpoint(decrypted, appservice.StepDecrypted, 2)
- mh.bridge.eventProcessor.Dispatch(decrypted)
- _, _ = mh.bridge.bot.RedactEvent(evt.RoomID, resp.EventID)
-
- return
- }
-
- mh.log.Warnfln("Failed to decrypt %s: %v", evt.ID, err)
- mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, err, true, 2)
- update.Body = fmt.Sprintf("\u26a0 Your message was not bridged: %v", err)
- } else {
- mh.log.Debugfln("Didn't get %s, giving up on %s", content.SessionID, evt.ID)
- mh.as.SendErrorMessageSendCheckpoint(evt, appservice.StepDecrypted, fmt.Errorf("didn't receive encryption keys"), true, 2)
- update.Body = "\u26a0 Your message was not bridged: the bridge hasn't received the decryption keys. " +
- "If this error keeps happening, try restarting your client."
- }
-
- newContent := update
- update.NewContent = &newContent
- if resp != nil {
- update.RelatesTo = &event.RelatesTo{
- Type: event.RelReplace,
- EventID: resp.EventID,
- }
- }
-
- _, err = mh.bridge.bot.SendMessageEvent(evt.RoomID, event.EventMessage, &update)
- if err != nil {
- mh.log.Debugfln("Failed to update decryption error notice %s: %v", resp.EventID, err)
- }
-}
diff --git a/bridge/portal.go b/bridge/portal.go
deleted file mode 100644
index f8d5064..0000000
--- a/bridge/portal.go
+++ /dev/null
@@ -1,1178 +0,0 @@
-package bridge
-
-import (
- "bytes"
- "fmt"
- "strings"
- "sync"
- "time"
-
- "github.com/bwmarrin/discordgo"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-type portalDiscordMessage struct {
- msg interface{}
- user *User
-}
-
-type portalMatrixMessage struct {
- evt *event.Event
- user *User
-}
-
-type Portal struct {
- *database.Portal
-
- bridge *Bridge
- log log.Logger
-
- roomCreateLock sync.Mutex
- encryptLock sync.Mutex
-
- discordMessages chan portalDiscordMessage
- matrixMessages chan portalMatrixMessage
-}
-
-var (
- portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
-)
-
-func (b *Bridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal {
- // If we weren't given a portal we'll attempt to create it if a key was
- // provided.
- if dbPortal == nil {
- if key == nil {
- return nil
- }
-
- dbPortal = b.db.Portal.New()
- dbPortal.Key = *key
- dbPortal.Insert()
- }
-
- portal := b.NewPortal(dbPortal)
-
- // No need to lock, it is assumed that our callers have already acquired
- // the lock.
- b.portalsByID[portal.Key] = portal
- if portal.MXID != "" {
- b.portalsByMXID[portal.MXID] = portal
- }
-
- return portal
-}
-
-func (b *Bridge) GetPortalByMXID(mxid id.RoomID) *Portal {
- b.portalsLock.Lock()
- defer b.portalsLock.Unlock()
-
- portal, ok := b.portalsByMXID[mxid]
- if !ok {
- return b.loadPortal(b.db.Portal.GetByMXID(mxid), nil)
- }
-
- return portal
-}
-
-func (b *Bridge) GetPortalByID(key database.PortalKey) *Portal {
- b.portalsLock.Lock()
- defer b.portalsLock.Unlock()
-
- portal, ok := b.portalsByID[key]
- if !ok {
- return b.loadPortal(b.db.Portal.GetByID(key), &key)
- }
-
- return portal
-}
-
-func (b *Bridge) GetAllPortals() []*Portal {
- return b.dbPortalsToPortals(b.db.Portal.GetAll())
-}
-
-func (b *Bridge) GetAllPortalsByID(id string) []*Portal {
- return b.dbPortalsToPortals(b.db.Portal.GetAllByID(id))
-}
-
-func (b *Bridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal {
- b.portalsLock.Lock()
- defer b.portalsLock.Unlock()
-
- output := make([]*Portal, len(dbPortals))
- for index, dbPortal := range dbPortals {
- if dbPortal == nil {
- continue
- }
-
- portal, ok := b.portalsByID[dbPortal.Key]
- if !ok {
- portal = b.loadPortal(dbPortal, nil)
- }
-
- output[index] = portal
- }
-
- return output
-}
-
-func (b *Bridge) NewPortal(dbPortal *database.Portal) *Portal {
- portal := &Portal{
- Portal: dbPortal,
- bridge: b,
- log: b.log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)),
-
- discordMessages: make(chan portalDiscordMessage, b.Config.Bridge.PortalMessageBuffer),
- matrixMessages: make(chan portalMatrixMessage, b.Config.Bridge.PortalMessageBuffer),
- }
-
- go portal.messageLoop()
-
- return portal
-}
-
-func (p *Portal) handleMatrixInvite(sender *User, evt *event.Event) {
- // Look up an existing puppet or create a new one.
- puppet := p.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
- if puppet != nil {
- p.log.Infoln("no puppet for %v", sender)
- // Open a conversation on the discord side?
- }
- p.log.Infoln("matrixInvite: puppet:", puppet)
-}
-
-func (p *Portal) messageLoop() {
- for {
- select {
- case msg := <-p.matrixMessages:
- p.handleMatrixMessages(msg)
- case msg := <-p.discordMessages:
- p.handleDiscordMessages(msg)
- }
- }
-}
-
-func (p *Portal) IsPrivateChat() bool {
- return p.Type == discordgo.ChannelTypeDM
-}
-
-func (p *Portal) MainIntent() *appservice.IntentAPI {
- if p.IsPrivateChat() && p.DMUser != "" {
- return p.bridge.GetPuppetByID(p.DMUser).DefaultIntent()
- }
-
- return p.bridge.bot
-}
-
-func (p *Portal) createMatrixRoom(user *User, channel *discordgo.Channel) error {
- p.roomCreateLock.Lock()
- defer p.roomCreateLock.Unlock()
-
- // If we have a matrix id the room should exist so we have nothing to do.
- if p.MXID != "" {
- return nil
- }
-
- p.Type = channel.Type
- if p.Type == discordgo.ChannelTypeDM {
- p.DMUser = channel.Recipients[0].ID
- }
-
- intent := p.MainIntent()
- if err := intent.EnsureRegistered(); err != nil {
- return err
- }
-
- name, err := p.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
- if err != nil {
- p.log.Warnfln("failed to format name, proceeding with generic name: %v", err)
- p.Name = channel.Name
- } else {
- p.Name = name
- }
-
- p.Topic = channel.Topic
-
- // TODO: get avatars figured out
- // p.Avatar = puppet.Avatar
- // p.AvatarURL = puppet.AvatarURL
-
- p.log.Infoln("Creating Matrix room for channel:", p.Portal.Key.ChannelID)
-
- initialState := []*event.Event{}
-
- creationContent := make(map[string]interface{})
- creationContent["m.federate"] = false
-
- var invite []id.UserID
-
- if p.bridge.Config.Bridge.Encryption.Default {
- initialState = append(initialState, &event.Event{
- Type: event.StateEncryption,
- Content: event.Content{
- Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1},
- },
- })
- p.Encrypted = true
-
- if p.IsPrivateChat() {
- invite = append(invite, p.bridge.bot.UserID)
- }
- }
-
- resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: p.Name,
- Topic: p.Topic,
- Invite: invite,
- Preset: "private_chat",
- IsDirect: p.IsPrivateChat(),
- InitialState: initialState,
- CreationContent: creationContent,
- })
- if err != nil {
- p.log.Warnln("Failed to create room:", err)
- return err
- }
-
- p.MXID = resp.RoomID
- p.Update()
- p.bridge.portalsLock.Lock()
- p.bridge.portalsByMXID[p.MXID] = p
- p.bridge.portalsLock.Unlock()
-
- p.ensureUserInvited(user)
- user.syncChatDoublePuppetDetails(p, true)
-
- p.syncParticipants(user, channel.Recipients)
-
- if p.IsPrivateChat() {
- puppet := user.bridge.GetPuppetByID(p.Key.Receiver)
-
- chats := map[id.UserID][]id.RoomID{puppet.MXID: {p.MXID}}
- user.updateDirectChats(chats)
- }
-
- firstEventResp, err := p.MainIntent().SendMessageEvent(p.MXID, portalCreationDummyEvent, struct{}{})
- if err != nil {
- p.log.Errorln("Failed to send dummy event to mark portal creation:", err)
- } else {
- p.FirstEventID = firstEventResp.EventID
- p.Update()
- }
-
- return nil
-}
-
-func (p *Portal) handleDiscordMessages(msg portalDiscordMessage) {
- if p.MXID == "" {
- discordMsg, ok := msg.msg.(*discordgo.MessageCreate)
- if !ok {
- p.log.Warnln("Can't create Matrix room from non new message event")
- return
- }
-
- p.log.Debugln("Creating Matrix room from incoming message")
-
- channel, err := msg.user.Session.Channel(discordMsg.ChannelID)
- if err != nil {
- p.log.Errorln("Failed to find channel for message:", err)
-
- return
- }
-
- if err := p.createMatrixRoom(msg.user, channel); err != nil {
- p.log.Errorln("Failed to create portal room:", err)
-
- return
- }
- }
-
- switch msg.msg.(type) {
- case *discordgo.MessageCreate:
- p.handleDiscordMessageCreate(msg.user, msg.msg.(*discordgo.MessageCreate).Message)
- case *discordgo.MessageUpdate:
- p.handleDiscordMessagesUpdate(msg.user, msg.msg.(*discordgo.MessageUpdate).Message)
- case *discordgo.MessageDelete:
- p.handleDiscordMessageDelete(msg.user, msg.msg.(*discordgo.MessageDelete).Message)
- case *discordgo.MessageReactionAdd:
- p.handleDiscordReaction(msg.user, msg.msg.(*discordgo.MessageReactionAdd).MessageReaction, true)
- case *discordgo.MessageReactionRemove:
- p.handleDiscordReaction(msg.user, msg.msg.(*discordgo.MessageReactionRemove).MessageReaction, false)
- default:
- p.log.Warnln("unknown message type")
- }
-}
-
-func (p *Portal) ensureUserInvited(user *User) bool {
- return user.ensureInvited(p.MainIntent(), p.MXID, p.IsPrivateChat())
-}
-
-func (p *Portal) markMessageHandled(msg *database.Message, discordID string, mxid id.EventID, authorID string, timestamp time.Time) *database.Message {
- if msg == nil {
- msg := p.bridge.db.Message.New()
- msg.Channel = p.Key
- msg.DiscordID = discordID
- msg.MatrixID = mxid
- msg.AuthorID = authorID
- msg.Timestamp = timestamp
- msg.Insert()
- } else {
- msg.UpdateMatrixID(mxid)
- }
-
- return msg
-}
-
-func (p *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr error) {
- content := &event.MessageEventContent{
- Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
- MsgType: event.MsgNotice,
- }
-
- _, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
- if err != nil {
- p.log.Warnfln("failed to send error message to matrix: %v", err)
- }
-}
-
-func (p *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, msgID string, attachment *discordgo.MessageAttachment) {
- // var captionContent *event.MessageEventContent
-
- // if attachment.Description != "" {
- // captionContent = &event.MessageEventContent{
- // Body: attachment.Description,
- // MsgType: event.MsgNotice,
- // }
- // }
- // p.log.Debugfln("captionContent: %#v", captionContent)
-
- content := &event.MessageEventContent{
- Body: attachment.Filename,
- Info: &event.FileInfo{
- Height: attachment.Height,
- MimeType: attachment.ContentType,
- Width: attachment.Width,
-
- // This gets overwritten later after the file is uploaded to the homeserver
- Size: attachment.Size,
- },
- }
-
- switch strings.ToLower(strings.Split(attachment.ContentType, "/")[0]) {
- case "audio":
- content.MsgType = event.MsgAudio
- case "image":
- content.MsgType = event.MsgImage
- case "video":
- content.MsgType = event.MsgVideo
- default:
- content.MsgType = event.MsgFile
- }
-
- data, err := p.downloadDiscordAttachment(attachment.URL)
- if err != nil {
- p.sendMediaFailedMessage(intent, err)
-
- return
- }
-
- err = p.uploadMatrixAttachment(intent, data, content)
- if err != nil {
- p.sendMediaFailedMessage(intent, err)
-
- return
- }
-
- resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
- if err != nil {
- p.log.Warnfln("failed to send media message to matrix: %v", err)
- }
-
- dbAttachment := p.bridge.db.Attachment.New()
- dbAttachment.Channel = p.Key
- dbAttachment.DiscordMessageID = msgID
- dbAttachment.DiscordAttachmentID = attachment.ID
- dbAttachment.MatrixEventID = resp.EventID
- dbAttachment.Insert()
-}
-
-func (p *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message) {
- if p.MXID == "" {
- p.log.Warnln("handle message called without a valid portal")
-
- return
- }
-
- // Handle room name changes
- if msg.Type == discordgo.MessageTypeChannelNameChange {
- channel, err := user.Session.Channel(msg.ChannelID)
- if err != nil {
- p.log.Errorf("Failed to find the channel for portal %s", p.Key)
- return
- }
-
- name, err := p.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
- if err != nil {
- p.log.Errorf("Failed to format name for portal %s", p.Key)
- return
- }
-
- p.Name = name
- p.Update()
-
- p.MainIntent().SetRoomName(p.MXID, name)
-
- return
- }
-
- // Handle normal message
- existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
- if existing != nil {
- p.log.Debugln("not handling duplicate message", msg.ID)
-
- return
- }
-
- puppet := p.bridge.GetPuppetByID(msg.Author.ID)
- puppet.SyncContact(user)
- intent := puppet.IntentFor(p)
-
- if msg.Content != "" {
- content := &event.MessageEventContent{
- Body: msg.Content,
- MsgType: event.MsgText,
- }
-
- if msg.MessageReference != nil {
- key := database.PortalKey{msg.MessageReference.ChannelID, user.ID}
- existing := p.bridge.db.Message.GetByDiscordID(key, msg.MessageReference.MessageID)
-
- if existing != nil && existing.MatrixID != "" {
- content.RelatesTo = &event.RelatesTo{
- Type: event.RelReply,
- EventID: existing.MatrixID,
- }
- }
- }
-
- resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
- if err != nil {
- p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
-
- return
- }
-
- ts, _ := msg.Timestamp.Parse()
- p.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
- }
-
- // now run through any attachments the message has
- for _, attachment := range msg.Attachments {
- p.handleDiscordAttachment(intent, msg.ID, attachment)
- }
-}
-
-func (p *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message) {
- if p.MXID == "" {
- p.log.Warnln("handle message called without a valid portal")
-
- return
- }
-
- // There's a few scenarios where the author is nil but I haven't figured
- // them all out yet.
- if msg.Author == nil {
- // If the server has to lookup opengraph previews it'll send the
- // message through without the preview and then add the preview later
- // via a message update. However, when it does this there is no author
- // as it's just the server, so for the moment we'll ignore this to
- // avoid a crash.
- if len(msg.Embeds) > 0 {
- p.log.Debugln("ignoring update for opengraph attachment")
-
- return
- }
-
- p.log.Errorfln("author is nil: %#v", msg)
- }
-
- intent := p.bridge.GetPuppetByID(msg.Author.ID).IntentFor(p)
-
- existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
- if existing == nil {
- // Due to the differences in Discord and Matrix attachment handling,
- // existing will return nil if the original message was empty as we
- // don't store/save those messages so we can determine when we're
- // working against an attachment and do the attachment lookup instead.
-
- // Find all the existing attachments and drop them in a map so we can
- // figure out which, if any have been deleted and clean them up on the
- // matrix side.
- attachmentMap := map[string]*database.Attachment{}
- attachments := p.bridge.db.Attachment.GetAllByDiscordMessageID(p.Key, msg.ID)
-
- for _, attachment := range attachments {
- attachmentMap[attachment.DiscordAttachmentID] = attachment
- }
-
- // Now run through the list of attachments on this message and remove
- // them from the map.
- for _, attachment := range msg.Attachments {
- if _, found := attachmentMap[attachment.ID]; found {
- delete(attachmentMap, attachment.ID)
- }
- }
-
- // Finally run through any attachments still in the map and delete them
- // on the matrix side and our database.
- for _, attachment := range attachmentMap {
- _, err := intent.RedactEvent(p.MXID, attachment.MatrixEventID)
- if err != nil {
- p.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
- }
-
- attachment.Delete()
- }
-
- return
- }
-
- content := &event.MessageEventContent{
- Body: msg.Content,
- MsgType: event.MsgText,
- }
-
- content.SetEdit(existing.MatrixID)
-
- resp, err := p.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
- if err != nil {
- p.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
-
- return
- }
-
- ts, _ := msg.Timestamp.Parse()
- p.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
-}
-
-func (p *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message) {
- // The discord delete message object is pretty empty and doesn't include
- // the author so we have to use the DMUser from the portal that was added
- // at creation time if we're a DM. We'll might have similar issues when we
- // add guild message support, but we'll cross that bridge when we get
- // there.
-
- // Find the message that we're working with. This could correctly return
- // nil if the message was just one or more attachments.
- existing := p.bridge.db.Message.GetByDiscordID(p.Key, msg.ID)
-
- var intent *appservice.IntentAPI
-
- if p.Type == discordgo.ChannelTypeDM {
- intent = p.bridge.GetPuppetByID(p.DMUser).IntentFor(p)
- } else {
- intent = p.MainIntent()
- }
-
- if existing != nil {
- _, err := intent.RedactEvent(p.MXID, existing.MatrixID)
- if err != nil {
- p.log.Warnfln("Failed to remove message %s: %v", existing.MatrixID, err)
- }
-
- existing.Delete()
- }
-
- // Now delete all of the existing attachments.
- attachments := p.bridge.db.Attachment.GetAllByDiscordMessageID(p.Key, msg.ID)
- for _, attachment := range attachments {
- _, err := intent.RedactEvent(p.MXID, attachment.MatrixEventID)
- if err != nil {
- p.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
- }
-
- attachment.Delete()
- }
-}
-
-func (p *Portal) syncParticipants(source *User, participants []*discordgo.User) {
- for _, participant := range participants {
- puppet := p.bridge.GetPuppetByID(participant.ID)
- puppet.SyncContact(source)
-
- user := p.bridge.GetUserByID(participant.ID)
- if user != nil {
- p.ensureUserInvited(user)
- }
-
- if user == nil || !puppet.IntentFor(p).IsCustomPuppet {
- if err := puppet.IntentFor(p).EnsureJoined(p.MXID); err != nil {
- p.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.ID, p.MXID, err)
- }
- }
- }
-}
-
-func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) {
- if portal.Encrypted && portal.bridge.crypto != nil {
- // TODO maybe the locking should be inside mautrix-go?
- portal.encryptLock.Lock()
- encrypted, err := portal.bridge.crypto.Encrypt(portal.MXID, eventType, *content)
- portal.encryptLock.Unlock()
- if err != nil {
- return eventType, fmt.Errorf("failed to encrypt event: %w", err)
- }
- eventType = event.EventEncrypted
- content.Parsed = encrypted
- }
- return eventType, nil
-}
-
-const doublePuppetKey = "fi.mau.double_puppet_source"
-const doublePuppetValue = "mautrix-discord"
-
-func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
- wrappedContent := event.Content{Parsed: content, Raw: extraContent}
- if timestamp != 0 && intent.IsCustomPuppet {
- if wrappedContent.Raw == nil {
- wrappedContent.Raw = map[string]interface{}{}
- }
- if intent.IsCustomPuppet {
- wrappedContent.Raw[doublePuppetKey] = doublePuppetValue
- }
- }
- var err error
- eventType, err = portal.encrypt(&wrappedContent, eventType)
- if err != nil {
- return nil, err
- }
-
- if eventType == event.EventEncrypted {
- // Clear other custom keys if the event was encrypted, but keep the double puppet identifier
- if intent.IsCustomPuppet {
- wrappedContent.Raw = map[string]interface{}{doublePuppetKey: doublePuppetValue}
- } else {
- wrappedContent.Raw = nil
- }
- }
-
- _, _ = intent.UserTyping(portal.MXID, false, 0)
- if timestamp == 0 {
- return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
- } else {
- return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
- }
-}
-
-func (p *Portal) handleMatrixMessages(msg portalMatrixMessage) {
- switch msg.evt.Type {
- case event.EventMessage:
- p.handleMatrixMessage(msg.user, msg.evt)
- default:
- p.log.Debugln("unknown event type", msg.evt.Type)
- }
-}
-
-func (p *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
- if p.IsPrivateChat() && sender.ID != p.Key.Receiver {
- return
- }
-
- existing := p.bridge.db.Message.GetByMatrixID(p.Key, evt.ID)
- if existing != nil {
- p.log.Debugln("not handling duplicate message", evt.ID)
-
- return
- }
-
- content, ok := evt.Content.Parsed.(*event.MessageEventContent)
- if !ok {
- p.log.Debugfln("Failed to handle event %s: unexpected parsed content type %T", evt.ID, evt.Content.Parsed)
-
- return
- }
-
- if content.RelatesTo != nil && content.RelatesTo.Type == event.RelReplace {
- existing := p.bridge.db.Message.GetByMatrixID(p.Key, content.RelatesTo.EventID)
-
- if existing != nil && existing.DiscordID != "" {
- // we don't have anything to save for the update message right now
- // as we're not tracking edited timestamps.
- _, err := sender.Session.ChannelMessageEdit(p.Key.ChannelID,
- existing.DiscordID, content.NewContent.Body)
- if err != nil {
- p.log.Errorln("Failed to update message %s: %v", existing.DiscordID, err)
-
- return
- }
- }
-
- return
- }
-
- var msg *discordgo.Message
- var err error
-
- switch content.MsgType {
- case event.MsgText, event.MsgEmote, event.MsgNotice:
- sent := false
-
- if content.RelatesTo != nil && content.RelatesTo.Type == event.RelReply {
- existing := p.bridge.db.Message.GetByMatrixID(
- p.Key,
- content.RelatesTo.EventID,
- )
-
- if existing != nil && existing.DiscordID != "" {
- msg, err = sender.Session.ChannelMessageSendReply(
- p.Key.ChannelID,
- content.Body,
- &discordgo.MessageReference{
- ChannelID: p.Key.ChannelID,
- MessageID: existing.DiscordID,
- },
- )
- if err == nil {
- sent = true
- }
- }
- }
- if !sent {
- msg, err = sender.Session.ChannelMessageSend(p.Key.ChannelID, content.Body)
- }
- case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
- data, err := p.downloadMatrixAttachment(evt.ID, content)
- if err != nil {
- p.log.Errorfln("Failed to download matrix attachment: %v", err)
-
- return
- }
-
- msgSend := &discordgo.MessageSend{
- Files: []*discordgo.File{
- &discordgo.File{
- Name: content.Body,
- ContentType: content.Info.MimeType,
- Reader: bytes.NewReader(data),
- },
- },
- }
-
- msg, err = sender.Session.ChannelMessageSendComplex(p.Key.ChannelID, msgSend)
- default:
- p.log.Warnln("unknown message type:", content.MsgType)
- return
- }
-
- if err != nil {
- p.log.Errorfln("Failed to send message: %v", err)
-
- return
- }
-
- if msg != nil {
- dbMsg := p.bridge.db.Message.New()
- dbMsg.Channel = p.Key
- dbMsg.DiscordID = msg.ID
- dbMsg.MatrixID = evt.ID
- dbMsg.AuthorID = sender.ID
- dbMsg.Timestamp = time.Now()
- dbMsg.Insert()
- }
-}
-
-func (p *Portal) handleMatrixLeave(sender *User) {
- p.log.Debugln("User left private chat portal, cleaning up and deleting...")
- p.delete()
- p.cleanup(false)
-
- // TODO: figure out how to close a dm from the API.
-
- p.cleanupIfEmpty()
-}
-
-func (p *Portal) leave(sender *User) {
- if p.MXID == "" {
- return
- }
-
- intent := p.bridge.GetPuppetByID(sender.ID).IntentFor(p)
- intent.LeaveRoom(p.MXID)
-}
-
-func (p *Portal) delete() {
- p.Portal.Delete()
- p.bridge.portalsLock.Lock()
- delete(p.bridge.portalsByID, p.Key)
-
- if p.MXID != "" {
- delete(p.bridge.portalsByMXID, p.MXID)
- }
-
- p.bridge.portalsLock.Unlock()
-}
-
-func (p *Portal) cleanupIfEmpty() {
- users, err := p.getMatrixUsers()
- if err != nil {
- p.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err)
-
- return
- }
-
- if len(users) == 0 {
- p.log.Infoln("Room seems to be empty, cleaning up...")
- p.delete()
- p.cleanup(false)
- }
-}
-
-func (p *Portal) cleanup(puppetsOnly bool) {
- if p.MXID != "" {
- return
- }
-
- if p.IsPrivateChat() {
- _, err := p.MainIntent().LeaveRoom(p.MXID)
- if err != nil {
- p.log.Warnln("Failed to leave private chat portal with main intent:", err)
- }
-
- return
- }
-
- intent := p.MainIntent()
- members, err := intent.JoinedMembers(p.MXID)
- if err != nil {
- p.log.Errorln("Failed to get portal members for cleanup:", err)
-
- return
- }
-
- for member := range members.Joined {
- if member == intent.UserID {
- continue
- }
-
- puppet := p.bridge.GetPuppetByMXID(member)
- if p != nil {
- _, err = puppet.DefaultIntent().LeaveRoom(p.MXID)
- if err != nil {
- p.log.Errorln("Error leaving as puppet while cleaning up portal:", err)
- }
- } else if !puppetsOnly {
- _, err = intent.KickUser(p.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
- if err != nil {
- p.log.Errorln("Error kicking user while cleaning up portal:", err)
- }
- }
- }
-
- _, err = intent.LeaveRoom(p.MXID)
- if err != nil {
- p.log.Errorln("Error leaving with main intent while cleaning up portal:", err)
- }
-}
-
-func (p *Portal) getMatrixUsers() ([]id.UserID, error) {
- members, err := p.MainIntent().JoinedMembers(p.MXID)
- if err != nil {
- return nil, fmt.Errorf("failed to get member list: %w", err)
- }
-
- var users []id.UserID
- for userID := range members.Joined {
- _, isPuppet := p.bridge.ParsePuppetMXID(userID)
- if !isPuppet && userID != p.bridge.bot.UserID {
- users = append(users, userID)
- }
- }
-
- return users, nil
-}
-
-func (p *Portal) handleMatrixKick(sender *User, target *Puppet) {
- // TODO: need to learn how to make this happen as discordgo proper doesn't
- // support group dms and it looks like it's a binary blob.
-}
-
-func (p *Portal) handleMatrixReaction(evt *event.Event) {
- user := p.bridge.GetUserByMXID(evt.Sender)
- if user == nil {
- p.log.Errorf("failed to find user for %s", evt.Sender)
-
- return
- }
-
- if user.ID != p.Key.Receiver {
- return
- }
-
- reaction := evt.Content.AsReaction()
- if reaction.RelatesTo.Type != event.RelAnnotation {
- p.log.Errorfln("Ignoring reaction %s due to unknown m.relates_to data", evt.ID)
-
- return
- }
-
- var discordID string
-
- msg := p.bridge.db.Message.GetByMatrixID(p.Key, reaction.RelatesTo.EventID)
-
- // Due to the differences in attachments between Discord and Matrix, if a
- // user reacts to a media message on discord our lookup above will fail
- // because the relation of matrix media messages to attachments in handled
- // in the attachments table instead of messages so we need to check that
- // before continuing.
- //
- // This also leads to interesting problems when a Discord message comes in
- // with multiple attachments. A user can react to each one individually on
- // Matrix, which will cause us to send it twice. Discord tends to ignore
- // this, but if the user removes one of them, discord removes it and now
- // they're out of sync. Perhaps we should add a counter to the reactions
- // table to keep them in sync and to avoid sending duplicates to Discord.
- if msg == nil {
- attachment := p.bridge.db.Attachment.GetByMatrixID(p.Key, reaction.RelatesTo.EventID)
- discordID = attachment.DiscordMessageID
- } else {
- if msg.DiscordID == "" {
- p.log.Debugf("Message %s has not yet been sent to discord", reaction.RelatesTo.EventID)
-
- return
- }
-
- discordID = msg.DiscordID
- }
-
- // Figure out if this is a custom emoji or not.
- emojiID := reaction.RelatesTo.Key
- if strings.HasPrefix(emojiID, "mxc://") {
- uri, _ := id.ParseContentURI(emojiID)
- emoji := p.bridge.db.Emoji.GetByMatrixURL(uri)
- if emoji == nil {
- p.log.Errorfln("failed to find emoji for %s", emojiID)
-
- return
- }
-
- emojiID = emoji.APIName()
- }
-
- err := user.Session.MessageReactionAdd(p.Key.ChannelID, discordID, emojiID)
- if err != nil {
- p.log.Debugf("Failed to send reaction %s id:%s: %v", p.Key, discordID, err)
-
- return
- }
-
- dbReaction := p.bridge.db.Reaction.New()
- dbReaction.Channel.ChannelID = p.Key.ChannelID
- dbReaction.Channel.Receiver = p.Key.Receiver
- dbReaction.MatrixEventID = evt.ID
- dbReaction.DiscordMessageID = discordID
- dbReaction.AuthorID = user.ID
- dbReaction.MatrixName = reaction.RelatesTo.Key
- dbReaction.DiscordID = emojiID
- dbReaction.Insert()
-}
-
-func (p *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool) {
- intent := p.bridge.GetPuppetByID(reaction.UserID).IntentFor(p)
-
- var discordID string
- var matrixID string
-
- if reaction.Emoji.ID != "" {
- dbEmoji := p.bridge.db.Emoji.GetByDiscordID(reaction.Emoji.ID)
-
- if dbEmoji == nil {
- data, mimeType, err := p.downloadDiscordEmoji(reaction.Emoji.ID, reaction.Emoji.Animated)
- if err != nil {
- p.log.Warnfln("Failed to download emoji %s from discord: %v", reaction.Emoji.ID, err)
-
- return
- }
-
- uri, err := p.uploadMatrixEmoji(intent, data, mimeType)
- if err != nil {
- p.log.Warnfln("Failed to upload discord emoji %s to homeserver: %v", reaction.Emoji.ID, err)
-
- return
- }
-
- dbEmoji = p.bridge.db.Emoji.New()
- dbEmoji.DiscordID = reaction.Emoji.ID
- dbEmoji.DiscordName = reaction.Emoji.Name
- dbEmoji.MatrixURL = uri
- dbEmoji.Insert()
- }
-
- discordID = dbEmoji.DiscordID
- matrixID = dbEmoji.MatrixURL.String()
- } else {
- discordID = reaction.Emoji.Name
- matrixID = reaction.Emoji.Name
- }
-
- // Find the message that we're working with.
- message := p.bridge.db.Message.GetByDiscordID(p.Key, reaction.MessageID)
- if message == nil {
- p.log.Debugfln("failed to add reaction to message %s: message not found", reaction.MessageID)
-
- return
- }
-
- // Lookup an existing reaction
- existing := p.bridge.db.Reaction.GetByDiscordID(p.Key, message.DiscordID, discordID)
-
- if !add {
- if existing == nil {
- p.log.Debugln("Failed to remove reaction for unknown message", reaction.MessageID)
-
- return
- }
-
- _, err := intent.RedactEvent(p.MXID, existing.MatrixEventID)
- if err != nil {
- p.log.Warnfln("Failed to remove reaction from %s: %v", p.MXID, err)
- }
-
- existing.Delete()
-
- return
- }
-
- content := event.Content{Parsed: &event.ReactionEventContent{
- RelatesTo: event.RelatesTo{
- EventID: message.MatrixID,
- Type: event.RelAnnotation,
- Key: matrixID,
- },
- }}
-
- resp, err := intent.Client.SendMessageEvent(p.MXID, event.EventReaction, &content)
- if err != nil {
- p.log.Errorfln("failed to send reaction from %s: %v", reaction.MessageID, err)
-
- return
- }
-
- if existing == nil {
- dbReaction := p.bridge.db.Reaction.New()
- dbReaction.Channel = p.Key
- dbReaction.DiscordMessageID = message.DiscordID
- dbReaction.MatrixEventID = resp.EventID
- dbReaction.AuthorID = reaction.UserID
-
- dbReaction.MatrixName = matrixID
- dbReaction.DiscordID = discordID
-
- dbReaction.Insert()
- }
-}
-
-func (p *Portal) handleMatrixRedaction(evt *event.Event) {
- user := p.bridge.GetUserByMXID(evt.Sender)
-
- if user.ID != p.Key.Receiver {
- return
- }
-
- // First look if we're redacting a message
- message := p.bridge.db.Message.GetByMatrixID(p.Key, evt.Redacts)
- if message != nil {
- if message.DiscordID != "" {
- err := user.Session.ChannelMessageDelete(p.Key.ChannelID, message.DiscordID)
- if err != nil {
- p.log.Debugfln("Failed to delete discord message %s: %v", message.DiscordID, err)
- } else {
- message.Delete()
- }
- }
-
- return
- }
-
- // Now check if it's a reaction.
- reaction := p.bridge.db.Reaction.GetByMatrixID(p.Key, evt.Redacts)
- if reaction != nil {
- if reaction.DiscordID != "" {
- err := user.Session.MessageReactionRemove(p.Key.ChannelID, reaction.DiscordMessageID, reaction.DiscordID, reaction.AuthorID)
- if err != nil {
- p.log.Debugfln("Failed to delete reaction %s for message %s: %v", reaction.DiscordID, reaction.DiscordMessageID, err)
- } else {
- reaction.Delete()
- }
- }
-
- return
- }
-
- p.log.Warnfln("Failed to redact %s@%s: no event found", p.Key, evt.Redacts)
-}
-
-func (p *Portal) update(user *User, channel *discordgo.Channel) {
- name, err := p.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
- if err != nil {
- p.log.Warnln("Failed to format channel name, using existing:", err)
- } else {
- p.Name = name
- }
-
- intent := p.MainIntent()
-
- if p.Name != name {
- _, err = intent.SetRoomName(p.MXID, p.Name)
- if err != nil {
- p.log.Warnln("Failed to update room name:", err)
- }
- }
-
- if p.Topic != channel.Topic {
- p.Topic = channel.Topic
- _, err = intent.SetRoomTopic(p.MXID, p.Topic)
- if err != nil {
- p.log.Warnln("Failed to update room topic:", err)
- }
- }
-
- if p.Avatar != channel.Icon {
- p.Avatar = channel.Icon
-
- var url string
-
- if p.Type == discordgo.ChannelTypeDM {
- dmUser, err := user.Session.User(p.DMUser)
- if err != nil {
- p.log.Warnln("failed to lookup the dmuser", err)
- } else {
- url = dmUser.AvatarURL("")
- }
- } else {
- url = discordgo.EndpointGroupIcon(channel.ID, channel.Icon)
- }
-
- p.AvatarURL = id.ContentURI{}
- if url != "" {
- uri, err := uploadAvatar(intent, url)
- if err != nil {
- p.log.Warnf("failed to upload avatar", err)
- } else {
- p.AvatarURL = uri
- }
- }
-
- intent.SetRoomAvatar(p.MXID, p.AvatarURL)
- }
-
- p.Update()
- p.log.Debugln("portal updated")
-}
diff --git a/bridge/puppet.go b/bridge/puppet.go
deleted file mode 100644
index 3b0753c..0000000
--- a/bridge/puppet.go
+++ /dev/null
@@ -1,291 +0,0 @@
-package bridge
-
-import (
- "fmt"
- "regexp"
- "sync"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-type Puppet struct {
- *database.Puppet
-
- bridge *Bridge
- log log.Logger
-
- MXID id.UserID
-
- customIntent *appservice.IntentAPI
- customUser *User
-
- syncLock sync.Mutex
-}
-
-var userIDRegex *regexp.Regexp
-
-func (b *Bridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
- return &Puppet{
- Puppet: dbPuppet,
- bridge: b,
- log: b.log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
-
- MXID: b.FormatPuppetMXID(dbPuppet.ID),
- }
-}
-
-func (b *Bridge) ParsePuppetMXID(mxid id.UserID) (string, bool) {
- if userIDRegex == nil {
- pattern := fmt.Sprintf(
- "^@%s:%s$",
- b.Config.Bridge.FormatUsername("([0-9]+)"),
- b.Config.Homeserver.Domain,
- )
-
- userIDRegex = regexp.MustCompile(pattern)
- }
-
- match := userIDRegex.FindStringSubmatch(string(mxid))
- if len(match) == 2 {
- return match[1], true
- }
-
- return "", false
-}
-
-func (b *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
- id, ok := b.ParsePuppetMXID(mxid)
- if !ok {
- return nil
- }
-
- return b.GetPuppetByID(id)
-}
-
-func (b *Bridge) GetPuppetByID(id string) *Puppet {
- b.puppetsLock.Lock()
- defer b.puppetsLock.Unlock()
-
- puppet, ok := b.puppets[id]
- if !ok {
- dbPuppet := b.db.Puppet.Get(id)
- if dbPuppet == nil {
- dbPuppet = b.db.Puppet.New()
- dbPuppet.ID = id
- dbPuppet.Insert()
- }
-
- puppet = b.NewPuppet(dbPuppet)
- b.puppets[puppet.ID] = puppet
- }
-
- return puppet
-}
-
-func (b *Bridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
- b.puppetsLock.Lock()
- defer b.puppetsLock.Unlock()
-
- puppet, ok := b.puppetsByCustomMXID[mxid]
- if !ok {
- dbPuppet := b.db.Puppet.GetByCustomMXID(mxid)
- if dbPuppet == nil {
- return nil
- }
-
- puppet = b.NewPuppet(dbPuppet)
- b.puppets[puppet.ID] = puppet
- b.puppetsByCustomMXID[puppet.CustomMXID] = puppet
- }
-
- return puppet
-}
-
-func (b *Bridge) GetAllPuppetsWithCustomMXID() []*Puppet {
- return b.dbPuppetsToPuppets(b.db.Puppet.GetAllWithCustomMXID())
-}
-
-func (b *Bridge) GetAllPuppets() []*Puppet {
- return b.dbPuppetsToPuppets(b.db.Puppet.GetAll())
-}
-
-func (b *Bridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
- b.puppetsLock.Lock()
- defer b.puppetsLock.Unlock()
-
- output := make([]*Puppet, len(dbPuppets))
- for index, dbPuppet := range dbPuppets {
- if dbPuppet == nil {
- continue
- }
-
- puppet, ok := b.puppets[dbPuppet.ID]
- if !ok {
- puppet = b.NewPuppet(dbPuppet)
- b.puppets[dbPuppet.ID] = puppet
-
- if dbPuppet.CustomMXID != "" {
- b.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
- }
- }
-
- output[index] = puppet
- }
-
- return output
-}
-
-func (b *Bridge) FormatPuppetMXID(did string) id.UserID {
- return id.NewUserID(
- b.Config.Bridge.FormatUsername(did),
- b.Config.Homeserver.Domain,
- )
-}
-
-func (p *Puppet) DefaultIntent() *appservice.IntentAPI {
- return p.bridge.as.Intent(p.MXID)
-}
-
-func (p *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
- if p.customIntent == nil {
- return p.DefaultIntent()
- }
-
- return p.customIntent
-}
-
-func (p *Puppet) CustomIntent() *appservice.IntentAPI {
- return p.customIntent
-}
-
-func (p *Puppet) updatePortalMeta(meta func(portal *Portal)) {
- for _, portal := range p.bridge.GetAllPortalsByID(p.ID) {
- // Get room create lock to prevent races between receiving contact info and room creation.
- portal.roomCreateLock.Lock()
- meta(portal)
- portal.roomCreateLock.Unlock()
- }
-}
-
-func (p *Puppet) updateName(source *User) bool {
- user, err := source.Session.User(p.ID)
- if err != nil {
- p.log.Warnln("failed to get user from id:", err)
- return false
- }
-
- newName := p.bridge.Config.Bridge.FormatDisplayname(user)
-
- if p.DisplayName != newName {
- err := p.DefaultIntent().SetDisplayName(newName)
- if err == nil {
- p.DisplayName = newName
- go p.updatePortalName()
- p.Update()
- } else {
- p.log.Warnln("failed to set display name:", err)
- }
-
- return true
- }
-
- return false
-}
-
-func (p *Puppet) updatePortalName() {
- p.updatePortalMeta(func(portal *Portal) {
- if portal.MXID != "" {
- _, err := portal.MainIntent().SetRoomName(portal.MXID, p.DisplayName)
- if err != nil {
- portal.log.Warnln("Failed to set name:", err)
- }
- }
-
- portal.Name = p.DisplayName
- portal.Update()
- })
-}
-
-func (p *Puppet) updateAvatar(source *User) bool {
- user, err := source.Session.User(p.ID)
- if err != nil {
- p.log.Warnln("Failed to get user:", err)
-
- return false
- }
-
- if p.Avatar == user.Avatar {
- return false
- }
-
- if user.Avatar == "" {
- p.log.Warnln("User does not have an avatar")
-
- return false
- }
-
- url, err := uploadAvatar(p.DefaultIntent(), user.AvatarURL(""))
- if err != nil {
- p.log.Warnln("Failed to upload user avatar:", err)
-
- return false
- }
-
- p.AvatarURL = url
-
- err = p.DefaultIntent().SetAvatarURL(p.AvatarURL)
- if err != nil {
- p.log.Warnln("Failed to set avatar:", err)
- }
-
- p.log.Debugln("Updated avatar", p.Avatar, "->", user.Avatar)
- p.Avatar = user.Avatar
- go p.updatePortalAvatar()
-
- return true
-}
-
-func (p *Puppet) updatePortalAvatar() {
- p.updatePortalMeta(func(portal *Portal) {
- if portal.MXID != "" {
- _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, p.AvatarURL)
- if err != nil {
- portal.log.Warnln("Failed to set avatar:", err)
- }
- }
-
- portal.AvatarURL = p.AvatarURL
- portal.Avatar = p.Avatar
- portal.Update()
- })
-
-}
-
-func (p *Puppet) SyncContact(source *User) {
- p.syncLock.Lock()
- defer p.syncLock.Unlock()
-
- p.log.Debugln("syncing contact", p.DisplayName)
-
- err := p.DefaultIntent().EnsureRegistered()
- if err != nil {
- p.log.Errorln("Failed to ensure registered:", err)
- }
-
- update := false
-
- update = p.updateName(source) || update
-
- if p.Avatar == "" {
- update = p.updateAvatar(source) || update
- p.log.Debugln("update avatar returned", update)
- }
-
- if update {
- p.Update()
- }
-}
diff --git a/bridge/user.go b/bridge/user.go
deleted file mode 100644
index f7170f8..0000000
--- a/bridge/user.go
+++ /dev/null
@@ -1,826 +0,0 @@
-package bridge
-
-import (
- "errors"
- "fmt"
- "net/http"
- "strings"
- "sync"
-
- "github.com/bwmarrin/discordgo"
- "github.com/skip2/go-qrcode"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/database"
-)
-
-var (
- ErrNotConnected = errors.New("not connected")
- ErrNotLoggedIn = errors.New("not logged in")
-)
-
-type User struct {
- *database.User
-
- sync.Mutex
-
- bridge *Bridge
- log log.Logger
-
- // TODO finish implementing
- Admin bool
-
- guilds map[string]*database.Guild
- guildsLock sync.Mutex
-
- Session *discordgo.Session
-}
-
-// this assume you are holding the guilds lock!!!
-func (u *User) loadGuilds() {
- u.guilds = map[string]*database.Guild{}
- for _, guild := range u.bridge.db.Guild.GetAll(u.ID) {
- u.guilds[guild.GuildID] = guild
- }
-}
-
-func (b *Bridge) loadUser(dbUser *database.User, mxid *id.UserID) *User {
- // If we weren't passed in a user we attempt to create one if we were given
- // a matrix id.
- if dbUser == nil {
- if mxid == nil {
- return nil
- }
-
- dbUser = b.db.User.New()
- dbUser.MXID = *mxid
- dbUser.Insert()
- }
-
- user := b.NewUser(dbUser)
-
- // We assume the usersLock was acquired by our caller.
- b.usersByMXID[user.MXID] = user
- if user.ID != "" {
- b.usersByID[user.ID] = user
- }
-
- if user.ManagementRoom != "" {
- // Lock the management rooms for our update
- b.managementRoomsLock.Lock()
- b.managementRooms[user.ManagementRoom] = user
- b.managementRoomsLock.Unlock()
- }
-
- // Load our guilds state from the database and turn it into a map
- user.guildsLock.Lock()
- user.loadGuilds()
- user.guildsLock.Unlock()
-
- return user
-}
-
-func (b *Bridge) GetUserByMXID(userID id.UserID) *User {
- // TODO: check if puppet
-
- b.usersLock.Lock()
- defer b.usersLock.Unlock()
-
- user, ok := b.usersByMXID[userID]
- if !ok {
- return b.loadUser(b.db.User.GetByMXID(userID), &userID)
- }
-
- return user
-}
-
-func (b *Bridge) GetUserByID(id string) *User {
- b.usersLock.Lock()
- defer b.usersLock.Unlock()
-
- user, ok := b.usersByID[id]
- if !ok {
- return b.loadUser(b.db.User.GetByID(id), nil)
- }
-
- return user
-}
-
-func (b *Bridge) NewUser(dbUser *database.User) *User {
- user := &User{
- User: dbUser,
- bridge: b,
- log: b.log.Sub("User").Sub(string(dbUser.MXID)),
- guilds: map[string]*database.Guild{},
- }
-
- return user
-}
-
-func (b *Bridge) getAllUsers() []*User {
- b.usersLock.Lock()
- defer b.usersLock.Unlock()
-
- dbUsers := b.db.User.GetAll()
- users := make([]*User, len(dbUsers))
-
- for idx, dbUser := range dbUsers {
- user, ok := b.usersByMXID[dbUser.MXID]
- if !ok {
- user = b.loadUser(dbUser, nil)
- }
- users[idx] = user
- }
-
- return users
-}
-
-func (b *Bridge) startUsers() {
- b.log.Debugln("Starting users")
-
- for _, user := range b.getAllUsers() {
- go user.Connect()
- }
-
- b.log.Debugln("Starting custom puppets")
- for _, customPuppet := range b.GetAllPuppetsWithCustomMXID() {
- go func(puppet *Puppet) {
- b.log.Debugln("Starting custom puppet", puppet.CustomMXID)
-
- if err := puppet.StartCustomMXID(true); err != nil {
- puppet.log.Errorln("Failed to start custom puppet:", err)
- }
- }(customPuppet)
- }
-}
-
-func (u *User) SetManagementRoom(roomID id.RoomID) {
- u.bridge.managementRoomsLock.Lock()
- defer u.bridge.managementRoomsLock.Unlock()
-
- existing, ok := u.bridge.managementRooms[roomID]
- if ok {
- // If there's a user already assigned to this management room, clear it
- // out.
- // I think this is due a name change or something? I dunno, leaving it
- // for now.
- existing.ManagementRoom = ""
- existing.Update()
- }
-
- u.ManagementRoom = roomID
- u.bridge.managementRooms[u.ManagementRoom] = u
- u.Update()
-}
-
-func (u *User) sendQRCode(bot *appservice.IntentAPI, roomID id.RoomID, code string) (id.EventID, error) {
- url, err := u.uploadQRCode(code)
- if err != nil {
- return "", err
- }
-
- content := event.MessageEventContent{
- MsgType: event.MsgImage,
- Body: code,
- URL: url.CUString(),
- }
-
- resp, err := bot.SendMessageEvent(roomID, event.EventMessage, &content)
- if err != nil {
- return "", err
- }
-
- return resp.EventID, nil
-}
-
-func (u *User) uploadQRCode(code string) (id.ContentURI, error) {
- qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
- if err != nil {
- u.log.Errorln("Failed to encode QR code:", err)
-
- return id.ContentURI{}, err
- }
-
- bot := u.bridge.as.BotClient()
-
- resp, err := bot.UploadBytes(qrCode, "image/png")
- if err != nil {
- u.log.Errorln("Failed to upload QR code:", err)
-
- return id.ContentURI{}, err
- }
-
- return resp.ContentURI, nil
-}
-
-func (u *User) tryAutomaticDoublePuppeting() {
- u.Lock()
- defer u.Unlock()
-
- if !u.bridge.Config.CanAutoDoublePuppet(u.MXID) {
- return
- }
-
- u.log.Debugln("Checking if double puppeting needs to be enabled")
-
- puppet := u.bridge.GetPuppetByID(u.ID)
- if puppet.CustomMXID != "" {
- u.log.Debugln("User already has double-puppeting enabled")
-
- return
- }
-
- accessToken, err := puppet.loginWithSharedSecret(u.MXID)
- if err != nil {
- u.log.Warnln("Failed to login with shared secret:", err)
-
- return
- }
-
- err = puppet.SwitchCustomMXID(accessToken, u.MXID)
- if err != nil {
- puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err)
-
- return
- }
-
- u.log.Infoln("Successfully automatically enabled custom puppet")
-}
-
-func (u *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
- doublePuppet := portal.bridge.GetPuppetByCustomMXID(u.MXID)
- if doublePuppet == nil {
- return
- }
-
- if doublePuppet == nil || doublePuppet.CustomIntent() == nil || portal.MXID == "" {
- return
- }
-
- // TODO sync mute status
-}
-
-func (u *User) Login(token string) error {
- if token == "" {
- return fmt.Errorf("No token specified")
- }
-
- u.Token = token
- u.Update()
-
- return u.Connect()
-}
-
-func (u *User) LoggedIn() bool {
- u.Lock()
- defer u.Unlock()
-
- return u.Token != ""
-}
-
-func (u *User) Logout() error {
- u.Lock()
- defer u.Unlock()
-
- if u.Session == nil {
- return ErrNotLoggedIn
- }
-
- puppet := u.bridge.GetPuppetByID(u.ID)
- if puppet.CustomMXID != "" {
- err := puppet.SwitchCustomMXID("", "")
- if err != nil {
- u.log.Warnln("Failed to logout-matrix while logging out of Discord:", err)
- }
- }
-
- if err := u.Session.Close(); err != nil {
- return err
- }
-
- u.Session = nil
-
- u.Token = ""
- u.Update()
-
- return nil
-}
-
-func (u *User) Connected() bool {
- u.Lock()
- defer u.Unlock()
-
- return u.Session != nil
-}
-
-func (u *User) Connect() error {
- u.Lock()
- defer u.Unlock()
-
- if u.Token == "" {
- return ErrNotLoggedIn
- }
-
- u.log.Debugln("connecting to discord")
-
- session, err := discordgo.New(u.Token)
- if err != nil {
- return err
- }
-
- u.Session = session
-
- // Add our event handlers
- u.Session.AddHandler(u.readyHandler)
- u.Session.AddHandler(u.connectedHandler)
- u.Session.AddHandler(u.disconnectedHandler)
-
- u.Session.AddHandler(u.guildCreateHandler)
- u.Session.AddHandler(u.guildDeleteHandler)
- u.Session.AddHandler(u.guildUpdateHandler)
-
- u.Session.AddHandler(u.channelCreateHandler)
- u.Session.AddHandler(u.channelDeleteHandler)
- u.Session.AddHandler(u.channelPinsUpdateHandler)
- u.Session.AddHandler(u.channelUpdateHandler)
-
- u.Session.AddHandler(u.messageCreateHandler)
- u.Session.AddHandler(u.messageDeleteHandler)
- u.Session.AddHandler(u.messageUpdateHandler)
- u.Session.AddHandler(u.reactionAddHandler)
- u.Session.AddHandler(u.reactionRemoveHandler)
-
- u.Session.Identify.Presence.Status = "online"
-
- return u.Session.Open()
-}
-
-func (u *User) Disconnect() error {
- u.Lock()
- defer u.Unlock()
-
- if u.Session == nil {
- return ErrNotConnected
- }
-
- if err := u.Session.Close(); err != nil {
- return err
- }
-
- u.Session = nil
-
- return nil
-}
-
-func (u *User) bridgeMessage(guildID string) bool {
- // Non guild message always get bridged.
- if guildID == "" {
- return true
- }
-
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- if guild, found := u.guilds[guildID]; found {
- if guild.Bridge {
- return true
- }
- }
-
- u.log.Debugfln("ignoring message for non-bridged guild %s-%s", u.ID, guildID)
-
- return false
-}
-
-func (u *User) readyHandler(s *discordgo.Session, r *discordgo.Ready) {
- u.log.Debugln("discord connection ready")
-
- // Update our user fields
- u.ID = r.User.ID
-
- // Update our guild map to match watch discord thinks we're in. This is the
- // only time we can get the full guild map as discordgo doesn't make it
- // available to us later. Also, discord might not give us the full guild
- // information here, so we use this to remove guilds the user left and only
- // add guilds whose full information we have. The are told about the
- // "unavailable" guilds later via the GuildCreate handler.
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- // build a list of the current guilds we're in so we can prune the old ones
- current := []string{}
-
- u.log.Debugln("database guild count", len(u.guilds))
- u.log.Debugln("discord guild count", len(r.Guilds))
-
- for _, guild := range r.Guilds {
- current = append(current, guild.ID)
-
- // If we already know about this guild, make sure we reset it's bridge
- // status.
- if val, found := u.guilds[guild.ID]; found {
- bridge := val.Bridge
- u.guilds[guild.ID].Bridge = bridge
-
- // Update the name if the guild is available
- if !guild.Unavailable {
- u.guilds[guild.ID].GuildName = guild.Name
- }
-
- val.Upsert()
- } else {
- g := u.bridge.db.Guild.New()
- g.DiscordID = u.ID
- g.GuildID = guild.ID
- u.guilds[guild.ID] = g
-
- if !guild.Unavailable {
- g.GuildName = guild.Name
- }
-
- g.Upsert()
- }
- }
-
- // Sync the guilds to the database.
- u.bridge.db.Guild.Prune(u.ID, current)
-
- // Finally reload from the database since it purged servers we're not in
- // anymore.
- u.loadGuilds()
-
- u.log.Debugln("updated database guild count", len(u.guilds))
-
- u.Update()
-}
-
-func (u *User) connectedHandler(s *discordgo.Session, c *discordgo.Connect) {
- u.log.Debugln("connected to discord")
-
- u.tryAutomaticDoublePuppeting()
-}
-
-func (u *User) disconnectedHandler(s *discordgo.Session, d *discordgo.Disconnect) {
- u.log.Debugln("disconnected from discord")
-}
-
-func (u *User) guildCreateHandler(s *discordgo.Session, g *discordgo.GuildCreate) {
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- // If we somehow already know about the guild, just update it's name
- if guild, found := u.guilds[g.ID]; found {
- guild.GuildName = g.Name
- guild.Upsert()
-
- return
- }
-
- // This is a brand new guild so lets get it added.
- guild := u.bridge.db.Guild.New()
- guild.DiscordID = u.ID
- guild.GuildID = g.ID
- guild.GuildName = g.Name
- guild.Upsert()
-
- u.guilds[g.ID] = guild
-}
-
-func (u *User) guildDeleteHandler(s *discordgo.Session, g *discordgo.GuildDelete) {
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- if guild, found := u.guilds[g.ID]; found {
- guild.Delete()
- delete(u.guilds, g.ID)
- u.log.Debugln("deleted guild", g.Guild.ID)
- }
-}
-
-func (u *User) guildUpdateHandler(s *discordgo.Session, g *discordgo.GuildUpdate) {
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- // If we somehow already know about the guild, just update it's name
- if guild, found := u.guilds[g.ID]; found {
- guild.GuildName = g.Name
- guild.Upsert()
-
- u.log.Debugln("updated guild", g.ID)
- }
-}
-
-func (u *User) createChannel(c *discordgo.Channel) {
- key := database.NewPortalKey(c.ID, u.User.ID)
- portal := u.bridge.GetPortalByID(key)
-
- if portal.MXID != "" {
- return
- }
-
- portal.Name = c.Name
- portal.Topic = c.Topic
- portal.Type = c.Type
-
- if portal.Type == discordgo.ChannelTypeDM {
- portal.DMUser = c.Recipients[0].ID
- }
-
- if c.Icon != "" {
- u.log.Debugln("channel icon", c.Icon)
- }
-
- portal.Update()
-
- portal.createMatrixRoom(u, c)
-}
-
-func (u *User) channelCreateHandler(s *discordgo.Session, c *discordgo.ChannelCreate) {
- u.createChannel(c.Channel)
-}
-
-func (u *User) channelDeleteHandler(s *discordgo.Session, c *discordgo.ChannelDelete) {
- u.log.Debugln("channel delete handler")
-}
-
-func (u *User) channelPinsUpdateHandler(s *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
- u.log.Debugln("channel pins update")
-}
-
-func (u *User) channelUpdateHandler(s *discordgo.Session, c *discordgo.ChannelUpdate) {
- key := database.NewPortalKey(c.ID, u.User.ID)
- portal := u.bridge.GetPortalByID(key)
-
- portal.update(u, c.Channel)
-}
-
-func (u *User) messageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
- if !u.bridgeMessage(m.GuildID) {
- return
- }
-
- key := database.NewPortalKey(m.ChannelID, u.ID)
- portal := u.bridge.GetPortalByID(key)
-
- msg := portalDiscordMessage{
- msg: m,
- user: u,
- }
-
- portal.discordMessages <- msg
-}
-
-func (u *User) messageDeleteHandler(s *discordgo.Session, m *discordgo.MessageDelete) {
- if !u.bridgeMessage(m.GuildID) {
- return
- }
-
- key := database.NewPortalKey(m.ChannelID, u.ID)
- portal := u.bridge.GetPortalByID(key)
-
- msg := portalDiscordMessage{
- msg: m,
- user: u,
- }
-
- portal.discordMessages <- msg
-}
-
-func (u *User) messageUpdateHandler(s *discordgo.Session, m *discordgo.MessageUpdate) {
- if !u.bridgeMessage(m.GuildID) {
- return
- }
-
- key := database.NewPortalKey(m.ChannelID, u.ID)
- portal := u.bridge.GetPortalByID(key)
-
- msg := portalDiscordMessage{
- msg: m,
- user: u,
- }
-
- portal.discordMessages <- msg
-}
-
-func (u *User) reactionAddHandler(s *discordgo.Session, m *discordgo.MessageReactionAdd) {
- if !u.bridgeMessage(m.MessageReaction.GuildID) {
- return
- }
-
- key := database.NewPortalKey(m.ChannelID, u.User.ID)
- portal := u.bridge.GetPortalByID(key)
-
- msg := portalDiscordMessage{
- msg: m,
- user: u,
- }
-
- portal.discordMessages <- msg
-}
-
-func (u *User) reactionRemoveHandler(s *discordgo.Session, m *discordgo.MessageReactionRemove) {
- if !u.bridgeMessage(m.MessageReaction.GuildID) {
- return
- }
-
- key := database.NewPortalKey(m.ChannelID, u.User.ID)
- portal := u.bridge.GetPortalByID(key)
-
- msg := portalDiscordMessage{
- msg: m,
- user: u,
- }
-
- portal.discordMessages <- msg
-}
-
-func (u *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) bool {
- ret := false
-
- inviteContent := event.Content{
- Parsed: &event.MemberEventContent{
- Membership: event.MembershipInvite,
- IsDirect: isDirect,
- },
- Raw: map[string]interface{}{},
- }
-
- customPuppet := u.bridge.GetPuppetByCustomMXID(u.MXID)
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- inviteContent.Raw["fi.mau.will_auto_accept"] = true
- }
-
- _, err := intent.SendStateEvent(roomID, event.StateMember, u.MXID.String(), &inviteContent)
-
- var httpErr mautrix.HTTPError
- if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
- u.bridge.StateStore.SetMembership(roomID, u.MXID, event.MembershipJoin)
- ret = true
- } else if err != nil {
- u.log.Warnfln("Failed to invite user to %s: %v", roomID, err)
- } else {
- ret = true
- }
-
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
- if err != nil {
- u.log.Warnfln("Failed to auto-join %s: %v", roomID, err)
- ret = false
- } else {
- ret = true
- }
- }
-
- return ret
-}
-
-func (u *User) getDirectChats() map[id.UserID][]id.RoomID {
- chats := map[id.UserID][]id.RoomID{}
-
- privateChats := u.bridge.db.Portal.FindPrivateChats(u.ID)
- for _, portal := range privateChats {
- if portal.MXID != "" {
- puppetMXID := u.bridge.FormatPuppetMXID(portal.Key.Receiver)
-
- chats[puppetMXID] = []id.RoomID{portal.MXID}
- }
- }
-
- return chats
-}
-
-func (u *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
- if !u.bridge.Config.Bridge.SyncDirectChatList {
- return
- }
-
- puppet := u.bridge.GetPuppetByMXID(u.MXID)
- if puppet == nil {
- return
- }
-
- intent := puppet.CustomIntent()
- if intent == nil {
- return
- }
-
- method := http.MethodPatch
- if chats == nil {
- chats = u.getDirectChats()
- method = http.MethodPut
- }
-
- u.log.Debugln("Updating m.direct list on homeserver")
-
- var err error
- if u.bridge.Config.Homeserver.Asmux {
- urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
- _, err = intent.MakeFullRequest(mautrix.FullRequest{
- Method: method,
- URL: urlPath,
- Headers: http.Header{"X-Asmux-Auth": {u.bridge.as.Registration.AppToken}},
- RequestJSON: chats,
- })
- } else {
- existingChats := map[id.UserID][]id.RoomID{}
-
- err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
- if err != nil {
- u.log.Warnln("Failed to get m.direct list to update it:", err)
-
- return
- }
-
- for userID, rooms := range existingChats {
- if _, ok := u.bridge.ParsePuppetMXID(userID); !ok {
- // This is not a ghost user, include it in the new list
- chats[userID] = rooms
- } else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
- // This is a ghost user, but we're not replacing the whole list, so include it too
- chats[userID] = rooms
- }
- }
-
- err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats)
- }
-
- if err != nil {
- u.log.Warnln("Failed to update m.direct list:", err)
- }
-}
-
-func (u *User) bridgeGuild(guildID string, everything bool) error {
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- guild, found := u.guilds[guildID]
- if !found {
- return fmt.Errorf("guildID not found")
- }
-
- // Update the guild
- guild.Bridge = true
- guild.Upsert()
-
- // If this is a full bridge, create portals for all the channels
- if everything {
- channels, err := u.Session.GuildChannels(guildID)
- if err != nil {
- return err
- }
-
- for _, channel := range channels {
- if channelIsBridgeable(channel) {
- u.createChannel(channel)
- }
- }
- }
-
- return nil
-}
-
-func (u *User) unbridgeGuild(guildID string) error {
- u.guildsLock.Lock()
- defer u.guildsLock.Unlock()
-
- guild, exists := u.guilds[guildID]
- if !exists {
- return fmt.Errorf("guildID not found")
- }
-
- if !guild.Bridge {
- return fmt.Errorf("guild not bridged")
- }
-
- // First update the guild so we don't have any other go routines recreating
- // channels we're about to destroy.
- guild.Bridge = false
- guild.Upsert()
-
- // Now run through the channels in the guild and remove any portals we
- // have for them.
- channels, err := u.Session.GuildChannels(guildID)
- if err != nil {
- return err
- }
-
- for _, channel := range channels {
- if channelIsBridgeable(channel) {
- key := database.PortalKey{
- ChannelID: channel.ID,
- Receiver: u.ID,
- }
-
- portal := u.bridge.GetPortalByID(key)
- portal.leave(u)
- }
- }
-
- return nil
-}
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2409c5b
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,2 @@
+#!/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'`'" "$@"
diff --git a/commands.go b/commands.go
new file mode 100644
index 0000000..42132c8
--- /dev/null
+++ b/commands.go
@@ -0,0 +1,285 @@
+// 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 .
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/skip2/go-qrcode"
+
+ "maunium.net/go/mautrix/bridge/commands"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/mautrix-discord/remoteauth"
+)
+
+type WrappedCommandEvent struct {
+ *commands.Event
+ Bridge *DiscordBridge
+ User *User
+ Portal *Portal
+}
+
+func (br *DiscordBridge) RegisterCommands() {
+ proc := br.CommandProcessor.(*commands.Processor)
+ proc.AddHandlers(
+ cmdLogin,
+ cmdLogout,
+ cmdReconnect,
+ cmdDisconnect,
+ cmdGuilds,
+ )
+}
+
+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 cmdLogin = &commands.FullHandler{
+ Func: wrapCommand(fnLogin),
+ Name: "login",
+ Help: commands.HelpMeta{
+ Section: commands.HelpSectionAuth,
+ Description: "Link the bridge to your Discord account by scanning a QR code.",
+ },
+}
+
+func fnLogin(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 {
+ ce.Reply("Error logging in: %v", err)
+ } else if err = ce.User.Login(user.Token); err != nil {
+ ce.Reply("Error connecting after login: %v", err)
+ }
+ ce.User.Lock()
+ ce.User.ID = user.UserID
+ ce.User.Update()
+ ce.User.Unlock()
+ ce.Reply("Successfully logged in as %s#%s", user.Username, user.Discriminator)
+}
+
+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: "Unlink the bridge from your WhatsApp account.",
+ },
+ RequiresLogin: true,
+}
+
+func fnLogout(ce *WrappedCommandEvent) {
+ err := ce.User.Logout()
+ if err != nil {
+ ce.Reply("Error logging out: %v", err)
+ } else {
+ ce.Reply("Logged out successfully.")
+ }
+}
+
+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 cmdGuilds = &commands.FullHandler{
+ Func: wrapCommand(fnGuilds),
+ Name: "guilds",
+ Aliases: []string{"servers", "guild", "server"},
+ Help: commands.HelpMeta{
+ Section: commands.HelpSectionUnclassified,
+ Description: "Guild bridging management",
+ Args: " [_guild ID_] [--entire]",
+ },
+ RequiresLogin: true,
+}
+
+func fnGuilds(ce *WrappedCommandEvent) {
+ if len(ce.Args) == 0 {
+ ce.Reply("**Usage**: `$cmdprefix guilds [guild ID] [--entire]`")
+ }
+ subcommand := strings.ToLower(ce.Args[0])
+ ce.Args = ce.Args[1:]
+ switch subcommand {
+ case "status":
+ fnListGuilds(ce)
+ case "bridge":
+ fnBridgeGuild(ce)
+ case "unbridge":
+ fnUnbridgeGuild(ce)
+ }
+}
+
+func fnListGuilds(ce *WrappedCommandEvent) {
+ ce.User.guildsLock.Lock()
+ defer ce.User.guildsLock.Unlock()
+ if len(ce.User.guilds) == 0 {
+ ce.Reply("You haven't joined any guilds")
+ } else {
+ var output strings.Builder
+ for _, guild := range ce.User.guilds {
+ status := "not bridged"
+ if guild.Bridge {
+ status = "bridged"
+ }
+ _, _ = fmt.Fprintf(&output, "* %s (`%s`) - %s\n", guild.GuildName, guild.GuildID, status)
+ }
+ ce.Reply("List of guilds:\n\n%s", output.String())
+ }
+}
+
+func fnBridgeGuild(ce *WrappedCommandEvent) {
+ if len(ce.Args) == 0 || len(ce.Args) > 2 {
+ ce.Reply("**Usage**: `$cmdprefix guilds bridge [--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 ")
+ } else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
+ ce.Reply("Error unbridging guild: %v", err)
+ } else {
+ ce.Reply("Successfully unbridged guild")
+ }
+}
diff --git a/config/appservice.go b/config/appservice.go
deleted file mode 100644
index 0647cc4..0000000
--- a/config/appservice.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package config
-
-import (
- as "maunium.net/go/mautrix/appservice"
-)
-
-type appservice struct {
- Address string `yaml:"address"`
- Hostname string `yaml:"hostname"`
- Port uint16 `yaml:"port"`
-
- ID string `yaml:"id"`
-
- Bot bot `yaml:"bot"`
-
- Provisioning provisioning `yaml:"provisioning"`
-
- Database database `yaml:"database"`
-
- EphemeralEvents bool `yaml:"ephemeral_events"`
-
- ASToken string `yaml:"as_token"`
- HSToken string `yaml:"hs_token"`
-}
-
-func (a *appservice) validate() error {
- if a.ID == "" {
- a.ID = "discord"
- }
-
- if a.Address == "" {
- a.Address = "http://localhost:29350"
- }
-
- if a.Hostname == "" {
- a.Hostname = "0.0.0.0"
- }
-
- if a.Port == 0 {
- a.Port = 29350
- }
-
- if err := a.Database.validate(); err != nil {
- return err
- }
-
- if err := a.Bot.validate(); err != nil {
- return err
- }
-
- return nil
-}
-
-func (a *appservice) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawAppservice appservice
-
- raw := rawAppservice{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *a = appservice(raw)
-
- return a.validate()
-}
-
-func (cfg *Config) CreateAppService() (*as.AppService, error) {
- appservice := as.Create()
-
- appservice.HomeserverURL = cfg.Homeserver.Address
- appservice.HomeserverDomain = cfg.Homeserver.Domain
-
- appservice.Host.Hostname = cfg.Appservice.Hostname
- appservice.Host.Port = cfg.Appservice.Port
- appservice.DefaultHTTPRetries = 4
-
- reg, err := cfg.getRegistration()
- if err != nil {
- return nil, err
- }
-
- appservice.Registration = reg
-
- return appservice, nil
-}
diff --git a/config/bot.go b/config/bot.go
deleted file mode 100644
index e3e1202..0000000
--- a/config/bot.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package config
-
-type bot struct {
- Username string `yaml:"username"`
- Displayname string `yaml:"displayname"`
- Avatar string `yaml:"avatar"`
-}
-
-func (b *bot) validate() error {
- if b.Username == "" {
- b.Username = "discordbot"
- }
-
- if b.Displayname == "" {
- b.Displayname = "Discord Bridge Bot"
- }
-
- return nil
-}
-
-func (b *bot) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawBot bot
-
- raw := rawBot{}
-
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *b = bot(raw)
-
- return b.validate()
-}
diff --git a/config/bridge.go b/config/bridge.go
index ce863db..669565c 100644
--- a/config/bridge.go
+++ b/config/bridge.go
@@ -1,3 +1,19 @@
+// 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 .
+
package config
import (
@@ -5,19 +21,19 @@ import (
"strings"
"text/template"
- "maunium.net/go/mautrix/id"
-
"github.com/bwmarrin/discordgo"
+
+ "maunium.net/go/mautrix/bridge/bridgeconfig"
)
-type bridge struct {
+type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
ChannelnameTemplate string `yaml:"channelname_template"`
CommandPrefix string `yaml:"command_prefix"`
- ManagementRoomText managementRoomText `yaml:"management_root_text"`
+ ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
@@ -30,127 +46,81 @@ type bridge struct {
DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"`
LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"`
- Encryption encryption `yaml:"encryption"`
+ Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
+
+ Provisioning struct {
+ Prefix string `yaml:"prefix"`
+ SharedSecret string `yaml:"shared_secret"`
+ } `yaml:"provisioning"`
+
+ Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
channelnameTemplate *template.Template `yaml:"-"`
}
-func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
- _, homeserver, _ := userID.Parse()
- _, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
+type umBridgeConfig BridgeConfig
- return hasSecret
-}
-
-func (b *bridge) validate() error {
- var err error
-
- if b.UsernameTemplate == "" {
- b.UsernameTemplate = "discord_{{.}}"
- }
-
- b.usernameTemplate, err = template.New("username").Parse(b.UsernameTemplate)
+func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+ err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
- if b.DisplaynameTemplate == "" {
- b.DisplaynameTemplate = "{{.Username}}#{{.Discriminator}} (D){{if .Bot}} (bot){{end}}"
+ 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")
}
- b.displaynameTemplate, err = template.New("displayname").Parse(b.DisplaynameTemplate)
+ bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
- if b.ChannelnameTemplate == "" {
- b.ChannelnameTemplate = "{{if .Guild}}{{.Guild}} - {{end}}{{if .Folder}}{{.Folder}} - {{end}}{{.Name}} (D)"
- }
-
- b.channelnameTemplate, err = template.New("channelname").Parse(b.ChannelnameTemplate)
+ bc.channelnameTemplate, err = template.New("channelname").Parse(bc.ChannelnameTemplate)
if err != nil {
return err
}
- if b.PortalMessageBuffer <= 0 {
- b.PortalMessageBuffer = 128
- }
-
- if b.CommandPrefix == "" {
- b.CommandPrefix = "!dis"
- }
-
- if err := b.ManagementRoomText.validate(); err != nil {
- return err
- }
-
return nil
}
-func (b *bridge) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawBridge bridge
+var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
- // Set our defaults that aren't zero values.
- raw := rawBridge{
- SyncWithCustomPuppets: true,
- DefaultBridgeReceipts: true,
- DefaultBridgePresence: true,
- }
-
- err := unmarshal(&raw)
- if err != nil {
- return err
- }
-
- *b = bridge(raw)
-
- return b.validate()
+func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
+ return bc.Encryption
}
-func (b bridge) FormatUsername(userid string) string {
+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
-
- b.usernameTemplate.Execute(&buffer, userid)
-
+ _ = bc.usernameTemplate.Execute(&buffer, userid)
return buffer.String()
}
-type simplfiedUser struct {
- Username string
- Discriminator string
- Locale string
- Verified bool
- MFAEnabled bool
- Bot bool
- System bool
-}
-
-func (b bridge) FormatDisplayname(user *discordgo.User) string {
+func (bc BridgeConfig) FormatDisplayname(user *discordgo.User) string {
var buffer strings.Builder
-
- b.displaynameTemplate.Execute(&buffer, simplfiedUser{
- Username: user.Username,
- Discriminator: user.Discriminator,
- Locale: user.Locale,
- Verified: user.Verified,
- MFAEnabled: user.MFAEnabled,
- Bot: user.Bot,
- System: user.System,
- })
-
+ _ = bc.displaynameTemplate.Execute(&buffer, user)
return buffer.String()
}
-type simplfiedChannel struct {
+type wrappedChannel struct {
+ *discordgo.Channel
Guild string
Folder string
- Name string
- NSFW bool
}
-func (b bridge) FormatChannelname(channel *discordgo.Channel, session *discordgo.Session) (string, error) {
+func (bc BridgeConfig) FormatChannelname(channel *discordgo.Channel, session *discordgo.Session) (string, error) {
var buffer strings.Builder
var guildName, folderName string
@@ -171,18 +141,17 @@ func (b bridge) FormatChannelname(channel *discordgo.Channel, session *discordgo
if channel.Name == "" {
recipients := make([]string, len(channel.Recipients))
for idx, user := range channel.Recipients {
- recipients[idx] = b.FormatDisplayname(user)
+ recipients[idx] = bc.FormatDisplayname(user)
}
return strings.Join(recipients, ", "), nil
}
}
- b.channelnameTemplate.Execute(&buffer, simplfiedChannel{
- Guild: guildName,
- Folder: folderName,
- Name: channel.Name,
- NSFW: channel.NSFW,
+ _ = bc.channelnameTemplate.Execute(&buffer, wrappedChannel{
+ Channel: channel,
+ Guild: guildName,
+ Folder: folderName,
})
return buffer.String(), nil
diff --git a/config/cmd.go b/config/cmd.go
deleted file mode 100644
index a0fea19..0000000
--- a/config/cmd.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
-
- "go.mau.fi/mautrix-discord/globals"
-)
-
-type Cmd struct {
- HomeserverAddress string `kong:"arg,help='The url to for the homeserver',required='1'"`
- Domain string `kong:"arg,help='The domain for the homeserver',required='1'"`
-
- Force bool `kong:"flag,help='Overwrite an existing configuration file if one already exists',short='f',default='0'"`
-}
-
-func (c *Cmd) Run(g *globals.Globals) error {
- if _, err := os.Stat(g.Config); err == nil {
- if c.Force == false {
- return fmt.Errorf("file %q exists, use -f to overwrite", g.Config)
- }
- }
-
- cfg := &Config{
- Homeserver: homeserver{
- Address: c.HomeserverAddress,
- Domain: c.Domain,
- },
- }
-
- if err := cfg.validate(); err != nil {
- return err
- }
-
- return cfg.Save(g.Config)
-}
diff --git a/config/config.go b/config/config.go
index cddbd55..b3f6692 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,101 +1,35 @@
+// 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 .
+
package config
import (
- "fmt"
- "io/ioutil"
-
- "gopkg.in/yaml.v2"
+ "maunium.net/go/mautrix/bridge/bridgeconfig"
+ "maunium.net/go/mautrix/id"
)
type Config struct {
- Homeserver homeserver `yaml:"homeserver"`
- Appservice appservice `yaml:"appservice"`
- Bridge bridge `yaml:"bridge"`
- Logging logging `yaml:"logging"`
+ *bridgeconfig.BaseConfig `yaml:",inline"`
- filename string `yaml:"-"`
+ Bridge BridgeConfig `yaml:"bridge"`
}
-var configUpdated bool
+func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
+ _, homeserver, _ := userID.Parse()
+ _, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
-func (cfg *Config) validate() error {
- if err := cfg.Homeserver.validate(); err != nil {
- return err
- }
-
- if err := cfg.Appservice.validate(); err != nil {
- return err
- }
-
- if err := cfg.Bridge.validate(); err != nil {
- return err
- }
-
- if err := cfg.Logging.validate(); err != nil {
- return err
- }
-
- if configUpdated {
- return cfg.Save(cfg.filename)
- }
-
- return nil
-}
-
-func (cfg *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawConfig Config
-
- raw := rawConfig{
- filename: cfg.filename,
- }
-
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *cfg = Config(raw)
-
- return cfg.validate()
-}
-
-func FromBytes(filename string, data []byte) (*Config, error) {
- cfg := Config{
- filename: filename,
- }
-
- if err := yaml.Unmarshal(data, &cfg); err != nil {
- return nil, err
- }
-
- if err := cfg.validate(); err != nil {
- return nil, err
- }
-
- return &cfg, nil
-}
-
-func FromString(str string) (*Config, error) {
- return FromBytes("", []byte(str))
-}
-
-func FromFile(filename string) (*Config, error) {
- data, err := ioutil.ReadFile(filename)
- if err != nil {
- return nil, err
- }
-
- return FromBytes(filename, data)
-}
-
-func (cfg *Config) Save(filename string) error {
- if filename == "" {
- return fmt.Errorf("no filename specified yep")
- }
-
- data, err := yaml.Marshal(cfg)
- if err != nil {
- return err
- }
-
- return ioutil.WriteFile(filename, data, 0600)
+ return hasSecret
}
diff --git a/config/database.go b/config/database.go
deleted file mode 100644
index 8f8247f..0000000
--- a/config/database.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package config
-
-import (
- log "maunium.net/go/maulogger/v2"
-
- db "go.mau.fi/mautrix-discord/database"
-)
-
-type database struct {
- Type string `yaml:"type"`
- URI string `yaml:"uri"`
-
- MaxOpenConns int `yaml:"max_open_conns"`
- MaxIdleConns int `yaml:"max_idle_conns"`
-}
-
-func (d *database) validate() error {
- if d.Type == "" {
- d.Type = "sqlite3"
- }
-
- if d.URI == "" {
- d.URI = "mautrix-discord.db"
- }
-
- if d.MaxOpenConns == 0 {
- d.MaxOpenConns = 20
- }
-
- if d.MaxIdleConns == 0 {
- d.MaxIdleConns = 2
- }
-
- return nil
-}
-
-func (d *database) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawDatabase database
-
- raw := rawDatabase{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *d = database(raw)
-
- return d.validate()
-}
-
-func (c *Config) CreateDatabase(baseLog log.Logger) (*db.Database, error) {
- return db.New(
- c.Appservice.Database.Type,
- c.Appservice.Database.URI,
- c.Appservice.Database.MaxOpenConns,
- c.Appservice.Database.MaxIdleConns,
- baseLog,
- )
-}
diff --git a/config/encryption.go b/config/encryption.go
deleted file mode 100644
index 1d57c39..0000000
--- a/config/encryption.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package config
-
-type encryption struct {
- Allow bool `yaml:"allow"`
- Default bool `yaml:"default"`
-
- KeySharing struct {
- Allow bool `yaml:"allow"`
- RequireCrossSigning bool `yaml:"require_cross_signing"`
- RequireVerification bool `yaml:"require_verification"`
- } `yaml:"key_sharing"`
-}
-
-func (e *encryption) validate() error {
- return nil
-}
-
-func (e *encryption) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawEncryption encryption
-
- raw := rawEncryption{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *e = encryption(raw)
-
- return e.validate()
-}
diff --git a/config/homeserver.go b/config/homeserver.go
deleted file mode 100644
index bb3aeb1..0000000
--- a/config/homeserver.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package config
-
-import (
- "errors"
-)
-
-var (
- ErrHomeserverNoAddress = errors.New("no homeserver address specified")
- ErrHomeserverNoDomain = errors.New("no homeserver domain specified")
-)
-
-type homeserver struct {
- Address string `yaml:"address"`
- Domain string `yaml:"domain"`
- Asmux bool `yaml:"asmux"`
- StatusEndpoint string `yaml:"status_endpoint"`
- AsyncMedia bool `yaml:"async_media"`
-}
-
-func (h *homeserver) validate() error {
- if h.Address == "" {
- return ErrHomeserverNoAddress
- }
-
- if h.Domain == "" {
- return ErrHomeserverNoDomain
- }
-
- return nil
-}
-
-func (h *homeserver) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawHomeserver homeserver
-
- raw := rawHomeserver{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *h = homeserver(raw)
-
- return h.validate()
-}
diff --git a/config/logging.go b/config/logging.go
deleted file mode 100644
index c40d185..0000000
--- a/config/logging.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package config
-
-import (
- "errors"
- "strings"
-
- "maunium.net/go/maulogger/v2"
- as "maunium.net/go/mautrix/appservice"
-)
-
-type logging as.LogConfig
-
-func (l *logging) validate() error {
- if l.Directory == "" {
- l.Directory = "./logs"
- }
-
- if l.FileNameFormat == "" {
- l.FileNameFormat = "{{.Date}}-{{.Index}}.log"
- }
-
- if l.FileDateFormat == "" {
- l.FileDateFormat = "2006-01-02"
- }
-
- if l.FileMode == 0 {
- l.FileMode = 384
- }
-
- if l.TimestampFormat == "" {
- l.TimestampFormat = "Jan _2, 2006 15:04:05"
- }
-
- if l.RawPrintLevel == "" {
- l.RawPrintLevel = "debug"
- } else {
- switch strings.ToUpper(l.RawPrintLevel) {
- case "TRACE":
- l.PrintLevel = -10
- case "DEBUG":
- l.PrintLevel = maulogger.LevelDebug.Severity
- case "INFO":
- l.PrintLevel = maulogger.LevelInfo.Severity
- case "WARN", "WARNING":
- l.PrintLevel = maulogger.LevelWarn.Severity
- case "ERR", "ERROR":
- l.PrintLevel = maulogger.LevelError.Severity
- case "FATAL":
- l.PrintLevel = maulogger.LevelFatal.Severity
- default:
- return errors.New("invalid print level " + l.RawPrintLevel)
- }
- }
-
- return nil
-}
-
-func (l *logging) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawLogging logging
-
- raw := rawLogging{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *l = logging(raw)
-
- return l.validate()
-}
-
-func (cfg *Config) CreateLogger() (maulogger.Logger, error) {
- logger := maulogger.Create()
-
- // create an as.LogConfig from our config so we can configure the logger
- realLogConfig := as.LogConfig(cfg.Logging)
- realLogConfig.Configure(logger)
-
- // Set the default logger.
- maulogger.DefaultLogger = logger.(*maulogger.BasicLogger)
-
- // If we were given a filename format attempt to open the file.
- if cfg.Logging.FileNameFormat != "" {
- if err := maulogger.OpenFile(); err != nil {
- return nil, err
- }
- }
-
- return logger, nil
-}
diff --git a/config/managementroomtext.go b/config/managementroomtext.go
deleted file mode 100644
index 3d83804..0000000
--- a/config/managementroomtext.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package config
-
-type managementRoomText struct {
- Welcome string `yaml:"welcome"`
- Connected string `yaml:"welcome_connected"`
- NotConnected string `yaml:"welcome_unconnected"`
- AdditionalHelp string `yaml:"additional_help"`
-}
-
-func (m *managementRoomText) validate() error {
- if m.Welcome == "" {
- m.Welcome = "Greetings, I am a Discord bridge bot!"
- }
-
- if m.Connected == "" {
- m.Connected = "Use `help` to get started."
- }
-
- if m.NotConnected == "" {
- m.NotConnected = "Use `help` to get started, or `login` to login."
- }
-
- return nil
-}
-
-func (m *managementRoomText) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawManagementRoomText managementRoomText
-
- raw := rawManagementRoomText{}
-
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *m = managementRoomText(raw)
-
- return m.validate()
-}
diff --git a/config/provisioning.go b/config/provisioning.go
deleted file mode 100644
index 70518b6..0000000
--- a/config/provisioning.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package config
-
-import (
- "strings"
-
- as "maunium.net/go/mautrix/appservice"
-)
-
-type provisioning struct {
- Prefix string `yaml:"prefix"`
- SharedSecret string `yaml:"shared_secret"`
-}
-
-func (p *provisioning) validate() error {
- if p.Prefix == "" {
- p.Prefix = "/_matrix/provision/v1"
- }
-
- if strings.ToLower(p.SharedSecret) == "generate" {
- p.SharedSecret = as.RandomString(64)
-
- configUpdated = true
- }
-
- return nil
-}
-
-func (p *provisioning) UnmarshalYAML(unmarshal func(interface{}) error) error {
- type rawProvisioning provisioning
-
- raw := rawProvisioning{}
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- *p = provisioning(raw)
-
- return p.validate()
-}
-
-func (p *provisioning) Enabled() bool {
- return strings.ToLower(p.SharedSecret) != "disable"
-}
diff --git a/config/registration.go b/config/registration.go
deleted file mode 100644
index c7cc767..0000000
--- a/config/registration.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package config
-
-import (
- "fmt"
- "regexp"
-
- as "maunium.net/go/mautrix/appservice"
-)
-
-func (cfg *Config) CopyToRegistration(registration *as.Registration) error {
- registration.ID = cfg.Appservice.ID
- registration.URL = cfg.Appservice.Address
- registration.EphemeralEvents = cfg.Appservice.EphemeralEvents
-
- falseVal := false
- registration.RateLimited = &falseVal
-
- registration.SenderLocalpart = cfg.Appservice.Bot.Username
-
- pattern := fmt.Sprintf(
- "^@%s:%s$",
- cfg.Bridge.FormatUsername("[0-9]+"),
- cfg.Homeserver.Domain,
- )
-
- userIDRegex, err := regexp.Compile(pattern)
- if err != nil {
- return err
- }
-
- registration.Namespaces.RegisterUserIDs(userIDRegex, true)
-
- return nil
-}
-
-func (cfg *Config) getRegistration() (*as.Registration, error) {
- registration := as.CreateRegistration()
-
- if err := cfg.CopyToRegistration(registration); err != nil {
- return nil, err
- }
-
- registration.AppToken = cfg.Appservice.ASToken
- registration.ServerToken = cfg.Appservice.HSToken
-
- return registration, nil
-}
diff --git a/config/upgrade.go b/config/upgrade.go
new file mode 100644
index 0000000..9c2231c
--- /dev/null
+++ b/config/upgrade.go
@@ -0,0 +1,79 @@
+// 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 .
+
+package config
+
+import (
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/bridge/bridgeconfig"
+ up "maunium.net/go/mautrix/util/configupgrade"
+)
+
+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", "channelname_template")
+ helper.Copy(up.Int, "bridge", "portal_message_buffer")
+ helper.Copy(up.Bool, "bridge", "sync_with_custom_puppets")
+ helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
+ helper.Copy(up.Bool, "bridge", "default_bridge_receipts")
+ helper.Copy(up.Bool, "bridge", "default_bridge_presence")
+ 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", "encryption", "allow")
+ helper.Copy(up.Bool, "bridge", "encryption", "default")
+ helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "allow")
+ helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "require_cross_signing")
+ helper.Copy(up.Bool, "bridge", "encryption", "key_sharing", "require_verification")
+
+ helper.Copy(up.Str, "bridge", "provisioning", "prefix")
+ if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
+ sharedSecret := appservice.RandomString(64)
+ helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
+ } else {
+ helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
+ }
+
+ 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", "asmux"},
+ {"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"},
+}
diff --git a/consts/consts.go b/consts/consts.go
deleted file mode 100644
index 8299051..0000000
--- a/consts/consts.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package consts
-
-const (
- Name = "mautrix-discord"
- Description = "Discord-Matrix puppeting bridge"
-)
diff --git a/custompuppet.go b/custompuppet.go
new file mode 100644
index 0000000..815cbf2
--- /dev/null
+++ b/custompuppet.go
@@ -0,0 +1,337 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha512"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+var (
+ ErrNoCustomMXID = errors.New("no custom mxid set")
+ ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
+)
+
+///////////////////////////////////////////////////////////////////////////////
+// additional bridge api
+///////////////////////////////////////////////////////////////////////////////
+func (br *DiscordBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) {
+ _, homeserver, err := mxid.Parse()
+ if err != nil {
+ return nil, err
+ }
+
+ homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver]
+ if !found {
+ if homeserver == br.AS.HomeserverDomain {
+ homeserverURL = br.AS.HomeserverURL
+ } else if br.Config.Bridge.DoublePuppetAllowDiscovery {
+ resp, err := mautrix.DiscoverClientAPI(homeserver)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err)
+ }
+
+ homeserverURL = resp.Homeserver.BaseURL
+ br.Log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid)
+ } else {
+ return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver)
+ }
+ }
+
+ client, err := mautrix.NewClient(homeserverURL, mxid, accessToken)
+ if err != nil {
+ return nil, err
+ }
+
+ client.Logger = br.AS.Log.Sub(mxid.String())
+ client.Client = br.AS.HTTPClient
+ client.DefaultHTTPRetries = br.AS.DefaultHTTPRetries
+
+ return client, nil
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// mautrix.Syncer implementation
+///////////////////////////////////////////////////////////////////////////////
+func (puppet *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter {
+ everything := []event.Type{{Type: "*"}}
+ return &mautrix.Filter{
+ Presence: mautrix.FilterPart{
+ Senders: []id.UserID{puppet.CustomMXID},
+ Types: []event.Type{event.EphemeralEventPresence},
+ },
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ Room: mautrix.RoomFilter{
+ Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}},
+ IncludeLeave: false,
+ AccountData: mautrix.FilterPart{NotTypes: everything},
+ State: mautrix.FilterPart{NotTypes: everything},
+ Timeline: mautrix.FilterPart{NotTypes: everything},
+ },
+ }
+}
+
+func (puppet *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
+ puppet.log.Warnln("Sync error:", err)
+ if errors.Is(err, mautrix.MUnknownToken) {
+ if !puppet.tryRelogin(err, "syncing") {
+ return 0, err
+ }
+
+ puppet.customIntent.AccessToken = puppet.AccessToken
+
+ return 0, nil
+ }
+
+ return 10 * time.Second, nil
+}
+
+func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error {
+ if !puppet.customUser.IsLoggedIn() {
+ puppet.log.Debugln("Skipping sync processing: custom user not connected to discord")
+
+ return nil
+ }
+
+ // for roomID, events := range resp.Rooms.Join {
+ // for _, evt := range events.Ephemeral.Events {
+ // evt.RoomID = roomID
+ // err := evt.Content.ParseRaw(evt.Type)
+ // if err != nil {
+ // continue
+ // }
+
+ // switch evt.Type {
+ // case event.EphemeralEventReceipt:
+ // if puppet.EnableReceipts {
+ // go puppet.bridge.MatrixHandler.HandleReceipt(evt)
+ // }
+ // case event.EphemeralEventTyping:
+ // go puppet.bridge.MatrixHandler.HandleTyping(evt)
+ // }
+ // }
+ // }
+
+ // if puppet.EnablePresence {
+ // for _, evt := range resp.Presence.Events {
+ // if evt.Sender != puppet.CustomMXID {
+ // continue
+ // }
+
+ // err := evt.Content.ParseRaw(evt.Type)
+ // if err != nil {
+ // continue
+ // }
+
+ // go puppet.bridge.MatrixHandler.HandlePresence(evt)
+ // }
+ // }
+
+ return nil
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// mautrix.Storer implementation
+///////////////////////////////////////////////////////////////////////////////
+func (puppet *Puppet) SaveFilterID(_ id.UserID, _ string) {
+}
+
+func (puppet *Puppet) SaveNextBatch(_ id.UserID, nbt string) {
+ puppet.NextBatch = nbt
+ puppet.Update()
+}
+
+func (puppet *Puppet) SaveRoom(_ *mautrix.Room) {
+}
+
+func (puppet *Puppet) LoadFilterID(_ id.UserID) string {
+ return ""
+}
+
+func (puppet *Puppet) LoadNextBatch(_ id.UserID) string {
+ return puppet.NextBatch
+}
+
+func (puppet *Puppet) LoadRoom(_ id.RoomID) *mautrix.Room {
+ return nil
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// additional puppet api
+///////////////////////////////////////////////////////////////////////////////
+func (puppet *Puppet) clearCustomMXID() {
+ puppet.CustomMXID = ""
+ puppet.AccessToken = ""
+ puppet.customIntent = nil
+ puppet.customUser = nil
+}
+
+func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
+ if puppet.CustomMXID == "" {
+ return nil, ErrNoCustomMXID
+ }
+
+ client, err := puppet.bridge.newDoublePuppetClient(puppet.CustomMXID, puppet.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+
+ client.Syncer = puppet
+ client.Store = puppet
+
+ ia := puppet.bridge.AS.NewIntentAPI("custom")
+ ia.Client = client
+ ia.Localpart, _, _ = puppet.CustomMXID.Parse()
+ ia.UserID = puppet.CustomMXID
+ ia.IsCustomPuppet = true
+
+ return ia, nil
+}
+
+func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
+ if puppet.CustomMXID == "" {
+ puppet.clearCustomMXID()
+
+ return nil
+ }
+
+ intent, err := puppet.newCustomIntent()
+ if err != nil {
+ puppet.clearCustomMXID()
+
+ return err
+ }
+
+ resp, err := intent.Whoami()
+ if err != nil {
+ if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !puppet.tryRelogin(err, "initializing double puppeting")) {
+ puppet.clearCustomMXID()
+
+ return err
+ }
+
+ intent.AccessToken = puppet.AccessToken
+ } else if resp.UserID != puppet.CustomMXID {
+ puppet.clearCustomMXID()
+
+ return ErrMismatchingMXID
+ }
+
+ puppet.customIntent = intent
+ puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
+ puppet.startSyncing()
+
+ return nil
+}
+
+func (puppet *Puppet) tryRelogin(cause error, action string) bool {
+ if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) {
+ return false
+ }
+
+ puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action)
+
+ accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID)
+ if err != nil {
+ puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err)
+
+ return false
+ }
+
+ puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action)
+ puppet.AccessToken = accessToken
+
+ return true
+}
+
+func (puppet *Puppet) startSyncing() {
+ if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+ return
+ }
+
+ go func() {
+ puppet.log.Debugln("Starting syncing...")
+ puppet.customIntent.SyncPresence = "offline"
+
+ err := puppet.customIntent.Sync()
+ if err != nil {
+ puppet.log.Errorln("Fatal error syncing:", err)
+ }
+ }()
+}
+
+func (puppet *Puppet) stopSyncing() {
+ if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+ return
+ }
+
+ puppet.customIntent.StopSync()
+}
+
+func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID) (string, error) {
+ _, homeserver, _ := mxid.Parse()
+
+ puppet.log.Debugfln("Logging into %s with shared secret", mxid)
+
+ mac := hmac.New(sha512.New, []byte(puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver]))
+ mac.Write([]byte(mxid))
+
+ client, err := puppet.bridge.newDoublePuppetClient(mxid, "")
+ if err != nil {
+ return "", fmt.Errorf("failed to create mautrix client to log in: %v", err)
+ }
+
+ resp, err := client.Login(&mautrix.ReqLogin{
+ Type: mautrix.AuthTypePassword,
+ Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)},
+ Password: hex.EncodeToString(mac.Sum(nil)),
+ DeviceID: "Discord Bridge",
+ InitialDeviceDisplayName: "Discord Bridge",
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return resp.AccessToken, nil
+}
+
+func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
+ prevCustomMXID := puppet.CustomMXID
+ if puppet.customIntent != nil {
+ puppet.stopSyncing()
+ }
+
+ puppet.CustomMXID = mxid
+ puppet.AccessToken = accessToken
+
+ err := puppet.StartCustomMXID(false)
+ if err != nil {
+ return err
+ }
+
+ if prevCustomMXID != "" {
+ delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
+ }
+
+ if puppet.CustomMXID != "" {
+ puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
+ }
+
+ puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
+ puppet.EnableReceipts = puppet.bridge.Config.Bridge.DefaultBridgeReceipts
+
+ puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID)
+
+ puppet.Update()
+
+ // TODO leave rooms with default puppet
+
+ return nil
+}
diff --git a/database/attachment.go b/database/attachment.go
index b00e28f..7451219 100644
--- a/database/attachment.go
+++ b/database/attachment.go
@@ -5,7 +5,9 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Attachment struct {
@@ -19,7 +21,7 @@ type Attachment struct {
MatrixEventID id.EventID
}
-func (a *Attachment) Scan(row Scannable) *Attachment {
+func (a *Attachment) Scan(row dbutil.Scannable) *Attachment {
err := row.Scan(
&a.Channel.ChannelID, &a.Channel.Receiver,
&a.DiscordMessageID, &a.DiscordAttachmentID,
diff --git a/database/cryptostore.go b/database/cryptostore.go
deleted file mode 100644
index 171c824..0000000
--- a/database/cryptostore.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package database
-
-import (
- "database/sql"
-
- log "maunium.net/go/maulogger/v2"
-
- "maunium.net/go/mautrix/crypto"
- "maunium.net/go/mautrix/id"
-)
-
-type SQLCryptoStore struct {
- *crypto.SQLCryptoStore
- UserID id.UserID
- GhostIDFormat string
-}
-
-var _ crypto.Store = (*SQLCryptoStore)(nil)
-
-func NewSQLCryptoStore(db *Database, userID id.UserID, ghostIDFormat string) *SQLCryptoStore {
- return &SQLCryptoStore{
- SQLCryptoStore: crypto.NewSQLCryptoStore(db.DB, db.dialect, "", "",
- []byte("maunium.net/go/mautrix-whatsapp"),
- &cryptoLogger{db.log.Sub("CryptoStore")}),
- UserID: userID,
- GhostIDFormat: ghostIDFormat,
- }
-}
-
-func (store *SQLCryptoStore) FindDeviceID() id.DeviceID {
- var deviceID id.DeviceID
-
- query := `SELECT device_id FROM crypto_account WHERE account_id=$1`
- err := store.DB.QueryRow(query, store.AccountID).Scan(&deviceID)
- if err != nil && err != sql.ErrNoRows {
- store.Log.Warn("Failed to scan device ID: %v", err)
- }
-
- return deviceID
-}
-
-func (store *SQLCryptoStore) GetRoomMembers(roomID id.RoomID) ([]id.UserID, error) {
- query := `
- SELECT user_id FROM mx_user_profile
- WHERE room_id=$1
- AND (membership='join' OR membership='invite')
- AND user_id<>$2
- AND user_id NOT LIKE $3
- `
-
- members := []id.UserID{}
-
- rows, err := store.DB.Query(query, roomID, store.UserID, store.GhostIDFormat)
- if err != nil {
- return members, err
- }
-
- for rows.Next() {
- var userID id.UserID
- err := rows.Scan(&userID)
- if err != nil {
- store.Log.Warn("Failed to scan member in %s: %v", roomID, err)
- return members, err
- }
-
- members = append(members, userID)
- }
-
- return members, nil
-}
-
-// TODO merge this with the one in the parent package
-type cryptoLogger struct {
- int log.Logger
-}
-
-var levelTrace = log.Level{
- Name: "TRACE",
- Severity: -10,
- Color: -1,
-}
-
-func (c *cryptoLogger) Error(message string, args ...interface{}) {
- c.int.Errorfln(message, args...)
-}
-
-func (c *cryptoLogger) Warn(message string, args ...interface{}) {
- c.int.Warnfln(message, args...)
-}
-
-func (c *cryptoLogger) Debug(message string, args ...interface{}) {
- c.int.Debugfln(message, args...)
-}
-
-func (c *cryptoLogger) Trace(message string, args ...interface{}) {
- c.int.Logfln(levelTrace, message, args...)
-}
diff --git a/database/database.go b/database/database.go
index 3798d72..d53c6f3 100644
--- a/database/database.go
+++ b/database/database.go
@@ -1,20 +1,18 @@
package database
import (
- "database/sql"
+ _ "embed"
+ "fmt"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
- log "maunium.net/go/maulogger/v2"
-
- "go.mau.fi/mautrix-discord/database/migrations"
+ "go.mau.fi/mautrix-discord/database/upgrades"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Database struct {
- *sql.DB
- log log.Logger
- dialect string
+ *dbutil.Database
User *UserQuery
Portal *PortalQuery
@@ -26,70 +24,51 @@ type Database struct {
Guild *GuildQuery
}
-func New(dbType, uri string, maxOpenConns, maxIdleConns int, baseLog log.Logger) (*Database, error) {
- conn, err := sql.Open(dbType, uri)
- if err != nil {
- return nil, err
+//go:embed legacymigrate.sql
+var legacyMigrate string
+
+func New(baseDB *dbutil.Database) *Database {
+ db := &Database{Database: baseDB}
+ _, err := db.Exec("SELECT id FROM version")
+ if err == nil {
+ baseDB.Log.Infoln("Migrating from legacy database versioning")
+ _, err = db.Exec(legacyMigrate)
+ if err != nil {
+ panic(fmt.Errorf("failed to migrate from legacy database versioning: %v", err))
+ }
}
-
- if dbType == "sqlite3" {
- conn.Exec("PRAGMA foreign_keys = ON")
- }
-
- conn.SetMaxOpenConns(maxOpenConns)
- conn.SetMaxIdleConns(maxIdleConns)
-
- dbLog := baseLog.Sub("Database")
-
- if err := migrations.Run(conn, dbLog, dbType); err != nil {
- return nil, err
- }
-
- db := &Database{
- DB: conn,
- log: dbLog,
- dialect: dbType,
- }
-
+ db.UpgradeTable = upgrades.Table
db.User = &UserQuery{
db: db,
- log: db.log.Sub("User"),
+ log: db.Log.Sub("User"),
}
-
db.Portal = &PortalQuery{
db: db,
- log: db.log.Sub("Portal"),
+ log: db.Log.Sub("Portal"),
}
-
db.Puppet = &PuppetQuery{
db: db,
- log: db.log.Sub("Puppet"),
+ log: db.Log.Sub("Puppet"),
}
-
db.Message = &MessageQuery{
db: db,
- log: db.log.Sub("Message"),
+ log: db.Log.Sub("Message"),
}
-
db.Reaction = &ReactionQuery{
db: db,
- log: db.log.Sub("Reaction"),
+ log: db.Log.Sub("Reaction"),
}
-
db.Attachment = &AttachmentQuery{
db: db,
- log: db.log.Sub("Attachment"),
+ log: db.Log.Sub("Attachment"),
}
-
db.Emoji = &EmojiQuery{
db: db,
- log: db.log.Sub("Emoji"),
+ log: db.Log.Sub("Emoji"),
}
-
db.Guild = &GuildQuery{
db: db,
- log: db.log.Sub("Guild"),
+ log: db.Log.Sub("Guild"),
}
-
- return db, nil
+ return db
}
diff --git a/database/emoji.go b/database/emoji.go
index 9474edb..3e3a01b 100644
--- a/database/emoji.go
+++ b/database/emoji.go
@@ -7,6 +7,7 @@ import (
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Emoji struct {
@@ -19,7 +20,7 @@ type Emoji struct {
MatrixURL id.ContentURI
}
-func (e *Emoji) Scan(row Scannable) *Emoji {
+func (e *Emoji) Scan(row dbutil.Scannable) *Emoji {
var matrixURL sql.NullString
err := row.Scan(&e.DiscordID, &e.DiscordName, &matrixURL)
diff --git a/database/guild.go b/database/guild.go
index 3232a26..c913d2f 100644
--- a/database/guild.go
+++ b/database/guild.go
@@ -5,6 +5,8 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix/util/dbutil"
)
type Guild struct {
@@ -17,7 +19,7 @@ type Guild struct {
Bridge bool
}
-func (g *Guild) Scan(row Scannable) *Guild {
+func (g *Guild) Scan(row dbutil.Scannable) *Guild {
err := row.Scan(&g.DiscordID, &g.GuildID, &g.GuildName, &g.Bridge)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
diff --git a/database/legacymigrate.sql b/database/legacymigrate.sql
new file mode 100644
index 0000000..7c307d9
--- /dev/null
+++ b/database/legacymigrate.sql
@@ -0,0 +1,10 @@
+DROP TABLE version;
+CREATE TABLE version(version INTEGER PRIMARY KEY);
+INSERT INTO version VALUES (1);
+CREATE TABLE crypto_version (version INTEGER PRIMARY KEY);
+INSERT INTO crypto_version VALUES (6);
+CREATE TABLE mx_version (version INTEGER PRIMARY KEY);
+INSERT INTO mx_version VALUES (1);
+
+UPDATE "user" SET id=null WHERE id='';
+ALTER TABLE "user" ADD CONSTRAINT user_id_key UNIQUE (id);
diff --git a/database/message.go b/database/message.go
index 59dde3e..be1b482 100644
--- a/database/message.go
+++ b/database/message.go
@@ -6,7 +6,9 @@ import (
"time"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Message struct {
@@ -22,7 +24,7 @@ type Message struct {
Timestamp time.Time
}
-func (m *Message) Scan(row Scannable) *Message {
+func (m *Message) Scan(row dbutil.Scannable) *Message {
var ts int64
err := row.Scan(&m.Channel.ChannelID, &m.Channel.Receiver, &m.DiscordID, &m.MatrixID, &m.AuthorID, &ts)
diff --git a/database/migrations/02-attachments.sql b/database/migrations/02-attachments.sql
deleted file mode 100644
index f36a85c..0000000
--- a/database/migrations/02-attachments.sql
+++ /dev/null
@@ -1,12 +0,0 @@
-CREATE TABLE attachment (
- channel_id TEXT NOT NULL,
- receiver TEXT NOT NULL,
-
- discord_message_id TEXT NOT NULL,
- discord_attachment_id TEXT NOT NULL,
-
- matrix_event_id TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY(discord_attachment_id, matrix_event_id),
- FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
-);
diff --git a/database/migrations/03-emoji.sql b/database/migrations/03-emoji.sql
deleted file mode 100644
index b0f4283..0000000
--- a/database/migrations/03-emoji.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-CREATE TABLE emoji (
- discord_id TEXT NOT NULL PRIMARY KEY,
- discord_name TEXT,
- matrix_url TEXT
-);
diff --git a/database/migrations/04-custom-puppet.sql b/database/migrations/04-custom-puppet.sql
deleted file mode 100644
index a5c6af9..0000000
--- a/database/migrations/04-custom-puppet.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE puppet ADD COLUMN custom_mxid TEXT;
-ALTER TABLE puppet ADD COLUMN access_token TEXT;
diff --git a/database/migrations/05-additional-puppet-fields.sql b/database/migrations/05-additional-puppet-fields.sql
deleted file mode 100644
index 7b48629..0000000
--- a/database/migrations/05-additional-puppet-fields.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE puppet ADD COLUMN next_batch TEXT;
-ALTER TABLE puppet ADD COLUMN enable_receipts BOOLEAN NOT NULL DEFAULT true;
diff --git a/database/migrations/06-remove-unique-user-constraint.postgres.sql b/database/migrations/06-remove-unique-user-constraint.postgres.sql
deleted file mode 100644
index 46613e4..0000000
--- a/database/migrations/06-remove-unique-user-constraint.postgres.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE "user" DROP CONSTRAINT user_id_key;
diff --git a/database/migrations/06-remove-unique-user-constraint.sqlite.sql b/database/migrations/06-remove-unique-user-constraint.sqlite.sql
deleted file mode 100644
index ad1df67..0000000
--- a/database/migrations/06-remove-unique-user-constraint.sqlite.sql
+++ /dev/null
@@ -1,18 +0,0 @@
-PRAGMA foreign_keys=off;
-
-ALTER TABLE "user" RENAME TO "old_user";
-
-CREATE TABLE "user" (
- mxid TEXT PRIMARY KEY,
- id TEXT,
-
- management_room TEXT,
-
- token TEXT
-);
-
-INSERT INTO "user" SELECT mxid, id, management_room, token FROM "old_user";
-
-DROP TABLE "old_user";
-
-PRAGMA foreign_keys=on;
diff --git a/database/migrations/07-guilds.sql b/database/migrations/07-guilds.sql
deleted file mode 100644
index 0cabe37..0000000
--- a/database/migrations/07-guilds.sql
+++ /dev/null
@@ -1,7 +0,0 @@
-CREATE TABLE guild (
- discord_id TEXT NOT NULL,
- guild_id TEXT NOT NULL,
- guild_name TEXT NOT NULL,
- bridge BOOLEAN DEFAULT FALSE,
- PRIMARY KEY(discord_id, guild_id)
-);
diff --git a/database/migrations/08-add-crypto-store-to-database.sql b/database/migrations/08-add-crypto-store-to-database.sql
deleted file mode 100644
index c615976..0000000
--- a/database/migrations/08-add-crypto-store-to-database.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 0
--- which is described as "Add crypto store to database".
diff --git a/database/migrations/09-add-account_id-to-crypto-store.sql b/database/migrations/09-add-account_id-to-crypto-store.sql
deleted file mode 100644
index 03dc1cc..0000000
--- a/database/migrations/09-add-account_id-to-crypto-store.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 1
--- which is described as "Add account_id to crypto store".
diff --git a/database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql b/database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql
deleted file mode 100644
index 38813b8..0000000
--- a/database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 2
--- which is described as "Add megolm withheld data to crypto store".
diff --git a/database/migrations/11-add-cross-signing-keys-to-crypto-store.sql b/database/migrations/11-add-cross-signing-keys-to-crypto-store.sql
deleted file mode 100644
index 39f5041..0000000
--- a/database/migrations/11-add-cross-signing-keys-to-crypto-store.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 3
--- which is described as "Add cross-signing keys to crypto store".
diff --git a/database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql b/database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql
deleted file mode 100644
index adb841e..0000000
--- a/database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 4
--- which is described as "Replace VARCHAR(255) with TEXT in the crypto
--- database".
diff --git a/database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql b/database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql
deleted file mode 100644
index 28906bd..0000000
--- a/database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- This migration is implemented in migrations.go as it comes from
--- maunium.net/go/mautrix/crypto/sql_store_upgrade. It runs upgrade at index 5
--- which is described as "Split last_used into last_encrypted and
--- last_decrypted in crypto store".
diff --git a/database/migrations/14-add-encrypted-column-to-portal-table.sql b/database/migrations/14-add-encrypted-column-to-portal-table.sql
deleted file mode 100644
index d032fee..0000000
--- a/database/migrations/14-add-encrypted-column-to-portal-table.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/migrations/migrations.go b/database/migrations/migrations.go
deleted file mode 100644
index 904b9f4..0000000
--- a/database/migrations/migrations.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package migrations
-
-import (
- "database/sql"
- "embed"
-
- "github.com/lopezator/migrator"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/crypto/sql_store_upgrade"
-)
-
-//go:embed *.sql
-var embeddedMigrations embed.FS
-
-func migrationFromFile(description, filename string) *migrator.Migration {
- return &migrator.Migration{
- Name: description,
- Func: func(tx *sql.Tx) error {
- data, err := embeddedMigrations.ReadFile(filename)
- if err != nil {
- return err
- }
-
- if _, err := tx.Exec(string(data)); err != nil {
- return err
- }
-
- return nil
- },
- }
-}
-
-func migrationFromFileWithDialect(dialect, description, sqliteFile, postgresFile string) *migrator.Migration {
- switch dialect {
- case "sqlite3":
- return migrationFromFile(description, sqliteFile)
- case "postgres":
- return migrationFromFile(description, postgresFile)
- default:
- return nil
- }
-}
-
-func Run(db *sql.DB, baseLog log.Logger, dialect string) error {
- subLogger := baseLog.Sub("Migrations")
- logger := migrator.LoggerFunc(func(msg string, args ...interface{}) {
- subLogger.Infof(msg, args...)
- })
-
- m, err := migrator.New(
- migrator.TableName("version"),
- migrator.WithLogger(logger),
- migrator.Migrations(
- migrationFromFile("Initial Schema", "01-initial.sql"),
- migrationFromFile("Attachments", "02-attachments.sql"),
- migrationFromFile("Emoji", "03-emoji.sql"),
- migrationFromFile("Custom Puppets", "04-custom-puppet.sql"),
- migrationFromFile(
- "Additional puppet fields",
- "05-additional-puppet-fields.sql",
- ),
- migrationFromFileWithDialect(
- dialect,
- "Remove unique user constraint",
- "06-remove-unique-user-constraint.sqlite.sql",
- "06-remove-unique-user-constraint.postgres.sql",
- ),
- migrationFromFile("Guild Bridging", "07-guilds.sql"),
- &migrator.Migration{
- Name: "Add crypto store to database",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[0](tx, dialect)
- },
- },
- &migrator.Migration{
- Name: "Add account_id to crypto store",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[1](tx, dialect)
- },
- },
- &migrator.Migration{
- Name: "Add megolm withheld data to crypto store",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[2](tx, dialect)
- },
- },
- &migrator.Migration{
- Name: "Add cross-signing keys to crypto store",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[3](tx, dialect)
- },
- },
- &migrator.Migration{
- Name: "Replace VARCHAR(255) with TEXT in the crypto database",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[4](tx, dialect)
- },
- },
- &migrator.Migration{
- Name: "Split last_used into last_encrypted and last_decrypted in crypto store",
- Func: func(tx *sql.Tx) error {
- return sql_store_upgrade.Upgrades[5](tx, dialect)
- },
- },
- migrationFromFile(
- "Add encryption column to portal table",
- "14-add-encrypted-column-to-portal-table.sql",
- ),
- ),
- )
- if err != nil {
- return err
- }
-
- if err := m.Migrate(db); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/database/portal.go b/database/portal.go
index 96e4c45..c30650f 100644
--- a/database/portal.go
+++ b/database/portal.go
@@ -6,7 +6,9 @@ import (
"github.com/bwmarrin/discordgo"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Portal struct {
@@ -30,7 +32,7 @@ type Portal struct {
FirstEventID id.EventID
}
-func (p *Portal) Scan(row Scannable) *Portal {
+func (p *Portal) Scan(row dbutil.Scannable) *Portal {
var mxid, avatarURL, firstEventID sql.NullString
var typ sql.NullInt32
diff --git a/database/puppet.go b/database/puppet.go
index 643dd41..3cdde8b 100644
--- a/database/puppet.go
+++ b/database/puppet.go
@@ -4,7 +4,9 @@ import (
"database/sql"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
const (
@@ -34,7 +36,7 @@ type Puppet struct {
EnableReceipts bool
}
-func (p *Puppet) Scan(row Scannable) *Puppet {
+func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
var did, displayName, avatar, avatarURL sql.NullString
var enablePresence sql.NullBool
var customMXID, accessToken, nextBatch sql.NullString
diff --git a/database/reaction.go b/database/reaction.go
index 617eedc..8f51209 100644
--- a/database/reaction.go
+++ b/database/reaction.go
@@ -5,7 +5,9 @@ import (
"errors"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type Reaction struct {
@@ -26,7 +28,7 @@ type Reaction struct {
DiscordID string // The id or unicode of the emoji for discord
}
-func (r *Reaction) Scan(row Scannable) *Reaction {
+func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
var discordID sql.NullString
err := row.Scan(
diff --git a/database/scannable.go b/database/scannable.go
deleted file mode 100644
index 66ad2fd..0000000
--- a/database/scannable.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package database
-
-type Scannable interface {
- Scan(...interface{}) error
-}
diff --git a/database/sqlstatestore.go b/database/sqlstatestore.go
deleted file mode 100644
index 9fcfd1c..0000000
--- a/database/sqlstatestore.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package database
-
-import (
- "database/sql"
- "encoding/json"
- "sync"
-
- log "maunium.net/go/maulogger/v2"
-
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-)
-
-type SQLStateStore struct {
- *appservice.TypingStateStore
-
- db *Database
- log log.Logger
-
- Typing map[id.RoomID]map[id.UserID]int64
- typingLock sync.RWMutex
-}
-
-// make sure that SQLStateStore implements the appservice.StateStore interface
-var _ appservice.StateStore = (*SQLStateStore)(nil)
-
-func NewSQLStateStore(db *Database) *SQLStateStore {
- return &SQLStateStore{
- TypingStateStore: appservice.NewTypingStateStore(),
- db: db,
- log: db.log.Sub("StateStore"),
- }
-}
-
-func (s *SQLStateStore) IsRegistered(userID id.UserID) bool {
- var isRegistered bool
-
- query := "SELECT EXISTS(SELECT 1 FROM mx_registrations WHERE user_id=$1)"
- row := s.db.QueryRow(query, userID)
-
- err := row.Scan(&isRegistered)
- if err != nil {
- s.log.Warnfln("Failed to scan registration existence for %s: %v", userID, err)
- }
-
- return isRegistered
-}
-
-func (s *SQLStateStore) MarkRegistered(userID id.UserID) {
- query := "INSERT INTO mx_registrations (user_id) VALUES ($1)" +
- " ON CONFLICT (user_id) DO NOTHING"
-
- _, err := s.db.Exec(query, userID)
- if err != nil {
- s.log.Warnfln("Failed to mark %s as registered: %v", userID, err)
- }
-}
-
-func (s *SQLStateStore) IsTyping(roomID id.RoomID, userID id.UserID) bool {
- s.log.Debugln("IsTyping")
-
- return false
-}
-
-func (s *SQLStateStore) SetTyping(roomID id.RoomID, userID id.UserID, timeout int64) {
- s.log.Debugln("SetTyping")
-}
-
-func (s *SQLStateStore) IsInRoom(roomID id.RoomID, userID id.UserID) bool {
- return s.IsMembership(roomID, userID, "join")
-}
-
-func (s *SQLStateStore) IsInvited(roomID id.RoomID, userID id.UserID) bool {
- return s.IsMembership(roomID, userID, "join", "invite")
-}
-
-func (s *SQLStateStore) IsMembership(roomID id.RoomID, userID id.UserID, allowedMemberships ...event.Membership) bool {
- membership := s.GetMembership(roomID, userID)
- for _, allowedMembership := range allowedMemberships {
- if allowedMembership == membership {
- return true
- }
- }
-
- return false
-}
-
-func (s *SQLStateStore) GetMembership(roomID id.RoomID, userID id.UserID) event.Membership {
- query := "SELECT membership FROM mx_user_profile WHERE " +
- "room_id=$1 AND user_id=$2"
- row := s.db.QueryRow(query, roomID, userID)
-
- membership := event.MembershipLeave
- err := row.Scan(&membership)
- if err != nil && err != sql.ErrNoRows {
- s.log.Warnfln("Failed to scan membership of %s in %s: %v", userID, roomID, err)
- }
-
- return membership
-}
-
-func (s *SQLStateStore) GetMember(roomID id.RoomID, userID id.UserID) *event.MemberEventContent {
- member, ok := s.TryGetMember(roomID, userID)
- if !ok {
- member.Membership = event.MembershipLeave
- }
-
- return member
-}
-
-func (s *SQLStateStore) TryGetMember(roomID id.RoomID, userID id.UserID) (*event.MemberEventContent, bool) {
- query := "SELECT membership, displayname, avatar_url FROM mx_user_profile " +
- "WHERE room_id=$1 AND user_id=$2"
- row := s.db.QueryRow(query, roomID, userID)
-
- var member event.MemberEventContent
- err := row.Scan(&member.Membership, &member.Displayname, &member.AvatarURL)
- if err != nil && err != sql.ErrNoRows {
- s.log.Warnfln("Failed to scan member info of %s in %s: %v", userID, roomID, err)
- }
-
- return &member, err == nil
-}
-
-func (s *SQLStateStore) SetMembership(roomID id.RoomID, userID id.UserID, membership event.Membership) {
- query := "INSERT INTO mx_user_profile (room_id, user_id, membership)" +
- " VALUES ($1, $2, $3) ON CONFLICT (room_id, user_id) DO UPDATE SET" +
- " membership=excluded.membership"
-
- _, err := s.db.Exec(query, roomID, userID, membership)
- if err != nil {
- s.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, membership, err)
- }
-}
-
-func (s *SQLStateStore) SetMember(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) {
- query := "INSERT INTO mx_user_profile" +
- " (room_id, user_id, membership, displayname, avatar_url)" +
- " VALUES ($1, $2, $3, $4, $5) ON CONFLICT (room_id, user_id)" +
- " DO UPDATE SET membership=excluded.membership," +
- " displayname=excluded.displayname, avatar_url=excluded.avatar_url"
- _, err := s.db.Exec(query, roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
- if err != nil {
- s.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, member, err)
- }
-}
-
-func (s *SQLStateStore) SetPowerLevels(roomID id.RoomID, levels *event.PowerLevelsEventContent) {
- levelsBytes, err := json.Marshal(levels)
- if err != nil {
- s.log.Errorfln("Failed to marshal power levels of %s: %v", roomID, err)
- return
- }
-
- query := "INSERT INTO mx_room_state (room_id, power_levels)" +
- " VALUES ($1, $2) ON CONFLICT (room_id) DO UPDATE SET" +
- " power_levels=excluded.power_levels"
- _, err = s.db.Exec(query, roomID, levelsBytes)
- if err != nil {
- s.log.Warnfln("Failed to store power levels of %s: %v", roomID, err)
- }
-}
-
-func (s *SQLStateStore) GetPowerLevels(roomID id.RoomID) *event.PowerLevelsEventContent {
- query := "SELECT power_levels FROM mx_room_state WHERE room_id=$1"
- row := s.db.QueryRow(query, roomID)
- if row == nil {
- return nil
- }
-
- var data []byte
- err := row.Scan(&data)
- if err != nil {
- s.log.Errorfln("Failed to scan power levels of %s: %v", roomID, err)
-
- return nil
- }
-
- levels := &event.PowerLevelsEventContent{}
- err = json.Unmarshal(data, levels)
- if err != nil {
- s.log.Errorfln("Failed to parse power levels of %s: %v", roomID, err)
-
- return nil
- }
-
- return levels
-}
-
-func (s *SQLStateStore) GetPowerLevel(roomID id.RoomID, userID id.UserID) int {
- if s.db.dialect == "postgres" {
- query := "SELECT COALESCE((power_levels->'users'->$2)::int," +
- " (power_levels->'users_default')::int, 0)" +
- " FROM mx_room_state WHERE room_id=$1"
- row := s.db.QueryRow(query, roomID, userID)
- if row == nil {
- // Power levels not in db
- return 0
- }
-
- var powerLevel int
- err := row.Scan(&powerLevel)
- if err != nil {
- s.log.Errorfln("Failed to scan power level of %s in %s: %v", userID, roomID, err)
- }
-
- return powerLevel
- }
-
- return s.GetPowerLevels(roomID).GetUserLevel(userID)
-}
-
-func (s *SQLStateStore) GetPowerLevelRequirement(roomID id.RoomID, eventType event.Type) int {
- if s.db.dialect == "postgres" {
- defaultType := "events_default"
- defaultValue := 0
- if eventType.IsState() {
- defaultType = "state_default"
- defaultValue = 50
- }
-
- query := "SELECT COALESCE((power_levels->'events'->$2)::int," +
- " (power_levels->'$3')::int, $4)" +
- " FROM mx_room_state WHERE room_id=$1"
- row := s.db.QueryRow(query, roomID, eventType.Type, defaultType, defaultValue)
- if row == nil {
- // Power levels not in db
- return defaultValue
- }
-
- var powerLevel int
- err := row.Scan(&powerLevel)
- if err != nil {
- s.log.Errorfln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
- }
-
- return powerLevel
- }
-
- return s.GetPowerLevels(roomID).GetEventLevel(eventType)
-}
-
-func (s *SQLStateStore) HasPowerLevel(roomID id.RoomID, userID id.UserID, eventType event.Type) bool {
- if s.db.dialect == "postgres" {
- defaultType := "events_default"
- defaultValue := 0
- if eventType.IsState() {
- defaultType = "state_default"
- defaultValue = 50
- }
-
- query := "SELECT COALESCE((power_levels->'users'->$2)::int," +
- " (power_levels->'users_default')::int, 0) >=" +
- " COALESCE((power_levels->'events'->$3)::int," +
- " (power_levels->'$4')::int, $5)" +
- " FROM mx_room_state WHERE room_id=$1"
- row := s.db.QueryRow(query, roomID, userID, eventType.Type, defaultType, defaultValue)
- if row == nil {
- // Power levels not in db
- return defaultValue == 0
- }
-
- var hasPower bool
- err := row.Scan(&hasPower)
- if err != nil {
- s.log.Errorfln("Failed to scan power level for %s in %s: %v", eventType, roomID, err)
- }
-
- return hasPower
- }
-
- return s.GetPowerLevel(roomID, userID) >= s.GetPowerLevelRequirement(roomID, eventType)
-}
-
-func (store *SQLStateStore) FindSharedRooms(userID id.UserID) []id.RoomID {
- query := `
- SELECT room_id FROM mx_user_profile
- LEFT JOIN portal ON portal.mxid=mx_user_profile.room_id
- WHERE user_id=$1 AND portal.encrypted=true
- `
-
- rooms := []id.RoomID{}
-
- rows, err := store.db.Query(query, userID)
- if err != nil {
- store.log.Warnfln("Failed to query shared rooms with %s: %v", userID, err)
-
- return rooms
- }
-
- for rows.Next() {
- var roomID id.RoomID
-
- err = rows.Scan(&roomID)
- if err != nil {
- store.log.Warnfln("Failed to scan room ID: %v", err)
- } else {
- rooms = append(rooms, roomID)
- }
- }
-
- return rooms
-}
diff --git a/database/migrations/01-initial.sql b/database/upgrades/00-initial-revision.sql
similarity index 54%
rename from database/migrations/01-initial.sql
rename to database/upgrades/00-initial-revision.sql
index 7e16d61..24254ba 100644
--- a/database/migrations/01-initial.sql
+++ b/database/upgrades/00-initial-revision.sql
@@ -1,3 +1,5 @@
+-- v1: Initial revision
+
CREATE TABLE portal (
channel_id TEXT,
receiver TEXT,
@@ -9,6 +11,8 @@ CREATE TABLE portal (
avatar TEXT NOT NULL,
avatar_url TEXT,
+ encrypted BOOLEAN NOT NULL DEFAULT false,
+
type INT,
dmuser TEXT,
@@ -24,7 +28,12 @@ CREATE TABLE puppet (
avatar TEXT,
avatar_url TEXT,
- enable_presence BOOLEAN NOT NULL DEFAULT true
+ enable_presence BOOLEAN NOT NULL DEFAULT true,
+ enable_receipts BOOLEAN NOT NULL DEFAULT true,
+
+ custom_mxid TEXT,
+ access_token TEXT,
+ next_batch TEXT
);
CREATE TABLE "user" (
@@ -38,12 +47,12 @@ CREATE TABLE "user" (
CREATE TABLE message (
channel_id TEXT NOT NULL,
- receiver TEXT NOT NULL,
+ receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
- matrix_message_id TEXT NOT NULL UNIQUE,
+ matrix_message_id TEXT NOT NULL UNIQUE,
- author_id TEXT NOT NULL,
+ author_id TEXT NOT NULL,
timestamp BIGINT NOT NULL,
PRIMARY KEY(discord_message_id, channel_id, receiver),
@@ -52,10 +61,10 @@ CREATE TABLE message (
CREATE TABLE reaction (
channel_id TEXT NOT NULL,
- receiver TEXT NOT NULL,
+ receiver TEXT NOT NULL,
discord_message_id TEXT NOT NULL,
- matrix_event_id TEXT NOT NULL UNIQUE,
+ matrix_event_id TEXT NOT NULL UNIQUE,
author_id TEXT NOT NULL,
@@ -68,20 +77,29 @@ CREATE TABLE reaction (
FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);
-CREATE TABLE mx_user_profile (
- room_id TEXT,
- user_id TEXT,
- membership TEXT NOT NULL,
- displayname TEXT,
- avatar_url TEXT,
- PRIMARY KEY (room_id, user_id)
+CREATE TABLE attachment (
+ channel_id TEXT NOT NULL,
+ receiver TEXT NOT NULL,
+
+ discord_message_id TEXT NOT NULL,
+ discord_attachment_id TEXT NOT NULL,
+
+ matrix_event_id TEXT NOT NULL UNIQUE,
+
+ PRIMARY KEY(discord_attachment_id, matrix_event_id),
+ FOREIGN KEY(channel_id, receiver) REFERENCES portal(channel_id, receiver) ON DELETE CASCADE
);
-CREATE TABLE mx_registrations (
- user_id TEXT PRIMARY KEY
+CREATE TABLE emoji (
+ discord_id TEXT PRIMARY KEY,
+ discord_name TEXT,
+ matrix_url TEXT
);
-CREATE TABLE mx_room_state (
- room_id TEXT PRIMARY KEY,
- power_levels TEXT
+CREATE TABLE guild (
+ discord_id TEXT NOT NULL,
+ guild_id TEXT NOT NULL,
+ guild_name TEXT NOT NULL,
+ bridge BOOLEAN DEFAULT FALSE,
+ PRIMARY KEY(discord_id, guild_id)
);
diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go
new file mode 100644
index 0000000..59d0d33
--- /dev/null
+++ b/database/upgrades/upgrades.go
@@ -0,0 +1,32 @@
+// 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 .
+
+package upgrades
+
+import (
+ "embed"
+
+ "maunium.net/go/mautrix/util/dbutil"
+)
+
+var Table dbutil.UpgradeTable
+
+//go:embed *.sql
+var rawUpgrades embed.FS
+
+func init() {
+ Table.RegisterFS(rawUpgrades)
+}
diff --git a/database/user.go b/database/user.go
index 4fc093a..cc6ceee 100644
--- a/database/user.go
+++ b/database/user.go
@@ -4,7 +4,9 @@ import (
"database/sql"
log "maunium.net/go/maulogger/v2"
+
"maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/dbutil"
)
type User struct {
@@ -19,10 +21,11 @@ type User struct {
Token string
}
-func (u *User) Scan(row Scannable) *User {
+func (u *User) Scan(row dbutil.Scannable) *User {
var token sql.NullString
+ var discordID sql.NullString
- err := row.Scan(&u.MXID, &u.ID, &u.ManagementRoom, &token)
+ err := row.Scan(&u.MXID, &discordID, &u.ManagementRoom, &token)
if err != nil {
if err != sql.ErrNoRows {
u.log.Errorln("Database scan failed:", err)
@@ -35,6 +38,10 @@ func (u *User) Scan(row Scannable) *User {
u.Token = token.String
}
+ if discordID.Valid {
+ u.ID = discordID.String
+ }
+
return u
}
@@ -44,13 +51,19 @@ func (u *User) Insert() {
" VALUES ($1, $2, $3, $4);"
var token sql.NullString
+ var discordID sql.NullString
if u.Token != "" {
token.String = u.Token
token.Valid = true
}
- _, err := u.db.Exec(query, u.MXID, u.ID, u.ManagementRoom, token)
+ if u.ID != "" {
+ discordID.String = u.ID
+ discordID.Valid = true
+ }
+
+ _, err := u.db.Exec(query, u.MXID, discordID, u.ManagementRoom, token)
if err != nil {
u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
@@ -63,13 +76,19 @@ func (u *User) Update() {
" WHERE mxid=$4;"
var token sql.NullString
+ var discordID sql.NullString
if u.Token != "" {
token.String = u.Token
token.Valid = true
}
- _, err := u.db.Exec(query, u.ID, u.ManagementRoom, token, u.MXID)
+ if u.ID != "" {
+ discordID.String = u.ID
+ discordID.Valid = true
+ }
+
+ _, err := u.db.Exec(query, discordID, u.ManagementRoom, token, u.MXID)
if err != nil {
u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
diff --git a/bridge/discord.go b/discord.go
similarity index 94%
rename from bridge/discord.go
rename to discord.go
index 2322b81..05096db 100644
--- a/bridge/discord.go
+++ b/discord.go
@@ -1,4 +1,4 @@
-package bridge
+package main
import (
"github.com/bwmarrin/discordgo"
diff --git a/bridge/emoji.go b/emoji.go
similarity index 80%
rename from bridge/emoji.go
rename to emoji.go
index fe83bc0..1130c7f 100644
--- a/bridge/emoji.go
+++ b/emoji.go
@@ -1,4 +1,4 @@
-package bridge
+package main
import (
"io/ioutil"
@@ -10,7 +10,7 @@ import (
"maunium.net/go/mautrix/id"
)
-func (p *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
+func (portal *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string, error) {
var url string
var mimeType string
@@ -43,7 +43,7 @@ func (p *Portal) downloadDiscordEmoji(id string, animated bool) ([]byte, string,
return data, mimeType, err
}
-func (p *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
+func (portal *Portal) uploadMatrixEmoji(intent *appservice.IntentAPI, data []byte, mimeType string) (id.ContentURI, error) {
uploaded, err := intent.UploadBytes(data, mimeType)
if err != nil {
return id.ContentURI{}, err
diff --git a/example-config.yaml b/example-config.yaml
index 060d1f8..0758495 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -11,6 +11,8 @@ homeserver:
# 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
@@ -23,6 +25,7 @@ appservice:
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29334
+
# Database config.
database:
# The database type. "sqlite3" and "postgres" are supported.
@@ -40,19 +43,16 @@ appservice:
max_conn_idle_time: null
max_conn_lifetime: null
- # 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
-
+ # The unique ID of this appservice.
id: discord
+ # Appservice bot details.
bot:
- username: discordbot
- displayname: Discord bridge bot
- avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
+ # 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+).
@@ -71,6 +71,7 @@ bridge:
# Displayname template for Discord users.
# TODO: document variables
displayname_template: '{{.Username}}#{{.Discriminator}} (D){{if .Bot}} (bot){{end}}'
+ channelname_template: '{{if .Guild}}{{.Guild}} - {{end}}{{if .Folder}}{{.Folder}} - {{end}}{{.Name}} (D)'
portal_message_buffer: 128
@@ -99,12 +100,12 @@ bridge:
example.com: foobar
# The prefix for commands. Only required in non-management rooms.
- command_prefix: '!dis'
+ 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 WhatsApp bridge bot."
+ 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.
@@ -135,6 +136,28 @@ bridge:
# Verification by the bridge is not yet implemented.
require_verification: true
+ # 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
+
+ # 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:
directory: ./logs
file_name_format: '{{.Date}}-{{.Index}}.log'
diff --git a/globals/globals.go b/globals/globals.go
deleted file mode 100644
index 5f81767..0000000
--- a/globals/globals.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package globals
-
-type Globals struct {
- Config string `kong:"flag,name='config',short='c',env='CONFIG',help='The configuration file to use',default='config.yaml'"`
-}
diff --git a/go.mod b/go.mod
index 912fe88..e561755 100644
--- a/go.mod
+++ b/go.mod
@@ -3,30 +3,28 @@ module go.mau.fi/mautrix-discord
go 1.17
require (
- github.com/alecthomas/kong v0.5.0
github.com/bwmarrin/discordgo v0.23.2
- 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.5
- github.com/lopezator/migrator v0.3.0
- github.com/mattn/go-sqlite3 v1.14.12
+ github.com/lib/pq v1.10.6
+ github.com/mattn/go-sqlite3 v1.14.13
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
- gopkg.in/yaml.v2 v2.4.0
maunium.net/go/maulogger/v2 v2.3.2
- maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417
+ maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a
)
require (
- github.com/pkg/errors v0.9.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.4 // indirect
github.com/yuin/goldmark v1.4.12 // indirect
- golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
- golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect
+ golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect
+ golang.org/x/net v0.0.0-20220513224357-95641704303c // indirect
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
+ gopkg.in/yaml.v3 v3.0.0 // indirect
+ maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo v0.23.2 => gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7
diff --git a/go.sum b/go.sum
index d2713ad..2ebb5a3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,42 +1,23 @@
-github.com/alecthomas/kong v0.5.0 h1:u8Kdw+eeml93qtMZ04iei0CFYve/WPcA5IFh+9wSskE=
-github.com/alecthomas/kong v0.5.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0=
-github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
-github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
-github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-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.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
-github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
-github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lopezator/migrator v0.3.0 h1:VW/rR+J8NYwPdkBxjrFdjwejpgvP59LbmANJxXuNbuk=
-github.com/lopezator/migrator v0.3.0/go.mod h1:bpVAVPkWSvTw8ya2Pk7E/KiNAyDWNImgivQY79o8/8I=
-github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
-github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
+github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
+github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
-github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -45,47 +26,35 @@ 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.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
-github.com/yuin/goldmark v1.4.11 h1:i45YIzqLnUc2tGaTlJCyUxSG8TvgyGqhqOZOUKIjJ6w=
-github.com/yuin/goldmark v1.4.11/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7 h1:8ieR27GadHnShqhsvPrDzL1/ZOntavGGt4TXqafncYE=
gitlab.com/beeper/discordgo v0.23.3-0.20220219094025-13ff4cc63da7/go.mod h1:Hwfv4M8yP/MDh47BN+4Z1WItJ1umLKUyplCH5KcQPgE=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 h1:iU7T1X1J6yxDr0rda54sWGkHgOp5XJrqm79gcNlC2VM=
-golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc=
-golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c=
+golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
-golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220513224357-95641704303c h1:nF9mHSvoKBLkQNQhJZNsc66z2UzAMUbLGjC95CF3pU0=
+golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
-golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
+gopkg.in/yaml.v3 v3.0.0/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.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
-maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417 h1:dEJ9MKQvd4v2Rk2W6EUiO1T6PrSWPsB/JQOHQn4H6X0=
-maunium.net/go/mautrix v0.10.13-0.20220417095934-0eee489b6417/go.mod h1:zOor2zO/F10T/GbU67vWr0vnhLso88rlRr1HIrb1XWU=
+maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a h1:hkr4xK3sXJv+WFAVAmpzBPbT2Q3bUn9S13QFIqzJgAw=
+maunium.net/go/mautrix v0.11.1-0.20220522190042-ec20c3fc994a/go.mod h1:CiKpMhAx5QZFHK03jpWb0iKI3sGU8x6+LfsOjDrcO8I=
diff --git a/main.go b/main.go
index ba9037a..f14cb7e 100644
--- a/main.go
+++ b/main.go
@@ -1,43 +1,168 @@
+// 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 .
+
package main
import (
- "fmt"
- "os"
+ _ "embed"
+ "sync"
- "github.com/alecthomas/kong"
+ "go.mau.fi/mautrix-discord/database"
+ "maunium.net/go/mautrix/bridge"
+ "maunium.net/go/mautrix/bridge/commands"
+ "maunium.net/go/mautrix/id"
+ "maunium.net/go/mautrix/util/configupgrade"
"go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/consts"
- "go.mau.fi/mautrix-discord/globals"
- "go.mau.fi/mautrix-discord/registration"
- "go.mau.fi/mautrix-discord/run"
- "go.mau.fi/mautrix-discord/version"
)
-var cli struct {
- globals.Globals
+// 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"
+)
- GenerateConfig config.Cmd `kong:"cmd,help='Generate the default configuration and exit.'"`
- GenerateRegistration registration.Cmd `kong:"cmd,help='Generate the registration file for synapse and exit.'"`
- Run run.Cmd `kong:"cmd,help='Run the bridge.',default='1'"`
- Version version.Cmd `kong:"cmd,help='Display the version and exit.'"`
+//go:embed example-config.yaml
+var ExampleConfig string
+
+type DiscordBridge struct {
+ bridge.Bridge
+
+ Config *config.Config
+ DB *database.Database
+
+ 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
+
+ puppets map[string]*Puppet
+ puppetsByCustomMXID map[id.UserID]*Puppet
+ puppetsLock sync.Mutex
+}
+
+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()
+
+ br.DB = database.New(br.Bridge.DB)
+}
+
+func (br *DiscordBridge) Start() {
+ if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
+ br.provisioning = newProvisioningAPI(br)
+ }
+ 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() {
- ctx := kong.Parse(
- &cli,
- kong.Name(consts.Name),
- kong.Description(consts.Description),
- kong.UsageOnError(),
- kong.ConfigureHelp(kong.HelpOptions{
- Compact: true,
- Summary: true,
- }),
- )
+ br := &DiscordBridge{
+ usersByMXID: make(map[id.UserID]*User),
+ usersByID: make(map[string]*User),
- err := ctx.Run(&cli.Globals)
- if err != nil {
- fmt.Fprintf(os.Stderr, "error: %s\n", err)
- os.Exit(1)
+ managementRooms: make(map[id.RoomID]*User),
+
+ portalsByMXID: make(map[id.RoomID]*Portal),
+ portalsByID: make(map[database.PortalKey]*Portal),
+
+ puppets: make(map[string]*Puppet),
+ puppetsByCustomMXID: make(map[id.UserID]*Puppet),
}
+ br.Bridge = bridge.Bridge{
+ Name: "mautrix-discord",
+ URL: "https://github.com/mautrix/discord",
+ Description: "A Matrix-Discord puppeting bridge.",
+ Version: "0.1.0",
+ ProtocolName: "Discord",
+
+ ConfigUpgrader: &configupgrade.StructUpgrader{
+ SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
+ Blocks: config.SpacedBlocks,
+ Base: ExampleConfig,
+ },
+
+ Child: br,
+ }
+ br.InitVersion(Tag, Commit, BuildTime)
+
+ br.Main()
}
diff --git a/portal.go b/portal.go
new file mode 100644
index 0000000..b9589ff
--- /dev/null
+++ b/portal.go
@@ -0,0 +1,1178 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/bridge"
+ "maunium.net/go/mautrix/bridge/bridgeconfig"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/mautrix-discord/database"
+)
+
+type portalDiscordMessage struct {
+ msg interface{}
+ user *User
+}
+
+type portalMatrixMessage struct {
+ evt *event.Event
+ user *User
+}
+
+type Portal struct {
+ *database.Portal
+
+ bridge *DiscordBridge
+ log log.Logger
+
+ roomCreateLock sync.Mutex
+ encryptLock sync.Mutex
+
+ discordMessages chan portalDiscordMessage
+ matrixMessages chan portalMatrixMessage
+}
+
+func (portal *Portal) IsEncrypted() bool {
+ return portal.Encrypted
+}
+
+func (portal *Portal) MarkEncrypted() {
+ portal.Encrypted = true
+ portal.Update()
+}
+
+func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) {
+ if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser /*|| portal.HasRelaybot()*/ {
+ portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt}
+ }
+}
+
+var _ bridge.Portal = (*Portal)(nil)
+
+var (
+ portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
+)
+
+func (br *DiscordBridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal {
+ // If we weren't given a portal we'll attempt to create it if a key was
+ // provided.
+ if dbPortal == nil {
+ if key == nil {
+ return nil
+ }
+
+ dbPortal = br.DB.Portal.New()
+ dbPortal.Key = *key
+ dbPortal.Insert()
+ }
+
+ portal := br.NewPortal(dbPortal)
+
+ // No need to lock, it is assumed that our callers have already acquired
+ // the lock.
+ br.portalsByID[portal.Key] = portal
+ if portal.MXID != "" {
+ br.portalsByMXID[portal.MXID] = portal
+ }
+
+ return portal
+}
+
+func (br *DiscordBridge) GetPortalByMXID(mxid id.RoomID) *Portal {
+ br.portalsLock.Lock()
+ defer br.portalsLock.Unlock()
+
+ portal, ok := br.portalsByMXID[mxid]
+ if !ok {
+ return br.loadPortal(br.DB.Portal.GetByMXID(mxid), nil)
+ }
+
+ return portal
+}
+
+func (br *DiscordBridge) GetPortalByID(key database.PortalKey) *Portal {
+ br.portalsLock.Lock()
+ defer br.portalsLock.Unlock()
+
+ portal, ok := br.portalsByID[key]
+ if !ok {
+ return br.loadPortal(br.DB.Portal.GetByID(key), &key)
+ }
+
+ return portal
+}
+
+func (br *DiscordBridge) GetAllPortals() []*Portal {
+ return br.dbPortalsToPortals(br.DB.Portal.GetAll())
+}
+
+func (br *DiscordBridge) GetAllPortalsByID(id string) []*Portal {
+ return br.dbPortalsToPortals(br.DB.Portal.GetAllByID(id))
+}
+
+func (br *DiscordBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal {
+ br.portalsLock.Lock()
+ defer br.portalsLock.Unlock()
+
+ output := make([]*Portal, len(dbPortals))
+ for index, dbPortal := range dbPortals {
+ if dbPortal == nil {
+ continue
+ }
+
+ portal, ok := br.portalsByID[dbPortal.Key]
+ if !ok {
+ portal = br.loadPortal(dbPortal, nil)
+ }
+
+ output[index] = portal
+ }
+
+ return output
+}
+
+func (br *DiscordBridge) NewPortal(dbPortal *database.Portal) *Portal {
+ portal := &Portal{
+ Portal: dbPortal,
+ bridge: br,
+ log: br.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)),
+
+ discordMessages: make(chan portalDiscordMessage, br.Config.Bridge.PortalMessageBuffer),
+ matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer),
+ }
+
+ go portal.messageLoop()
+
+ return portal
+}
+
+func (portal *Portal) messageLoop() {
+ for {
+ select {
+ case msg := <-portal.matrixMessages:
+ portal.handleMatrixMessages(msg)
+ case msg := <-portal.discordMessages:
+ portal.handleDiscordMessages(msg)
+ }
+ }
+}
+
+func (portal *Portal) IsPrivateChat() bool {
+ return portal.Type == discordgo.ChannelTypeDM
+}
+
+func (portal *Portal) MainIntent() *appservice.IntentAPI {
+ if portal.IsPrivateChat() && portal.DMUser != "" {
+ return portal.bridge.GetPuppetByID(portal.DMUser).DefaultIntent()
+ }
+
+ return portal.bridge.Bot
+}
+
+func (portal *Portal) createMatrixRoom(user *User, channel *discordgo.Channel) error {
+ portal.roomCreateLock.Lock()
+ defer portal.roomCreateLock.Unlock()
+
+ // If we have a matrix id the room should exist so we have nothing to do.
+ if portal.MXID != "" {
+ return nil
+ }
+
+ portal.Type = channel.Type
+ if portal.Type == discordgo.ChannelTypeDM {
+ portal.DMUser = channel.Recipients[0].ID
+ }
+
+ intent := portal.MainIntent()
+ if err := intent.EnsureRegistered(); err != nil {
+ return err
+ }
+
+ name, err := portal.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
+ if err != nil {
+ portal.log.Warnfln("failed to format name, proceeding with generic name: %v", err)
+ portal.Name = channel.Name
+ } else {
+ portal.Name = name
+ }
+
+ portal.Topic = channel.Topic
+
+ // TODO: get avatars figured out
+ // portal.Avatar = puppet.Avatar
+ // portal.AvatarURL = puppet.AvatarURL
+
+ portal.log.Infoln("Creating Matrix room for channel:", portal.Portal.Key.ChannelID)
+
+ initialState := []*event.Event{}
+
+ creationContent := make(map[string]interface{})
+ creationContent["m.federate"] = false
+
+ var invite []id.UserID
+
+ if portal.bridge.Config.Bridge.Encryption.Default {
+ initialState = append(initialState, &event.Event{
+ Type: event.StateEncryption,
+ Content: event.Content{
+ Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1},
+ },
+ })
+ portal.Encrypted = true
+
+ if portal.IsPrivateChat() {
+ invite = append(invite, portal.bridge.Bot.UserID)
+ }
+ }
+
+ resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
+ Visibility: "private",
+ Name: portal.Name,
+ Topic: portal.Topic,
+ Invite: invite,
+ Preset: "private_chat",
+ IsDirect: portal.IsPrivateChat(),
+ InitialState: initialState,
+ CreationContent: creationContent,
+ })
+ if err != nil {
+ portal.log.Warnln("Failed to create room:", err)
+ return err
+ }
+
+ portal.MXID = resp.RoomID
+ portal.Update()
+ portal.bridge.portalsLock.Lock()
+ portal.bridge.portalsByMXID[portal.MXID] = portal
+ portal.bridge.portalsLock.Unlock()
+
+ portal.ensureUserInvited(user)
+ user.syncChatDoublePuppetDetails(portal, true)
+
+ portal.syncParticipants(user, channel.Recipients)
+
+ if portal.IsPrivateChat() {
+ puppet := user.bridge.GetPuppetByID(portal.Key.Receiver)
+
+ chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}
+ user.updateDirectChats(chats)
+ }
+
+ firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, portalCreationDummyEvent, struct{}{})
+ if err != nil {
+ portal.log.Errorln("Failed to send dummy event to mark portal creation:", err)
+ } else {
+ portal.FirstEventID = firstEventResp.EventID
+ portal.Update()
+ }
+
+ return nil
+}
+
+func (portal *Portal) handleDiscordMessages(msg portalDiscordMessage) {
+ if portal.MXID == "" {
+ discordMsg, ok := msg.msg.(*discordgo.MessageCreate)
+ if !ok {
+ portal.log.Warnln("Can't create Matrix room from non new message event")
+ return
+ }
+
+ portal.log.Debugln("Creating Matrix room from incoming message")
+
+ channel, err := msg.user.Session.Channel(discordMsg.ChannelID)
+ if err != nil {
+ portal.log.Errorln("Failed to find channel for message:", err)
+
+ return
+ }
+
+ if err := portal.createMatrixRoom(msg.user, channel); err != nil {
+ portal.log.Errorln("Failed to create portal room:", err)
+
+ return
+ }
+ }
+
+ switch msg.msg.(type) {
+ case *discordgo.MessageCreate:
+ portal.handleDiscordMessageCreate(msg.user, msg.msg.(*discordgo.MessageCreate).Message)
+ case *discordgo.MessageUpdate:
+ portal.handleDiscordMessagesUpdate(msg.user, msg.msg.(*discordgo.MessageUpdate).Message)
+ case *discordgo.MessageDelete:
+ portal.handleDiscordMessageDelete(msg.user, msg.msg.(*discordgo.MessageDelete).Message)
+ case *discordgo.MessageReactionAdd:
+ portal.handleDiscordReaction(msg.user, msg.msg.(*discordgo.MessageReactionAdd).MessageReaction, true)
+ case *discordgo.MessageReactionRemove:
+ portal.handleDiscordReaction(msg.user, msg.msg.(*discordgo.MessageReactionRemove).MessageReaction, false)
+ default:
+ portal.log.Warnln("unknown message type")
+ }
+}
+
+func (portal *Portal) ensureUserInvited(user *User) bool {
+ return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat())
+}
+
+func (portal *Portal) markMessageHandled(msg *database.Message, discordID string, mxid id.EventID, authorID string, timestamp time.Time) *database.Message {
+ if msg == nil {
+ msg := portal.bridge.DB.Message.New()
+ msg.Channel = portal.Key
+ msg.DiscordID = discordID
+ msg.MatrixID = mxid
+ msg.AuthorID = authorID
+ msg.Timestamp = timestamp
+ msg.Insert()
+ } else {
+ msg.UpdateMatrixID(mxid)
+ }
+
+ return msg
+}
+
+func (portal *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr error) {
+ content := &event.MessageEventContent{
+ Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
+ MsgType: event.MsgNotice,
+ }
+
+ _, err := portal.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
+ if err != nil {
+ portal.log.Warnfln("failed to send error message to matrix: %v", err)
+ }
+}
+
+func (portal *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, msgID string, attachment *discordgo.MessageAttachment) {
+ // var captionContent *event.MessageEventContent
+
+ // if attachment.Description != "" {
+ // captionContent = &event.MessageEventContent{
+ // Body: attachment.Description,
+ // MsgType: event.MsgNotice,
+ // }
+ // }
+ // portal.Log.Debugfln("captionContent: %#v", captionContent)
+
+ content := &event.MessageEventContent{
+ Body: attachment.Filename,
+ Info: &event.FileInfo{
+ Height: attachment.Height,
+ MimeType: attachment.ContentType,
+ Width: attachment.Width,
+
+ // This gets overwritten later after the file is uploaded to the homeserver
+ Size: attachment.Size,
+ },
+ }
+
+ switch strings.ToLower(strings.Split(attachment.ContentType, "/")[0]) {
+ case "audio":
+ content.MsgType = event.MsgAudio
+ case "image":
+ content.MsgType = event.MsgImage
+ case "video":
+ content.MsgType = event.MsgVideo
+ default:
+ content.MsgType = event.MsgFile
+ }
+
+ data, err := portal.downloadDiscordAttachment(attachment.URL)
+ if err != nil {
+ portal.sendMediaFailedMessage(intent, err)
+
+ return
+ }
+
+ err = portal.uploadMatrixAttachment(intent, data, content)
+ if err != nil {
+ portal.sendMediaFailedMessage(intent, err)
+
+ return
+ }
+
+ resp, err := portal.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
+ if err != nil {
+ portal.log.Warnfln("failed to send media message to matrix: %v", err)
+ }
+
+ dbAttachment := portal.bridge.DB.Attachment.New()
+ dbAttachment.Channel = portal.Key
+ dbAttachment.DiscordMessageID = msgID
+ dbAttachment.DiscordAttachmentID = attachment.ID
+ dbAttachment.MatrixEventID = resp.EventID
+ dbAttachment.Insert()
+}
+
+func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message) {
+ if portal.MXID == "" {
+ portal.log.Warnln("handle message called without a valid portal")
+
+ return
+ }
+
+ // Handle room name changes
+ if msg.Type == discordgo.MessageTypeChannelNameChange {
+ channel, err := user.Session.Channel(msg.ChannelID)
+ if err != nil {
+ portal.log.Errorf("Failed to find the channel for portal %s", portal.Key)
+ return
+ }
+
+ name, err := portal.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
+ if err != nil {
+ portal.log.Errorf("Failed to format name for portal %s", portal.Key)
+ return
+ }
+
+ portal.Name = name
+ portal.Update()
+
+ portal.MainIntent().SetRoomName(portal.MXID, name)
+
+ return
+ }
+
+ // Handle normal message
+ existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID)
+ if existing != nil {
+ portal.log.Debugln("not handling duplicate message", msg.ID)
+
+ return
+ }
+
+ puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
+ puppet.SyncContact(user)
+ intent := puppet.IntentFor(portal)
+
+ if msg.Content != "" {
+ content := &event.MessageEventContent{
+ Body: msg.Content,
+ MsgType: event.MsgText,
+ }
+
+ if msg.MessageReference != nil {
+ key := database.PortalKey{msg.MessageReference.ChannelID, user.ID}
+ existing := portal.bridge.DB.Message.GetByDiscordID(key, msg.MessageReference.MessageID)
+
+ if existing != nil && existing.MatrixID != "" {
+ content.RelatesTo = &event.RelatesTo{
+ Type: event.RelReply,
+ EventID: existing.MatrixID,
+ }
+ }
+ }
+
+ resp, err := portal.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
+ if err != nil {
+ portal.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
+
+ return
+ }
+
+ ts, _ := msg.Timestamp.Parse()
+ portal.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
+ }
+
+ // now run through any attachments the message has
+ for _, attachment := range msg.Attachments {
+ portal.handleDiscordAttachment(intent, msg.ID, attachment)
+ }
+}
+
+func (portal *Portal) handleDiscordMessagesUpdate(user *User, msg *discordgo.Message) {
+ if portal.MXID == "" {
+ portal.log.Warnln("handle message called without a valid portal")
+
+ return
+ }
+
+ // There's a few scenarios where the author is nil but I haven't figured
+ // them all out yet.
+ if msg.Author == nil {
+ // If the server has to lookup opengraph previews it'll send the
+ // message through without the preview and then add the preview later
+ // via a message update. However, when it does this there is no author
+ // as it's just the server, so for the moment we'll ignore this to
+ // avoid a crash.
+ if len(msg.Embeds) > 0 {
+ portal.log.Debugln("ignoring update for opengraph attachment")
+
+ return
+ }
+
+ portal.log.Errorfln("author is nil: %#v", msg)
+ }
+
+ intent := portal.bridge.GetPuppetByID(msg.Author.ID).IntentFor(portal)
+
+ existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID)
+ if existing == nil {
+ // Due to the differences in Discord and Matrix attachment handling,
+ // existing will return nil if the original message was empty as we
+ // don't store/save those messages so we can determine when we're
+ // working against an attachment and do the attachment lookup instead.
+
+ // Find all the existing attachments and drop them in a map so we can
+ // figure out which, if any have been deleted and clean them up on the
+ // matrix side.
+ attachmentMap := map[string]*database.Attachment{}
+ attachments := portal.bridge.DB.Attachment.GetAllByDiscordMessageID(portal.Key, msg.ID)
+
+ for _, attachment := range attachments {
+ attachmentMap[attachment.DiscordAttachmentID] = attachment
+ }
+
+ // Now run through the list of attachments on this message and remove
+ // them from the map.
+ for _, attachment := range msg.Attachments {
+ if _, found := attachmentMap[attachment.ID]; found {
+ delete(attachmentMap, attachment.ID)
+ }
+ }
+
+ // Finally run through any attachments still in the map and delete them
+ // on the matrix side and our database.
+ for _, attachment := range attachmentMap {
+ _, err := intent.RedactEvent(portal.MXID, attachment.MatrixEventID)
+ if err != nil {
+ portal.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
+ }
+
+ attachment.Delete()
+ }
+
+ return
+ }
+
+ content := &event.MessageEventContent{
+ Body: msg.Content,
+ MsgType: event.MsgText,
+ }
+
+ content.SetEdit(existing.MatrixID)
+
+ resp, err := portal.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli())
+ if err != nil {
+ portal.log.Warnfln("failed to send message %q to matrix: %v", msg.ID, err)
+
+ return
+ }
+
+ ts, _ := msg.Timestamp.Parse()
+ portal.markMessageHandled(existing, msg.ID, resp.EventID, msg.Author.ID, ts)
+}
+
+func (portal *Portal) handleDiscordMessageDelete(user *User, msg *discordgo.Message) {
+ // The discord delete message object is pretty empty and doesn't include
+ // the author so we have to use the DMUser from the portal that was added
+ // at creation time if we're a DM. We'll might have similar issues when we
+ // add guild message support, but we'll cross that bridge when we get
+ // there.
+
+ // Find the message that we're working with. This could correctly return
+ // nil if the message was just one or more attachments.
+ existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID)
+
+ var intent *appservice.IntentAPI
+
+ if portal.Type == discordgo.ChannelTypeDM {
+ intent = portal.bridge.GetPuppetByID(portal.DMUser).IntentFor(portal)
+ } else {
+ intent = portal.MainIntent()
+ }
+
+ if existing != nil {
+ _, err := intent.RedactEvent(portal.MXID, existing.MatrixID)
+ if err != nil {
+ portal.log.Warnfln("Failed to remove message %s: %v", existing.MatrixID, err)
+ }
+
+ existing.Delete()
+ }
+
+ // Now delete all of the existing attachments.
+ attachments := portal.bridge.DB.Attachment.GetAllByDiscordMessageID(portal.Key, msg.ID)
+ for _, attachment := range attachments {
+ _, err := intent.RedactEvent(portal.MXID, attachment.MatrixEventID)
+ if err != nil {
+ portal.log.Warnfln("Failed to remove attachment %s: %v", attachment.MatrixEventID, err)
+ }
+
+ attachment.Delete()
+ }
+}
+
+func (portal *Portal) syncParticipants(source *User, participants []*discordgo.User) {
+ for _, participant := range participants {
+ puppet := portal.bridge.GetPuppetByID(participant.ID)
+ puppet.SyncContact(source)
+
+ user := portal.bridge.GetUserByID(participant.ID)
+ if user != nil {
+ portal.ensureUserInvited(user)
+ }
+
+ if user == nil || !puppet.IntentFor(portal).IsCustomPuppet {
+ if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil {
+ portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.ID, portal.MXID, err)
+ }
+ }
+ }
+}
+
+func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) {
+ if portal.Encrypted && portal.bridge.Crypto != nil {
+ // TODO maybe the locking should be inside mautrix-go?
+ portal.encryptLock.Lock()
+ encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, *content)
+ portal.encryptLock.Unlock()
+ if err != nil {
+ return eventType, fmt.Errorf("failed to encrypt event: %w", err)
+ }
+ eventType = event.EventEncrypted
+ content.Parsed = encrypted
+ }
+ return eventType, nil
+}
+
+const doublePuppetKey = "fi.mau.double_puppet_source"
+const doublePuppetValue = "mautrix-discord"
+
+func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
+ wrappedContent := event.Content{Parsed: content, Raw: extraContent}
+ if timestamp != 0 && intent.IsCustomPuppet {
+ if wrappedContent.Raw == nil {
+ wrappedContent.Raw = map[string]interface{}{}
+ }
+ if intent.IsCustomPuppet {
+ wrappedContent.Raw[doublePuppetKey] = doublePuppetValue
+ }
+ }
+ var err error
+ eventType, err = portal.encrypt(&wrappedContent, eventType)
+ if err != nil {
+ return nil, err
+ }
+
+ if eventType == event.EventEncrypted {
+ // Clear other custom keys if the event was encrypted, but keep the double puppet identifier
+ if intent.IsCustomPuppet {
+ wrappedContent.Raw = map[string]interface{}{doublePuppetKey: doublePuppetValue}
+ } else {
+ wrappedContent.Raw = nil
+ }
+ }
+
+ _, _ = intent.UserTyping(portal.MXID, false, 0)
+ if timestamp == 0 {
+ return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
+ } else {
+ return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
+ }
+}
+
+func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) {
+ switch msg.evt.Type {
+ case event.EventMessage:
+ portal.handleMatrixMessage(msg.user, msg.evt)
+ case event.EventRedaction:
+ portal.handleMatrixRedaction(msg.user, msg.evt)
+ case event.EventReaction:
+ portal.handleMatrixReaction(msg.user, msg.evt)
+ default:
+ portal.log.Debugln("unknown event type", msg.evt.Type)
+ }
+}
+
+func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) {
+ if portal.IsPrivateChat() && sender.ID != portal.Key.Receiver {
+ return
+ }
+
+ existing := portal.bridge.DB.Message.GetByMatrixID(portal.Key, evt.ID)
+ if existing != nil {
+ portal.log.Debugln("not handling duplicate message", evt.ID)
+
+ return
+ }
+
+ content, ok := evt.Content.Parsed.(*event.MessageEventContent)
+ if !ok {
+ portal.log.Debugfln("Failed to handle event %s: unexpected parsed content type %T", evt.ID, evt.Content.Parsed)
+
+ return
+ }
+
+ if content.RelatesTo != nil && content.RelatesTo.Type == event.RelReplace {
+ existing := portal.bridge.DB.Message.GetByMatrixID(portal.Key, content.RelatesTo.EventID)
+
+ if existing != nil && existing.DiscordID != "" {
+ // we don't have anything to save for the update message right now
+ // as we're not tracking edited timestamps.
+ _, err := sender.Session.ChannelMessageEdit(portal.Key.ChannelID,
+ existing.DiscordID, content.NewContent.Body)
+ if err != nil {
+ portal.log.Errorln("Failed to update message %s: %v", existing.DiscordID, err)
+
+ return
+ }
+ }
+
+ return
+ }
+
+ var msg *discordgo.Message
+ var err error
+
+ switch content.MsgType {
+ case event.MsgText, event.MsgEmote, event.MsgNotice:
+ sent := false
+
+ if content.RelatesTo != nil && content.RelatesTo.Type == event.RelReply {
+ existing := portal.bridge.DB.Message.GetByMatrixID(
+ portal.Key,
+ content.RelatesTo.EventID,
+ )
+
+ if existing != nil && existing.DiscordID != "" {
+ msg, err = sender.Session.ChannelMessageSendReply(
+ portal.Key.ChannelID,
+ content.Body,
+ &discordgo.MessageReference{
+ ChannelID: portal.Key.ChannelID,
+ MessageID: existing.DiscordID,
+ },
+ )
+ if err == nil {
+ sent = true
+ }
+ }
+ }
+ if !sent {
+ msg, err = sender.Session.ChannelMessageSend(portal.Key.ChannelID, content.Body)
+ }
+ case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo:
+ data, err := portal.downloadMatrixAttachment(evt.ID, content)
+ if err != nil {
+ portal.log.Errorfln("Failed to download matrix attachment: %v", err)
+
+ return
+ }
+
+ msgSend := &discordgo.MessageSend{
+ Files: []*discordgo.File{
+ &discordgo.File{
+ Name: content.Body,
+ ContentType: content.Info.MimeType,
+ Reader: bytes.NewReader(data),
+ },
+ },
+ }
+
+ msg, err = sender.Session.ChannelMessageSendComplex(portal.Key.ChannelID, msgSend)
+ default:
+ portal.log.Warnln("unknown message type:", content.MsgType)
+ return
+ }
+
+ if err != nil {
+ portal.log.Errorfln("Failed to send message: %v", err)
+
+ return
+ }
+
+ if msg != nil {
+ dbMsg := portal.bridge.DB.Message.New()
+ dbMsg.Channel = portal.Key
+ dbMsg.DiscordID = msg.ID
+ dbMsg.MatrixID = evt.ID
+ dbMsg.AuthorID = sender.ID
+ dbMsg.Timestamp = time.Now()
+ dbMsg.Insert()
+ }
+}
+
+func (portal *Portal) HandleMatrixLeave(brSender bridge.User) {
+ portal.log.Debugln("User left private chat portal, cleaning up and deleting...")
+ portal.delete()
+ portal.cleanup(false)
+
+ // TODO: figure out how to close a dm from the API.
+
+ portal.cleanupIfEmpty()
+}
+
+func (portal *Portal) leave(sender *User) {
+ if portal.MXID == "" {
+ return
+ }
+
+ intent := portal.bridge.GetPuppetByID(sender.ID).IntentFor(portal)
+ intent.LeaveRoom(portal.MXID)
+}
+
+func (portal *Portal) delete() {
+ portal.Portal.Delete()
+ portal.bridge.portalsLock.Lock()
+ delete(portal.bridge.portalsByID, portal.Key)
+
+ if portal.MXID != "" {
+ delete(portal.bridge.portalsByMXID, portal.MXID)
+ }
+
+ portal.bridge.portalsLock.Unlock()
+}
+
+func (portal *Portal) cleanupIfEmpty() {
+ users, err := portal.getMatrixUsers()
+ if err != nil {
+ portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err)
+
+ return
+ }
+
+ if len(users) == 0 {
+ portal.log.Infoln("Room seems to be empty, cleaning up...")
+ portal.delete()
+ portal.cleanup(false)
+ }
+}
+
+func (portal *Portal) cleanup(puppetsOnly bool) {
+ if portal.MXID != "" {
+ return
+ }
+
+ if portal.IsPrivateChat() {
+ _, err := portal.MainIntent().LeaveRoom(portal.MXID)
+ if err != nil {
+ portal.log.Warnln("Failed to leave private chat portal with main intent:", err)
+ }
+
+ return
+ }
+
+ intent := portal.MainIntent()
+ members, err := intent.JoinedMembers(portal.MXID)
+ if err != nil {
+ portal.log.Errorln("Failed to get portal members for cleanup:", err)
+
+ return
+ }
+
+ for member := range members.Joined {
+ if member == intent.UserID {
+ continue
+ }
+
+ puppet := portal.bridge.GetPuppetByMXID(member)
+ if portal != nil {
+ _, err = puppet.DefaultIntent().LeaveRoom(portal.MXID)
+ if err != nil {
+ portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err)
+ }
+ } else if !puppetsOnly {
+ _, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
+ if err != nil {
+ portal.log.Errorln("Error kicking user while cleaning up portal:", err)
+ }
+ }
+ }
+
+ _, err = intent.LeaveRoom(portal.MXID)
+ if err != nil {
+ portal.log.Errorln("Error leaving with main intent while cleaning up portal:", err)
+ }
+}
+
+func (portal *Portal) getMatrixUsers() ([]id.UserID, error) {
+ members, err := portal.MainIntent().JoinedMembers(portal.MXID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get member list: %w", err)
+ }
+
+ var users []id.UserID
+ for userID := range members.Joined {
+ _, isPuppet := portal.bridge.ParsePuppetMXID(userID)
+ if !isPuppet && userID != portal.bridge.Bot.UserID {
+ users = append(users, userID)
+ }
+ }
+
+ return users, nil
+}
+
+func (portal *Portal) handleMatrixReaction(user *User, evt *event.Event) {
+ if user.ID != portal.Key.Receiver {
+ return
+ }
+
+ reaction := evt.Content.AsReaction()
+ if reaction.RelatesTo.Type != event.RelAnnotation {
+ portal.log.Errorfln("Ignoring reaction %s due to unknown m.relates_to data", evt.ID)
+
+ return
+ }
+
+ var discordID string
+
+ msg := portal.bridge.DB.Message.GetByMatrixID(portal.Key, reaction.RelatesTo.EventID)
+
+ // Due to the differences in attachments between Discord and Matrix, if a
+ // user reacts to a media message on discord our lookup above will fail
+ // because the relation of matrix media messages to attachments in handled
+ // in the attachments table instead of messages so we need to check that
+ // before continuing.
+ //
+ // This also leads to interesting problems when a Discord message comes in
+ // with multiple attachments. A user can react to each one individually on
+ // Matrix, which will cause us to send it twice. Discord tends to ignore
+ // this, but if the user removes one of them, discord removes it and now
+ // they're out of sync. Perhaps we should add a counter to the reactions
+ // table to keep them in sync and to avoid sending duplicates to Discord.
+ if msg == nil {
+ attachment := portal.bridge.DB.Attachment.GetByMatrixID(portal.Key, reaction.RelatesTo.EventID)
+ discordID = attachment.DiscordMessageID
+ } else {
+ if msg.DiscordID == "" {
+ portal.log.Debugf("Message %s has not yet been sent to discord", reaction.RelatesTo.EventID)
+
+ return
+ }
+
+ discordID = msg.DiscordID
+ }
+
+ // Figure out if this is a custom emoji or not.
+ emojiID := reaction.RelatesTo.Key
+ if strings.HasPrefix(emojiID, "mxc://") {
+ uri, _ := id.ParseContentURI(emojiID)
+ emoji := portal.bridge.DB.Emoji.GetByMatrixURL(uri)
+ if emoji == nil {
+ portal.log.Errorfln("failed to find emoji for %s", emojiID)
+
+ return
+ }
+
+ emojiID = emoji.APIName()
+ }
+
+ err := user.Session.MessageReactionAdd(portal.Key.ChannelID, discordID, emojiID)
+ if err != nil {
+ portal.log.Debugf("Failed to send reaction %s id:%s: %v", portal.Key, discordID, err)
+
+ return
+ }
+
+ dbReaction := portal.bridge.DB.Reaction.New()
+ dbReaction.Channel.ChannelID = portal.Key.ChannelID
+ dbReaction.Channel.Receiver = portal.Key.Receiver
+ dbReaction.MatrixEventID = evt.ID
+ dbReaction.DiscordMessageID = discordID
+ dbReaction.AuthorID = user.ID
+ dbReaction.MatrixName = reaction.RelatesTo.Key
+ dbReaction.DiscordID = emojiID
+ dbReaction.Insert()
+}
+
+func (portal *Portal) handleDiscordReaction(user *User, reaction *discordgo.MessageReaction, add bool) {
+ intent := portal.bridge.GetPuppetByID(reaction.UserID).IntentFor(portal)
+
+ var discordID string
+ var matrixID string
+
+ if reaction.Emoji.ID != "" {
+ dbEmoji := portal.bridge.DB.Emoji.GetByDiscordID(reaction.Emoji.ID)
+
+ if dbEmoji == nil {
+ data, mimeType, err := portal.downloadDiscordEmoji(reaction.Emoji.ID, reaction.Emoji.Animated)
+ if err != nil {
+ portal.log.Warnfln("Failed to download emoji %s from discord: %v", reaction.Emoji.ID, err)
+
+ return
+ }
+
+ uri, err := portal.uploadMatrixEmoji(intent, data, mimeType)
+ if err != nil {
+ portal.log.Warnfln("Failed to upload discord emoji %s to homeserver: %v", reaction.Emoji.ID, err)
+
+ return
+ }
+
+ dbEmoji = portal.bridge.DB.Emoji.New()
+ dbEmoji.DiscordID = reaction.Emoji.ID
+ dbEmoji.DiscordName = reaction.Emoji.Name
+ dbEmoji.MatrixURL = uri
+ dbEmoji.Insert()
+ }
+
+ discordID = dbEmoji.DiscordID
+ matrixID = dbEmoji.MatrixURL.String()
+ } else {
+ discordID = reaction.Emoji.Name
+ matrixID = reaction.Emoji.Name
+ }
+
+ // Find the message that we're working with.
+ message := portal.bridge.DB.Message.GetByDiscordID(portal.Key, reaction.MessageID)
+ if message == nil {
+ portal.log.Debugfln("failed to add reaction to message %s: message not found", reaction.MessageID)
+
+ return
+ }
+
+ // Lookup an existing reaction
+ existing := portal.bridge.DB.Reaction.GetByDiscordID(portal.Key, message.DiscordID, discordID)
+
+ if !add {
+ if existing == nil {
+ portal.log.Debugln("Failed to remove reaction for unknown message", reaction.MessageID)
+
+ return
+ }
+
+ _, err := intent.RedactEvent(portal.MXID, existing.MatrixEventID)
+ if err != nil {
+ portal.log.Warnfln("Failed to remove reaction from %s: %v", portal.MXID, err)
+ }
+
+ existing.Delete()
+
+ return
+ }
+
+ content := event.Content{Parsed: &event.ReactionEventContent{
+ RelatesTo: event.RelatesTo{
+ EventID: message.MatrixID,
+ Type: event.RelAnnotation,
+ Key: matrixID,
+ },
+ }}
+
+ resp, err := intent.Client.SendMessageEvent(portal.MXID, event.EventReaction, &content)
+ if err != nil {
+ portal.log.Errorfln("failed to send reaction from %s: %v", reaction.MessageID, err)
+
+ return
+ }
+
+ if existing == nil {
+ dbReaction := portal.bridge.DB.Reaction.New()
+ dbReaction.Channel = portal.Key
+ dbReaction.DiscordMessageID = message.DiscordID
+ dbReaction.MatrixEventID = resp.EventID
+ dbReaction.AuthorID = reaction.UserID
+
+ dbReaction.MatrixName = matrixID
+ dbReaction.DiscordID = discordID
+
+ dbReaction.Insert()
+ }
+}
+
+func (portal *Portal) handleMatrixRedaction(user *User, evt *event.Event) {
+ if user.ID != portal.Key.Receiver {
+ return
+ }
+
+ // First look if we're redacting a message
+ message := portal.bridge.DB.Message.GetByMatrixID(portal.Key, evt.Redacts)
+ if message != nil {
+ if message.DiscordID != "" {
+ err := user.Session.ChannelMessageDelete(portal.Key.ChannelID, message.DiscordID)
+ if err != nil {
+ portal.log.Debugfln("Failed to delete discord message %s: %v", message.DiscordID, err)
+ } else {
+ message.Delete()
+ }
+ }
+
+ return
+ }
+
+ // Now check if it's a reaction.
+ reaction := portal.bridge.DB.Reaction.GetByMatrixID(portal.Key, evt.Redacts)
+ if reaction != nil {
+ if reaction.DiscordID != "" {
+ err := user.Session.MessageReactionRemove(portal.Key.ChannelID, reaction.DiscordMessageID, reaction.DiscordID, reaction.AuthorID)
+ if err != nil {
+ portal.log.Debugfln("Failed to delete reaction %s for message %s: %v", reaction.DiscordID, reaction.DiscordMessageID, err)
+ } else {
+ reaction.Delete()
+ }
+ }
+
+ return
+ }
+
+ portal.log.Warnfln("Failed to redact %s@%s: no event found", portal.Key, evt.Redacts)
+}
+
+func (portal *Portal) update(user *User, channel *discordgo.Channel) {
+ name, err := portal.bridge.Config.Bridge.FormatChannelname(channel, user.Session)
+ if err != nil {
+ portal.log.Warnln("Failed to format channel name, using existing:", err)
+ } else {
+ portal.Name = name
+ }
+
+ intent := portal.MainIntent()
+
+ if portal.Name != name {
+ _, err = intent.SetRoomName(portal.MXID, portal.Name)
+ if err != nil {
+ portal.log.Warnln("Failed to update room name:", err)
+ }
+ }
+
+ if portal.Topic != channel.Topic {
+ portal.Topic = channel.Topic
+ _, err = intent.SetRoomTopic(portal.MXID, portal.Topic)
+ if err != nil {
+ portal.log.Warnln("Failed to update room topic:", err)
+ }
+ }
+
+ if portal.Avatar != channel.Icon {
+ portal.Avatar = channel.Icon
+
+ var url string
+
+ if portal.Type == discordgo.ChannelTypeDM {
+ dmUser, err := user.Session.User(portal.DMUser)
+ if err != nil {
+ portal.log.Warnln("failed to lookup the dmuser", err)
+ } else {
+ url = dmUser.AvatarURL("")
+ }
+ } else {
+ url = discordgo.EndpointGroupIcon(channel.ID, channel.Icon)
+ }
+
+ portal.AvatarURL = id.ContentURI{}
+ if url != "" {
+ uri, err := uploadAvatar(intent, url)
+ if err != nil {
+ portal.log.Warnf("failed to upload avatar", err)
+ } else {
+ portal.AvatarURL = uri
+ }
+ }
+
+ intent.SetRoomAvatar(portal.MXID, portal.AvatarURL)
+ }
+
+ portal.Update()
+ portal.log.Debugln("portal updated")
+}
diff --git a/bridge/provisioning.go b/provisioning.go
similarity index 96%
rename from bridge/provisioning.go
rename to provisioning.go
index 43cfa0b..9c3fc4e 100644
--- a/bridge/provisioning.go
+++ b/provisioning.go
@@ -1,4 +1,4 @@
-package bridge
+package main
import (
"bufio"
@@ -25,21 +25,21 @@ const (
)
type ProvisioningAPI struct {
- bridge *Bridge
+ bridge *DiscordBridge
log log.Logger
}
-func newProvisioningAPI(bridge *Bridge) *ProvisioningAPI {
+func newProvisioningAPI(br *DiscordBridge) *ProvisioningAPI {
p := &ProvisioningAPI{
- bridge: bridge,
- log: bridge.log.Sub("Provisioning"),
+ bridge: br,
+ log: br.Log.Sub("Provisioning"),
}
- prefix := bridge.Config.Appservice.Provisioning.Prefix
+ prefix := br.Config.Bridge.Provisioning.Prefix
p.log.Debugln("Enabling provisioning API at", prefix)
- r := bridge.as.Router.PathPrefix(prefix).Subrouter()
+ r := br.AS.Router.PathPrefix(prefix).Subrouter()
r.Use(p.authMiddleware)
@@ -117,7 +117,7 @@ func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler {
auth = auth[len("Bearer "):]
}
- if auth != p.bridge.Config.Appservice.Provisioning.SharedSecret {
+ if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret {
jsonResponse(w, http.StatusForbidden, map[string]interface{}{
"error": "Invalid auth token",
"errcode": "M_FORBIDDEN",
@@ -176,7 +176,7 @@ func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
discord := map[string]interface{}{
- "logged_in": user.LoggedIn(),
+ "logged_in": user.IsLoggedIn(),
"connected": user.Connected(),
"conn": nil,
}
@@ -210,7 +210,7 @@ func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
force := strings.ToLower(r.URL.Query().Get("force")) != "false"
- if !user.LoggedIn() {
+ if !user.IsLoggedIn() {
jsonResponse(w, http.StatusNotFound, Error{
Error: "You're not logged in",
ErrCode: "not logged in",
@@ -285,7 +285,7 @@ func (p *ProvisioningAPI) login(w http.ResponseWriter, r *http.Request) {
return nil
})
- if user.LoggedIn() {
+ if user.IsLoggedIn() {
c.WriteJSON(Error{
Error: "You're already logged into Discord",
ErrCode: "already logged in",
diff --git a/puppet.go b/puppet.go
new file mode 100644
index 0000000..1b31d50
--- /dev/null
+++ b/puppet.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+ "fmt"
+ "regexp"
+ "sync"
+
+ log "maunium.net/go/maulogger/v2"
+
+ "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 log.Logger
+
+ MXID id.UserID
+
+ customIntent *appservice.IntentAPI
+ customUser *User
+
+ syncLock sync.Mutex
+}
+
+var _ bridge.Ghost = (*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.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)),
+
+ 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 {
+ id, ok := br.ParsePuppetMXID(mxid)
+ if !ok {
+ return nil
+ }
+
+ return br.GetPuppetByID(id)
+}
+
+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) DefaultIntent() *appservice.IntentAPI {
+ return puppet.bridge.AS.Intent(puppet.MXID)
+}
+
+func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
+ if puppet.customIntent == nil {
+ return puppet.DefaultIntent()
+ }
+
+ return puppet.customIntent
+}
+
+func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
+ return puppet.customIntent
+}
+
+func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
+ for _, portal := range puppet.bridge.GetAllPortalsByID(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(source *User) bool {
+ user, err := source.Session.User(puppet.ID)
+ if err != nil {
+ puppet.log.Warnln("failed to get user from id:", err)
+ return false
+ }
+
+ newName := puppet.bridge.Config.Bridge.FormatDisplayname(user)
+
+ if puppet.DisplayName != newName {
+ err := puppet.DefaultIntent().SetDisplayName(newName)
+ if err == nil {
+ puppet.DisplayName = newName
+ go puppet.updatePortalName()
+ puppet.Update()
+ } else {
+ puppet.log.Warnln("failed to set display name:", err)
+ }
+
+ return true
+ }
+
+ return false
+}
+
+func (puppet *Puppet) updatePortalName() {
+ puppet.updatePortalMeta(func(portal *Portal) {
+ if portal.MXID != "" {
+ _, err := portal.MainIntent().SetRoomName(portal.MXID, puppet.DisplayName)
+ if err != nil {
+ portal.log.Warnln("Failed to set name:", err)
+ }
+ }
+
+ portal.Name = puppet.DisplayName
+ portal.Update()
+ })
+}
+
+func (puppet *Puppet) updateAvatar(source *User) bool {
+ user, err := source.Session.User(puppet.ID)
+ if err != nil {
+ puppet.log.Warnln("Failed to get user:", err)
+
+ return false
+ }
+
+ if puppet.Avatar == user.Avatar {
+ return false
+ }
+
+ if user.Avatar == "" {
+ puppet.log.Warnln("User does not have an avatar")
+
+ return false
+ }
+
+ url, err := uploadAvatar(puppet.DefaultIntent(), user.AvatarURL(""))
+ if err != nil {
+ puppet.log.Warnln("Failed to upload user avatar:", err)
+
+ return false
+ }
+
+ puppet.AvatarURL = url
+
+ err = puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
+ if err != nil {
+ puppet.log.Warnln("Failed to set avatar:", err)
+ }
+
+ puppet.log.Debugln("Updated avatar", puppet.Avatar, "->", user.Avatar)
+ puppet.Avatar = user.Avatar
+ go puppet.updatePortalAvatar()
+
+ return true
+}
+
+func (puppet *Puppet) updatePortalAvatar() {
+ puppet.updatePortalMeta(func(portal *Portal) {
+ if portal.MXID != "" {
+ _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, puppet.AvatarURL)
+ if err != nil {
+ portal.log.Warnln("Failed to set avatar:", err)
+ }
+ }
+
+ portal.AvatarURL = puppet.AvatarURL
+ portal.Avatar = puppet.Avatar
+ portal.Update()
+ })
+
+}
+
+func (puppet *Puppet) SyncContact(source *User) {
+ puppet.syncLock.Lock()
+ defer puppet.syncLock.Unlock()
+
+ puppet.log.Debugln("syncing contact", puppet.DisplayName)
+
+ err := puppet.DefaultIntent().EnsureRegistered()
+ if err != nil {
+ puppet.log.Errorln("Failed to ensure registered:", err)
+ }
+
+ update := false
+
+ update = puppet.updateName(source) || update
+
+ if puppet.Avatar == "" {
+ update = puppet.updateAvatar(source) || update
+ puppet.log.Debugln("update avatar returned", update)
+ }
+
+ if update {
+ puppet.Update()
+ }
+}
diff --git a/registration/cmd.go b/registration/cmd.go
deleted file mode 100644
index e2990f2..0000000
--- a/registration/cmd.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package registration
-
-import (
- "fmt"
- "os"
- "regexp"
-
- "maunium.net/go/mautrix/appservice"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/globals"
-)
-
-type Cmd struct {
- Filename string `kong:"flag,help='The filename to store the registration into',name='REGISTRATION',short='r',default='registration.yaml'"`
- Force bool `kong:"flag,help='Overwrite an existing registration file if it already exists',short='f',default='0'"`
-}
-
-func (c *Cmd) Run(g *globals.Globals) error {
- // Check if the file exists before blinding overwriting it.
- if _, err := os.Stat(c.Filename); err == nil {
- if c.Force == false {
- return fmt.Errorf("file %q exists, use -f to overwrite", c.Filename)
- }
- }
-
- cfg, err := config.FromFile(g.Config)
- if err != nil {
- return err
- }
-
- registration := appservice.CreateRegistration()
-
- // Load existing values from the config into the registration.
- if err := cfg.CopyToRegistration(registration); err != nil {
- return err
- }
-
- // Save the new App and Server tokens in the config.
- cfg.Appservice.ASToken = registration.AppToken
- cfg.Appservice.HSToken = registration.ServerToken
-
- // Workaround for https://github.com/matrix-org/synapse/pull/5758
- registration.SenderLocalpart = appservice.RandomString(32)
-
- // Register the bot's user.
- pattern := fmt.Sprintf(
- "^@%s:%s$",
- cfg.Appservice.Bot.Username,
- cfg.Homeserver.Domain,
- )
- botRegex, err := regexp.Compile(pattern)
- if err != nil {
- return err
- }
- registration.Namespaces.RegisterUserIDs(botRegex, true)
-
- // Finally save the registration and the updated config file.
- if err := registration.Save(c.Filename); err != nil {
- return err
- }
-
- if err := cfg.Save(g.Config); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/run/cmd.go b/run/cmd.go
deleted file mode 100644
index d53a061..0000000
--- a/run/cmd.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package run
-
-import (
- "fmt"
- "os"
- "os/signal"
- "syscall"
-
- "go.mau.fi/mautrix-discord/bridge"
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/globals"
-)
-
-type Cmd struct{}
-
-func (c *Cmd) Run(g *globals.Globals) error {
- fmt.Printf("g.Config: %q\n", g.Config)
- cfg, err := config.FromFile(g.Config)
- if err != nil {
- return err
- }
-
- bridge, err := bridge.New(cfg)
- if err != nil {
- return err
- }
-
- if err := bridge.Start(); err != nil {
- return err
- }
-
- ch := make(chan os.Signal)
- signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
- <-ch
-
- bridge.Stop()
-
- return nil
-}
diff --git a/user.go b/user.go
new file mode 100644
index 0000000..3ca88f5
--- /dev/null
+++ b/user.go
@@ -0,0 +1,820 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/bwmarrin/discordgo"
+ log "maunium.net/go/maulogger/v2"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/appservice"
+ "maunium.net/go/mautrix/bridge"
+ "maunium.net/go/mautrix/bridge/bridgeconfig"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/mautrix-discord/database"
+)
+
+var (
+ ErrNotConnected = errors.New("not connected")
+ ErrNotLoggedIn = errors.New("not logged in")
+)
+
+type User struct {
+ *database.User
+
+ sync.Mutex
+
+ bridge *DiscordBridge
+ log log.Logger
+
+ PermissionLevel bridgeconfig.PermissionLevel
+
+ guilds map[string]*database.Guild
+ guildsLock sync.Mutex
+
+ Session *discordgo.Session
+}
+
+func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel {
+ return user.PermissionLevel
+}
+
+func (user *User) GetManagementRoomID() id.RoomID {
+ return user.ManagementRoom
+}
+
+func (user *User) GetMXID() id.UserID {
+ return user.MXID
+}
+
+func (user *User) GetCommandState() map[string]interface{} {
+ return nil
+}
+
+func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
+ p := user.bridge.GetPuppetByCustomMXID(user.MXID)
+ if p == nil || p.CustomIntent() == nil {
+ return nil
+ }
+ return p
+}
+
+func (user *User) GetIGhost() bridge.Ghost {
+ if user.ID == "" {
+ return nil
+ }
+ p := user.bridge.GetPuppetByID(user.ID)
+ if p == nil {
+ return nil
+ }
+ return p
+}
+
+var _ bridge.User = (*User)(nil)
+
+// this assume you are holding the guilds lock!!!
+func (user *User) loadGuilds() {
+ user.guilds = map[string]*database.Guild{}
+ for _, guild := range user.bridge.DB.Guild.GetAll(user.ID) {
+ user.guilds[guild.GuildID] = guild
+ }
+}
+
+func (br *DiscordBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User {
+ // If we weren't passed in a user we attempt to create one if we were given
+ // a matrix id.
+ if dbUser == nil {
+ if mxid == nil {
+ return nil
+ }
+
+ dbUser = br.DB.User.New()
+ dbUser.MXID = *mxid
+ dbUser.Insert()
+ }
+
+ user := br.NewUser(dbUser)
+
+ // We assume the usersLock was acquired by our caller.
+ br.usersByMXID[user.MXID] = user
+ if user.ID != "" {
+ br.usersByID[user.ID] = user
+ }
+
+ if user.ManagementRoom != "" {
+ // Lock the management rooms for our update
+ br.managementRoomsLock.Lock()
+ br.managementRooms[user.ManagementRoom] = user
+ br.managementRoomsLock.Unlock()
+ }
+
+ // Load our guilds state from the database and turn it into a map
+ user.guildsLock.Lock()
+ user.loadGuilds()
+ user.guildsLock.Unlock()
+
+ return user
+}
+
+func (br *DiscordBridge) GetUserByMXID(userID id.UserID) *User {
+ // TODO: check if puppet
+
+ br.usersLock.Lock()
+ defer br.usersLock.Unlock()
+
+ user, ok := br.usersByMXID[userID]
+ if !ok {
+ return br.loadUser(br.DB.User.GetByMXID(userID), &userID)
+ }
+
+ return user
+}
+
+func (br *DiscordBridge) GetUserByID(id string) *User {
+ br.usersLock.Lock()
+ defer br.usersLock.Unlock()
+
+ user, ok := br.usersByID[id]
+ if !ok {
+ return br.loadUser(br.DB.User.GetByID(id), nil)
+ }
+
+ return user
+}
+
+func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
+ user := &User{
+ User: dbUser,
+ bridge: br,
+ log: br.Log.Sub("User").Sub(string(dbUser.MXID)),
+ guilds: map[string]*database.Guild{},
+ }
+
+ user.PermissionLevel = br.Config.Bridge.Permissions.Get(user.MXID)
+
+ return user
+}
+
+func (br *DiscordBridge) getAllUsers() []*User {
+ br.usersLock.Lock()
+ defer br.usersLock.Unlock()
+
+ dbUsers := br.DB.User.GetAll()
+ users := make([]*User, len(dbUsers))
+
+ for idx, dbUser := range dbUsers {
+ user, ok := br.usersByMXID[dbUser.MXID]
+ if !ok {
+ user = br.loadUser(dbUser, nil)
+ }
+ users[idx] = user
+ }
+
+ return users
+}
+
+func (br *DiscordBridge) startUsers() {
+ br.Log.Debugln("Starting users")
+
+ for _, user := range br.getAllUsers() {
+ go user.Connect()
+ }
+
+ br.Log.Debugln("Starting custom puppets")
+ for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() {
+ go func(puppet *Puppet) {
+ br.Log.Debugln("Starting custom puppet", puppet.CustomMXID)
+
+ if err := puppet.StartCustomMXID(true); err != nil {
+ puppet.log.Errorln("Failed to start custom puppet:", err)
+ }
+ }(customPuppet)
+ }
+}
+
+func (user *User) SetManagementRoom(roomID id.RoomID) {
+ user.bridge.managementRoomsLock.Lock()
+ defer user.bridge.managementRoomsLock.Unlock()
+
+ existing, ok := user.bridge.managementRooms[roomID]
+ if ok {
+ // If there's a user already assigned to this management room, clear it
+ // out.
+ // I think this is due a name change or something? I dunno, leaving it
+ // for now.
+ existing.ManagementRoom = ""
+ existing.Update()
+ }
+
+ user.ManagementRoom = roomID
+ user.bridge.managementRooms[user.ManagementRoom] = user
+ user.Update()
+}
+
+func (user *User) tryAutomaticDoublePuppeting() {
+ user.Lock()
+ defer user.Unlock()
+
+ if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
+ return
+ }
+
+ user.log.Debugln("Checking if double puppeting needs to be enabled")
+
+ puppet := user.bridge.GetPuppetByID(user.ID)
+ if puppet.CustomMXID != "" {
+ user.log.Debugln("User already has double-puppeting enabled")
+
+ return
+ }
+
+ accessToken, err := puppet.loginWithSharedSecret(user.MXID)
+ if err != nil {
+ user.log.Warnln("Failed to login with shared secret:", err)
+
+ return
+ }
+
+ err = puppet.SwitchCustomMXID(accessToken, user.MXID)
+ if err != nil {
+ puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err)
+
+ return
+ }
+
+ user.log.Infoln("Successfully automatically enabled custom puppet")
+}
+
+func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
+ doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
+ if doublePuppet == nil {
+ return
+ }
+
+ if doublePuppet == nil || doublePuppet.CustomIntent() == nil || portal.MXID == "" {
+ return
+ }
+
+ // TODO sync mute status
+}
+
+func (user *User) Login(token string) error {
+ user.Token = token
+ user.Update()
+ return user.Connect()
+}
+
+func (user *User) IsLoggedIn() bool {
+ user.Lock()
+ defer user.Unlock()
+
+ return user.Token != ""
+}
+
+func (user *User) Logout() error {
+ user.Lock()
+ defer user.Unlock()
+
+ if user.Session == nil {
+ return ErrNotLoggedIn
+ }
+
+ puppet := user.bridge.GetPuppetByID(user.ID)
+ if puppet.CustomMXID != "" {
+ err := puppet.SwitchCustomMXID("", "")
+ if err != nil {
+ user.log.Warnln("Failed to logout-matrix while logging out of Discord:", err)
+ }
+ }
+
+ if err := user.Session.Close(); err != nil {
+ return err
+ }
+
+ user.Session = nil
+
+ user.Token = ""
+ user.Update()
+
+ return nil
+}
+
+func (user *User) Connected() bool {
+ user.Lock()
+ defer user.Unlock()
+
+ return user.Session != nil
+}
+
+func (user *User) Connect() error {
+ user.Lock()
+ defer user.Unlock()
+
+ if user.Token == "" {
+ return ErrNotLoggedIn
+ }
+
+ user.log.Debugln("connecting to discord")
+
+ session, err := discordgo.New(user.Token)
+ if err != nil {
+ return err
+ }
+
+ user.Session = session
+
+ // Add our event handlers
+ user.Session.AddHandler(user.readyHandler)
+ user.Session.AddHandler(user.connectedHandler)
+ user.Session.AddHandler(user.disconnectedHandler)
+
+ user.Session.AddHandler(user.guildCreateHandler)
+ user.Session.AddHandler(user.guildDeleteHandler)
+ user.Session.AddHandler(user.guildUpdateHandler)
+
+ user.Session.AddHandler(user.channelCreateHandler)
+ user.Session.AddHandler(user.channelDeleteHandler)
+ user.Session.AddHandler(user.channelPinsUpdateHandler)
+ user.Session.AddHandler(user.channelUpdateHandler)
+
+ user.Session.AddHandler(user.messageCreateHandler)
+ user.Session.AddHandler(user.messageDeleteHandler)
+ user.Session.AddHandler(user.messageUpdateHandler)
+ user.Session.AddHandler(user.reactionAddHandler)
+ user.Session.AddHandler(user.reactionRemoveHandler)
+
+ user.Session.Identify.Presence.Status = "online"
+
+ return user.Session.Open()
+}
+
+func (user *User) Disconnect() error {
+ user.Lock()
+ defer user.Unlock()
+
+ if user.Session == nil {
+ return ErrNotConnected
+ }
+
+ if err := user.Session.Close(); err != nil {
+ return err
+ }
+
+ user.Session = nil
+
+ return nil
+}
+
+func (user *User) bridgeMessage(guildID string) bool {
+ // Non guild message always get bridged.
+ if guildID == "" {
+ return true
+ }
+
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ if guild, found := user.guilds[guildID]; found {
+ if guild.Bridge {
+ return true
+ }
+ }
+
+ user.log.Debugfln("ignoring message for non-bridged guild %s-%s", user.ID, guildID)
+
+ return false
+}
+
+func (user *User) readyHandler(s *discordgo.Session, r *discordgo.Ready) {
+ user.log.Debugln("discord connection ready")
+
+ // Update our user fields
+ user.ID = r.User.ID
+
+ // Update our guild map to match watch discord thinks we're in. This is the
+ // only time we can get the full guild map as discordgo doesn't make it
+ // available to us later. Also, discord might not give us the full guild
+ // information here, so we use this to remove guilds the user left and only
+ // add guilds whose full information we have. The are told about the
+ // "unavailable" guilds later via the GuildCreate handler.
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ // build a list of the current guilds we're in so we can prune the old ones
+ current := []string{}
+
+ user.log.Debugln("database guild count", len(user.guilds))
+ user.log.Debugln("discord guild count", len(r.Guilds))
+
+ for _, guild := range r.Guilds {
+ current = append(current, guild.ID)
+
+ // If we already know about this guild, make sure we reset it's bridge
+ // status.
+ if val, found := user.guilds[guild.ID]; found {
+ bridge := val.Bridge
+ user.guilds[guild.ID].Bridge = bridge
+
+ // Update the name if the guild is available
+ if !guild.Unavailable {
+ user.guilds[guild.ID].GuildName = guild.Name
+ }
+
+ val.Upsert()
+ } else {
+ g := user.bridge.DB.Guild.New()
+ g.DiscordID = user.ID
+ g.GuildID = guild.ID
+ user.guilds[guild.ID] = g
+
+ if !guild.Unavailable {
+ g.GuildName = guild.Name
+ }
+
+ g.Upsert()
+ }
+ }
+
+ // Sync the guilds to the database.
+ user.bridge.DB.Guild.Prune(user.ID, current)
+
+ // Finally reload from the database since it purged servers we're not in
+ // anymore.
+ user.loadGuilds()
+
+ user.log.Debugln("updated database guild count", len(user.guilds))
+
+ user.Update()
+}
+
+func (user *User) connectedHandler(s *discordgo.Session, c *discordgo.Connect) {
+ user.log.Debugln("connected to discord")
+
+ user.tryAutomaticDoublePuppeting()
+}
+
+func (user *User) disconnectedHandler(s *discordgo.Session, d *discordgo.Disconnect) {
+ user.log.Debugln("disconnected from discord")
+}
+
+func (user *User) guildCreateHandler(s *discordgo.Session, g *discordgo.GuildCreate) {
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ // If we somehow already know about the guild, just update it's name
+ if guild, found := user.guilds[g.ID]; found {
+ guild.GuildName = g.Name
+ guild.Upsert()
+
+ return
+ }
+
+ // This is a brand new guild so lets get it added.
+ guild := user.bridge.DB.Guild.New()
+ guild.DiscordID = user.ID
+ guild.GuildID = g.ID
+ guild.GuildName = g.Name
+ guild.Upsert()
+
+ user.guilds[g.ID] = guild
+}
+
+func (user *User) guildDeleteHandler(s *discordgo.Session, g *discordgo.GuildDelete) {
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ if guild, found := user.guilds[g.ID]; found {
+ guild.Delete()
+ delete(user.guilds, g.ID)
+ user.log.Debugln("deleted guild", g.Guild.ID)
+ }
+}
+
+func (user *User) guildUpdateHandler(s *discordgo.Session, g *discordgo.GuildUpdate) {
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ // If we somehow already know about the guild, just update it's name
+ if guild, found := user.guilds[g.ID]; found {
+ guild.GuildName = g.Name
+ guild.Upsert()
+
+ user.log.Debugln("updated guild", g.ID)
+ }
+}
+
+func (user *User) createChannel(c *discordgo.Channel) {
+ key := database.NewPortalKey(c.ID, user.User.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ if portal.MXID != "" {
+ return
+ }
+
+ portal.Name = c.Name
+ portal.Topic = c.Topic
+ portal.Type = c.Type
+
+ if portal.Type == discordgo.ChannelTypeDM {
+ portal.DMUser = c.Recipients[0].ID
+ }
+
+ if c.Icon != "" {
+ user.log.Debugln("channel icon", c.Icon)
+ }
+
+ portal.Update()
+
+ portal.createMatrixRoom(user, c)
+}
+
+func (user *User) channelCreateHandler(s *discordgo.Session, c *discordgo.ChannelCreate) {
+ user.createChannel(c.Channel)
+}
+
+func (user *User) channelDeleteHandler(s *discordgo.Session, c *discordgo.ChannelDelete) {
+ user.log.Debugln("channel delete handler")
+}
+
+func (user *User) channelPinsUpdateHandler(s *discordgo.Session, c *discordgo.ChannelPinsUpdate) {
+ user.log.Debugln("channel pins update")
+}
+
+func (user *User) channelUpdateHandler(s *discordgo.Session, c *discordgo.ChannelUpdate) {
+ key := database.NewPortalKey(c.ID, user.User.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ portal.update(user, c.Channel)
+}
+
+func (user *User) messageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
+ if !user.bridgeMessage(m.GuildID) {
+ return
+ }
+
+ key := database.NewPortalKey(m.ChannelID, user.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ msg := portalDiscordMessage{
+ msg: m,
+ user: user,
+ }
+
+ portal.discordMessages <- msg
+}
+
+func (user *User) messageDeleteHandler(s *discordgo.Session, m *discordgo.MessageDelete) {
+ if !user.bridgeMessage(m.GuildID) {
+ return
+ }
+
+ key := database.NewPortalKey(m.ChannelID, user.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ msg := portalDiscordMessage{
+ msg: m,
+ user: user,
+ }
+
+ portal.discordMessages <- msg
+}
+
+func (user *User) messageUpdateHandler(s *discordgo.Session, m *discordgo.MessageUpdate) {
+ if !user.bridgeMessage(m.GuildID) {
+ return
+ }
+
+ key := database.NewPortalKey(m.ChannelID, user.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ msg := portalDiscordMessage{
+ msg: m,
+ user: user,
+ }
+
+ portal.discordMessages <- msg
+}
+
+func (user *User) reactionAddHandler(s *discordgo.Session, m *discordgo.MessageReactionAdd) {
+ if !user.bridgeMessage(m.MessageReaction.GuildID) {
+ return
+ }
+
+ key := database.NewPortalKey(m.ChannelID, user.User.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ msg := portalDiscordMessage{
+ msg: m,
+ user: user,
+ }
+
+ portal.discordMessages <- msg
+}
+
+func (user *User) reactionRemoveHandler(s *discordgo.Session, m *discordgo.MessageReactionRemove) {
+ if !user.bridgeMessage(m.MessageReaction.GuildID) {
+ return
+ }
+
+ key := database.NewPortalKey(m.ChannelID, user.User.ID)
+ portal := user.bridge.GetPortalByID(key)
+
+ msg := portalDiscordMessage{
+ msg: m,
+ user: user,
+ }
+
+ portal.discordMessages <- msg
+}
+
+func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) bool {
+ ret := false
+
+ inviteContent := event.Content{
+ Parsed: &event.MemberEventContent{
+ Membership: event.MembershipInvite,
+ IsDirect: isDirect,
+ },
+ Raw: map[string]interface{}{},
+ }
+
+ customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
+ if customPuppet != nil && customPuppet.CustomIntent() != nil {
+ inviteContent.Raw["fi.mau.will_auto_accept"] = true
+ }
+
+ _, err := intent.SendStateEvent(roomID, event.StateMember, user.MXID.String(), &inviteContent)
+
+ var httpErr mautrix.HTTPError
+ if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
+ user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin)
+ ret = true
+ } else if err != nil {
+ user.log.Warnfln("Failed to invite user to %s: %v", roomID, err)
+ } else {
+ ret = true
+ }
+
+ if customPuppet != nil && customPuppet.CustomIntent() != nil {
+ err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
+ if err != nil {
+ user.log.Warnfln("Failed to auto-join %s: %v", roomID, err)
+ ret = false
+ } else {
+ ret = true
+ }
+ }
+
+ return ret
+}
+
+func (user *User) getDirectChats() map[id.UserID][]id.RoomID {
+ chats := map[id.UserID][]id.RoomID{}
+
+ privateChats := user.bridge.DB.Portal.FindPrivateChats(user.ID)
+ for _, portal := range privateChats {
+ if portal.MXID != "" {
+ puppetMXID := user.bridge.FormatPuppetMXID(portal.Key.Receiver)
+
+ chats[puppetMXID] = []id.RoomID{portal.MXID}
+ }
+ }
+
+ return chats
+}
+
+func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) {
+ if !user.bridge.Config.Bridge.SyncDirectChatList {
+ return
+ }
+
+ puppet := user.bridge.GetPuppetByMXID(user.MXID)
+ if puppet == nil {
+ return
+ }
+
+ intent := puppet.CustomIntent()
+ if intent == nil {
+ return
+ }
+
+ method := http.MethodPatch
+ if chats == nil {
+ chats = user.getDirectChats()
+ method = http.MethodPut
+ }
+
+ user.log.Debugln("Updating m.direct list on homeserver")
+
+ var err error
+ if user.bridge.Config.Homeserver.Asmux {
+ urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"})
+ _, err = intent.MakeFullRequest(mautrix.FullRequest{
+ Method: method,
+ URL: urlPath,
+ Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}},
+ RequestJSON: chats,
+ })
+ } else {
+ existingChats := map[id.UserID][]id.RoomID{}
+
+ err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats)
+ if err != nil {
+ user.log.Warnln("Failed to get m.direct list to update it:", err)
+
+ return
+ }
+
+ for userID, rooms := range existingChats {
+ if _, ok := user.bridge.ParsePuppetMXID(userID); !ok {
+ // This is not a ghost user, include it in the new list
+ chats[userID] = rooms
+ } else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
+ // This is a ghost user, but we're not replacing the whole list, so include it too
+ chats[userID] = rooms
+ }
+ }
+
+ err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats)
+ }
+
+ if err != nil {
+ user.log.Warnln("Failed to update m.direct list:", err)
+ }
+}
+
+func (user *User) bridgeGuild(guildID string, everything bool) error {
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ guild, found := user.guilds[guildID]
+ if !found {
+ return fmt.Errorf("guildID not found")
+ }
+
+ // Update the guild
+ guild.Bridge = true
+ guild.Upsert()
+
+ // If this is a full bridge, create portals for all the channels
+ if everything {
+ channels, err := user.Session.GuildChannels(guildID)
+ if err != nil {
+ return err
+ }
+
+ for _, channel := range channels {
+ if channelIsBridgeable(channel) {
+ user.createChannel(channel)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (user *User) unbridgeGuild(guildID string) error {
+ user.guildsLock.Lock()
+ defer user.guildsLock.Unlock()
+
+ guild, exists := user.guilds[guildID]
+ if !exists {
+ return fmt.Errorf("guildID not found")
+ }
+
+ if !guild.Bridge {
+ return fmt.Errorf("guild not bridged")
+ }
+
+ // First update the guild so we don't have any other go routines recreating
+ // channels we're about to destroy.
+ guild.Bridge = false
+ guild.Upsert()
+
+ // Now run through the channels in the guild and remove any portals we
+ // have for them.
+ channels, err := user.Session.GuildChannels(guildID)
+ if err != nil {
+ return err
+ }
+
+ for _, channel := range channels {
+ if channelIsBridgeable(channel) {
+ key := database.PortalKey{
+ ChannelID: channel.ID,
+ Receiver: user.ID,
+ }
+
+ portal := user.bridge.GetPortalByID(key)
+ portal.leave(user)
+ }
+ }
+
+ return nil
+}
diff --git a/version/cmd.go b/version/cmd.go
deleted file mode 100644
index d1f0f0f..0000000
--- a/version/cmd.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package version
-
-import (
- "fmt"
-
- "go.mau.fi/mautrix-discord/consts"
- "go.mau.fi/mautrix-discord/globals"
-)
-
-type Cmd struct{}
-
-func (c *Cmd) Run(g *globals.Globals) error {
- fmt.Printf("%s %s\n", consts.Name, String)
-
- return nil
-}
diff --git a/version/version.go b/version/version.go
deleted file mode 100644
index 0aefad0..0000000
--- a/version/version.go
+++ /dev/null
@@ -1,3 +0,0 @@
-package version
-
-const String = "0.0.1"