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
}