move gotd fork into repo. (#111)

- update to latest telegram layer
- remove some references to fields in tg.Entities that don't exist in
the schema
- originally added here:
https://github.com/beeper/td/commit/820929062a2ba0104397bc01235ab58a9cff780e
  - referenced here
-
https://github.com/mautrix/telegramgo/commit/124f0967ed195b5a380c9bd02e170ada9710dde3
-
https://github.com/mautrix/telegramgo/commit/4205047aab2e0639217148b5d125bfaab668bd8e
This commit is contained in:
Adam Van Ymeren
2025-06-27 20:03:37 -07:00
committed by GitHub
parent 0952df0244
commit 7a04f298d2
19264 changed files with 1539697 additions and 84 deletions
@@ -0,0 +1,113 @@
package peer
import (
"context"
"fmt"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// PromiseDecorator is a decorator of peer promise.
type PromiseDecorator = func(Promise) Promise
// ConstraintError is a peer resolve constraint error.
type ConstraintError struct {
Expected string
Got tg.InputPeerClass
}
// Error implements error.
func (c *ConstraintError) Error() string {
return fmt.Sprintf("expected %q, got %T", c.Expected, c.Got)
}
func tryUnpackConstraint(p tg.InputPeerClass, resolveErr error) (tg.InputPeerClass, error) {
var constraintErr *ConstraintError
if errors.As(resolveErr, &constraintErr) {
return constraintErr.Got, nil
}
return p, resolveErr
}
// OnlyChannel returns Promise which returns error if resolved peer is not a channel.
func OnlyChannel(p Promise) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
resolved, err := tryUnpackConstraint(p(ctx))
if err != nil {
return nil, err
}
switch resolved.(type) {
case *tg.InputPeerChannel, *tg.InputPeerChannelFromMessage:
return resolved, nil
default:
return nil, &ConstraintError{
Expected: "channel",
Got: resolved,
}
}
}
}
// OnlyChat returns Promise which returns error if resolved peer is not a chat.
func OnlyChat(p Promise) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
resolved, err := tryUnpackConstraint(p(ctx))
if err != nil {
return nil, err
}
switch resolved.(type) {
case *tg.InputPeerChat:
return resolved, nil
default:
return nil, &ConstraintError{
Expected: "chat",
Got: resolved,
}
}
}
}
// OnlyUser returns Promise which returns error if resolved peer is not a user.
func OnlyUser(p Promise) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
resolved, err := tryUnpackConstraint(p(ctx))
if err != nil {
return nil, err
}
switch resolved.(type) {
case *tg.InputPeerUser, *tg.InputPeerUserFromMessage, *tg.InputPeerSelf:
return resolved, nil
default:
return nil, &ConstraintError{
Expected: "user",
Got: resolved,
}
}
}
}
// OnlyUserID returns Promise which returns error if resolved peer is not a user object with ID.
// Unlike OnlyUser, it returns error if resolved peer is tg.InputPeerSelf.
func OnlyUserID(p Promise) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
resolved, err := tryUnpackConstraint(p(ctx))
if err != nil {
return nil, err
}
switch resolved.(type) {
case *tg.InputPeerUser, *tg.InputPeerUserFromMessage:
return resolved, nil
default:
return nil, &ConstraintError{
Expected: "userID",
Got: resolved,
}
}
}
}
@@ -0,0 +1,146 @@
package peer
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func createPromise(peer tg.InputPeerClass) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
return peer, nil
}
}
func TestConstraintsCombine(t *testing.T) {
decorate := func(promise Promise, decorators ...PromiseDecorator) Promise {
for _, decorator := range decorators {
promise = decorator(promise)
}
return promise
}
tests := []struct {
name string
decorators []PromiseDecorator
input tg.InputPeerClass
wantErr bool
}{
{
"UserOrChannel",
[]PromiseDecorator{OnlyUser, OnlyChannel},
&tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
},
false,
},
{
"UserOrChannel",
[]PromiseDecorator{OnlyUser, OnlyChannel},
&tg.InputPeerChat{
ChatID: 10,
},
true,
},
{
"ChannelOrUser",
[]PromiseDecorator{OnlyChannel, OnlyUser},
&tg.InputPeerUser{
UserID: 10,
AccessHash: 10,
},
false,
},
{
"ChannelOrUser",
[]PromiseDecorator{OnlyChannel, OnlyUser},
&tg.InputPeerChat{
ChatID: 10,
},
true,
},
}
for _, test := range tests {
tname := "Good"
if test.wantErr {
tname = "Bad"
}
t.Run(test.name, func(t *testing.T) {
t.Run(tname, func(t *testing.T) {
a := require.New(t)
promise := decorate(createPromise(test.input), test.decorators...)
_, err := promise(context.Background())
if test.wantErr {
a.Error(err)
} else {
a.NoError(err)
}
})
})
}
}
func TestConstraints(t *testing.T) {
tests := []struct {
name string
decorator func(Promise) Promise
input tg.InputPeerClass
wantErr bool
}{
{"Channel", OnlyChannel, &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}, false},
{"Channel", OnlyChannel, &tg.InputPeerUser{
UserID: 10,
AccessHash: 10,
}, true},
{"User", OnlyUser, &tg.InputPeerUser{
UserID: 10,
AccessHash: 10,
}, false},
{"User", OnlyUser, &tg.InputPeerSelf{}, false},
{"User", OnlyUser, &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}, true},
{"UserID", OnlyUserID, &tg.InputPeerUser{
UserID: 10,
AccessHash: 10,
}, false},
{"UserID", OnlyUserID, &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}, true},
{"UserID", OnlyUserID, &tg.InputPeerSelf{}, true},
{"Chat", OnlyChat, &tg.InputPeerChat{
ChatID: 10,
}, false},
{"Chat", OnlyChat, &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}, true},
}
for _, test := range tests {
tname := "Good"
if test.wantErr {
tname = "Bad"
}
t.Run(test.name, func(t *testing.T) {
t.Run(tname, func(t *testing.T) {
a := require.New(t)
promise := test.decorator(createPromise(test.input))
_, err := promise(context.Background())
if test.wantErr {
a.Error(err)
} else {
a.NoError(err)
}
})
})
}
}
+40
View File
@@ -0,0 +1,40 @@
package peer
import (
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// ToInputUser converts given peer to input user if possible.
func ToInputUser(user tg.InputPeerClass) (tg.InputUserClass, bool) {
switch u := user.(type) {
case *tg.InputPeerUser:
v := new(tg.InputUser)
v.FillFrom(u)
return v, true
case *tg.InputPeerUserFromMessage:
v := new(tg.InputUserFromMessage)
v.FillFrom(u)
return v, true
case *tg.InputPeerSelf:
v := new(tg.InputUserSelf)
return v, true
default:
return nil, false
}
}
// ToInputChannel converts given peer to input channel if possible.
func ToInputChannel(channel tg.InputPeerClass) (tg.InputChannelClass, bool) {
switch u := channel.(type) {
case *tg.InputPeerChannel:
v := new(tg.InputChannel)
v.FillFrom(u)
return v, true
case *tg.InputPeerChannelFromMessage:
v := new(tg.InputChannelFromMessage)
v.FillFrom(u)
return v, true
default:
return nil, false
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package peer conatains some peer resolving and extracting helpers.
package peer
+159
View File
@@ -0,0 +1,159 @@
package peer
import (
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Entities is simple peer entities storage.
type Entities struct {
users map[int64]*tg.User
chats map[int64]*tg.Chat
channels map[int64]*tg.Channel
}
// NewEntities creates new Entities struct.
func NewEntities(
users map[int64]*tg.User,
chats map[int64]*tg.Chat,
channels map[int64]*tg.Channel,
) Entities {
return Entities{users: users, chats: chats, channels: channels}
}
// EntitySearchResult is abstraction for different RPC responses which
// contains entities.
type EntitySearchResult interface {
MapChats() tg.ChatClassArray
MapUsers() tg.UserClassArray
}
// EntitiesFromResult fills Entities struct using given context.
func EntitiesFromResult(r EntitySearchResult) Entities {
return NewEntities(
r.MapUsers().UserToMap(),
r.MapChats().ChatToMap(),
r.MapChats().ChannelToMap(),
)
}
// EntitiesFromUpdate fills Entities struct using given context.
func EntitiesFromUpdate(uctx tg.Entities) Entities {
return NewEntities(
uctx.Users,
uctx.Chats,
uctx.Channels,
)
}
// Users returns map of users.
// Notice that returned map is not a copy.
func (ent Entities) Users() map[int64]*tg.User {
return ent.users
}
// Chats returns map of chats.
// Notice that returned map is not a copy.
func (ent Entities) Chats() map[int64]*tg.Chat {
return ent.chats
}
// Channels returns map of channels.
// Notice that returned map is not a copy.
func (ent Entities) Channels() map[int64]*tg.Channel {
return ent.channels
}
// FillFromResult adds and updates all entities from given result.
func (ent Entities) FillFromResult(r EntitySearchResult) {
r.MapUsers().FillUserMap(ent.users)
r.MapChats().FillChatMap(ent.chats)
r.MapChats().FillChannelMap(ent.channels)
}
// FillFromUpdate adds and updates all entities from given updates.
func (ent Entities) FillFromUpdate(uctx tg.Entities) {
ent.Fill(
uctx.Users,
uctx.Chats,
uctx.Channels,
)
}
// Fill adds and updates all entities from given maps.
func (ent Entities) Fill(
users map[int64]*tg.User,
chats map[int64]*tg.Chat,
channels map[int64]*tg.Channel,
) {
for k, v := range users {
ent.users[k] = v
}
for k, v := range chats {
ent.chats[k] = v
}
for k, v := range channels {
ent.channels[k] = v
}
}
// ExtractPeer finds and creates InputPeerClass using given PeerClass.
func (ent Entities) ExtractPeer(peerID tg.PeerClass) (tg.InputPeerClass, error) {
var peer tg.InputPeerClass
switch p := peerID.(type) {
case *tg.PeerUser: // peerUser#9db1bc6d
dialog, ok := ent.users[p.UserID]
if !ok {
return nil, errors.Errorf("user %d not found", p.UserID)
}
peer = &tg.InputPeerUser{
UserID: dialog.ID,
AccessHash: dialog.AccessHash,
}
case *tg.PeerChat: // peerChat#bad0e5bb
dialog, ok := ent.chats[p.ChatID]
if !ok {
return nil, errors.Errorf("chat %d not found", p.ChatID)
}
peer = &tg.InputPeerChat{
ChatID: dialog.ID,
}
case *tg.PeerChannel: // peerChannel#bddde532
dialog, ok := ent.channels[p.ChannelID]
if !ok {
return nil, errors.Errorf("channel %d not found", p.ChannelID)
}
peer = &tg.InputPeerChannel{
ChannelID: dialog.ID,
AccessHash: dialog.AccessHash,
}
default:
return nil, errors.Errorf("unexpected peer type %T", peerID)
}
return peer, nil
}
// User finds user by given ID.
func (ent Entities) User(id int64) (*tg.User, bool) {
v, ok := ent.users[id]
return v, ok
}
// Chat finds chat by given ID.
func (ent Entities) Chat(id int64) (*tg.Chat, bool) {
v, ok := ent.chats[id]
return v, ok
}
// Channel finds channel by given ID.
func (ent Entities) Channel(id int64) (*tg.Channel, bool) {
v, ok := ent.channels[id]
return v, ok
}
@@ -0,0 +1,128 @@
package peer
import (
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type badPeer struct {
tg.PeerClass
}
type mockResult struct {
Entities
}
func (m mockResult) MapUsers() (r tg.UserClassArray) {
for _, e := range m.Entities.Users() {
r = append(r, e)
}
return r
}
func (m mockResult) MapChats() (r tg.ChatClassArray) {
for _, e := range m.Entities.Chats() {
r = append(r, e)
}
for _, e := range m.Entities.Channels() {
r = append(r, e)
}
return r
}
func TestEntities(t *testing.T) {
users := map[int64]*tg.User{
10: {ID: 10, AccessHash: 10},
}
chats := map[int64]*tg.Chat{
10: {ID: 10},
}
channels := map[int64]*tg.Channel{
10: {ID: 10, AccessHash: 10},
}
ent := NewEntities(users, chats, channels)
ctx := tg.Entities{
Users: users,
Chats: chats,
Channels: channels,
}
result := mockResult{Entities: ent}
tests := []struct {
name string
filler func() Entities
}{
{"NewEntities", func() Entities {
return ent
}},
{"EntitiesFromResult", func() Entities {
return EntitiesFromResult(result)
}},
{"FillFromResult", func() Entities {
e := NewEntities(
map[int64]*tg.User{},
map[int64]*tg.Chat{},
map[int64]*tg.Channel{},
)
e.FillFromResult(result)
return e
}},
{"EntitiesFromUpdate", func() Entities {
return EntitiesFromUpdate(ctx)
}},
{"FillFromUpdate", func() Entities {
e := NewEntities(
map[int64]*tg.User{},
map[int64]*tg.Chat{},
map[int64]*tg.Channel{},
)
e.FillFromUpdate(ctx)
return e
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
a := require.New(t)
e := test.filler()
_, err := e.ExtractPeer(badPeer{})
a.Error(err)
user, err := e.ExtractPeer(&tg.PeerUser{UserID: 10})
peerUser := &tg.InputPeerUser{
UserID: 10,
AccessHash: 10,
}
a.Equal(peerUser, user)
a.NoError(err)
_, err = e.ExtractPeer(&tg.PeerUser{UserID: 11})
a.Error(err)
chat, err := e.ExtractPeer(&tg.PeerChat{ChatID: 10})
peerChat := &tg.InputPeerChat{
ChatID: 10,
}
a.Equal(peerChat, chat)
a.NoError(err)
_, err = e.ExtractPeer(&tg.PeerChat{ChatID: 11})
a.Error(err)
channel, err := e.ExtractPeer(&tg.PeerChannel{ChannelID: 10})
peerChannel := &tg.InputPeerChannel{
ChannelID: 10,
AccessHash: 10,
}
a.Equal(peerChannel, channel)
a.NoError(err)
_, err = e.ExtractPeer(&tg.PeerChannel{ChannelID: 11})
a.Error(err)
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package peer
import (
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type nodeData struct {
key string
value tg.InputPeerClass
expiresAt time.Time
}
type linkedNode struct {
next, prev *linkedNode
nodeData
}
// linkedList is a doubly linked list implementation.
// This implementation is highly inspired by container/list, but type safe.
type linkedList struct {
root linkedNode
len int
}
// Front returns the first element of list l or nil if the list is empty.
func (l *linkedList) Front() *linkedNode {
if l.len == 0 {
return nil
}
return l.root.next
}
// Back returns the last element of list l or nil if the list is empty.
func (l *linkedList) Back() *linkedNode {
if l.len == 0 {
return nil
}
return l.root.prev
}
// insert inserts e after at, increments l.len, and returns e.
func (l *linkedList) insert(e, at *linkedNode) *linkedNode {
e.prev = at
e.next = at.next
e.prev.next = e
e.next.prev = e
l.len++
return e
}
func (l *linkedList) insertValue(v nodeData, at *linkedNode) *linkedNode {
e := new(linkedNode)
e.nodeData = v
return l.insert(e, at)
}
// PushFront inserts a new element e with value v at the front of list l and returns e.
func (l *linkedList) PushFront(v nodeData) *linkedNode {
l.lazyInit()
return l.insertValue(v, &l.root)
}
// lazyInit lazily initializes a zero List value.
func (l *linkedList) lazyInit() {
if l.root.next == nil {
l.root.next = &l.root
l.root.prev = &l.root
l.len = 0
}
}
// remove removes e from its list, decrements l.len, and returns e.
func (l *linkedList) remove(e *linkedNode) nodeData {
e.prev.next = e.next
e.next.prev = e.prev
e.next = nil // avoid memory leaks
e.prev = nil // avoid memory leaks
l.len--
value := e.nodeData
return value
}
// move moves e to next to at and returns e.
func (l *linkedList) move(e, at *linkedNode) *linkedNode {
if e == at {
return e
}
e.prev.next = e.next
e.next.prev = e.prev
e.prev = at
e.next = at.next
e.prev.next = e
e.next.prev = e
return e
}
// Remove removes e from l if e is an element of list l.
// It returns the element value e.Value.
// The element must not be nil.
func (l *linkedList) Remove(e *linkedNode) nodeData {
return l.remove(e)
}
// MoveToFront moves element e to the front of list l.
// If e is not an element of l, the list is not modified.
// The element must not be nil.
func (l *linkedList) MoveToFront(e *linkedNode) {
if l.root.next == e {
return
}
// see comment in List.Remove about initialization of l
l.move(e, &l.root)
}
+145
View File
@@ -0,0 +1,145 @@
package peer
import (
"context"
"sync"
"time"
"golang.org/x/sync/singleflight"
"go.mau.fi/mautrix-telegram/pkg/gotd/clock"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// LRUResolver is simple decorator for Resolver to cache result in LRU.
type LRUResolver struct {
next Resolver
clock clock.Clock
expiration time.Duration
capacity int
cache map[string]*linkedNode
lruList *linkedList
// Guards LRU state — cache and lruList
mux sync.Mutex
// Prevents multiple identical requests at the same time.
sg singleflight.Group
}
// NewLRUResolver creates new LRUResolver.
func NewLRUResolver(next Resolver, capacity int) *LRUResolver {
return &LRUResolver{
next: next,
clock: clock.System,
expiration: time.Minute,
capacity: capacity,
cache: make(map[string]*linkedNode, capacity),
lruList: &linkedList{},
sg: singleflight.Group{},
}
}
// WithClock sets clock to use when counting expiration.
func (l *LRUResolver) WithClock(c clock.Clock) *LRUResolver {
l.clock = c
return l
}
// WithExpiration sets expiration timeout for records in cache.
// If zero, expiration will be disabled. Default value is a minute.
func (l *LRUResolver) WithExpiration(expiration time.Duration) *LRUResolver {
l.expiration = expiration
return l
}
// Evict deletes record from cache.
func (l *LRUResolver) Evict(key string) (tg.InputPeerClass, bool) {
return l.delete(key)
}
// ResolveDomain implements Resolver.
func (l *LRUResolver) ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error) {
if v, ok := l.get(domain); ok {
return v, nil
}
r, err := l.next.ResolveDomain(ctx, domain)
if err != nil {
return nil, err
}
l.put(domain, r)
return r, nil
}
// ResolvePhone implements Resolver.
func (l *LRUResolver) ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error) {
if v, ok := l.get(phone); ok {
return v, nil
}
r, err := l.next.ResolvePhone(ctx, phone)
if err != nil {
return nil, err
}
l.put(phone, r)
return r, nil
}
func (l *LRUResolver) get(key string) (v tg.InputPeerClass, ok bool) {
l.mux.Lock()
defer l.mux.Unlock()
if found, ok := l.cache[key]; ok {
// Delete expired and return false.
if l.expiration > 0 && l.clock.Now().After(found.expiresAt) {
l.deleteLocked(key)
return nil, false
}
l.lruList.MoveToFront(found)
return found.value, true
}
return
}
func (l *LRUResolver) put(key string, value tg.InputPeerClass) {
l.mux.Lock()
defer l.mux.Unlock()
if found, ok := l.cache[key]; ok {
found.value = value
l.lruList.MoveToFront(found)
} else {
if len(l.cache) >= l.capacity {
l.deleteLocked(l.lruList.Back().key)
}
l.cache[key] = l.lruList.PushFront(nodeData{
key,
value,
l.clock.Now().Add(l.expiration),
})
}
}
func (l *LRUResolver) delete(key string) (tg.InputPeerClass, bool) {
l.mux.Lock()
defer l.mux.Unlock()
return l.deleteLocked(key)
}
// deleteLocked deletes record from cache.
// Assumes mutex is locked.
func (l *LRUResolver) deleteLocked(key string) (tg.InputPeerClass, bool) {
found, ok := l.cache[key]
if !ok {
return nil, false
}
l.lruList.Remove(found)
delete(l.cache, key)
return nil, true
}
@@ -0,0 +1,52 @@
package peer_test
import (
"context"
"fmt"
"os"
"os/signal"
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message/peer"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func resolveLRU(ctx context.Context) error {
client, err := telegram.ClientFromEnvironment(telegram.Options{})
if err != nil {
return err
}
return client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
resolver := peer.NewLRUResolver(peer.Plain(raw), 16).WithExpiration(time.Minute)
sender := message.NewSender(raw).WithResolver(resolver)
// "durovschat" will be resolved by Plain resolver.
if _, err := sender.Resolve("@durovschat").Dice(ctx); err != nil {
return err
}
// "durovschat" will be resolved by cache.
if _, err := sender.Resolve("https://t.me/durovschat").Darts(ctx); err != nil {
return err
}
// Evict and delete record.
resolver.Evict("durovschat")
return nil
})
}
func ExampleLRUResolver_cache() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := resolveLRU(ctx); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(2)
}
}
+158
View File
@@ -0,0 +1,158 @@
package peer
import (
"context"
"strconv"
"testing"
"time"
"github.com/go-faster/errors"
"github.com/stretchr/testify/require"
"github.com/gotd/neo"
"go.mau.fi/mautrix-telegram/pkg/gotd/testutil"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func TestLRU(t *testing.T) {
a := require.New(t)
c := neo.NewTime(time.Now())
lru := NewLRUResolver(nil, 4).WithClock(c).WithExpiration(1 * time.Second)
// Add 5 entries.
// State: [4 3 2 1]
for i := range [5]struct{}{} {
lru.put(strconv.Itoa(i), &tg.InputPeerChat{
ChatID: int64(i),
})
}
// First entry must be evicted.
// State: [4 3 2 1]
_, ok := lru.get(strconv.Itoa(0))
a.False(ok)
// Third must not.
// State: [2 3 4 1]
_, ok = lru.get(strconv.Itoa(2))
a.True(ok)
a.Equal("2", lru.lruList.Front().nodeData.key)
// Add yet another.
// State: [6 2 3 4]
lru.put(strconv.Itoa(6), &tg.InputPeerChat{
ChatID: 6,
})
a.Equal("6", lru.lruList.Front().nodeData.key)
// Then yet one must be evicted.
_, ok = lru.get(strconv.Itoa(1))
a.False(ok)
// Add which already exist.
// State: [4 6 2 3]
lru.put(strconv.Itoa(4), &tg.InputPeerChat{
ChatID: 6,
})
a.Equal("4", lru.lruList.Front().nodeData.key)
// Delete key which does not exist.
// State: [4 6 2 3]
_, ok = lru.Evict(strconv.Itoa(10))
a.False(ok)
c.Travel(time.Hour)
// Delete expired key.
// State: [6 2 3]
_, ok = lru.get("4")
a.False(ok)
}
type mockResolver struct {
counter int
returnErr bool
domain, phone string
peer tg.InputPeerClass
t testing.TB
}
func (m *mockResolver) ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error) {
m.counter++
if m.returnErr && m.counter == 1 {
return nil, testutil.TestError()
}
if domain != m.domain {
err := errors.Errorf("expected domain %q, got %q", m.domain, domain)
m.t.Error(err)
return nil, err
}
return m.peer, nil
}
func (m *mockResolver) ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error) {
m.counter++
if m.returnErr && m.counter == 1 {
return nil, testutil.TestError()
}
if phone != m.phone {
err := errors.Errorf("expected phone %q, got %q", m.phone, phone)
m.t.Error(err)
return nil, err
}
return m.peer, nil
}
func TestLRUResolver_Resolve(t *testing.T) {
ctx := context.Background()
expectedDomain := "telegram"
expected := &tg.InputPeerUser{
UserID: 10,
}
t.Run("Cache", func(t *testing.T) {
a := require.New(t)
resolver := &mockResolver{
domain: expectedDomain,
peer: expected,
t: t,
}
lru := NewLRUResolver(resolver, 10)
r, err := lru.ResolveDomain(ctx, expectedDomain)
a.NoError(err)
a.Equal(expected, r)
r2, err := lru.ResolveDomain(ctx, expectedDomain)
a.NoError(err)
a.Equal(expected, r2)
a.Equalf(1, resolver.counter, "RPC call was not cached")
})
t.Run("Error", func(t *testing.T) {
a := require.New(t)
resolver := &mockResolver{
returnErr: true,
domain: expectedDomain,
peer: expected,
t: t,
}
lru := NewLRUResolver(resolver, 10)
_, err := lru.ResolveDomain(ctx, expectedDomain)
a.Error(err)
r2, err := lru.ResolveDomain(ctx, expectedDomain)
a.NoError(err)
a.Equal(expected, r2)
a.Equalf(2, resolver.counter, "RPC call error was cached")
})
}
+129
View File
@@ -0,0 +1,129 @@
package peer
import (
"context"
"strings"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/ascii"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/internal/deeplink"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Promise is a peer promise.
type Promise func(ctx context.Context) (tg.InputPeerClass, error)
// Resolve uses given string to create new peer promise.
// It resolves peer of message using given Resolver.
// Input examples:
//
// @telegram
// telegram
// t.me/telegram
// https://t.me/telegram
// tg:resolve?domain=telegram
// tg://resolve?domain=telegram
// +13115552368
// +1 (311) 555-0123
// +1 311 555-6162
// 13115556162
func Resolve(r Resolver, from string) Promise {
from = strings.TrimSpace(from)
if deeplink.IsDeeplinkLike(from) {
return ResolveDeeplink(r, from)
}
if isPhoneNumber(from) {
return ResolvePhone(r, from)
}
return ResolveDomain(r, from)
}
func isPhoneNumber(s string) bool {
if s == "" {
return false
}
r := rune(s[0])
return r == '+' || ascii.IsDigit(r)
}
func cleanupPhone(phone string) string {
clean := strings.Builder{}
clean.Grow(len(phone) + 1)
for _, ch := range phone {
if ascii.IsDigit(ch) {
clean.WriteRune(ch)
}
}
return clean.String()
}
// ResolvePhone uses given phone to create new peer promise.
// It resolves peer of message using given Resolver.
// Input example:
//
// +13115552368
// +1 (311) 555-0123
// +1 311 555-6162
// 13115556162
//
// Note that Telegram represents phone numbers according to the E.164 standard
// without the plus sign (”+”) prefix. The resolver therefore takes an easy
// route and just deletes any non-digit symbols from phone number string.
func ResolvePhone(r Resolver, phone string) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
return r.ResolvePhone(ctx, cleanupPhone(phone))
}
}
// ResolveDomain uses given domain to create new peer promise.
// It resolves peer of message using given Resolver.
// Can has prefix with @ or not.
// Input examples:
//
// @telegram
// telegram
func ResolveDomain(r Resolver, domain string) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
domain = strings.TrimPrefix(domain, "@")
if err := validateDomain(domain); err != nil {
return nil, errors.Wrap(err, "validate domain")
}
return r.ResolveDomain(ctx, domain)
}
}
func validateDomain(domain string) error {
return deeplink.ValidateDomain(domain)
}
// ResolveDeeplink uses given deeplink to create new peer promise.
// Deeplink is a URL like https://t.me/telegram.
// It resolves peer of message using given Resolver.
// Input examples:
//
// t.me/telegram
// https://t.me/telegram
// tg:resolve?domain=telegram
// tg://resolve?domain=telegram
func ResolveDeeplink(r Resolver, u string) Promise {
return func(ctx context.Context) (tg.InputPeerClass, error) {
link, err := deeplink.Expect(u, deeplink.Resolve)
if err != nil {
return nil, err
}
domain := link.Args.Get("domain")
if err := validateDomain(domain); err != nil {
return nil, errors.Wrap(err, "validate domain")
}
return r.ResolveDomain(ctx, domain)
}
}
@@ -0,0 +1,105 @@
package peer
import (
"context"
"fmt"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
func resolver(t *testing.T, expectedDomain string, expected tg.InputPeerClass) Resolver {
return &mockResolver{
domain: expectedDomain,
peer: expected,
t: t,
}
}
func TestSender_Resolve(t *testing.T) {
formats := []struct {
fmt string
wantErr bool
}{
{`%s`, false},
{`@%s`, false},
{`t.me/%s`, false},
{`t.me/%s/`, false},
{`https://t.me/%s`, false},
{`https://t.me/%s/`, false},
{`tg:resolve?domain=%s`, false},
{`tg://resolve?domain=%s`, false},
{`https://t.co/%s`, true},
{`rt://resolve?domain=%s`, true},
}
tests := []struct {
name string
domain string
wantErr bool
}{
{"Good", "telegram", false},
{"Good with numbers", "telegram123", false},
{"Good with _", "telegram_test", false},
{"Good with numbers and _", "telegram_test123", false},
{"Bad", "_gotd_test", true},
{"Bad", "gotd_test_", true},
{"Bad", "_gotd_test123", true},
{"Bad", "gotd.test", true},
}
for _, format := range formats {
t.Run(format.fmt, func(t *testing.T) {
for _, tt := range tests {
name := tt.name
if tt.wantErr {
name = fmt.Sprintf("%s (%q)", tt.name, tt.domain)
}
expected := &tg.InputPeerUser{
UserID: 1,
AccessHash: 10,
}
t.Run(name, func(t *testing.T) {
a := require.New(t)
p, err := Resolve(
resolver(t, tt.domain, expected),
fmt.Sprintf(format.fmt, tt.domain),
)(context.Background())
if tt.wantErr || format.wantErr {
a.Error(err)
return
}
a.NoError(err)
a.Equal(expected, p)
})
}
})
}
}
func Test_cleanupPhone(t *testing.T) {
tests := []struct {
phone string
want string
}{
{"+13115552368", "13115552368"},
{"+1 (311) 555-0123", "13115550123"},
{"+1 311 555-6162", "13115556162"},
{"13115556162", "13115556162"},
{"123gotd_test", "123"},
}
for _, tt := range tests {
t.Run(tt.phone, func(t *testing.T) {
r := cleanupPhone(tt.phone)
require.Equal(t, tt.want, r)
_, err := strconv.ParseInt(r, 10, 64)
require.NoError(t, err)
})
}
}
@@ -0,0 +1,64 @@
package peer
import (
"context"
"github.com/go-faster/errors"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
// Resolver is an abstraction to resolve domains and Telegram deeplinks.
type Resolver interface {
ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error)
ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error)
}
// DefaultResolver creates and returns default resolver.
func DefaultResolver(raw *tg.Client) Resolver {
return NewLRUResolver(SingleflightResolver(Plain(raw)), 10)
}
// Plain creates plain resolver.
func Plain(raw *tg.Client) Resolver {
return plainResolver{raw: raw}
}
type plainResolver struct {
raw *tg.Client
}
func (p plainResolver) ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error) {
peer, err := p.raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: domain,
})
if err != nil {
return nil, errors.Wrap(err, "resolve")
}
return EntitiesFromResult(peer).ExtractPeer(peer.Peer)
}
func (p plainResolver) ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error) {
r, err := p.raw.ContactsGetContacts(ctx, 0)
if err != nil {
return nil, errors.Wrap(err, "get contacts")
}
switch c := r.(type) {
case *tg.ContactsContacts:
for _, u := range c.Users {
user, ok := u.AsNotEmpty()
if !ok {
continue
}
if user.Phone == phone {
return user.AsInputPeer(), nil
}
}
return nil, errors.Errorf("can't resolve phone %q", phone)
default:
return nil, errors.Errorf("unexpected type %T", r)
}
}
@@ -0,0 +1,78 @@
package peer
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgmock"
)
func Test_plainResolver_Resolve(t *testing.T) {
mock := tgmock.NewRequire(t)
raw := tg.NewClient(mock)
domain := "adcd"
mock.ExpectCall(&tg.ContactsResolveUsernameRequest{
Username: domain,
}).ThenResult(&tg.ContactsResolvedPeer{
Peer: &tg.PeerUser{UserID: 10},
Users: []tg.UserClass{
&tg.User{ID: 10, AccessHash: 10, Username: domain},
},
}).ExpectCall(&tg.ContactsResolveUsernameRequest{
Username: domain,
}).ThenRPCErr(&tgerr.Error{
Code: 1337,
Message: "TEST_ERROR",
Type: "TEST_ERROR",
})
ctx := context.Background()
resolver := plainResolver{raw: raw}
r, err := resolver.ResolveDomain(ctx, domain)
require.IsType(t, &tg.InputPeerUser{}, r)
require.Equal(t, int64(10), r.(*tg.InputPeerUser).UserID)
require.NoError(t, err)
_, err = resolver.ResolveDomain(ctx, domain)
require.Error(t, err)
}
func Test_plainResolver_ResolvePhone(t *testing.T) {
mock := tgmock.New(t)
raw := tg.NewClient(mock)
phone := "adcd"
mock.ExpectCall(&tg.ContactsGetContactsRequest{
Hash: 0,
}).ThenResult(&tg.ContactsContacts{
Contacts: []tg.Contact{{
UserID: 10,
Mutual: false,
}},
SavedCount: 1,
Users: []tg.UserClass{
&tg.User{ID: 10, AccessHash: 10, Username: "rustmustdie", Phone: phone},
},
}).Expect().ThenRPCErr(&tgerr.Error{
Code: 1337,
Message: "TEST_ERROR",
Type: "TEST_ERROR",
})
ctx := context.Background()
resolver := plainResolver{raw: raw}
r, err := resolver.ResolvePhone(ctx, phone)
require.NoError(t, err)
require.IsType(t, &tg.InputPeerUser{}, r)
require.Equal(t, int64(10), r.(*tg.InputPeerUser).UserID)
_, err = resolver.ResolvePhone(ctx, phone)
require.Error(t, err)
}
@@ -0,0 +1,52 @@
package peer
import (
"context"
"golang.org/x/sync/singleflight"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
)
type singleFlight struct {
next Resolver
sg singleflight.Group
}
func (s *singleFlight) ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error) {
ch := s.sg.DoChan(domain, func() (interface{}, error) {
return s.next.ResolveDomain(ctx, domain)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
if r.Err != nil {
return nil, r.Err
}
return r.Val.(tg.InputPeerClass), nil
}
}
func (s *singleFlight) ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error) {
ch := s.sg.DoChan(phone, func() (interface{}, error) {
return s.next.ResolvePhone(ctx, phone)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
if r.Err != nil {
return nil, r.Err
}
return r.Val.(tg.InputPeerClass), nil
}
}
// SingleflightResolver is a simple resolver decorator
// which prevents duplicate resolve calls.
func SingleflightResolver(next Resolver) Resolver {
return &singleFlight{next: next}
}