Compare commits

...

10 commits

Author SHA1 Message Date
65038fb7a2
feat: restore XMPP bridge to use direct client connection instead of bridge user
Some checks are pending
ci / plugin-ci (push) Waiting to run
- Replace bridgeUser with bridgeClient (*xmppClient.Client) in XMPP bridge
- Update createXMPPClient to return XMPP client with TLS configuration
- Migrate connection, disconnection, and room operations to use bridgeClient
- Update Ping() and RoomExists() methods to use client methods directly
- Maintain bridge-agnostic user management system for additional users
- Fix formatting and import organization across bridge components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 18:04:10 +02:00
db8037ffbf
feat: implement comprehensive bridge-agnostic user management system
This commit implements a complete multi-user bridge management system that allows bridges to control multiple users with async goroutine management and convenience methods for channel operations.

Key features:
- Bridge-agnostic BridgeUser interface with validation, identity, state management, channel operations, connection lifecycle, and goroutine lifecycle methods
- BridgeUserManager interface for user lifecycle management with bridge type identification
- XMPPUser implementation for XMPP bridge with XMPP client integration, connection monitoring, and room operations
- MattermostUser implementation for Mattermost bridge with API integration and channel management
- Updated Bridge interface to include GetUserManager() method
- Base UserManager implementation with generic user management logic
- Added Ping() and CheckChannelExists() methods to BridgeUser interface for health checking and room validation
- Updated bridge manager naming from Manager to BridgeManager for clarity

The system enables bridges to manage multiple users (like "Mattermost Bridge" user in XMPP) with proper state management, connection monitoring, and channel operations abstracted across different bridge protocols.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 17:50:44 +02:00
ea1711e94c
feat: implement OnSharedChannelsPing hook with active bridge health checking
- Add Ping() method to Bridge interface for active connectivity testing
- Implement XMPP ping using disco#info query to server domain (fast & reliable)
- Implement Mattermost bridge ping using GetServerVersion API call
- Add comprehensive OnSharedChannelsPing hook with proper error handling
- Replace timeout-prone IQ ping with proven disco#info approach
- Add detailed logging for monitoring and debugging ping operations
- Fix doctor command to use new Ping method instead of TestConnection
- Performance: XMPP ping now completes in ~4ms vs previous 5s timeout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 16:42:59 +02:00
35174c61a2
pluginctl: update to v0.1.2 2025-08-04 13:49:25 +02:00
a95ca8fb76
feat: implement comprehensive room validation and admin-only command access
- Add RoomExists and GetRoomMapping methods to Bridge interface
- Implement XMPP room existence checking using disco#info queries (XEP-0030)
- Add room validation in BridgeManager to prevent duplicate mappings and invalid rooms
- Enhance XMPP client with CheckRoomExists method and comprehensive logging
- Implement admin-only access control for all bridge commands
- Add user-friendly error messages with actionable troubleshooting steps
- Update doctor command with room existence testing and pre-join validation
- Add SimpleLogger implementation for standalone command usage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 11:29:35 +02:00
1f45197aa8
feat: refactor channel mapping with structured parameters and shared channel integration
- Add ChannelMappingRequest and ChannelMappingDeleteRequest structs with validation
- Update BridgeManager interface to accept structured parameters instead of individual strings
- Implement proper user ID and team ID propagation to shared channels
- Add shared channel creation/deletion integration with Mattermost API
- Update command handlers to provide user and team context
- Enhance logging with comprehensive parameter tracking

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 19:10:40 +02:00
a5eb80817c
fix: correct plugin ID in manifest
Updates plugin ID to match proper naming convention from
com.mattermost.bridge-xmpp to com.mattermost.plugin-bridge-xmpp.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 18:30:27 +02:00
2e13d96dce
feat: implement centralized channel mapping management
Adds OnChannelMappingDeleted method to BridgeManager for centralized
cleanup of channel mappings across all bridge types. Updates slash
commands to use centralized management and fixes method naming
inconsistencies.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 18:18:10 +02:00
5d143808a3
feat: add direct message testing to XMPP doctor command
- Add reusable MessageBody and XMPPMessage structs to xmpp client
- Refactor SendMessage to use shared structs instead of inline definitions
- Add SendDirectMessage method for direct user messaging (type="chat")
- Enhance doctor command with --test-dm flag (enabled by default)
- Add testDirectMessage function that sends test message to admin@localhost
- Update help text, examples, and timing measurements for direct messages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 16:58:02 +02:00
5551a8bc8d
feat: add /xmppbridge unmap command for channel unmapping
- Add DeleteChannelRoomMapping method to Bridge interface
- Implement channel unmapping logic in XMPP bridge (cache + KVStore removal)
- Add /xmppbridge unmap command handler with validation
- Bridge user automatically leaves XMPP room when unmapping
- Update command help text and autocomplete

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 16:44:59 +02:00
20 changed files with 3480 additions and 214 deletions

View file

@ -1,48 +1,88 @@
run:
timeout: 5m
modules-download-mode: readonly
linters-settings:
gofmt:
simplify: true
goimports:
local-prefixes: github.com/mattermost/mattermost-plugin-bridge-xmpp
govet:
check-shadowing: true
enable-all: true
disable:
- fieldalignment
misspell:
locale: US
version: "2"
linters:
disable-all: true
enable:
- bodyclose
- errcheck
- gocritic
- gofmt
- goimports
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- revive
- staticcheck
- stylecheck
- staticcheck # Now includes gosimple and stylecheck
- typecheck
- unconvert
- unused
- whitespace
- govet # Ensure this is included
settings:
errcheck:
# Add any errcheck settings here
exclude-functions:
- io.Copy(*bytes.Buffer)
gocritic:
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
gosec:
# Add gosec settings
excludes:
- G104 # Errors unhandled
staticcheck:
# Configure staticcheck (includes gosimple/stylecheck checks)
checks: ["all"]
revive:
# Add revive rules
rules:
- name: exported
disabled: false
exclusions:
presets:
- comments
- std-error-handling
- common-false-positives
rules:
- path: '_test\.go'
linters:
- errcheck
- gosec
formatters:
enable:
- gofmt
- goimports
settings:
gofmt:
simplify: true
goimports:
local-prefixes:
- github.com/mattermost/mattermost-plugin-bridge-xmpp
output:
formats:
text:
path: stdout
colors: true
print-linter-name: true
run:
timeout: 5m
tests: true
issues:
exclude-rules:
- path: server/configuration.go
linters:
- unused
- path: _test\.go
linters:
- bodyclose
- scopelint # https://github.com/kyoh86/scopelint/issues/4
max-issues-per-linter: 0
max-same-issues: 0
fix: false

View file

