From ec9c1a824f38a0e27f4c8dcb60095f29d3feefae Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 09:07:19 -0400 Subject: [PATCH 01/31] Support plumbing WA groups Fixes #202 --- commands.go | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++-- portal.go | 11 +- user.go | 4 +- 3 files changed, 316 insertions(+), 15 deletions(-) diff --git a/commands.go b/commands.go index 1946be99..3e7384d8 100644 --- a/commands.go +++ b/commands.go @@ -36,6 +36,7 @@ import ( "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix/event" @@ -54,6 +55,7 @@ type WrappedCommandEvent struct { func (br *WABridge) RegisterCommands() { proc := br.CommandProcessor.(*commands.Processor) proc.AddHandlers( + cmdCancel, cmdSetRelay, cmdUnsetRelay, cmdInviteLink, @@ -92,14 +94,153 @@ func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { } } +type StateHandler struct { + Func func(*WrappedCommandEvent) + Name string +} + +func (sh *StateHandler) Run(ce *commands.Event) { + wrapCommand(sh.Func)(ce) +} + +func (sh *StateHandler) GetName() string { + return sh.Name +} + var ( HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11} HelpSectionCreatingPortals = commands.HelpSection{Name: "Creating portals", Order: 15} HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25} HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30} + + roomArgHelpMd = " [<_Matrix room ID_> | --here]" + roomArgHelp = " [ | --here]" ) +func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, ok bool) { + if len(ce.Args) <= argIndex { + ok = true + return + } + roomArg := ce.Args[argIndex] + if roomArg == "--here" { + roomID = ce.RoomID + } else if strings.HasPrefix(roomArg, "!") { + roomID = id.RoomID(roomArg) + } else if strings.HasPrefix(roomArg, "#") { + resp, err := ce.MainIntent().ResolveAlias(id.RoomAlias(roomArg)) + if err != nil { + ce.Log.Errorln("Failed to resolve room alias %s to a room ID: %v", roomArg, err) + ce.Reply("Unable to find a room with the provided alias") + return + } else { + roomID = resp.RoomID + } + } + if roomID == "" { + ce.Reply("Invalid room ID") + return + } + + var thatThisSuffix string + if roomID == ce.RoomID { + thatThisSuffix = "is" + } else { + thatThisSuffix = "at" + } + + portal := ce.Bridge.GetPortalByMXID(roomID) + if portal != nil { + ce.Reply("Th%s room is already a portal room.", thatThisSuffix) + } else if !userHasPowerLevel(roomID, ce.MainIntent(), ce.User, "bridge") { + ce.Reply("You do not have the permissions to bridge th%s room.", thatThisSuffix) + } else { + ok = true + } + return +} + +func getInitialState(intent *appservice.IntentAPI, roomID id.RoomID) ( + name string, + topic string, + levels *event.PowerLevelsEventContent, + encrypted bool, +) { + state, err := intent.State(roomID) + if err == nil { + for _, events := range state { + for _, evt := range events { + switch evt.Type { + case event.StateRoomName: + name = evt.Content.AsRoomName().Name + case event.StateTopic: + topic = evt.Content.AsTopic().Topic + case event.StatePowerLevels: + levels = evt.Content.AsPowerLevels() + case event.StateEncryption: + encrypted = true + default: + continue + } + } + } + } + return +} + +func warnMissingPower(levels *event.PowerLevelsEventContent, ce *WrappedCommandEvent) { + if levels.GetUserLevel(ce.Bot.UserID) < levels.Redact() { + ce.Reply( + "Warning: The bot does not have privileges to redact messages on Matrix. " + + "Message deletions from WhatsApp will not be bridged unless you give " + + "redaction permissions to [%[1]s](https://matrix.to/#/%[1]s)", + ce.Bot.UserID, + ) + } + /* TODO Check other permissions too: + Important: + - invite/kick + Optional: + - m.bridge/uk.half-shot.bridge + - set room name/topic/avatar + - change power levels + - top PL for bot to control all users, including initial inviter + */ +} + +func userHasPowerLevel(roomID id.RoomID, intent *appservice.IntentAPI, sender *User, stateEventName string) bool { + if sender.Admin { + return true + } + levels, err := intent.PowerLevels(roomID) + if err != nil || levels == nil { + return false + } + eventType := event.Type{Type: "fi.mau.whatsapp" + stateEventName, Class: event.StateEventType} + return levels.GetUserLevel(sender.MXID) >= levels.GetEventLevel(eventType) +} + +var cmdCancel = &commands.FullHandler{ + Func: wrapCommand(fnCancel), + Name: "cancel", + Help: commands.HelpMeta{ + Section: commands.HelpSectionGeneral, + Description: "Cancel an ongoing action.", + }, +} + +func fnCancel(ce *WrappedCommandEvent) { + status := ce.User.GetCommandState() + if status != nil { + action := status["next"].(commands.Handler).GetName() + ce.User.CommandState = nil + ce.Reply("%s cancelled.", action) + } else { + ce.Reply("No ongoing command.") + } +} + var cmdSetRelay = &commands.FullHandler{ Func: wrapCommand(fnSetRelay), Name: "set-relay", @@ -692,7 +833,7 @@ func fnDeletePortal(ce *WrappedCommandEvent) { ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") ce.Portal.Delete() - ce.Portal.Cleanup(false) + ce.Portal.Cleanup("", false) } var cmdDeleteAllPortals = &commands.FullHandler{ @@ -750,7 +891,7 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) { go func() { for _, portal := range portalsToDelete { - portal.Cleanup(false) + portal.Cleanup("", false) } ce.Reply("Finished background cleanup of deleted portal rooms.") }() @@ -970,14 +1111,19 @@ var cmdOpen = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionCreatingPortals, Description: "Open a group chat portal.", - Args: "<_group JID_>", + Args: "<_group JID_>" + roomArgHelpMd, }, RequiresLogin: true, } func fnOpen(ce *WrappedCommandEvent) { if len(ce.Args) == 0 { - ce.Reply("**Usage:** `open `") + ce.Reply("**Usage:** `open " + roomArgHelp + "`") + return + } + + bridgeRoomID, ok := getBridgeRoomID(ce, 1) + if !ok { return } @@ -1000,16 +1146,166 @@ func fnOpen(ce *WrappedCommandEvent) { ce.Log.Debugln("Importing", jid, "for", ce.User.MXID) portal := ce.User.GetPortalByJID(info.JID) if len(portal.MXID) > 0 { - portal.UpdateMatrixRoom(ce.User, info) - ce.Reply("Portal room synced.") + if bridgeRoomID == "" { + portal.UpdateMatrixRoom(ce.User, info) + ce.Reply("Portal room synced.") + } else { + // TODO Move to a function + hasPortalMessage := "That WhatsApp group already has a portal at [%[1]s](https://matrix.to/#/%[1]s). " + if !userHasPowerLevel(portal.MXID, ce.MainIntent(), ce.User, "unbridge") { + ce.Reply( + hasPortalMessage + + "Additionally, you do not have the permissions to unbridge that room.", + portal.MXID, + ) + } else { + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmBridge, "Room bridging"}, + "mxid": portal.MXID, + "bridgeToMXID": bridgeRoomID, + "jid": info.JID, + } + ce.Reply( + hasPortalMessage + + "However, you have the permissions to unbridge that room.\n\n" + + "To delete that portal completely and continue bridging, use " + + "`$cmdprefix delete-and-continue`. To unbridge the portal " + + "without kicking Matrix users, use `$cmdprefix unbridge-and-" + + "continue`. To cancel, use `$cmdprefix cancel`.", + portal.MXID, + ) + } + } } else { - err = portal.CreateMatrixRoom(ce.User, info, true, true) - if err != nil { - ce.Reply("Failed to create room: %v", err) + if bridgeRoomID == "" { + err = portal.CreateMatrixRoom(ce.User, info, true, true) + if err != nil { + ce.Reply("Failed to create room: %v", err) + } else { + ce.Reply("Portal room created.") + } } else { - ce.Reply("Portal room created.") + // TODO Move to a function + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmBridge, "Room bridging"}, + "bridgeToMXID": bridgeRoomID, + "jid": info.JID, + } + ce.Reply( + "That WhatsApp group has no existing portal. To confirm bridging the " + + "group, use `$cmdprefix continue`. To cancel, use `$cmdprefix cancel`.", + ) + } + } +} + +func cleanupOldPortalWhileBridging(ce *WrappedCommandEvent, portal *Portal) (bool, func()) { + if len(portal.MXID) == 0 { + ce.Reply( + "The portal seems to have lost its Matrix room between you" + + "calling `$cmdprefix bridge` and this command.\n\n" + + "Continuing without touching the previous Matrix room...", + ) + return true, nil + } + switch ce.Args[0] { + case "delete-and-continue": + return true, func () { + portal.Cleanup("Portal deleted (moving to another room)", false) + } + case "unbridge-and-continue": + return true, func () { + portal.Cleanup("Room unbridged (portal moving to another room)", true) + } + default: + ce.Reply( + "The chat you were trying to bridge already has a Matrix portal room.\n\n" + + "Please use `$cmdprefix delete-and-continue` or `$cmdprefix unbridge-and-" + + "continue` to either delete or unbridge the existing room (respectively) and " + + "continue with the bridging.\n\n" + + "If you changed your mind, use `$cmdprefix cancel` to cancel.", + ) + return false, nil + } +} + +func confirmBridge(ce *WrappedCommandEvent) { + defer func() { + if err := recover(); err != nil { + ce.User.CommandState = nil + ce.Reply("Fatal error: %v. This shouldn't happen unless you're messing with the command handler code.", err) + } + }() + + status := ce.User.GetCommandState() + bridgeToMXID := status["bridgeToMXID"].(id.RoomID) + portal := ce.User.GetPortalByJID(status["jid"].(types.JID)) + if portal == nil { + panic("could not retrieve portal that was expected to exist") + } + + _, mxidInStatus := status["mxid"] + if mxidInStatus { + ok, f := cleanupOldPortalWhileBridging(ce, portal) + if !ok { + return + } else if f != nil { + go f() + ce.Reply("Cleaning up previous portal room...") } + } else if len(portal.MXID) > 0 { + ce.User.CommandState = nil + ce.Reply( + "The portal seems to have created a Matrix room between you " + + "calling `$cmdprefix bridge` and this command.\n\n" + + "Please start over by calling the bridge command again.", + ) + return + } else if ce.Args[0] != "continue" { + ce.Reply( + "Please use `$cmdprefix continue` to confirm the bridging or " + + "`$cmdprefix cancel` to cancel.", + ) + return + } + + ce.User.CommandState = nil + lockedConfirmBridge(ce, portal, bridgeToMXID) +} + +func lockedConfirmBridge(ce *WrappedCommandEvent, portal *Portal, roomID id.RoomID) { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + + user := ce.User + if !user.IsLoggedIn() { + ce.Reply("You are not logged in to WhatsApp.") + return + } + // TODO Handle non-groups (DMs) too? + info, err := user.Client.GetGroupInfo(portal.Key.JID) + if err != nil { + ce.Reply("Failed to get group info: %v", err) } + + portal.MXID = roomID + portal.bridge.portalsLock.Lock() + portal.bridge.portalsByMXID[portal.MXID] = portal + portal.bridge.portalsLock.Unlock() + var levels *event.PowerLevelsEventContent + portal.Name, portal.Topic, levels, portal.Encrypted = getInitialState( + ce.MainIntent(), ce.RoomID, + ) + portal.Avatar = "" + portal.Update(nil) + portal.UpdateBridgeInfo() + + // TODO Let UpdateMatrixRoom also update power levels + go portal.UpdateMatrixRoom(user, info) + + warnMissingPower(levels, ce) + + ce.Reply("Bridging complete. Portal synchronization should begin momentarily.") } var cmdPM = &commands.FullHandler{ diff --git a/portal.go b/portal.go index 86906c44..279e07ba 100644 --- a/portal.go +++ b/portal.go @@ -3345,11 +3345,11 @@ func (portal *Portal) CleanupIfEmpty() { if len(users) == 0 { portal.log.Infoln("Room seems to be empty, cleaning up...") portal.Delete() - portal.Cleanup(false) + portal.Cleanup("", false) } } -func (portal *Portal) Cleanup(puppetsOnly bool) { +func (portal *Portal) Cleanup(message string, puppetsOnly bool) { if len(portal.MXID) == 0 { return } @@ -3366,6 +3366,9 @@ func (portal *Portal) Cleanup(puppetsOnly bool) { portal.log.Errorln("Failed to get portal members for cleanup:", err) return } + if message == "" { + message = "Deleting portal" + } for member := range members.Joined { if member == intent.UserID { continue @@ -3377,7 +3380,7 @@ func (portal *Portal) Cleanup(puppetsOnly bool) { portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err) } } else if !puppetsOnly { - _, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) + _, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: message}) if err != nil { portal.log.Errorln("Error kicking user while cleaning up portal:", err) } @@ -3394,7 +3397,7 @@ func (portal *Portal) HandleMatrixLeave(brSender bridge.User) { if portal.IsPrivateChat() { portal.log.Debugln("User left private chat portal, cleaning up and deleting...") portal.Delete() - portal.Cleanup(false) + portal.Cleanup("", false) return } else if portal.bridge.Config.Bridge.BridgeMatrixLeave { err := sender.Client.LeaveGroup(portal.Key.JID) diff --git a/user.go b/user.go index 2ce53d1b..80676927 100644 --- a/user.go +++ b/user.go @@ -83,6 +83,8 @@ type User struct { BackfillQueue *BackfillQueue BridgeState *bridge.BridgeStateQueue + + CommandState map[string]interface{} } func (br *WABridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { @@ -128,7 +130,7 @@ func (user *User) GetMXID() id.UserID { } func (user *User) GetCommandState() map[string]interface{} { - return nil + return user.CommandState } func (br *WABridge) GetUserByMXIDIfExists(userID id.UserID) *User { From 95591a58a045020f9eb9760876c8e7b2f8d4abd2 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 09:38:27 -0400 Subject: [PATCH 02/31] Linting fixes --- commands.go | 64 +++++++++++++++++++++++------------------------------ 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/commands.go b/commands.go index 3e7384d8..e6a181da 100644 --- a/commands.go +++ b/commands.go @@ -191,21 +191,20 @@ func getInitialState(intent *appservice.IntentAPI, roomID id.RoomID) ( func warnMissingPower(levels *event.PowerLevelsEventContent, ce *WrappedCommandEvent) { if levels.GetUserLevel(ce.Bot.UserID) < levels.Redact() { - ce.Reply( - "Warning: The bot does not have privileges to redact messages on Matrix. " + - "Message deletions from WhatsApp will not be bridged unless you give " + + ce.Reply("Warning: The bot does not have privileges to redact messages on Matrix. "+ + "Message deletions from WhatsApp will not be bridged unless you give "+ "redaction permissions to [%[1]s](https://matrix.to/#/%[1]s)", ce.Bot.UserID, ) } /* TODO Check other permissions too: - Important: - - invite/kick - Optional: - - m.bridge/uk.half-shot.bridge - - set room name/topic/avatar - - change power levels - - top PL for bot to control all users, including initial inviter + Important: + - invite/kick + Optional: + - m.bridge/uk.half-shot.bridge + - set room name/topic/avatar + - change power levels + - top PL for bot to control all users, including initial inviter */ } @@ -1153,24 +1152,22 @@ func fnOpen(ce *WrappedCommandEvent) { // TODO Move to a function hasPortalMessage := "That WhatsApp group already has a portal at [%[1]s](https://matrix.to/#/%[1]s). " if !userHasPowerLevel(portal.MXID, ce.MainIntent(), ce.User, "unbridge") { - ce.Reply( - hasPortalMessage + + ce.Reply(hasPortalMessage+ "Additionally, you do not have the permissions to unbridge that room.", portal.MXID, ) } else { ce.User.CommandState = map[string]interface{}{ - "next": &StateHandler{confirmBridge, "Room bridging"}, - "mxid": portal.MXID, + "next": &StateHandler{confirmBridge, "Room bridging"}, + "mxid": portal.MXID, "bridgeToMXID": bridgeRoomID, - "jid": info.JID, + "jid": info.JID, } - ce.Reply( - hasPortalMessage + - "However, you have the permissions to unbridge that room.\n\n" + - "To delete that portal completely and continue bridging, use " + - "`$cmdprefix delete-and-continue`. To unbridge the portal " + - "without kicking Matrix users, use `$cmdprefix unbridge-and-" + + ce.Reply(hasPortalMessage+ + "However, you have the permissions to unbridge that room.\n\n"+ + "To delete that portal completely and continue bridging, use "+ + "`$cmdprefix delete-and-continue`. To unbridge the portal "+ + "without kicking Matrix users, use `$cmdprefix unbridge-and-"+ "continue`. To cancel, use `$cmdprefix cancel`.", portal.MXID, ) @@ -1187,12 +1184,11 @@ func fnOpen(ce *WrappedCommandEvent) { } else { // TODO Move to a function ce.User.CommandState = map[string]interface{}{ - "next": &StateHandler{confirmBridge, "Room bridging"}, + "next": &StateHandler{confirmBridge, "Room bridging"}, "bridgeToMXID": bridgeRoomID, - "jid": info.JID, + "jid": info.JID, } - ce.Reply( - "That WhatsApp group has no existing portal. To confirm bridging the " + + ce.Reply("That WhatsApp group has no existing portal. To confirm bridging the " + "group, use `$cmdprefix continue`. To cancel, use `$cmdprefix cancel`.", ) } @@ -1201,8 +1197,7 @@ func fnOpen(ce *WrappedCommandEvent) { func cleanupOldPortalWhileBridging(ce *WrappedCommandEvent, portal *Portal) (bool, func()) { if len(portal.MXID) == 0 { - ce.Reply( - "The portal seems to have lost its Matrix room between you" + + ce.Reply("The portal seems to have lost its Matrix room between you" + "calling `$cmdprefix bridge` and this command.\n\n" + "Continuing without touching the previous Matrix room...", ) @@ -1210,16 +1205,15 @@ func cleanupOldPortalWhileBridging(ce *WrappedCommandEvent, portal *Portal) (boo } switch ce.Args[0] { case "delete-and-continue": - return true, func () { + return true, func() { portal.Cleanup("Portal deleted (moving to another room)", false) } case "unbridge-and-continue": - return true, func () { + return true, func() { portal.Cleanup("Room unbridged (portal moving to another room)", true) } default: - ce.Reply( - "The chat you were trying to bridge already has a Matrix portal room.\n\n" + + ce.Reply("The chat you were trying to bridge already has a Matrix portal room.\n\n" + "Please use `$cmdprefix delete-and-continue` or `$cmdprefix unbridge-and-" + "continue` to either delete or unbridge the existing room (respectively) and " + "continue with the bridging.\n\n" + @@ -1233,7 +1227,7 @@ func confirmBridge(ce *WrappedCommandEvent) { defer func() { if err := recover(); err != nil { ce.User.CommandState = nil - ce.Reply("Fatal error: %v. This shouldn't happen unless you're messing with the command handler code.", err) + ce.Reply("Fatal error: %v. This shouldn't happen unless you're messing with the command handler code.", err) } }() @@ -1255,15 +1249,13 @@ func confirmBridge(ce *WrappedCommandEvent) { } } else if len(portal.MXID) > 0 { ce.User.CommandState = nil - ce.Reply( - "The portal seems to have created a Matrix room between you " + + ce.Reply("The portal seems to have created a Matrix room between you " + "calling `$cmdprefix bridge` and this command.\n\n" + "Please start over by calling the bridge command again.", ) return } else if ce.Args[0] != "continue" { - ce.Reply( - "Please use `$cmdprefix continue` to confirm the bridging or " + + ce.Reply("Please use `$cmdprefix continue` to confirm the bridging or " + "`$cmdprefix cancel` to cancel.", ) return From c9380923dde8613d192516e8c4762dd16b1f30db Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 10:43:49 -0400 Subject: [PATCH 03/31] Add missing return --- commands.go | 1 + 1 file changed, 1 insertion(+) diff --git a/commands.go b/commands.go index e6a181da..2421ecc2 100644 --- a/commands.go +++ b/commands.go @@ -1278,6 +1278,7 @@ func lockedConfirmBridge(ce *WrappedCommandEvent, portal *Portal, roomID id.Room info, err := user.Client.GetGroupInfo(portal.Key.JID) if err != nil { ce.Reply("Failed to get group info: %v", err) + return } portal.MXID = roomID From 03727f79f3db6615215d4e2a03fa245fdcc02997 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 12:14:53 -0400 Subject: [PATCH 04/31] Update some messages --- commands.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands.go b/commands.go index 2421ecc2..a4616c3a 100644 --- a/commands.go +++ b/commands.go @@ -132,14 +132,14 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o resp, err := ce.MainIntent().ResolveAlias(id.RoomAlias(roomArg)) if err != nil { ce.Log.Errorln("Failed to resolve room alias %s to a room ID: %v", roomArg, err) - ce.Reply("Unable to find a room with the provided alias") + ce.Reply("Unable to find a room with the provided alias.") return } else { roomID = resp.RoomID } } if roomID == "" { - ce.Reply("Invalid room ID") + ce.Reply("Please provide a valid room ID.") return } From 47bcd51c7c0a2d4d07cb0995875183064e734255 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 20:27:40 -0400 Subject: [PATCH 05/31] Add `bridge` command as alias of `open --here` --- commands.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/commands.go b/commands.go index a4616c3a..a07260d2 100644 --- a/commands.go +++ b/commands.go @@ -75,6 +75,7 @@ func (br *WABridge) RegisterCommands() { cmdBackfill, cmdList, cmdSearch, + cmdBridge, cmdOpen, cmdPM, cmdSync, @@ -114,8 +115,9 @@ var ( HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25} HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30} - roomArgHelpMd = " [<_Matrix room ID_> | --here]" - roomArgHelp = " [ | --here]" + roomArgName = "Matrix room ID or alias" + roomArgHelpMd = " [<_" + roomArgName + "_> | --here]" + roomArgHelp = " [<" + roomArgName + "> | --here]" ) func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, ok bool) { @@ -1104,6 +1106,29 @@ func fnSearch(ce *WrappedCommandEvent) { ce.Reply(strings.Join(result, "\n\n")) } +var cmdBridge = &commands.FullHandler{ + Func: wrapCommand(fnBridge), + Name: "bridge", + Help: commands.HelpMeta{ + Section: HelpSectionCreatingPortals, + Description: "Bridge a WhatsApp group chat to the current Matrix room, or to a specified room.", + Args: "<_group JID_> [<_" + roomArgName + "_>]", + }, + RequiresLogin: true, +} + +func fnBridge(ce *WrappedCommandEvent) { + if len(ce.Args) == 0 { + ce.Reply("**Usage:** `bridge [<_" + roomArgName + "_>]`") + return + } + + if len(ce.Args) == 1 { + ce.Args = append(ce.Args, "--here") + } + fnOpen(ce) +} + var cmdOpen = &commands.FullHandler{ Func: wrapCommand(fnOpen), Name: "open", From 63edde1b8cef0ca31b142f87bc50f29246b4185e Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 10 Jun 2022 20:28:34 -0400 Subject: [PATCH 06/31] Support plumbing via provisioning API --- commands.go | 48 +++++++++---------------------- main.go | 19 +++++++++++++ portal.go | 17 +++++++++++ provisioning.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 34 deletions(-) diff --git a/commands.go b/commands.go index a07260d2..04f64579 100644 --- a/commands.go +++ b/commands.go @@ -128,22 +128,21 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o roomArg := ce.Args[argIndex] if roomArg == "--here" { roomID = ce.RoomID - } else if strings.HasPrefix(roomArg, "!") { - roomID = id.RoomID(roomArg) - } else if strings.HasPrefix(roomArg, "#") { - resp, err := ce.MainIntent().ResolveAlias(id.RoomAlias(roomArg)) + } else { + var isAlias bool + var err error + roomID, isAlias, err = ce.Bridge.ResolveRoomArg(roomArg) if err != nil { - ce.Log.Errorln("Failed to resolve room alias %s to a room ID: %v", roomArg, err) - ce.Reply("Unable to find a room with the provided alias.") + if isAlias { + ce.Log.Errorln("Failed to resolve room alias %s to a room ID: %v", roomArg, err) + ce.Reply("Unable to find a room with the provided alias.") + } else { + ce.Log.Errorln("Invalid room ID %s: %v", roomArg, err) + ce.Reply("Please provide a valid room ID or alias.") + } return - } else { - roomID = resp.RoomID } } - if roomID == "" { - ce.Reply("Please provide a valid room ID.") - return - } var thatThisSuffix string if roomID == ce.RoomID { @@ -152,8 +151,7 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o thatThisSuffix = "at" } - portal := ce.Bridge.GetPortalByMXID(roomID) - if portal != nil { + if ce.Bridge.GetPortalByMXID(roomID) != nil { ce.Reply("Th%s room is already a portal room.", thatThisSuffix) } else if !userHasPowerLevel(roomID, ce.MainIntent(), ce.User, "bridge") { ce.Reply("You do not have the permissions to bridge th%s room.", thatThisSuffix) @@ -1257,7 +1255,7 @@ func confirmBridge(ce *WrappedCommandEvent) { }() status := ce.User.GetCommandState() - bridgeToMXID := status["bridgeToMXID"].(id.RoomID) + roomID := status["bridgeToMXID"].(id.RoomID) portal := ce.User.GetPortalByJID(status["jid"].(types.JID)) if portal == nil { panic("could not retrieve portal that was expected to exist") @@ -1287,10 +1285,6 @@ func confirmBridge(ce *WrappedCommandEvent) { } ce.User.CommandState = nil - lockedConfirmBridge(ce, portal, bridgeToMXID) -} - -func lockedConfirmBridge(ce *WrappedCommandEvent, portal *Portal, roomID id.RoomID) { portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() @@ -1306,21 +1300,7 @@ func lockedConfirmBridge(ce *WrappedCommandEvent, portal *Portal, roomID id.Room return } - portal.MXID = roomID - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - var levels *event.PowerLevelsEventContent - portal.Name, portal.Topic, levels, portal.Encrypted = getInitialState( - ce.MainIntent(), ce.RoomID, - ) - portal.Avatar = "" - portal.Update(nil) - portal.UpdateBridgeInfo() - - // TODO Let UpdateMatrixRoom also update power levels - go portal.UpdateMatrixRoom(user, info) - + levels := portal.BridgeMatrixRoom(roomID, user, info) warnMissingPower(levels, ce) ce.Reply("Bridging complete. Portal synchronization should begin momentarily.") diff --git a/main.go b/main.go index bd74f830..40d3648f 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ package main import ( _ "embed" + "errors" "net/http" "os" "strconv" @@ -253,6 +254,24 @@ func (br *WABridge) GetConfigPtr() interface{} { return br.Config } +func (br *WABridge) ResolveRoomArg(roomArg string) (roomID id.RoomID, isAlias bool, err error) { + // TODO Use stricter check for correct room ID/alias grammar + if strings.HasPrefix(roomArg, "!") { + roomID = id.RoomID(roomArg) + } else if strings.HasPrefix(roomArg, "#") { + isAlias = true + resp, resolveErr := br.AS.BotIntent().ResolveAlias(id.RoomAlias(roomArg)) + if resolveErr != nil { + err = resolveErr + } else { + roomID = resp.RoomID + } + } else { + err = errors.New("not a valid room ID or alias") + } + return +} + func main() { br := &WABridge{ usersByMXID: make(map[id.UserID]*User), diff --git a/portal.go b/portal.go index 279e07ba..11032fba 100644 --- a/portal.go +++ b/portal.go @@ -1093,6 +1093,23 @@ func (portal *Portal) UpdateMatrixRoom(user *User, groupInfo *types.GroupInfo) b return true } +func (portal *Portal) BridgeMatrixRoom(roomID id.RoomID, user *User, info *types.GroupInfo) (levels *event.PowerLevelsEventContent) { + portal.MXID = roomID + portal.bridge.portalsLock.Lock() + portal.bridge.portalsByMXID[portal.MXID] = portal + portal.bridge.portalsLock.Unlock() + portal.Name, portal.Topic, levels, portal.Encrypted = getInitialState( + portal.bridge.AS.BotIntent(), roomID, + ) + portal.Avatar = "" + portal.Update(nil) + portal.UpdateBridgeInfo() + + // TODO Let UpdateMatrixRoom also update power levels + go portal.UpdateMatrixRoom(user, info) + return +} + func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { anyone := 0 nope := 99 diff --git a/provisioning.go b/provisioning.go index 93fe3946..1fffb8ff 100644 --- a/provisioning.go +++ b/provisioning.go @@ -67,6 +67,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/resolve_identifier/{number}", prov.ResolveIdentifier).Methods(http.MethodGet) r.HandleFunc("/v1/bulk_resolve_identifier", prov.BulkResolveIdentifier).Methods(http.MethodPost) r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost) + r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost) @@ -462,6 +463,81 @@ func (prov *ProvisioningAPI) BulkResolveIdentifier(w http.ResponseWriter, r *htt } } +func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) { + groupID := mux.Vars(r)["groupID"] + roomArg := mux.Vars(r)["roomIDorAlias"] + if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "User is not logged into WhatsApp", + ErrCode: "no session", + }) + } else if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if prov.bridge.GetPortalByMXID(roomID) != nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is already bridged to a WhatsApp group.", + ErrCode: "room already bridged", + }) + } else if !userHasPowerLevel(roomID, prov.bridge.AS.BotIntent(), user, "bridge") { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "User does not have the permissions to bridge that room.", + ErrCode: "not enough permissions", + }) + } else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid group ID", + ErrCode: "invalid group id", + }) + } else if info, err := user.Client.GetGroupInfo(jid); err != nil { + // TODO return better responses for different errors (like ErrGroupNotFound and ErrNotInGroup) + jsonResponse(w, http.StatusInternalServerError, Error{ + Error: fmt.Sprintf("Failed to get group info: %v", err), + ErrCode: "error getting group info", + }) + } else { + prov.log.Debugln("Importing", jid, "for", user.MXID) + portal := user.GetPortalByJID(info.JID) + if portal.MXID == roomID { + jsonResponse(w, http.StatusOK, Error{ + Error: "WhatsApp group is already bridged to that Matrix room.", + ErrCode: "bridge exists", + }) + return + } else if len(portal.MXID) > 0 { + switch strings.ToLower(r.URL.Query().Get("force")) { + case "delete": + portal.Cleanup("Portal deleted (moving to another room)", false) + case "unbridge": + portal.Cleanup("Room unbridged (portal moving to another room)", true) + default: + jsonResponse(w, http.StatusConflict, Error{ + Error: "WhatsApp group is already bridged to another Matrix room.", + ErrCode: "group already bridged", + }) + return + } + } + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + // TODO Store detected power levels & warn about missing permissions + portal.BridgeMatrixRoom(roomID, user, info) + jsonResponse(w, http.StatusAccepted, PortalInfo{ + RoomID: portal.MXID, + GroupInfo: info, + }) + } +} + func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) { groupID, _ := mux.Vars(r)["groupID"] if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { From 35a8c3bf0def6c5ccfa76348ff2b682fd07df24f Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 13 Jun 2022 11:09:01 -0400 Subject: [PATCH 07/31] Ensure bot is in room before trying to bridge it --- commands.go | 6 ++++++ provisioning.go | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/commands.go b/commands.go index 04f64579..19994b7b 100644 --- a/commands.go +++ b/commands.go @@ -142,6 +142,12 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o } return } + err = ce.Bot.EnsureJoined(roomID) + if err != nil { + ce.Log.Errorln("Failed to join %s: %v", roomArg, err) + ce.Reply("Failed to join target room %s. Ensure that the room exists and that the bridge bot can join it.", roomArg) + return + } } var thatThisSuffix string diff --git a/provisioning.go b/provisioning.go index 1fffb8ff..7a5a1ea7 100644 --- a/provisioning.go +++ b/provisioning.go @@ -483,6 +483,11 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) ErrCode: "invalid room id", }) } + } else if err := prov.bridge.Bot.EnsureJoined(roomID); err != nil { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Bridge bot is not in target room and cannot join it", + ErrCode: "room unknown", + }) } else if prov.bridge.GetPortalByMXID(roomID) != nil { jsonResponse(w, http.StatusConflict, Error{ Error: "Room is already bridged to a WhatsApp group.", From 5a0fdcb8b68e2c70a2f9dda0189424b1221d7697 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 13 Jun 2022 11:09:45 -0400 Subject: [PATCH 08/31] Add `unbridge` command Also check for state event PLs for unbridging/deleting portals --- commands.go | 62 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/commands.go b/commands.go index 19994b7b..9df3e70d 100644 --- a/commands.go +++ b/commands.go @@ -70,6 +70,7 @@ func (br *WABridge) RegisterCommands() { cmdReconnect, cmdDisconnect, cmdPing, + cmdUnbridge, cmdDeletePortal, cmdDeleteAllPortals, cmdBackfill, @@ -120,6 +121,14 @@ var ( roomArgHelp = " [<" + roomArgName + "> | --here]" ) +func getThatThisSuffix(targetRoomID id.RoomID, currentRoomID id.RoomID) string { + if targetRoomID == currentRoomID { + return "is" + } else { + return "at" + } +} + func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, ok bool) { if len(ce.Args) <= argIndex { ok = true @@ -150,12 +159,7 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o } } - var thatThisSuffix string - if roomID == ce.RoomID { - thatThisSuffix = "is" - } else { - thatThisSuffix = "at" - } + thatThisSuffix := getThatThisSuffix(roomID, ce.RoomID) if ce.Bridge.GetPortalByMXID(roomID) != nil { ce.Reply("Th%s room is already a portal room.", thatThisSuffix) @@ -797,6 +801,45 @@ func fnPing(ce *WrappedCommandEvent) { } } +func checkUnbridgePermission(portal *Portal, ce *WrappedCommandEvent) bool { + thatThisSuffix := getThatThisSuffix(portal.MXID, ce.RoomID) + errMsg := fmt.Sprintf("You do not have the permissions to unbridge th%s portal.", thatThisSuffix) + + if portal.IsPrivateChat() { + if portal.Key.Receiver != ce.User.JID { + ce.Reply(errMsg) + return false + } + } + + if !userHasPowerLevel(ce.Portal.MXID, ce.MainIntent(), ce.User, "unbridge") { + ce.Reply(errMsg) + return false + } + + return true +} + +var cmdUnbridge = &commands.FullHandler{ + Func: wrapCommand(fnUnbridge), + Name: "unbridge", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Remove puppets from the current portal room and forget the portal.", + }, + RequiresPortal: true, +} + +func fnUnbridge(ce *WrappedCommandEvent) { + if !checkUnbridgePermission(ce.Portal, ce) { + return + } + + ce.Portal.log.Infoln(ce.User.MXID, "requested unbridging of portal.") + ce.Portal.Delete() + ce.Portal.Cleanup("Room unbridged", true) +} + func canDeletePortal(portal *Portal, userID id.UserID) bool { if len(portal.MXID) == 0 { return false @@ -825,7 +868,7 @@ var cmdDeletePortal = &commands.FullHandler{ Name: "delete-portal", Help: commands.HelpMeta{ Section: HelpSectionPortalManagement, - Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.", + Description: "Remove all users from the current portal room and forget the portal. If the portal is used by other people, this is limited to bridge admins.", }, RequiresPortal: true, } @@ -835,10 +878,13 @@ func fnDeletePortal(ce *WrappedCommandEvent) { ce.Reply("Only bridge admins can delete portals with other Matrix users") return } + if !checkUnbridgePermission(ce.Portal, ce) { + return + } ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") ce.Portal.Delete() - ce.Portal.Cleanup("", false) + ce.Portal.Cleanup("Portal deleted", false) } var cmdDeleteAllPortals = &commands.FullHandler{ From 59bee47f20db844c918ffeec83a76b8a491a4ade Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 13 Jun 2022 12:10:28 -0400 Subject: [PATCH 09/31] Fix race condition in `create` command While creating a new WA group for a Matrix room, wait until it's finished before auto-creating new portal rooms for joined groups. --- commands.go | 3 +++ user.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/commands.go b/commands.go index 9df3e70d..fc271f7c 100644 --- a/commands.go +++ b/commands.go @@ -513,6 +513,8 @@ func fnCreate(ce *WrappedCommandEvent) { } ce.Log.Infofln("Creating group for %s with name %s and participants %+v", ce.RoomID, roomNameEvent.Name, participants) + ce.User.groupCreateLock.Lock() + defer ce.User.groupCreateLock.Unlock() resp, err := ce.User.Client.CreateGroup(roomNameEvent.Name, participants) if err != nil { ce.Reply("Failed to create group: %v", err) @@ -524,6 +526,7 @@ func fnCreate(ce *WrappedCommandEvent) { if len(portal.MXID) != 0 { portal.log.Warnln("Detected race condition in room creation") // TODO race condition, clean up the old room + // TODO confirm whether this is fixed by the lock on group creation } portal.MXID = ce.RoomID portal.Name = roomNameEvent.Name diff --git a/user.go b/user.go index 80676927..cebdd57a 100644 --- a/user.go +++ b/user.go @@ -68,6 +68,7 @@ type User struct { mgmtCreateLock sync.Mutex spaceCreateLock sync.Mutex + groupCreateLock sync.Mutex connLock sync.Mutex historySyncs chan *events.HistorySync @@ -1150,6 +1151,8 @@ func (user *User) markUnread(portal *Portal, unread bool) { } func (user *User) handleGroupCreate(evt *events.JoinedGroup) { + user.groupCreateLock.Lock() + defer user.groupCreateLock.Unlock() portal := user.GetPortalByJID(evt.JID) if len(portal.MXID) == 0 { err := portal.CreateMatrixRoom(user, &evt.GroupInfo, true, true) From ad9dcc2d7b7cf7d581957b7a5fbeae4f13606c93 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 13 Jun 2022 15:17:43 -0400 Subject: [PATCH 10/31] Support `set-relay` in provisioning API --- provisioning.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/provisioning.go b/provisioning.go index 7a5a1ea7..f34005cd 100644 --- a/provisioning.go +++ b/provisioning.go @@ -69,6 +69,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost) r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) + r.HandleFunc("/v1/set_relay/{roomIDorAlias}", prov.SetRelay).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost) @@ -583,6 +584,47 @@ func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) { } } +func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { + roomArg := mux.Vars(r)["roomIDorAlias"] + if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "User is not logged into WhatsApp", + ErrCode: "no session", + }) + } else if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if portal := prov.bridge.GetPortalByMXID(roomID); portal == nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is not bridged to WhatsApp", + ErrCode: "room not bridged", + }) + } else if !prov.bridge.Config.Bridge.Relay.Enabled { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "Relay mode is not enabled on this instance of the bridge", + ErrCode: "relay mode not enabled", + }) + } else if prov.bridge.Config.Bridge.Relay.AdminOnly && !user.Admin { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "Only admins are allowed to enable relay mode on this instance of the bridge", + ErrCode: "relay mode not allowed for non-admins", + }) + } else { + portal.RelayUserID = user.MXID + portal.Update(nil) + jsonResponse(w, http.StatusOK, Response{true, "Relay user set"}) + } +} + func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) wa := map[string]interface{}{ From 3c652da62ccd0b6eda79023bbd53dd01537f9888 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 13 Jun 2022 15:36:20 -0400 Subject: [PATCH 11/31] Support `invite-link` in provisioning API --- provisioning.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/provisioning.go b/provisioning.go index f34005cd..745698cc 100644 --- a/provisioning.go +++ b/provisioning.go @@ -70,6 +70,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) r.HandleFunc("/v1/set_relay/{roomIDorAlias}", prov.SetRelay).Methods(http.MethodPost) + r.HandleFunc("/v1/invite_link/{roomIDorAlias}", prov.GetInvite).Methods(http.MethodGet) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost) @@ -343,6 +344,10 @@ type PortalInfo struct { JustCreated bool `json:"just_created"` } +type LinkInfo struct { + InviteLink string `json:"invite_link"` +} + func looksEmaily(str string) bool { for _, char := range str { // Characters that are usually in emails, but shouldn't be in phone numbers @@ -625,6 +630,52 @@ func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { } } +func (prov *ProvisioningAPI) GetInvite(w http.ResponseWriter, r *http.Request) { + roomArg := mux.Vars(r)["roomIDorAlias"] + reset := r.URL.Query().Has("reset") + if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "User is not logged into WhatsApp", + ErrCode: "no session", + }) + } else if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if portal := prov.bridge.GetPortalByMXID(roomID); portal == nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is not bridged to WhatsApp", + ErrCode: "room not bridged", + }) + } else if portal.IsPrivateChat() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Can't get invite link to private chat", + ErrCode: "room not bridged to group", + }) + } else if portal.IsBroadcastList() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Can't get invite link to broadcast list", + ErrCode: "room not bridged to group", + }) + } else if link, err := user.Client.GetGroupInviteLink(portal.Key.JID, reset); err != nil { + prov.log.Errorln("Failed to get invite link: %v", err) + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Failed to get invite link", + ErrCode: "Failed to get invite link", + }) + } else { + jsonResponse(w, http.StatusOK, LinkInfo{link}) + } +} + func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) wa := map[string]interface{}{ From 950750354d239b7c39d7e13840bc72fd8d26e784 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 14 Jun 2022 14:07:57 -0400 Subject: [PATCH 12/31] Support `create` in provisioning API --- commands.go | 73 +++++++++++++++++++++++++++++++++---------------- provisioning.go | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/commands.go b/commands.go index fc271f7c..549480c0 100644 --- a/commands.go +++ b/commands.go @@ -35,6 +35,8 @@ import ( "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" + log "maunium.net/go/maulogger/v2" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/bridge" @@ -470,38 +472,58 @@ func fnCreate(ce *WrappedCommandEvent) { return } - members, err := ce.Bot.JoinedMembers(ce.RoomID) + portal, _, errMsg := createGroup(ce.User, ce.RoomID, &ce.Log, ce.Reply) + if errMsg != "" { + ce.Reply(errMsg) + } else { + ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) + } +} + +// Creates a new WhatsApp group out of the provided Matrix room ID, and bridges that room to the created group. +// +// If replier is set, it will be used to post user-visible messages about the progres of the group creation. +// +// On failure, returns an error message string (instead of an error object from a function call). +func createGroup(user *User, roomID id.RoomID, log *log.Logger, replier func(string, ...interface{})) ( + newPortal *Portal, + info *types.GroupInfo, + errMsg string, +) { + bridge := user.bridge + + members, err := bridge.Bot.JoinedMembers(roomID) if err != nil { - ce.Reply("Failed to get room members: %v", err) + errMsg = fmt.Sprintf("Failed to get room members: %v", err) return } var roomNameEvent event.RoomNameEventContent - err = ce.Bot.StateEvent(ce.RoomID, event.StateRoomName, "", &roomNameEvent) + err = bridge.Bot.StateEvent(roomID, event.StateRoomName, "", &roomNameEvent) if err != nil && !errors.Is(err, mautrix.MNotFound) { - ce.Log.Errorln("Failed to get room name to create group:", err) - ce.Reply("Failed to get room name") + (*log).Errorln("Failed to get room name to create group:", err) + errMsg = "Failed to get room name" return } else if len(roomNameEvent.Name) == 0 { - ce.Reply("Please set a name for the room first") + errMsg = "Please set a name for the room first" return } var encryptionEvent event.EncryptionEventContent - err = ce.Bot.StateEvent(ce.RoomID, event.StateEncryption, "", &encryptionEvent) + err = bridge.Bot.StateEvent(roomID, event.StateEncryption, "", &encryptionEvent) if err != nil && !errors.Is(err, mautrix.MNotFound) { - ce.Reply("Failed to get room encryption status") + errMsg = "Failed to get room encryption status" return } var participants []types.JID participantDedup := make(map[types.JID]bool) - participantDedup[ce.User.JID.ToNonAD()] = true + participantDedup[user.JID.ToNonAD()] = true participantDedup[types.EmptyJID] = true for userID := range members.Joined { - jid, ok := ce.Bridge.ParsePuppetMXID(userID) + jid, ok := user.bridge.ParsePuppetMXID(userID) if !ok { - user := ce.Bridge.GetUserByMXID(userID) + user := user.bridge.GetUserByMXID(userID) if user != nil && !user.JID.IsEmpty() { jid = user.JID.ToNonAD() } @@ -512,15 +534,15 @@ func fnCreate(ce *WrappedCommandEvent) { } } - ce.Log.Infofln("Creating group for %s with name %s and participants %+v", ce.RoomID, roomNameEvent.Name, participants) - ce.User.groupCreateLock.Lock() - defer ce.User.groupCreateLock.Unlock() - resp, err := ce.User.Client.CreateGroup(roomNameEvent.Name, participants) + (*log).Infofln("Creating group for %s with name %s and participants %+v", roomID, roomNameEvent.Name, participants) + user.groupCreateLock.Lock() + defer user.groupCreateLock.Unlock() + resp, err := user.Client.CreateGroup(roomNameEvent.Name, participants) if err != nil { - ce.Reply("Failed to create group: %v", err) + errMsg = fmt.Sprintf("Failed to create group: %v", err) return } - portal := ce.User.GetPortalByJID(resp.JID) + portal := user.GetPortalByJID(resp.JID) portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() if len(portal.MXID) != 0 { @@ -528,17 +550,19 @@ func fnCreate(ce *WrappedCommandEvent) { // TODO race condition, clean up the old room // TODO confirm whether this is fixed by the lock on group creation } - portal.MXID = ce.RoomID + portal.MXID = roomID portal.Name = roomNameEvent.Name portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 - if !portal.Encrypted && ce.Bridge.Config.Bridge.Encryption.Default { + if !portal.Encrypted && user.bridge.Config.Bridge.Encryption.Default { _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}) if err != nil { portal.log.Warnln("Failed to enable encryption in room:", err) - if errors.Is(err, mautrix.MForbidden) { - ce.Reply("I don't seem to have permission to enable encryption in this room.") - } else { - ce.Reply("Failed to enable encryption in room: %v", err) + if replier != nil { + if errors.Is(err, mautrix.MForbidden) { + replier("I don't seem to have permission to enable encryption in this room.") + } else { + replier("Failed to enable encryption in room: %v", err) + } } } portal.Encrypted = true @@ -547,7 +571,8 @@ func fnCreate(ce *WrappedCommandEvent) { portal.Update(nil) portal.UpdateBridgeInfo() - ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) + newPortal, info = portal, resp + return } var cmdLogin = &commands.FullHandler{ diff --git a/provisioning.go b/provisioning.go index 745698cc..fd51db0e 100644 --- a/provisioning.go +++ b/provisioning.go @@ -69,6 +69,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost) r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) + r.HandleFunc("/v1/create/{roomIDorAlias}", prov.CreateGroup).Methods(http.MethodPost) r.HandleFunc("/v1/set_relay/{roomIDorAlias}", prov.SetRelay).Methods(http.MethodPost) r.HandleFunc("/v1/invite_link/{roomIDorAlias}", prov.GetInvite).Methods(http.MethodGet) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) @@ -589,6 +590,53 @@ func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) { } } +func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) { + roomArg := mux.Vars(r)["roomIDorAlias"] + if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "User is not logged into WhatsApp", + ErrCode: "no session", + }) + } else if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if err := prov.bridge.Bot.EnsureJoined(roomID); err != nil { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Bridge bot is not in target room and cannot join it", + ErrCode: "room unknown", + }) + } else if prov.bridge.GetPortalByMXID(roomID) != nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is already bridged to a WhatsApp group.", + ErrCode: "room already bridged", + }) + } else if !userHasPowerLevel(roomID, prov.bridge.AS.BotIntent(), user, "bridge") { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "User does not have the permissions to bridge that room.", + ErrCode: "not enough permissions", + }) + } else if portal, info, errMsg := createGroup(user, roomID, &prov.log, nil); errMsg != "" { + jsonResponse(w, http.StatusForbidden, Error{ + Error: errMsg, + ErrCode: "error in group creation", + }) + } else { + jsonResponse(w, http.StatusOK, PortalInfo{ + RoomID: portal.MXID, + GroupInfo: info, + }) + } +} + func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { roomArg := mux.Vars(r)["roomIDorAlias"] if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { From 9c940e772286949bea8150724a7b556f8a512f11 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 14 Jun 2022 14:08:43 -0400 Subject: [PATCH 13/31] More consistency in provisioning API responses --- provisioning.go | 48 +++++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/provisioning.go b/provisioning.go index fd51db0e..7e5661ac 100644 --- a/provisioning.go +++ b/provisioning.go @@ -495,14 +495,21 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) Error: "Bridge bot is not in target room and cannot join it", ErrCode: "room unknown", }) - } else if prov.bridge.GetPortalByMXID(roomID) != nil { - jsonResponse(w, http.StatusConflict, Error{ - Error: "Room is already bridged to a WhatsApp group.", - ErrCode: "room already bridged", - }) + } else if portal := prov.bridge.GetPortalByMXID(roomID); portal != nil { + if portal.MXID != roomID { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is already a WhatsApp portal room", + ErrCode: "room already bridged", + }) + } else { + jsonResponse(w, http.StatusOK, Error{ + Error: "WhatsApp group is already bridged to that Matrix room", + ErrCode: "bridge exists", + }) + } } else if !userHasPowerLevel(roomID, prov.bridge.AS.BotIntent(), user, "bridge") { jsonResponse(w, http.StatusForbidden, Error{ - Error: "User does not have the permissions to bridge that room.", + Error: "User does not have the permissions to bridge that room", ErrCode: "not enough permissions", }) } else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) { @@ -521,7 +528,7 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) portal := user.GetPortalByJID(info.JID) if portal.MXID == roomID { jsonResponse(w, http.StatusOK, Error{ - Error: "WhatsApp group is already bridged to that Matrix room.", + Error: "WhatsApp group is already bridged to that Matrix room", ErrCode: "bridge exists", }) return @@ -533,7 +540,7 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) portal.Cleanup("Room unbridged (portal moving to another room)", true) default: jsonResponse(w, http.StatusConflict, Error{ - Error: "WhatsApp group is already bridged to another Matrix room.", + Error: "WhatsApp group is already bridged to another Matrix room", ErrCode: "group already bridged", }) return @@ -543,7 +550,7 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) defer portal.roomCreateLock.Unlock() // TODO Store detected power levels & warn about missing permissions portal.BridgeMatrixRoom(roomID, user, info) - jsonResponse(w, http.StatusAccepted, PortalInfo{ + jsonResponse(w, http.StatusOK, PortalInfo{ RoomID: portal.MXID, GroupInfo: info, }) @@ -614,20 +621,27 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) Error: "Bridge bot is not in target room and cannot join it", ErrCode: "room unknown", }) - } else if prov.bridge.GetPortalByMXID(roomID) != nil { - jsonResponse(w, http.StatusConflict, Error{ - Error: "Room is already bridged to a WhatsApp group.", - ErrCode: "room already bridged", - }) + } else if portal := prov.bridge.GetPortalByMXID(roomID); portal != nil { + if portal.MXID != roomID { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is already a WhatsApp portal room", + ErrCode: "room already bridged", + }) + } else { + jsonResponse(w, http.StatusOK, Error{ + Error: "WhatsApp group is already bridged to that Matrix room", + ErrCode: "bridge exists", + }) + } } else if !userHasPowerLevel(roomID, prov.bridge.AS.BotIntent(), user, "bridge") { jsonResponse(w, http.StatusForbidden, Error{ - Error: "User does not have the permissions to bridge that room.", + Error: "User does not have the permissions to bridge that room", ErrCode: "not enough permissions", }) } else if portal, info, errMsg := createGroup(user, roomID, &prov.log, nil); errMsg != "" { jsonResponse(w, http.StatusForbidden, Error{ Error: errMsg, - ErrCode: "error in group creation", + ErrCode: "error creating group", }) } else { jsonResponse(w, http.StatusOK, PortalInfo{ @@ -717,7 +731,7 @@ func (prov *ProvisioningAPI) GetInvite(w http.ResponseWriter, r *http.Request) { prov.log.Errorln("Failed to get invite link: %v", err) jsonResponse(w, http.StatusBadRequest, Error{ Error: "Failed to get invite link", - ErrCode: "Failed to get invite link", + ErrCode: "failed to get invite link", }) } else { jsonResponse(w, http.StatusOK, LinkInfo{link}) From e8f82b389a435fde65508fc0bfda63c4f2b42517 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 14 Jun 2022 16:16:44 -0400 Subject: [PATCH 14/31] Support `unset-relay` in provisioning API --- provisioning.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/provisioning.go b/provisioning.go index 7e5661ac..39567d89 100644 --- a/provisioning.go +++ b/provisioning.go @@ -71,6 +71,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) r.HandleFunc("/v1/create/{roomIDorAlias}", prov.CreateGroup).Methods(http.MethodPost) r.HandleFunc("/v1/set_relay/{roomIDorAlias}", prov.SetRelay).Methods(http.MethodPost) + r.HandleFunc("/v1/unset_relay/{roomIDorAlias}", prov.UnsetRelay).Methods(http.MethodPost) r.HandleFunc("/v1/invite_link/{roomIDorAlias}", prov.GetInvite).Methods(http.MethodGet) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost) @@ -652,6 +653,14 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) } func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { + prov.EditRelay(w, r, true) +} + +func (prov *ProvisioningAPI) UnsetRelay(w http.ResponseWriter, r *http.Request) { + prov.EditRelay(w, r, false) +} + +func (prov *ProvisioningAPI) EditRelay(w http.ResponseWriter, r *http.Request, set bool) { roomArg := mux.Vars(r)["roomIDorAlias"] if user := r.Context().Value("user").(*User); !user.IsLoggedIn() { jsonResponse(w, http.StatusBadRequest, Error{ @@ -686,9 +695,16 @@ func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { ErrCode: "relay mode not allowed for non-admins", }) } else { - portal.RelayUserID = user.MXID + var action string + if set { + portal.RelayUserID = user.MXID + action = "set" + } else { + portal.RelayUserID = "" + action = "unset" + } portal.Update(nil) - jsonResponse(w, http.StatusOK, Response{true, "Relay user set"}) + jsonResponse(w, http.StatusOK, Response{true, "Relay user " + action}) } } From 96a47f553e1303e0110112a7096302f5fff98294 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 15 Jun 2022 11:13:09 -0400 Subject: [PATCH 15/31] Room arg for `create`, `*-relay`, `invite-link` Also: - Check for permissions when plumbing with `create` - Add some more checks on command arguments - Make some help message formatting more consistent --- commands.go | 183 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 130 insertions(+), 53 deletions(-) diff --git a/commands.go b/commands.go index 549480c0..f03fa3bd 100644 --- a/commands.go +++ b/commands.go @@ -117,10 +117,16 @@ var ( HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25} HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30} +) + +const ( + roomArgName = "Matrix room ID or alias" + roomArgHere = "--here" - roomArgName = "Matrix room ID or alias" - roomArgHelpMd = " [<_" + roomArgName + "_> | --here]" - roomArgHelp = " [<" + roomArgName + "> | --here]" + roomArgHelpMd = "[<_" + roomArgName + "_>|`" + roomArgHere + "`]" + roomArgHelpNohereMd = "[<_" + roomArgName + "_>]" + roomArgHelpPl = "[" + roomArgName + " | " + roomArgHere + "]" + roomArgHelpNoherePl = "[" + roomArgName + "]" ) func getThatThisSuffix(targetRoomID id.RoomID, currentRoomID id.RoomID) string { @@ -131,19 +137,20 @@ func getThatThisSuffix(targetRoomID id.RoomID, currentRoomID id.RoomID) string { } } -func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, ok bool) { +func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int, hereByDefault bool) (roomID id.RoomID, ok bool) { + ok = true if len(ce.Args) <= argIndex { - ok = true - return - } - roomArg := ce.Args[argIndex] - if roomArg == "--here" { + if hereByDefault { + roomID = ce.RoomID + } + } else if roomArg := ce.Args[argIndex]; roomArg == "--here" { roomID = ce.RoomID } else { var isAlias bool var err error roomID, isAlias, err = ce.Bridge.ResolveRoomArg(roomArg) if err != nil { + ok = false if isAlias { ce.Log.Errorln("Failed to resolve room alias %s to a room ID: %v", roomArg, err) ce.Reply("Unable to find a room with the provided alias.") @@ -151,24 +158,59 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int) (roomID id.RoomID, o ce.Log.Errorln("Invalid room ID %s: %v", roomArg, err) ce.Reply("Please provide a valid room ID or alias.") } - return - } - err = ce.Bot.EnsureJoined(roomID) - if err != nil { - ce.Log.Errorln("Failed to join %s: %v", roomArg, err) - ce.Reply("Failed to join target room %s. Ensure that the room exists and that the bridge bot can join it.", roomArg) - return } } + return +} + +func getBridgeableRoomID(ce *WrappedCommandEvent, argIndex int, hereByDefault bool) (roomID id.RoomID, ok bool) { + roomID, ok = getBridgeRoomID(ce, argIndex, hereByDefault) + // When ok, an empty roomID means that a new room should be created + if !ok || roomID == "" { + return + } - thatThisSuffix := getThatThisSuffix(roomID, ce.RoomID) + if err := ce.Bot.EnsureJoined(roomID); err != nil { + ok = false + ce.Log.Errorln("Failed to join room: %v", err) + ce.Reply("Failed to join target room. Ensure that the room exists and that the bridge bot can join it.") + return + } + var errMsg string if ce.Bridge.GetPortalByMXID(roomID) != nil { - ce.Reply("Th%s room is already a portal room.", thatThisSuffix) + errMsg = "Th%s room is already a portal room." } else if !userHasPowerLevel(roomID, ce.MainIntent(), ce.User, "bridge") { - ce.Reply("You do not have the permissions to bridge th%s room.", thatThisSuffix) + errMsg = "You do not have the permissions to bridge th%s room." + } + if errMsg != "" { + ok = false + ce.Reply(errMsg, getThatThisSuffix(roomID, ce.RoomID)) + } + return +} + +func getPortalForCmd(ce *WrappedCommandEvent, argIndex int) (portal *Portal) { + bridgeRoomID, ok := getBridgeRoomID(ce, argIndex, true) + if !ok { + return + } + + if bridgeRoomID == ce.RoomID { + portal = ce.Portal } else { - ok = true + portal = ce.Bridge.GetPortalByMXID(bridgeRoomID) + } + if portal == nil { + var roomString string + if bridgeRoomID == ce.RoomID { + roomString = "this room" + } else if len(ce.Args) <= argIndex { + roomString = "that room" + } else { + roomString = ce.Args[argIndex] + } + ce.Reply("That command can only be ran for portal rooms, and %s is not a portal room.", roomString) } return } @@ -258,21 +300,13 @@ var cmdSetRelay = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionPortalManagement, Description: "Relay messages in this room through your WhatsApp account.", + Args: roomArgHelpNohereMd, }, - RequiresPortal: true, - RequiresLogin: 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 admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = ce.User.MXID - ce.Portal.Update(nil) - ce.Reply("Messages from non-logged-in users in this room will now be bridged through your WhatsApp account") - } + fnEditRelay(ce, true) } var cmdUnsetRelay = &commands.FullHandler{ @@ -281,19 +315,41 @@ var cmdUnsetRelay = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionPortalManagement, Description: "Stop relaying messages in this room.", + Args: roomArgHelpNohereMd, }, - RequiresPortal: true, } func fnUnsetRelay(ce *WrappedCommandEvent) { + fnEditRelay(ce, false) +} + +func fnEditRelay(ce *WrappedCommandEvent, set bool) { + var action string + if n := len(ce.Args); n != 0 && n > 1 { + if set { + action = "set" + } else { + action = "unset" + } + ce.Reply("**Usage:** `%s-relay %s`", action, roomArgHelpNoherePl) + return + } + 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 admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = "" - ce.Portal.Update(nil) - ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") + } else if portal := getPortalForCmd(ce, 0); portal != nil { + if set { + portal.RelayUserID = ce.User.MXID + action = "now" + } else { + portal.RelayUserID = "" + action = "no longer" + } + portal.Update(nil) + ce.Reply("Messages from non-logged-in users in th%s room will %s be bridged through your WhatsApp account", + getThatThisSuffix(portal.MXID, ce.RoomID), action) } } @@ -303,19 +359,34 @@ var cmdInviteLink = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Get an invite link to the current group chat, optionally regenerating the link and revoking the old link.", - Args: "[--reset]", + Args: "[--reset] " + roomArgHelpNohereMd, }, - RequiresPortal: true, - RequiresLogin: true, + RequiresLogin: true, } func fnInviteLink(ce *WrappedCommandEvent) { - reset := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--reset" - if ce.Portal.IsPrivateChat() { + argLen := len(ce.Args) + reset := false + roomArgIndex := 0 + for i := 0; i < argLen; i++ { + if strings.ToLower(ce.Args[i]) == "--reset" { + reset = true + roomArgIndex = (i + 1) % 2 + break + } + } + if !reset && argLen > 1 || reset && argLen > 2 { + ce.Reply("**Usage:** `invite-link [--reset] " + roomArgHelpNoherePl + "`") + return + } + + if portal := getPortalForCmd(ce, roomArgIndex); portal == nil { + return + } else if portal.IsPrivateChat() { ce.Reply("Can't get invite link to private chat") - } else if ce.Portal.IsBroadcastList() { + } else if portal.IsBroadcastList() { ce.Reply("Can't get invite link to broadcast list") - } else if link, err := ce.User.Client.GetGroupInviteLink(ce.Portal.Key.JID, reset); err != nil { + } else if link, err := ce.User.Client.GetGroupInviteLink(portal.Key.JID, reset); err != nil { ce.Reply("Failed to get invite link: %v", err) } else { ce.Reply(link) @@ -461,18 +532,24 @@ var cmdCreate = &commands.FullHandler{ Name: "create", Help: commands.HelpMeta{ Section: HelpSectionCreatingPortals, - Description: "Create a WhatsApp group chat for the current Matrix room.", + Description: "Create a WhatsApp group chat for the current Matrix room, or for a specified room.", + Args: roomArgHelpNohereMd, }, RequiresLogin: true, } func fnCreate(ce *WrappedCommandEvent) { - if ce.Portal != nil { - ce.Reply("This is already a portal room") + if len(ce.Args) > 1 { + ce.Reply("**Usage:** `create " + roomArgHelpNoherePl + "`") + return + } + + bridgeRoomID, ok := getBridgeableRoomID(ce, 0, true) + if !ok { return } - portal, _, errMsg := createGroup(ce.User, ce.RoomID, &ce.Log, ce.Reply) + portal, _, errMsg := createGroup(ce.User, bridgeRoomID, &ce.Log, ce.Reply) if errMsg != "" { ce.Reply(errMsg) } else { @@ -1190,14 +1267,14 @@ var cmdBridge = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionCreatingPortals, Description: "Bridge a WhatsApp group chat to the current Matrix room, or to a specified room.", - Args: "<_group JID_> [<_" + roomArgName + "_>]", + Args: "<_group JID_> " + roomArgHelpNohereMd, }, RequiresLogin: true, } func fnBridge(ce *WrappedCommandEvent) { if len(ce.Args) == 0 { - ce.Reply("**Usage:** `bridge [<_" + roomArgName + "_>]`") + ce.Reply("**Usage:** `bridge " + roomArgHelpNoherePl + "`") return } @@ -1213,18 +1290,18 @@ var cmdOpen = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionCreatingPortals, Description: "Open a group chat portal.", - Args: "<_group JID_>" + roomArgHelpMd, + Args: "<_group JID_> " + roomArgHelpMd, }, RequiresLogin: true, } func fnOpen(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `open " + roomArgHelp + "`") + if n := len(ce.Args); n == 0 || n > 2 { + ce.Reply("**Usage:** `open " + roomArgHelpPl + "`") return } - bridgeRoomID, ok := getBridgeRoomID(ce, 1) + bridgeRoomID, ok := getBridgeableRoomID(ce, 1, false) if !ok { return } From 2b2d62603c21a4b00d3a58a4b770577adb7a1ae2 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 15 Jun 2022 14:28:07 -0400 Subject: [PATCH 16/31] Improve `--here` argument - Match on it case-insensitively - Check against a const instead of a re-typed literal --- commands.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands.go b/commands.go index f03fa3bd..eb98cb5c 100644 --- a/commands.go +++ b/commands.go @@ -143,7 +143,7 @@ func getBridgeRoomID(ce *WrappedCommandEvent, argIndex int, hereByDefault bool) if hereByDefault { roomID = ce.RoomID } - } else if roomArg := ce.Args[argIndex]; roomArg == "--here" { + } else if roomArg := ce.Args[argIndex]; strings.ToLower(roomArg) == roomArgHere { roomID = ce.RoomID } else { var isAlias bool @@ -1279,7 +1279,7 @@ func fnBridge(ce *WrappedCommandEvent) { } if len(ce.Args) == 1 { - ce.Args = append(ce.Args, "--here") + ce.Args = append(ce.Args, roomArgHere) } fnOpen(ce) } From da8b6871a5beb4d93609daaf7f6514c95b3af63d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 15 Jun 2022 14:34:18 -0400 Subject: [PATCH 17/31] Compress checkUnbridgePermission Namely, avoid building the message string when it's unneeded --- commands.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/commands.go b/commands.go index eb98cb5c..f39a38f5 100644 --- a/commands.go +++ b/commands.go @@ -907,22 +907,16 @@ func fnPing(ce *WrappedCommandEvent) { } func checkUnbridgePermission(portal *Portal, ce *WrappedCommandEvent) bool { - thatThisSuffix := getThatThisSuffix(portal.MXID, ce.RoomID) - errMsg := fmt.Sprintf("You do not have the permissions to unbridge th%s portal.", thatThisSuffix) - if portal.IsPrivateChat() { - if portal.Key.Receiver != ce.User.JID { - ce.Reply(errMsg) - return false + if portal.Key.Receiver == ce.User.JID { + return true } + } else if userHasPowerLevel(ce.Portal.MXID, ce.MainIntent(), ce.User, "unbridge") { + return true } - if !userHasPowerLevel(ce.Portal.MXID, ce.MainIntent(), ce.User, "unbridge") { - ce.Reply(errMsg) - return false - } - - return true + ce.Reply("You do not have the permissions to unbridge th%s portal.", getThatThisSuffix(portal.MXID, ce.RoomID)) + return false } var cmdUnbridge = &commands.FullHandler{ From 9f050a42abd36d2cddac374a3ad668c858018939 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 16 Jun 2022 12:01:00 -0400 Subject: [PATCH 18/31] Scale power levels When setting a user/puppet's power level with the bridge bot, reduce the power level to a value below the bot's own level. Also don't try to change levels that are >= the bot's level, or when the bot doesn't have permissions to edit power levels. --- portal.go | 86 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/portal.go b/portal.go index 11032fba..16049960 100644 --- a/portal.go +++ b/portal.go @@ -873,6 +873,7 @@ func (portal *Portal) SyncParticipants(source *User, metadata *types.GroupInfo) changed = true } changed = portal.applyPowerLevelFixes(levels) || changed + botLevel, plSetLevel := getControlLevels(portal.MainIntent().UserID, levels) participantMap := make(map[types.JID]bool) for _, participant := range metadata.Participants { participantMap[participant.JID] = true @@ -889,14 +890,11 @@ func (portal *Portal) SyncParticipants(source *User, metadata *types.GroupInfo) } } - expectedLevel := 0 - if participant.IsSuperAdmin { - expectedLevel = 95 - } else if participant.IsAdmin { - expectedLevel = 50 - } + pType := getParticipantType(participant) + expectedLevel := getParticipantLevel(pType, botLevel, plSetLevel, levels.GetUserLevel(puppet.MXID)) changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed if user != nil { + expectedLevel = getParticipantLevel(pType, botLevel, plSetLevel, levels.GetUserLevel(user.MXID)) changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed } } @@ -1137,6 +1135,57 @@ func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { } } +type participantType int + +const ( + ptSuperAdmin participantType = iota + ptAdmin + ptUser +) + +func getParticipantType(p types.GroupParticipant) participantType { + if p.IsSuperAdmin { + return ptSuperAdmin + } else if p.IsAdmin { + return ptAdmin + } else { + return ptUser + } +} + +func getControlLevels(botMXID id.UserID, levels *event.PowerLevelsEventContent) (senderLevel, plSetLevel int) { + senderLevel = levels.GetUserLevel(botMXID) + plSetLevel = levels.GetEventLevel(event.StatePowerLevels) + return +} + +func getParticipantLevel(pType participantType, senderLevel, plSetLevel, originalLevel int) int { + if senderLevel < plSetLevel || senderLevel <= originalLevel { + return originalLevel + } + + var goalLevel, margin int + switch pType { + case ptSuperAdmin: + goalLevel = 95 + margin = 5 + case ptAdmin: + goalLevel = 50 + margin = 10 + default: + goalLevel = 0 + margin = 15 + } + + marginLevel := senderLevel - margin + if goalLevel <= marginLevel { + return goalLevel + } else { + // NOTE this can be a negative level, which the spec allows + return marginLevel + } +} + func (portal *Portal) applyPowerLevelFixes(levels *event.PowerLevelsEventContent) bool { changed := false changed = levels.EnsureEventLevel(event.EventReaction, 0) || changed @@ -1149,17 +1198,20 @@ func (portal *Portal) ChangeAdminStatus(jids []types.JID, setAdmin bool) id.Even if err != nil { levels = portal.GetBasePowerLevels() } - newLevel := 0 + pType := ptUser if setAdmin { - newLevel = 50 + pType = ptAdmin } changed := portal.applyPowerLevelFixes(levels) + botLevel, plSetLevel := getControlLevels(portal.MainIntent().UserID, levels) for _, jid := range jids { puppet := portal.bridge.GetPuppetByJID(jid) + newLevel := getParticipantLevel(pType, botLevel, plSetLevel, levels.GetUserLevel(puppet.MXID)) changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed user := portal.bridge.GetUserByJID(jid) if user != nil { + newLevel = getParticipantLevel(pType, botLevel, plSetLevel, levels.GetUserLevel(user.MXID)) changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed } } @@ -1180,12 +1232,14 @@ func (portal *Portal) RestrictMessageSending(restrict bool) id.EventID { levels = portal.GetBasePowerLevels() } - newLevel := 0 + pType := ptUser if restrict { - newLevel = 50 + pType = ptAdmin } changed := portal.applyPowerLevelFixes(levels) + botLevel, plSetLevel := getControlLevels(portal.MainIntent().UserID, levels) + newLevel := getParticipantLevel(pType, botLevel, plSetLevel, levels.EventsDefault) if levels.EventsDefault == newLevel && !changed { return "" } @@ -1205,14 +1259,16 @@ func (portal *Portal) RestrictMetadataChanges(restrict bool) id.EventID { if err != nil { levels = portal.GetBasePowerLevels() } - newLevel := 0 + pType := ptUser if restrict { - newLevel = 50 + pType = ptAdmin } changed := portal.applyPowerLevelFixes(levels) - changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed + botLevel, plSetLevel := getControlLevels(portal.MainIntent().UserID, levels) + for _, eventType := range []event.Type{event.StateRoomName, event.StateRoomAvatar, event.StateTopic} { + newLevel := getParticipantLevel(pType, botLevel, plSetLevel, levels.GetEventLevel(eventType)) + changed = levels.EnsureEventLevel(eventType, newLevel) || changed + } if changed { resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels) if err != nil { From ac894c66b16ca0937ce4d73ca6eb9d8a29fa9944 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 16 Jun 2022 12:05:12 -0400 Subject: [PATCH 19/31] Make extra puppet leave if kicking it fails --- portal.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/portal.go b/portal.go index 16049960..7b89bb83 100644 --- a/portal.go +++ b/portal.go @@ -844,6 +844,13 @@ func (portal *Portal) kickExtraUsers(participantMap map[types.JID]bool) { }) if err != nil { portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) + puppet := portal.bridge.GetPuppetByMXID(member) + if puppet != nil { + _, err = puppet.DefaultIntent().LeaveRoom(portal.MXID) + if err != nil { + portal.log.Errorln("Error leaving as puppet for user %s who had left: %v:", member, err) + } + } } } } From 1440f3144f10df58081cc95d7e6bfcf6409ccda7 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 10:10:29 -0400 Subject: [PATCH 20/31] Check power levels before plumbing a room Before preparing a portal in an existing Matrix room, check that the room's power levels allow bridging to work properly. If not, notify the user of which permissions are missing. Support this for both bot commands and the provisioning API. --- commands.go | 107 ++++++++++------------ portal.go | 237 ++++++++++++++++++++++++++++++++++++++++++++++-- provisioning.go | 52 ++++++++++- 3 files changed, 332 insertions(+), 64 deletions(-) diff --git a/commands.go b/commands.go index f39a38f5..be1cbe6e 100644 --- a/commands.go +++ b/commands.go @@ -169,27 +169,69 @@ func getBridgeableRoomID(ce *WrappedCommandEvent, argIndex int, hereByDefault bo if !ok || roomID == "" { return } + ok = false if err := ce.Bot.EnsureJoined(roomID); err != nil { - ok = false ce.Log.Errorln("Failed to join room: %v", err) ce.Reply("Failed to join target room. Ensure that the room exists and that the bridge bot can join it.") return } - var errMsg string + var reply string if ce.Bridge.GetPortalByMXID(roomID) != nil { - errMsg = "Th%s room is already a portal room." + reply = "Th%s room is already a portal room." } else if !userHasPowerLevel(roomID, ce.MainIntent(), ce.User, "bridge") { - errMsg = "You do not have the permissions to bridge th%s room." + reply = "You do not have the permissions to bridge th%s room." + } else if pInfo := GetMissingRequiredPerms(roomID, ce.MainIntent(), ce.Bridge, ce.Log); pInfo != nil { + reply = "The bridge is missing **required** privileges in th%s room:\n" + + formatMissingPerms(pInfo, ce.Bot.UserID) + } else { + ok = true + if pInfo := GetMissingOptionalPerms(roomID, ce.MainIntent(), ce.Bridge, ce.Log); pInfo != nil { + reply = "The bridge is missing optional privileges in th%s room:\n" + + formatMissingPerms(pInfo, ce.Bot.UserID) + } } - if errMsg != "" { - ok = false - ce.Reply(errMsg, getThatThisSuffix(roomID, ce.RoomID)) + + if reply != "" { + ce.Reply(reply, getThatThisSuffix(roomID, ce.RoomID)) } return } +func formatMissingPerms(pInfo *MissingPermsInfo, botMXID id.UserID) string { + numMissingPerms := len(pInfo.MissingPerms) + if numMissingPerms == 0 { + return "" + } + var missingBotPerm, missingUserPerm bool + var permReply strings.Builder + for _, missingPerm := range pInfo.MissingPerms { + permReply.WriteString(fmt.Sprintf( + "- **%s** %s\n - To grant this privilege, lower its power level requirement to **%d**", + missingPerm.Description, + missingPerm.Consequence, + pInfo.GetLevel(missingPerm.IsForBot))) + if missingPerm.IsForBot { + permReply.WriteString(fmt.Sprintf( + ", or raise the power level of [%[1]s](https://matrix.to/#/%[1]s) to **%[2]d**", + botMXID, + missingPerm.ReqLevel)) + missingBotPerm = true + } else { + missingUserPerm = true + } + permReply.WriteString(".\n") + } + if numMissingPerms > 1 || missingBotPerm && missingUserPerm { + permReply.WriteString(fmt.Sprintf( + "\nTo quickly grant all missing privileges at once, raise the power level of [%[1]s](https://matrix.to/#/%[1]s) to **%[2]d**.", + botMXID, + pInfo.GetQuickfixLevel())) + } + return permReply.String() +} + func getPortalForCmd(ce *WrappedCommandEvent, argIndex int) (portal *Portal) { bridgeRoomID, ok := getBridgeRoomID(ce, argIndex, true) if !ok { @@ -215,53 +257,6 @@ func getPortalForCmd(ce *WrappedCommandEvent, argIndex int) (portal *Portal) { return } -func getInitialState(intent *appservice.IntentAPI, roomID id.RoomID) ( - name string, - topic string, - levels *event.PowerLevelsEventContent, - encrypted bool, -) { - state, err := intent.State(roomID) - if err == nil { - for _, events := range state { - for _, evt := range events { - switch evt.Type { - case event.StateRoomName: - name = evt.Content.AsRoomName().Name - case event.StateTopic: - topic = evt.Content.AsTopic().Topic - case event.StatePowerLevels: - levels = evt.Content.AsPowerLevels() - case event.StateEncryption: - encrypted = true - default: - continue - } - } - } - } - return -} - -func warnMissingPower(levels *event.PowerLevelsEventContent, ce *WrappedCommandEvent) { - if levels.GetUserLevel(ce.Bot.UserID) < levels.Redact() { - ce.Reply("Warning: The bot does not have privileges to redact messages on Matrix. "+ - "Message deletions from WhatsApp will not be bridged unless you give "+ - "redaction permissions to [%[1]s](https://matrix.to/#/%[1]s)", - ce.Bot.UserID, - ) - } - /* TODO Check other permissions too: - Important: - - invite/kick - Optional: - - m.bridge/uk.half-shot.bridge - - set room name/topic/avatar - - change power levels - - top PL for bot to control all users, including initial inviter - */ -} - func userHasPowerLevel(roomID id.RoomID, intent *appservice.IntentAPI, sender *User, stateEventName string) bool { if sender.Admin { return true @@ -1451,9 +1446,7 @@ func confirmBridge(ce *WrappedCommandEvent) { return } - levels := portal.BridgeMatrixRoom(roomID, user, info) - warnMissingPower(levels, ce) - + portal.BridgeMatrixRoom(roomID, user, info) ce.Reply("Bridging complete. Portal synchronization should begin momentarily.") } diff --git a/portal.go b/portal.go index 7b89bb83..c55d8e30 100644 --- a/portal.go +++ b/portal.go @@ -1098,21 +1098,246 @@ func (portal *Portal) UpdateMatrixRoom(user *User, groupInfo *types.GroupInfo) b return true } -func (portal *Portal) BridgeMatrixRoom(roomID id.RoomID, user *User, info *types.GroupInfo) (levels *event.PowerLevelsEventContent) { +type MissingPermsInfo struct { + BotLevel int + UserLevel int + MissingPerms []MissingPermDescriptor + + // cached to simplify the quickfix check + plSetLevel int +} + +type MissingPermDescriptor struct { + Key string + IsEvent bool + Description string + Consequence string + IsForBot bool + ReqLevel int +} + +func initMissingPermsInfo(botMXID id.UserID, levels *event.PowerLevelsEventContent) (info MissingPermsInfo) { + info.BotLevel, info.plSetLevel = getControlLevels(botMXID, levels) + info.UserLevel = getParticipantLevel(ptUser, info.BotLevel, info.plSetLevel, levels.UsersDefault) + return +} + +func (info *MissingPermsInfo) GetLevel(forBot bool) int { + if forBot { + return info.BotLevel + } else { + return info.UserLevel + } +} + +func (info *MissingPermsInfo) GetMinSufficientBotLevel() int { + minLevel := info.BotLevel + for _, missingPerm := range info.MissingPerms { + if missingPerm.IsForBot && minLevel < missingPerm.ReqLevel { + minLevel = missingPerm.ReqLevel + } + } + return minLevel +} + +func (info *MissingPermsInfo) GetQuickfixLevel() int { + minLevel := info.BotLevel + if minLevel < info.plSetLevel { + minLevel = info.plSetLevel + } + for _, missingPerm := range info.MissingPerms { + if minLevel < missingPerm.ReqLevel { + minLevel = missingPerm.ReqLevel + } + } + return minLevel +} + +type permDescriptor struct { + evt event.Type + key string + level int + isForBot bool + description string + consequence string +} + +func GetMissingRequiredPerms(roomID id.RoomID, botIntent *appservice.IntentAPI, bridge *WABridge, log log.Logger) *MissingPermsInfo { + levels, err := botIntent.PowerLevels(roomID) + if err != nil { + log.Errorln("Failed to get power levels: %v", err) + return nil + } + info := initMissingPermsInfo(botIntent.UserID, levels) + + var joinRules event.JoinRulesEventContent + err = botIntent.StateEvent(roomID, event.StateJoinRules, "", &joinRules) + if err != nil { + log.Warnln("Failed to get join rule: %v", err) + } else if joinRules.JoinRule == event.JoinRuleInvite || joinRules.JoinRule == event.JoinRulePrivate || joinRules.JoinRule == event.JoinRuleKnock { + checkPerm(&info, levels, &permDescriptor{ + key: "invite", + level: levels.Invite(), + isForBot: true, + description: "The room is private, and the bridge bot doesn't have privileges to invite Matrix users to it.", + consequence: "This means WhatsApp puppets will be unable to join the room.", + }) + } + + checkPerm(&info, levels, &permDescriptor{ + evt: event.EventMessage, + isForBot: false, + description: "WhatsApp puppets don't have privileges to send messages.", + consequence: "This means WhatsApp messages cannot be bridged.", + }) + + if len(info.MissingPerms) == 0 { + return nil + } else { + return &info + } +} + +func GetMissingOptionalPerms(roomID id.RoomID, botIntent *appservice.IntentAPI, bridge *WABridge, log log.Logger) *MissingPermsInfo { + levels, err := botIntent.PowerLevels(roomID) + if err != nil { + log.Errorln("Failed to get power levels: %v", err) + return nil + } + info := initMissingPermsInfo(botIntent.UserID, levels) + + var joinRules event.JoinRulesEventContent + err = botIntent.StateEvent(roomID, event.StateJoinRules, "", &joinRules) + if err != nil { + log.Warnln("Failed to get join rule: %v", err) + } else if joinRules.JoinRule == event.JoinRuleRestricted { + checkPerm(&info, levels, &permDescriptor{ + key: "invite", + level: levels.Invite(), + isForBot: true, + description: "Room membership is restricted, and the bridge bot doesn't have privileges to invite Matrix users to it.", + consequence: "This means the only WhatsApp puppets that can join the room are ones which are members of rooms/spaces that grant access to it.", + }) + } + + checkPerm(&info, levels, &permDescriptor{ + evt: event.EventRedaction, + isForBot: false, + description: "WhatsApp puppets don't have privileges to redact their own messages on Matrix.", + consequence: "This means the bridge bot must redact their messages in order to bridge message deletions from WhatsApp.", + }) + + checkPerm(&info, levels, &permDescriptor{ + key: "redact", + level: levels.Redact(), + isForBot: true, + description: "The bridge bot doesn't have privileges to redact messages on Matrix.", + consequence: "This means some message deletions from WhatsApp will not be bridged.", + }) + + checkPerm(&info, levels, &permDescriptor{ + evt: event.EventReaction, + isForBot: false, + description: "WhatsApp puppets don't have privileges to send reactions.", + consequence: "This means message reactions from WhatsApp will not be bridged.", + }) + + if bridge.Config.Bridge.AllowUserInvite { + checkPerm(&info, levels, &permDescriptor{ + evt: event.StateBridge, + isForBot: true, + description: "The bridge bot doesn't have privileges to mark the room as being a bridge to WhatsApp.", + consequence: "This means other Matrix users (including bots) may not be able to see it is a WhatsApp portal room.", + }) + } + + checkPerm(&info, levels, &permDescriptor{ + evt: event.StateRoomName, + isForBot: true, + description: "The bridge bot doesn't have privileges to set the room name.", + consequence: "This means the WhatsApp group's subject will not be bridged.", + }) + + checkPerm(&info, levels, &permDescriptor{ + evt: event.StateTopic, + isForBot: true, + description: "The bridge bot doesn't have privileges to set the room topic.", + consequence: "This means the WhatsApp group's description will not be bridged.", + }) + + checkPerm(&info, levels, &permDescriptor{ + evt: event.StateRoomAvatar, + isForBot: true, + description: "The bridge bot doesn't have privileges to set the room avatar.", + consequence: "This means the WhatsApp group's icon will not be bridged.", + }) + + checkPerm(&info, levels, &permDescriptor{ + evt: event.StatePowerLevels, + isForBot: true, + description: "The bridge bot doesn't have privileges to set the power levels of Matrix users or change the room's permission settings.", + consequence: "This means permissions and admin settings from WhatsApp will not be bridged.", + }) + + if len(info.MissingPerms) == 0 { + return nil + } else { + return &info + } +} + +func checkPerm(info *MissingPermsInfo, levels *event.PowerLevelsEventContent, perm *permDescriptor) { + isEvent := perm.key == "" + var key string + var reqLevel int + if isEvent { + key = perm.evt.Type + reqLevel = levels.GetEventLevel(perm.evt) + } else { + key = perm.key + reqLevel = perm.level + } + if perm.isForBot && info.BotLevel < reqLevel || !perm.isForBot && info.UserLevel < reqLevel { + info.MissingPerms = append(info.MissingPerms, MissingPermDescriptor{ + Key: key, + IsEvent: isEvent, + Description: perm.description, + Consequence: perm.consequence, + IsForBot: perm.isForBot, + ReqLevel: reqLevel, + }) + } +} + +func (portal *Portal) BridgeMatrixRoom(roomID id.RoomID, user *User, info *types.GroupInfo) { portal.MXID = roomID portal.bridge.portalsLock.Lock() portal.bridge.portalsByMXID[portal.MXID] = portal portal.bridge.portalsLock.Unlock() - portal.Name, portal.Topic, levels, portal.Encrypted = getInitialState( - portal.bridge.AS.BotIntent(), roomID, - ) + + state, err := portal.bridge.AS.BotIntent().State(roomID) + if err == nil { + for _, events := range state { + for _, evt := range events { + switch evt.Type { + case event.StateRoomName: + portal.Name = evt.Content.AsRoomName().Name + case event.StateTopic: + portal.Topic = evt.Content.AsTopic().Topic + case event.StateEncryption: + portal.Encrypted = true + default: + continue + } + } + } + } + portal.Avatar = "" portal.Update(nil) portal.UpdateBridgeInfo() - // TODO Let UpdateMatrixRoom also update power levels go portal.UpdateMatrixRoom(user, info) - return } func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { diff --git a/provisioning.go b/provisioning.go index 39567d89..3dd686e6 100644 --- a/provisioning.go +++ b/provisioning.go @@ -339,11 +339,18 @@ type OtherUserInfo struct { Avatar id.ContentURI `json:"avatar_url"` } +type PermsInfo struct { + BotLevel int `json:"bot_level"` + UserLevel int `json:"user_level"` + MissingPerms *map[string]interface{} `json:"missing_permissions"` +} + type PortalInfo struct { RoomID id.RoomID `json:"room_id"` OtherUser *OtherUserInfo `json:"other_user,omitempty"` GroupInfo *types.GroupInfo `json:"group_info,omitempty"` JustCreated bool `json:"just_created"` + PermsInfo *PermsInfo `json:"permissions_info,omitempty"` } type LinkInfo struct { @@ -513,6 +520,8 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) Error: "User does not have the permissions to bridge that room", ErrCode: "not enough permissions", }) + } else if pInfo := GetMissingRequiredPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log); pInfo != nil { + missingRequiredPermsResponse(w, pInfo) } else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) { jsonResponse(w, http.StatusBadRequest, Error{ Error: "Invalid group ID", @@ -549,11 +558,11 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) } portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() - // TODO Store detected power levels & warn about missing permissions portal.BridgeMatrixRoom(roomID, user, info) jsonResponse(w, http.StatusOK, PortalInfo{ RoomID: portal.MXID, GroupInfo: info, + PermsInfo: getPermsInfo(GetMissingOptionalPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log)), }) } } @@ -639,6 +648,8 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) Error: "User does not have the permissions to bridge that room", ErrCode: "not enough permissions", }) + } else if pInfo := GetMissingRequiredPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log); pInfo != nil { + missingRequiredPermsResponse(w, pInfo) } else if portal, info, errMsg := createGroup(user, roomID, &prov.log, nil); errMsg != "" { jsonResponse(w, http.StatusForbidden, Error{ Error: errMsg, @@ -648,6 +659,7 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) jsonResponse(w, http.StatusOK, PortalInfo{ RoomID: portal.MXID, GroupInfo: info, + PermsInfo: getPermsInfo(GetMissingOptionalPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log)), }) } } @@ -785,6 +797,44 @@ func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, resp) } +func missingRequiredPermsResponse(w http.ResponseWriter, info *MissingPermsInfo) { + jsonResponse(w, http.StatusForbidden, map[string]interface{}{ + "error": "Bridge is missing required permissions in this room", + "errcode": "bridge missing permissions", + "permissions_info": getPermsInfo(info), + }) +} + +func getPermsInfo(info *MissingPermsInfo) *PermsInfo { + if info == nil { + return nil + } + + var obj PermsInfo + obj.BotLevel = info.BotLevel + obj.UserLevel = info.UserLevel + + missingPerms := make(map[string]interface{}) + obj.MissingPerms = &missingPerms + + const eventsKey = "events" + eventPerms := make(map[string]int) + missingPerms[eventsKey] = eventPerms + + for _, perm := range info.MissingPerms { + if perm.IsEvent { + eventPerms[perm.Key] = perm.ReqLevel + } else { + missingPerms[perm.Key] = perm.ReqLevel + } + } + if len(eventPerms) == 0 { + delete(missingPerms, eventsKey) + } + + return &obj +} + func jsonResponse(w http.ResponseWriter, status int, response interface{}) { w.Header().Add("Content-Type", "application/json") w.WriteHeader(status) From 09e19b66bdb7783e4e3c778db0e92f8a1dd0c35f Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 10:13:05 -0400 Subject: [PATCH 21/31] Update room unbridging/deletion - Allow specifying a target room - Make `delete-all` check for unbridge permissions - Fix the unbridge permission check to use the target room, not the current room - Make provisioning API check for unbridge permissions before swapping portal rooms --- commands.go | 40 +++++++++++++++++++++++----------------- provisioning.go | 7 +++++++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/commands.go b/commands.go index be1cbe6e..35bc9434 100644 --- a/commands.go +++ b/commands.go @@ -901,16 +901,14 @@ func fnPing(ce *WrappedCommandEvent) { } } -func checkUnbridgePermission(portal *Portal, ce *WrappedCommandEvent) bool { +func checkUnbridgePermission(portal *Portal, user *User) bool { if portal.IsPrivateChat() { - if portal.Key.Receiver == ce.User.JID { + if portal.Key.Receiver == user.JID { return true } - } else if userHasPowerLevel(ce.Portal.MXID, ce.MainIntent(), ce.User, "unbridge") { + } else if userHasPowerLevel(portal.MXID, portal.MainIntent(), user, "unbridge") { return true } - - ce.Reply("You do not have the permissions to unbridge th%s portal.", getThatThisSuffix(portal.MXID, ce.RoomID)) return false } @@ -921,17 +919,21 @@ var cmdUnbridge = &commands.FullHandler{ Section: HelpSectionPortalManagement, Description: "Remove puppets from the current portal room and forget the portal.", }, - RequiresPortal: true, } func fnUnbridge(ce *WrappedCommandEvent) { - if !checkUnbridgePermission(ce.Portal, ce) { + portal := getPortalForCmd(ce, 0) + if portal == nil { + return + } + if !checkUnbridgePermission(portal, ce.User) { + ce.Reply("You do not have the permissions to unbridge th%s portal.", getThatThisSuffix(portal.MXID, ce.RoomID)) return } - ce.Portal.log.Infoln(ce.User.MXID, "requested unbridging of portal.") - ce.Portal.Delete() - ce.Portal.Cleanup("Room unbridged", true) + portal.log.Infoln(ce.User.MXID, "requested unbridging of portal.") + portal.Delete() + portal.Cleanup("Room unbridged", true) } func canDeletePortal(portal *Portal, userID id.UserID) bool { @@ -964,21 +966,25 @@ var cmdDeletePortal = &commands.FullHandler{ Section: HelpSectionPortalManagement, Description: "Remove all users from the current portal room and forget the portal. If the portal is used by other people, this is limited to bridge admins.", }, - RequiresPortal: true, } func fnDeletePortal(ce *WrappedCommandEvent) { - if !ce.User.Admin && !canDeletePortal(ce.Portal, ce.User.MXID) { + portal := getPortalForCmd(ce, 0) + if portal == nil { + return + } + if !ce.User.Admin && !canDeletePortal(portal, ce.User.MXID) { ce.Reply("Only bridge admins can delete portals with other Matrix users") return } - if !checkUnbridgePermission(ce.Portal, ce) { + if !checkUnbridgePermission(portal, ce.User) { + ce.Reply("You do not have the permissions to unbridge th%s portal.", getThatThisSuffix(portal.MXID, ce.RoomID)) return } - ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") - ce.Portal.Delete() - ce.Portal.Cleanup("Portal deleted", false) + portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") + portal.Delete() + portal.Cleanup("Portal deleted", false) } var cmdDeleteAllPortals = &commands.FullHandler{ @@ -999,7 +1005,7 @@ func fnDeleteAllPortals(ce *WrappedCommandEvent) { } else { portalsToDelete = portals[:0] for _, portal := range portals { - if canDeletePortal(portal, ce.User.MXID) { + if canDeletePortal(portal, ce.User.MXID) && checkUnbridgePermission(portal, ce.User) { portalsToDelete = append(portalsToDelete, portal) } } diff --git a/provisioning.go b/provisioning.go index 3dd686e6..c1fe097c 100644 --- a/provisioning.go +++ b/provisioning.go @@ -543,6 +543,13 @@ func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) }) return } else if len(portal.MXID) > 0 { + if !userHasPowerLevel(portal.MXID, prov.bridge.AS.BotIntent(), user, "unbridge") { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "User does not have the permissions to unbridge that room", + ErrCode: "not enough permissions", + }) + return + } switch strings.ToLower(r.URL.Query().Get("force")) { case "delete": portal.Cleanup("Portal deleted (moving to another room)", false) From 1f66da4635e58718b2f214fe3b1b654b3625dae0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 10:17:20 -0400 Subject: [PATCH 22/31] Support `unbridge`/`delete` in provisioning API --- provisioning.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/provisioning.go b/provisioning.go index c1fe097c..b9d4623a 100644 --- a/provisioning.go +++ b/provisioning.go @@ -70,6 +70,8 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) r.HandleFunc("/v1/create/{roomIDorAlias}", prov.CreateGroup).Methods(http.MethodPost) + r.HandleFunc("/v1/unbridge/{roomIDorAlias}", prov.UnbridgePortal).Methods(http.MethodPost) + r.HandleFunc("/v1/delete/{roomIDorAlias}", prov.DeletePortal).Methods(http.MethodPost) r.HandleFunc("/v1/set_relay/{roomIDorAlias}", prov.SetRelay).Methods(http.MethodPost) r.HandleFunc("/v1/unset_relay/{roomIDorAlias}", prov.UnsetRelay).Methods(http.MethodPost) r.HandleFunc("/v1/invite_link/{roomIDorAlias}", prov.GetInvite).Methods(http.MethodGet) @@ -671,6 +673,60 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) } } +func (prov *ProvisioningAPI) UnbridgePortal(w http.ResponseWriter, r *http.Request) { + prov.CleanupPortal(w, r, false) +} + +func (prov *ProvisioningAPI) DeletePortal(w http.ResponseWriter, r *http.Request) { + prov.CleanupPortal(w, r, true) +} + +func (prov *ProvisioningAPI) CleanupPortal(w http.ResponseWriter, r *http.Request, deletePortal bool) { + roomArg := mux.Vars(r)["roomIDorAlias"] + user := r.Context().Value("user").(*User) + if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if portal := prov.bridge.GetPortalByMXID(roomID); portal == nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is not bridged to WhatsApp", + ErrCode: "room not bridged", + }) + } else if deletePortal && !user.Admin && canDeletePortal(portal, user.MXID) { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "Only bridge admins can delete portals with other Matrix users", + ErrCode: "not admin", + }) + } else if !checkUnbridgePermission(portal, user) { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "User does not have the permissions to unbridge that room", + ErrCode: "not enough permissions", + }) + } else { + var requestAction, responseAction string + if deletePortal { + requestAction = "deletion" + responseAction = "Portal deleted" + } else { + requestAction = "unbridging" + responseAction = "Room unbridged" + } + portal.log.Infoln("%s requested %s of portal.", user.MXID, requestAction) + portal.Delete() + portal.Cleanup(responseAction, !deletePortal) + jsonResponse(w, http.StatusOK, Response{true, responseAction}) + } +} + func (prov *ProvisioningAPI) SetRelay(w http.ResponseWriter, r *http.Request) { prov.EditRelay(w, r, true) } From 5f91ef25febdaf1c3b44e088f2c11c28ecc2ddcf Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 10:42:57 -0400 Subject: [PATCH 23/31] Add missing separator in custom state event type --- commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands.go b/commands.go index 35bc9434..152ec4ea 100644 --- a/commands.go +++ b/commands.go @@ -265,7 +265,7 @@ func userHasPowerLevel(roomID id.RoomID, intent *appservice.IntentAPI, sender *U if err != nil || levels == nil { return false } - eventType := event.Type{Type: "fi.mau.whatsapp" + stateEventName, Class: event.StateEventType} + eventType := event.Type{Type: "fi.mau.whatsapp." + stateEventName, Class: event.StateEventType} return levels.GetUserLevel(sender.MXID) >= levels.GetEventLevel(eventType) } From 7c375b319642fb55234a3783bb9b58894a5ca821 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 10:50:38 -0400 Subject: [PATCH 24/31] Format room ID in portal check error reply --- commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands.go b/commands.go index 152ec4ea..36d7d16f 100644 --- a/commands.go +++ b/commands.go @@ -250,7 +250,7 @@ func getPortalForCmd(ce *WrappedCommandEvent, argIndex int) (portal *Portal) { } else if len(ce.Args) <= argIndex { roomString = "that room" } else { - roomString = ce.Args[argIndex] + roomString = fmt.Sprintf("[%[1]s](https://matrix.to/#/%[1]s)", ce.Args[argIndex]) } ce.Reply("That command can only be ran for portal rooms, and %s is not a portal room.", roomString) } From b45f1a57905bbc9a06d3bb87734a5014bcfef966 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 11:33:43 -0400 Subject: [PATCH 25/31] Restrict deletion with `bridge.allow_user_invite` If unbridged invites are allowed, require admin permissions to delete a portal with unbridged members. --- commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands.go b/commands.go index 36d7d16f..911989d7 100644 --- a/commands.go +++ b/commands.go @@ -952,7 +952,7 @@ func canDeletePortal(portal *Portal, userID id.UserID) bool { continue } user := portal.bridge.GetUserByMXID(otherUser) - if user != nil && user.Session != nil { + if user != nil && (user.Session != nil || portal.bridge.Config.Bridge.AllowUserInvite) { return false } } From be721a061715550cc6076e7f000438250254f8eb Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 14:02:55 -0400 Subject: [PATCH 26/31] Ask for confirmation to delete/unbridge portals --- commands.go | 124 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/commands.go b/commands.go index 911989d7..cd0985bd 100644 --- a/commands.go +++ b/commands.go @@ -86,15 +86,19 @@ func (br *WABridge) RegisterCommands() { ) } +func wrapCommandEvent(ce *commands.Event) *WrappedCommandEvent { + user := ce.User.(*User) + var portal *Portal + if ce.Portal != nil { + portal = ce.Portal.(*Portal) + } + br := ce.Bridge.Child.(*WABridge) + return &WrappedCommandEvent{ce, br, user, portal} +} + func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { return func(ce *commands.Event) { - user := ce.User.(*User) - var portal *Portal - if ce.Portal != nil { - portal = ce.Portal.(*Portal) - } - br := ce.Bridge.Child.(*WABridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) + handler(wrapCommandEvent(ce)) } } @@ -104,7 +108,14 @@ type StateHandler struct { } func (sh *StateHandler) Run(ce *commands.Event) { - wrapCommand(sh.Func)(ce) + wce := wrapCommandEvent(ce) + defer func() { + if err := recover(); err != nil { + wce.User.CommandState = nil + wce.Reply("Fatal error: %v. This shouldn't happen unless you're messing with the command handler code.", err) + } + }() + sh.Func(wce) } func (sh *StateHandler) GetName() string { @@ -931,9 +942,20 @@ func fnUnbridge(ce *WrappedCommandEvent) { return } - portal.log.Infoln(ce.User.MXID, "requested unbridging of portal.") - portal.Delete() - portal.Cleanup("Room unbridged", true) + const command = "unbridge" + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmCleanup, "Room unbridging"}, + "deletePortal": false, + "roomID": portal.MXID, + "command": command, + "completedMessage": "Room successfully unbridged.", + } + ce.Reply( + "Please confirm unbridging from th%s room "+ + "by typing `$cmdprefix confirm-%s`.", + getThatThisSuffix(portal.MXID, ce.RoomID), + command, + ) } func canDeletePortal(portal *Portal, userID id.UserID) bool { @@ -982,9 +1004,24 @@ func fnDeletePortal(ce *WrappedCommandEvent) { return } - portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") - portal.Delete() - portal.Cleanup("Portal deleted", false) + const command = "delete" + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmCleanup, "Portal deletion"}, + "deletePortal": true, + "roomID": portal.MXID, + "command": command, + "completedMessage": "Portal successfully deleted.", + } + ce.Reply( + "Please confirm deletion of th%s portal "+ + "by typing `$cmdprefix confirm-%s`."+ + "\n\n"+ + "**WARNING:** If the bridge bot has the power level to do so, **this "+ + "will kick ALL users** in the room. If you just want to remove the "+ + "bridge, use `$cmdprefix unbridge` instead.", + getThatThisSuffix(portal.MXID, ce.RoomID), + command, + ) } var cmdDeleteAllPortals = &commands.FullHandler{ @@ -996,7 +1033,59 @@ var cmdDeleteAllPortals = &commands.FullHandler{ }, } +func confirmCleanup(ce *WrappedCommandEvent) { + status := ce.User.GetCommandState() + command := status["command"].(string) + if ce.Args[0] != "confirm-"+command { + fnCancel(ce) + return + } + + deletePortal := status["deletePortal"].(bool) + roomID := status["roomID"].(id.RoomID) + portal := ce.Bridge.GetPortalByMXID(roomID) + if portal == nil { + panic("could not retrieve portal that was expected to exist") + } + ce.User.CommandState = nil + + var requestAction, responseAction string + if deletePortal { + requestAction = "deletion" + responseAction = "Portal deleted" + } else { + requestAction = "unbridging" + responseAction = "Room unbridged" + } + portal.log.Infoln("%s requested %s of portal.", ce.User.MXID, requestAction) + portal.Delete() + portal.Cleanup(responseAction, !deletePortal) + if ce.RoomID != roomID { + ce.Reply(status["completedMessage"].(string)) + } +} + func fnDeleteAllPortals(ce *WrappedCommandEvent) { + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmDeleteAll, "Deletion of all portals"}, + } + ce.Reply( + "Please confirm deletion of all WhatsApp portal rooms that you have permissions to delete " + + "by typing `$cmdprefix confirm-delete-all`." + + "\n\n" + + "**WARNING:** If the bridge bot has the power level to do so, **this " + + "will kick ALL users** in **EVERY** portal room. If you just want to remove the " + + "bridge from certain rooms, use `$cmdprefix unbridge` instead.", + ) +} + +func confirmDeleteAll(ce *WrappedCommandEvent) { + if ce.Args[0] != "confirm-delete-all" { + fnCancel(ce) + return + } + ce.User.CommandState = nil + portals := ce.Bridge.GetAllPortals() var portalsToDelete []*Portal @@ -1399,13 +1488,6 @@ func cleanupOldPortalWhileBridging(ce *WrappedCommandEvent, portal *Portal) (boo } func confirmBridge(ce *WrappedCommandEvent) { - defer func() { - if err := recover(); err != nil { - ce.User.CommandState = nil - ce.Reply("Fatal error: %v. This shouldn't happen unless you're messing with the command handler code.", err) - } - }() - status := ce.User.GetCommandState() roomID := status["bridgeToMXID"].(id.RoomID) portal := ce.User.GetPortalByJID(status["jid"].(types.JID)) From 4c09e72aa7b7b1c8509ef8add1a07b98ecae0c16 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 15:38:55 -0400 Subject: [PATCH 27/31] Remove no-op error check GetPortalByJID never returns nil --- commands.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/commands.go b/commands.go index cd0985bd..234f55e9 100644 --- a/commands.go +++ b/commands.go @@ -1491,9 +1491,6 @@ func confirmBridge(ce *WrappedCommandEvent) { status := ce.User.GetCommandState() roomID := status["bridgeToMXID"].(id.RoomID) portal := ce.User.GetPortalByJID(status["jid"].(types.JID)) - if portal == nil { - panic("could not retrieve portal that was expected to exist") - } _, mxidInStatus := status["mxid"] if mxidInStatus { From 8c42734cafb7ac984a3a0d15e84ff7ff4b25c129 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 21 Jun 2022 15:40:27 -0400 Subject: [PATCH 28/31] Use object, not pointer, for log parameter --- commands.go | 8 ++++---- provisioning.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/commands.go b/commands.go index 234f55e9..06f83f52 100644 --- a/commands.go +++ b/commands.go @@ -555,7 +555,7 @@ func fnCreate(ce *WrappedCommandEvent) { return } - portal, _, errMsg := createGroup(ce.User, bridgeRoomID, &ce.Log, ce.Reply) + portal, _, errMsg := createGroup(ce.User, bridgeRoomID, ce.Log, ce.Reply) if errMsg != "" { ce.Reply(errMsg) } else { @@ -568,7 +568,7 @@ func fnCreate(ce *WrappedCommandEvent) { // If replier is set, it will be used to post user-visible messages about the progres of the group creation. // // On failure, returns an error message string (instead of an error object from a function call). -func createGroup(user *User, roomID id.RoomID, log *log.Logger, replier func(string, ...interface{})) ( +func createGroup(user *User, roomID id.RoomID, log log.Logger, replier func(string, ...interface{})) ( newPortal *Portal, info *types.GroupInfo, errMsg string, @@ -584,7 +584,7 @@ func createGroup(user *User, roomID id.RoomID, log *log.Logger, replier func(str var roomNameEvent event.RoomNameEventContent err = bridge.Bot.StateEvent(roomID, event.StateRoomName, "", &roomNameEvent) if err != nil && !errors.Is(err, mautrix.MNotFound) { - (*log).Errorln("Failed to get room name to create group:", err) + log.Errorln("Failed to get room name to create group:", err) errMsg = "Failed to get room name" return } else if len(roomNameEvent.Name) == 0 { @@ -617,7 +617,7 @@ func createGroup(user *User, roomID id.RoomID, log *log.Logger, replier func(str } } - (*log).Infofln("Creating group for %s with name %s and participants %+v", roomID, roomNameEvent.Name, participants) + log.Infofln("Creating group for %s with name %s and participants %+v", roomID, roomNameEvent.Name, participants) user.groupCreateLock.Lock() defer user.groupCreateLock.Unlock() resp, err := user.Client.CreateGroup(roomNameEvent.Name, participants) diff --git a/provisioning.go b/provisioning.go index b9d4623a..026e2e63 100644 --- a/provisioning.go +++ b/provisioning.go @@ -659,7 +659,7 @@ func (prov *ProvisioningAPI) CreateGroup(w http.ResponseWriter, r *http.Request) }) } else if pInfo := GetMissingRequiredPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log); pInfo != nil { missingRequiredPermsResponse(w, pInfo) - } else if portal, info, errMsg := createGroup(user, roomID, &prov.log, nil); errMsg != "" { + } else if portal, info, errMsg := createGroup(user, roomID, prov.log, nil); errMsg != "" { jsonResponse(w, http.StatusForbidden, Error{ Error: errMsg, ErrCode: "error creating group", From 4b286a26f4644c4b716b16f92e87872d1e083ffd Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 22 Jun 2022 08:16:18 -0400 Subject: [PATCH 29/31] Plumbing and provisioning for `join` --- commands.go | 153 ++++++++++++++++++++++++++++++++++++++---------- provisioning.go | 83 ++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 31 deletions(-) diff --git a/commands.go b/commands.go index 06f83f52..f2341de3 100644 --- a/commands.go +++ b/commands.go @@ -448,27 +448,116 @@ var cmdJoin = &commands.FullHandler{ Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Join a group chat with an invite link.", - Args: "<_invite link_>", + Args: "<_invite link_> " + roomArgHelpMd, }, RequiresLogin: true, } func fnJoin(ce *WrappedCommandEvent) { if len(ce.Args) == 0 { - ce.Reply("**Usage:** `join `") + ce.Reply("**Usage:** `join " + roomArgHelpPl + "`") return - } else if !strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { + } + + inviteLink := ce.Args[0] + if !strings.HasPrefix(inviteLink, whatsmeow.InviteLinkPrefix) { ce.Reply("That doesn't look like a WhatsApp invite link") return } - jid, err := ce.User.Client.JoinGroupWithLink(ce.Args[0]) + bridgeRoomID, ok := getBridgeableRoomID(ce, 1, false) + if !ok { + return + } + + if bridgeRoomID == "" { + joinOrBridgeGroup(ce, inviteLink, bridgeRoomID) + } else { + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmJoin, "Joining"}, + "bridgeToMXID": bridgeRoomID, + "inviteLink": inviteLink, + } + ce.Reply("To confirm joining that WhatsApp link in th%s room, "+ + "use `$cmdprefix continue`. To cancel, use `$cmdprefix cancel`.", + getThatThisSuffix(bridgeRoomID, ce.RoomID), + ) + } +} + +func confirmJoin(ce *WrappedCommandEvent) { + status := ce.User.GetCommandState() + bridgeRoomID := status["bridgeToMXID"].(id.RoomID) + inviteLink := status["inviteLink"].(string) + + if ce.Args[0] != "continue" { + ce.Reply("Please use `$cmdprefix continue` to confirm the joining or " + + "`$cmdprefix cancel` to cancel.") + return + } + ce.User.CommandState = nil + joinOrBridgeGroup(ce, inviteLink, bridgeRoomID) +} + +func joinOrBridgeGroup(ce *WrappedCommandEvent, inviteLink string, bridgeRoomID id.RoomID) { + jid, foundPortal, errMsg := joinGroup(ce.User, inviteLink, bridgeRoomID, ce.Log) + if errMsg != "" { + ce.Reply(errMsg) + return + } + + useNewPortal := bridgeRoomID == "" + if !useNewPortal && foundPortal != nil { + offerToReplacePortal(ce, foundPortal, bridgeRoomID, jid) + return + } + + var replySuffix string + if foundPortal == nil { + if useNewPortal { + replySuffix = " The portal should be created momentarily." + } else { + replySuffix = " Portal synchronization should begin momentarily." + } + } + ce.Reply("Successfully joined group `%s`.%s", jid, replySuffix) +} + +// Joins a WhatsApp group via an invite link, creating a portal for the group if needed. +// +// If bridgeRoomID is set, the group will be bridged to that room, unless the group already has a portal. +// +// On failure, returns an error message string (instead of an error object from a function call). +func joinGroup(user *User, inviteLink string, bridgeRoomID id.RoomID, log log.Logger) ( + jid types.JID, + foundPortal *Portal, + errMsg string, +) { + log.Infofln("Joining group via invite link %s", inviteLink) + useNewPortal := bridgeRoomID == "" + if !useNewPortal { + user.groupCreateLock.Lock() + defer user.groupCreateLock.Unlock() + } + + jid, err := user.Client.JoinGroupWithLink(inviteLink) if err != nil { - ce.Reply("Failed to join group: %v", err) + errMsg = fmt.Sprintf("Failed to join group: %v", err) return } - ce.Log.Debugln("%s successfully joined group %s", ce.User.MXID, jid) - ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) + log.Debugln("%s successfully joined group %s", user.MXID, jid) + portal := user.GetPortalByJID(jid) + if len(portal.MXID) > 0 { + foundPortal = portal + } + if !useNewPortal && foundPortal == nil { + if user.bridge.GetPortalByMXID(bridgeRoomID) != nil { + errMsg = "The room seems to have been bridged already." + return + } + portal.MXID = bridgeRoomID + } + return } func tryDecryptEvent(crypto bridge.Crypto, evt *event.Event) (json.RawMessage, error) { @@ -1413,29 +1502,7 @@ func fnOpen(ce *WrappedCommandEvent) { portal.UpdateMatrixRoom(ce.User, info) ce.Reply("Portal room synced.") } else { - // TODO Move to a function - hasPortalMessage := "That WhatsApp group already has a portal at [%[1]s](https://matrix.to/#/%[1]s). " - if !userHasPowerLevel(portal.MXID, ce.MainIntent(), ce.User, "unbridge") { - ce.Reply(hasPortalMessage+ - "Additionally, you do not have the permissions to unbridge that room.", - portal.MXID, - ) - } else { - ce.User.CommandState = map[string]interface{}{ - "next": &StateHandler{confirmBridge, "Room bridging"}, - "mxid": portal.MXID, - "bridgeToMXID": bridgeRoomID, - "jid": info.JID, - } - ce.Reply(hasPortalMessage+ - "However, you have the permissions to unbridge that room.\n\n"+ - "To delete that portal completely and continue bridging, use "+ - "`$cmdprefix delete-and-continue`. To unbridge the portal "+ - "without kicking Matrix users, use `$cmdprefix unbridge-and-"+ - "continue`. To cancel, use `$cmdprefix cancel`.", - portal.MXID, - ) - } + offerToReplacePortal(ce, portal, bridgeRoomID, info.JID) } } else { if bridgeRoomID == "" { @@ -1446,7 +1513,6 @@ func fnOpen(ce *WrappedCommandEvent) { ce.Reply("Portal room created.") } } else { - // TODO Move to a function ce.User.CommandState = map[string]interface{}{ "next": &StateHandler{confirmBridge, "Room bridging"}, "bridgeToMXID": bridgeRoomID, @@ -1459,6 +1525,31 @@ func fnOpen(ce *WrappedCommandEvent) { } } +func offerToReplacePortal(ce *WrappedCommandEvent, portal *Portal, bridgeRoomID id.RoomID, jid types.JID) { + hasPortalMessage := "That WhatsApp group already has a portal at [%[1]s](https://matrix.to/#/%[1]s). " + if !userHasPowerLevel(portal.MXID, ce.MainIntent(), ce.User, "unbridge") { + ce.Reply(hasPortalMessage+ + "Additionally, you do not have the permissions to unbridge that room.", + portal.MXID, + ) + } else { + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmBridge, "Room bridging"}, + "mxid": portal.MXID, + "bridgeToMXID": bridgeRoomID, + "jid": jid, + } + ce.Reply(hasPortalMessage+ + "However, you have the permissions to unbridge that room.\n\n"+ + "To delete that portal completely and continue bridging, use "+ + "`$cmdprefix delete-and-continue`. To unbridge the portal "+ + "without kicking Matrix users, use `$cmdprefix unbridge-and-"+ + "continue`. To cancel, use `$cmdprefix cancel`.", + portal.MXID, + ) + } +} + func cleanupOldPortalWhileBridging(ce *WrappedCommandEvent, portal *Portal) (bool, func()) { if len(portal.MXID) == 0 { ce.Reply("The portal seems to have lost its Matrix room between you" + diff --git a/provisioning.go b/provisioning.go index 026e2e63..7fb64672 100644 --- a/provisioning.go +++ b/provisioning.go @@ -67,6 +67,7 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/v1/resolve_identifier/{number}", prov.ResolveIdentifier).Methods(http.MethodGet) r.HandleFunc("/v1/bulk_resolve_identifier", prov.BulkResolveIdentifier).Methods(http.MethodPost) r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost) + r.HandleFunc("/v1/join/{inviteLinkPath}", prov.JoinGroup).Methods(http.MethodPost) r.HandleFunc("/v1/bridge/{groupID}/{roomIDorAlias}", prov.BridgeGroup).Methods(http.MethodPost) r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost) r.HandleFunc("/v1/create/{roomIDorAlias}", prov.CreateGroup).Methods(http.MethodPost) @@ -355,6 +356,12 @@ type PortalInfo struct { PermsInfo *PermsInfo `json:"permissions_info,omitempty"` } +type JoinInfo struct { + JID types.JID `json:"jid"` + FoundRoomID id.RoomID `json:"found_room_id,omitempty"` + PermsInfo *PermsInfo `json:"permissions_info,omitempty"` +} + type LinkInfo struct { InviteLink string `json:"invite_link"` } @@ -480,6 +487,82 @@ func (prov *ProvisioningAPI) BulkResolveIdentifier(w http.ResponseWriter, r *htt } } +func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if !user.IsLoggedIn() { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "User is not logged into WhatsApp", + ErrCode: "no session", + }) + return + } + inviteLinkPath := mux.Vars(r)["inviteLinkPath"] + roomArg := r.URL.Query().Get("roomIDorAlias") + var bridgeRoomID id.RoomID + if roomArg != "" { + ok := false + if roomID, isAlias, err := prov.bridge.ResolveRoomArg(roomArg); err != nil || roomID == "" { + if isAlias { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Failed to resolve room alias", + ErrCode: "error resolving room alias", + }) + } else { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Invalid room ID", + ErrCode: "invalid room id", + }) + } + } else if err := prov.bridge.Bot.EnsureJoined(roomID); err != nil { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: "Bridge bot is not in target room and cannot join it", + ErrCode: "room unknown", + }) + } else if prov.bridge.GetPortalByMXID(roomID) != nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "Room is already a WhatsApp portal room", + ErrCode: "room already bridged", + }) + } else if !userHasPowerLevel(roomID, prov.bridge.AS.BotIntent(), user, "bridge") { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "User does not have the permissions to bridge that room", + ErrCode: "not enough permissions", + }) + } else if pInfo := GetMissingRequiredPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log); pInfo != nil { + missingRequiredPermsResponse(w, pInfo) + } else { + ok = true + bridgeRoomID = roomID + } + if !ok { + return + } + } + if jid, foundPortal, errMsg := joinGroup(user, whatsmeow.InviteLinkPrefix+inviteLinkPath, "", prov.log); errMsg != "" { + jsonResponse(w, http.StatusBadRequest, Error{ + Error: errMsg, + ErrCode: "error joining group via invite link", + }) + } else if bridgeRoomID != "" && foundPortal != nil { + jsonResponse(w, http.StatusConflict, Error{ + Error: "The WhatsApp group for this invite link is already bridged to another room", + ErrCode: "bridge exists", + }) + } else { + var roomID id.RoomID + var permsInfo *PermsInfo + if foundPortal != nil { + roomID = foundPortal.MXID + permsInfo = getPermsInfo(GetMissingOptionalPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log)) + } + jsonResponse(w, http.StatusOK, JoinInfo{ + JID: jid, + FoundRoomID: roomID, + PermsInfo: permsInfo, + }) + } +} + func (prov *ProvisioningAPI) BridgeGroup(w http.ResponseWriter, r *http.Request) { groupID := mux.Vars(r)["groupID"] roomArg := mux.Vars(r)["roomIDorAlias"] From f67410c4949a558bd5deb212d3a45916ddbc6527 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 22 Jun 2022 08:16:48 -0400 Subject: [PATCH 30/31] Update description of `invite-link` Mention that it can get the link of a specified room --- commands.go | 50 +++++++++++++++++++++++++++++++------------------ provisioning.go | 24 ++++++++++++------------ 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/commands.go b/commands.go index f2341de3..2454d8b9 100644 --- a/commands.go +++ b/commands.go @@ -34,6 +34,7 @@ import ( "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" log "maunium.net/go/maulogger/v2" @@ -364,7 +365,7 @@ var cmdInviteLink = &commands.FullHandler{ Name: "invite-link", Help: commands.HelpMeta{ Section: HelpSectionInvites, - Description: "Get an invite link to the current group chat, optionally regenerating the link and revoking the old link.", + Description: "Get an invite link for the group chat of the current portal or a specified one, optionally regenerating the link and revoking the old link.", Args: "[--reset] " + roomArgHelpNohereMd, }, RequiresLogin: true, @@ -506,21 +507,21 @@ func joinOrBridgeGroup(ce *WrappedCommandEvent, inviteLink string, bridgeRoomID return } - useNewPortal := bridgeRoomID == "" - if !useNewPortal && foundPortal != nil { - offerToReplacePortal(ce, foundPortal, bridgeRoomID, jid) - return - } - var replySuffix string + roomConflict := false if foundPortal == nil { - if useNewPortal { + if bridgeRoomID == "" { replySuffix = " The portal should be created momentarily." } else { replySuffix = " Portal synchronization should begin momentarily." } + } else if bridgeRoomID != "" && bridgeRoomID != foundPortal.MXID { + roomConflict = true } ce.Reply("Successfully joined group `%s`.%s", jid, replySuffix) + if roomConflict { + offerToReplacePortal(ce, foundPortal, bridgeRoomID, jid) + } } // Joins a WhatsApp group via an invite link, creating a portal for the group if needed. @@ -534,11 +535,8 @@ func joinGroup(user *User, inviteLink string, bridgeRoomID id.RoomID, log log.Lo errMsg string, ) { log.Infofln("Joining group via invite link %s", inviteLink) - useNewPortal := bridgeRoomID == "" - if !useNewPortal { - user.groupCreateLock.Lock() - defer user.groupCreateLock.Unlock() - } + user.groupCreateLock.Lock() + defer user.groupCreateLock.Unlock() jid, err := user.Client.JoinGroupWithLink(inviteLink) if err != nil { @@ -546,16 +544,32 @@ func joinGroup(user *User, inviteLink string, bridgeRoomID id.RoomID, log log.Lo return } log.Debugln("%s successfully joined group %s", user.MXID, jid) + + if bridgeRoomID != "" && user.bridge.GetPortalByMXID(bridgeRoomID) != nil { + log.Warnln("Detected race condition in bridging room %s via invite link %s", bridgeRoomID, inviteLink) + errMsg = "The room seems to have been bridged already." + return + } + portal := user.GetPortalByJID(jid) if len(portal.MXID) > 0 { foundPortal = portal } - if !useNewPortal && foundPortal == nil { - if user.bridge.GetPortalByMXID(bridgeRoomID) != nil { - errMsg = "The room seems to have been bridged already." - return + info, err := user.Client.GetGroupInfo(jid) + if err != nil { + log.Errorln("Failed to get info of joined group %s: %v", jid, err) + if foundPortal == nil { + // if a "join" event never comes, the portal created by the lookup won't get updated, so just remove it + portal.Delete() } - portal.MXID = bridgeRoomID + } else if bridgeRoomID != "" && foundPortal == nil { + portal.BridgeMatrixRoom(bridgeRoomID, user, info) + } else { + // simulate a "join" event now, as a real event may never come if the user was already in the group + user.HandleEvent(&events.JoinedGroup{ + Reason: "join-cmd", + GroupInfo: *info, + }) } return } diff --git a/provisioning.go b/provisioning.go index 7fb64672..469b2191 100644 --- a/provisioning.go +++ b/provisioning.go @@ -497,7 +497,7 @@ func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) { return } inviteLinkPath := mux.Vars(r)["inviteLinkPath"] - roomArg := r.URL.Query().Get("roomIDorAlias") + roomArg := r.URL.Query().Get("room_id") var bridgeRoomID id.RoomID if roomArg != "" { ok := false @@ -538,26 +538,26 @@ func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) { return } } - if jid, foundPortal, errMsg := joinGroup(user, whatsmeow.InviteLinkPrefix+inviteLinkPath, "", prov.log); errMsg != "" { + if jid, foundPortal, errMsg := joinGroup(user, whatsmeow.InviteLinkPrefix+inviteLinkPath, bridgeRoomID, prov.log); errMsg != "" { jsonResponse(w, http.StatusBadRequest, Error{ Error: errMsg, ErrCode: "error joining group via invite link", }) - } else if bridgeRoomID != "" && foundPortal != nil { - jsonResponse(w, http.StatusConflict, Error{ - Error: "The WhatsApp group for this invite link is already bridged to another room", - ErrCode: "bridge exists", - }) } else { - var roomID id.RoomID + var foundRoomID id.RoomID var permsInfo *PermsInfo + status := http.StatusOK if foundPortal != nil { - roomID = foundPortal.MXID - permsInfo = getPermsInfo(GetMissingOptionalPerms(roomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log)) + if bridgeRoomID != "" && bridgeRoomID != foundPortal.MXID { + status = http.StatusConflict + foundRoomID = foundPortal.MXID + } + } else if bridgeRoomID != "" { + permsInfo = getPermsInfo(GetMissingOptionalPerms(bridgeRoomID, prov.bridge.AS.BotIntent(), prov.bridge, prov.log)) } - jsonResponse(w, http.StatusOK, JoinInfo{ + jsonResponse(w, status, JoinInfo{ JID: jid, - FoundRoomID: roomID, + FoundRoomID: foundRoomID, PermsInfo: permsInfo, }) } From fd0ab85a4a9a51465042bd10fc15fa2865c3e3c8 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 22 Jun 2022 17:23:58 -0400 Subject: [PATCH 31/31] Ask for confirmation to `create` --- commands.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/commands.go b/commands.go index 2454d8b9..967ca929 100644 --- a/commands.go +++ b/commands.go @@ -658,6 +658,27 @@ func fnCreate(ce *WrappedCommandEvent) { return } + ce.User.CommandState = map[string]interface{}{ + "next": &StateHandler{confirmCreate, "Group creation"}, + "bridgeToMXID": bridgeRoomID, + } + ce.Reply("To confirm creating a WhatsApp group for th%s room, "+ + "use `$cmdprefix continue`. To cancel, use `$cmdprefix cancel`.", + getThatThisSuffix(bridgeRoomID, ce.RoomID), + ) +} + +func confirmCreate(ce *WrappedCommandEvent) { + status := ce.User.GetCommandState() + bridgeRoomID := status["bridgeToMXID"].(id.RoomID) + + if ce.Args[0] != "continue" { + ce.Reply("Please use `$cmdprefix continue` to confirm the joining or " + + "`$cmdprefix cancel` to cancel.") + return + } + ce.User.CommandState = nil + portal, _, errMsg := createGroup(ce.User, bridgeRoomID, ce.Log, ce.Reply) if errMsg != "" { ce.Reply(errMsg)