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