From 586cb2bfe63b919bfaa11e75752af9324d343b8a Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 24 Nov 2025 10:28:43 -0800 Subject: [PATCH] initial pass at wiring up login to discordgo --- go.mod | 3 +- go.sum | 6 +- pkg/connector/client.go | 112 +++++++++++++++++++++++++++--- pkg/connector/connector.go | 7 +- pkg/connector/dbmeta.go | 15 +++- pkg/connector/login.go | 137 +++++++++++++++++++++++++++++++++++-- pkg/connector/userinfo.go | 5 +- 7 files changed, 259 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index d5ef0f8..7d80acc 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -40,4 +41,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 +replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec diff --git a/go.sum b/go.sum index 85e2dd4..3f4e757 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2 h1:8lgTjYGSIlS90f0jiFfEC4UwxCq9FiUo4dKwjknbupQ= -github.com/beeper/discordgo v0.0.0-20250607214857-f23a8518ece2/go.mod h1:59+AOzzjmL6onAh62nuLXmn7dJCaC/owDLWbGtjTcFA= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I= +github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -11,6 +11,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 5be113b..b55f0fc 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -18,36 +18,128 @@ package connector import ( "context" + "errors" + "fmt" + "time" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/status" ) type DiscordClient struct { + UserLogin *bridgev2.UserLogin + Session *discordgo.Session } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { - //TODO implement me - panic("implement me") + meta := login.Metadata.(*UserLoginMetadata) + + session, err := discordgo.New(meta.Token) + if meta.HeartbeatSession.IsExpired() { + log.Ctx(ctx).Info().Msg("Heartbeat session expired, creating a new one") + meta.HeartbeatSession = discordgo.NewHeartbeatSession() + } + meta.HeartbeatSession.BumpLastUsed() + session.HeartbeatSession = meta.HeartbeatSession + login.Save(ctx) + + if err != nil { + return err + } + + // FIXME(skip): Implement. + session.EventHandler = func(evt any) {} + + login.Client = &DiscordClient{ + UserLogin: login, + Session: session, + } + + return nil } var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) func (d *DiscordClient) Connect(ctx context.Context) { - //TODO implement me - panic("implement me") + log := zerolog.Ctx(ctx) + + if d.Session == nil { + log.Error().Msg("No session present") + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "discord-not-logged-in", + }) + return + } + + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnecting, + }) + if err := d.connect(ctx); err != nil { + log.Err(err).Msg("Couldn't connect to Discord") + } + // TODO(skip): Use event handler and send this in response to READY/RESUMED instead? + d.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateConnected, + }) +} + +func (cl *DiscordClient) connect(ctx context.Context) error { + log := log.Ctx(ctx) + log.Info().Msg("Opening session") + + err := cl.Session.Open() + for attempts := 0; errors.Is(err, discordgo.ErrImmediateDisconnect) && attempts < 2; attempts += 1 { + log.Err(err).Int("attempts", attempts).Msg("Immediately disconnected while trying to open session, trying again in 5 seconds") + time.Sleep(5 * time.Second) + err = cl.Session.Open() + } + if err != nil { + log.Err(err).Msg("Failed to connect to Discord") + return err + } + + // Ensure that we actually have a user. + if !cl.IsLoggedIn() { + return fmt.Errorf("unknown identity even after connecting to Discord") + } + user := cl.Session.State.User + log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord") + + if cl.UserLogin != nil { + // Feels a bit hacky to check for this here, but it should be true when + // logging in initially. The UserLogin is only ever created if we know + // that we connected successfully. We _do_ know that by now here, but we're + // not tasked with creating the UserLogin; the login code is. Alas. + + // FIXME(skip): Avatar. + cl.UserLogin.RemoteProfile = status.RemoteProfile{ + Email: user.Email, + Phone: user.Phone, + Name: user.String(), + } + if err := cl.UserLogin.Save(ctx); err != nil { + log.Err(err).Msg("Couldn't save UserLogin after connecting") + } + } + + return nil } func (d *DiscordClient) Disconnect() { - //TODO implement me - panic("implement me") + log.Debug().Msg("Disconnecting session") + d.Session.Close() + d.Session = nil } func (d *DiscordClient) IsLoggedIn() bool { - //TODO implement me - panic("implement me") + return d.Session != nil && d.Session.State != nil && d.Session.State.User != nil && d.Session.State.User.ID != "" } func (d *DiscordClient) LogoutRemote(ctx context.Context) { - //TODO implement me - panic("implement me") + // FIXME(skip): Implement. + d.Disconnect() } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index bb8bf05..e3a16d8 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -23,18 +23,17 @@ import ( ) type DiscordConnector struct { + bridge *bridgev2.Bridge } var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { - //TODO implement me - panic("implement me") + d.bridge = bridge } func (d *DiscordConnector) Start(ctx context.Context) error { - //TODO implement me - panic("implement me") + return nil } func (d *DiscordConnector) GetName() bridgev2.BridgeName { diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index f397ec4..903e99a 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -17,10 +17,19 @@ package connector import ( + "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2/database" ) -func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { - //TODO implement me - panic("implement me") +type UserLoginMetadata struct { + Token string `json:"token"` + HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` +} + +func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { + return database.MetaTypes{ + UserLogin: func() any { + return &UserLoginMetadata{} + }, + } } diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 71ee401..8802642 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -18,16 +18,145 @@ package connector import ( "context" + "fmt" + "strings" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const ( + LoginFlowIDToken = "fi.mau.discord.login.token" ) func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow { - //TODO implement me - panic("implement me") + // FIXME(skip): Provide actually user-friendly login flows. + return []bridgev2.LoginFlow{ + { + ID: LoginFlowIDToken, + Name: "Token", + Description: "Provide a Discord user token to connect with.", + }, + } } func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - //TODO implement me - panic("implement me") + if flowID != LoginFlowIDToken { + return nil, fmt.Errorf("unknown login flow ID") + } + + return &DiscordLogin{User: user}, nil +} + +type DiscordLogin struct { + User *bridgev2.User + Token string + Session *discordgo.Session +} + +var _ bridgev2.LoginProcessUserInput = (*DiscordLogin)(nil) + +func (dl *DiscordLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: "fi.mau.discord.enter_token", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "token", + Name: "Discord user account token", + // Cribbed from https://regex101.com/r/1GMR0y/1. + Pattern: `^(mfa\.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})$`, + }, + }, + }, + }, nil +} + +func (dl *DiscordLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + token := input["token"] + if token == "" { + return nil, fmt.Errorf("no token provided") + } + + log := zerolog.Ctx(ctx) + + log.Info().Msg("Creating session from provided token") + dl.Token = token + + session, err := discordgo.New(token) + if err != nil { + return nil, fmt.Errorf("couldn't create discord session: %w", err) + } + + // FIXME(skip): Implement. + session.EventHandler = func(evt any) {} + + // Set up logging. + session.LogLevel = discordgo.LogInformational + session.Logger = func(msgL, caller int, format string, a ...any) { + // FIXME(skip): Hook up zerolog properly. + log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf + } + + cl := DiscordClient{ + Session: session, + } + err = cl.connect(ctx) + if err != nil { + dl.softlyCloseSession() + return nil, err + } + // At this point we've opened a WebSocket connection to the gateway, received + // a READY packet, and know who we are. + user := session.State.User + + dl.Session = session + ul, err := dl.User.NewLogin(ctx, &database.UserLogin{ + ID: networkid.UserLoginID(user.ID), + Metadata: &UserLoginMetadata{ + Token: token, + HeartbeatSession: session.HeartbeatSession, + }, + }, &bridgev2.NewLoginParams{ + // We already have a Session; call this instead of the connector's main LoadUserLogin method and thread the Session through. + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &cl + return nil + }, + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + dl.softlyCloseSession() + return nil, fmt.Errorf("couldn't create login: %w", err) + } + zerolog.Ctx(ctx).Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord during login") + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: "fi.mau.discord.complete", + Instructions: fmt.Sprintf("Logged in as %s", user), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (dl *DiscordLogin) softlyCloseSession() { + log.Debug().Msg("Closing session") + err := dl.Session.Close() + if err != nil { + log.Err(err).Msg("Couldn't close Discord session in response to login cancellation") + } +} + +func (dl *DiscordLogin) Cancel() { + dl.softlyCloseSession() } diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index cdf0ee4..bedb53f 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -24,8 +24,9 @@ import ( ) func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { - //TODO implement me - panic("implement me") + // We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map + // directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison. + return userID == networkid.UserID(d.UserLogin.ID) } func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {