Skip to content

Commit

Permalink
Add relay mode (#392)
Browse files Browse the repository at this point in the history
  • Loading branch information
maltee1 authored Dec 26, 2023
1 parent a57b51a commit c345091
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 8 deletions.
47 changes: 47 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ func (br *SignalBridge) RegisterCommands() {
cmdLogin,
cmdPM,
cmdDisconnect,
cmdSetRelay,
cmdUnsetRelay,
)
}

Expand All @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions config/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -68,6 +70,8 @@ type BridgeConfig struct {

Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`

Relay RelaybotConfig `yaml:"relay"`

usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
}
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions config/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
18 changes: 18 additions & 0 deletions example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
m.location: "<b>{{ .Sender.Displayname }}</b> sent a location"

# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug
Expand Down
7 changes: 4 additions & 3 deletions messagetracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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, ""
Expand Down Expand Up @@ -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 {
Expand Down
74 changes: 69 additions & 5 deletions portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.Err(err).Msg("Failed to apply relaybot format")
}
content.FormattedBody = data
return true
}
2 changes: 2 additions & 0 deletions user.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type User struct {
bridge *SignalBridge
log zerolog.Logger

Admin bool
PermissionLevel bridgeconfig.PermissionLevel

SignalDevice *signalmeow.Device
Expand Down Expand Up @@ -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
}
Expand Down

0 comments on commit c345091

Please sign in to comment.