bridgev2: add support for starting DMs

This commit is contained in:
Tulir Asokan 2024-06-19 21:33:02 +03:00
commit eefa219183
9 changed files with 211 additions and 21 deletions

View file

@ -29,6 +29,7 @@ var (
HelpSectionGeneral = HelpSection{"General", 0}
HelpSectionAuth = HelpSection{"Authentication", 10}
HelpSectionChats = HelpSection{"Starting and managing chats", 20}
HelpSectionAdmin = HelpSection{"Administration", 50}
)

View file

@ -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 <login ID>`\n\nYour logins:\n\n%s", ce.User.GetFormattedUserLogins())

View file

@ -40,6 +40,7 @@ func NewProcessor(bridge *Bridge) *CommandProcessor {
CommandHelp, CommandCancel,
CommandRegisterPush,
CommandLogin, CommandLogout, CommandSetPreferredLogin,
CommandResolveIdentifier, CommandStartChat,
)
return proc
}

128
bridgev2/cmdstartchat.go Normal file
View file

@ -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 <code>%s</code> 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)
}
}

View file

@ -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, '@') {

View file

@ -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 {

View file

@ -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)

View file

@ -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() {

View file

@ -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)
}