From 15b0dc51b33e8c04626f430d43ba348b9ad11524 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 21 Aug 2024 10:15:47 -0600 Subject: [PATCH] snc: implement resolving Telegram IDs, usernames, and phone numbers Signed-off-by: Sumner Evans --- pkg/connector/chatinfo.go | 48 ++++------- pkg/connector/client.go | 52 +++++++----- pkg/connector/startnewchat.go | 95 ++++++++++++++++++++++ pkg/connector/store/scoped_store.go | 26 ++++++ pkg/connector/store/upgrades/00-latest.sql | 9 ++ 5 files changed, 177 insertions(+), 53 deletions(-) create mode 100644 pkg/connector/startnewchat.go diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index dac74eb9..2cc75f5c 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -15,41 +15,23 @@ import ( "go.mau.fi/mautrix-telegram/pkg/connector/media" ) -func (t *TelegramClient) getDMChatInfo(ctx context.Context, userID int64) (*bridgev2.ChatInfo, error) { - // FIXME there's no reason for this function to fetch the user info +func (t *TelegramClient) getDMChatInfo(userID int64) (*bridgev2.ChatInfo, error) { + networkUserID := ids.MakeUserID(userID) chatInfo := bridgev2.ChatInfo{ - Type: ptr.Ptr(database.RoomTypeDM), - Members: &bridgev2.ChatMemberList{IsFull: true}, - CanBackfill: true, - } - accessHash, err := t.ScopedStore.GetAccessHash(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to get access hash for user %d: %w", userID, err) - } - users, err := t.client.API().UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{ - UserID: userID, - AccessHash: accessHash, - }}) - if err != nil { - return nil, err - } else if len(users) == 0 { - return nil, fmt.Errorf("failed to get user info for user %d", userID) - } else if userInfo, err := t.getUserInfoFromTelegramUser(ctx, users[0]); err != nil { - return nil, err - } else if err = t.updateGhostWithUserInfo(ctx, userID, userInfo); err != nil { - return nil, err - } else { - networkUserID := ids.MakeUserID(userID) - chatInfo.Members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{ - networkUserID: { - EventSender: bridgev2.EventSender{ - SenderLogin: ids.MakeUserLoginID(userID), - Sender: ids.MakeUserID(userID), + Type: ptr.Ptr(database.RoomTypeDM), + Members: &bridgev2.ChatMemberList{ + IsFull: true, + MemberMap: map[networkid.UserID]bridgev2.ChatMember{ + networkUserID: { + EventSender: bridgev2.EventSender{ + SenderLogin: ids.MakeUserLoginID(userID), + Sender: networkUserID, + }, }, - UserInfo: userInfo, + t.userID: {EventSender: t.mySender()}, }, - t.userID: {EventSender: t.mySender()}, - } + }, + CanBackfill: true, } if userID == t.telegramUserID { // TODO also hardcode the avatar used by telegram? @@ -150,7 +132,7 @@ func (t *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Porta switch peerType { case ids.PeerTypeUser: - return t.getDMChatInfo(ctx, id) + return t.getDMChatInfo(id) case ids.PeerTypeChat: fullChat, err := APICallWithUpdates(ctx, t, func() (*tg.MessagesChatFull, error) { return t.client.API().MessagesGetFullChat(ctx, id) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index a19fbc4d..6d51464e 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -55,11 +55,11 @@ var ( _ bridgev2.ReactionHandlingNetworkAPI = (*TelegramClient)(nil) _ bridgev2.RedactionHandlingNetworkAPI = (*TelegramClient)(nil) _ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil) - _ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil) _ bridgev2.TypingHandlingNetworkAPI = (*TelegramClient)(nil) _ bridgev2.BackfillingNetworkAPI = (*TelegramClient)(nil) _ bridgev2.BackfillingNetworkAPIWithLimits = (*TelegramClient)(nil) - // _ bridgev2.IdentifierResolvingNetworkAPI = (*TelegramClient)(nil) + _ bridgev2.IdentifierResolvingNetworkAPI = (*TelegramClient)(nil) + _ bridgev2.UserSearchingNetworkAPI = (*TelegramClient)(nil) // _ bridgev2.GroupCreatingNetworkAPI = (*TelegramClient)(nil) // _ bridgev2.ContactListingNetworkAPI = (*TelegramClient)(nil) ) @@ -390,30 +390,38 @@ func (t *TelegramClient) Disconnect() { t.clientCancel() } +func (t *TelegramClient) getInputUser(ctx context.Context, id int64) (*tg.InputUser, error) { + accessHash, err := t.ScopedStore.GetAccessHash(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get access hash for user %d: %w", id, err) + } + return &tg.InputUser{UserID: id, AccessHash: accessHash}, nil +} + +func (t *TelegramClient) getSingleUser(ctx context.Context, id int64) (tg.UserClass, error) { + if inputUser, err := t.getInputUser(ctx, id); err != nil { + return nil, err + } else if users, err := t.client.API().UsersGetUsers(ctx, []tg.InputUserClass{inputUser}); err != nil { + return nil, err + } else if len(users) == 0 { + return nil, fmt.Errorf("failed to get user info for user %d", id) + } else { + return users[0], nil + } +} + func (t *TelegramClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { id, err := ids.ParseUserID(ghost.ID) if err != nil { return nil, err } - accessHash, err := t.ScopedStore.GetAccessHash(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get access hash for user %d: %w", id, err) - } - users, err := t.client.API().UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{ - UserID: id, - AccessHash: accessHash, - }}) - if err != nil { + if user, err := t.getSingleUser(ctx, id); err != nil { + return nil, fmt.Errorf("failed to get user %d: %w", id, err) + } else if userInfo, err := t.getUserInfoFromTelegramUser(ctx, user); err != nil { return nil, err + } else { + return userInfo, t.updateGhostWithUserInfo(ctx, id, userInfo) } - if len(users) == 0 { - return nil, fmt.Errorf("failed to get user info for user %d", id) - } - userInfo, err := t.getUserInfoFromTelegramUser(ctx, users[0]) - if err != nil { - return nil, err - } - return userInfo, t.updateGhostWithUserInfo(ctx, id, userInfo) } func (t *TelegramClient) getUserInfoFromTelegramUser(ctx context.Context, u tg.UserClass) (*bridgev2.UserInfo, error) { @@ -437,7 +445,11 @@ func (t *TelegramClient) getUserInfoFromTelegramUser(ctx context.Context, u tg.U identifiers = append(identifiers, fmt.Sprintf("telegram:%s", username.Username)) } if phone, ok := user.GetPhone(); ok { - identifiers = append(identifiers, fmt.Sprintf("tel:+%s", strings.TrimPrefix(phone, "+"))) + normalized := strings.TrimPrefix(phone, "+") + identifiers = append(identifiers, fmt.Sprintf("tel:+%s", normalized)) + if err := t.ScopedStore.SetPhoneNumber(ctx, user.ID, normalized); err != nil { + return nil, err + } } } slices.Sort(identifiers) diff --git a/pkg/connector/startnewchat.go b/pkg/connector/startnewchat.go new file mode 100644 index 00000000..31968593 --- /dev/null +++ b/pkg/connector/startnewchat.go @@ -0,0 +1,95 @@ +package connector + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/gotd/td/tg" + "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-telegram/pkg/connector/ids" +) + +func (t *TelegramClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { + // t.client.API().ContactsSearch() + panic("unimplemented") +} + +// Parses usernames with or without the @ sign in front of the username. +// This verifies the following restrictions: +// - Usernames must be at least 5 characters long +// - Usernames must be at most 32 characters long +// - Usernames must start with a letter +// - Usernames must contain only letters, numbers, and underscores +// - Usernames cannot end with an underscore +var usernameRe = regexp.MustCompile(`^@?([a-zA-Z](?:\w{3,30})[a-zA-Z\d])$`) + +func (t *TelegramClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { + if len(identifier) == 0 { + return nil, fmt.Errorf("empty identifier") + } + + if identifier[0] == '+' { + normalized := strings.TrimPrefix(identifier, "+") + if userID, err := t.ScopedStore.GetUserIDByPhoneNumber(ctx, normalized); err != nil { + return nil, fmt.Errorf("failed to get user ID by phone number: %w", err) + } else if userID == 0 { + return nil, fmt.Errorf("no user found with phone number '%s'", normalized) + } else if user, err := t.getSingleUser(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get user with ID %d: %w", userID, err) + } else { + return t.getResolveIdentifierResponseForUserID(ctx, user) + } + } else if userID, err := strconv.ParseInt(identifier, 10, 64); err == nil { + // This is an integer, try and parse it as a Telegram User ID + if user, err := t.getSingleUser(ctx, userID); err != nil { + return nil, fmt.Errorf("failed to get user with ID %d: %w", userID, err) + } else { + return t.getResolveIdentifierResponseForUserID(ctx, user) + } + } else if match := usernameRe.FindStringSubmatch(identifier); match != nil && !strings.Contains(identifier, "__") { + resolved, err := APICallWithUpdates(ctx, t, func() (*tg.ContactsResolvedPeer, error) { + return t.client.API().ContactsResolveUsername(ctx, match[1]) + }) + if err != nil { + if tg.IsUsernameNotOccupied(err) { + return nil, fmt.Errorf("no user found with username '%s'", match[1]) + } else { + return nil, fmt.Errorf("failed to resolve username: %w", err) + } + } + peer, ok := resolved.GetPeer().(*tg.PeerUser) + if !ok { + return nil, fmt.Errorf("unexpected peer type: %T", resolved.GetPeer()) + } + for _, user := range resolved.GetUsers() { + if user.GetID() == peer.GetUserID() { + return t.getResolveIdentifierResponseForUserID(ctx, user) + } + } + return nil, fmt.Errorf("peer user not found in contact resolved response") + } else { + return nil, fmt.Errorf("invalid identifier: %s (must be a phone number, username, or Telegram user ID)", identifier) + } +} + +func (t *TelegramClient) getResolveIdentifierResponseForUserID(ctx context.Context, user tg.UserClass) (*bridgev2.ResolveIdentifierResponse, error) { + networkUserID := ids.MakeUserID(user.GetID()) + if userInfo, err := t.getUserInfoFromTelegramUser(ctx, user); err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } else if ghost, err := t.main.Bridge.GetGhostByID(ctx, networkUserID); err != nil { + return nil, fmt.Errorf("failed to get ghost: %w", err) + } else { + return &bridgev2.ResolveIdentifierResponse{ + Ghost: ghost, + UserID: networkUserID, + UserInfo: userInfo, + Chat: &bridgev2.CreateChatResponse{ + PortalKey: ids.PeerTypeUser.AsPortalKey(user.GetID(), t.loginID), + }, + }, nil + } +} diff --git a/pkg/connector/store/scoped_store.go b/pkg/connector/store/scoped_store.go index 95bcfc5c..08e1e964 100644 --- a/pkg/connector/store/scoped_store.go +++ b/pkg/connector/store/scoped_store.go @@ -57,6 +57,15 @@ const ( ON CONFLICT (username) DO UPDATE SET entity_id=excluded.entity_id ` clearUsernameQuery = `DELETE FROM telegram_username WHERE entity_id=$1` + + // User Phone Number Queries + getEntityIDForPhoneNumber = "SELECT entity_id FROM telegram_phone_number WHERE phone_number=$1" + setPhoneNumberQuery = ` + INSERT INTO telegram_phone_number (phone_number, entity_id) + VALUES ($1, $2) + ON CONFLICT (phone_number) DO UPDATE SET entity_id=excluded.entity_id + ` + clearPhoneNumberQuery = "DELETE FROM telegram_phone_number WHERE entity_id=$1" ) var _ updates.StateStorage = (*ScopedStore)(nil) @@ -187,6 +196,23 @@ func (s *ScopedStore) SetUsername(ctx context.Context, userID int64, username st return } +func (s *ScopedStore) GetUserIDByPhoneNumber(ctx context.Context, phoneNumber string) (userID int64, err error) { + err = s.db.QueryRow(ctx, getEntityIDForPhoneNumber, phoneNumber).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + return +} + +func (s *ScopedStore) SetPhoneNumber(ctx context.Context, userID int64, phoneNumber string) (err error) { + if phoneNumber == "" { + _, err = s.db.Exec(ctx, clearPhoneNumberQuery, userID) + } else { + _, err = s.db.Exec(ctx, setPhoneNumberQuery, phoneNumber, userID) + } + return +} + // Helper Functions func (s *ScopedStore) assertUserIDMatches(userID int64) { diff --git a/pkg/connector/store/upgrades/00-latest.sql b/pkg/connector/store/upgrades/00-latest.sql index 61c266fd..3f2c38b7 100644 --- a/pkg/connector/store/upgrades/00-latest.sql +++ b/pkg/connector/store/upgrades/00-latest.sql @@ -35,6 +35,15 @@ CREATE TABLE telegram_username ( CREATE INDEX telegram_username_entity_idx ON telegram_username (entity_id); +CREATE TABLE telegram_phone_number ( + phone_number TEXT NOT NULL, + entity_id BIGINT NOT NULL, + + PRIMARY KEY (phone_number) +); + +CREATE INDEX telegram_phone_number_entity_idx ON telegram_phone_number (entity_id); + CREATE TABLE telegram_file ( id TEXT PRIMARY KEY, mxc TEXT NOT NULL,