feat: implement bidirectional message bridge system with XMPP-Mattermost integration

This commit implements a comprehensive bridge-agnostic message routing system that enables
real-time bidirectional message synchronization between XMPP and Mattermost platforms.

Key features:
- Bridge-agnostic message types and structures for extensibility
- Central message bus system with publisher-subscriber pattern
- Complete Bridge interface implementation for both XMPP and Mattermost
- Message aggregation from multiple sources for scalability
- Loop prevention mechanisms to avoid infinite message cycles
- Buffered channels for high-performance message processing

Architecture highlights:
- Producer-consumer pattern for message routing between bridges
- Thread-safe goroutine lifecycle management with context cancellation
- Message handlers separated into dedicated files for maintainability
- Support for future bridge implementations (Slack, Discord, etc.)
- Markdown content standardization across all bridges

Files added:
- server/model/message.go: Core bridge-agnostic message structures
- server/bridge/messagebus.go: Central message routing system
- server/bridge/mattermost/message_handler.go: Mattermost-specific message processing
- server/bridge/xmpp/message_handler.go: XMPP-specific message processing

Files modified:
- server/bridge/manager.go: Integration with message bus and routing
- server/bridge/mattermost/bridge.go: Complete Bridge interface implementation
- server/bridge/xmpp/bridge.go: Message aggregation and interface completion
- server/model/bridge.go: Extended Bridge interface for bidirectional messaging
- server/xmpp/client.go: Enhanced message listening with mellium.im/xmpp

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Felipe M 2025-08-04 21:52:28 +02:00
parent 69a67704f4
commit 7b56cb34c6
No known key found for this signature in database
GPG key ID: 52E5D65FCF99808A
9 changed files with 1119 additions and 41 deletions

View file

