From eefa21918360606c8a7ab605e5eb822684316adf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 19 Jun 2024 21:33:02 +0300 Subject: [PATCH] bridgev2: add support for starting DMs --- bridgev2/cmdhelp.go | 1 + bridgev2/cmdlogin.go | 10 --- bridgev2/cmdprocessor.go | 1 + bridgev2/cmdstartchat.go | 128 +++++++++++++++++++++++++++++++++++ bridgev2/login.go | 24 ++++--- bridgev2/networkinterface.go | 31 +++++++++ bridgev2/portal.go | 10 +++ bridgev2/portalreid.go | 3 + bridgev2/user.go | 24 +++++++ 9 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 bridgev2/cmdstartchat.go diff --git a/bridgev2/cmdhelp.go b/bridgev2/cmdhelp.go index 043d487c..80b8e972 100644 --- a/bridgev2/cmdhelp.go +++ b/bridgev2/cmdhelp.go @@ -29,6 +29,7 @@ var ( HelpSectionGeneral = HelpSection{"General", 0} HelpSectionAuth = HelpSection{"Authentication", 10} + HelpSectionChats = HelpSection{"Starting and managing chats", 20} HelpSectionAdmin = HelpSection{"Administration", 50} ) diff --git a/bridgev2/cmdlogin.go b/bridgev2/cmdlogin.go index 76727906..dfa66319 100644 --- a/bridgev2/cmdlogin.go +++ b/bridgev2/cmdlogin.go @@ -300,16 +300,6 @@ var CommandLogout = &FullHandler{ }, } -func (user *User) GetFormattedUserLogins() string { - user.Bridge.cacheLock.Lock() - logins := make([]string, len(user.logins)) - for key, val := range user.logins { - logins = append(logins, fmt.Sprintf("* `%s` (%s)", key, val.Metadata.RemoteName)) - } - user.Bridge.cacheLock.Unlock() - return strings.Join(logins, "\n") -} - func fnLogout(ce *CommandEvent) { if len(ce.Args) == 0 { ce.Reply("Usage: `$cmdprefix logout `\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins()) diff --git a/bridgev2/cmdprocessor.go b/bridgev2/cmdprocessor.go index 7b064fda..15604af6 100644 --- a/bridgev2/cmdprocessor.go +++ b/bridgev2/cmdprocessor.go @@ -40,6 +40,7 @@ func NewProcessor(bridge *Bridge) *CommandProcessor { CommandHelp, CommandCancel, CommandRegisterPush, CommandLogin, CommandLogout, CommandSetPreferredLogin, + CommandResolveIdentifier, CommandStartChat, ) return proc } diff --git a/bridgev2/cmdstartchat.go b/bridgev2/cmdstartchat.go new file mode 100644 index 00000000..d8746d25 --- /dev/null +++ b/bridgev2/cmdstartchat.go @@ -0,0 +1,128 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package bridgev2 + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/net/html" + + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +var CommandResolveIdentifier = &FullHandler{ + Func: fnResolveIdentifier, + Name: "resolve-identifier", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Check if a given identifier is on the remote network", + Args: "[_login ID_] <_identifier_>", + }, + RequiresLogin: true, +} + +var CommandStartChat = &FullHandler{ + Func: fnResolveIdentifier, + Name: "start-chat", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Start a direct chat with the given user", + Args: "[_login ID_] <_identifier_>", + }, + RequiresLogin: true, +} + +func getClientForStartingChat[T IdentifierResolvingNetworkAPI](ce *CommandEvent, thing string) (*UserLogin, T, []string) { + remainingArgs := ce.Args[1:] + login := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0])) + if login == nil || login.UserMXID != ce.User.MXID { + remainingArgs = ce.Args + login = ce.User.GetDefaultLogin() + } + api, ok := login.Client.(T) + if !ok { + ce.Reply("This bridge does not support %s", thing) + } + return login, api, remainingArgs +} + +func fnResolveIdentifier(ce *CommandEvent) { + login, api, identifierParts := getClientForStartingChat[IdentifierResolvingNetworkAPI](ce, "resolving identifiers") + if api == nil { + return + } + createChat := ce.Command == "start-chat" + identifier := strings.Join(identifierParts, " ") + resp, err := api.ResolveIdentifier(ce.Ctx, identifier, createChat) + if err != nil { + ce.Reply("Failed to resolve identifier: %v", err) + return + } else if resp == nil { + ce.ReplyAdvanced(fmt.Sprintf("Identifier %s not found", html.EscapeString(identifier)), false, true) + return + } + var targetName string + var targetMXID id.UserID + if resp.Ghost != nil { + if resp.UserInfo != nil { + resp.Ghost.UpdateInfo(ce.Ctx, resp.UserInfo) + } + targetName = resp.Ghost.Name + targetMXID = resp.Ghost.MXID + if !createChat { + ce.Reply("Found `%s` / [%s](%s)", resp.Ghost.ID, resp.Ghost.Name, resp.Ghost.MXID.URI().MatrixToURL()) + } + } else if resp.UserInfo != nil && resp.UserInfo.Name != nil { + targetName = *resp.UserInfo.Name + } + var formattedName string + if targetMXID != "" { + formattedName = fmt.Sprintf("`%s` / [%s](%s)", resp.UserID, targetName, targetMXID.URI().MatrixToURL()) + } else if targetName != "" { + formattedName = fmt.Sprintf("`%s` / %s", resp.UserID, targetName) + } else { + formattedName = fmt.Sprintf("`%s`", resp.UserID) + } + if createChat { + if resp.Chat == nil { + ce.Reply("Interface error: network connector did not return chat for create chat request") + return + } + portal := resp.Chat.Portal + if portal == nil { + portal, err = ce.Bridge.GetPortalByID(ce.Ctx, resp.Chat.PortalID) + if err != nil { + ce.Reply("Failed to get portal: %v", err) + return + } + } + if portal.MXID != "" { + name := portal.Name + if name == "" { + name = portal.MXID.String() + } + portal.UpdateInfo(ce.Ctx, resp.Chat.PortalInfo, login, nil, time.Time{}) + ce.Reply("You already have a direct chat with %s at [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL()) + } else { + err = portal.CreateMatrixRoom(ce.Ctx, login, resp.Chat.PortalInfo) + if err != nil { + ce.Reply("Failed to create room: %v", err) + return + } + name := portal.Name + if name == "" { + name = portal.MXID.String() + } + ce.Reply("Created chat with %s: [%s](%s)", formattedName, name, portal.MXID.URI().MatrixToURL()) + } + } else { + ce.Reply("Found %s", formattedName) + } +} diff --git a/bridgev2/login.go b/bridgev2/login.go index 636619ce..a1dc9e91 100644 --- a/bridgev2/login.go +++ b/bridgev2/login.go @@ -146,6 +146,18 @@ func isOnlyNumbers(input string) bool { return true } +func CleanPhoneNumber(phone string) (string, error) { + phone = numberCleaner.Replace(phone) + if len(phone) < 2 { + return "", fmt.Errorf("phone number must start with + and contain numbers") + } else if phone[0] != '+' { + return "", fmt.Errorf("phone number must start with +") + } else if !isOnlyNumbers(phone[1:]) { + return "", fmt.Errorf("phone number must only contain numbers") + } + return phone, nil +} + func (f *LoginInputDataField) FillDefaultValidate() { noopValidate := func(input string) (string, error) { return input, nil } if f.Validate != nil { @@ -153,17 +165,7 @@ func (f *LoginInputDataField) FillDefaultValidate() { } switch f.Type { case LoginInputFieldTypePhoneNumber: - f.Validate = func(phone string) (string, error) { - phone = numberCleaner.Replace(phone) - if len(phone) < 2 { - return "", fmt.Errorf("phone number must start with + and contain numbers") - } else if phone[0] != '+' { - return "", fmt.Errorf("phone number must start with +") - } else if !isOnlyNumbers(phone[1:]) { - return "", fmt.Errorf("phone number must only contain numbers") - } - return phone, nil - } + f.Validate = CleanPhoneNumber case LoginInputFieldTypeEmail: f.Validate = func(email string) (string, error) { if !strings.ContainsRune(email, '@') { diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 267be890..06c8196e 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -180,6 +180,37 @@ type NetworkAPI interface { HandleMatrixTyping(ctx context.Context, msg *MatrixTyping) error } +type ResolveIdentifierResponse struct { + Ghost *Ghost + + UserID networkid.UserID + UserInfo *UserInfo + + Chat *CreateChatResponse +} + +type CreateChatResponse struct { + Portal *Portal + + PortalID networkid.PortalKey + PortalInfo *PortalInfo +} + +type IdentifierResolvingNetworkAPI interface { + NetworkAPI + ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*ResolveIdentifierResponse, error) +} + +type UserSearchingNetworkAPI interface { + IdentifierResolvingNetworkAPI + SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) +} + +type GroupCreatingNetworkAPI interface { + IdentifierResolvingNetworkAPI + CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error) +} + type PushType int func (pt PushType) String() string { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index ffcd13c5..7c2c27dd 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1357,6 +1357,9 @@ func (portal *Portal) SyncParticipants(ctx context.Context, members []networkid. if err != nil { return nil, nil, fmt.Errorf("failed to get user logins in portal: %w", err) } + if !slices.Contains(loginsInPortal, source) { + loginsInPortal = append(loginsInPortal, source) + } expectedUserIDs := make([]id.UserID, 0, len(members)) expectedExtraUsers := make([]id.UserID, 0) expectedIntents := make([]MatrixAPI, len(members)) @@ -1459,6 +1462,13 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *PortalInfo, source * } // TODO detect changes to functional members list? } + if source != nil { + // TODO is this a good place for this call? there's another one in QueueRemoteEvent + err := portal.Bridge.DB.UserPortal.EnsureExists(ctx, source.UserLogin, portal.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to ensure user portal row exists") + } + } if changed { portal.UpdateBridgeInfo(ctx) err := portal.Save(ctx) diff --git a/bridgev2/portalreid.go b/bridgev2/portalreid.go index fea818dd..0622aef6 100644 --- a/bridgev2/portalreid.go +++ b/bridgev2/portalreid.go @@ -29,6 +29,9 @@ const ( ) func (br *Bridge) ReIDPortal(ctx context.Context, source, target networkid.PortalKey) (ReIDResult, *Portal, error) { + if source == target { + return ReIDResultError, nil, fmt.Errorf("illegal re-ID call: source and target are the same") + } log := zerolog.Ctx(ctx) log.Debug().Msg("Re-ID'ing portal") defer func() { diff --git a/bridgev2/user.go b/bridgev2/user.go index bf8eaf13..86268ec1 100644 --- a/bridgev2/user.go +++ b/bridgev2/user.go @@ -9,10 +9,13 @@ package bridgev2 import ( "context" "fmt" + "strings" "sync" "sync/atomic" "github.com/rs/zerolog" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -149,6 +152,27 @@ func (user *User) DoublePuppet(ctx context.Context) MatrixAPI { return intent } +func (user *User) GetFormattedUserLogins() string { + user.Bridge.cacheLock.Lock() + logins := make([]string, len(user.logins)) + for key, val := range user.logins { + logins = append(logins, fmt.Sprintf("* `%s` (%s)", key, val.Metadata.RemoteName)) + } + user.Bridge.cacheLock.Unlock() + return strings.Join(logins, "\n") +} + +func (user *User) GetDefaultLogin() *UserLogin { + user.Bridge.cacheLock.Lock() + defer user.Bridge.cacheLock.Unlock() + if len(user.logins) == 0 { + return nil + } + loginKeys := maps.Keys(user.logins) + slices.Sort(loginKeys) + return user.logins[loginKeys[0]] +} + func (user *User) Save(ctx context.Context) error { return user.Bridge.DB.User.Update(ctx, user.User) }