package main import ( "context" "fmt" "log" "sync" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) type Bridge struct { as *appservice.AppService db *Database ntfy *NtfyClient cfg *Config mu sync.Mutex listeners map[string]context.CancelFunc } func NewBridge(as *appservice.AppService, db *Database, ntfy *NtfyClient, cfg *Config) *Bridge { return &Bridge{ as: as, db: db, ntfy: ntfy, cfg: cfg, listeners: make(map[string]context.CancelFunc), } } func (b *Bridge) StartExistingBridges(ctx context.Context) { bridges, err := b.db.GetAllBridges() if err != nil { log.Fatalf("Failed to load bridges: %v", err) } botIntent := b.as.BotIntent() for topic, roomID := range bridges { log.Printf("[startup] Restoring listener: topic=%q room=%s", topic, roomID) b.startListener(topic, roomID, botIntent) } log.Printf("[startup] Restored %d bridge(s)", len(bridges)) } func (b *Bridge) CreateBridge(ctx context.Context, sender id.UserID, topicName string) (id.RoomID, error) { botIntent := b.as.BotIntent() if existing, err := b.db.GetBridgeByTopic(topicName); err != nil { return "", fmt.Errorf("database error: %w", err) } else if existing != "" { return "", fmt.Errorf("topic '%s' is already bridged to %s", topicName, existing) } spaceRoomID, err := b.ensureSpace(ctx, sender) if err != nil { return "", fmt.Errorf("space setup: %w", err) } roomResp, err := botIntent.Client.CreateRoom(ctx, &mautrix.ReqCreateRoom{ Name: topicName, Topic: fmt.Sprintf("ntfy topic: %s", topicName), Preset: "private_chat", Invite: []id.UserID{sender}, }) if err != nil { return "", fmt.Errorf("create room: %w", err) } bridgeRoomID := roomResp.RoomID if _, err := botIntent.SendStateEvent(ctx, id.RoomID(spaceRoomID), event.StateSpaceChild, string(bridgeRoomID), &event.SpaceChildEventContent{ Via: []string{b.cfg.Homeserver.Domain}, }); err != nil { log.Printf("[bridge] failed to add room to space: %v", err) } userIntent := b.as.Intent(sender) userIntent.IsCustomPuppet = true if err := userIntent.EnsureJoined(ctx, bridgeRoomID); err != nil { log.Printf("[bridge] double puppet join failed: %v", err) } if err := b.db.SetBridge(topicName, string(bridgeRoomID)); err != nil { return "", fmt.Errorf("save bridge: %w", err) } b.startListener(topicName, string(bridgeRoomID), botIntent) return bridgeRoomID, nil } func (b *Bridge) DeleteBridge(topicName string) error { b.mu.Lock() if cancel, ok := b.listeners[topicName]; ok { cancel() delete(b.listeners, topicName) } b.mu.Unlock() return b.db.DeleteBridge(topicName) } func (b *Bridge) PublishToNtfy(topic, message string) error { return b.ntfy.PublishToNtfy(topic, message) } func (b *Bridge) GetBridgeByRoom(roomID string) (string, error) { return b.db.GetBridgeByRoom(roomID) } func (b *Bridge) GetAllBridges() (map[string]string, error) { return b.db.GetAllBridges() } func (b *Bridge) HasPermission(userID string) bool { return b.cfg.Bridge.HasPermission(userID, "admin") } func (b *Bridge) startListener(topic, roomID string, intent *appservice.IntentAPI) { b.mu.Lock() defer b.mu.Unlock() if _, exists := b.listeners[topic]; exists { return } ctx, cancel := context.WithCancel(context.Background()) b.listeners[topic] = cancel go b.ntfy.Listen(ctx, topic, roomID, intent) } func (b *Bridge) ensureSpace(ctx context.Context, sender id.UserID) (string, error) { spaceRoomID, err := b.db.GetSpaceRoomID(string(sender)) if err != nil { return "", err } if spaceRoomID != "" { return spaceRoomID, nil } resp, err := b.as.BotIntent().Client.CreateRoom(ctx, &mautrix.ReqCreateRoom{ Name: "Notifications", Preset: "private_chat", CreationContent: map[string]interface{}{ "type": "m.space", }, Invite: []id.UserID{sender}, }) if err != nil { return "", err } spaceRoomID = string(resp.RoomID) if err := b.db.SetSpaceRoomID(string(sender), spaceRoomID); err != nil { return "", err } userIntent := b.as.Intent(sender) userIntent.IsCustomPuppet = true if err := userIntent.EnsureJoined(ctx, id.RoomID(spaceRoomID)); err != nil { log.Printf("[bridge] double puppet join space failed: %v", err) } return spaceRoomID, nil }