@ -0,0 +1,207 @@
package mattermost
import (
"fmt"
"strings"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
pluginModel "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/model"
mmModel "github.com/mattermost/mattermost/server/public/model"
)
// mattermostMessageHandler handles incoming messages for the Mattermost bridge
type mattermostMessageHandler struct {
bridge *mattermostBridge
logger logger.Logger
}
// newMessageHandler creates a new Mattermost message handler
func newMessageHandler(bridge *mattermostBridge) *mattermostMessageHandler {
return &mattermostMessageHandler{
bridge: bridge,
logger: bridge.logger,
}
}
// ProcessMessage processes an incoming message for the Mattermost bridge
func (h *mattermostMessageHandler) ProcessMessage(msg *pluginModel.DirectionalMessage) error {
h.logger.LogDebug("Processing message for Mattermost bridge",
"source_bridge", msg.SourceBridge,
"direction", msg.Direction,
"channel_id", msg.SourceChannelID)
// Skip messages that originated from Mattermost to prevent loops
if msg.SourceBridge == "mattermost" {
h.logger.LogDebug("Skipping Mattermost-originated message to prevent loop")
return nil
}
// For incoming messages to Mattermost, we post them to Mattermost channels
if msg.Direction == pluginModel.DirectionIncoming {
return h.postMessageToMattermost(msg.BridgeMessage)
}
h.logger.LogDebug("Ignoring outgoing message for Mattermost bridge")
return nil
}
// CanHandleMessage determines if this handler can process the message
func (h *mattermostMessageHandler) CanHandleMessage(msg *pluginModel.BridgeMessage) bool {
// Mattermost bridge can handle text messages that didn't originate from Mattermost
return msg.MessageType == "text" && msg.SourceBridge != "mattermost"
}
// GetSupportedMessageTypes returns the message types this handler supports
func (h *mattermostMessageHandler) GetSupportedMessageTypes() []string {
return []string{"text"}
}
// postMessageToMattermost posts a message to a Mattermost channel
func (h *mattermostMessageHandler) postMessageToMattermost(msg *pluginModel.BridgeMessage) error {
if h.bridge.api == nil {
return fmt.Errorf("Mattermost API not initialized")
}
// Get the Mattermost channel ID from the channel mapping
channelID, err := h.bridge.GetRoomMapping(msg.SourceChannelID)
if err != nil {
return fmt.Errorf("failed to get channel mapping: %w", err)
}
if channelID == "" {
// Check if the source channel ID is already a Mattermost channel ID
channelID = msg.SourceChannelID
}
// Verify the channel exists
channel, appErr := h.bridge.api.GetChannel(channelID)
if appErr != nil {
return fmt.Errorf("failed to get channel %s: %w", channelID, appErr)
}
if channel == nil {
return fmt.Errorf("channel %s not found", channelID)
}
// Format the message content
content := h.formatMessageContent(msg)
// Create the post
post := &mmModel.Post{
ChannelId: channelID,
Message: content,
Type: mmModel.PostTypeDefault,
Props: map[string]interface{}{
"from_bridge": msg.SourceBridge,
"bridge_user_id": msg.SourceUserID,
"bridge_user_name": msg.SourceUserName,
"bridge_message_id": msg.MessageID,
"bridge_timestamp": msg.Timestamp.Unix(),
},
}
// Add thread ID if present
if msg.ThreadID != "" {
post.RootId = msg.ThreadID
}
// Post the message as the plugin bot
createdPost, appErr := h.bridge.api.CreatePost(post)
if appErr != nil {
return fmt.Errorf("failed to create post in channel %s: %w", channelID, appErr)
}
h.logger.LogDebug("Message posted to Mattermost channel",
"channel_id", channelID,
"post_id", createdPost.Id,
"source_bridge", msg.SourceBridge,
"content_length", len(content))
return nil
}
// formatMessageContent formats the message content for Mattermost
func (h *mattermostMessageHandler) formatMessageContent(msg *pluginModel.BridgeMessage) string {
// For messages from other bridges, prefix with the bridge info and user name
if msg.SourceUserName != "" {
bridgeIcon := h.getBridgeIcon(msg.SourceBridge)
return fmt.Sprintf("%s **%s**: %s", bridgeIcon, msg.SourceUserName, msg.Content)
}
return msg.Content
}
// getBridgeIcon returns an icon/emoji for the source bridge
func (h *mattermostMessageHandler) getBridgeIcon(bridgeType string) string {
switch bridgeType {
case "xmpp":
return ":speech_balloon:" // Chat bubble emoji for XMPP
case "slack":
return ":slack:" // Slack emoji if available
case "discord":
return ":discord:" // Discord emoji if available
default:
return ":bridge_at_night:" // Generic bridge emoji
}
}
// mattermostUserResolver handles user resolution for the Mattermost bridge
type mattermostUserResolver struct {
bridge *mattermostBridge
logger logger.Logger
}
// newUserResolver creates a new Mattermost user resolver
func newUserResolver(bridge *mattermostBridge) *mattermostUserResolver {
return &mattermostUserResolver{
bridge: bridge,
logger: bridge.logger,
}
}
// ResolveUser converts an external user ID to an ExternalUser
func (r *mattermostUserResolver) ResolveUser(externalUserID string) (*pluginModel.ExternalUser, error) {
r.logger.LogDebug("Resolving Mattermost user", "user_id", externalUserID)
// For Mattermost, the external user ID is the Mattermost user ID
user, appErr := r.bridge.api.GetUser(externalUserID)
if appErr != nil {
return nil, fmt.Errorf("failed to get Mattermost user: %w", appErr)
}
if user == nil {
return nil, fmt.Errorf("Mattermost user not found: %s", externalUserID)
}
return &pluginModel.ExternalUser{
BridgeType: "mattermost",
ExternalUserID: externalUserID,
DisplayName: r.GetDisplayName(externalUserID),
MattermostUserID: externalUserID, // Same as external ID for Mattermost
}, nil
}
// FormatUserMention formats a user mention for Markdown content
func (r *mattermostUserResolver) FormatUserMention(user *pluginModel.ExternalUser) string {
// For Mattermost, use the standard @username format
return fmt.Sprintf("@%s", user.DisplayName)
}
// GetDisplayName extracts display name from external user ID
func (r *mattermostUserResolver) GetDisplayName(externalUserID string) string {
// Try to get the actual username from Mattermost API
user, appErr := r.bridge.api.GetUser(externalUserID)
if appErr != nil || user == nil {
r.logger.LogWarn("Failed to get user for display name", "user_id", externalUserID)
return "Unknown User"
}
// Prefer username, fallback to first name + last name, then to ID
if user.Username != "" {
return user.Username
}
fullName := strings.TrimSpace(user.FirstName + " " + user.LastName)
if fullName != "" {
return fullName
}
return user.Id[:8] // Show first 8 chars of ID as fallback
}