소스 검색

Switch startup, config, commands and db migrations to mautrix-go systems

Tulir Asokan 3 년 전
부모
커밋
9f9f7ca4fd
74개의 변경된 파일3468개의 추가작업 그리고 5680개의 파일을 삭제
  1. 10 10
      attachments.go
  2. 1 1
      avatar.go
  3. 0 42
      bridge/bot.go
  4. 0 203
      bridge/bridge.go
  5. 0 117
      bridge/commandhandler.go
  6. 0 360
      bridge/commands.go
  7. 0 339
      bridge/crypto.go
  8. 0 337
      bridge/custompuppet.go
  9. 0 376
      bridge/matrix.go
  10. 0 1178
      bridge/portal.go
  11. 0 291
      bridge/puppet.go
  12. 0 826
      bridge/user.go
  13. 2 0
      build.sh
  14. 285 0
      commands.go
  15. 0 85
      config/appservice.go
  16. 0 33
      config/bot.go
  17. 58 89
      config/bridge.go
  18. 0 36
      config/cmd.go
  19. 24 90
      config/config.go
  20. 0 58
      config/database.go
  21. 0 29
      config/encryption.go
  22. 0 43
      config/homeserver.go
  23. 0 89
      config/logging.go
  24. 0 38
      config/managementroomtext.go
  25. 0 43
      config/provisioning.go
  26. 0 47
      config/registration.go
  27. 79 0
      config/upgrade.go
  28. 0 6
      consts/consts.go
  29. 337 0
      custompuppet.go
  30. 3 1
      database/attachment.go
  31. 0 97
      database/cryptostore.go
  32. 27 48
      database/database.go
  33. 2 1
      database/emoji.go
  34. 3 1
      database/guild.go
  35. 10 0
      database/legacymigrate.sql
  36. 3 1
      database/message.go
  37. 0 12
      database/migrations/02-attachments.sql
  38. 0 5
      database/migrations/03-emoji.sql
  39. 0 2
      database/migrations/04-custom-puppet.sql
  40. 0 2
      database/migrations/05-additional-puppet-fields.sql
  41. 0 1
      database/migrations/06-remove-unique-user-constraint.postgres.sql
  42. 0 18
      database/migrations/06-remove-unique-user-constraint.sqlite.sql
  43. 0 7
      database/migrations/07-guilds.sql
  44. 0 3
      database/migrations/08-add-crypto-store-to-database.sql
  45. 0 3
      database/migrations/09-add-account_id-to-crypto-store.sql
  46. 0 3
      database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql
  47. 0 3
      database/migrations/11-add-cross-signing-keys-to-crypto-store.sql
  48. 0 4
      database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql
  49. 0 4
      database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql
  50. 0 1
      database/migrations/14-add-encrypted-column-to-portal-table.sql
  51. 0 120
      database/migrations/migrations.go
  52. 3 1
      database/portal.go
  53. 3 1
      database/puppet.go
  54. 3 1
      database/reaction.go
  55. 0 5
      database/scannable.go
  56. 0 304
      database/sqlstatestore.go
  57. 36 18
      database/upgrades/00-initial-revision.sql
  58. 32 0
      database/upgrades/upgrades.go
  59. 23 4
      database/user.go
  60. 1 1
      discord.go
  61. 3 3
      emoji.go
  62. 36 13
      example-config.yaml
  63. 0 5
      globals/globals.go
  64. 8 10
      go.mod
  65. 14 45
      go.sum
  66. 154 29
      main.go
  67. 1178 0
      portal.go
  68. 11 11
      provisioning.go
  69. 299 0
      puppet.go
  70. 0 68
      registration/cmd.go
  71. 0 39
      run/cmd.go
  72. 820 0
      user.go
  73. 0 16
      version/cmd.go
  74. 0 3
      version/version.go

+ 10 - 10
bridge/attachments.go → 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

+ 1 - 1
bridge/avatar.go → avatar.go

@@ -1,4 +1,4 @@
-package bridge
+package main
 
 import (
 	"fmt"

+ 0 - 42
bridge/bot.go

@@ -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)
-		}
-	}
-}

+ 0 - 203
bridge/bridge.go

@@ -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")
-}

+ 0 - 117
bridge/commandhandler.go

@@ -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())
-	}
-}

+ 0 - 360
bridge/commands.go

@@ -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
-}

+ 0 - 339
bridge/crypto.go

@@ -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
-}

+ 0 - 337
bridge/custompuppet.go

@@ -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
-}

