diff --git a/commands.go b/commands.go index 87b29b4f..d1307408 100644 --- a/commands.go +++ b/commands.go @@ -49,6 +49,8 @@ func (br *SignalBridge) RegisterCommands() { cmdLogin, cmdPM, cmdDisconnect, + cmdSetRelay, + cmdUnsetRelay, ) } @@ -64,6 +66,51 @@ func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { } } +var cmdSetRelay = &commands.FullHandler{ + Func: wrapCommand(fnSetRelay), + Name: "set-relay", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Relay messages in this room through your Signal account.", + }, + RequiresPortal: true, + RequiresLogin: true, +} + +func fnSetRelay(ce *WrappedCommandEvent) { + if !ce.Bridge.Config.Bridge.Relay.Enabled { + ce.Reply("Relay mode is not enabled on this instance of the bridge") + } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { + ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") + } else { + ce.Portal.RelayUserID = ce.User.MXID + ce.Portal.Update() + ce.Reply("Messages from non-logged-in users in this room will now be bridged through your Signal account") + } +} + +var cmdUnsetRelay = &commands.FullHandler{ + Func: wrapCommand(fnUnsetRelay), + Name: "unset-relay", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Stop relaying messages in this room.", + }, + RequiresPortal: true, +} + +func fnUnsetRelay(ce *WrappedCommandEvent) { + if !ce.Bridge.Config.Bridge.Relay.Enabled { + ce.Reply("Relay mode is not enabled on this instance of the bridge") + } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { + ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") + } else { + ce.Portal.RelayUserID = "" + ce.Portal.Update() + ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") + } +} + var cmdDisconnect = &commands.FullHandler{ Func: wrapCommand(fnDisconnect), Name: "disconnect", diff --git a/config/bridge.go b/config/bridge.go index cb2a53ac..728a0742 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -24,6 +24,8 @@ import ( "time" "maunium.net/go/mautrix/bridge/bridgeconfig" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-signal/pkg/signalmeow" ) @@ -68,6 +70,8 @@ type BridgeConfig struct { Permissions bridgeconfig.PermissionConfig `yaml:"permissions"` + Relay RelaybotConfig `yaml:"relay"` + usernameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"` } @@ -169,3 +173,57 @@ func (bc BridgeConfig) FormatDisplayname(contact *signalmeow.Contact) string { }) return buffer.String() } + +type RelaybotConfig struct { + Enabled bool `yaml:"enabled"` + AdminOnly bool `yaml:"admin_only"` + MessageFormats map[event.MessageType]string `yaml:"message_formats"` + messageTemplates *template.Template `yaml:"-"` +} + +type umRelaybotConfig RelaybotConfig + +func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + err := unmarshal((*umRelaybotConfig)(rc)) + if err != nil { + return err + } + + rc.messageTemplates = template.New("messageTemplates") + for key, format := range rc.MessageFormats { + _, err := rc.messageTemplates.New(string(key)).Parse(format) + if err != nil { + return err + } + } + + return nil +} + +type Sender struct { + UserID string + event.MemberEventContent +} + +type formatData struct { + Sender Sender + Message string + Content *event.MessageEventContent +} + +func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) { + if len(member.Displayname) == 0 { + member.Displayname = sender.String() + } + member.Displayname = template.HTMLEscapeString(member.Displayname) + var output strings.Builder + err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{ + Sender: Sender{ + UserID: template.HTMLEscapeString(sender.String()), + MemberEventContent: member, + }, + Content: content, + Message: content.FormattedBody, + }) + return output.String(), err +} diff --git a/config/upgrade.go b/config/upgrade.go index ef797744..1cb11dd9 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -128,6 +128,9 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints") helper.Copy(up.Map, "bridge", "permissions") + helper.Copy(up.Bool, "bridge", "relay", "enabled") + helper.Copy(up.Bool, "bridge", "relay", "admin_only") + helper.Copy(up.Map, "bridge", "relay", "message_formats") } var SpacedBlocks = [][]string{ diff --git a/example-config.yaml b/example-config.yaml index 2cf9612e..1044ce95 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -262,6 +262,24 @@ bridge: "example.com": user "@admin:example.com": admin + # Settings for relay mode + relay: + # Whether relay mode should be allowed. If allowed, `!wa set-relay` can be used to turn any + # authenticated user into a relaybot for that chat. + enabled: false + # Should only admins be allowed to set themselves as relay users? + admin_only: true + # The formats to use when sending messages to Signal via the relaybot. + message_formats: + m.text: "{{ .Sender.Displayname }}: {{ .Message }}" + m.notice: "{{ .Sender.Displayname }}: {{ .Message }}" + m.emote: "* {{ .Sender.Displayname }} {{ .Message }}" + m.file: "{{ .Sender.Displayname }} sent a file" + m.image: "{{ .Sender.Displayname }} sent an image" + m.audio: "{{ .Sender.Displayname }} sent an audio file" + m.video: "{{ .Sender.Displayname }} sent a video" + m.location: "{{ .Sender.Displayname }} sent a location" + # Logging config. See https://github.com/tulir/zeroconfig for details. logging: min_level: debug diff --git a/messagetracking.go b/messagetracking.go index 50f0a554..62db364f 100644 --- a/messagetracking.go +++ b/messagetracking.go @@ -33,6 +33,7 @@ var ( errUserNotConnected = errors.New("you are not connected to Signal") errDifferentUser = errors.New("user is not the recipient of this private chat portal") errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot") + errRelaybotNotLoggedIn = errors.New("neither user nor relay bot of chat are logged in") errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") errUnexpectedParsedContentType = errors.New("unexpected parsed content type") errInvalidGeoURI = errors.New("invalid `geo:` URI in message") @@ -96,7 +97,8 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev case errors.Is(err, errUserNotConnected): return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "" case errors.Is(err, errUserNotLoggedIn), - errors.Is(err, errDifferentUser): + errors.Is(err, errDifferentUser), + errors.Is(err, errRelaybotNotLoggedIn): return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, "" default: return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "" @@ -267,8 +269,7 @@ func (mt *messageTimings) String() string { mt.preproc = niceRound(mt.preproc) mt.convert = niceRound(mt.convert) mt.totalSend = niceRound(mt.totalSend) - whatsmeowTimings := "N/A" - return fmt.Sprintf("BRIDGE: receive: %s, decrypt: %s, queue: %s, total hs->portal: %s, implicit rr: %s -- PORTAL: preprocess: %s, convert: %s, total send: %s -- WHATSMEOW: %s", mt.initReceive, mt.decrypt, mt.implicitRR, mt.portalQueue, mt.totalReceive, mt.preproc, mt.convert, mt.totalSend, whatsmeowTimings) + return fmt.Sprintf("BRIDGE: receive: %s, decrypt: %s, queue: %s, total hs->portal: %s, implicit rr: %s -- PORTAL: preprocess: %s, convert: %s, total send: %s ", mt.initReceive, mt.decrypt, mt.implicitRR, mt.portalQueue, mt.totalReceive, mt.preproc, mt.convert, mt.totalSend) } type metricSender struct { diff --git a/portal.go b/portal.go index dd9c90f1..2f131a61 100644 --- a/portal.go +++ b/portal.go @@ -85,6 +85,8 @@ type Portal struct { currentlyTypingLock sync.Mutex latestReadTimestamp uint64 // Cache the latest read timestamp to avoid unnecessary read receipts + + relayUser *User } const recentMessageBufferSize = 32 @@ -117,11 +119,20 @@ func (portal *Portal) MarkEncrypted() { } func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser { + if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() { portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt} } } +func (portal *Portal) GetRelayUser() *User { + if !portal.HasRelaybot() { + return nil + } else if portal.relayUser == nil { + portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID) + } + return portal.relayUser +} + func isUUID(s string) bool { if _, uuidErr := uuid.Parse(s); uuidErr == nil { return true @@ -263,7 +274,7 @@ func (portal *Portal) messageLoop() { func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { // If we have no SignalDevice, the bridge isn't logged in properly, // so send BAD_CREDENTIALS so the user knows - if !msg.user.SignalDevice.IsDeviceLoggedIn() { + if !msg.user.SignalDevice.IsDeviceLoggedIn() && !portal.HasRelaybot() { go portal.sendMessageMetrics(msg.evt, errUserNotLoggedIn, "Ignoring", nil) msg.user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"}) return @@ -367,7 +378,9 @@ func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event) { if portal.ExpirationTime > 0 { signalmeow.AddExpiryToDataMessage(msg, uint32(portal.ExpirationTime)) } - + if !sender.IsLoggedIn() { + sender = portal.GetRelayUser() + } err = portal.sendSignalMessage(ctx, msg, sender, evt.ID) timings.totalSend = time.Since(start) @@ -397,6 +410,10 @@ func (portal *Portal) handleMatrixRedaction(sender *User, evt *event.Event) { return } + if !sender.IsLoggedIn() { + sender = portal.GetRelayUser() + } + // If this is a message redaction, send a redaction to Signal if dbMessage != nil { msg := signalmeow.DataMessageForDelete(dedupedTimestamp(dbMessage)) @@ -428,6 +445,10 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { // Find the original signal message based on eventID relatedEventID := evt.Content.AsReaction().RelatesTo.EventID dbMessage := portal.bridge.DB.Message.GetByMXID(relatedEventID) + if !sender.IsLoggedIn() { + portal.log.Error().Msgf("Cannot relay reaction from non-logged-in user. Ignoring") + return + } if dbMessage == nil { portal.sendMessageStatusCheckpointFailed(evt, errors.New("could not find original message for reaction")) portal.log.Error().Msgf("Could not find original message for reaction %s", evt.ID) @@ -438,6 +459,7 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event) { targetAuthorUUID := dbMessage.Sender targetTimestamp := dedupedTimestamp(dbMessage) msg := signalmeow.DataMessageForReaction(signalEmoji, targetAuthorUUID, targetTimestamp, false) + err := portal.sendSignalMessage(context.Background(), msg, sender, evt.ID) if err != nil { portal.sendMessageStatusCheckpointFailed(evt, err) @@ -624,15 +646,39 @@ func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, ev if evt.Type == event.EventSticker { content.MsgType = event.MessageType(event.EventSticker.Type) } - + realSenderMXID := sender.MXID + isRelay := false + if !sender.IsLoggedIn() { + if !portal.HasRelaybot() { + return nil, errUserNotLoggedIn + } + sender = portal.GetRelayUser() + if !sender.IsLoggedIn() { + return nil, errRelaybotNotLoggedIn + } + isRelay = true + } var outgoingMessage *signalmeow.SignalContent + relaybotFormatted := isRelay && portal.addRelaybotFormat(realSenderMXID, content) + if relaybotFormatted && content.FileName == "" { + content.FileName = content.Body + } + + if evt.Type == event.EventSticker { + if relaybotFormatted { + // Stickers can't have captions, so force relaybot stickers to be images + content.MsgType = event.MsgImage + } else { + content.MsgType = event.MessageType(event.EventSticker.Type) + } + } switch content.MsgType { case event.MsgText, event.MsgEmote, event.MsgNotice: if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices { return nil, errMNoticeDisabled } - if content.MsgType == event.MsgEmote { + if content.MsgType == event.MsgEmote && !relaybotFormatted { content.Body = "/me " + content.Body if content.FormattedBody != "" { content.FormattedBody = "/me " + content.FormattedBody @@ -1836,3 +1882,21 @@ func (portal *Portal) HandleNewDisappearingMessageTime(newTimer uint32) { intent.SendNotice(portal.MXID, fmt.Sprintf("Disappearing messages set to %s", exfmt.Duration(time.Duration(newTimer)*time.Second))) } } + +func (portal *Portal) HasRelaybot() bool { + return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0 +} + +func (portal *Portal) addRelaybotFormat(userID id.UserID, content *event.MessageEventContent) bool { + member := portal.MainIntent().Member(portal.MXID, userID) + if member == nil { + member = &event.MemberEventContent{} + } + content.EnsureHasHTML() + data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member) + if err != nil { + portal.log.Error().Msgf("Failed to apply relaybot format: %s", err) + } + content.FormattedBody = data + return true +} diff --git a/user.go b/user.go index 4bc04d7d..ba866e5f 100644 --- a/user.go +++ b/user.go @@ -54,6 +54,7 @@ type User struct { bridge *SignalBridge log zerolog.Logger + Admin bool PermissionLevel bridgeconfig.PermissionLevel SignalDevice *signalmeow.Device @@ -187,6 +188,7 @@ func (br *SignalBridge) NewUser(dbUser *database.User) *User { PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID), } + user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin user.BridgeState = br.NewBridgeStateQueue(user) return user }