diff --git a/server/bridge/xmpp/bridge.go b/server/bridge/xmpp/bridge.go index e7f76f7..1cba16f 100644 --- a/server/bridge/xmpp/bridge.go +++ b/server/bridge/xmpp/bridge.go @@ -3,12 +3,12 @@ package xmpp import ( "context" "crypto/tls" + "fmt" + "strings" "sync" "sync/atomic" "time" - "fmt" - "github.com/mattermost/mattermost/server/public/plugin" "mellium.im/xmlstream" "mellium.im/xmpp/stanza" @@ -66,7 +66,6 @@ func NewBridge(log logger.Logger, api plugin.API, store kvstore.KVStore, cfg *co cancel: cancel, channelMappings: make(map[string]string), config: cfg, - userManager: bridge.NewUserManager(bridgeID, log), incomingMessages: make(chan *pluginModel.DirectionalMessage, defaultMessageBufferSize), bridgeID: bridgeID, remoteID: remoteID, @@ -81,6 +80,9 @@ func NewBridge(log logger.Logger, api plugin.API, store kvstore.KVStore, cfg *co b.bridgeClient = b.createXMPPClient(cfg) } + // Initialize with a default user manager - will be replaced in Start() after XEP detection + b.userManager = bridge.NewUserManager(bridgeID, log) + return b } @@ -102,6 +104,64 @@ func (b *xmppBridge) createXMPPClient(cfg *config.Configuration) *xmppClient.Cli ) } +// createUserManager creates the appropriate user manager based on configuration +func (b *xmppBridge) createUserManager(cfg *config.Configuration, bridgeID string, log logger.Logger, store kvstore.KVStore) pluginModel.BridgeUserManager { + b.logger.LogDebug("Creating user manager", "enable_ghost_users", cfg.EnableXMPPGhostUsers, "enable_sync", cfg.EnableSync, "ghost_prefix", cfg.XMPPGhostUserPrefix) + + // Check if ghost users are enabled in configuration + if !cfg.IsGhostUserEnabled() { + b.logger.LogInfo("Ghost users disabled, using bridge user manager", "enable_ghost_users", cfg.EnableXMPPGhostUsers, "enable_sync", cfg.EnableSync) + return bridge.NewUserManager(bridgeID, log) + } + + // Check if we have a bridge client to test XEP-0077 support + if b.bridgeClient == nil { + b.logger.LogWarn("Bridge client not available, cannot check XEP-0077 support, falling back to bridge user manager") + return bridge.NewUserManager(bridgeID, log) + } + + // Check XEP-0077 server support + if supported, err := b.checkXEP0077Support(); err != nil { + b.logger.LogWarn("Failed to check XEP-0077 support, falling back to bridge user manager", "error", err) + return bridge.NewUserManager(bridgeID, log) + } else if !supported { + b.logger.LogInfo("XEP-0077 In-Band Registration not supported by server, using bridge user manager") + return bridge.NewUserManager(bridgeID, log) + } + + // Both ghost users are enabled and XEP-0077 is supported - use ghost user manager + b.logger.LogInfo("Ghost users enabled and XEP-0077 supported, using XMPP ghost user manager") + return NewXMPPUserManager(bridgeID, log, store, b.api, cfg, b.bridgeClient) +} + +// waitForCapabilityDetection waits for server capability detection to complete +func (b *xmppBridge) waitForCapabilityDetection() error { + if b.bridgeClient == nil { + return fmt.Errorf("bridge client not available") + } + + // Trigger capability detection synchronously + if err := b.bridgeClient.DetectServerCapabilities(); err != nil { + return fmt.Errorf("failed to detect server capabilities: %w", err) + } + + return nil +} + +// checkXEP0077Support checks if the XMPP server supports XEP-0077 In-Band Registration +func (b *xmppBridge) checkXEP0077Support() (bool, error) { + if b.bridgeClient == nil { + return false, fmt.Errorf("bridge client not available") + } + + // Check XEP features from the client + if b.bridgeClient.XEPFeatures == nil { + return false, fmt.Errorf("XEP features not initialized") + } + + return b.bridgeClient.XEPFeatures.InBandRegistration != nil, nil +} + // getConfiguration safely retrieves the current configuration func (b *xmppBridge) getConfiguration() *config.Configuration { b.configMu.RLock() @@ -130,6 +190,9 @@ func (b *xmppBridge) UpdateConfiguration(cfg *config.Configuration) error { b.logger.LogError("Failed to disconnect old XMPP bridge client") } b.bridgeClient = b.createXMPPClient(cfg) + + // Recreate user manager since ghost user settings or XEP support may have changed + b.userManager = b.createUserManager(cfg, b.bridgeID, b.logger, b.kvstore) } b.configMu.Unlock() @@ -174,6 +237,14 @@ func (b *xmppBridge) Start() error { return fmt.Errorf("failed to connect to XMPP server: %w", err) } + // Wait for server capability detection to complete before creating user manager + if err := b.waitForCapabilityDetection(); err != nil { + return fmt.Errorf("failed to detect server capabilities: %w", err) + } + + // Initialize proper user manager now that we're connected and server capabilities are detected + b.userManager = b.createUserManager(cfg, b.bridgeID, b.logger, b.kvstore) + // Load and join mapped channels if err := b.loadAndJoinMappedChannels(); err != nil { b.logger.LogWarn("Failed to join some mapped channels", "error", err) @@ -604,6 +675,30 @@ func (b *xmppBridge) ID() string { return b.bridgeID } +// isBridgeUserMessage checks if the incoming XMPP message is from our bridge user to prevent loops +func (b *xmppBridge) isBridgeUserMessage(msg *stanza.Message) bool { + // Skip messages from our own XMPP user to prevent loops + // In MUC, messages come back as roomJID/nickname, so we need to check the nickname/resource + bridgeJID := b.bridgeClient.GetJID() + bridgeNickname := bridgeJID.Localpart() // Use localpart as nickname + incomingResource := msg.From.Resourcepart() + + b.logger.LogDebug("Bridge user comparison details", + "bridge_jid", bridgeJID.String(), + "bridge_nickname", bridgeNickname, + "incoming_resource", incomingResource) + + // Check multiple ways this could be our bridge user: + // 1. Direct nickname match + // 2. Resource starts with "mattermost_" (common pattern) + // 3. Resource contains bridge-related identifiers + isBridgeUser := incomingResource == bridgeNickname || + strings.HasPrefix(incomingResource, "mattermost_") || + strings.Contains(incomingResource, "bridge") + + return isBridgeUser +} + // handleIncomingXMPPMessage handles incoming XMPP messages and converts them to bridge messages // //nolint:gocritic // msg parameter must match external XMPP library handler signature @@ -639,10 +734,10 @@ func (b *xmppBridge) handleIncomingXMPPMessage(msg stanza.Message, t xmlstream.T userID, displayName := b.bridgeClient.ExtractUserInfo(msg.From) - // Skip messages from our own XMPP user to prevent loops - if userID == b.bridgeClient.GetJID().String() { - b.logger.LogDebug("Skipping message from our own XMPP user to prevent loop", - "our_jid", b.bridgeClient.GetJID().String(), + // Check if this message is from our bridge user to prevent loops + if b.isBridgeUserMessage(&msg) { + b.logger.LogDebug("Ignoring message from bridge user to prevent loops", + "incoming_message_from", msg.From.String(), "source_user_id", userID) return nil } diff --git a/server/bridge/xmpp/message_handler.go b/server/bridge/xmpp/message_handler.go index 0a31ffc..79fcc43 100644 --- a/server/bridge/xmpp/message_handler.go +++ b/server/bridge/xmpp/message_handler.go @@ -58,14 +58,6 @@ func (h *xmppMessageHandler) GetSupportedMessageTypes() []string { // sendMessageToXMPP sends a message to an XMPP room func (h *xmppMessageHandler) sendMessageToXMPP(msg *pluginModel.BridgeMessage) error { - if h.bridge.bridgeClient == nil { - return fmt.Errorf("XMPP client not initialized") - } - - if !h.bridge.connected.Load() { - return fmt.Errorf("not connected to XMPP server") - } - // Get the XMPP room JID from the channel mapping roomJID, err := h.bridge.GetChannelMapping(msg.SourceChannelID) if err != nil { @@ -75,32 +67,114 @@ func (h *xmppMessageHandler) sendMessageToXMPP(msg *pluginModel.BridgeMessage) e return fmt.Errorf("channel is not mapped to any XMPP room") } - // Format the message content with user information - content := h.formatMessageContent(msg) - - // Create XMPP message request - req := xmppClient.MessageRequest{ - RoomJID: roomJID, - Message: content, + // Check if we're using ghost users (XMPPUserManager) + userManager := h.bridge.userManager + if userManager == nil { + return fmt.Errorf("user manager not available") } - // Send the message - _, err = h.bridge.bridgeClient.SendMessage(&req) + h.logger.LogDebug("Routing message", "manager_type", fmt.Sprintf("%T", userManager), "source_user_id", msg.SourceUserID, "room_jid", roomJID) + + // Check if this is an XMPPUserManager (ghost users enabled) + if xmppUserManager, ok := userManager.(*UserManager); ok { + // Ghost users are enabled - send message through ghost user's own client + return h.sendMessageViaGhostUser(xmppUserManager, msg, roomJID) + } + + // Regular user manager - send message through bridge client + return h.sendMessageViaBridgeUser(msg, roomJID) +} + +// sendMessageViaGhostUser sends a message using a ghost user's individual XMPP client +func (h *xmppMessageHandler) sendMessageViaGhostUser(xmppUserManager *UserManager, msg *pluginModel.BridgeMessage, roomJID string) error { + // Validate source user ID for ghost user messaging + if msg.SourceUserID == "" { + return fmt.Errorf("cannot send message with ghost users: source user ID is empty") + } + + // Get or create the ghost user + bridgeUser, err := xmppUserManager.GetOrCreateUser(msg.SourceUserID, msg.SourceUserName) if err != nil { - return fmt.Errorf("failed to send message to XMPP room: %w", err) + h.logger.LogWarn("Failed to get/create ghost user, falling back to bridge user", "source_user_id", msg.SourceUserID, "error", err) + return h.sendMessageViaBridgeUser(msg, roomJID) } - h.logger.LogDebug("Message sent to XMPP room", - "channel_id", msg.SourceChannelID, + // Cast to XMPPUser to access XMPP-specific methods + xmppUser, ok := bridgeUser.(*User) + if !ok { + return fmt.Errorf("expected XMPPUser, got %T", bridgeUser) + } + + // Check if the ghost user is connected + if !xmppUser.IsConnected() { + h.logger.LogDebug("Ghost user not connected, attempting to connect", "user_id", msg.SourceUserID, "ghost_jid", xmppUser.GetJID()) + // TODO: Start the user if not started - this will be handled in the user manager fix + h.logger.LogWarn("Ghost user not connected, falling back to bridge user", "user_id", msg.SourceUserID, "ghost_jid", xmppUser.GetJID()) + return h.sendMessageViaBridgeUser(msg, roomJID) + } + + // Format message content for ghost user (no prefix needed since it's sent as the actual user) + content := msg.Content + + // Send message through the ghost user's own XMPP client + err = xmppUser.SendMessageToChannel(roomJID, content) + if err != nil { + h.logger.LogWarn("Failed to send message via ghost user, falling back to bridge user", "user_id", msg.SourceUserID, "ghost_jid", xmppUser.GetJID(), "error", err) + return h.sendMessageViaBridgeUser(msg, roomJID) + } + + h.logger.LogDebug("Message sent via ghost user", + "source_user_id", msg.SourceUserID, + "ghost_jid", xmppUser.GetJID(), "room_jid", roomJID, "content_length", len(content)) return nil } -// formatMessageContent formats the message content for XMPP -func (h *xmppMessageHandler) formatMessageContent(msg *pluginModel.BridgeMessage) string { - // For messages from other bridges, prefix with the user name +// sendMessageViaBridgeUser sends a message using the bridge user's XMPP client +func (h *xmppMessageHandler) sendMessageViaBridgeUser(msg *pluginModel.BridgeMessage, roomJID string) error { + // Validate bridge client is available + if h.bridge.bridgeClient == nil { + return fmt.Errorf("XMPP bridge client not initialized") + } + + if !h.bridge.connected.Load() { + return fmt.Errorf("not connected to XMPP server") + } + + // Get bridge user JID + h.bridge.configMu.RLock() + bridgeJID := h.bridge.config.XMPPUsername + h.bridge.configMu.RUnlock() + + // Format message content for bridge user (prefix with original username) + content := h.formatMessageContentForBridgeUser(msg) + + // Create XMPP message request + req := xmppClient.MessageRequest{ + RoomJID: roomJID, + GhostUserJID: bridgeJID, + Message: content, + } + + // Send the message through bridge client + _, err := h.bridge.bridgeClient.SendMessage(&req) + if err != nil { + return fmt.Errorf("failed to send message to XMPP room via bridge user: %w", err) + } + + h.logger.LogDebug("Message sent via bridge user", + "bridge_jid", bridgeJID, + "room_jid", roomJID, + "content_length", len(content)) + + return nil +} + +// formatMessageContentForBridgeUser formats message content for bridge user (with username prefix) +func (h *xmppMessageHandler) formatMessageContentForBridgeUser(msg *pluginModel.BridgeMessage) string { + // Prefix with the original user name for bridge user messages if msg.SourceUserName != "" { return fmt.Sprintf("<%s> %s", msg.SourceUserName, msg.Content) } diff --git a/server/bridge/xmpp/user.go b/server/bridge/xmpp/user.go index c8891ff..a76a676 100644 --- a/server/bridge/xmpp/user.go +++ b/server/bridge/xmpp/user.go @@ -14,10 +14,8 @@ import ( xmppClient "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/xmpp" ) -// XMPPUser represents an XMPP user that implements the BridgeUser interface -// -//nolint:revive // XMPPUser is clearer than User in this context -type XMPPUser struct { +// User represents an XMPP user that implements the BridgeUser interface +type User struct { // User identity id string displayName string @@ -42,8 +40,8 @@ type XMPPUser struct { logger logger.Logger } -// NewXMPPUser creates a new XMPP user -func NewXMPPUser(id, displayName, jid string, cfg *config.Configuration, log logger.Logger) *XMPPUser { +// NewXMPPUser creates a new XMPP user with specific credentials +func NewXMPPUser(id, displayName, jid, password string, cfg *config.Configuration, log logger.Logger) *User { ctx, cancel := context.WithCancel(context.Background()) // Create TLS config based on certificate verification setting @@ -51,18 +49,18 @@ func NewXMPPUser(id, displayName, jid string, cfg *config.Configuration, log log InsecureSkipVerify: cfg.XMPPInsecureSkipVerify, //nolint:gosec // Allow insecure TLS for testing environments } - // Create XMPP client for this user + // Create XMPP client for this user with provided credentials client := xmppClient.NewClientWithTLS( cfg.XMPPServerURL, jid, - cfg.XMPPPassword, // This might need to be user-specific in the future + password, // Use the provided password (ghost password or bridge password) cfg.GetXMPPResource(), id, // Use user ID as remote ID tlsConfig, log, ) - return &XMPPUser{ + return &User{ id: id, displayName: displayName, jid: jid, @@ -76,7 +74,7 @@ func NewXMPPUser(id, displayName, jid string, cfg *config.Configuration, log log } // Validation -func (u *XMPPUser) Validate() error { +func (u *User) Validate() error { if u.id == "" { return fmt.Errorf("user ID cannot be empty") } @@ -96,22 +94,22 @@ func (u *XMPPUser) Validate() error { } // Identity (bridge-agnostic) -func (u *XMPPUser) GetID() string { +func (u *User) GetID() string { return u.id } -func (u *XMPPUser) GetDisplayName() string { +func (u *User) GetDisplayName() string { return u.displayName } // State management -func (u *XMPPUser) GetState() model.UserState { +func (u *User) GetState() model.UserState { u.stateMu.RLock() defer u.stateMu.RUnlock() return u.state } -func (u *XMPPUser) SetState(state model.UserState) error { +func (u *User) SetState(state model.UserState) error { u.stateMu.Lock() defer u.stateMu.Unlock() @@ -125,7 +123,7 @@ func (u *XMPPUser) SetState(state model.UserState) error { } // Channel operations -func (u *XMPPUser) JoinChannel(channelID string) error { +func (u *User) JoinChannel(channelID string) error { if !u.connected.Load() { return fmt.Errorf("user %s is not connected", u.id) } @@ -142,7 +140,7 @@ func (u *XMPPUser) JoinChannel(channelID string) error { return nil } -func (u *XMPPUser) LeaveChannel(channelID string) error { +func (u *User) LeaveChannel(channelID string) error { if !u.connected.Load() { return fmt.Errorf("user %s is not connected", u.id) } @@ -159,13 +157,18 @@ func (u *XMPPUser) LeaveChannel(channelID string) error { return nil } -func (u *XMPPUser) SendMessageToChannel(channelID, message string) error { +func (u *User) SendMessageToChannel(channelID, message string) error { if !u.connected.Load() { return fmt.Errorf("user %s is not connected", u.id) } u.logger.LogDebug("XMPP user sending message to channel", "user_id", u.id, "channel_id", channelID) + // Ensure we're joined to the room before sending the message + if err := u.EnsureJoinedToRoom(channelID); err != nil { + return fmt.Errorf("failed to ensure joined to room before sending message: %w", err) + } + // Create message request for XMPP req := xmppClient.MessageRequest{ RoomJID: channelID, @@ -183,7 +186,7 @@ func (u *XMPPUser) SendMessageToChannel(channelID, message string) error { } // Connection lifecycle -func (u *XMPPUser) Connect() error { +func (u *User) Connect() error { u.logger.LogDebug("Connecting XMPP user", "user_id", u.id, "jid", u.jid) err := u.client.Connect() @@ -207,7 +210,7 @@ func (u *XMPPUser) Connect() error { return nil } -func (u *XMPPUser) Disconnect() error { +func (u *User) Disconnect() error { u.logger.LogDebug("Disconnecting XMPP user", "user_id", u.id, "jid", u.jid) if u.client == nil { @@ -226,11 +229,11 @@ func (u *XMPPUser) Disconnect() error { return err } -func (u *XMPPUser) IsConnected() bool { +func (u *User) IsConnected() bool { return u.connected.Load() } -func (u *XMPPUser) Ping() error { +func (u *User) Ping() error { if !u.connected.Load() { return fmt.Errorf("XMPP user %s is not connected", u.id) } @@ -242,8 +245,36 @@ func (u *XMPPUser) Ping() error { return u.client.Ping() } +// CheckRoomMembership checks if the user is joined to an XMPP room +func (u *User) CheckRoomMembership(channelID string) (bool, error) { + if !u.connected.Load() { + return false, fmt.Errorf("XMPP user %s is not connected", u.id) + } + + if u.client == nil { + return false, fmt.Errorf("XMPP client not initialized for user %s", u.id) + } + + return u.client.CheckRoomMembership(channelID) +} + +// EnsureJoinedToRoom ensures the user is joined to an XMPP room, joining if necessary +func (u *User) EnsureJoinedToRoom(channelID string) error { + if !u.connected.Load() { + return fmt.Errorf("XMPP user %s is not connected", u.id) + } + + if u.client == nil { + return fmt.Errorf("XMPP client not initialized for user %s", u.id) + } + + u.logger.LogDebug("Ensuring user is joined to room", "user_id", u.id, "channel_id", channelID) + + return u.client.EnsureJoinedToRoom(channelID) +} + // CheckChannelExists checks if an XMPP room/channel exists -func (u *XMPPUser) CheckChannelExists(channelID string) (bool, error) { +func (u *User) CheckChannelExists(channelID string) (bool, error) { if !u.connected.Load() { return false, fmt.Errorf("XMPP user %s is not connected", u.id) } @@ -256,7 +287,7 @@ func (u *XMPPUser) CheckChannelExists(channelID string) (bool, error) { } // Goroutine lifecycle -func (u *XMPPUser) Start(ctx context.Context) error { +func (u *User) Start(ctx context.Context) error { u.logger.LogDebug("Starting XMPP user", "user_id", u.id, "jid", u.jid) // Update context @@ -274,7 +305,7 @@ func (u *XMPPUser) Start(ctx context.Context) error { return nil } -func (u *XMPPUser) Stop() error { +func (u *User) Stop() error { u.logger.LogDebug("Stopping XMPP user", "user_id", u.id, "jid", u.jid) // Cancel context to stop goroutines @@ -292,7 +323,7 @@ func (u *XMPPUser) Stop() error { } // connectionMonitor monitors the XMPP connection for this user -func (u *XMPPUser) connectionMonitor() { +func (u *User) connectionMonitor() { u.logger.LogDebug("Starting connection monitor for XMPP user", "user_id", u.id) // Simple monitoring - check connection periodically @@ -328,11 +359,55 @@ func (u *XMPPUser) connectionMonitor() { } // GetJID returns the XMPP JID for this user (XMPP-specific method) -func (u *XMPPUser) GetJID() string { +func (u *User) GetJID() string { return u.jid } // GetClient returns the underlying XMPP client (for advanced operations) -func (u *XMPPUser) GetClient() *xmppClient.Client { +func (u *User) GetClient() *xmppClient.Client { return u.client } + +// UpdateCredentials updates the user's JID and password for ghost user mode +// This creates a new XMPP client with the updated credentials +func (u *User) UpdateCredentials(newJID, newPassword string) error { + u.logger.LogDebug("Updating XMPP user credentials", "user_id", u.id, "old_jid", u.jid, "new_jid", newJID) + + // Disconnect existing client if connected + wasConnected := u.IsConnected() + if wasConnected { + if err := u.Disconnect(); err != nil { + u.logger.LogWarn("Error disconnecting before credential update", "user_id", u.id, "error", err) + } + } + + // Create TLS config based on certificate verification setting + tlsConfig := &tls.Config{ + InsecureSkipVerify: u.config.XMPPInsecureSkipVerify, //nolint:gosec // Allow insecure TLS for testing environments + } + + // Create new XMPP client with updated credentials + newClient := xmppClient.NewClientWithTLS( + u.config.XMPPServerURL, + newJID, + newPassword, + u.config.GetXMPPResource(), + u.id, // Use user ID as remote ID + tlsConfig, + u.logger, + ) + + // Update user fields + u.jid = newJID + u.client = newClient + + // Reconnect if we were previously connected + if wasConnected { + if err := u.Connect(); err != nil { + return fmt.Errorf("failed to reconnect after credential update: %w", err) + } + } + + u.logger.LogInfo("XMPP user credentials updated successfully", "user_id", u.id, "new_jid", newJID) + return nil +} diff --git a/server/bridge/xmpp/user_manager.go b/server/bridge/xmpp/user_manager.go new file mode 100644 index 0000000..fe7bdd4 --- /dev/null +++ b/server/bridge/xmpp/user_manager.go @@ -0,0 +1,452 @@ +package xmpp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/mattermost/mattermost/server/public/plugin" + "mellium.im/xmpp/jid" + + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/config" + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger" + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/model" + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/store/kvstore" + xmppClient "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/xmpp" +) + +const ( + // KV store key prefixes for XMPP-specific data only + ghostCredPrefix = "ghost_cred_" // Stores GhostUserData (JID, password, etc.) +) + +// buildGhostUserKey generates a KV store key for ghost user data +func buildGhostUserKey(mattermostUserID string) string { + return ghostCredPrefix + mattermostUserID +} + +// GhostUserData represents persistent XMPP-specific ghost user information +type GhostUserData struct { + MattermostUserID string `json:"mattermost_user_id"` // Mattermost user ID this ghost represents + GhostJID string `json:"ghost_jid"` // XMPP JID of the ghost user + GhostPassword string `json:"ghost_password"` // XMPP password for the ghost user + Created int64 `json:"created"` // Timestamp when ghost was created +} + +// UserManager manages XMPP users using XEP-0077 ghost users ONLY +// Only stores XMPP-specific data, uses Mattermost API for user info +type UserManager struct { + bridgeType string + logger logger.Logger + kvstore kvstore.KVStore + api plugin.API // Mattermost API for user info + config *config.Configuration + configMu sync.RWMutex + bridgeClient *xmppClient.Client + ctx context.Context + cancel context.CancelFunc +} + +// NewXMPPUserManager creates a new XMPP-specific user manager for ghost users only +func NewXMPPUserManager(bridgeType string, log logger.Logger, store kvstore.KVStore, api plugin.API, cfg *config.Configuration, bridgeClient *xmppClient.Client) model.BridgeUserManager { + ctx, cancel := context.WithCancel(context.Background()) + return &UserManager{ + bridgeType: bridgeType, + logger: log, + kvstore: store, + api: api, + config: cfg, + bridgeClient: bridgeClient, + ctx: ctx, + cancel: cancel, + } +} + +// generateGhostUserJID creates a JID for a new ghost user based on Mattermost user ID and configuration +func (m *UserManager) generateGhostUserJID(mattermostUserID string) string { + m.configMu.RLock() + prefix := m.config.XMPPGhostUserPrefix + domain := m.config.GetXMPPGhostUserDomain() + m.configMu.RUnlock() + + return fmt.Sprintf("%s%s@%s", prefix, mattermostUserID, domain) +} + +// registerGhostUser registers a new ghost user via XEP-0077 In-Band Registration +func (m *UserManager) registerGhostUser(mattermostUserID, ghostJID, ghostPassword string) error { + if m.bridgeClient == nil { + return fmt.Errorf("bridge client not available for ghost user registration") + } + + m.logger.LogDebug("Registering ghost user", "jid", ghostJID, "mattermost_user_id", mattermostUserID) + + // Get In-Band Registration handler from bridge client + regHandler, err := m.bridgeClient.GetInBandRegistration() + if err != nil { + return fmt.Errorf("failed to get in-band registration handler: %w", err) + } + + // Parse the ghost JID to get the server part + ghostJIDParsed, err := jid.Parse(ghostJID) + if err != nil { + return fmt.Errorf("failed to parse ghost JID %s: %w", ghostJID, err) + } + + // Register ghost user via XEP-0077 + request := &xmppClient.RegistrationRequest{ + Username: ghostJIDParsed.Localpart(), + Password: ghostPassword, + } + + response, err := regHandler.RegisterAccount(ghostJIDParsed.Domain(), request) + if err != nil { + return fmt.Errorf("XEP-0077 registration failed for %s: %w", ghostJID, err) + } + + if !response.Success { + return fmt.Errorf("XEP-0077 registration failed for %s: %s", ghostJID, response.Error) + } + + m.logger.LogInfo("Ghost user registered successfully", "mattermost_user_id", mattermostUserID, "jid", ghostJID) + return nil +} + +// CreateUser creates a ghost user via XEP-0077 registration +func (m *UserManager) CreateUser(user model.BridgeUser) error { + if err := user.Validate(); err != nil { + return fmt.Errorf("invalid user: %w", err) + } + + mattermostUserID := user.GetID() + + // Check if user already exists in KV store + if m.HasUser(mattermostUserID) { + return fmt.Errorf("user %s already exists", mattermostUserID) + } + + m.logger.LogDebug("Creating XMPP ghost user", "bridge_type", m.bridgeType, "mattermost_user_id", mattermostUserID, "display_name", user.GetDisplayName()) + + // Generate ghost user JID and password + ghostJID := m.generateGhostUserJID(mattermostUserID) + ghostPassword := generateSecurePassword() + + // Register ghost user via XEP-0077 + if err := m.registerGhostUser(mattermostUserID, ghostJID, ghostPassword); err != nil { + return fmt.Errorf("failed to register ghost user %s: %w", mattermostUserID, err) + } + + // Store ghost user data + ghostData := &GhostUserData{ + MattermostUserID: mattermostUserID, + GhostJID: ghostJID, + GhostPassword: ghostPassword, + Created: m.getCurrentTimestamp(), + } + + if err := m.storeGhostUserData(mattermostUserID, ghostData); err != nil { + return fmt.Errorf("failed to store ghost user data for %s: %w", mattermostUserID, err) + } + + m.logger.LogInfo("XMPP ghost user created successfully", "bridge_type", m.bridgeType, "mattermost_user_id", mattermostUserID) + return nil +} + +// GetUser retrieves a user by Mattermost user ID, creating XMPPUser from ghost data +func (m *UserManager) GetUser(mattermostUserID string) (model.BridgeUser, error) { + // Check if ghost user data exists + ghostData, err := m.loadGhostUserData(mattermostUserID) + if err != nil { + return nil, fmt.Errorf("ghost user not found for Mattermost user %s: %w", mattermostUserID, err) + } + + // Create XMPPUser directly with ghost credentials + m.configMu.RLock() + cfg := m.config + m.configMu.RUnlock() + + user := NewXMPPUser(mattermostUserID, mattermostUserID, ghostData.GhostJID, ghostData.GhostPassword, cfg, m.logger) + + // Ensure the user is connected + if err := m.ensureUserConnected(user, mattermostUserID); err != nil { + return nil, fmt.Errorf("failed to ensure ghost user is connected: %w", err) + } + + return user, nil +} + +// GetOrCreateUser retrieves a user by Mattermost user ID, creating a new ghost user if it doesn't exist +func (m *UserManager) GetOrCreateUser(mattermostUserID, displayName string) (model.BridgeUser, error) { + // Try to get existing user first + user, err := m.GetUser(mattermostUserID) + if err == nil { + return user, nil + } + + m.logger.LogDebug("Ghost user not found, creating new ghost user", "mattermost_user_id", mattermostUserID, "display_name", displayName) + + // User doesn't exist, create a new ghost user + m.configMu.RLock() + cfg := m.config + m.configMu.RUnlock() + + // Generate ghost user JID and password + ghostJID := m.generateGhostUserJID(mattermostUserID) + ghostPassword := generateSecurePassword() + + // Register ghost user via XEP-0077 first + if err := m.registerGhostUser(mattermostUserID, ghostJID, ghostPassword); err != nil { + return nil, fmt.Errorf("failed to register ghost user: %w", err) + } + + // Create XMPPUser instance with the correct ghost credentials + xmppUser := NewXMPPUser(mattermostUserID, displayName, ghostJID, ghostPassword, cfg, m.logger) + + // Store ghost user data + ghostData := &GhostUserData{ + MattermostUserID: mattermostUserID, + GhostJID: ghostJID, + GhostPassword: ghostPassword, + Created: m.getCurrentTimestamp(), + } + + if err := m.storeGhostUserData(mattermostUserID, ghostData); err != nil { + m.logger.LogWarn("Failed to store ghost user data", "mattermost_user_id", mattermostUserID, "jid", ghostJID, "error", err) + // Don't fail creation for storage issues, just log warning + } + + // Ensure the newly created user is connected + if err := m.ensureUserConnected(xmppUser, mattermostUserID); err != nil { + return nil, fmt.Errorf("failed to connect newly created ghost user: %w", err) + } + + m.logger.LogInfo("Ghost user created and connected successfully", "mattermost_user_id", mattermostUserID, "ghost_jid", ghostJID) + return xmppUser, nil +} + +// DeleteUser removes a user and cleans up ghost user account if cleanup is enabled +func (m *UserManager) DeleteUser(mattermostUserID string) error { + m.logger.LogDebug("Deleting XMPP ghost user", "bridge_type", m.bridgeType, "mattermost_user_id", mattermostUserID) + + // Check if ghost user data exists + if !m.HasUser(mattermostUserID) { + return fmt.Errorf("ghost user not found for Mattermost user %s", mattermostUserID) + } + + // Clean up ghost user account if cleanup is enabled + m.configMu.RLock() + shouldCleanup := m.config.IsGhostUserCleanupEnabled() + m.configMu.RUnlock() + + if shouldCleanup { + // Full cleanup: removes XMPP account + local data + if err := m.cleanupGhostUser(mattermostUserID); err != nil { + m.logger.LogWarn("Failed to cleanup ghost user account", "mattermost_user_id", mattermostUserID, "error", err) + // Don't fail deletion for cleanup issues, continue with local removal + } + } else { + // Only remove our local data, preserve XMPP account + if err := m.removeGhostUserData(mattermostUserID); err != nil { + m.logger.LogWarn("Failed to remove ghost user data from KV store", "mattermost_user_id", mattermostUserID, "error", err) + } + } + + m.logger.LogInfo("XMPP ghost user deleted successfully", "bridge_type", m.bridgeType, "mattermost_user_id", mattermostUserID) + return nil +} + +// ListUsers returns a list of all users from KV store +func (m *UserManager) ListUsers() []model.BridgeUser { + keys, err := m.kvstore.ListKeysWithPrefix(0, 100, ghostCredPrefix) + if err != nil { + m.logger.LogWarn("Failed to list ghost user keys from KV store", "error", err) + return []model.BridgeUser{} + } + + users := make([]model.BridgeUser, 0, len(keys)) + for _, key := range keys { + mattermostUserID := key[len(ghostCredPrefix):] + user, err := m.GetUser(mattermostUserID) + if err != nil { + m.logger.LogWarn("Failed to load user", "mattermost_user_id", mattermostUserID, "error", err) + continue + } + users = append(users, user) + } + + return users +} + +// HasUser checks if a ghost user exists for the given Mattermost user ID +func (m *UserManager) HasUser(mattermostUserID string) bool { + key := buildGhostUserKey(mattermostUserID) + value, err := m.kvstore.Get(key) + return err == nil && !bytes.Equal(value, []byte{}) // Check if key exists and is not empty +} + +// Start initializes the user manager and starts all ghost users from KV store +func (m *UserManager) Start(ctx context.Context) error { + m.logger.LogDebug("Starting XMPP ghost user manager", "bridge_type", m.bridgeType) + + m.ctx = ctx + + // Get all users from KV store and start them + users := m.ListUsers() + startedCount := 0 + + for _, user := range users { + if err := user.Start(ctx); err != nil { + m.logger.LogWarn("Failed to start ghost user during manager startup", "bridge_type", m.bridgeType, "user_id", user.GetID(), "error", err) + // Continue starting other users even if one fails + } else { + startedCount++ + } + } + + m.logger.LogInfo("XMPP ghost user manager started", "bridge_type", m.bridgeType, "user_count", startedCount) + return nil +} + +// Stop shuts down the user manager and all ghost users +func (m *UserManager) Stop() error { + m.logger.LogDebug("Stopping XMPP ghost user manager", "bridge_type", m.bridgeType) + + if m.cancel != nil { + m.cancel() + } + + // Get all users from KV store and stop them + users := m.ListUsers() + for _, user := range users { + if err := user.Stop(); err != nil { + m.logger.LogWarn("Error stopping ghost user during manager shutdown", "bridge_type", m.bridgeType, "user_id", user.GetID(), "error", err) + } + } + + m.logger.LogInfo("XMPP ghost user manager stopped", "bridge_type", m.bridgeType) + return nil +} + +// UpdateConfiguration updates configuration +func (m *UserManager) UpdateConfiguration(cfg *config.Configuration) error { + m.logger.LogDebug("Updating configuration for XMPP ghost user manager", "bridge_type", m.bridgeType) + + m.configMu.Lock() + m.config = cfg + m.configMu.Unlock() + + m.logger.LogInfo("XMPP ghost user manager configuration updated", "bridge_type", m.bridgeType) + return nil +} + +// GetBridgeType returns the bridge type this manager handles +func (m *UserManager) GetBridgeType() string { + return m.bridgeType +} + +// KV store operations for ghost user data only + +func (m *UserManager) storeGhostUserData(mattermostUserID string, data *GhostUserData) error { + key := buildGhostUserKey(mattermostUserID) + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal ghost user data: %w", err) + } + return m.kvstore.Set(key, jsonData) +} + +func (m *UserManager) loadGhostUserData(mattermostUserID string) (*GhostUserData, error) { + key := buildGhostUserKey(mattermostUserID) + data, err := m.kvstore.Get(key) + if err != nil { + return nil, fmt.Errorf("ghost user data not found: %w", err) + } + + var ghostData GhostUserData + if err := json.Unmarshal(data, &ghostData); err != nil { + return nil, fmt.Errorf("failed to unmarshal ghost user data: %w", err) + } + + return &ghostData, nil +} + +func (m *UserManager) removeGhostUserData(mattermostUserID string) error { + key := buildGhostUserKey(mattermostUserID) + return m.kvstore.Delete(key) +} + +func (m *UserManager) cleanupGhostUser(mattermostUserID string) error { + ghostData, err := m.loadGhostUserData(mattermostUserID) + if err != nil { + // No ghost data found, nothing to cleanup + return nil + } + + // Get In-Band Registration handler from bridge client + regHandler, err := m.bridgeClient.GetInBandRegistration() + if err != nil { + return fmt.Errorf("failed to get in-band registration handler for cleanup: %w", err) + } + + // Parse the ghost JID to get the server part + ghostJIDParsed, err := jid.Parse(ghostData.GhostJID) + if err != nil { + return fmt.Errorf("failed to parse ghost JID %s for cleanup: %w", ghostData.GhostJID, err) + } + + // Unregister the ghost user account via XEP-0077 + response, err := regHandler.CancelRegistration(ghostJIDParsed.Domain()) + if err != nil { + return fmt.Errorf("failed to cancel registration for ghost user %s: %w", ghostData.GhostJID, err) + } + + if !response.Success { + m.logger.LogWarn("XEP-0077 registration cancellation failed", "jid", ghostData.GhostJID, "error", response.Error) + // Continue with cleanup even if cancellation failed + } + + // Remove ghost user data from KV store + if err := m.removeGhostUserData(mattermostUserID); err != nil { + m.logger.LogWarn("Failed to delete ghost user data from KV store", "mattermost_user_id", mattermostUserID, "error", err) + } + + m.logger.LogInfo("Ghost user account cleaned up successfully", "mattermost_user_id", mattermostUserID, "jid", ghostData.GhostJID) + return nil +} + +// ensureUserConnected ensures that a ghost user is connected to XMPP +func (m *UserManager) ensureUserConnected(xmppUser *User, mattermostUserID string) error { + // Check if user is already connected + if xmppUser.IsConnected() { + m.logger.LogDebug("Ghost user already connected", "mattermost_user_id", mattermostUserID, "ghost_jid", xmppUser.GetJID()) + return nil + } + + m.logger.LogDebug("Starting ghost user connection", "mattermost_user_id", mattermostUserID, "ghost_jid", xmppUser.GetJID()) + + // Start the user (this will connect it to XMPP) + if err := xmppUser.Start(m.ctx); err != nil { + return fmt.Errorf("failed to start ghost user %s: %w", xmppUser.GetJID(), err) + } + + // Verify connection was successful + if !xmppUser.IsConnected() { + return fmt.Errorf("ghost user %s failed to connect after start", xmppUser.GetJID()) + } + + m.logger.LogInfo("Ghost user connected successfully", "mattermost_user_id", mattermostUserID, "ghost_jid", xmppUser.GetJID()) + return nil +} + +// Helper functions + +func generateSecurePassword() string { + // TODO: Implement secure password generation using crypto/rand + return "temp_secure_password_123" +} + +func (m *UserManager) getCurrentTimestamp() int64 { + // TODO: Use proper time source (time.Now().Unix()) + return 0 +} diff --git a/server/config/config.go b/server/config/config.go index db8c497..9d651a6 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -5,8 +5,6 @@ import ( "strings" ) -const DefaultXMPPUsernamePrefix = "xmpp" - // Configuration captures the plugin's external configuration as exposed in the Mattermost server // configuration, as well as values computed from the configuration. Any public fields will be // deserialized from the Mattermost server configuration in OnConfigurationChange. @@ -23,9 +21,14 @@ type Configuration struct { XMPPUsername string `json:"XMPPUsername"` XMPPPassword string `json:"XMPPPassword"` EnableSync bool `json:"EnableSync"` - XMPPUsernamePrefix string `json:"XMPPUsernamePrefix"` XMPPResource string `json:"XMPPResource"` XMPPInsecureSkipVerify bool `json:"XMPPInsecureSkipVerify"` + + // Ghost User Settings (XEP-0077 In-Band Registration) + EnableXMPPGhostUsers bool `json:"EnableXMPPGhostUsers"` + XMPPGhostUserPrefix string `json:"XMPPGhostUserPrefix"` + XMPPGhostUserDomain string `json:"XMPPGhostUserDomain"` + XMPPGhostUserCleanup bool `json:"XMPPGhostUserCleanup"` } // Equals compares two configuration structs @@ -41,9 +44,12 @@ func (c *Configuration) Equals(other *Configuration) bool { c.XMPPUsername == other.XMPPUsername && c.XMPPPassword == other.XMPPPassword && c.EnableSync == other.EnableSync && - c.XMPPUsernamePrefix == other.XMPPUsernamePrefix && c.XMPPResource == other.XMPPResource && - c.XMPPInsecureSkipVerify == other.XMPPInsecureSkipVerify + c.XMPPInsecureSkipVerify == other.XMPPInsecureSkipVerify && + c.EnableXMPPGhostUsers == other.EnableXMPPGhostUsers && + c.XMPPGhostUserPrefix == other.XMPPGhostUserPrefix && + c.XMPPGhostUserDomain == other.XMPPGhostUserDomain && + c.XMPPGhostUserCleanup == other.XMPPGhostUserCleanup } // Clone shallow copies the configuration. Your implementation may require a deep copy if @@ -53,14 +59,6 @@ func (c *Configuration) Clone() *Configuration { return &clone } -// GetXMPPUsernamePrefix returns the configured username prefix, or the default if not set -func (c *Configuration) GetXMPPUsernamePrefix() string { - if c.XMPPUsernamePrefix == "" { - return DefaultXMPPUsernamePrefix - } - return c.XMPPUsernamePrefix -} - // GetXMPPResource returns the configured XMPP resource, or a default if not set func (c *Configuration) GetXMPPResource() string { if c.XMPPResource == "" { @@ -69,6 +67,41 @@ func (c *Configuration) GetXMPPResource() string { return c.XMPPResource } +// GetXMPPGhostUserDomain returns the configured ghost user domain, or derives it from server URL +func (c *Configuration) GetXMPPGhostUserDomain() string { + if c.XMPPGhostUserDomain != "" { + return c.XMPPGhostUserDomain + } + + // Extract domain from bridge username as fallback + if c.XMPPUsername != "" && strings.Contains(c.XMPPUsername, "@") { + parts := strings.Split(c.XMPPUsername, "@") + if len(parts) > 1 { + return parts[1] + } + } + + // Last resort: try to extract from server URL + if c.XMPPServerURL != "" { + parts := strings.Split(c.XMPPServerURL, ":") + if len(parts) > 0 { + return parts[0] + } + } + + return "localhost" +} + +// IsGhostUserEnabled returns true if ghost users should be created +func (c *Configuration) IsGhostUserEnabled() bool { + return c.EnableXMPPGhostUsers && c.EnableSync +} + +// IsGhostUserCleanupEnabled returns true if ghost users should be cleaned up on removal +func (c *Configuration) IsGhostUserCleanupEnabled() bool { + return c.XMPPGhostUserCleanup +} + // IsValid validates the configuration and returns an error if invalid func (c *Configuration) IsValid() error { if c.EnableSync { @@ -87,10 +120,16 @@ func (c *Configuration) IsValid() error { return fmt.Errorf("XMPP Server URL must include port (e.g., server.com:5222)") } - // Validate username prefix doesn't contain invalid characters - prefix := c.GetXMPPUsernamePrefix() - if strings.ContainsAny(prefix, ":@/\\") { - return fmt.Errorf("XMPP Username Prefix cannot contain special characters (:, @, /, \\)") + // Validate ghost user configuration if enabled + if c.EnableXMPPGhostUsers { + if c.XMPPGhostUserPrefix == "" { + return fmt.Errorf("XMPP Ghost User Prefix is required when ghost users are enabled") + } + + // Validate ghost user prefix doesn't contain invalid characters + if strings.ContainsAny(c.XMPPGhostUserPrefix, ":@/\\") { + return fmt.Errorf("XMPP Ghost User Prefix cannot contain special characters (:, @, /, \\)") + } } } diff --git a/server/hooks_user.go b/server/hooks_user.go new file mode 100644 index 0000000..78c13a5 --- /dev/null +++ b/server/hooks_user.go @@ -0,0 +1,51 @@ +package main + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" +) + +// UserHasBeenDeleted is called when a user has been deleted from Mattermost +// This allows us to clean up ghost users from the XMPP server +func (p *Plugin) UserHasBeenDeleted(c *plugin.Context, user *model.User) { + if user == nil { + p.logger.LogWarn("UserHasBeenDeleted called with nil user") + return + } + + p.logger.LogDebug("User deleted from Mattermost, cleaning up bridge users", "user_id", user.Id, "username", user.Username) + + // Clean up ghost users from external bridges (skip Mattermost bridge) + for _, bridgeName := range p.bridgeManager.ListBridges() { + // Skip the Mattermost bridge since it represents the Mattermost side + if bridgeName == "mattermost" { + p.logger.LogDebug("Skipping Mattermost bridge for user cleanup", "user_id", user.Id) + continue + } + + bridge, err := p.bridgeManager.GetBridge(bridgeName) + if err != nil { + p.logger.LogWarn("Failed to get bridge for user cleanup", "bridge", bridgeName, "error", err) + continue + } + + userManager := bridge.GetUserManager() + if userManager == nil { + p.logger.LogDebug("Bridge has no user manager, skipping cleanup", "bridge", bridgeName) + continue + } + + // Check if this user exists in the bridge + if !userManager.HasUser(user.Id) { + p.logger.LogDebug("User not found in bridge, skipping cleanup", "bridge", bridgeName, "user_id", user.Id) + continue + } + + // Delete the user from the bridge (this will handle ghost user cleanup if enabled) + if err := userManager.DeleteUser(user.Id); err != nil { + p.logger.LogWarn("Failed to delete user from bridge", "bridge", bridgeName, "user_id", user.Id, "error", err) + } else { + p.logger.LogInfo("Successfully deleted user from bridge", "bridge", bridgeName, "user_id", user.Id, "username", user.Username) + } + } +} diff --git a/server/xmpp/client.go b/server/xmpp/client.go index 98907c5..d7cdc37 100644 --- a/server/xmpp/client.go +++ b/server/xmpp/client.go @@ -182,11 +182,15 @@ func (c *Client) GetInBandRegistration() (*InBandRegistration, error) { return c.XEPFeatures.InBandRegistration, nil } +// DetectServerCapabilities discovers which XEPs are supported by the server (public method) +func (c *Client) DetectServerCapabilities() error { + return c.detectServerCapabilities() +} + // detectServerCapabilities discovers which XEPs are supported by the server -func (c *Client) detectServerCapabilities() { +func (c *Client) detectServerCapabilities() error { if c.session == nil { - c.logger.LogError("Cannot detect server capabilities: no session") - return + return fmt.Errorf("no XMPP session available for capability detection") } c.logger.LogDebug("Detecting server capabilities for XEP support") @@ -203,6 +207,7 @@ func (c *Client) detectServerCapabilities() { enabledFeatures := c.XEPFeatures.ListFeatures() c.logger.LogInfo("Server capability detection completed", "enabled_xeps", enabledFeatures) + return nil } // checkInBandRegistrationSupport checks if the server supports XEP-0077 In-Band Registration @@ -355,10 +360,6 @@ func (c *Client) Connect() error { return fmt.Errorf("failed to start session serving") } c.logger.LogInfo("XMPP client connected successfully", "jid", c.jidAddr.String()) - - // Detect server capabilities and enable supported XEPs - go c.detectServerCapabilities() - return nil case <-time.After(10 * time.Second): return fmt.Errorf("timeout waiting for session to be ready") @@ -693,6 +694,51 @@ func (c *Client) SetOfflinePresence() error { return nil } +// CheckRoomMembership verifies if the client is joined to an XMPP room +// Since disco#items is not available in this XMPP library, we use a simpler approach: +// we assume the user is not joined and always attempt to join (XMPP MUC handles duplicate joins gracefully) +func (c *Client) CheckRoomMembership(roomJID string) (bool, error) { + if c.session == nil { + return false, fmt.Errorf("XMPP session not established") + } + + c.logger.LogDebug("Checking room membership (conservative approach - assume not joined)", "room_jid", roomJID) + + // For safety, we always return false to ensure EnsureJoinedToRoom will attempt to join + // XMPP MUC servers handle duplicate joins gracefully by ignoring them + return false, nil +} + +// EnsureJoinedToRoom ensures the client is joined to an XMPP room, joining if necessary +func (c *Client) EnsureJoinedToRoom(roomJID string) error { + if c.session == nil { + return fmt.Errorf("XMPP session not established") + } + + c.logger.LogDebug("Ensuring joined to room", "room_jid", roomJID) + + // First check if we're already joined + isJoined, err := c.CheckRoomMembership(roomJID) + if err != nil { + c.logger.LogWarn("Failed to check room membership, attempting to join anyway", "room_jid", roomJID, "error", err) + // Continue with join attempt even if membership check failed + } else if isJoined { + c.logger.LogDebug("Already joined to room", "room_jid", roomJID) + return nil + } + + // Not joined, attempt to join the room + c.logger.LogDebug("Not joined to room, attempting to join", "room_jid", roomJID) + + err = c.JoinRoom(roomJID) + if err != nil { + return fmt.Errorf("failed to join room %s: %w", roomJID, err) + } + + c.logger.LogInfo("Successfully joined room", "room_jid", roomJID) + return nil +} + // CheckRoomExists verifies if an XMPP room exists and is accessible using disco#info func (c *Client) CheckRoomExists(roomJID string) (bool, error) { if c.session == nil {