+ 0 - 376
bridge/matrix.go

@@ -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)
-	}
-}

+ 0 - 1178
bridge/portal.go

@@ -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")
-}

+ 0 - 291
bridge/puppet.go

@@ -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()
-	}
-}

+ 0 - 826
bridge/user.go

@@ -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
-}

+ 2 - 0
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'`'" "$@"

+ 285 - 0
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 <https://www.gnu.org/licenses/>.
+
+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:        "<status/bridge/unbridge> [_guild ID_] [--entire]",
+	},
+	RequiresLogin: true,
+}
+
+func fnGuilds(ce *WrappedCommandEvent) {
+	if len(ce.Args) == 0 {
+		ce.Reply("**Usage**: `$cmdprefix guilds <status/bridge/unbridge> [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 <guild ID> [--entire]")
+	} else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
+		ce.Reply("Error bridging guild: %v", err)
+	} else {
+		ce.Reply("Successfully bridged guild")
+	}
+}
+func fnUnbridgeGuild(ce *WrappedCommandEvent) {
+	if len(ce.Args) != 1 {
+		ce.Reply("**Usage**: `$cmdprefix guilds unbridge <guild ID>")
+	} else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
+		ce.Reply("Error unbridging guild: %v", err)
+	} else {
+		ce.Reply("Successfully unbridged guild")
+	}
+}

+ 0 - 85
config/appservice.go

@@ -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
-}

+ 0 - 33
config/bot.go

@@ -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()
-}

+ 58 - 89
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 <https://www.gnu.org/licenses/>.
+
 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}}"
-	}
-
-	b.displaynameTemplate, err = template.New("displayname").Parse(b.DisplaynameTemplate)
+	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")
 	}
 
-	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.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
 	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 {
+	bc.channelnameTemplate, err = template.New("channelname").Parse(bc.ChannelnameTemplate)
+	if err != nil {
 		return err
 	}
 
 	return nil
 }
 
-func (b *bridge) UnmarshalYAML(unmarshal func(interface{}) error) error {
-	type rawBridge bridge
-
-	// Set our defaults that aren't zero values.
-	raw := rawBridge{
-		SyncWithCustomPuppets: true,
-		DefaultBridgeReceipts: true,
-		DefaultBridgePresence: true,
-	}
+var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
 
-	err := unmarshal(&raw)
-	if err != nil {
-		return err
-	}
+func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
+	return bc.Encryption
+}
 
-	*b = bridge(raw)
+func (bc BridgeConfig) GetCommandPrefix() string {
+	return bc.CommandPrefix
+}
 
-	return b.validate()
+func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
+	return bc.ManagementRoomText
 }
 
-func (b bridge) FormatUsername(userid string) string {
+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

+ 0 - 36
config/cmd.go

@@ -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)
-}

+ 24 - 90
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 <https://www.gnu.org/licenses/>.
+
 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"`
-
-	filename string `yaml:"-"`
-}
-
-var configUpdated bool
-
-func (cfg *Config) validate() error {
-	if err := cfg.Homeserver.validate(); err != nil {
-		return err
-	}
-
-	if err := cfg.Appservice.validate(); err != nil {
-		return err
-	}
+	*bridgeconfig.BaseConfig `yaml:",inline"`
 
-	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
+	Bridge BridgeConfig `yaml:"bridge"`
 }
 
-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
-	}
+func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
+	_, homeserver, _ := userID.Parse()
+	_, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver]
 
-	return ioutil.WriteFile(filename, data, 0600)
+	return hasSecret
 }

+ 0 - 58
config/database.go

@@ -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,
-	)
-}

+ 0 - 29
config/encryption.go

@@ -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()
-}

+ 0 - 43
config/homeserver.go

@@ -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()
-}

+ 0 - 89
config/logging.go

@@ -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
-}

+ 0 - 38
config/managementroomtext.go

@@ -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()
-}

+ 0 - 43
config/provisioning.go

@@ -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"
-}

+ 0 - 47
config/registration.go

@@ -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
-}

+ 79 - 0
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 <https://www.gnu.org/licenses/>.
+
+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"},
+}

+ 0 - 6
consts/consts.go

@@ -1,6 +0,0 @@
-package consts
-
-const (
-	Name        = "mautrix-discord"
-	Description = "Discord-Matrix puppeting bridge"
-)

+ 337 - 0
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
+}

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

+ 0 - 97
database/cryptostore.go

@@ -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...)
-}

+ 27 - 48
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
-	}
-
-	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,
+//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))
+		}
 	}
