// mautrix-discord - A Matrix-Discord puppeting bridge. // Copyright (C) 2026 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 connector import ( "context" "encoding/json" "errors" "net/http" "strings" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "go.mau.fi/util/exhttp" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "go.mau.fi/mautrix-discord/pkg/discordid" ) const ( ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED" ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN" ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED" ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED" ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED" ErrCodeGuildBridgeFailed = "M_UNKNOWN" ErrCodeGuildUnbridgeFailed = "M_UNKNOWN" ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED" ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED" ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED" ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED" ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED" ) type ProvisioningAPI struct { log zerolog.Logger connector *DiscordConnector prov bridgev2.IProvisioningAPI } func (d *DiscordConnector) setUpProvisioningAPIs() error { c, ok := d.Bridge.Matrix.(bridgev2.MatrixConnectorWithProvisioning) if !ok { return errors.New("matrix connector doesn't support provisioning; not setting up") } prov := c.GetProvisioning() r := prov.GetRouter() if r == nil { return errors.New("matrix connector's provisioning api didn't return a router") } log := d.Bridge.Log.With().Str("component", "provisioning").Logger() p := &ProvisioningAPI{ connector: d, log: log, prov: prov, } // NOTE: aim to provide backwards compatibility with v1 provisioning APIs r.HandleFunc("POST /v1/login/token", p.legacyTokenLogin) r.HandleFunc("GET /v1/ping", p.legacyPing) r.HandleFunc("POST /v1/logout", p.legacyLogout) r.HandleFunc("GET /v1/guilds", p.makeHandler(p.guildsList, true)) r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild, true)) // Unbridging doesn't touch discordgo, so it's okay to do it even when // logged out. r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild, false)) return nil } type provHandler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient) func (p *ProvisioningAPI) makeHandler(handler provHandler, enforceLoggedIn bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := p.prov.GetUser(r) logins := user.GetUserLogins() if len(logins) < 1 { mautrix.RespError{ ErrCode: ErrCodeNotConnected, Err: "user has no logins", }.Write(w) return } login := logins[0] client := login.Client.(*DiscordClient) if !client.IsLoggedIn() && enforceLoggedIn { mautrix.RespError{ ErrCode: ErrCodeNotConnected, Err: "not logged in to discord", }.Write(w) return } handler(w, r, login, client) } } type guildEntry struct { ID string `json:"id"` Name string `json:"name"` // TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here AvatarURL string `json:"avatar_url"` // new in v2: Bridged bool `json:"bridged"` Available bool `json:"available"` // legacy fields from v1: MXID string `json:"mxid"` AutoBridge bool `json:"auto_bridge_channels"` BridgingMode string `json:"bridging_mode"` } type respGuildsList struct { Guilds []guildEntry `json:"guilds"` } func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { ctx := r.Context() p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api") bridgedGuildIDs := client.bridgedGuildIDs() var resp respGuildsList resp.Guilds = []guildEntry{} for _, guild := range client.Session.State.Guilds { portalKey := client.guildPortalKey(guild.ID) portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey) if err != nil { p.log.Err(err). Str("guild_id", guild.ID). Msg("Failed to get guild portal for provisioning list") } _, beingBridged := bridgedGuildIDs[guild.ID] mxid := "" if portal != nil && portal.MXID != "" { mxid = portal.MXID.String() } else if beingBridged { // Beeper Desktop expects the space to exist by the time it receives // our HTTP response. If it doesn't, then the space won't appear // until the app is reloaded, and the toggle in the user interface // won't respond to the user's click. // // Pre-bridgev2, we synchronously bridged guilds. However, this // might take a while for guilds with many channels. // // To solve this, generate a deterministic room ID to use as the // MXID so that it recognizes the guild as bridged, even if the // portals haven't been created just yet. This lets us // asynchronously bridge guilds while keeping the UI responsive. mxid = p.connector.Bridge.Matrix.GenerateDeterministicRoomID(portalKey).String() } resp.Guilds = append(resp.Guilds, guildEntry{ // For now, have the ID exactly correspond to the portal ID. This // practically means that the ID will begin with an asterisk (the // guild portal ID sigil). // // Otherwise, Beeper Desktop will show a duplicate space for every // guild, as it recognizes the guild returned from this HTTP // endpoint and the actual space itself as separate "entities". // (Despite this, they point to identical rooms.) ID: string(discordid.MakeGuildPortalIDWithID(guild.ID)), Name: guild.Name, AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon), Bridged: beingBridged, Available: !guild.Unavailable, // v1 (legacy) backwards compat: MXID: mxid, AutoBridge: beingBridged, BridgingMode: "everything", }) } exhttp.WriteJSONResponse(w, 200, resp) } // normalizeGuildID removes the guild portal sigil from a guild ID if it's // there. // // This helps facilitate code that would like to accept portal keys // corresponding to guilds as well as plain Discord guild IDs. func normalizeGuildID(guildID string) string { return strings.TrimPrefix(guildID, discordid.GuildPortalKeySigil) } // collectAllGuildPortals fetches all portals associated with a guild. This // includes the guild space portal itself as well as child portals inside of // portals that represent guild category channels. // // The order of the returned slice is undefined. func (p *ProvisioningAPI) collectAllGuildPortals(ctx context.Context, guild *bridgev2.Portal) ([]*bridgev2.Portal, error) { if guild == nil { return nil, nil } // Fetch all top-level channels and category channels. children, err := p.connector.Bridge.GetChildPortals(ctx, guild.PortalKey) if err != nil { return nil, err } portals := make([]*bridgev2.Portal, 0, 1+len(children)) portals = append(portals, guild) portals = append(portals, children...) // Fetch channels that are inside of categories. for _, child := range children { grandchildren, err := p.connector.Bridge.GetChildPortals(ctx, child.PortalKey) if err != nil { return nil, err } portals = append(portals, grandchildren...) } return portals, nil } func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { guildID := normalizeGuildID(r.PathValue("guildID")) if guildID == "" { mautrix.MInvalidParam.WithMessage("no guild id").Write(w) return } p.log.Info(). Str("login_id", discordid.ParseUserLoginID(login.ID)). Str("guild_id", guildID). Msg("requested to bridge guild via provisioning api") meta := login.Metadata.(*discordid.UserLoginMetadata) if meta.BridgedGuildIDs == nil { meta.BridgedGuildIDs = map[string]bool{} } _, alreadyBridged := meta.BridgedGuildIDs[guildID] meta.BridgedGuildIDs[guildID] = true if err := login.Save(r.Context()); err != nil { p.log.Err(err).Msg("Failed to save login after guild bridge request") mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w) return } go client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID) responseStatus := 201 if alreadyBridged { responseStatus = 200 } exhttp.WriteJSONResponse(w, responseStatus, nil) } // Legacy v1 provisioning endpoints for backwards compatibility with clients // that haven't migrated to the bridgev2 provisioning API yet. func (p *ProvisioningAPI) legacyTokenLogin(w http.ResponseWriter, r *http.Request) { user := p.prov.GetUser(r) if logins := user.GetUserLogins(); len(logins) > 0 { for _, login := range logins { client := login.Client.(*DiscordClient) if client.HasToken() { exhttp.WriteJSONResponse(w, http.StatusConflict, mautrix.RespError{ ErrCode: ErrCodeAlreadyLoggedIn, Err: "already logged in to Discord", }) return } } } var body struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { exhttp.WriteJSONResponse(w, http.StatusBadRequest, mautrix.RespError{ ErrCode: mautrix.MBadJSON.ErrCode, Err: "failed to parse request body", }) return } if body.Token == "" { exhttp.WriteJSONResponse(w, http.StatusBadRequest, mautrix.RespError{ ErrCode: mautrix.MBadJSON.ErrCode, Err: "missing token", }) return } login, err := p.connector.CreateLogin(r.Context(), user, LoginFlowIDToken) if err != nil { p.log.Err(err).Msg("Failed to create login process") exhttp.WriteJSONResponse(w, http.StatusInternalServerError, mautrix.RespError{ ErrCode: ErrCodeLoginPrepareFailed, Err: "failed to prepare login", }) return } _, err = login.Start(r.Context()) if err != nil { p.log.Err(err).Msg("Failed to start login process") exhttp.WriteJSONResponse(w, http.StatusInternalServerError, mautrix.RespError{ ErrCode: ErrCodeLoginPrepareFailed, Err: "failed to start login", }) return } _, err = login.(bridgev2.LoginProcessUserInput).SubmitUserInput(r.Context(), map[string]string{ "token": body.Token, }) if err != nil { p.log.Err(err).Msg("Failed to submit token") exhttp.WriteJSONResponse(w, http.StatusUnauthorized, mautrix.RespError{ ErrCode: ErrCodePostLoginConnFailed, Err: "failed to connect to Discord", }) return } discordUser := login.(*DiscordTokenLogin).DiscordUser exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{ "success": true, "id": discordUser.ID, "username": discordUser.Username, "discriminator": discordUser.Discriminator, }) } func (p *ProvisioningAPI) legacyPing(w http.ResponseWriter, r *http.Request) { user := p.prov.GetUser(r) resp := map[string]any{ "mxid": user.MXID, "management_room": user.ManagementRoom, } discord := map[string]any{ "logged_in": false, "connected": false, } if logins := user.GetUserLogins(); len(logins) > 0 { login := logins[0] client := login.Client.(*DiscordClient) discord["id"] = discordid.ParseUserLoginID(login.ID) discord["logged_in"] = client.HasToken() discord["connected"] = client.IsLoggedIn() if client.Session != nil { discord["conn"] = map[string]any{ "last_heartbeat_ack": client.Session.LastHeartbeatAck.UnixMilli(), "last_heartbeat_sent": client.Session.LastHeartbeatSent.UnixMilli(), } } } resp["Discord"] = discord exhttp.WriteJSONResponse(w, http.StatusOK, resp) } func (p *ProvisioningAPI) legacyLogout(w http.ResponseWriter, r *http.Request) { user := p.prov.GetUser(r) logins := user.GetUserLogins() if len(logins) == 0 { exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{ "success": true, "status": "not logged in", }) return } logins[0].Logout(r.Context()) exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{ "success": true, "status": "logged out successfully", }) } func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { guildID := normalizeGuildID(r.PathValue("guildID")) if guildID == "" { mautrix.MInvalidParam.WithMessage("no guild id").Write(w) return } log := p.log.With(). Str("login_id", discordid.ParseUserLoginID(login.ID)). Str("guild_id", guildID). Str("action", "unbridge guild"). Logger() ctx := log.WithContext(r.Context()) log.Info().Msg("Unbridging guild via provisioning API") // Immediately record user intent by committing the change to UserLogin // metadata, even if the portal deletion we're about to attempt fails. meta := login.Metadata.(*discordid.UserLoginMetadata) if meta.BridgedGuildIDs != nil { delete(meta.BridgedGuildIDs, guildID) } if err := login.Save(ctx); err != nil { log.Err(err).Msg("Failed to save login after guild unbridge request") mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w) return } portalKey := client.guildPortalKey(guildID) guildPortal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey) if err != nil { log.Err(err).Msg("Failed to get guild portal") mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w) return } if guildPortal == nil || guildPortal.MXID == "" { mautrix.RespError{ ErrCode: ErrCodeGuildNotBridged, Err: "guild is not bridged", }.Write(w) return } deletingPortals, err := p.collectAllGuildPortals(ctx, guildPortal) if err != nil { log.Err(err).Msg("Failed to collect portal subtree for deletion") mautrix.MUnknown.WithMessage("failed to collect portal subtree for deletion: %v", err).Write(w) return } // DeleteManyPortals will sort by depth for us so children get deleted // before their parents. bridgev2.DeleteManyPortals(ctx, deletingPortals, func(portal *bridgev2.Portal, del bool, err error) { log.Err(err). Stringer("portal_mxid", portal.MXID). Bool("delete_room", del). Msg("Failed during portal cleanup") }) log.Info(). Int("deleted_portals", len(deletingPortals)). Msg("Finished unbridging") exhttp.WriteJSONResponse(w, 200, map[string]any{ "success": true, "deleted_portals": len(deletingPortals), }) }