Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add relay mode #392

Merged
merged 2 commits into from
Dec 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
maltee1 marked this conversation as resolved.
Show resolved Hide resolved
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