-
+	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
 }

+ 2 - 1
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)
 

+ 3 - 1
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) {

+ 10 - 0
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);

+ 3 - 1
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)

+ 0 - 12
database/migrations/02-attachments.sql

@@ -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
-);

+ 0 - 5
database/migrations/03-emoji.sql

@@ -1,5 +0,0 @@
-CREATE TABLE emoji (
-	discord_id TEXT NOT NULL PRIMARY KEY,
-	discord_name TEXT,
-	matrix_url TEXT
-);

+ 0 - 2
database/migrations/04-custom-puppet.sql

@@ -1,2 +0,0 @@
-ALTER TABLE puppet ADD COLUMN custom_mxid TEXT;
-ALTER TABLE puppet ADD COLUMN access_token TEXT;

+ 0 - 2
database/migrations/05-additional-puppet-fields.sql

@@ -1,2 +0,0 @@
-ALTER TABLE puppet ADD COLUMN next_batch TEXT;
-ALTER TABLE puppet ADD COLUMN enable_receipts BOOLEAN NOT NULL DEFAULT true;

+ 0 - 1
database/migrations/06-remove-unique-user-constraint.postgres.sql

@@ -1 +0,0 @@
-ALTER TABLE "user" DROP CONSTRAINT user_id_key;

+ 0 - 18
database/migrations/06-remove-unique-user-constraint.sqlite.sql

@@ -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;

+ 0 - 7
database/migrations/07-guilds.sql

@@ -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)
-);

+ 0 - 3
database/migrations/08-add-crypto-store-to-database.sql

@@ -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".

+ 0 - 3
database/migrations/09-add-account_id-to-crypto-store.sql

@@ -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".

+ 0 - 3
database/migrations/10-add-megolm-withheld-data-to-crypto-store.sql

@@ -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".

+ 0 - 3
database/migrations/11-add-cross-signing-keys-to-crypto-store.sql

@@ -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".

+ 0 - 4
database/migrations/12-replace-varchar-with-text-in-the-crypto-database.sql

@@ -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".

+ 0 - 4
database/migrations/13-split-last_used-into-last_encrypted-and-last_decrypted-in-crypto-store.sql

@@ -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".

+ 0 - 1
database/migrations/14-add-encrypted-column-to-portal-table.sql

@@ -1 +0,0 @@
-ALTER TABLE portal ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT false;

+ 0 - 120
database/migrations/migrations.go

@@ -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
-}

+ 3 - 1
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
 

+ 3 - 1
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

+ 3 - 1
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(

+ 0 - 5
database/scannable.go

@@ -1,5 +0,0 @@
-package database
-
-type Scannable interface {
-	Scan(...interface{}) error
-}

+ 0 - 304
database/sqlstatestore.go

@@ -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
-}

+ 36 - 18
database/migrations/01-initial.sql → 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)
 );

+ 32 - 0
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 <https://www.gnu.org/licenses/>.
+
+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)
+}

+ 23 - 4
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)

+ 1 - 1
bridge/discord.go → discord.go

@@ -1,4 +1,4 @@
-package bridge
+package main
 
 import (
 	"github.com/bwmarrin/discordgo"

+ 3 - 3
bridge/emoji.go → 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

+ 36 - 13
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'

+ 0 - 5
globals/globals.go

@@ -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'"`
-}

+ 8 - 10
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

+ 14 - 45
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=

+ 154 - 29
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 <https://www.gnu.org/licenses/>.
+
 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"
+)
+
+//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
+}
 
-	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.'"`
+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,
-		}),
-	)
-
-	err := ctx.Run(&cli.Globals)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "error: %s\n", err)
-		os.Exit(1)
+	br := &DiscordBridge{
+		usersByMXID: make(map[id.UserID]*User),
+		usersByID:   make(map[string]*User),
+
+		managementRooms: make(map[id.RoomID]*User),
+
+		portalsByMXID: make(map[id.RoomID]*Portal),
+		portalsByID:   make(map[database.PortalKey]*Portal),
+
+		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()
 }

+ 1178 - 0
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")
+}

+ 11 - 11
bridge/provisioning.go → 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",

+ 299 - 0
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()
+	}
+}

+ 0 - 68
registration/cmd.go

@@ -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
-}

+ 0 - 39
run/cmd.go

@@ -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
-}

+ 820 - 0
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
+}

+ 0 - 16
version/cmd.go

@@ -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
-}

+ 0 - 3
version/version.go

@@ -1,3 +0,0 @@
-package version
-
-const String = "0.0.1"