diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 4ff64b7..10a625f 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -35,6 +35,7 @@ var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia) + d.setUpProvisioningAPIs() } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go new file mode 100644 index 0000000..1860171 --- /dev/null +++ b/pkg/connector/provisioning.go @@ -0,0 +1,204 @@ +// 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" + "errors" + "net/http" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "go.mau.fi/util/exhttp" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" +) + +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("GET /v1/guilds", p.makeHandler(p.guildsList)) + r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild)) + r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild)) + + return nil +} + +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"` + + // v1-compatible fields: + MXID string `json:"mxid"` + AutoBridge bool `json:"auto_bridge_channels"` + BridgingMode string `json:"bridging_mode"` + + Available bool `json:"available"` +} +type respGuildsList struct { + Guilds []guildEntry `json:"guilds"` +} + +func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient)) 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) + + handler(w, r, login, client) + } +} + +func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + p.log.Info().Str("login_id", string(login.ID)).Msg("guilds list requested via provisioning api") + + var resp respGuildsList + resp.Guilds = []guildEntry{} + for _, guild := range client.Session.State.Guilds { + resp.Guilds = append(resp.Guilds, guildEntry{ + ID: guild.ID, + Name: guild.Name, + AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon), + + BridgingMode: "everything", + + Available: !guild.Unavailable, + }) + } + + exhttp.WriteJSONResponse(w, 200, resp) +} + +func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + guildID := r.PathValue("guildID") + if guildID == "" { + mautrix.MInvalidParam.WithMessage("no guild id").Write(w) + return + } + + p.log.Info(). + Str("login_id", string(login.ID)). + Str("guild_id", guildID). + Msg("requested to bridge guild via provisioning api") + + // TODO detect guild already bridged + go client.bridgeGuild(context.TODO(), guildID) + + exhttp.WriteJSONResponse(w, 201, nil) +} + +func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) { + guildID := r.PathValue("guildID") + if guildID == "" { + mautrix.MInvalidParam.WithMessage("no guild id").Write(w) + return + } + + p.log.Info(). + Str("login_id", string(login.ID)). + Str("guild_id", guildID). + Msg("requested to unbridge guild via provisioning api") + + ctx := context.TODO() + + portalKey := client.guildPortalKeyFromID(guildID) + portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil { + p.log.Err(err).Msg("Failed to get guild portal") + mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w) + return + } + if portal == nil || portal.MXID == "" { + mautrix.RespError{ + ErrCode: ErrCodeGuildNotBridged, + Err: "guild is not bridged", + }.Write(w) + return + } + + children, err := p.connector.Bridge.GetChildPortals(ctx, portalKey) + if err != nil { + p.log.Err(err).Msg("Failed to get child portals") + mautrix.MUnknown.WithMessage("failed to get children: %v", err).Write(w) + return + } + + portalsToDelete := append(children, portal) + bridgev2.DeleteManyPortals(ctx, portalsToDelete, func(portal *bridgev2.Portal, del bool, err error) { + p.log.Err(err). + Stringer("portal_mxid", portal.MXID). + Bool("delete_room", del). + Msg("Failed during portal cleanup") + }) + + p.log.Info().Int("children", len(children)).Msg("Finished unbridging") + exhttp.WriteJSONResponse(w, 200, map[string]any{ + "success": true, + "deleted_portals": len(children) + 1, + }) +}