@ -2,11 +2,13 @@
# Testing and Quality Assurance
# ====================================================================================
GOLANGCI_LINT_BINARY = ./build/bin/golangci-lint
GOTESTSUM_BINARY = ./build/bin/gotestsum
## Install go tools
install-go-tools:
@echo Installing go tools
$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0
$(GO) install gotest.tools/gotestsum@v1.7.0
@echo "Installing development tools..."
@pluginctl tools install --bin-dir ./build/bin
## Runs eslint and golangci-lint
.PHONY: check-style
@ -24,14 +26,14 @@ endif
ifneq ($(HAS_SERVER),)
@echo Running golangci-lint
$(GO) vet ./...
$(GOBIN)/golangci-lint run ./...
$(GOLANGCI_LINT_BINARY) run ./...
endif
## Runs any lints and unit tests defined for the server and webapp, if they exist.
.PHONY: test
test: apply webapp/node_modules install-go-tools
ifneq ($(HAS_SERVER),)
$(GOBIN)/gotestsum -- -v ./...
$(GOTESTSUM_BINARY) -- -v ./...
endif
ifneq ($(HAS_WEBAPP),)
cd webapp && $(NPM) run test;
@ -42,7 +44,7 @@ endif
.PHONY: test-ci
test-ci: apply webapp/node_modules install-go-tools
ifneq ($(HAS_SERVER),)
$(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./...
$(GOTESTSUM_BINARY) --format standard-verbose --junitfile report.xml -- ./...
endif
ifneq ($(HAS_WEBAPP),)
cd webapp && $(NPM) run test;

View file

@ -27,6 +27,8 @@ type Config struct {
Resource string
TestRoom string
TestMUC bool
TestDirectMessage bool
TestRoomExists bool
Verbose bool
InsecureSkipVerify bool
}
@ -41,20 +43,23 @@ func main() {
flag.StringVar(&config.Resource, "resource", defaultResource, "XMPP resource")
flag.StringVar(&config.TestRoom, "test-room", defaultTestRoom, "MUC room JID for testing")
flag.BoolVar(&config.TestMUC, "test-muc", true, "Enable MUC room testing (join/wait/leave)")
flag.BoolVar(&config.TestDirectMessage, "test-dm", true, "Enable direct message testing (send message to admin user)")
flag.BoolVar(&config.TestRoomExists, "test-room-exists", true, "Enable room existence testing using disco#info")
flag.BoolVar(&config.Verbose, "verbose", true, "Enable verbose logging")
flag.BoolVar(&config.InsecureSkipVerify, "insecure-skip-verify", true, "Skip TLS certificate verification (for development)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "xmpp-client-doctor - Test XMPP client connectivity and MUC operations\n\n")
fmt.Fprintf(os.Stderr, "This tool tests the XMPP client implementation by connecting to an XMPP server,\n")
fmt.Fprintf(os.Stderr, "performing connection tests, optionally testing MUC room operations,\n")
fmt.Fprintf(os.Stderr, "and then disconnecting gracefully.\n\n")
fmt.Fprintf(os.Stderr, "performing connection tests, room existence checks, optionally testing MUC room operations\n")
fmt.Fprintf(os.Stderr, "and direct messages, and then disconnecting gracefully.\n\n")
fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, " %s [flags]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " %s # Test basic connectivity\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --test-muc # Test connectivity and MUC operations\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --test-muc=false # Test connectivity only\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --test-dm # Test connectivity and direct messages\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --test-muc=false --test-dm=false # Test connectivity only\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Flags:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nDefault values are configured for the development server in ./sidecar/\n")
@ -75,6 +80,12 @@ func main() {
if config.TestMUC {
log.Printf(" Test Room: %s", config.TestRoom)
}
if config.TestDirectMessage {
log.Printf(" Test Direct Messages: enabled")
}
if config.TestRoomExists {
log.Printf(" Test Room Existence: enabled")
}
}
// Test the XMPP client
@ -89,6 +100,12 @@ func main() {
if config.TestMUC {
fmt.Println("✅ XMPP MUC operations test passed!")
}
if config.TestDirectMessage {
fmt.Println("✅ XMPP direct message test passed!")
}
if config.TestRoomExists {
fmt.Println("✅ XMPP room existence test passed!")
}
}
}
@ -97,6 +114,9 @@ func testXMPPClient(config *Config) error {
log.Printf("Creating XMPP client...")
}
// Create a simple logger for the XMPP client
doctorLogger := &SimpleLogger{verbose: config.Verbose}
// Create XMPP client with optional TLS configuration
var client *xmpp.Client
if config.InsecureSkipVerify {
@ -113,6 +133,7 @@ func testXMPPClient(config *Config) error {
config.Resource,
"doctor-remote-id",
tlsConfig,
doctorLogger,
)
} else {
client = xmpp.NewClient(
@ -121,6 +142,7 @@ func testXMPPClient(config *Config) error {
config.Password,
config.Resource,
"doctor-remote-id",
doctorLogger,
)
}
@ -143,7 +165,7 @@ func testXMPPClient(config *Config) error {
// Test connection health
start = time.Now()
err = client.TestConnection()
err = client.Ping()
if err != nil {
return fmt.Errorf("connection health test failed: %w", err)
}
@ -154,7 +176,9 @@ func testXMPPClient(config *Config) error {
}
var mucDuration time.Duration
var dmDuration time.Duration
var roomExistsDuration time.Duration
// Test MUC operations if requested
if config.TestMUC {
start = time.Now()
@ -165,6 +189,26 @@ func testXMPPClient(config *Config) error {
mucDuration = time.Since(start)
}
// Test direct message if requested
if config.TestDirectMessage {
start = time.Now()
err = testDirectMessage(client, config)
if err != nil {
return fmt.Errorf("direct message test failed: %w", err)
}
dmDuration = time.Since(start)
}
// Test room existence if requested
if config.TestRoomExists {
start = time.Now()
err = testRoomExists(client, config)
if err != nil {
return fmt.Errorf("room existence test failed: %w", err)
}
roomExistsDuration = time.Since(start)
}
if config.Verbose {
log.Printf("Disconnecting from XMPP server...")
}
@ -185,11 +229,23 @@ func testXMPPClient(config *Config) error {
if config.TestMUC {
log.Printf(" MUC operations time: %v", mucDuration)
}
if config.TestDirectMessage {
log.Printf(" Direct message time: %v", dmDuration)
}
if config.TestRoomExists {
log.Printf(" Room existence check time: %v", roomExistsDuration)
}
log.Printf(" Disconnect time: %v", disconnectDuration)
totalTime := connectDuration + pingDuration + disconnectDuration
if config.TestMUC {
totalTime += mucDuration
}
if config.TestDirectMessage {
totalTime += dmDuration
}
if config.TestRoomExists {
totalTime += roomExistsDuration
}
log.Printf(" Total time: %v", totalTime)
}
@ -199,17 +255,38 @@ func testXMPPClient(config *Config) error {
func testMUCOperations(client *xmpp.Client, config *Config) error {
if config.Verbose {
log.Printf("Testing MUC operations with room: %s", config.TestRoom)
log.Printf("Attempting to join MUC room...")
log.Printf("First checking if room exists...")
}
// Check if room exists before attempting to join
start := time.Now()
exists, err := client.CheckRoomExists(config.TestRoom)
if err != nil {
return fmt.Errorf("failed to check room existence for %s: %w", config.TestRoom, err)
}
checkDuration := time.Since(start)
if config.Verbose {
log.Printf("✅ Room existence check completed in %v", checkDuration)
log.Printf("Room %s exists: %t", config.TestRoom, exists)
}
if !exists {
return fmt.Errorf("cannot test MUC operations: room %s does not exist or is not accessible", config.TestRoom)
}
if config.Verbose {
log.Printf("Room exists, proceeding to join...")
}
// Test joining the room
start := time.Now()
err := client.JoinRoom(config.TestRoom)
start = time.Now()
err = client.JoinRoom(config.TestRoom)
if err != nil {
return fmt.Errorf("failed to join MUC room %s: %w", config.TestRoom, err)
}
joinDuration := time.Since(start)
var sendDuration time.Duration
if config.Verbose {
@ -223,7 +300,7 @@ func testMUCOperations(client *xmpp.Client, config *Config) error {
RoomJID: config.TestRoom,
Message: testMessage,
}
start = time.Now()
_, err = client.SendMessage(messageReq)
if err != nil {
@ -255,11 +332,84 @@ func testMUCOperations(client *xmpp.Client, config *Config) error {
if config.Verbose {
log.Printf("✅ Successfully left MUC room in %v", leaveDuration)
log.Printf("MUC operations summary:")
log.Printf(" Room existence check time: %v", checkDuration)
log.Printf(" Join time: %v", joinDuration)
log.Printf(" Send message time: %v", sendDuration)
log.Printf(" Wait time: 5s")
log.Printf(" Leave time: %v", leaveDuration)
log.Printf(" Total MUC time: %v", joinDuration+sendDuration+5*time.Second+leaveDuration)
log.Printf(" Total MUC time: %v", checkDuration+joinDuration+sendDuration+5*time.Second+leaveDuration)
}
return nil
}
func testDirectMessage(client *xmpp.Client, config *Config) error {
if config.Verbose {
log.Printf("Testing direct message functionality...")
log.Printf("Sending test message to admin user...")
}
// Send a test message to the admin user
testMessage := fmt.Sprintf("Test direct message from XMPP doctor at %s", time.Now().Format("15:04:05"))
adminJID := "admin@localhost" // Default admin user for development server
start := time.Now()
err := client.SendDirectMessage(adminJID, testMessage)
if err != nil {
return fmt.Errorf("failed to send direct message to %s: %w", adminJID, err)
}
sendDuration := time.Since(start)
if config.Verbose {
log.Printf("✅ Successfully sent direct message in %v", sendDuration)
log.Printf("Message: %s", testMessage)
log.Printf("Recipient: %s", adminJID)
log.Printf("Direct message test summary:")
log.Printf(" Send message time: %v", sendDuration)
}
return nil
}
func testRoomExists(client *xmpp.Client, config *Config) error {
if config.Verbose {
log.Printf("Testing room existence functionality...")
log.Printf("Checking if test room exists: %s", config.TestRoom)
}
// Test room existence check
start := time.Now()
exists, err := client.CheckRoomExists(config.TestRoom)
if err != nil {
return fmt.Errorf("failed to check room existence for %s: %w", config.TestRoom, err)
}
checkDuration := time.Since(start)
if config.Verbose {
log.Printf("✅ Room existence check completed in %v", checkDuration)
log.Printf("Room %s exists: %t", config.TestRoom, exists)
}
// Test with a non-existent room to verify negative case
nonExistentRoom := "nonexistent-room-12345@conference.localhost"
if config.Verbose {
log.Printf("Testing negative case with non-existent room: %s", nonExistentRoom)
}
start = time.Now()
existsNegative, err := client.CheckRoomExists(nonExistentRoom)
if err != nil {
return fmt.Errorf("failed to check non-existent room %s: %w", nonExistentRoom, err)
}
checkNegativeDuration := time.Since(start)
if config.Verbose {
log.Printf("✅ Negative room existence check completed in %v", checkNegativeDuration)
log.Printf("Non-existent room %s exists: %t (should be false)", nonExistentRoom, existsNegative)
log.Printf("Room existence test summary:")
log.Printf(" Test room check time: %v", checkDuration)
log.Printf(" Negative case check time: %v", checkNegativeDuration)
log.Printf(" Total room existence test time: %v", checkDuration+checkNegativeDuration)
}
return nil
@ -270,4 +420,31 @@ func maskPassword(password string) string {
return "****"
}
return password[:2] + "****"
}
}
// SimpleLogger provides basic logging functionality for the doctor command
type SimpleLogger struct {
verbose bool
}
// LogDebug logs debug messages if verbose mode is enabled
func (l *SimpleLogger) LogDebug(msg string, args ...interface{}) {
if l.verbose {
log.Printf("[DEBUG] "+msg, args...)
}
}
// LogInfo logs info messages
func (l *SimpleLogger) LogInfo(msg string, args ...interface{}) {
log.Printf("[INFO] "+msg, args...)
}
// LogWarn logs warning messages
func (l *SimpleLogger) LogWarn(msg string, args ...interface{}) {
log.Printf("[WARN] "+msg, args...)
}
// LogError logs error messages
func (l *SimpleLogger) LogError(msg string, args ...interface{}) {
log.Printf("[ERROR] "+msg, args...)
}

173
go.mod
View file

@ -3,7 +3,6 @@ module github.com/mattermost/mattermost-plugin-bridge-xmpp
go 1.24.3
require (
github.com/golang/mock v1.6.0
github.com/gorilla/mux v1.8.1
github.com/mattermost/mattermost/server/public v0.1.10
github.com/pkg/errors v0.9.1
@ -13,53 +12,225 @@ require (
)
require (
4d63.com/gocheckcompilerdirectives v1.2.1 // indirect
4d63.com/gochecknoglobals v0.2.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/4meepo/tagalign v1.3.4 // indirect
github.com/Abirdcfly/dupword v0.1.1 // indirect
github.com/Antonboom/errname v0.1.13 // indirect
github.com/Antonboom/nilnil v0.1.9 // indirect
github.com/Antonboom/testifylint v1.4.3 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.5.2 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect
github.com/alecthomas/go-check-sumtype v0.1.4 // indirect
github.com/alexkohler/nakedret/v2 v2.0.4 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.4.1 // indirect
github.com/breml/bidichk v0.2.7 // indirect
github.com/breml/errchkjson v0.3.6 // indirect
github.com/butuzov/ireturn v0.3.0 // indirect
github.com/butuzov/mirror v1.2.0 // indirect
github.com/catenacyber/perfsprint v0.7.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.2.0 // indirect
github.com/curioswitch/go-reassign v0.2.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.6 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-critic/go-critic v0.11.4 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.1.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect
github.com/golangci/gofmt v0.0.0-20240816233607-d8596aa466a9 // indirect
github.com/golangci/golangci-lint v1.61.0 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/modinfo v0.3.4 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.5.3 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.4.2 // indirect
github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.6.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af // indirect
github.com/jjti/go-spancheck v0.6.2 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/julz/importas v0.1.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.1.0 // indirect
github.com/kisielk/errcheck v1.7.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.5 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/kyoh86/exportloopref v0.1.11 // indirect
github.com/lasiar/canonicalheader v1.1.1 // indirect
github.com/ldez/gomoddirectives v0.2.4 // indirect
github.com/ldez/tagliatelle v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufeee/execinquery v1.2.1 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect
github.com/mattermost/logr/v2 v2.0.21 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.3.9 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.16.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polyfloyd/go-errorlint v1.6.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.0.7 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.27.0 // indirect
github.com/securego/gosec/v2 v2.21.2 // indirect
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.10.0 // indirect
github.com/sonatard/noctx v0.0.2 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.2.0 // indirect
github.com/tetafro/godot v1.4.17 // indirect
github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 // indirect
github.com/timonwong/loggercheck v0.9.4 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/tomarrell/wrapcheck/v2 v2.9.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.1.0 // indirect
github.com/ultraware/whitespace v0.1.1 // indirect
github.com/uudashr/gocognit v1.1.3 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.5 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.12.2 // indirect
go-simpler.org/sloglint v0.7.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/gotestsum v1.7.0 // indirect
honnef.co/go/tools v0.5.1 // indirect
mellium.im/reader v0.1.0 // indirect
mellium.im/xmlstream v0.15.4 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
gotest.tools/gotestsum
)

837
go.sum

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
{
"id": "com.mattermost.bridge-xmpp",
"id": "com.mattermost.plugin-bridge-xmpp",
"name": "Mattermost Bridge for XMPP",
"description": "This plugin provides a bridge connecting Mattermost and XMPP servers.",
"homepage_url": "https://github.com/mattermost/mattermost-plugin-bridge-xmpp",
"support_url": "https://github.com/mattermost/mattermost-plugin-bridge-xmpp/issues",
"icon_path": "assets/logo.png",
"version": "",
"min_server_version": "6.2.1",
"min_server_version": "9.5.0",
"server": {
"executables": {
"darwin-amd64": "server/dist/plugin-darwin-amd64",
@ -29,28 +29,40 @@
"display_name": "XMPP Server URL",
"type": "text",
"help_text": "The URL of the XMPP server to connect to (e.g., xmpp.example.com:5222)",
"placeholder": "xmpp.example.com:5222"
"placeholder": "xmpp.example.com:5222",
"default": null,
"hosting": "",
"secret": false
},
{
"key": "XMPPUsername",
"display_name": "XMPP Username",
"type": "text",
"help_text": "The username for authenticating with the XMPP server",
"placeholder": "bridge@xmpp.example.com"
"placeholder": "bridge@xmpp.example.com",
"default": null,
"hosting": "",
"secret": false
},
{
"key": "XMPPPassword",
"display_name": "XMPP Password",
"type": "text",
"secret": true,
"help_text": "The password for authenticating with the XMPP server"
"help_text": "The password for authenticating with the XMPP server",
"placeholder": "",
"default": null,
"hosting": "",
"secret": true
},
{
"key": "EnableSync",
"display_name": "Enable Message Synchronization",
"type": "bool",
"help_text": "When enabled, messages will be synchronized between Mattermost and XMPP",
"default": false
"placeholder": "",
"default": false,
"hosting": "",
"secret": false
},
{
"key": "XMPPUsernamePrefix",
@ -58,7 +70,9 @@
"type": "text",
"help_text": "Prefix for XMPP users in Mattermost (e.g., 'xmpp' creates usernames like 'xmpp:user@domain')",
"placeholder": "xmpp",
"default": "xmpp"
"default": "xmpp",
"hosting": "",
"secret": false
},
{
"key": "XMPPResource",
@ -66,20 +80,26 @@
"type": "text",
"help_text": "XMPP resource identifier for the bridge client",
"placeholder": "mattermost-bridge",
"default": "mattermost-bridge"
"default": "mattermost-bridge",
"hosting": "",
"secret": false
},
{
"key": "XMPPInsecureSkipVerify",
"display_name": "Skip TLS Certificate Verification",
"type": "bool",
"help_text": "Skip TLS certificate verification for XMPP connections (use only for testing/development)",
"default": false
"placeholder": "",
"default": false,
"hosting": "",
"secret": false
}
]
],
"sections": null
},
"props": {
"pluginctl": {
"version": "v0.1.1"
"version": "v0.1.2"
}
}
}

View file

@ -6,29 +6,38 @@ import (
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/model"
mmModel "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// Manager manages multiple bridge instances
type Manager struct {
bridges map[string]model.Bridge
mu sync.RWMutex
logger logger.Logger
// BridgeManager manages multiple bridge instances
type BridgeManager struct {
bridges map[string]model.Bridge
mu sync.RWMutex
logger logger.Logger
api plugin.API
remoteID string
}
// NewManager creates a new bridge manager
func NewManager(logger logger.Logger) model.BridgeManager {
// NewBridgeManager creates a new bridge manager
func NewBridgeManager(logger logger.Logger, api plugin.API, remoteID string) model.BridgeManager {
if logger == nil {
panic("logger cannot be nil")
}
if api == nil {
panic("plugin API cannot be nil")
}
return &Manager{
bridges: make(map[string]model.Bridge),
logger: logger,
return &BridgeManager{
bridges: make(map[string]model.Bridge),
logger: logger,
api: api,
remoteID: remoteID,
}
}
// RegisterBridge registers a bridge with the manager
func (m *Manager) RegisterBridge(name string, bridge model.Bridge) error {
func (m *BridgeManager) RegisterBridge(name string, bridge model.Bridge) error {
if name == "" {
return fmt.Errorf("bridge name cannot be empty")
}
@ -50,7 +59,7 @@ func (m *Manager) RegisterBridge(name string, bridge model.Bridge) error {
}
// StartBridge starts a specific bridge
func (m *Manager) StartBridge(name string) error {
func (m *BridgeManager) StartBridge(name string) error {
m.mu.RLock()
bridge, exists := m.bridges[name]
m.mu.RUnlock()
@ -71,7 +80,7 @@ func (m *Manager) StartBridge(name string) error {
}
// StopBridge stops a specific bridge
func (m *Manager) StopBridge(name string) error {
func (m *BridgeManager) StopBridge(name string) error {
m.mu.RLock()
bridge, exists := m.bridges[name]
m.mu.RUnlock()
@ -92,7 +101,7 @@ func (m *Manager) StopBridge(name string) error {
}
// UnregisterBridge removes a bridge from the manager
func (m *Manager) UnregisterBridge(name string) error {
func (m *BridgeManager) UnregisterBridge(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -115,7 +124,7 @@ func (m *Manager) UnregisterBridge(name string) error {
}
// GetBridge retrieves a bridge by name
func (m *Manager) GetBridge(name string) (model.Bridge, error) {
func (m *BridgeManager) GetBridge(name string) (model.Bridge, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -128,7 +137,7 @@ func (m *Manager) GetBridge(name string) (model.Bridge, error) {
}
// ListBridges returns a list of all registered bridge names
func (m *Manager) ListBridges() []string {
func (m *BridgeManager) ListBridges() []string {
m.mu.RLock()
defer m.mu.RUnlock()
@ -141,7 +150,7 @@ func (m *Manager) ListBridges() []string {
}
// HasBridge checks if a bridge with the given name is registered
func (m *Manager) HasBridge(name string) bool {
func (m *BridgeManager) HasBridge(name string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
@ -150,7 +159,7 @@ func (m *Manager) HasBridge(name string) bool {
}
// HasBridges checks if any bridges are registered
func (m *Manager) HasBridges() bool {
func (m *BridgeManager) HasBridges() bool {
m.mu.RLock()
defer m.mu.RUnlock()
@ -158,7 +167,7 @@ func (m *Manager) HasBridges() bool {
}
// Shutdown stops and unregisters all bridges
func (m *Manager) Shutdown() error {
func (m *BridgeManager) Shutdown() error {
m.mu.Lock()
defer m.mu.Unlock()
@ -187,7 +196,7 @@ func (m *Manager) Shutdown() error {
}
// OnPluginConfigurationChange propagates configuration changes to all registered bridges
func (m *Manager) OnPluginConfigurationChange(config any) error {
func (m *BridgeManager) OnPluginConfigurationChange(config any) error {
m.mu.RLock()
defer m.mu.RUnlock()
@ -213,4 +222,185 @@ func (m *Manager) OnPluginConfigurationChange(config any) error {
m.logger.LogInfo("Configuration changes propagated to all bridges")
return nil
}
}
// CreateChannelMapping handles the creation of a channel mapping by calling the appropriate bridge
func (m *BridgeManager) CreateChannelMapping(req model.CreateChannelMappingRequest) error {
// Validate request
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid mapping request: %w", err)
}
m.logger.LogDebug("Creating channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "bridge_room_id", req.BridgeRoomID, "user_id", req.UserID, "team_id", req.TeamID)
// Get the specific bridge
bridge, err := m.GetBridge(req.BridgeName)
if err != nil {
m.logger.LogError("Failed to get bridge", "bridge_name", req.BridgeName, "error", err)
return fmt.Errorf("failed to get bridge '%s': %w", req.BridgeName, err)
}
// Check if bridge is connected
if !bridge.IsConnected() {
return fmt.Errorf("bridge '%s' is not connected", req.BridgeName)
}
// NEW: Check if room already mapped to another channel
existingChannelID, err := bridge.GetRoomMapping(req.BridgeRoomID)
if err != nil {
m.logger.LogError("Failed to check room mapping", "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to check room mapping: %w", err)
}
if existingChannelID != "" {
m.logger.LogWarn("Room already mapped to another channel",
"bridge_room_id", req.BridgeRoomID,
"existing_channel_id", existingChannelID,
"requested_channel_id", req.ChannelID)
return fmt.Errorf("room '%s' is already mapped to channel '%s'", req.BridgeRoomID, existingChannelID)
}
// NEW: Check if room exists on target bridge
roomExists, err := bridge.RoomExists(req.BridgeRoomID)
if err != nil {
m.logger.LogError("Failed to check room existence", "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to check room existence: %w", err)
}
if !roomExists {
m.logger.LogWarn("Room does not exist on bridge",
"bridge_room_id", req.BridgeRoomID,
"bridge_name", req.BridgeName)
return fmt.Errorf("room '%s' does not exist on %s bridge", req.BridgeRoomID, req.BridgeName)
}
m.logger.LogDebug("Room validation passed",
"bridge_room_id", req.BridgeRoomID,
"bridge_name", req.BridgeName,
"room_exists", roomExists,
"already_mapped", false)
// Create the channel mapping on the receiving bridge
if err = bridge.CreateChannelMapping(req.ChannelID, req.BridgeRoomID); err != nil {
m.logger.LogError("Failed to create channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to create channel mapping for bridge '%s': %w", req.BridgeName, err)
}
mattermostBridge, err := m.GetBridge("mattermost")
if err != nil {
m.logger.LogError("Failed to get Mattermost bridge", "error", err)
return fmt.Errorf("failed to get Mattermost bridge: %w", err)
}
// Create the channel mapping in the Mattermost bridge
if err = mattermostBridge.CreateChannelMapping(req.ChannelID, req.BridgeRoomID); err != nil {
m.logger.LogError("Failed to create channel mapping in Mattermost bridge", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to create channel mapping in Mattermost bridge: %w", err)
}
// Share the channel using Mattermost's shared channels API
if err = m.shareChannel(req); err != nil {
m.logger.LogError("Failed to share channel", "channel_id", req.ChannelID, "bridge_room_id", req.BridgeRoomID, "error", err)
// Don't fail the entire operation if sharing fails, but log the error
m.logger.LogWarn("Channel mapping created but sharing failed - channel may not sync properly")
}
m.logger.LogInfo("Successfully created channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "bridge_room_id", req.BridgeRoomID)
return nil
}
// DeleteChannepMapping handles the deletion of a channel mapping by calling the appropriate bridges
func (m *BridgeManager) DeleteChannepMapping(req model.DeleteChannelMappingRequest) error {
// Validate request
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid delete request: %w", err)
}
m.logger.LogDebug("Deleting channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "user_id", req.UserID, "team_id", req.TeamID)
// Get the specific bridge
bridge, err := m.GetBridge(req.BridgeName)
if err != nil {
m.logger.LogError("Failed to get bridge", "bridge_name", req.BridgeName, "error", err)
return fmt.Errorf("failed to get bridge '%s': %w", req.BridgeName, err)
}
// Check if bridge is connected
if !bridge.IsConnected() {
return fmt.Errorf("bridge '%s' is not connected", req.BridgeName)
}
// Delete the channel mapping from the specific bridge
if err = bridge.DeleteChannelMapping(req.ChannelID); err != nil {
m.logger.LogError("Failed to delete channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "error", err)
return fmt.Errorf("failed to delete channel mapping for bridge '%s': %w", req.BridgeName, err)
}
// Also delete from Mattermost bridge to clean up reverse mappings
mattermostBridge, err := m.GetBridge("mattermost")
if err != nil {
m.logger.LogError("Failed to get Mattermost bridge", "error", err)
return fmt.Errorf("failed to get Mattermost bridge: %w", err)
}
// Delete the channel mapping from the Mattermost bridge
if err = mattermostBridge.DeleteChannelMapping(req.ChannelID); err != nil {
m.logger.LogError("Failed to delete channel mapping from Mattermost bridge", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "error", err)
return fmt.Errorf("failed to delete channel mapping from Mattermost bridge: %w", err)
}
// Unshare the channel using Mattermost's shared channels API
if err = m.unshareChannel(req.ChannelID); err != nil {
m.logger.LogError("Failed to unshare channel", "channel_id", req.ChannelID, "error", err)
// Don't fail the entire operation if unsharing fails, but log the error
m.logger.LogWarn("Channel mapping deleted but unsharing failed - channel may still appear as shared")
}
m.logger.LogInfo("Successfully deleted channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName)
return nil
}
// shareChannel creates a shared channel configuration using the Mattermost API
func (m *BridgeManager) shareChannel(req model.CreateChannelMappingRequest) error {
if m.remoteID == "" {
return fmt.Errorf("remote ID not set - plugin not registered for shared channels")
}
// Create SharedChannel configuration
sharedChannel := &mmModel.SharedChannel{
ChannelId: req.ChannelID,
TeamId: req.TeamID,
Home: true,
ReadOnly: false,
ShareName: model.SanitizeShareName(fmt.Sprintf("bridge-%s", req.BridgeRoomID)),
ShareDisplayName: fmt.Sprintf("Bridge: %s", req.BridgeRoomID),
SharePurpose: fmt.Sprintf("Shared channel bridged to %s", req.BridgeRoomID),
ShareHeader: "test header",
CreatorId: req.UserID,
RemoteId: m.remoteID,
}
// Share the channel
sharedChannel, err := m.api.ShareChannel(sharedChannel)
if err != nil {
return fmt.Errorf("failed to share channel via API: %w", err)
}
m.logger.LogInfo("Successfully shared channel", "channel_id", req.ChannelID, "shared_channel_id", sharedChannel.ChannelId)
return nil
}
// unshareChannel removes shared channel configuration using the Mattermost API
func (m *BridgeManager) unshareChannel(channelID string) error {
// Unshare the channel
unshared, err := m.api.UnshareChannel(channelID)
if err != nil {
return fmt.Errorf("failed to unshare channel via API: %w", err)
}
if !unshared {
m.logger.LogWarn("Channel was not shared or already unshared", "channel_id", channelID)
} else {
m.logger.LogInfo("Successfully unshared channel", "channel_id", channelID)
}
return nil
}

View file

@ -0,0 +1,338 @@
package mattermost
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/config"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
pluginModel "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/model"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/store/kvstore"
"github.com/mattermost/mattermost/server/public/plugin"
)
// mattermostBridge handles syncing messages between Mattermost instances
type mattermostBridge struct {
logger logger.Logger
api plugin.API
kvstore kvstore.KVStore
userManager pluginModel.BridgeUserManager
// Connection management
connected atomic.Bool
ctx context.Context
cancel context.CancelFunc
// Current configuration
config *config.Configuration
configMu sync.RWMutex
// Channel mappings cache
channelMappings map[string]string
mappingsMu sync.RWMutex
}
// NewBridge creates a new Mattermost bridge
func NewBridge(log logger.Logger, api plugin.API, kvstore kvstore.KVStore, cfg *config.Configuration) pluginModel.Bridge {
ctx, cancel := context.WithCancel(context.Background())
bridge := &mattermostBridge{
logger: log,
api: api,
kvstore: kvstore,
ctx: ctx,
cancel: cancel,
channelMappings: make(map[string]string),
config: cfg,
userManager: bridge.NewUserManager("mattermost", log),
}
return bridge
}
// UpdateConfiguration updates the bridge configuration
func (b *mattermostBridge) UpdateConfiguration(newConfig any) error {
cfg, ok := newConfig.(*config.Configuration)
if !ok {
return fmt.Errorf("invalid configuration type")
}
b.configMu.Lock()
b.config = cfg
b.configMu.Unlock()
// Log the configuration change
b.logger.LogInfo("Mattermost bridge configuration updated")
return nil
}
// Start initializes the bridge
func (b *mattermostBridge) Start() error {
b.logger.LogDebug("Starting Mattermost bridge")
b.configMu.RLock()
config := b.config
b.configMu.RUnlock()
if config == nil {
return fmt.Errorf("bridge configuration not set")
}
// For Mattermost bridge, we're always "connected" since we're running within Mattermost
b.connected.Store(true)
// Load existing channel mappings
if err := b.loadChannelMappings(); err != nil {
b.logger.LogWarn("Failed to load some channel mappings", "error", err)
}
b.logger.LogInfo("Mattermost bridge started successfully")
return nil
}
// Stop shuts down the bridge
func (b *mattermostBridge) Stop() error {
b.logger.LogInfo("Stopping Mattermost bridge")
if b.cancel != nil {
b.cancel()
}
b.connected.Store(false)
b.logger.LogInfo("Mattermost bridge stopped")
return nil
}
// loadChannelMappings loads existing channel mappings from KV store
func (b *mattermostBridge) loadChannelMappings() error {
b.logger.LogDebug("Loading channel mappings for Mattermost bridge")
// Get all channel mappings from KV store for Mattermost bridge
mappings, err := b.getAllChannelMappings()
if err != nil {
return fmt.Errorf("failed to load channel mappings: %w", err)
}
if len(mappings) == 0 {
b.logger.LogInfo("No channel mappings found for Mattermost bridge")
return nil
}
b.logger.LogInfo("Found channel mappings for Mattermost bridge", "count", len(mappings))
// Update local cache
b.mappingsMu.Lock()
for channelID, roomID := range mappings {
b.channelMappings[channelID] = roomID
}
b.mappingsMu.Unlock()
return nil
}
// getAllChannelMappings retrieves all channel mappings from KV store for Mattermost bridge
func (b *mattermostBridge) getAllChannelMappings() (map[string]string, error) {
if b.kvstore == nil {
return nil, fmt.Errorf("KV store not initialized")
}
mappings := make(map[string]string)
// Get all keys with the Mattermost bridge mapping prefix
mattermostPrefix := kvstore.KeyPrefixChannelMap + "mattermost_"
keys, err := b.kvstore.ListKeysWithPrefix(0, 1000, mattermostPrefix)
if err != nil {
return nil, fmt.Errorf("failed to list Mattermost bridge mapping keys: %w", err)
}
// Load each mapping
for _, key := range keys {
channelIDBytes, err := b.kvstore.Get(key)
if err != nil {
b.logger.LogWarn("Failed to load mapping for key", "key", key, "error", err)
continue
}
// Extract room ID from the key
roomID := kvstore.ExtractIdentifierFromChannelMapKey(key, "mattermost")
if roomID == "" {
b.logger.LogWarn("Failed to extract room ID from key", "key", key)
continue
}
channelID := string(channelIDBytes)
mappings[channelID] = roomID
}
return mappings, nil
}
// IsConnected returns whether the bridge is connected
func (b *mattermostBridge) IsConnected() bool {
// Mattermost bridge is always "connected" since it runs within Mattermost
return b.connected.Load()
}
// Ping actively tests the Mattermost API connectivity
func (b *mattermostBridge) Ping() error {
if !b.connected.Load() {
return fmt.Errorf("Mattermost bridge is not connected")
}
if b.api == nil {
return fmt.Errorf("Mattermost API not initialized")
}
b.logger.LogDebug("Testing Mattermost bridge connectivity with API ping")
// Test API connectivity with a lightweight call
// Using GetServerVersion as it's a simple, read-only operation
version := b.api.GetServerVersion()
if version == "" {
b.logger.LogWarn("Mattermost bridge ping returned empty version")
return fmt.Errorf("Mattermost API ping returned empty server version")
}
b.logger.LogDebug("Mattermost bridge ping successful", "server_version", version)
return nil
}
// CreateChannelMapping creates a mapping between a Mattermost channel and another Mattermost room/channel
func (b *mattermostBridge) CreateChannelMapping(channelID, roomID string) error {
if b.kvstore == nil {
return fmt.Errorf("KV store not initialized")
}
// Store forward and reverse mappings using bridge-agnostic keys
err := b.kvstore.Set(kvstore.BuildChannelMapKey("mattermost", channelID), []byte(roomID))
if err != nil {
return fmt.Errorf("failed to store channel room mapping: %w", err)
}
// Update local cache
b.mappingsMu.Lock()
b.channelMappings[channelID] = roomID
b.mappingsMu.Unlock()
b.logger.LogInfo("Created Mattermost channel room mapping", "channel_id", channelID, "room_id", roomID)
return nil
}
// GetChannelMapping gets the room ID for a Mattermost channel
func (b *mattermostBridge) GetChannelMapping(channelID string) (string, error) {
// Check cache first
b.mappingsMu.RLock()
roomID, exists := b.channelMappings[channelID]
b.mappingsMu.RUnlock()
if exists {
return roomID, nil
}
if b.kvstore == nil {
return "", fmt.Errorf("KV store not initialized")
}
// Check if we have a mapping in the KV store for this channel ID
roomIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("mattermost", channelID))
if err != nil {
return "", nil // Unmapped channels are expected
}
roomID = string(roomIDBytes)
// Update cache
b.mappingsMu.Lock()
b.channelMappings[channelID] = roomID
b.mappingsMu.Unlock()
return roomID, nil
}
// DeleteChannelMapping removes a mapping between a Mattermost channel and room
func (b *mattermostBridge) DeleteChannelMapping(channelID string) error {
if b.kvstore == nil {
return fmt.Errorf("KV store not initialized")
}
// Get the room ID from the mapping before deleting
roomID, err := b.GetChannelMapping(channelID)
if err != nil {
return fmt.Errorf("failed to get channel mapping: %w", err)
}
if roomID == "" {
return fmt.Errorf("channel is not mapped to any room")
}
// Delete forward and reverse mappings from KV store
err = b.kvstore.Delete(kvstore.BuildChannelMapKey("mattermost", channelID))
if err != nil {
return fmt.Errorf("failed to delete channel room mapping: %w", err)
}
// Remove from local cache
b.mappingsMu.Lock()
delete(b.channelMappings, channelID)
b.mappingsMu.Unlock()
b.logger.LogInfo("Deleted Mattermost channel room mapping", "channel_id", channelID, "room_id", roomID)
return nil
}
// RoomExists checks if a Mattermost channel exists on the server
func (b *mattermostBridge) RoomExists(roomID string) (bool, error) {
if b.api == nil {
return false, fmt.Errorf("Mattermost API not initialized")
}
b.logger.LogDebug("Checking if Mattermost channel exists", "channel_id", roomID)
// Use the Mattermost API to check if the channel exists
channel, appErr := b.api.GetChannel(roomID)
if appErr != nil {
if appErr.StatusCode == 404 {
b.logger.LogDebug("Mattermost channel does not exist", "channel_id", roomID)
return false, nil
}
b.logger.LogError("Failed to check channel existence", "channel_id", roomID, "error", appErr)
return false, fmt.Errorf("failed to check channel existence: %w", appErr)
}
if channel == nil {
b.logger.LogDebug("Mattermost channel does not exist (nil response)", "channel_id", roomID)
return false, nil
}
b.logger.LogDebug("Mattermost channel exists", "channel_id", roomID, "channel_name", channel.Name)
return true, nil
}
// GetRoomMapping retrieves the Mattermost channel ID for a given room ID (reverse lookup)
func (b *mattermostBridge) GetRoomMapping(roomID string) (string, error) {
if b.kvstore == nil {
return "", fmt.Errorf("KV store not initialized")
}
b.logger.LogDebug("Getting channel mapping for Mattermost room", "room_id", roomID)
// Look up the channel ID using the room ID as the key
channelIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("mattermost", roomID))
if err != nil {
// No mapping found is not an error, just return empty string
b.logger.LogDebug("No channel mapping found for room", "room_id", roomID)
return "", nil
}
channelID := string(channelIDBytes)
b.logger.LogDebug("Found channel mapping for room", "room_id", roomID, "channel_id", channelID)
return channelID, nil
}
// GetUserManager returns the user manager for this bridge
func (b *mattermostBridge) GetUserManager() pluginModel.BridgeUserManager {
return b.userManager
}

View file

@ -0,0 +1,300 @@
package mattermost
import (
"context"
"fmt"
"sync"
"time"
"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"
mmModel "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// MattermostUser represents a Mattermost user that implements the BridgeUser interface
type MattermostUser struct {
// User identity
id string
displayName string
username string
email string
// Mattermost API
api plugin.API
// State management
state model.UserState
stateMu sync.RWMutex
// Configuration
config *config.Configuration
// Goroutine lifecycle
ctx context.Context
cancel context.CancelFunc
// Logger
logger logger.Logger
}
// NewMattermostUser creates a new Mattermost user
func NewMattermostUser(id, displayName, username, email string, api plugin.API, cfg *config.Configuration, logger logger.Logger) *MattermostUser {
ctx, cancel := context.WithCancel(context.Background())
return &MattermostUser{
id: id,
displayName: displayName,
username: username,
email: email,
api: api,
state: model.UserStateOffline,
config: cfg,
ctx: ctx,
cancel: cancel,
logger: logger,
}
}
// Validation
func (u *MattermostUser) Validate() error {
if u.id == "" {
return fmt.Errorf("user ID cannot be empty")
}
if u.username == "" {
return fmt.Errorf("username cannot be empty")
}
if u.config == nil {
return fmt.Errorf("configuration cannot be nil")
}
if u.api == nil {
return fmt.Errorf("Mattermost API cannot be nil")
}
return nil
}
// Identity (bridge-agnostic)
func (u *MattermostUser) GetID() string {
return u.id
}
func (u *MattermostUser) GetDisplayName() string {
return u.displayName
}
// State management
func (u *MattermostUser) GetState() model.UserState {
u.stateMu.RLock()
defer u.stateMu.RUnlock()
return u.state
}
func (u *MattermostUser) SetState(state model.UserState) error {
u.stateMu.Lock()
defer u.stateMu.Unlock()
u.logger.LogDebug("Changing Mattermost user state", "user_id", u.id, "old_state", u.state, "new_state", state)
u.state = state
// TODO: Update user status in Mattermost if needed
// This could involve setting custom status or presence indicators
return nil
}
// Channel operations (abstracted from rooms/channels/groups)
func (u *MattermostUser) JoinChannel(channelID string) error {
u.logger.LogDebug("Mattermost user joining channel", "user_id", u.id, "channel_id", channelID)
// Add user to channel
_, appErr := u.api.AddUserToChannel(channelID, u.id, "")
if appErr != nil {
return fmt.Errorf("failed to add Mattermost user %s to channel %s: %w", u.id, channelID, appErr)
}
u.logger.LogInfo("Mattermost user joined channel", "user_id", u.id, "channel_id", channelID)
return nil
}
func (u *MattermostUser) LeaveChannel(channelID string) error {
u.logger.LogDebug("Mattermost user leaving channel", "user_id", u.id, "channel_id", channelID)
// Remove user from channel
appErr := u.api.DeleteChannelMember(channelID, u.id)
if appErr != nil {
return fmt.Errorf("failed to remove Mattermost user %s from channel %s: %w", u.id, channelID, appErr)
}
u.logger.LogInfo("Mattermost user left channel", "user_id", u.id, "channel_id", channelID)
return nil
}
func (u *MattermostUser) SendMessageToChannel(channelID, message string) error {
u.logger.LogDebug("Mattermost user sending message to channel", "user_id", u.id, "channel_id", channelID)
// Create post
post := &mmModel.Post{
UserId: u.id,
ChannelId: channelID,
Message: message,
}
// Send post
_, appErr := u.api.CreatePost(post)
if appErr != nil {
return fmt.Errorf("failed to send message to Mattermost channel %s: %w", channelID, appErr)
}
u.logger.LogDebug("Mattermost user sent message to channel", "user_id", u.id, "channel_id", channelID)
return nil
}
// Connection lifecycle
func (u *MattermostUser) Connect() error {
u.logger.LogDebug("Connecting Mattermost user", "user_id", u.id, "username", u.username)
// For Mattermost users, "connecting" means verifying the user exists and is accessible
user, appErr := u.api.GetUser(u.id)
if appErr != nil {
return fmt.Errorf("failed to verify Mattermost user %s: %w", u.id, appErr)
}
// Update user information if it has changed
if user.GetDisplayName("") != u.displayName {
u.displayName = user.GetDisplayName("")
u.logger.LogDebug("Updated Mattermost user display name", "user_id", u.id, "display_name", u.displayName)
}
u.logger.LogInfo("Mattermost user connected", "user_id", u.id, "username", u.username)
// Update state to online
_ = u.SetState(model.UserStateOnline)
return nil
}
func (u *MattermostUser) Disconnect() error {
u.logger.LogDebug("Disconnecting Mattermost user", "user_id", u.id, "username", u.username)
// For Mattermost users, "disconnecting" is mostly a state change
// The user still exists in Mattermost, but we're not actively managing them
_ = u.SetState(model.UserStateOffline)
u.logger.LogInfo("Mattermost user disconnected", "user_id", u.id, "username", u.username)
return nil
}
func (u *MattermostUser) IsConnected() bool {
return u.GetState() == model.UserStateOnline
}
func (u *MattermostUser) Ping() error {
if u.api == nil {
return fmt.Errorf("Mattermost API not initialized for user %s", u.id)
}
// Test API connectivity by getting server version
version := u.api.GetServerVersion()
if version == "" {
return fmt.Errorf("Mattermost API ping returned empty server version for user %s", u.id)
}
return nil
}
// CheckChannelExists checks if a Mattermost channel exists
func (u *MattermostUser) CheckChannelExists(channelID string) (bool, error) {
if u.api == nil {
return false, fmt.Errorf("Mattermost API not initialized for user %s", u.id)
}
// Try to get the channel by ID
_, appErr := u.api.GetChannel(channelID)
if appErr != nil {
// Check if it's a "not found" error
if appErr.StatusCode == 404 {
return false, nil // Channel doesn't exist
}
return false, fmt.Errorf("failed to check channel existence: %w", appErr)
}
return true, nil
}
// Goroutine lifecycle
func (u *MattermostUser) Start(ctx context.Context) error {
u.logger.LogDebug("Starting Mattermost user", "user_id", u.id, "username", u.username)
// Update context
u.ctx = ctx
// Connect to verify user exists
if err := u.Connect(); err != nil {
return fmt.Errorf("failed to start Mattermost user %s: %w", u.id, err)
}
// Start monitoring in a goroutine
go u.monitor()
u.logger.LogInfo("Mattermost user started", "user_id", u.id, "username", u.username)
return nil
}
func (u *MattermostUser) Stop() error {
u.logger.LogDebug("Stopping Mattermost user", "user_id", u.id, "username", u.username)
// Cancel context to stop goroutines
if u.cancel != nil {
u.cancel()
}
// Disconnect
if err := u.Disconnect(); err != nil {
u.logger.LogWarn("Error disconnecting Mattermost user during stop", "user_id", u.id, "error", err)
}
u.logger.LogInfo("Mattermost user stopped", "user_id", u.id, "username", u.username)
return nil
}
// monitor periodically checks the user's status and updates information
func (u *MattermostUser) monitor() {
u.logger.LogDebug("Starting monitor for Mattermost user", "user_id", u.id)
// Simple monitoring - check user exists periodically
for {
select {
case <-u.ctx.Done():
u.logger.LogDebug("Monitor stopped for Mattermost user", "user_id", u.id)
return
default:
// Wait before next check
timeoutCtx, cancel := context.WithTimeout(u.ctx, 60*time.Second)
select {
case <-u.ctx.Done():
cancel()
return
case <-timeoutCtx.Done():
cancel()
continue
}
}
}
}
// GetUsername returns the Mattermost username for this user (Mattermost-specific method)
func (u *MattermostUser) GetUsername() string {
return u.username
}
// GetEmail returns the Mattermost email for this user (Mattermost-specific method)
func (u *MattermostUser) GetEmail() string {
return u.email
}
// GetAPI returns the Mattermost API instance (for advanced operations)
func (u *MattermostUser) GetAPI() plugin.API {
return u.api
}

188
server/bridge/user.go Normal file
View file

@ -0,0 +1,188 @@
package bridge
import (
"context"
"fmt"
"sync"
"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"
)
// Manager implements the BridgeUserManager interface with bridge-agnostic logic
type UserManager struct {
bridgeType string
logger logger.Logger
users map[string]model.BridgeUser
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
}
// NewUserManager creates a new user manager for a specific bridge type
func NewUserManager(bridgeType string, logger logger.Logger) model.BridgeUserManager {
ctx, cancel := context.WithCancel(context.Background())
return &UserManager{
bridgeType: bridgeType,
logger: logger,
users: make(map[string]model.BridgeUser),
ctx: ctx,
cancel: cancel,
}
}
// CreateUser adds a user to the bridge system
func (m *UserManager) CreateUser(user model.BridgeUser) error {
// Validate the user first
if err := user.Validate(); err != nil {
return fmt.Errorf("invalid user: %w", err)
}
userID := user.GetID()
m.mu.Lock()
defer m.mu.Unlock()
// Check if user already exists
if _, exists := m.users[userID]; exists {
return fmt.Errorf("user %s already exists", userID)
}
m.logger.LogDebug("Adding bridge user", "bridge_type", m.bridgeType, "user_id", userID, "display_name", user.GetDisplayName())
// Store the user
m.users[userID] = user
m.logger.LogInfo("Bridge user added successfully", "bridge_type", m.bridgeType, "user_id", userID)
return nil
}
// GetUser retrieves a user by ID
func (m *UserManager) GetUser(userID string) (model.BridgeUser, error) {
m.mu.RLock()
defer m.mu.RUnlock()
user, exists := m.users[userID]
if !exists {
return nil, fmt.Errorf("user %s not found", userID)
}
return user, nil
}
// DeleteUser removes a user from the bridge system
func (m *UserManager) DeleteUser(userID string) error {
m.mu.Lock()
defer m.mu.Unlock()
user, exists := m.users[userID]
if !exists {
return fmt.Errorf("user %s not found", userID)
}
m.logger.LogDebug("Deleting bridge user", "bridge_type", m.bridgeType, "user_id", userID)
// Stop the user first
if err := user.Stop(); err != nil {
m.logger.LogWarn("Error stopping user during deletion", "bridge_type", m.bridgeType, "user_id", userID, "error", err)
}
// Disconnect if still connected
if user.IsConnected() {
if err := user.Disconnect(); err != nil {
m.logger.LogWarn("Error disconnecting user during deletion", "bridge_type", m.bridgeType, "user_id", userID, "error", err)
}
}
// Remove from map
delete(m.users, userID)
m.logger.LogInfo("Bridge user deleted successfully", "bridge_type", m.bridgeType, "user_id", userID)
return nil
}
// ListUsers returns a list of all users
func (m *UserManager) ListUsers() []model.BridgeUser {
m.mu.RLock()
defer m.mu.RUnlock()
users := make([]model.BridgeUser, 0, len(m.users))
for _, user := range m.users {
users = append(users, user)
}
return users
}
// HasUser checks if a user exists
func (m *UserManager) HasUser(userID string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, exists := m.users[userID]
return exists
}
// Start initializes the user manager
func (m *UserManager) Start(ctx context.Context) error {
m.logger.LogDebug("Starting user manager", "bridge_type", m.bridgeType)
// Update context
m.ctx = ctx
// Start all existing users
m.mu.RLock()
defer m.mu.RUnlock()
for userID, user := range m.users {
if err := user.Start(ctx); err != nil {
m.logger.LogWarn("Failed to start user during manager startup", "bridge_type", m.bridgeType, "user_id", userID, "error", err)
// Continue starting other users even if one fails
}
}
m.logger.LogInfo("User manager started", "bridge_type", m.bridgeType, "user_count", len(m.users))
return nil
}
// Stop shuts down the user manager
func (m *UserManager) Stop() error {
m.logger.LogDebug("Stopping user manager", "bridge_type", m.bridgeType)
if m.cancel != nil {
m.cancel()
}
// Stop all users
m.mu.RLock()
users := make([]model.BridgeUser, 0, len(m.users))
for _, user := range m.users {
users = append(users, user)
}
m.mu.RUnlock()
for _, user := range users {
if err := user.Stop(); err != nil {
m.logger.LogWarn("Error stopping user during manager shutdown", "bridge_type", m.bridgeType, "user_id", user.GetID(), "error", err)
}
}
m.logger.LogInfo("User manager stopped", "bridge_type", m.bridgeType)
return nil
}
// UpdateConfiguration updates configuration for all users
func (m *UserManager) UpdateConfiguration(cfg *config.Configuration) error {
m.logger.LogDebug("Updating configuration for user manager", "bridge_type", m.bridgeType)
// For now, we don't propagate config changes to individual users
// This can be extended later if needed
m.logger.LogInfo("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
}

View file

@ -9,6 +9,7 @@ import (
"fmt"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/config"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
pluginModel "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/model"
@ -19,10 +20,11 @@ import (
// xmppBridge handles syncing messages between Mattermost and XMPP
type xmppBridge struct {
logger logger.Logger
api plugin.API
kvstore kvstore.KVStore
xmppClient *xmppClient.Client
logger logger.Logger
api plugin.API
kvstore kvstore.KVStore
bridgeClient *xmppClient.Client // Main bridge XMPP client connection
userManager pluginModel.BridgeUserManager
// Connection management
connected atomic.Bool
@ -41,7 +43,7 @@ type xmppBridge struct {
// NewBridge creates a new XMPP bridge
func NewBridge(log logger.Logger, api plugin.API, kvstore kvstore.KVStore, cfg *config.Configuration) pluginModel.Bridge {
ctx, cancel := context.WithCancel(context.Background())
bridge := &xmppBridge{
b := &xmppBridge{
logger: log,
api: api,
kvstore: kvstore,
@ -49,14 +51,15 @@ func NewBridge(log logger.Logger, api plugin.API, kvstore kvstore.KVStore, cfg *
cancel: cancel,
channelMappings: make(map[string]string),
config: cfg,
userManager: bridge.NewUserManager("xmpp", log),
}
// Initialize XMPP client with configuration
if cfg.EnableSync && cfg.XMPPServerURL != "" && cfg.XMPPUsername != "" && cfg.XMPPPassword != "" {
bridge.xmppClient = bridge.createXMPPClient(cfg)
b.bridgeClient = b.createXMPPClient(cfg)
}
return bridge
return b
}
// createXMPPClient creates an XMPP client with the given configuration
@ -71,8 +74,9 @@ func (b *xmppBridge) createXMPPClient(cfg *config.Configuration) *xmppClient.Cli
cfg.XMPPUsername,
cfg.XMPPPassword,
cfg.GetXMPPResource(),
"", // remoteID not needed for bridge user
"", // remoteID not needed for bridge client
tlsConfig,
b.logger,
)
}
@ -86,28 +90,28 @@ func (b *xmppBridge) UpdateConfiguration(newConfig any) error {
b.configMu.Lock()
oldConfig := b.config
b.config = cfg
defer b.configMu.Unlock()
b.logger.LogInfo("XMPP bridge configuration updated")
// Initialize or update XMPP client with new configuration
if cfg.EnableSync {
if cfg.XMPPServerURL == "" || cfg.XMPPUsername == "" || cfg.XMPPPassword == "" {
b.configMu.Unlock()
return fmt.Errorf("XMPP server URL, username, and password are required when sync is enabled")
}
b.xmppClient = b.createXMPPClient(cfg)
b.bridgeClient = b.createXMPPClient(cfg)
} else {
b.xmppClient = nil
b.bridgeClient = nil
}
b.configMu.Unlock()
// Check if we need to restart the bridge due to configuration changes
wasConnected := b.connected.Load()
needsRestart := oldConfig != nil && !oldConfig.Equals(cfg) && wasConnected
// Log the configuration change
if needsRestart {
b.logger.LogInfo("Configuration changed, restarting bridge", "old_config", oldConfig, "new_config", cfg)
b.logger.LogInfo("Configuration changed, restarting bridge")
} else {
b.logger.LogInfo("Configuration updated", "config", cfg)
}
@ -142,9 +146,6 @@ func (b *xmppBridge) Start() error {
return fmt.Errorf("bridge configuration not set")
}
// Print the configuration for debugging
b.logger.LogDebug("Bridge configuration", "config", config)
if !config.EnableSync {
b.logger.LogInfo("XMPP sync is disabled, bridge will not start")
return nil
@ -177,8 +178,8 @@ func (b *xmppBridge) Stop() error {
b.cancel()
}
if b.xmppClient != nil {
if err := b.xmppClient.Disconnect(); err != nil {
if b.bridgeClient != nil {
if err := b.bridgeClient.Disconnect(); err != nil {
b.logger.LogWarn("Error disconnecting from XMPP server", "error", err)
}
}
@ -190,13 +191,13 @@ func (b *xmppBridge) Stop() error {
// connectToXMPP establishes connection to the XMPP server
func (b *xmppBridge) connectToXMPP() error {
if b.xmppClient == nil {
if b.bridgeClient == nil {
return fmt.Errorf("XMPP client is not initialized")
}
b.logger.LogDebug("Connecting to XMPP server")
err := b.xmppClient.Connect()
err := b.bridgeClient.Connect()
if err != nil {
b.connected.Store(false)
return fmt.Errorf("failed to connect to XMPP server: %w", err)
@ -206,11 +207,11 @@ func (b *xmppBridge) connectToXMPP() error {
b.logger.LogInfo("Successfully connected to XMPP server")
// Set online presence after successful connection
if err := b.xmppClient.SetOnlinePresence(); err != nil {
if err := b.bridgeClient.SetOnlinePresence(); err != nil {
b.logger.LogWarn("Failed to set online presence", "error", err)
// Don't fail the connection for presence issues
} else {
b.logger.LogDebug("Set bridge user online presence")
b.logger.LogDebug("Set bridge client online presence")
}
return nil
@ -249,7 +250,7 @@ func (b *xmppBridge) joinXMPPRoom(channelID, roomJID string) error {
return fmt.Errorf("not connected to XMPP server")
}
err := b.xmppClient.JoinRoom(roomJID)
err := b.bridgeClient.JoinRoom(roomJID)
if err != nil {
return fmt.Errorf("failed to join XMPP room: %w", err)
}
@ -311,7 +312,7 @@ func (b *xmppBridge) connectionMonitor() {
case <-b.ctx.Done():
return
case <-ticker.C:
if err := b.checkConnection(); err != nil {
if err := b.Ping(); err != nil {
b.logger.LogWarn("XMPP connection check failed", "error", err)
b.handleReconnection()
}
@ -319,14 +320,6 @@ func (b *xmppBridge) connectionMonitor() {
}
}
// checkConnection verifies the XMPP connection is still active
func (b *xmppBridge) checkConnection() error {
if !b.connected.Load() {
return fmt.Errorf("not connected")
}
return b.xmppClient.TestConnection()
}
// handleReconnection attempts to reconnect to XMPP and rejoin rooms
func (b *xmppBridge) handleReconnection() {
b.configMu.RLock()
@ -340,8 +333,8 @@ func (b *xmppBridge) handleReconnection() {
b.logger.LogInfo("Attempting to reconnect to XMPP server")
b.connected.Store(false)
if b.xmppClient != nil {
b.xmppClient.Disconnect()
if b.bridgeClient != nil {
_ = b.bridgeClient.Disconnect()
}
// Retry connection with exponential backoff
@ -378,19 +371,35 @@ func (b *xmppBridge) IsConnected() bool {
return b.connected.Load()
}
// CreateChannelRoomMapping creates a mapping between a Mattermost channel and XMPP room
func (b *xmppBridge) CreateChannelRoomMapping(channelID, roomJID string) error {
// Ping actively tests the XMPP connection health
func (b *xmppBridge) Ping() error {
if !b.connected.Load() {
return fmt.Errorf("XMPP bridge is not connected")
}
if b.bridgeClient == nil {
return fmt.Errorf("XMPP client not initialized")
}
b.logger.LogDebug("Testing XMPP bridge connectivity with ping")
// Use the XMPP client's ping method
if err := b.bridgeClient.Ping(); err != nil {
b.logger.LogWarn("XMPP bridge ping failed", "error", err)
return fmt.Errorf("XMPP bridge ping failed: %w", err)
}
b.logger.LogDebug("XMPP bridge ping successful")
return nil
}
// CreateChannelMapping creates a mapping between a Mattermost channel and XMPP room
func (b *xmppBridge) CreateChannelMapping(channelID, roomJID string) error {
if b.kvstore == nil {
return fmt.Errorf("KV store not initialized")
}
// Store forward and reverse mappings using bridge-agnostic keys
err := b.kvstore.Set(kvstore.BuildChannelMapKey("mattermost", channelID), []byte(roomJID))
if err != nil {
return fmt.Errorf("failed to store channel room mapping: %w", err)
}
err = b.kvstore.Set(kvstore.BuildChannelMapKey("xmpp", roomJID), []byte(channelID))
err := b.kvstore.Set(kvstore.BuildChannelMapKey("xmpp", roomJID), []byte(channelID))
if err != nil {
return fmt.Errorf("failed to store reverse room mapping: %w", err)
}
@ -402,7 +411,7 @@ func (b *xmppBridge) CreateChannelRoomMapping(channelID, roomJID string) error {
// Join the room if connected
if b.connected.Load() {
if err := b.xmppClient.JoinRoom(roomJID); err != nil {
if err := b.bridgeClient.JoinRoom(roomJID); err != nil {
b.logger.LogWarn("Failed to join newly mapped room", "channel_id", channelID, "room_jid", roomJID, "error", err)
}
}
@ -411,8 +420,8 @@ func (b *xmppBridge) CreateChannelRoomMapping(channelID, roomJID string) error {
return nil
}
// GetChannelRoomMapping gets the XMPP room JID for a Mattermost channel
func (b *xmppBridge) GetChannelRoomMapping(channelID string) (string, error) {
// GetChannelMapping gets the XMPP room JID for a Mattermost channel
func (b *xmppBridge) GetChannelMapping(channelID string) (string, error) {
// Check cache first
b.mappingsMu.RLock()
roomJID, exists := b.channelMappings[channelID]
@ -427,7 +436,7 @@ func (b *xmppBridge) GetChannelRoomMapping(channelID string) (string, error) {
}
// Check if we have a mapping in the KV store for this channel ID
roomJIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("mattermost", channelID))
roomJIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("xmpp", channelID))
if err != nil {
return "", nil // Unmapped channels are expected
}
@ -441,3 +450,92 @@ func (b *xmppBridge) GetChannelRoomMapping(channelID string) (string, error) {
return roomJID, nil
}
// DeleteChannelMapping removes a mapping between a Mattermost channel and XMPP room
func (b *xmppBridge) DeleteChannelMapping(channelID string) error {
if b.kvstore == nil {
return fmt.Errorf("KV store not initialized")
}
// Get the room JID from the mapping before deleting
roomJID, err := b.GetChannelMapping(channelID)
if err != nil {
return fmt.Errorf("failed to get channel mapping: %w", err)
}
if roomJID == "" {
return fmt.Errorf("channel is not mapped to any room")
}
err = b.kvstore.Delete(kvstore.BuildChannelMapKey("xmpp", roomJID))
if err != nil {
return fmt.Errorf("failed to delete reverse room mapping: %w", err)
}
// Remove from local cache
b.mappingsMu.Lock()
delete(b.channelMappings, channelID)
b.mappingsMu.Unlock()
// Leave the room if connected
if b.connected.Load() && b.bridgeClient != nil {
if err := b.bridgeClient.LeaveRoom(roomJID); err != nil {
b.logger.LogWarn("Failed to leave unmapped room", "channel_id", channelID, "room_jid", roomJID, "error", err)
// Don't fail the entire operation if leaving the room fails
} else {
b.logger.LogInfo("Left XMPP room after unmapping", "channel_id", channelID, "room_jid", roomJID)
}
}
b.logger.LogInfo("Deleted channel room mapping", "channel_id", channelID, "room_jid", roomJID)
return nil
}
// RoomExists checks if an XMPP room exists on the remote service
func (b *xmppBridge) RoomExists(roomID string) (bool, error) {
if !b.connected.Load() {
return false, fmt.Errorf("not connected to XMPP server")
}
if b.bridgeClient == nil {
return false, fmt.Errorf("XMPP client not initialized")
}
b.logger.LogDebug("Checking if XMPP room exists", "room_jid", roomID)
// Use the XMPP client to check room existence
exists, err := b.bridgeClient.CheckRoomExists(roomID)
if err != nil {
b.logger.LogError("Failed to check room existence", "room_jid", roomID, "error", err)
return false, fmt.Errorf("failed to check room existence: %w", err)
}
b.logger.LogDebug("Room existence check completed", "room_jid", roomID, "exists", exists)
return exists, nil
}
// GetRoomMapping retrieves the Mattermost channel ID for a given XMPP room JID (reverse lookup)
func (b *xmppBridge) GetRoomMapping(roomID string) (string, error) {
if b.kvstore == nil {
return "", fmt.Errorf("KV store not initialized")
}
b.logger.LogDebug("Getting channel mapping for XMPP room", "room_jid", roomID)
// Look up the channel ID using the room JID as the key
channelIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("xmpp", roomID))
if err != nil {
// No mapping found is not an error, just return empty string
b.logger.LogDebug("No channel mapping found for room", "room_jid", roomID)
return "", nil
}
channelID := string(channelIDBytes)
b.logger.LogDebug("Found channel mapping for room", "room_jid", roomID, "channel_id", channelID)
return channelID, nil
}
// GetUserManager returns the user manager for this bridge
func (b *xmppBridge) GetUserManager() pluginModel.BridgeUserManager {
return b.userManager
}

336
server/bridge/xmpp/user.go Normal file
View file

@ -0,0 +1,336 @@
package xmpp
import (
"context"
"crypto/tls"
"fmt"
"sync"
"sync/atomic"
"time"
"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"
xmppClient "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/xmpp"
)
// XMPPUser represents an XMPP user that implements the BridgeUser interface
type XMPPUser struct {
// User identity
id string
displayName string
jid string
// XMPP client
client *xmppClient.Client
// State management
state model.UserState
stateMu sync.RWMutex
connected atomic.Bool
// Configuration
config *config.Configuration
// Goroutine lifecycle
ctx context.Context
cancel context.CancelFunc
// Logger
logger logger.Logger
}
// NewXMPPUser creates a new XMPP user
func NewXMPPUser(id, displayName, jid string, cfg *config.Configuration, logger logger.Logger) *XMPPUser {
ctx, cancel := context.WithCancel(context.Background())
// Create TLS config based on certificate verification setting
tlsConfig := &tls.Config{
InsecureSkipVerify: cfg.XMPPInsecureSkipVerify,
}
// Create XMPP client for this user
client := xmppClient.NewClientWithTLS(
cfg.XMPPServerURL,
jid,
cfg.XMPPPassword, // This might need to be user-specific in the future
cfg.GetXMPPResource(),
id, // Use user ID as remote ID
tlsConfig,
logger,
)
return &XMPPUser{
id: id,
displayName: displayName,
jid: jid,
client: client,
state: model.UserStateOffline,
config: cfg,
ctx: ctx,
cancel: cancel,
logger: logger,
}
}
// Validation
func (u *XMPPUser) Validate() error {
if u.id == "" {
return fmt.Errorf("user ID cannot be empty")
}
if u.jid == "" {
return fmt.Errorf("JID cannot be empty")
}
if u.config == nil {
return fmt.Errorf("configuration cannot be nil")
}
if u.config.XMPPServerURL == "" {
return fmt.Errorf("XMPP server URL cannot be empty")
}
if u.client == nil {
return fmt.Errorf("XMPP client cannot be nil")
}
return nil
}
// Identity (bridge-agnostic)
func (u *XMPPUser) GetID() string {
return u.id
}
func (u *XMPPUser) GetDisplayName() string {
return u.displayName
}
// State management
func (u *XMPPUser) GetState() model.UserState {
u.stateMu.RLock()
defer u.stateMu.RUnlock()
return u.state
}
func (u *XMPPUser) SetState(state model.UserState) error {
u.stateMu.Lock()
defer u.stateMu.Unlock()
u.logger.LogDebug("Changing XMPP user state", "user_id", u.id, "old_state", u.state, "new_state", state)
u.state = state
// TODO: Send presence update to XMPP server based on state
// This would involve mapping UserState to XMPP presence types
return nil
}
// Channel operations
func (u *XMPPUser) JoinChannel(channelID string) error {
if !u.connected.Load() {
return fmt.Errorf("user %s is not connected", u.id)
}
u.logger.LogDebug("XMPP user joining channel", "user_id", u.id, "channel_id", channelID)
// For XMPP, channelID is the room JID
err := u.client.JoinRoom(channelID)
if err != nil {
return fmt.Errorf("failed to join XMPP room %s: %w", channelID, err)
}
u.logger.LogInfo("XMPP user joined channel", "user_id", u.id, "channel_id", channelID)
return nil
}
func (u *XMPPUser) LeaveChannel(channelID string) error {
if !u.connected.Load() {
return fmt.Errorf("user %s is not connected", u.id)
}
u.logger.LogDebug("XMPP user leaving channel", "user_id", u.id, "channel_id", channelID)
// For XMPP, channelID is the room JID
err := u.client.LeaveRoom(channelID)
if err != nil {
return fmt.Errorf("failed to leave XMPP room %s: %w", channelID, err)
}
u.logger.LogInfo("XMPP user left channel", "user_id", u.id, "channel_id", channelID)
return nil
}
func (u *XMPPUser) 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)
// Create message request for XMPP
req := xmppClient.MessageRequest{
RoomJID: channelID,
GhostUserJID: u.jid,
Message: message,
}
_, err := u.client.SendMessage(req)
if err != nil {
return fmt.Errorf("failed to send message to XMPP room %s: %w", channelID, err)
}
u.logger.LogDebug("XMPP user sent message to channel", "user_id", u.id, "channel_id", channelID)
return nil
}
// Connection lifecycle
func (u *XMPPUser) Connect() error {
u.logger.LogDebug("Connecting XMPP user", "user_id", u.id, "jid", u.jid)
err := u.client.Connect()
if err != nil {
u.connected.Store(false)
return fmt.Errorf("failed to connect XMPP user %s: %w", u.id, err)
}
u.connected.Store(true)
u.logger.LogInfo("XMPP user connected", "user_id", u.id, "jid", u.jid)
// Set online presence after successful connection
if err := u.client.SetOnlinePresence(); err != nil {
u.logger.LogWarn("Failed to set online presence for XMPP user", "user_id", u.id, "error", err)
// Don't fail the connection for presence issues
}
// Update state to online
_ = u.SetState(model.UserStateOnline)
return nil
}
func (u *XMPPUser) Disconnect() error {
u.logger.LogDebug("Disconnecting XMPP user", "user_id", u.id, "jid", u.jid)
if u.client == nil {
return nil
}
err := u.client.Disconnect()
if err != nil {
u.logger.LogWarn("Error disconnecting XMPP user", "user_id", u.id, "error", err)
}
u.connected.Store(false)
_ = u.SetState(model.UserStateOffline)
u.logger.LogInfo("XMPP user disconnected", "user_id", u.id, "jid", u.jid)
return err
}
func (u *XMPPUser) IsConnected() bool {
return u.connected.Load()
}
func (u *XMPPUser) Ping() 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)
}
return u.client.Ping()
}
// CheckChannelExists checks if an XMPP room/channel exists
func (u *XMPPUser) CheckChannelExists(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.CheckRoomExists(channelID)
}
// Goroutine lifecycle
func (u *XMPPUser) Start(ctx context.Context) error {
u.logger.LogDebug("Starting XMPP user", "user_id", u.id, "jid", u.jid)
// Update context
u.ctx = ctx
// Connect to XMPP server
if err := u.Connect(); err != nil {
return fmt.Errorf("failed to start XMPP user %s: %w", u.id, err)
}
// Start connection monitoring in a goroutine
go u.connectionMonitor()
u.logger.LogInfo("XMPP user started", "user_id", u.id, "jid", u.jid)
return nil
}
func (u *XMPPUser) Stop() error {
u.logger.LogDebug("Stopping XMPP user", "user_id", u.id, "jid", u.jid)
// Cancel context to stop goroutines
if u.cancel != nil {
u.cancel()
}
// Disconnect from XMPP server
if err := u.Disconnect(); err != nil {
u.logger.LogWarn("Error disconnecting XMPP user during stop", "user_id", u.id, "error", err)
}
u.logger.LogInfo("XMPP user stopped", "user_id", u.id, "jid", u.jid)
return nil
}
// connectionMonitor monitors the XMPP connection for this user
func (u *XMPPUser) connectionMonitor() {
u.logger.LogDebug("Starting connection monitor for XMPP user", "user_id", u.id)
// Simple monitoring - check connection periodically
for {
select {
case <-u.ctx.Done():
u.logger.LogDebug("Connection monitor stopped for XMPP user", "user_id", u.id)
return
default:
// Check connection every 30 seconds
if u.connected.Load() {
if err := u.client.Ping(); err != nil {
u.logger.LogWarn("Connection check failed for XMPP user", "user_id", u.id, "error", err)
u.connected.Store(false)
_ = u.SetState(model.UserStateOffline)
// TODO: Implement reconnection logic if needed
}
}
// Wait before next check
timeoutCtx, cancel := context.WithTimeout(u.ctx, 30*time.Second) // 30 seconds
select {
case <-u.ctx.Done():
cancel()
return
case <-timeoutCtx.Done():
cancel()
continue
}
}
}
}
// GetJID returns the XMPP JID for this user (XMPP-specific method)
func (u *XMPPUser) GetJID() string {
return u.jid
}
// GetClient returns the underlying XMPP client (for advanced operations)
func (u *XMPPUser) GetClient() *xmppClient.Client {
return u.client
}

View file

@ -29,6 +29,9 @@ func NewCommandHandler(client *pluginapi.Client, bridgeManager pluginModel.Bridg
mapSubcommand.AddTextArgument("XMPP room JID (e.g., room@conference.example.com)", "[room_jid]", "")
xmppBridgeData.AddCommand(mapSubcommand)
unmapSubcommand := model.NewAutocompleteData("unmap", "", "Unmap current channel from XMPP room")
xmppBridgeData.AddCommand(unmapSubcommand)
statusSubcommand := model.NewAutocompleteData("status", "", "Show bridge connection status")
xmppBridgeData.AddCommand(statusSubcommand)
@ -36,7 +39,7 @@ func NewCommandHandler(client *pluginapi.Client, bridgeManager pluginModel.Bridg
Trigger: xmppBridgeCommandTrigger,
AutoComplete: true,
AutoCompleteDesc: "Manage XMPP bridge mappings",
AutoCompleteHint: "[map|status]",
AutoCompleteHint: "[map|unmap|status]",
AutocompleteData: xmppBridgeData,
})
if err != nil {
@ -51,6 +54,14 @@ func NewCommandHandler(client *pluginapi.Client, bridgeManager pluginModel.Bridg
// ExecuteCommand hook calls this method to execute the commands that were registered in the NewCommandHandler function.
func (c *Handler) Handle(args *model.CommandArgs) (*model.CommandResponse, error) {
// Check if user is system admin for all plugin commands
if !c.isSystemAdmin(args.UserId) {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Only system administrators can use XMPP bridge commands.",
}, nil
}
trigger := strings.TrimPrefix(strings.Fields(args.Command)[0], "/")
switch trigger {
case xmppBridgeCommandTrigger:
@ -72,6 +83,7 @@ func (c *Handler) executeXMPPBridgeCommand(args *model.CommandArgs) *model.Comma
**Available commands:**
- ` + "`/xmppbridge map <room_jid>`" + ` - Map current channel to XMPP room
- ` + "`/xmppbridge unmap`" + ` - Unmap current channel from XMPP room
- ` + "`/xmppbridge status`" + ` - Show bridge connection status
**Example:**
@ -83,6 +95,8 @@ func (c *Handler) executeXMPPBridgeCommand(args *model.CommandArgs) *model.Comma
switch subcommand {
case "map":
return c.executeMapCommand(args, fields)
case "unmap":
return c.executeUnmapCommand(args)
case "status":
return c.executeStatusCommand(args)
default:
@ -112,7 +126,7 @@ func (c *Handler) executeMapCommand(args *model.CommandArgs, fields []string) *m
}
}
// Get the XMPP bridge
// Get the XMPP bridge to check existing mappings
bridge, err := c.bridgeManager.GetBridge("xmpp")
if err != nil {
return &model.CommandResponse{
@ -130,7 +144,7 @@ func (c *Handler) executeMapCommand(args *model.CommandArgs, fields []string) *m
}
// Check if channel is already mapped
existingMapping, err := bridge.GetChannelRoomMapping(channelID)
existingMapping, err := bridge.GetChannelMapping(channelID)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
@ -145,21 +159,73 @@ func (c *Handler) executeMapCommand(args *model.CommandArgs, fields []string) *m
}
}
// Create the mapping
err = bridge.CreateChannelRoomMapping(channelID, roomJID)
// Create the mapping using BridgeManager
mappingReq := pluginModel.CreateChannelMappingRequest{
ChannelID: channelID,
BridgeName: "xmpp",
BridgeRoomID: roomJID,
UserID: args.UserId,
TeamID: args.TeamId,
}
err = c.bridgeManager.CreateChannelMapping(mappingReq)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("❌ Failed to create channel mapping: %v", err),
}
return c.formatMappingError("create", roomJID, err)
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeInChannel,
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("✅ Successfully mapped this channel to XMPP room: `%s`", roomJID),
}
}
func (c *Handler) executeUnmapCommand(args *model.CommandArgs) *model.CommandResponse {
channelID := args.ChannelId
// Get the XMPP bridge to check existing mappings
bridge, err := c.bridgeManager.GetBridge("xmpp")
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ XMPP bridge is not available. Please check the plugin configuration.",
}
}
// Check if channel is mapped
roomJID, err := bridge.GetChannelMapping(channelID)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("Error checking existing mapping: %v", err),
}
}
if roomJID == "" {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ This channel is not mapped to any XMPP room.",
}
}
// Delete the mapping
deleteReq := pluginModel.DeleteChannelMappingRequest{
ChannelID: channelID,
BridgeName: "xmpp",
UserID: args.UserId,
TeamID: args.TeamId,
}
err = c.bridgeManager.DeleteChannepMapping(deleteReq)
if err != nil {
return c.formatMappingError("delete", roomJID, err)
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("✅ Successfully unmapped this channel from XMPP room: `%s`", roomJID),
}
}
func (c *Handler) executeStatusCommand(args *model.CommandArgs) *model.CommandResponse {
// Get the XMPP bridge
bridge, err := c.bridgeManager.GetBridge("xmpp")
@ -181,7 +247,7 @@ func (c *Handler) executeStatusCommand(args *model.CommandArgs) *model.CommandRe
// Check if current channel is mapped
channelID := args.ChannelId
roomJID, err := bridge.GetChannelRoomMapping(channelID)
roomJID, err := bridge.GetChannelMapping(channelID)
var mappingText string
if err != nil {
@ -201,6 +267,89 @@ func (c *Handler) executeStatusCommand(args *model.CommandArgs) *model.CommandRe
%s
**Commands:**
- Use `+"`/xmppbridge map <room_jid>`"+` to map this channel to an XMPP room`, statusText, mappingText),
- Use `+"`/xmppbridge map <room_jid>`"+` to map this channel to an XMPP room
- Use `+"`/xmppbridge unmap`"+` to unmap this channel from an XMPP room`, statusText, mappingText),
}
}
// isSystemAdmin checks if the user is a system administrator
func (c *Handler) isSystemAdmin(userID string) bool {
user, err := c.client.User.Get(userID)
if err != nil {
c.client.Log.Warn("Failed to get user for admin check", "user_id", userID, "error", err)
return false
}
return user.IsSystemAdmin()
}
// formatMappingError provides user-friendly error messages for mapping operations
func (c *Handler) formatMappingError(operation, roomJID string, err error) *model.CommandResponse {
errorMsg := err.Error()
// Handle specific error cases with user-friendly messages
switch {
case strings.Contains(errorMsg, "already mapped to channel"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Room Already Mapped**
The XMPP room **%s** is already connected to another channel.
**What you can do:**
- Choose a different XMPP room that isn't already in use
- Unmap the room from the other channel first using `+"`/xmppbridge unmap`"+`
- Use `+"`/xmppbridge status`"+` to check current mappings`, roomJID),
}
case strings.Contains(errorMsg, "does not exist"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Room Not Found**
The XMPP room **%s** doesn't exist or isn't accessible.
**What you can do:**
- Check that the room JID is spelled correctly
- Make sure the room exists on the XMPP server
- Verify you have permission to access the room
- Contact your XMPP administrator if needed
**Example format:** room@conference.example.com`, roomJID),
}
case strings.Contains(errorMsg, "not connected"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: ` **Bridge Not Connected**
The XMPP bridge is currently disconnected.
**What you can do:**
- Wait a moment and try again (the bridge may be reconnecting)
- Contact your system administrator
- Use ` + "`/xmppbridge status`" + ` to check the connection status`,
}
default:
// Generic error message for unknown cases
action := "create the mapping"
if operation == "delete" {
action = "remove the mapping"
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Operation Failed**
Unable to %s for room **%s**.
**What you can do:**
- Try the command again in a few moments
- Use `+"`/xmppbridge status`"+` to check the bridge status
- Contact your system administrator if the problem persists
**Error details:** %s`, action, roomJID, errorMsg),
}
}
}

View file

@ -19,13 +19,13 @@ const DefaultXMPPUsernamePrefix = "xmpp"
// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep
// copy appropriate for your types.
type Configuration struct {
XMPPServerURL string `json:"XMPPServerURL"`
XMPPUsername string `json:"XMPPUsername"`
XMPPPassword string `json:"XMPPPassword"`
EnableSync bool `json:"EnableSync"`
XMPPUsernamePrefix string `json:"XMPPUsernamePrefix"`
XMPPResource string `json:"XMPPResource"`
XMPPInsecureSkipVerify bool `json:"XMPPInsecureSkipVerify"`
XMPPServerURL string `json:"XMPPServerURL"`
XMPPUsername string `json:"XMPPUsername"`
XMPPPassword string `json:"XMPPPassword"`
EnableSync bool `json:"EnableSync"`
XMPPUsernamePrefix string `json:"XMPPUsernamePrefix"`
XMPPResource string `json:"XMPPResource"`
XMPPInsecureSkipVerify bool `json:"XMPPInsecureSkipVerify"`
}
// Equals compares two configuration structs
@ -95,4 +95,4 @@ func (c *Configuration) IsValid() error {
}
return nil
}
}

View file

@ -0,0 +1,46 @@
package main
import "github.com/mattermost/mattermost/server/public/model"
// OnSharedChannelsPing is called to check if the bridge is healthy and ready to process messages
func (p *Plugin) OnSharedChannelsPing(remoteCluster *model.RemoteCluster) bool {
config := p.getConfiguration()
p.logger.LogDebug("OnSharedChannelsPing called", "remote_cluster_id", remoteCluster.RemoteId)
var remoteClusterID string
if remoteCluster != nil {
remoteClusterID = remoteCluster.RemoteId
}
p.logger.LogDebug("Received shared channels ping", "remote_cluster_id", remoteClusterID)
// If sync is disabled, we're still "healthy" but not actively processing
if !config.EnableSync {
p.logger.LogDebug("Ping received but sync is disabled", "remote_cluster_id", remoteClusterID)
return true
}
// Check if bridge manager is available
if p.bridgeManager == nil {
p.logger.LogError("Bridge manager not initialized during ping", "remote_cluster_id", remoteClusterID)
return false
}
// Get the XMPP bridge for active connectivity testing
bridge, err := p.bridgeManager.GetBridge("xmpp")
if err != nil {
p.logger.LogWarn("XMPP bridge not available during ping", "error", err, "remote_cluster_id", remoteClusterID)
// Return true if bridge is not registered - this might be expected during startup/shutdown
return false
}
// Perform active ping test on the XMPP bridge
if err := bridge.Ping(); err != nil {
p.logger.LogError("XMPP bridge ping failed", "error", err, "remote_cluster_id", remoteClusterID)
return false
}
p.logger.LogDebug("Shared channels ping successful - XMPP bridge is healthy", "remote_cluster_id", remoteClusterID)
return true
}

View file

@ -38,4 +38,4 @@ func (l *PluginAPILogger) LogWarn(message string, keyValuePairs ...any) {
// LogError logs an error message
func (l *PluginAPILogger) LogError(message string, keyValuePairs ...any) {
l.api.LogError(message, keyValuePairs...)
}
}

View file

@ -1,5 +1,77 @@
package model
import (
"context"
"fmt"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/config"
)
type BridgeID string
type UserState int
const (
UserStateOnline UserState = iota
UserStateAway
UserStateBusy
UserStateOffline
)
// CreateChannelMappingRequest contains information needed to create a channel mapping
type CreateChannelMappingRequest struct {
ChannelID string // Mattermost channel ID
BridgeName string // Name of the bridge (e.g., "xmpp")
BridgeRoomID string // Remote room/channel ID (e.g., JID for XMPP)
UserID string // ID of user who triggered the mapping creation
TeamID string // Team ID where the channel belongs
}
// Validate checks if all required fields are present and valid
func (r CreateChannelMappingRequest) Validate() error {
if r.ChannelID == "" {
return fmt.Errorf("channelID cannot be empty")
}
if r.BridgeName == "" {
return fmt.Errorf("bridgeName cannot be empty")
}
if r.BridgeRoomID == "" {
return fmt.Errorf("bridgeRoomID cannot be empty")
}
if r.UserID == "" {
return fmt.Errorf("userID cannot be empty")
}
if r.TeamID == "" {
return fmt.Errorf("teamID cannot be empty")
}
return nil
}
// DeleteChannelMappingRequest contains information needed to delete a channel mapping
type DeleteChannelMappingRequest struct {
ChannelID string // Mattermost channel ID
BridgeName string // Name of the bridge (e.g., "xmpp")
UserID string // ID of user who triggered the mapping deletion
TeamID string // Team ID where the channel belongs
}
// Validate checks if all required fields are present and valid
func (r DeleteChannelMappingRequest) Validate() error {
if r.ChannelID == "" {
return fmt.Errorf("channelID cannot be empty")
}
if r.BridgeName == "" {
return fmt.Errorf("bridgeName cannot be empty")
}
if r.UserID == "" {
return fmt.Errorf("userID cannot be empty")
}
if r.TeamID == "" {
return fmt.Errorf("teamID cannot be empty")
}
return nil
}
type BridgeManager interface {
// RegisterBridge registers a bridge with the given name. Returns an error if the name is empty,
// the bridge is nil, or a bridge with the same name is already registered.
@ -39,6 +111,12 @@ type BridgeManager interface {
// Returns an error if any bridge fails to update its configuration, but continues to
// attempt updating all bridges.
OnPluginConfigurationChange(config any) error
// CreateChannelMapping is called when a channel mapping is created.
CreateChannelMapping(req CreateChannelMappingRequest) error
// DeleteChannepMapping is called when a channel mapping is deleted.
DeleteChannepMapping(req DeleteChannelMappingRequest) error
}
type Bridge interface {
@ -51,12 +129,79 @@ type Bridge interface {
// Stop stops the bridge
Stop() error
// CreateChannelRoomMapping creates a mapping between a Mattermost channel ID and an bridge room ID.
CreateChannelRoomMapping(channelID, roomJID string) error
// CreateChannelMapping creates a mapping between a Mattermost channel ID and an bridge room ID.
CreateChannelMapping(channelID, roomJID string) error
// GetChannelRoomMapping retrieves the bridge room ID for a given Mattermost channel ID.
GetChannelRoomMapping(channelID string) (string, error)
// GetChannelMapping retrieves the bridge room ID for a given Mattermost channel ID.
GetChannelMapping(channelID string) (string, error)
// DeleteChannelMapping removes a mapping between a Mattermost channel ID and a bridge room ID.
DeleteChannelMapping(channelID string) error
// RoomExists checks if a room/channel exists on the remote service.
RoomExists(roomID string) (bool, error)
// GetRoomMapping retrieves the Mattermost channel ID for a given room ID (reverse lookup).
GetRoomMapping(roomID string) (string, error)
// IsConnected checks if the bridge is connected to the remote service.
IsConnected() bool
// Ping actively tests the bridge connection health by sending a lightweight request.
Ping() error
// GetUserManager returns the user manager for this bridge.
GetUserManager() BridgeUserManager
}
// BridgeUser represents a user connected to any bridge service
type BridgeUser interface {
// Validation
Validate() error
// Identity (bridge-agnostic)
GetID() string
GetDisplayName() string
// State management
GetState() UserState
SetState(state UserState) error
// Channel operations (abstracted from rooms/channels/groups)
JoinChannel(channelID string) error
LeaveChannel(channelID string) error
SendMessageToChannel(channelID, message string) error
// Connection lifecycle
Connect() error
Disconnect() error
IsConnected() bool
Ping() error
// Channel existence check
CheckChannelExists(channelID string) (bool, error)
// Goroutine lifecycle
Start(ctx context.Context) error
Stop() error
}
// BridgeUserManager manages users for a specific bridge
type BridgeUserManager interface {
// User lifecycle
CreateUser(user BridgeUser) error
GetUser(userID string) (BridgeUser, error)
DeleteUser(userID string) error
ListUsers() []BridgeUser
HasUser(userID string) bool
// Manager lifecycle
Start(ctx context.Context) error
Stop() error
// Configuration updates
UpdateConfiguration(config *config.Configuration) error
// Bridge type identification
GetBridgeType() string
}

40
server/model/strings.go Normal file
View file

@ -0,0 +1,40 @@
package model
import "strings"
// sanitizeShareName creates a valid ShareName matching the regex: ^[a-z0-9]+([a-z\-\_0-9]+|(__)?)[a-z0-9]*$
func SanitizeShareName(name string) string {
// Convert to lowercase and replace spaces with hyphens
shareName := strings.ToLower(name)
shareName = strings.ReplaceAll(shareName, " ", "-")
// Remove any characters that aren't lowercase letters, numbers, hyphens, or underscores
var validShareName strings.Builder
for _, r := range shareName {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
validShareName.WriteRune(r)
}
}
result := validShareName.String()
if result == "" {
return "matrixbridge" // fallback if no valid characters
}
// Ensure it starts with alphanumeric
for len(result) > 0 && (result[0] == '-' || result[0] == '_') {
result = result[1:]
}
// Ensure it ends with alphanumeric
for len(result) > 0 && (result[len(result)-1] == '-' || result[len(result)-1] == '_') {
result = result[:len(result)-1]
}
// Final fallback check
if result == "" {
return "matrixbridge"
}
return result
}

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge"
mattermostbridge "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge/mattermost"
xmppbridge "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge/xmpp"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/command"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/config"
@ -43,6 +44,10 @@ type Plugin struct {
// remoteID is the identifier returned by RegisterPluginForSharedChannels
remoteID string
// botUserID is the ID of the bot user created for this plugin
botUserID string
// backgroundJob is the scheduled job that runs periodically to perform background tasks.
backgroundJob *cluster.Job
// configurationLock synchronizes access to the configuration.
@ -71,8 +76,14 @@ func (p *Plugin) OnActivate() error {
cfg := p.getConfiguration()
p.logger.LogDebug("Loaded configuration in OnActivate", "config", cfg)
// Register the plugin for shared channels
if err := p.registerForSharedChannels(); err != nil {
p.logger.LogError("Failed to register for shared channels", "error", err)
return fmt.Errorf("failed to register for shared channels: %w", err)
}
// Initialize bridge manager
p.bridgeManager = bridge.NewManager(p.logger)
p.bridgeManager = bridge.NewBridgeManager(p.logger, p.API, p.remoteID)
// Initialize and register bridges with current configuration
if err := p.initBridges(*cfg); err != nil {
@ -118,6 +129,10 @@ func (p *Plugin) OnDeactivate() error {
}
}
if err := p.API.UnregisterPluginForSharedChannels(manifest.Id); err != nil {
p.API.LogError("Failed to unregister plugin for shared channels", "err", err)
}
return nil
}
@ -138,24 +153,69 @@ func (p *Plugin) initXMPPClient() {
cfg.XMPPPassword,
cfg.GetXMPPResource(),
p.remoteID,
p.logger,
)
}
func (p *Plugin) initBridges(cfg config.Configuration) error {
// Create and register XMPP bridge
bridge := xmppbridge.NewBridge(
xmppBridge := xmppbridge.NewBridge(
p.logger,
p.API,
p.kvstore,
&cfg,
)
if err := p.bridgeManager.RegisterBridge("xmpp", bridge); err != nil {
if err := p.bridgeManager.RegisterBridge("xmpp", xmppBridge); err != nil {
return fmt.Errorf("failed to register XMPP bridge: %w", err)
}
// Create and register Mattermost bridge
mattermostBridge := mattermostbridge.NewBridge(
p.logger,
p.API,
p.kvstore,
&cfg,
)
if err := p.bridgeManager.RegisterBridge("mattermost", mattermostBridge); err != nil {
return fmt.Errorf("failed to register Mattermost bridge: %w", err)
}
p.logger.LogInfo("Bridge instances created and registered successfully")
return nil
}
func (p *Plugin) registerForSharedChannels() error {
botUserID, err := p.API.EnsureBotUser(&model.Bot{
Username: "mattermost-bridge",
DisplayName: "Mattermost Bridge",
Description: "Mattermost Bridge Bot",
})
if err != nil {
return fmt.Errorf("failed to ensure bot user: %w", err)
}
p.botUserID = botUserID
opts := model.RegisterPluginOpts{
Displayname: "XMPP-Bridge",
PluginID: manifest.Id,
CreatorID: botUserID,
AutoShareDMs: false,
AutoInvited: true,
}
remoteID, appErr := p.API.RegisterPluginForSharedChannels(opts)
if appErr != nil {
return fmt.Errorf("failed to register plugin for shared channels: %w", appErr)
}
// Store the remote ID for use in sync operations
p.remoteID = remoteID
p.logger.LogInfo("Successfully registered plugin for shared channels", "remote_id", remoteID)
return nil
}
// See https://developers.mattermost.com/extend/plugins/server/reference/

View file

@ -8,8 +8,10 @@ import (
"fmt"
"time"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
"mellium.im/sasl"
"mellium.im/xmpp"
"mellium.im/xmpp/disco"
"mellium.im/xmpp/jid"
"mellium.im/xmpp/muc"
"mellium.im/xmpp/mux"
@ -22,18 +24,19 @@ type Client struct {
username string
password string
resource string
remoteID string // Plugin remote ID for metadata
serverDomain string // explicit server domain for testing
tlsConfig *tls.Config // custom TLS configuration
remoteID string // Plugin remote ID for metadata
serverDomain string // explicit server domain for testing
tlsConfig *tls.Config // custom TLS configuration
logger logger.Logger // Logger for debugging
// XMPP connection
session *xmpp.Session
jidAddr jid.JID
ctx context.Context
cancel context.CancelFunc
mucClient *muc.Client
mux *mux.ServeMux
sessionReady chan struct{}
session *xmpp.Session
jidAddr jid.JID
ctx context.Context
cancel context.CancelFunc
mucClient *muc.Client
mux *mux.ServeMux
sessionReady chan struct{}
sessionServing bool
}
@ -52,6 +55,21 @@ type SendMessageResponse struct {
StanzaID string `json:"stanza_id"`
}
// MessageBody represents the body element of an XMPP message
type MessageBody struct {
XMLName xml.Name `xml:"body"`
Text string `xml:",chardata"`
}
// XMPPMessage represents a complete XMPP message stanza
type XMPPMessage struct {
XMLName xml.Name `xml:"jabber:client message"`
Type string `xml:"type,attr"`
To string `xml:"to,attr"`
From string `xml:"from,attr"`
Body MessageBody `xml:"body"`
}
// GhostUser represents an XMPP ghost user
type GhostUser struct {
JID string `json:"jid"`
@ -65,17 +83,18 @@ type UserProfile struct {
}
// NewClient creates a new XMPP client.
func NewClient(serverURL, username, password, resource, remoteID string) *Client {
func NewClient(serverURL, username, password, resource, remoteID string, logger logger.Logger) *Client {
ctx, cancel := context.WithCancel(context.Background())
mucClient := &muc.Client{}
mux := mux.New("jabber:client", muc.HandleClient(mucClient))
return &Client{
serverURL: serverURL,
username: username,
password: password,
resource: resource,
remoteID: remoteID,
logger: logger,
ctx: ctx,
cancel: cancel,
mucClient: mucClient,
@ -85,8 +104,8 @@ func NewClient(serverURL, username, password, resource, remoteID string) *Client
}
// NewClientWithTLS creates a new XMPP client with custom TLS configuration.
func NewClientWithTLS(serverURL, username, password, resource, remoteID string, tlsConfig *tls.Config) *Client {
client := NewClient(serverURL, username, password, resource, remoteID)
func NewClientWithTLS(serverURL, username, password, resource, remoteID string, tlsConfig *tls.Config, logger logger.Logger) *Client {
client := NewClient(serverURL, username, password, resource, remoteID, logger)
client.tlsConfig = tlsConfig
return client
}
@ -164,11 +183,11 @@ func (c *Client) serveSession() {
close(c.sessionReady) // Signal failure
return
}
// Signal that the session is ready to serve
c.sessionServing = true
close(c.sessionReady)
err := c.session.Serve(c.mux)
if err != nil {
c.sessionServing = false
@ -202,23 +221,6 @@ func (c *Client) Disconnect() error {
return nil
}
// TestConnection tests the XMPP connection
func (c *Client) TestConnection() error {
if c.session == nil {
if err := c.Connect(); err != nil {
return err
}
}
// For now, just check if session exists and is not closed
// A proper ping implementation would require more complex IQ handling
if c.session == nil {
return fmt.Errorf("XMPP session is not established")
}
return nil
}
// JoinRoom joins an XMPP Multi-User Chat room
func (c *Client) JoinRoom(roomJID string) error {
if c.session == nil {
@ -251,7 +253,7 @@ func (c *Client) JoinRoom(roomJID string) error {
opts := []muc.Option{
muc.MaxBytes(0), // Don't limit message history
}
// Run the join operation in a goroutine to avoid blocking
errChan := make(chan error, 1)
go func() {
@ -324,26 +326,12 @@ func (c *Client) SendMessage(req MessageRequest) (*SendMessageResponse, error) {
sendCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
defer cancel()
// Create the message body structure
type messageBody struct {
XMLName xml.Name `xml:"body"`
Text string `xml:",chardata"`
}
// Create complete message with body
type message struct {
XMLName xml.Name `xml:"jabber:client message"`
Type string `xml:"type,attr"`
To string `xml:"to,attr"`
From string `xml:"from,attr"`
Body messageBody `xml:"body"`
}
fullMsg := message{
fullMsg := XMPPMessage{
Type: "groupchat",
To: to.String(),
From: c.jidAddr.String(),
Body: messageBody{Text: req.Message},
Body: MessageBody{Text: req.Message},
}
// Send the message using the session encoder
@ -359,6 +347,39 @@ func (c *Client) SendMessage(req MessageRequest) (*SendMessageResponse, error) {
return response, nil
}
// SendDirectMessage sends a direct message to a specific user
func (c *Client) SendDirectMessage(userJID, message string) error {
if c.session == nil {
if err := c.Connect(); err != nil {
return err
}
}
to, err := jid.Parse(userJID)
if err != nil {
return fmt.Errorf("failed to parse user JID: %w", err)
}
// Create a context with timeout for the send operation
sendCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
defer cancel()
// Create direct message using reusable structs
msg := XMPPMessage{
Type: "chat",
To: to.String(),
From: c.jidAddr.String(),
Body: MessageBody{Text: message},
}
// Send the message using the session encoder
if err := c.session.Encode(sendCtx, msg); err != nil {
return fmt.Errorf("failed to send direct message: %w", err)
}
return nil
}
// ResolveRoomAlias resolves a room alias to room JID
func (c *Client) ResolveRoomAlias(roomAlias string) (string, error) {
// For XMPP, return the alias as-is if it's already a valid JID
@ -396,3 +417,121 @@ func (c *Client) SetOnlinePresence() error {
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 {
return false, fmt.Errorf("XMPP session not established")
}
c.logger.LogDebug("Checking room existence using disco#info", "room_jid", roomJID)
// Parse and validate the room JID
roomAddr, err := jid.Parse(roomJID)
if err != nil {
c.logger.LogError("Invalid room JID", "room_jid", roomJID, "error", err)
return false, fmt.Errorf("invalid room JID: %w", err)
}
// Set timeout for the disco query
ctx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
defer cancel()
// Perform disco#info query to the room
info, err := disco.GetInfo(ctx, "", roomAddr, c.session)
if err != nil {
// Check if it's a service-unavailable or item-not-found error
if stanzaErr, ok := err.(stanza.Error); ok {
c.logger.LogDebug("Received stanza error during disco#info query",
"room_jid", roomJID,
"error_condition", string(stanzaErr.Condition),
"error_type", string(stanzaErr.Type))
switch stanzaErr.Condition {
case stanza.ServiceUnavailable, stanza.ItemNotFound:
c.logger.LogDebug("Room does not exist", "room_jid", roomJID, "condition", string(stanzaErr.Condition))
return false, nil // Room doesn't exist
case stanza.Forbidden:
c.logger.LogWarn("Access denied to room (room exists but not accessible)", "room_jid", roomJID)
return false, fmt.Errorf("access denied to room %s", roomJID)
case stanza.NotAuthorized:
c.logger.LogWarn("Not authorized to query room (room exists but not queryable)", "room_jid", roomJID)
return false, fmt.Errorf("not authorized to query room %s", roomJID)
default:
c.logger.LogError("Unexpected disco query error", "room_jid", roomJID, "condition", string(stanzaErr.Condition), "error", err)
return false, fmt.Errorf("disco query failed: %w", err)
}
}
c.logger.LogError("Disco query error", "room_jid", roomJID, "error", err)
return false, fmt.Errorf("disco query error: %w", err)
}
c.logger.LogDebug("Received disco#info response, checking for MUC features",
"room_jid", roomJID,
"features_count", len(info.Features),
"identities_count", len(info.Identity))
// Verify it's actually a MUC room by checking features
for _, feature := range info.Features {
if feature.Var == muc.NS { // "http://jabber.org/protocol/muc"
c.logger.LogDebug("Room exists and has MUC feature", "room_jid", roomJID)
return true, nil
}
}
// Check for conference identity as backup verification
for _, identity := range info.Identity {
if identity.Category == "conference" {
c.logger.LogDebug("Room exists and has conference identity", "room_jid", roomJID, "identity_type", identity.Type)
return true, nil
}
}
// Log all features and identities for debugging
c.logger.LogDebug("Room exists but doesn't appear to be a MUC room",
"room_jid", roomJID,
"features", func() []string {
var features []string
for _, f := range info.Features {
features = append(features, f.Var)
}
return features
}(),
"identities", func() []string {
var identities []string
for _, i := range info.Identity {
identities = append(identities, fmt.Sprintf("%s/%s", i.Category, i.Type))
}
return identities
}())
return false, nil
}
// Ping sends a lightweight ping to the XMPP server to test connectivity
func (c *Client) Ping() error {
if c.session == nil {
return fmt.Errorf("XMPP session not established")
}
c.logger.LogDebug("Sending XMPP ping to test connectivity")
// Create a context with timeout for the ping
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
// Use disco#info query to server domain as a connectivity test
// This is a standard, lightweight XMPP operation that all servers support
_, err := disco.GetInfo(ctx, "", c.jidAddr.Domain(), c.session)
if err != nil {
duration := time.Since(start)
c.logger.LogDebug("XMPP ping failed", "error", err, "duration", duration)
return fmt.Errorf("XMPP server ping failed: %w", err)
}
duration := time.Since(start)
c.logger.LogDebug("XMPP ping successful", "duration", duration)
return nil
}