Files
mautrix-discord/user.go
Tulir Asokan 111824486b Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:43:53 +03:00

1527 lines
44 KiB
Go

package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net/http"
"net/url"
"os"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"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 zerolog.Logger
PermissionLevel bridgeconfig.PermissionLevel
spaceCreateLock sync.Mutex
spaceMembershipChecked bool
dmSpaceMembershipChecked bool
Session *discordgo.Session
BridgeState *bridge.BridgeStateQueue
bridgeStateLock sync.Mutex
wasDisconnected bool
wasLoggedOut bool
markedOpened map[string]time.Time
markedOpenedLock sync.Mutex
pendingInteractions map[string]*WrappedCommandEvent
pendingInteractionsLock sync.Mutex
nextDiscordUploadID atomic.Int32
relationships map[string]*discordgo.Relationship
}
func (user *User) GetRemoteID() string {
return user.DiscordID
}
func (user *User) GetRemoteName() string {
if user.Session != nil && user.Session.State != nil && user.Session.State.User != nil {
if user.Session.State.User.Discriminator == "0" {
return fmt.Sprintf("@%s", user.Session.State.User.Username)
}
return fmt.Sprintf("%s#%s", user.Session.State.User.Username, user.Session.State.User.Discriminator)
}
return user.DiscordID
}
var discordLog zerolog.Logger
func discordToZeroLevel(level int) zerolog.Level {
switch level {
case discordgo.LogError:
return zerolog.ErrorLevel
case discordgo.LogWarning:
return zerolog.WarnLevel
case discordgo.LogInformational:
return zerolog.InfoLevel
case discordgo.LogDebug:
fallthrough
default:
return zerolog.DebugLevel
}
}
func init() {
discordgo.Logger = func(msgL, caller int, format string, a ...interface{}) {
discordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
}
}
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.DiscordID == "" {
return nil
}
p := user.bridge.GetPuppetByID(user.DiscordID)
if p == nil {
return nil
}
return p
}
var _ bridge.User = (*User)(nil)
func (br *DiscordBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User {
if dbUser == nil {
if mxid == nil {
return nil
}
dbUser = br.DB.User.New()
dbUser.MXID = *mxid
dbUser.Insert()
}
user := br.NewUser(dbUser)
br.usersByMXID[user.MXID] = user
if user.DiscordID != "" {
br.usersByID[user.DiscordID] = user
}
if user.ManagementRoom != "" {
br.managementRoomsLock.Lock()
br.managementRooms[user.ManagementRoom] = user
br.managementRoomsLock.Unlock()
}
return user
}
func (br *DiscordBridge) GetUserByMXID(userID id.UserID) *User {
if userID == br.Bot.UserID || br.IsGhost(userID) {
return nil
}
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) GetCachedUserByID(id string) *User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
return br.usersByID[id]
}
func (br *DiscordBridge) GetCachedUserByMXID(userID id.UserID) *User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
return br.usersByMXID[userID]
}
func (br *DiscordBridge) NewUser(dbUser *database.User) *User {
user := &User{
User: dbUser,
bridge: br,
log: br.ZLog.With().Str("user_id", string(dbUser.MXID)).Logger(),
markedOpened: make(map[string]time.Time),
PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID),
pendingInteractions: make(map[string]*WrappedCommandEvent),
relationships: make(map[string]*discordgo.Relationship),
}
user.nextDiscordUploadID.Store(rand.Int31n(100))
user.BridgeState = br.NewBridgeStateQueue(user)
return user
}
func (br *DiscordBridge) getAllUsersWithToken() []*User {
br.usersLock.Lock()
defer br.usersLock.Unlock()
dbUsers := br.DB.User.GetAllWithToken()
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.ZLog.Debug().Msg("Starting users")
usersWithToken := br.getAllUsersWithToken()
for _, u := range usersWithToken {
go u.startupTryConnect(0)
}
if len(usersWithToken) == 0 {
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
}
br.ZLog.Debug().Msg("Starting custom puppets")
for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() {
go func(puppet *Puppet) {
br.ZLog.Debug().Str("user_id", puppet.CustomMXID.String()).Msg("Starting custom puppet")
if err := puppet.StartCustomMXID(true); err != nil {
puppet.log.Error().Err(err).Msg("Failed to start custom puppet")
}
}(customPuppet)
}
}
func (user *User) startupTryConnect(retryCount int) {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
err := user.Connect()
if err != nil {
user.log.Error().Err(err).Msg("Error connecting on startup")
closeErr := &websocket.CloseError{}
if errors.As(err, &closeErr) && closeErr.Code == 4004 {
user.invalidAuthHandler(nil)
} else if retryCount < 6 {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-unknown-websocket-error", Message: err.Error()})
retryInSeconds := 2 << retryCount
user.log.Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
time.Sleep(time.Duration(retryInSeconds) * time.Second)
user.startupTryConnect(retryCount + 1)
} else {
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "dc-unknown-websocket-error", Message: err.Error()})
}
}
}
func (user *User) SetManagementRoom(roomID id.RoomID) {
user.bridge.managementRoomsLock.Lock()
defer user.bridge.managementRoomsLock.Unlock()
existing, ok := user.bridge.managementRooms[roomID]
if ok {
existing.ManagementRoom = ""
existing.Update()
}
user.ManagementRoom = roomID
user.bridge.managementRooms[user.ManagementRoom] = user
user.Update()
}
func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.RoomID) id.RoomID {
if len(*ptr) > 0 {
return *ptr
}
user.spaceCreateLock.Lock()
defer user.spaceCreateLock.Unlock()
if len(*ptr) > 0 {
return *ptr
}
initialState := []*event.Event{{
Type: event.StateRoomAvatar,
Content: event.Content{
Parsed: &event.RoomAvatarEventContent{
URL: user.bridge.Config.AppService.Bot.ParsedAvatar,
},
},
}}
if parent != "" {
parentIDStr := parent.String()
initialState = append(initialState, &event.Event{
Type: event.StateSpaceParent,
StateKey: &parentIDStr,
Content: event.Content{
Parsed: &event.SpaceParentEventContent{
Canonical: true,
Via: []string{user.bridge.AS.HomeserverDomain},
},
},
})
}
resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
Visibility: "private",
Name: name,
Topic: topic,
InitialState: initialState,
CreationContent: map[string]interface{}{
"type": event.RoomTypeSpace,
},
PowerLevelOverride: &event.PowerLevelsEventContent{
Users: map[id.UserID]int{
user.bridge.Bot.UserID: 9001,
user.MXID: 50,
},
},
RoomVersion: "11",
})
if err != nil {
user.log.Error().Err(err).Msg("Failed to auto-create space room")
} else {
*ptr = resp.RoomID
user.Update()
user.ensureInvited(nil, *ptr, false, true)
if parent != "" {
_, err = user.bridge.Bot.SendStateEvent(parent, event.StateSpaceChild, resp.RoomID.String(), &event.SpaceChildEventContent{
Via: []string{user.bridge.AS.HomeserverDomain},
Order: " 0000",
})
if err != nil {
user.log.Error().Err(err).
Str("created_space_id", resp.RoomID.String()).
Str("parent_space_id", parent.String()).
Msg("Failed to add created space room to parent space")
}
}
}
return *ptr
}
func (user *User) GetSpaceRoom() id.RoomID {
return user.getSpaceRoom(&user.SpaceRoom, "Discord", "Your Discord bridged chats", "")
}
func (user *User) GetDMSpaceRoom() id.RoomID {
return user.getSpaceRoom(&user.DMSpaceRoom, "Direct Messages", "Your Discord direct messages", user.GetSpaceRoom())
}
func (user *User) ViewingChannel(portal *Portal) bool {
if portal.GuildID != "" || !user.Session.IsUser {
return false
}
user.markedOpenedLock.Lock()
defer user.markedOpenedLock.Unlock()
ts := user.markedOpened[portal.Key.ChannelID]
// TODO is there an expiry time?
if ts.IsZero() {
user.markedOpened[portal.Key.ChannelID] = time.Now()
err := user.Session.MarkViewing(portal.Key.ChannelID)
if err != nil {
user.log.Error().Err(err).
Str("channel_id", portal.Key.ChannelID).
Msg("Failed to mark user as viewing channel")
}
return true
}
return false
}
func (user *User) mutePortal(intent *appservice.IntentAPI, portal *Portal, unmute bool) {
if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteChannelsOnCreate {
return
}
var err error
if unmute {
user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Unmuting portal")
err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID))
} else {
user.log.Debug().Str("room_id", portal.MXID.String()).Msg("Muting portal")
err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{
Actions: []pushrules.PushActionType{pushrules.ActionDontNotify},
})
}
if err != nil && !errors.Is(err, mautrix.MNotFound) {
user.log.Warn().Err(err).
Str("room_id", portal.MXID.String()).
Msg("Failed to update push rule through double puppet")
}
}
func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) {
doublePuppetIntent := portal.bridge.GetPuppetByCustomMXID(user.MXID).CustomIntent()
if doublePuppetIntent == nil || portal.MXID == "" {
return
}
// TODO sync mute status properly
if portal.GuildID != "" && user.bridge.Config.Bridge.MuteChannelsOnCreate && justCreated {
user.mutePortal(doublePuppetIntent, portal, false)
}
}
func (user *User) NextDiscordUploadID() string {
val := user.nextDiscordUploadID.Add(2)
return strconv.Itoa(int(val))
}
func (user *User) Login(token string) error {
user.bridgeStateLock.Lock()
user.wasLoggedOut = false
user.bridgeStateLock.Unlock()
user.DiscordToken = token
var err error
const maxRetries = 3
Loop:
for i := 0; i < maxRetries; i++ {
err = user.Connect()
if err == nil {
user.Update()
return nil
}
user.log.Error().Err(err).Msg("Error connecting for login")
closeErr := &websocket.CloseError{}
errors.As(err, &closeErr)
switch closeErr.Code {
case 4004, 4010, 4011, 4012, 4013, 4014:
break Loop
case 4000:
fallthrough
default:
if i < maxRetries-1 {
time.Sleep(time.Duration(i+1) * 2 * time.Second)
}
}
}
user.DiscordToken = ""
return err
}
func (user *User) IsLoggedIn() bool {
user.Lock()
defer user.Unlock()
return user.DiscordToken != ""
}
func (user *User) Logout(isOverwriting bool) {
user.Lock()
defer user.Unlock()
if user.DiscordID != "" {
puppet := user.bridge.GetPuppetByID(user.DiscordID)
if puppet.CustomMXID != "" {
err := puppet.SwitchCustomMXID("", "")
if err != nil {
user.log.Warn().Err(err).Msg("Failed to disable custom puppet while logging out of Discord")
}
}
}
if user.Session != nil {
if err := user.Session.Close(); err != nil {
user.log.Warn().Err(err).Msg("Error closing session")
}
}
user.Session = nil
user.DiscordToken = ""
user.ReadStateVersion = 0
if !isOverwriting {
user.bridge.usersLock.Lock()
if user.bridge.usersByID[user.DiscordID] == user {
delete(user.bridge.usersByID, user.DiscordID)
}
user.bridge.usersLock.Unlock()
}
user.DiscordID = ""
user.Update()
user.log.Info().Msg("User logged out")
}
func (user *User) Connected() bool {
user.Lock()
defer user.Unlock()
return user.Session != nil
}
const BotIntents = discordgo.IntentGuilds |
discordgo.IntentGuildMessages |
discordgo.IntentGuildMessageReactions |
discordgo.IntentGuildMessageTyping |
discordgo.IntentGuildBans |
discordgo.IntentGuildEmojis |
discordgo.IntentGuildIntegrations |
discordgo.IntentGuildInvites |
//discordgo.IntentGuildVoiceStates |
//discordgo.IntentGuildScheduledEvents |
discordgo.IntentDirectMessages |
discordgo.IntentDirectMessageTyping |
discordgo.IntentDirectMessageTyping |
// Privileged intents
discordgo.IntentMessageContent |
//discordgo.IntentGuildPresences |
discordgo.IntentGuildMembers
func (user *User) Connect() error {
user.Lock()
defer user.Unlock()
if user.DiscordToken == "" {
return ErrNotLoggedIn
}
user.log.Debug().Msg("Connecting to discord")
session, err := discordgo.New(user.DiscordToken)
if err != nil {
return err
}
if user.bridge.Config.Bridge.Proxy != "" {
u, _ := url.Parse(user.bridge.Config.Bridge.Proxy)
tlsConf := &tls.Config{
InsecureSkipVerify: os.Getenv("DISCORD_SKIP_TLS_VERIFICATION") == "true",
}
session.Client.Transport = &http.Transport{
Proxy: http.ProxyURL(u),
TLSClientConfig: tlsConf,
ForceAttemptHTTP2: true,
}
session.Dialer.Proxy = http.ProxyURL(u)
session.Dialer.TLSClientConfig = tlsConf
}
// TODO move to config
if os.Getenv("DISCORD_DEBUG") == "1" {
session.LogLevel = discordgo.LogDebug
} else {
session.LogLevel = discordgo.LogInformational
}
userDiscordLog := user.log.With().Str("component", "discordgo").Logger()
session.Logger = func(msgL, caller int, format string, a ...interface{}) {
userDiscordLog.WithLevel(discordToZeroLevel(msgL)).Caller(caller+1).Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
}
if !session.IsUser {
session.Identify.Intents = BotIntents
}
session.EventHandler = user.eventHandlerSync
if session.IsUser {
err = session.LoadMainPage(context.TODO())
if err != nil {
user.log.Warn().Err(err).Msg("Failed to load main page")
}
}
user.Session = session
for {
err = user.Session.Open()
if errors.Is(err, discordgo.ErrImmediateDisconnect) {
user.log.Warn().Err(err).Msg("Retrying initial connection in 5 seconds")
time.Sleep(5 * time.Second)
continue
}
return err
}
}
func (user *User) eventHandlerSync(rawEvt any) {
go user.eventHandler(rawEvt)
}
func (user *User) eventHandler(rawEvt any) {
defer func() {
err := recover()
if err != nil {
user.log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Any(zerolog.ErrorFieldName, err).
Msg("Panic in Discord event handler")
}
}()
switch evt := rawEvt.(type) {
case *discordgo.Ready:
user.readyHandler(evt)
case *discordgo.Resumed:
user.resumeHandler(evt)
case *discordgo.Connect:
user.connectedHandler(evt)
case *discordgo.Disconnect:
user.disconnectedHandler(evt)
case *discordgo.InvalidAuth:
user.invalidAuthHandler(evt)
case *discordgo.GuildCreate:
user.guildCreateHandler(evt)
case *discordgo.GuildDelete:
user.guildDeleteHandler(evt)
case *discordgo.GuildUpdate:
user.guildUpdateHandler(evt)
case *discordgo.GuildRoleCreate:
user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
case *discordgo.GuildRoleUpdate:
user.discordRoleToDB(evt.GuildID, evt.Role, nil, nil)
case *discordgo.GuildRoleDelete:
user.bridge.DB.Role.DeleteByID(evt.GuildID, evt.RoleID)
case *discordgo.ChannelCreate:
user.channelCreateHandler(evt)
case *discordgo.ChannelDelete:
user.channelDeleteHandler(evt)
case *discordgo.ChannelUpdate:
user.channelUpdateHandler(evt)
case *discordgo.ChannelRecipientAdd:
user.channelRecipientAdd(evt)
case *discordgo.ChannelRecipientRemove:
user.channelRecipientRemove(evt)
case *discordgo.RelationshipAdd:
user.relationshipAddHandler(evt)
case *discordgo.RelationshipRemove:
user.relationshipRemoveHandler(evt)
case *discordgo.RelationshipUpdate:
user.relationshipUpdateHandler(evt)
case *discordgo.MessageCreate:
user.pushPortalMessage(evt, "message create", evt.ChannelID, evt.GuildID)
case *discordgo.MessageDelete:
user.pushPortalMessage(evt, "message delete", evt.ChannelID, evt.GuildID)
case *discordgo.MessageDeleteBulk:
user.pushPortalMessage(evt, "bulk message delete", evt.ChannelID, evt.GuildID)
case *discordgo.MessageUpdate:
user.pushPortalMessage(evt, "message update", evt.ChannelID, evt.GuildID)
case *discordgo.MessageReactionAdd:
user.pushPortalMessage(evt, "reaction add", evt.ChannelID, evt.GuildID)
case *discordgo.MessageReactionRemove:
user.pushPortalMessage(evt, "reaction remove", evt.ChannelID, evt.GuildID)
case *discordgo.MessageAck:
user.messageAckHandler(evt)
case *discordgo.TypingStart:
user.typingStartHandler(evt)
case *discordgo.InteractionSuccess:
user.interactionSuccessHandler(evt)
case *discordgo.ThreadListSync:
user.threadListSyncHandler(evt)
case *discordgo.Event:
// Ignore
default:
user.log.Debug().Type("event_type", evt).Msg("Unhandled event")
}
}
func (user *User) Disconnect() error {
user.Lock()
defer user.Unlock()
if user.Session == nil {
return ErrNotConnected
}
user.log.Info().Msg("Disconnecting session manually")
if err := user.Session.Close(); err != nil {
return err
}
user.Session = nil
return nil
}
func (user *User) getGuildBridgingMode(guildID string) database.GuildBridgingMode {
if guildID == "" {
return database.GuildBridgeEverything
}
guild := user.bridge.GetGuildByID(guildID, false)
if guild == nil {
return database.GuildBridgeNothing
}
return guild.BridgingMode
}
type ChannelSlice []*discordgo.Channel
func (s ChannelSlice) Len() int {
return len(s)
}
func (s ChannelSlice) Less(i, j int) bool {
if s[i].Position != 0 || s[j].Position != 0 {
return s[i].Position < s[j].Position
}
return compareMessageIDs(s[i].LastMessageID, s[j].LastMessageID) == 1
}
func (s ChannelSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (user *User) readyHandler(r *discordgo.Ready) {
user.log.Debug().Msg("Discord connection ready")
user.bridgeStateLock.Lock()
user.wasLoggedOut = false
user.bridgeStateLock.Unlock()
if user.DiscordID != r.User.ID {
user.bridge.usersLock.Lock()
user.DiscordID = r.User.ID
if previousUser, ok := user.bridge.usersByID[user.DiscordID]; ok && previousUser != user {
user.log.Warn().
Str("previous_user_id", previousUser.MXID.String()).
Msg("Another user is logged in with same Discord ID, logging them out")
// TODO send notice?
previousUser.Logout(true)
}
user.bridge.usersByID[user.DiscordID] = user
user.bridge.usersLock.Unlock()
user.Update()
}
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling})
user.tryAutomaticDoublePuppeting()
for _, relationship := range r.Relationships {
user.relationships[relationship.ID] = relationship
}
updateTS := time.Now()
portalsInSpace := make(map[string]bool)
for _, guild := range user.GetPortals() {
portalsInSpace[guild.DiscordID] = guild.InSpace
}
for _, guild := range r.Guilds {
user.handleGuild(guild, updateTS, portalsInSpace[guild.ID])
}
// The private channel list doesn't seem to be sorted by default, so sort it by message IDs (highest=newest first)
sort.Sort(ChannelSlice(r.PrivateChannels))
for i, ch := range r.PrivateChannels {
portal := user.GetPortalByMeta(ch)
user.handlePrivateChannel(portal, ch, updateTS, i < user.bridge.Config.Bridge.PrivateChannelCreateLimit, portalsInSpace[portal.Key.ChannelID])
}
user.PrunePortalList(updateTS)
if r.ReadState != nil && r.ReadState.Version > user.ReadStateVersion {
// TODO can we figure out which read states are actually new?
for _, entry := range r.ReadState.Entries {
user.messageAckHandler(&discordgo.MessageAck{
MessageID: string(entry.LastMessageID),
ChannelID: entry.ID,
})
}
user.ReadStateVersion = r.ReadState.Version
user.Update()
}
go user.subscribeGuilds(2 * time.Second)
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
func (user *User) subscribeGuilds(delay time.Duration) {
if !user.Session.IsUser {
return
}
for _, guildMeta := range user.Session.State.Guilds {
guild := user.bridge.GetGuildByID(guildMeta.ID, false)
if guild != nil && guild.MXID != "" {
user.log.Debug().Str("guild_id", guild.ID).Msg("Subscribing to guild")
dat := discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
}
err := user.Session.SubscribeGuild(dat)
if err != nil {
user.log.Warn().Err(err).Str("guild_id", guild.ID).Msg("Failed to subscribe to guild")
}
time.Sleep(delay)
}
}
}
func (user *User) resumeHandler(_ *discordgo.Resumed) {
user.log.Debug().Msg("Discord connection resumed")
user.subscribeGuilds(0 * time.Second)
}
func (user *User) addPrivateChannelToSpace(portal *Portal) bool {
if portal.MXID == "" {
return false
}
_, err := user.bridge.Bot.SendStateEvent(user.GetDMSpaceRoom(), event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
Via: []string{user.bridge.AS.HomeserverDomain},
})
if err != nil {
user.log.Error().Err(err).
Str("room_id", portal.MXID.String()).
Msg("Failed to add DMM room to user DM space")
return false
} else {
return true
}
}
func (user *User) relationshipAddHandler(r *discordgo.RelationshipAdd) {
user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship added")
user.relationships[r.ID] = r.Relationship
user.handleRelationshipChange(r.ID, r.Nickname)
}
func (user *User) relationshipUpdateHandler(r *discordgo.RelationshipUpdate) {
user.log.Debug().Interface("relationship", r.Relationship).Msg("Relationship update")
user.relationships[r.ID] = r.Relationship
user.handleRelationshipChange(r.ID, r.Nickname)
}
func (user *User) relationshipRemoveHandler(r *discordgo.RelationshipRemove) {
user.log.Debug().Str("other_user_id", r.ID).Msg("Relationship removed")
delete(user.relationships, r.ID)
user.handleRelationshipChange(r.ID, "")
}
func (user *User) handleRelationshipChange(userID, nickname string) {
puppet := user.bridge.GetPuppetByID(userID)
portal := user.FindPrivateChatWith(userID)
if portal == nil || puppet == nil {
return
}
updated := portal.FriendNick == (nickname != "")
portal.FriendNick = nickname != ""
if nickname != "" {
updated = portal.UpdateNameDirect(nickname, true)
} else if portal.Name != puppet.Name {
if portal.shouldSetDMRoomMetadata() {
updated = portal.UpdateNameDirect(puppet.Name, false)
} else if portal.NameSet {
_, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateRoomName, "", map[string]any{})
if err != nil {
portal.log.Warn().Err(err).Msg("Failed to clear room name after friend nickname was removed")
} else {
portal.log.Debug().Msg("Cleared room name after friend nickname was removed")
portal.NameSet = false
portal.Update()
updated = true
}
}
}
if !updated {
portal.Update()
}
}
func (user *User) handlePrivateChannel(portal *Portal, meta *discordgo.Channel, timestamp time.Time, create, isInSpace bool) {
if create && portal.MXID == "" {
err := portal.CreateMatrixRoom(user, meta)
if err != nil {
user.log.Error().Err(err).
Str("channel_id", portal.Key.ChannelID).
Msg("Failed to create portal for private channel in create handler")
}
} else {
portal.UpdateInfo(user, meta)
portal.ForwardBackfillMissed(user, meta.LastMessageID, nil)
}
user.MarkInPortal(database.UserPortal{
DiscordID: portal.Key.ChannelID,
Type: database.UserPortalTypeDM,
Timestamp: timestamp,
InSpace: isInSpace || user.addPrivateChannelToSpace(portal),
})
}
func (user *User) addGuildToSpace(guild *Guild, isInSpace bool, timestamp time.Time) bool {
if len(guild.MXID) > 0 && !isInSpace {
_, err := user.bridge.Bot.SendStateEvent(user.GetSpaceRoom(), event.StateSpaceChild, guild.MXID.String(), &event.SpaceChildEventContent{
Via: []string{user.bridge.AS.HomeserverDomain},
})
if err != nil {
user.log.Error().Err(err).
Str("guild_space_id", guild.MXID.String()).
Msg("Failed to add guild space to user space")
} else {
isInSpace = true
}
}
user.MarkInPortal(database.UserPortal{
DiscordID: guild.ID,
Type: database.UserPortalTypeGuild,
Timestamp: timestamp,
InSpace: isInSpace,
})
return isInSpace
}
func (user *User) discordRoleToDB(guildID string, role *discordgo.Role, dbRole *database.Role, txn dbutil.Execable) bool {
var changed bool
if dbRole == nil {
dbRole = user.bridge.DB.Role.New()
dbRole.ID = role.ID
dbRole.GuildID = guildID
changed = true
} else {
changed = dbRole.Name != role.Name ||
dbRole.Icon != role.Icon ||
dbRole.Mentionable != role.Mentionable ||
dbRole.Managed != role.Managed ||
dbRole.Hoist != role.Hoist ||
dbRole.Color != role.Color ||
dbRole.Position != role.Position ||
dbRole.Permissions != role.Permissions
}
dbRole.Role = *role
if changed {
dbRole.Upsert(txn)
}
return changed
}
func (user *User) handleGuildRoles(guildID string, newRoles []*discordgo.Role) {
existingRoles := user.bridge.DB.Role.GetAll(guildID)
existingRoleMap := make(map[string]*database.Role, len(existingRoles))
for _, role := range existingRoles {
existingRoleMap[role.ID] = role
}
txn, err := user.bridge.DB.Begin()
if err != nil {
user.log.Error().Err(err).Msg("Failed to start transaction for guild role sync")
panic(err)
}
for _, role := range newRoles {
user.discordRoleToDB(guildID, role, existingRoleMap[role.ID], txn)
delete(existingRoleMap, role.ID)
}
for _, removeRole := range existingRoleMap {
removeRole.Delete(txn)
}
err = txn.Commit()
if err != nil {
user.log.Error().Err(err).Msg("Failed to commit guild role sync transaction")
rollbackErr := txn.Rollback()
if rollbackErr != nil {
user.log.Error().Err(rollbackErr).Msg("Failed to rollback errored guild role sync transaction")
}
panic(err)
}
}
func (user *User) handleGuild(meta *discordgo.Guild, timestamp time.Time, isInSpace bool) {
guild := user.bridge.GetGuildByID(meta.ID, true)
guild.UpdateInfo(user, meta)
if len(meta.Channels) > 0 {
for _, ch := range meta.Channels {
if !user.channelIsBridgeable(ch) {
continue
}
portal := user.GetPortalByMeta(ch)
if guild.BridgingMode >= database.GuildBridgeEverything && portal.MXID == "" {
err := portal.CreateMatrixRoom(user, ch)
if err != nil {
user.log.Error().Err(err).
Str("guild_id", guild.ID).
Str("channel_id", ch.ID).
Msg("Failed to create portal for guild channel in guild handler")
}
} else {
portal.UpdateInfo(user, ch)
if user.bridge.Config.Bridge.Backfill.MaxGuildMembers < 0 || meta.MemberCount < user.bridge.Config.Bridge.Backfill.MaxGuildMembers {
portal.ForwardBackfillMissed(user, ch.LastMessageID, nil)
}
}
}
}
if len(meta.Roles) > 0 {
user.handleGuildRoles(meta.ID, meta.Roles)
}
user.addGuildToSpace(guild, isInSpace, timestamp)
}
func (user *User) connectedHandler(_ *discordgo.Connect) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
user.log.Debug().Msg("Connected to Discord")
if user.wasDisconnected {
user.wasDisconnected = false
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
}
func (user *User) disconnectedHandler(_ *discordgo.Disconnect) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
if user.wasLoggedOut {
user.log.Debug().Msg("Disconnected from Discord (not updating bridge state as user was just logged out)")
return
}
user.log.Debug().Msg("Disconnected from Discord")
user.wasDisconnected = true
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "dc-transient-disconnect", Message: "Temporarily disconnected from Discord, trying to reconnect"})
}
func (user *User) invalidAuthHandler(_ *discordgo.InvalidAuth) {
user.bridgeStateLock.Lock()
defer user.bridgeStateLock.Unlock()
user.log.Info().Msg("Got logged out from Discord due to invalid token")
user.wasLoggedOut = true
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-websocket-disconnect-4004", Message: "Discord access token is no longer valid, please log in again"})
go user.Logout(false)
}
func (user *User) handlePossible40002(err error) bool {
var restErr *discordgo.RESTError
if !errors.As(err, &restErr) || restErr.Message == nil || restErr.Message.Code != discordgo.ErrCodeActionRequiredVerifiedAccount {
return false
}
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "dc-http-40002", Message: restErr.Message.Message})
return true
}
func (user *User) guildCreateHandler(g *discordgo.GuildCreate) {
user.log.Info().
Str("guild_id", g.ID).
Str("name", g.Name).
Bool("unavailable", g.Unavailable).
Msg("Got guild create event")
user.handleGuild(g.Guild, time.Now(), false)
}
func (user *User) guildDeleteHandler(g *discordgo.GuildDelete) {
if g.Unavailable {
user.log.Info().Str("guild_id", g.ID).Msg("Ignoring guild delete event with unavailable flag")
return
}
user.log.Info().Str("guild_id", g.ID).Msg("Got guild delete event")
user.MarkNotInPortal(g.ID)
guild := user.bridge.GetGuildByID(g.ID, false)
if guild == nil || guild.MXID == "" {
return
}
if user.bridge.Config.Bridge.DeleteGuildOnLeave && !user.PortalHasOtherUsers(g.ID) {
user.log.Debug().Str("guild_id", g.ID).Msg("No other users in guild, cleaning up all portals")
err := user.unbridgeGuild(g.ID)
if err != nil {
user.log.Warn().Err(err).Msg("Failed to unbridge guild that was deleted")
}
}
}
func (user *User) guildUpdateHandler(g *discordgo.GuildUpdate) {
user.log.Debug().Str("guild_id", g.ID).Msg("Got guild update event")
user.handleGuild(g.Guild, time.Now(), user.IsInSpace(g.ID))
}
func (user *User) threadListSyncHandler(t *discordgo.ThreadListSync) {
for _, meta := range t.Threads {
log := user.log.With().
Str("action", "thread list sync").
Str("guild_id", t.GuildID).
Str("parent_id", meta.ParentID).
Str("thread_id", meta.ID).
Logger()
ctx := log.WithContext(context.Background())
thread := user.bridge.GetThreadByID(meta.ID, nil)
if thread == nil {
msg := user.bridge.DB.Message.GetByDiscordID(database.NewPortalKey(meta.ParentID, ""), meta.ID)
if len(msg) == 0 {
log.Debug().Msg("Found unknown thread in thread list sync and don't have message")
} else {
log.Debug().Msg("Found unknown thread in thread list sync for existing message, creating thread")
user.bridge.threadFound(ctx, user, msg[0], meta.ID, meta)
}
} else {
thread.Parent.ForwardBackfillMissed(user, meta.LastMessageID, thread)
}
}
}
func (user *User) channelCreateHandler(c *discordgo.ChannelCreate) {
if user.getGuildBridgingMode(c.GuildID) < database.GuildBridgeEverything {
user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Ignoring channel create event in unbridged guild")
return
}
user.log.Info().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel create event")
portal := user.GetPortalByMeta(c.Channel)
if portal.MXID != "" {
return
}
if c.GuildID == "" {
user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String()))
} else if user.channelIsBridgeable(c.Channel) {
err := portal.CreateMatrixRoom(user, c.Channel)
if err != nil {
user.log.Error().Err(err).
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Error creating Matrix room after channel create event")
}
} else {
user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel create event, but it's not bridgeable, ignoring")
}
}
func (user *User) channelDeleteHandler(c *discordgo.ChannelDelete) {
portal := user.GetExistingPortalByID(c.ID)
if portal == nil {
user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Ignoring channel delete event of unknown channel")
return
}
user.log.Info().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Got channel delete event, cleaning up portal")
portal.Delete()
portal.cleanup(!user.bridge.Config.Bridge.DeletePortalOnChannelDelete)
if c.GuildID == "" {
user.MarkNotInPortal(portal.Key.ChannelID)
}
user.log.Debug().
Str("guild_id", c.GuildID).Str("channel_id", c.ID).
Msg("Completed cleaning up channel")
}
func (user *User) channelUpdateHandler(c *discordgo.ChannelUpdate) {
portal := user.GetPortalByMeta(c.Channel)
if c.GuildID == "" {
user.handlePrivateChannel(portal, c.Channel, time.Now(), true, user.IsInSpace(portal.Key.String()))
} else if user.channelIsBridgeable(c.Channel) {
portal.UpdateInfo(user, c.Channel)
}
}
func (user *User) channelRecipientAdd(c *discordgo.ChannelRecipientAdd) {
portal := user.GetExistingPortalByID(c.ChannelID)
if portal != nil {
portal.syncParticipant(user, c.User, false)
}
}
func (user *User) channelRecipientRemove(c *discordgo.ChannelRecipientRemove) {
portal := user.GetExistingPortalByID(c.ChannelID)
if portal != nil {
portal.syncParticipant(user, c.User, true)
}
}
func (user *User) findPortal(channelID string) (*Portal, *Thread) {
portal := user.GetExistingPortalByID(channelID)
if portal != nil {
return portal, nil
}
thread := user.bridge.GetThreadByID(channelID, nil)
if thread != nil && thread.Parent != nil {
return thread.Parent, thread
}
if !user.Session.IsUser {
channel, _ := user.Session.State.Channel(channelID)
if channel == nil {
user.log.Debug().Str("channel_id", channelID).Msg("Fetching info of unknown channel to handle message")
var err error
channel, err = user.Session.Channel(channelID)
if err != nil {
user.log.Warn().Err(err).Str("channel_id", channelID).Msg("Failed to get info of unknown channel")
} else {
user.log.Debug().Str("channel_id", channelID).Msg("Got info for channel to handle message")
_ = user.Session.State.ChannelAdd(channel)
}
}
if channel != nil && user.channelIsBridgeable(channel) {
user.log.Debug().Str("channel_id", channelID).Msg("Creating portal and updating info to handle message")
portal = user.GetPortalByMeta(channel)
if channel.GuildID == "" {
user.handlePrivateChannel(portal, channel, time.Now(), false, false)
} else {
user.log.Warn().
Str("channel_id", channel.ID).Str("guild_id", channel.GuildID).
Msg("Unexpected unknown guild channel")
}
return portal, nil
}
}
return nil, nil
}
func (user *User) pushPortalMessage(msg interface{}, typeName, channelID, guildID string) {
if user.getGuildBridgingMode(guildID) <= database.GuildBridgeNothing {
// If guild bridging mode is nothing, don't even check if the portal exists
return
}
portal, thread := user.findPortal(channelID)
if portal == nil {
user.log.Debug().
Str("discord_event", typeName).
Str("guild_id", guildID).
Str("channel_id", channelID).
Msg("Dropping event in unknown channel")
return
}
if mode := user.getGuildBridgingMode(portal.GuildID); mode <= database.GuildBridgeNothing || (portal.MXID == "" && mode <= database.GuildBridgeIfPortalExists) {
return
}
wrappedMsg := portalDiscordMessage{
msg: msg,
user: user,
thread: thread,
}
select {
case portal.discordMessages <- wrappedMsg:
default:
user.log.Warn().
Str("discord_event", typeName).
Str("guild_id", guildID).
Str("channel_id", channelID).
Msg("Portal message buffer is full")
portal.discordMessages <- wrappedMsg
}
}
type CustomReadReceipt struct {
Timestamp int64 `json:"ts,omitempty"`
DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"`
}
type CustomReadMarkers struct {
mautrix.ReqSetReadMarkers
ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"`
FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"`
}
func (user *User) makeReadMarkerContent(eventID id.EventID) *CustomReadMarkers {
var extra CustomReadReceipt
extra.DoublePuppetSource = user.bridge.Name
return &CustomReadMarkers{
ReqSetReadMarkers: mautrix.ReqSetReadMarkers{
Read: eventID,
FullyRead: eventID,
},
ReadExtra: extra,
FullyReadExtra: extra,
}
}
func (user *User) messageAckHandler(m *discordgo.MessageAck) {
portal := user.GetExistingPortalByID(m.ChannelID)
if portal == nil || portal.MXID == "" {
return
}
dp := user.GetIDoublePuppet()
if dp == nil {
return
}
msg := user.bridge.DB.Message.GetLastByDiscordID(portal.Key, m.MessageID)
if msg == nil {
user.log.Debug().
Str("channel_id", m.ChannelID).Str("message_id", m.MessageID).
Msg("Dropping message ack event for unknown message")
return
}
err := dp.CustomIntent().SetReadMarkers(portal.MXID, user.makeReadMarkerContent(msg.MXID))
if err != nil {
user.log.Error().Err(err).
Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
Msg("Failed to mark event as read")
} else {
user.log.Debug().
Str("event_id", msg.MXID.String()).Str("message_id", msg.DiscordID).
Msg("Marked event as read after Discord message ack")
if user.ReadStateVersion < m.Version {
user.ReadStateVersion = m.Version
// TODO maybe don't update every time?
user.Update()
}
}
}
func (user *User) typingStartHandler(t *discordgo.TypingStart) {
if t.UserID == user.DiscordID {
return
}
portal := user.GetExistingPortalByID(t.ChannelID)
if portal == nil || portal.MXID == "" {
return
}
targetUser := user.bridge.GetCachedUserByID(t.UserID)
if targetUser != nil {
return
}
portal.handleDiscordTyping(t)
}
func (user *User) interactionSuccessHandler(s *discordgo.InteractionSuccess) {
user.pendingInteractionsLock.Lock()
defer user.pendingInteractionsLock.Unlock()
ce, ok := user.pendingInteractions[s.Nonce]
if !ok {
user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for unknown interaction")
} else {
user.log.Debug().Str("nonce", s.Nonce).Str("id", s.ID).Msg("Got interaction success for pending interaction")
ce.React("✅")
delete(user.pendingInteractions, s.Nonce)
}
}
func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect, ignoreCache bool) bool {
if roomID == "" {
return false
}
if intent == nil {
intent = user.bridge.Bot
}
if !ignoreCache && intent.StateStore.IsInvited(roomID, user.MXID) {
return true
}
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.Error().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room")
} else {
ret = true
}
if customPuppet != nil && customPuppet.CustomIntent() != nil {
err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
if err != nil {
user.log.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room")
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.FindPrivateChatsOf(user.DiscordID)
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.Debug().Msg("Updating m.direct list on homeserver")
var err error
if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux {
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.Warn().Err(err).Msg("Failed to get m.direct event to update it")
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.Warn().Err(err).Msg("Failed to update m.direct event")
}
}
func (user *User) bridgeGuild(guildID string, everything bool) error {
guild := user.bridge.GetGuildByID(guildID, false)
if guild == nil {
return errors.New("guild not found")
}
meta, _ := user.Session.State.Guild(guildID)
err := guild.CreateMatrixRoom(user, meta)
if err != nil {
return err
}
log := user.log.With().Str("guild_id", guild.ID).Logger()
user.addGuildToSpace(guild, false, time.Now())
for _, ch := range meta.Channels {
portal := user.GetPortalByMeta(ch)
if (everything && user.channelIsBridgeable(ch)) || ch.Type == discordgo.ChannelTypeGuildCategory {
err = portal.CreateMatrixRoom(user, ch)
if err != nil {
log.Error().Err(err).Str("channel_id", ch.ID).
Msg("Failed to create room for guild channel while bridging guild")
}
}
}
if everything {
guild.BridgingMode = database.GuildBridgeEverything
} else {
guild.BridgingMode = database.GuildBridgeCreateOnMessage
}
guild.Update()
if user.Session.IsUser {
log.Debug().Msg("Subscribing to guild after bridging")
err = user.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild")
}
}
return nil
}
func (user *User) unbridgeGuild(guildID string) error {
if user.PermissionLevel < bridgeconfig.PermissionLevelAdmin && user.PortalHasOtherUsers(guildID) {
return errors.New("only bridge admins can unbridge guilds with other users")
}
guild := user.bridge.GetGuildByID(guildID, false)
if guild == nil {
return errors.New("guild not found")
}
guild.roomCreateLock.Lock()
defer guild.roomCreateLock.Unlock()
if guild.BridgingMode == database.GuildBridgeNothing && guild.MXID == "" {
return errors.New("that guild is not bridged")
}
guild.BridgingMode = database.GuildBridgeNothing
guild.Update()
for _, portal := range user.bridge.GetAllPortalsInGuild(guild.ID) {
portal.cleanup(false)
portal.RemoveMXID()
}
guild.cleanup()
guild.RemoveMXID()
return nil
}