Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -19,10 +21,12 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
notifier := &fakeAccountFreezeNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: repo,
|
||||
Restrictions: restrictions,
|
||||
Now: fixedNow,
|
||||
Commands: repo,
|
||||
Restrictions: restrictions,
|
||||
FreezeNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
|
||||
|
|
@ -53,6 +57,9 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
|
||||
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
|
||||
}
|
||||
if len(notifier.items) != 1 || notifier.items[0].UserID != 1001 || !notifier.items[0].Frozen || notifier.items[0].Version != 1 {
|
||||
t.Fatalf("freeze notifications = %+v, want one versioned frozen state", notifier.items)
|
||||
}
|
||||
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
|
|
@ -68,6 +75,95 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
if !again.AlreadyExecuted || restrictions.setCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls)
|
||||
}
|
||||
if len(notifier.items) != 1 {
|
||||
t.Fatalf("idempotent replay emitted duplicate notification: %+v", notifier.items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBotReturnsTokenOnceWithoutPersistingCredential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
bots := &fakeBotService{token: "test-one-time-bot-credential"}
|
||||
svc := NewService(Dependencies{Commands: repo, Bots: bots, Now: fixedNow})
|
||||
req := CreateBotRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "create-bot-once", Actor: "ops", Reason: "requested"},
|
||||
OwnerUserID: 1001,
|
||||
Name: "Audit Safe Bot",
|
||||
Username: "audit_safe_bot",
|
||||
}
|
||||
|
||||
first, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot: %v", err)
|
||||
}
|
||||
if first.Details["token"] != bots.token || bots.createCalls != 1 {
|
||||
t.Fatalf("first result=%+v createCalls=%d", first, bots.createCalls)
|
||||
}
|
||||
stored := repo.items[req.CommandID].ResultJSON
|
||||
if bytes.Contains(stored, []byte(bots.token)) || bytes.Contains(stored, []byte(`"token"`)) {
|
||||
t.Fatalf("persisted admin result contains bot credential: %s", stored)
|
||||
}
|
||||
|
||||
replay, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot replay: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || bots.createCalls != 1 {
|
||||
t.Fatalf("replay=%+v createCalls=%d", replay, bots.createCalls)
|
||||
}
|
||||
if _, leaked := replay.Details["token"]; leaked {
|
||||
t.Fatalf("replayed command exposed one-time bot token: %+v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{1001: {ID: 1001}}}
|
||||
channels := &fakeChannelsService{channels: map[int64]domain.Channel{2001: {
|
||||
ID: 2001, Megagroup: true,
|
||||
}}}
|
||||
svc := NewService(Dependencies{Commands: repo, Users: users, Channels: channels, Now: fixedNow})
|
||||
meta := CommandMeta{CommandID: "invalid-user-flags", Actor: "ops", Reason: "test"}
|
||||
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: 1001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetUserFlags error=%v", err)
|
||||
}
|
||||
meta.CommandID = "invalid-channel-flags"
|
||||
if _, err := svc.SetChannelFlags(ctx, SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: 2001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetChannelFlags error=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 || users.users[1001].Scam || users.users[1001].Fake ||
|
||||
channels.channels[2001].Scam || channels.channels[2001].Fake {
|
||||
t.Fatalf("invalid moderation state reached command/store boundary: commands=%d user=%+v channel=%+v",
|
||||
len(repo.items), users.users[1001], channels.channels[2001])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
|
||||
now := fixedNow()
|
||||
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
|
||||
1001: {
|
||||
UserID: 1001, Frozen: true, Version: 2, Since: now,
|
||||
Until: now.Add(time.Hour), AppealURL: "https://appeals.example.test/1001",
|
||||
},
|
||||
1002: {UserID: 1002, Frozen: false, Version: 4},
|
||||
}}}
|
||||
svc := NewService(Dependencies{Restrictions: store, Now: fixedNow})
|
||||
|
||||
got, err := svc.AccountFreezes(context.Background(), []int64{1001, 1001, 0, 1002})
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFreezes: %v", err)
|
||||
}
|
||||
if len(store.requests) != 1 || !reflect.DeepEqual(store.requests[0], []int64{1001, 1002}) {
|
||||
t.Fatalf("batch requests = %v, want one deduplicated request", store.requests)
|
||||
}
|
||||
if len(got) != 1 || !got[1001].Frozen || got[1001].Version != 2 {
|
||||
t.Fatalf("AccountFreezes = %+v, want active user 1001 only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
|
||||
|
|
@ -472,6 +568,22 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
type fakeBotService struct {
|
||||
token string
|
||||
createCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) {
|
||||
f.createCalls++
|
||||
return domain.User{ID: 2001, FirstName: name, Username: username, Bot: true}, f.token, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotService) DeleteBot(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
f.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
|
|
@ -490,11 +602,37 @@ func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.Acco
|
|||
f.items = map[int64]domain.AccountFreeze{}
|
||||
}
|
||||
f.setCalls++
|
||||
r.Version = f.items[r.UserID].Version + 1
|
||||
r.UpdatedAt = fixedNow()
|
||||
f.items[r.UserID] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type fakeBatchRestrictionStore struct {
|
||||
fakeRestrictionStore
|
||||
requests [][]int64
|
||||
}
|
||||
|
||||
func (f *fakeBatchRestrictionStore) GetAccountFreezes(_ context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
f.requests = append(f.requests, append([]int64(nil), userIDs...))
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range userIDs {
|
||||
if freeze, ok := f.items[id]; ok && freeze.Frozen {
|
||||
out[id] = freeze
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeAccountFreezeNotifier struct {
|
||||
items []domain.AccountFreeze
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezeNotifier) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error {
|
||||
f.items = append(f.items, freeze)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMessagesService struct {
|
||||
byID []domain.Message
|
||||
deleteCalls int
|
||||
|
|
@ -621,6 +759,60 @@ func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Scam = scam
|
||||
u.Fake = fake
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetSupport(_ context.Context, userID int64, support bool) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Support = support
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Username = username
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if forProfile {
|
||||
u.ProfileColor = color
|
||||
} else {
|
||||
u.Color = color
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakeStarsService struct {
|
||||
balances map[int64]domain.StarsBalance
|
||||
creditCalls int
|
||||
|
|
@ -695,6 +887,66 @@ func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, ve
|
|||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) SetScamFake(_ context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.Scam = scam
|
||||
ch.Fake = fake
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetSettings(_ context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if patch.Gigagroup != nil {
|
||||
ch.Gigagroup = *patch.Gigagroup
|
||||
}
|
||||
if patch.SlowmodeSeconds != nil {
|
||||
ch.SlowmodeSeconds = *patch.SlowmodeSeconds
|
||||
}
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetUsername(_ context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.Username = username
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetColor(_ context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if forProfile {
|
||||
ch.ProfileColor = color
|
||||
} else {
|
||||
ch.Color = color
|
||||
}
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) AdminSetEmojiStatus(_ context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.EmojiStatus = status
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type fakeChannelNotifier struct {
|
||||
channels []int64
|
||||
}
|
||||
|
|
@ -739,9 +991,18 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
|||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := PublishStarGiftCollectiblesRequest{
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{{Name: "Ruby", RarityPermille: 1000, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")}},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{{Name: "Stars", RarityPermille: 1000, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")}},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 1000}},
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileKey: "model-1", FileName: "sapphire.lottie", Data: []byte("model-1")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")},
|
||||
{Name: "Moons", RarityPermille: 500, FileKey: "pattern-1", FileName: "moons.tgs", Data: []byte("pattern-1")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
|
||||
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
|
|
@ -755,6 +1016,101 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesRejectsUnsafeClientPreviewPool(t *testing.T) {
|
||||
valid := func() PublishStarGiftCollectiblesRequest {
|
||||
return PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "unsafe-pool", Actor: "ops", Reason: "regression", DryRun: true},
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileName: "ruby.lottie", Data: []byte("ruby")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileName: "sapphire.lottie", Data: []byte("sapphire")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileName: "stars.lottie", Data: []byte("stars")},
|
||||
{Name: "Moons", RarityPermille: 500, FileName: "moons.lottie", Data: []byte("moons")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
}
|
||||
tests := map[string]func(*PublishStarGiftCollectiblesRequest){
|
||||
"single model": func(req *PublishStarGiftCollectiblesRequest) { req.Models = req.Models[:1] },
|
||||
"single pattern": func(req *PublishStarGiftCollectiblesRequest) { req.Patterns = req.Patterns[:1] },
|
||||
"single backdrop": func(req *PublishStarGiftCollectiblesRequest) { req.Backdrops = req.Backdrops[:1] },
|
||||
"duplicate backdrop id": func(req *PublishStarGiftCollectiblesRequest) {
|
||||
req.Backdrops[1].BackdropID = req.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := valid()
|
||||
mutate(&req)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: &fakeGiftsService{}, Now: fixedNow})
|
||||
if _, err := svc.PublishStarGiftCollectibles(context.Background(), req); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
|
||||
permille := 922
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x42, 32),
|
||||
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
|
||||
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
|
||||
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
|
||||
},
|
||||
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
|
||||
{Name: "Regular Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 5, FileName: "regular-two.tgs", SHA256: strings.Repeat("e", 64), Data: []byte("regular-two")}},
|
||||
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}},
|
||||
{Name: "Pattern Two", DocumentID: 6, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 6, FileName: "pattern-two.tgs", SHA256: strings.Repeat("f", 64), Data: []byte("pattern-two")}},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "White", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
|
||||
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
|
||||
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
|
||||
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
|
||||
result, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
models := gifts.lastBundle.Collectible.Models
|
||||
if len(models) != 3 || !models[2].Crafted || models[2].RarityKind != domain.StarGiftRarityLegendary || models[2].RarityPermille != 0 ||
|
||||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
|
||||
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
|
||||
}
|
||||
catalog := gifts.lastBundle.Catalog
|
||||
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
|
||||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
|
||||
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
|
||||
}
|
||||
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
|
||||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
|
||||
t.Fatalf("official provenance was not preserved: %+v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
|
|
@ -855,6 +1211,67 @@ func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
|
||||
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
|
||||
document := func(id int64, name string) officialgifts.Document {
|
||||
raw := []byte(lottie)
|
||||
sum := sha256.Sum256(raw)
|
||||
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
|
||||
}
|
||||
permille := 1000
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x24, sha256.Size),
|
||||
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
|
||||
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
|
||||
},
|
||||
BaseDocument: document(1, "gift.json"),
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(2, "model.json")},
|
||||
{Name: "Model Two", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(4, "model-two.json")},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(3, "pattern.json")},
|
||||
{Name: "Pattern Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(5, "pattern-two.json")},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "Backdrop Two", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
|
||||
})
|
||||
req := ImportOfficialStarGiftRequest{
|
||||
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
|
||||
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
|
||||
}
|
||||
result, err := svc.ImportOfficialStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
|
||||
}
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
|
||||
if err != nil || !ok || len(preview.Models) != 2 || len(preview.Patterns) != 2 {
|
||||
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
|
||||
}
|
||||
model := preview.Models[0].Document
|
||||
pattern := preview.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
|
||||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
type adminGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *adminGiftBlob) Name() string { return "localfs" }
|
||||
|
|
@ -868,11 +1285,41 @@ func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
|||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func bytesOf(value byte, count int) []byte {
|
||||
out := make([]byte, count)
|
||||
for i := range out {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
|
||||
|
||||
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
|
||||
if giftID != f.bundle.Gift.ID {
|
||||
return officialgifts.Bundle{}, officialgifts.ErrNotFound
|
||||
}
|
||||
out := f.bundle
|
||||
if !include {
|
||||
out.Collectible = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeGiftsService struct {
|
||||
createCalls int
|
||||
lastBundle domain.StarGiftCatalogBundleWrite
|
||||
}
|
||||
|
||||
func (f *fakeGiftsService) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if id <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
return domain.StarGiftAnimation{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,19 @@ type Service interface {
|
|||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
|
||||
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
|
||||
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
|
||||
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
|
||||
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
|
||||
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
||||
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
|
||||
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
|
||||
SetChannelUsername(ctx context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error)
|
||||
SetChannelColor(ctx context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error)
|
||||
SetChannelEmojiStatus(ctx context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error)
|
||||
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
|
||||
|
|
@ -55,7 +67,9 @@ type Service interface {
|
|||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
||||
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||
GiveGift(ctx context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error)
|
||||
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
}
|
||||
|
|
@ -111,8 +125,20 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
|
||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
|
||||
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
|
||||
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
|
||||
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
||||
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
||||
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
|
||||
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
|
||||
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
|
||||
mux.HandleFunc("POST /v1/channels/set-username", s.authenticated(s.handleSetChannelUsername))
|
||||
mux.HandleFunc("POST /v1/channels/set-color", s.authenticated(s.handleSetChannelColor))
|
||||
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
|
||||
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
|
||||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
|
||||
|
|
@ -135,7 +161,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
||||
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
||||
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
||||
mux.HandleFunc("POST /v1/gifts/give", s.authenticated(s.handleGiveGift))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
|
||||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
return mux
|
||||
|
|
@ -219,6 +247,114 @@ func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserFlags(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserFlagsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserFlags(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelFlags(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelFlagsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelFlags(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetSupport(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetSupportRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetSupport(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserColorRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserColor(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserEmojiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserEmojiStatusRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetUserEmojiStatus(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelSettingsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelSettings(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelColor(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelColorRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelColor(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetChannelEmojiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelEmojiStatusRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelEmojiStatus(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.CreateBotRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.CreateBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.DeleteBotRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.DeleteBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeSessionsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -605,6 +741,15 @@ func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.R
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleGiveGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GiveGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GiveGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
|
|
@ -626,6 +771,27 @@ func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request)
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || documentID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.EmojiAnimation(r.Context(), documentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "emoji animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
|
|
|
|||
|
|
@ -121,11 +121,14 @@ func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
|
|||
func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":1000,"sort_order":0,"file_key":"model-0"}],"patterns":[{"name":"Stars","rarity_permille":1000,"sort_order":0,"file_key":"pattern-0"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":1000,"sort_order":0}]}`
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":500,"sort_order":0,"file_key":"model-0"},{"name":"Sapphire","rarity_permille":500,"sort_order":1,"file_key":"model-1"}],"patterns":[{"name":"Stars","rarity_permille":500,"sort_order":0,"file_key":"pattern-0"},{"name":"Moons","rarity_permille":500,"sort_order":1,"file_key":"pattern-1"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":500,"sort_order":0},{"name":"Day","backdrop_id":2,"center_color":11189196,"edge_color":7833753,"pattern_color":14544639,"text_color":1118481,"rarity_permille":500,"sort_order":1}]}`
|
||||
if err := writer.WriteField("metadata", metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, name := range map[string]string{"model-0": "ruby.lottie", "pattern-0": "stars.tgs"} {
|
||||
for key, name := range map[string]string{
|
||||
"model-0": "ruby.lottie", "model-1": "sapphire.lottie",
|
||||
"pattern-0": "stars.tgs", "pattern-1": "moons.tgs",
|
||||
} {
|
||||
part, err := writer.CreateFormFile(key, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -147,8 +150,8 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
|||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 1 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 1 {
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 2 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 2 || svc.req.Backdrops[1].BackdropID != 2 {
|
||||
t.Fatalf("decoded collectible request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
|
@ -231,6 +234,58 @@ func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVer
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelFlags(_ context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetSupport(_ context.Context, req admin.SetSupportRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GiveGift(_ context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserEmojiStatus(_ context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelSettings(_ context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelUsername(_ context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelColor(_ context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetChannelEmojiStatus(_ context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
|
@ -331,6 +386,10 @@ func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, erro
|
|||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -33,6 +34,10 @@ const (
|
|||
botFatherCmdSetInlineFB = "setinlinefeedback"
|
||||
botFatherCmdSetJoinGroups = "setjoingroups"
|
||||
botFatherCmdSetPrivacy = "setprivacy"
|
||||
botFatherCmdSetLogin = "setlogin"
|
||||
botFatherCmdLoginInfo = "logininfo"
|
||||
botFatherCmdResetLogin = "resetloginsecret"
|
||||
botFatherCmdDone = "done"
|
||||
|
||||
botFatherStepName = "name"
|
||||
botFatherStepUsername = "username"
|
||||
|
|
@ -41,6 +46,8 @@ const (
|
|||
|
||||
botFatherDraftBotID = "bot_id"
|
||||
botFatherDraftBotUsername = "bot_username"
|
||||
|
||||
maxTelegramLoginCommandsPerMessage = 32
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
|
|
@ -60,6 +67,10 @@ You can control me by sending these commands:
|
|||
/setinlinefeedback - change inline feedback settings
|
||||
/setjoingroups - toggle whether a bot can join groups
|
||||
/setprivacy - toggle a bot's group privacy mode
|
||||
/setlogin - configure Telegram Login allowed URLs and signing
|
||||
/logininfo - show a bot's Telegram Login configuration
|
||||
/resetloginsecret - rotate a bot's OIDC Client Secret
|
||||
/done - finish the active Telegram Login configuration
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
|
|
@ -169,12 +180,13 @@ func (s *Service) botReplyRandomID() int64 {
|
|||
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
|
||||
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
|
||||
var botFatherGlobalCommands = map[string]bool{
|
||||
"start": true, "help": true, "cancel": true,
|
||||
"start": true, "help": true, "cancel": true, botFatherCmdDone: true,
|
||||
botFatherCmdNewBot: true, "mybots": true,
|
||||
botFatherCmdToken: true, botFatherCmdRevoke: true,
|
||||
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
|
||||
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
|
||||
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
|
||||
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
|
|
@ -231,6 +243,9 @@ var pickerPrompts = map[string]string{
|
|||
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
|
||||
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
|
||||
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
|
||||
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
|
||||
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
|
||||
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
|
||||
}
|
||||
|
||||
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
|
||||
|
|
@ -277,7 +292,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: botFatherHelpText}
|
||||
case "cancel":
|
||||
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
|
|
@ -289,7 +304,12 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
|
||||
return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
|
||||
}
|
||||
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
|
||||
case botFatherCmdDone:
|
||||
return s.finishTelegramLoginConfiguration(ctx, userID)
|
||||
case botFatherCmdNewBot:
|
||||
count, err := s.bots.CountBotsByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -322,7 +342,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
|
||||
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
|
||||
return s.startBotPicker(ctx, userID, cmd)
|
||||
case botFatherCmdSetInlineFB:
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
|
|
@ -351,6 +372,8 @@ func valuePrompt(cmd, username string) string {
|
|||
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
|
||||
case botFatherCmdSetPrivacy:
|
||||
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
|
||||
case botFatherCmdSetLogin:
|
||||
return telegramLoginConfigurationPrompt(username)
|
||||
default:
|
||||
return "Send the new value, or /cancel."
|
||||
}
|
||||
|
|
@ -445,6 +468,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
|
|||
}
|
||||
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdLoginInfo:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
|
||||
}
|
||||
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
|
||||
case botFatherCmdResetLogin:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
|
||||
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
|
||||
case botFatherCmdSetLogin:
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
state.Step = botFatherStepValue
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
|
||||
state.Draft[botFatherDraftBotUsername] = chosen.Username
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
prompt := telegramLoginConfigurationPrompt(chosen.Username)
|
||||
if !created {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
|
||||
}
|
||||
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
|
||||
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
|
||||
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
|
|
@ -507,6 +585,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
case botFatherCmdSetLogin:
|
||||
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
|
|
@ -583,6 +663,266 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
|
|||
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationPrompt(username string) string {
|
||||
return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
|
||||
|
||||
add origin https://example.com
|
||||
add redirect https://example.com/auth/callback
|
||||
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
|
||||
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
|
||||
remove origin https://example.com
|
||||
remove redirect https://example.com/auth/callback
|
||||
remove app 12
|
||||
algorithm RS256|ES256|EdDSA|ES256K
|
||||
enable
|
||||
disable
|
||||
|
||||
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationContinuePrompt(username string) string {
|
||||
return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
|
||||
}
|
||||
|
||||
func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
|
||||
return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
|
||||
}
|
||||
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
|
||||
username := state.Draft[botFatherDraftBotUsername]
|
||||
if botID == 0 || username == "" {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
|
||||
}
|
||||
owns, err := s.OwnsBot(ctx, userID, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !owns {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "That bot is no longer available."}
|
||||
}
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !configured {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
|
||||
}
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
|
||||
s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
|
||||
}
|
||||
|
||||
func (s *Service) handleTelegramLoginConfigurationInput(
|
||||
ctx context.Context,
|
||||
state domain.BotChatState,
|
||||
botID int64,
|
||||
username string,
|
||||
text string,
|
||||
) botReply {
|
||||
if strings.EqualFold(strings.TrimSpace(text), "done") {
|
||||
return s.finishTelegramLoginConfiguration(ctx, state.UserID)
|
||||
}
|
||||
lines := make([]string, 0, 4)
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if line := strings.TrimSpace(raw); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
if len(lines) > maxTelegramLoginCommandsPerMessage {
|
||||
return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
|
||||
}
|
||||
|
||||
applied := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
|
||||
if err != nil {
|
||||
if len(lines) == 1 {
|
||||
if reply.Text == "" {
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
failure := reply.Text
|
||||
if failure == "" {
|
||||
failure = "Something went wrong on my side. Please try that line again later."
|
||||
}
|
||||
var out strings.Builder
|
||||
if len(applied) > 0 {
|
||||
fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
|
||||
if i+1 < len(lines) {
|
||||
fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
|
||||
}
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
if len(lines) == 1 {
|
||||
out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
|
||||
} else {
|
||||
fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
|
||||
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
|
||||
status := "disabled"
|
||||
if configuration.Client.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
|
||||
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
|
||||
if len(configuration.AllowedURLs) == 0 {
|
||||
out.WriteString("\nAllowed URLs: none")
|
||||
} else {
|
||||
out.WriteString("\nAllowed URLs:")
|
||||
for _, allowed := range configuration.AllowedURLs {
|
||||
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
|
||||
}
|
||||
}
|
||||
if len(configuration.NativeApps) > 0 {
|
||||
out.WriteString("\nNative apps:")
|
||||
for _, app := range configuration.NativeApps {
|
||||
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "origin":
|
||||
return domain.TelegramLoginAllowedWebOrigin, true
|
||||
case "redirect":
|
||||
return domain.TelegramLoginAllowedRedirectURI, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "RS256":
|
||||
return domain.TelegramLoginSigningRS256, true
|
||||
case "ES256":
|
||||
return domain.TelegramLoginSigningES256, true
|
||||
case "EDDSA":
|
||||
return domain.TelegramLoginSigningEdDSA, true
|
||||
case "ES256K":
|
||||
return domain.TelegramLoginSigningES256K, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(text))
|
||||
if len(fields) == 1 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "enable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
|
||||
case "disable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
|
||||
}
|
||||
}
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
|
||||
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
|
||||
}
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
|
||||
}
|
||||
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
|
||||
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
|
||||
kind, ok := telegramLoginAllowedURLKind(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(fields[0], "add") {
|
||||
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
|
||||
}
|
||||
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
|
||||
platform := domain.TelegramLoginNativeIOS
|
||||
if strings.EqualFold(fields[1], "android") {
|
||||
platform = domain.TelegramLoginNativeAndroid
|
||||
}
|
||||
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
|
||||
if err != nil {
|
||||
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
|
||||
}
|
||||
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
|
||||
appID, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil || appID <= 0 {
|
||||
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
|
||||
if err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
|
||||
}
|
||||
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
167
internal/app/bots/botfather_login_test.go
Normal file
167
internal/app/bots/botfather_login_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
|
||||
t.Helper()
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 1
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 2
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
|
||||
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1090")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") {
|
||||
t.Fatalf("/setlogin reply = %q", reply)
|
||||
}
|
||||
created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot")
|
||||
if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") {
|
||||
t.Fatalf("create login reply = %q", created)
|
||||
}
|
||||
secretMarker := "only be shown once:\n"
|
||||
secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0]
|
||||
if len(secret) < 32 {
|
||||
t.Fatalf("client secret is unexpectedly short: %q", secret)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add origin reply = %q", reply)
|
||||
}
|
||||
state, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID)
|
||||
if err != nil || !found || state.Step != botFatherStepValue || state.Draft[botFatherDraftBotID] != strconv.FormatInt(bot.ID, 10) {
|
||||
t.Fatalf("state after first command = %+v, found=%v err=%v", state, found, err)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add redirect reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
|
||||
t.Fatalf("algorithm reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add iOS app reply = %q", reply)
|
||||
}
|
||||
fingerprint := strings.Repeat("A", 64)
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add Android app reply = %q", reply)
|
||||
}
|
||||
done := sendToBotFather(t, svc, messages, owner, "/done")
|
||||
if !strings.Contains(done, "Finished configuring") || !strings.Contains(done, "Signing algorithm: ES256") {
|
||||
t.Fatalf("/done reply = %q", done)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /done: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/logininfo")
|
||||
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
|
||||
if !strings.Contains(info, want) {
|
||||
t.Fatalf("login info = %q, missing %q", info, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(info, secret) {
|
||||
t.Fatal("/logininfo leaked the one-time client secret")
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/resetloginsecret")
|
||||
rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) {
|
||||
t.Fatalf("rotate reply = %q", rotated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginBatchAndCancelFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1091")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Batch Login Demo", "batch_login_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/done"); !strings.Contains(reply, "no active") {
|
||||
t.Fatalf("inactive /done reply = %q", reply)
|
||||
}
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "@batch_login_bot")
|
||||
tooMany := strings.TrimSuffix(strings.Repeat("enable\n", maxTelegramLoginCommandsPerMessage+1), "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, tooMany); !strings.Contains(reply, "at most 32 lines") {
|
||||
t.Fatalf("oversized batch reply = %q", reply)
|
||||
}
|
||||
oversizedConfiguration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(oversizedConfiguration.AllowedURLs) != 0 || oversizedConfiguration.Client.SigningAlgorithm != "RS256" {
|
||||
t.Fatalf("configuration after oversized batch = %+v, found=%v err=%v", oversizedConfiguration, found, err)
|
||||
}
|
||||
batch := strings.Join([]string{
|
||||
"add origin http://batch.example.test:3000",
|
||||
"add redirect http://batch.example.test:3000/auth/telegram/callback",
|
||||
"algorithm ES256",
|
||||
"enable",
|
||||
}, "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, batch); !strings.Contains(reply, "Applied all 4 commands") || !strings.Contains(reply, "/done") {
|
||||
t.Fatalf("batch reply = %q", reply)
|
||||
}
|
||||
configuration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || configuration.Client.SigningAlgorithm != "ES256" || len(configuration.AllowedURLs) != 2 {
|
||||
t.Fatalf("configuration after batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
|
||||
partial := strings.Join([]string{
|
||||
"add origin http://second.example.test:3001",
|
||||
"add redirect not-a-url",
|
||||
"disable",
|
||||
}, "\n")
|
||||
partialReply := sendToBotFather(t, svc, messages, owner, partial)
|
||||
for _, want := range []string{"Applied 1 command(s) before the error", "Stopped at line 2", "1 later command(s) were not applied", "/done"} {
|
||||
if !strings.Contains(partialReply, want) {
|
||||
t.Fatalf("partial batch reply = %q, missing %q", partialReply, want)
|
||||
}
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after partial batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || !found {
|
||||
t.Fatalf("state after partial batch: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "already applied have been kept") {
|
||||
t.Fatalf("/cancel reply = %q", reply)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /cancel: found=%v err=%v", found, err)
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after /cancel = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package bots
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
revocationErr := errors.New("authorization store unavailable")
|
||||
rev := &captureRevoker{err: revocationErr}
|
||||
svc := NewService(users, botStore, messages)
|
||||
svc.SetRouterHooks(rev)
|
||||
owner := newOwner(t, users, "+2099")
|
||||
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
|
||||
|
||||
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
|
||||
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
|
||||
}
|
||||
if botStore.deleteCalls != 0 {
|
||||
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
|
||||
}
|
||||
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
|
||||
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
rev.err = nil
|
||||
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteBot after revocation recovery: %v", err)
|
||||
}
|
||||
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
|
||||
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotWriteAccessGrant(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2012")
|
||||
|
|
@ -400,11 +433,12 @@ type captureRevoker struct {
|
|||
botUserID int64
|
||||
pushedCommandsTo int64
|
||||
pushedCommands []domain.BotCommand
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
|
||||
c.botUserID = botUserID
|
||||
return nil
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -82,6 +83,7 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
|
|
@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
return func(s *Service) {
|
||||
if login != nil {
|
||||
s.telegramLogin = login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
|
||||
func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -436,6 +448,45 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// botAccountDeleter is the optional store capability used to permanently delete
|
||||
// a user-created bot. Only the Postgres store implements it, so the memory store
|
||||
// and other BotStore mocks are unaffected.
|
||||
type botAccountDeleter interface {
|
||||
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
|
||||
}
|
||||
|
||||
// DeleteBot permanently removes a user-created bot. System service bots are
|
||||
// rejected. Live sessions are dropped and the bot's caches are invalidated so
|
||||
// the deletion is visible immediately. Returns the tombstoned user.
|
||||
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
if domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
deleter, ok := s.bots.(botAccountDeleter)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
|
||||
}
|
||||
// Session revocation is part of the deletion invariant: a deleted bot must
|
||||
// never retain an authenticated connection. Fail closed before tombstoning
|
||||
// when the hook is unavailable or revocation fails.
|
||||
if s.hooks == nil {
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
|
||||
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
u, err := deleter.DeleteBotAccount(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ type countingBotStore struct {
|
|||
*memory.BotStore
|
||||
getBotCalls int
|
||||
getBotsCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotStore) reset() {
|
||||
|
|
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
|
|||
return s.BotStore.GetBots(ctx, botUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
s.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
func TestBotFatherCancelAndUnknown(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+1001")
|
||||
|
|
|
|||
|
|
@ -754,6 +754,16 @@ func normalizeStickersBotShortName(raw string) string {
|
|||
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
||||
if strings.Contains(raw, "://") {
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
query := parsed.Query()
|
||||
route := strings.Trim(parsed.Path, "/")
|
||||
if route == "" {
|
||||
route = strings.ToLower(parsed.Host)
|
||||
}
|
||||
if route == "addstickers" || route == "addemoji" {
|
||||
if shortName := query.Get("set"); shortName != "" {
|
||||
raw = shortName
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
|
||||
|
|
|
|||
|
|
@ -649,3 +649,19 @@ func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, user
|
|||
h.userID = userID
|
||||
h.kind = kind
|
||||
}
|
||||
|
||||
func TestNormalizeStickersBotShortNameAcceptsHostBasedAppLinks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "telesrv://addstickers?set=Legacy_Pack", want: "legacy_pack"},
|
||||
{raw: "owpg://tenant.example.test/addstickers?set=Hosted_Pack", want: "hosted_pack"},
|
||||
{raw: "owpg://tenant.example.test/addemoji?set=Emoji_Pack", want: "emoji_pack"},
|
||||
{raw: "https://telesrv.net/addstickers/Web_Pack", want: "web_pack"},
|
||||
} {
|
||||
if got := normalizeStickersBotShortName(tc.raw); got != tc.want {
|
||||
t.Fatalf("normalizeStickersBotShortName(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,49 @@ func (s *Service) SetVerified(ctx context.Context, channelID int64, verified boo
|
|||
return s.channels.SetChannelVerified(ctx, channelID, verified)
|
||||
}
|
||||
|
||||
// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
|
||||
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
|
||||
}
|
||||
|
||||
// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
|
||||
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
|
||||
}
|
||||
|
||||
// AdminSetUsername force-sets or clears a channel username through the admin path.
|
||||
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
|
||||
}
|
||||
|
||||
// AdminSetColor force-sets a channel name/profile color through the admin path.
|
||||
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
|
||||
}
|
||||
|
||||
// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
|
||||
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
|
||||
}
|
||||
|
||||
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
||||
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
|
|||
34
internal/app/channels/service_suggested_post.go
Normal file
34
internal/app/channels/service_suggested_post.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type suggestedPostStore interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil || req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ToggleSuggestedPostApproval(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ProcessSuggestedPostLifecycle(ctx, req)
|
||||
}
|
||||
|
|
@ -132,5 +132,8 @@ func cloneUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Service struct {
|
|||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -49,6 +50,10 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -84,6 +89,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ func cloneDialogMessages(in []domain.Message) []domain.Message {
|
|||
|
||||
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -424,6 +425,7 @@ func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMess
|
|||
|
||||
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -500,6 +502,9 @@ func cloneDialogUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -54,6 +55,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -93,6 +98,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -946,6 +952,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
79
internal/app/files/emoji_animation.go
Normal file
79
internal/app/files/emoji_animation.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxEmojiAnimationBytes = 2 << 20
|
||||
|
||||
// DocumentAnimationJSON returns the Lottie JSON for an animated custom-emoji
|
||||
// document, decompressing TGS (gzip) transparently. Non-emoji documents and
|
||||
// documents without a stored blob return found=false. It backs the admin emoji
|
||||
// browser preview and reuses the existing file-blob storage (doc:<id> key).
|
||||
func (s *Service) DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error) {
|
||||
if s == nil || s.media == nil || s.blobs == nil || documentID <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
doc, found, err := s.GetDocument(ctx, documentID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !found || !documentIsCustomEmoji(doc) {
|
||||
return nil, false, nil
|
||||
}
|
||||
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", documentID))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !found || blob.Size <= 0 || blob.Size > maxEmojiAnimationBytes {
|
||||
return nil, false, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if int64(len(data)) != total {
|
||||
return nil, false, nil
|
||||
}
|
||||
out, err := gunzipIfNeeded(data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func documentIsCustomEmoji(doc domain.Document) bool {
|
||||
for _, a := range doc.Attributes {
|
||||
if a.Kind == domain.DocAttrCustomEmoji {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gunzipIfNeeded transparently decompresses TGS (gzip-wrapped Lottie); raw JSON
|
||||
// (non-gzip) is returned unchanged.
|
||||
func gunzipIfNeeded(data []byte) ([]byte, error) {
|
||||
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
|
||||
return data, nil
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tgs gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
out, err := io.ReadAll(io.LimitReader(gz, maxEmojiAnimationBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress tgs: %w", err)
|
||||
}
|
||||
if len(out) > maxEmojiAnimationBytes {
|
||||
return nil, fmt.Errorf("decompressed tgs too large")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ const tdesktopClient = "tdesktop"
|
|||
//
|
||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||
// so this compatibility key must always remain an array, even when it is empty.
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
|
||||
const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"boosts_channel_level_max": 100,
|
||||
"stargifts_pinned_to_top_limit": 6,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"dialogs_pinned_limit_default": 5,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
|
|
@ -57,6 +58,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
|
|
@ -85,6 +90,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,18 +132,18 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000, Animation: &animation,
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000, Animation: &animation,
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000,
|
||||
}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA,
|
||||
},
|
||||
|
|
@ -151,7 +151,7 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
if err != nil {
|
||||
t.Fatalf("create official collectible bundle: %v", err)
|
||||
}
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 1 || len(result.Collectible.Patterns) != 1 {
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 2 || len(result.Collectible.Patterns) != 2 {
|
||||
t.Fatalf("collectible result = %+v", result.Collectible)
|
||||
}
|
||||
model := result.Collectible.Models[0].Document
|
||||
|
|
|
|||
|
|
@ -517,6 +517,18 @@ func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey s
|
|||
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
|
||||
}
|
||||
|
||||
// GrantUnique atomically assigns a freshly minted collectible to a user.
|
||||
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
|
||||
|
|
|
|||
131
internal/app/telegramlogin/crypto.go
Normal file
131
internal/app/telegramlogin/crypto.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const opaqueTokenBytes = 32
|
||||
|
||||
func GenerateOpaqueToken() (string, error) {
|
||||
raw := make([]byte, opaqueTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate opaque token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func HashOpaqueToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func HashClientSecret(pepper []byte, secret string) ([]byte, error) {
|
||||
if len(pepper) < 32 || secret == "" {
|
||||
return nil, domain.ErrTelegramLoginSecretInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, pepper)
|
||||
_, _ = mac.Write([]byte(secret))
|
||||
return mac.Sum(nil), nil
|
||||
}
|
||||
|
||||
func VerifyClientSecret(pepper []byte, secret string, expected []byte) bool {
|
||||
actual, err := HashClientSecret(pepper, secret)
|
||||
if err != nil || len(expected) != sha256.Size {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func PKCEChallenge(verifier string) (string, error) {
|
||||
if len(verifier) < 43 || len(verifier) > 128 {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(verifier); i++ {
|
||||
c := verifier[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func ValidatePKCEChallenge(challenge, method string) error {
|
||||
if method != "S256" || len(challenge) < 43 || len(challenge) > 128 {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(challenge); i++ {
|
||||
c := challenge[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CodeSealer struct {
|
||||
activeKeyID string
|
||||
keys map[string]cipher.AEAD
|
||||
}
|
||||
|
||||
func NewCodeSealer(activeKeyID string, rawKeys map[string][]byte) (*CodeSealer, error) {
|
||||
if activeKeyID == "" || len(rawKeys) == 0 {
|
||||
return nil, errors.New("telegram login code seal key ring is empty")
|
||||
}
|
||||
keys := make(map[string]cipher.AEAD, len(rawKeys))
|
||||
for keyID, raw := range rawKeys {
|
||||
if keyID == "" || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("invalid telegram login code seal key %q", keyID)
|
||||
}
|
||||
block, err := aes.NewCipher(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code seal key %q: %w", keyID, err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code sealer %q: %w", keyID, err)
|
||||
}
|
||||
keys[keyID] = aead
|
||||
}
|
||||
if _, ok := keys[activeKeyID]; !ok {
|
||||
return nil, fmt.Errorf("active telegram login code seal key %q not found", activeKeyID)
|
||||
}
|
||||
return &CodeSealer{activeKeyID: activeKeyID, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Seal(plaintext string, aad []byte) (sealed, nonce []byte, keyID string, err error) {
|
||||
if s == nil || plaintext == "" {
|
||||
return nil, nil, "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead := s.keys[s.activeKeyID]
|
||||
nonce = make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("generate telegram login code nonce: %w", err)
|
||||
}
|
||||
return aead.Seal(nil, nonce, []byte(plaintext), aad), nonce, s.activeKeyID, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Open(sealed, nonce []byte, keyID string, aad []byte) (string, error) {
|
||||
if s == nil {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead, ok := s.keys[keyID]
|
||||
if !ok || len(nonce) != aead.NonceSize() {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
plaintext, err := aead.Open(nil, nonce, sealed, aad)
|
||||
if err != nil || len(plaintext) == 0 {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
407
internal/app/telegramlogin/jose.go
Normal file
407
internal/app/telegramlogin/jose.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const defaultIDTokenTTL = time.Hour
|
||||
|
||||
type SigningKeyMaterial struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm
|
||||
KeyID string
|
||||
PrivateKey any
|
||||
Active bool
|
||||
PublishUntil time.Time
|
||||
}
|
||||
|
||||
type signingKey struct {
|
||||
algorithm domain.TelegramLoginSigningAlgorithm
|
||||
jwaAlgorithm jwa.SignatureAlgorithm
|
||||
keyID string
|
||||
private jwk.Key
|
||||
public jwk.Key
|
||||
active bool
|
||||
publishUntil time.Time
|
||||
}
|
||||
|
||||
// SigningKeyRing owns no mutable crypto state. Rotation is performed by
|
||||
// constructing a new ring containing the new active key and old public keys
|
||||
// with a PublishUntil at least as long as the maximum ID-token lifetime.
|
||||
type SigningKeyRing struct {
|
||||
keys []signingKey
|
||||
active map[domain.TelegramLoginSigningAlgorithm]signingKey
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) {
|
||||
if len(materials) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring is empty")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
ring := &SigningKeyRing{
|
||||
keys: make([]signingKey, 0, len(materials)),
|
||||
active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey),
|
||||
now: now,
|
||||
}
|
||||
seenKeyIDs := make(map[string]struct{}, len(materials))
|
||||
for _, material := range materials {
|
||||
key, err := importSigningKey(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, duplicate := seenKeyIDs[key.keyID]; duplicate {
|
||||
return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID)
|
||||
}
|
||||
seenKeyIDs[key.keyID] = struct{}{}
|
||||
if key.active {
|
||||
if _, duplicate := ring.active[key.algorithm]; duplicate {
|
||||
return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm)
|
||||
}
|
||||
ring.active[key.algorithm] = key
|
||||
}
|
||||
ring.keys = append(ring.keys, key)
|
||||
}
|
||||
if len(ring.active) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring has no active key")
|
||||
}
|
||||
return ring, nil
|
||||
}
|
||||
|
||||
func importSigningKey(material SigningKeyMaterial) (signingKey, error) {
|
||||
if !material.Algorithm.Valid() || material.PrivateKey == nil {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing key material")
|
||||
}
|
||||
if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled {
|
||||
return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k")
|
||||
}
|
||||
if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
privateKey, err := jwk.Import(material.PrivateKey)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err)
|
||||
}
|
||||
if err := privateKey.Validate(); err != nil {
|
||||
return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
publicKey, err := privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
thumbprint, err := publicKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
keyID := strings.TrimSpace(material.KeyID)
|
||||
if keyID == "" {
|
||||
keyID = base64.RawURLEncoding.EncodeToString(thumbprint)
|
||||
}
|
||||
if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing kid")
|
||||
}
|
||||
jwaAlgorithm, err := telegramLoginJWA(material.Algorithm)
|
||||
if err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
for _, key := range []jwk.Key{privateKey, publicKey} {
|
||||
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err)
|
||||
}
|
||||
}
|
||||
return signingKey{
|
||||
algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID,
|
||||
private: privateKey, public: publicKey, active: material.Active,
|
||||
publishUntil: material.PublishUntil.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
key, ok := rsaPrivateKey(raw)
|
||||
if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
|
||||
return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits")
|
||||
}
|
||||
if err := key.Validate(); err != nil {
|
||||
return fmt.Errorf("validate telegram login RSA private key: %w", err)
|
||||
}
|
||||
case domain.TelegramLoginSigningES256:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256 requires a P-256 ECDSA private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
key, ok := raw.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return errors.New("telegram login EdDSA requires an Ed25519 private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve == nil || key.Curve.Params() == nil ||
|
||||
!strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key")
|
||||
}
|
||||
default:
|
||||
return domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case rsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case ecdsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
return jwa.RS256(), nil
|
||||
case domain.TelegramLoginSigningES256:
|
||||
return jwa.ES256(), nil
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
return jwa.EdDSA(), nil
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
if telegramLoginES256KEnabled {
|
||||
return jwa.ES256K(), nil
|
||||
}
|
||||
return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build")
|
||||
default:
|
||||
return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) SupportedAlgorithms() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]string, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, string(algorithm))
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// ActiveAlgorithms returns the algorithms that can sign new tokens on this
|
||||
// instance. Callers use it to prevent durable client configuration from
|
||||
// selecting an algorithm without an active private key.
|
||||
func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, algorithm)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) JWKS() ([]byte, string, error) {
|
||||
if r == nil {
|
||||
return nil, "", errors.New("telegram login signing key ring is nil")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
set := jwk.NewSet()
|
||||
for _, key := range r.keys {
|
||||
if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) {
|
||||
continue
|
||||
}
|
||||
clone, err := key.public.Clone()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err)
|
||||
}
|
||||
if err := set.AddKey(clone); err != nil {
|
||||
return nil, "", fmt.Errorf("add telegram login public JWK: %w", err)
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(set)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) {
|
||||
if r == nil || token == nil {
|
||||
return "", errors.New("telegram login ID token signer is unavailable")
|
||||
}
|
||||
key, ok := r.active[algorithm]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no active telegram login signing key for %s", algorithm)
|
||||
}
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err)
|
||||
}
|
||||
return string(signed), nil
|
||||
}
|
||||
|
||||
type IDTokenIssuerConfig struct {
|
||||
Issuer string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
AllowHTTP bool
|
||||
}
|
||||
|
||||
type IDTokenIssuer struct {
|
||||
issuer string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
keys *SigningKeyRing
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issuer() string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return i.issuer
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) TTL() time.Duration {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return i.ttl
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) SupportedAlgorithms() []string {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return i.keys.SupportedAlgorithms()
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) JWKS() ([]byte, string, error) {
|
||||
if i == nil {
|
||||
return nil, "", errors.New("telegram login ID token issuer is nil")
|
||||
}
|
||||
return i.keys.JWKS()
|
||||
}
|
||||
|
||||
func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) {
|
||||
if keys == nil {
|
||||
return nil, errors.New("telegram login signing key ring is required")
|
||||
}
|
||||
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
|
||||
}
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = defaultIDTokenTTL
|
||||
}
|
||||
if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour {
|
||||
return nil, errors.New("telegram login ID token TTL is outside the bounded range")
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) {
|
||||
if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 ||
|
||||
request.ClientID == "" || request.ApprovedAt.IsZero() {
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName,
|
||||
FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername,
|
||||
Picture: request.Picture, PhoneNumber: request.PhoneNumber,
|
||||
}
|
||||
identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := i.now().UTC()
|
||||
builder := jwt.NewBuilder().
|
||||
Issuer(i.issuer).
|
||||
Audience([]string{request.ClientID}).
|
||||
Subject(fmt.Sprintf("%d", identity.UserID)).
|
||||
IssuedAt(now).
|
||||
Expiration(now.Add(i.ttl))
|
||||
if request.Nonce != "" {
|
||||
builder.Claim("nonce", request.Nonce)
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopeProfile) {
|
||||
builder.Claim("id", identity.UserID).
|
||||
Claim("name", identity.Name).
|
||||
Claim("given_name", identity.GivenName)
|
||||
if identity.FamilyName != "" {
|
||||
builder.Claim("family_name", identity.FamilyName)
|
||||
}
|
||||
if identity.PreferredUsername != "" {
|
||||
builder.Claim("preferred_username", identity.PreferredUsername)
|
||||
}
|
||||
if identity.Picture != "" {
|
||||
builder.Claim("picture", identity.Picture)
|
||||
}
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared {
|
||||
builder.Claim("phone_number", identity.PhoneNumber).
|
||||
Claim("phone_number_verified", true)
|
||||
}
|
||||
token, err := builder.Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build telegram login ID token: %w", err)
|
||||
}
|
||||
return i.keys.sign(request.SigningAlgorithm, token)
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = false
|
||||
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KFailsClosedWithoutBuildTag(t *testing.T) {
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K, PrivateKey: struct{}{}, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("ES256K configuration unexpectedly accepted without jwx_es256k")
|
||||
}
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = true
|
||||
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KIDTokenRoundTripWithBuildTag(t *testing.T) {
|
||||
raw, err := secp256k1.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K,
|
||||
KeyID: "secp256k1-active",
|
||||
PrivateKey: raw.ToECDSA(),
|
||||
Active: true,
|
||||
}}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed, err := issuer.Issue(domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningES256K,
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID},
|
||||
Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ApprovedAt: now.Add(-time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)); err != nil {
|
||||
t.Fatalf("verify ES256K token: %v", err)
|
||||
}
|
||||
}
|
||||
203
internal/app/telegramlogin/jose_test.go
Normal file
203
internal/app/telegramlogin/jose_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginTestSigningKeys(t *testing.T, now *time.Time) *SigningKeyRing {
|
||||
t.Helper()
|
||||
oldRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activeRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
es256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, ed25519Key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-old", PrivateKey: oldRSA, PublishUntil: now.Add(2 * time.Hour)},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-active", PrivateKey: activeRSA, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningES256, KeyID: "p256-active", PrivateKey: es256, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningEdDSA, KeyID: "ed25519-active", PrivateKey: ed25519Key, Active: true},
|
||||
}, func() time.Time { return *now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ring
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRotationAndAlgorithms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
|
||||
t.Fatalf("SupportedAlgorithms = %#v", got)
|
||||
}
|
||||
body, etag, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse JWKS: %v", err)
|
||||
}
|
||||
if set.Len() != 4 || etag == "" {
|
||||
t.Fatalf("JWKS len=%d etag=%q body=%s", set.Len(), etag, body)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < set.Len(); i++ {
|
||||
key, _ := set.Key(i)
|
||||
if key.Has("d") || key.Has("p") || key.Has("q") {
|
||||
t.Fatalf("JWKS leaked private key material: %s", body)
|
||||
}
|
||||
}
|
||||
now = now.Add(3 * time.Hour)
|
||||
body, _, err = ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err = jwk.Parse(body)
|
||||
if err != nil || set.Len() != 3 {
|
||||
t.Fatalf("JWKS after retirement len=%d err=%v body=%s", set.Len(), err, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profileRequest := domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Scopes: []domain.TelegramLoginScope{
|
||||
domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone,
|
||||
},
|
||||
Nonce: "request-nonce", Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ProfileName: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
PhoneNumber: "15551234567", PhoneShared: true, ApprovedAt: now.Add(-time.Minute),
|
||||
}
|
||||
signed, err := issuer.Issue(profileRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwksBody, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(jwksBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify signed token: %v", err)
|
||||
}
|
||||
issuerValue, _ := token.Issuer()
|
||||
subject, _ := token.Subject()
|
||||
audience, _ := token.Audience()
|
||||
if issuerValue != "https://oauth.telesrv.test" || subject != "42" || len(audience) != 1 || audience[0] != "9001" {
|
||||
t.Fatalf("standard claims iss=%q sub=%q aud=%#v", issuerValue, subject, audience)
|
||||
}
|
||||
var id float64
|
||||
var name, phone, nonce string
|
||||
var verified bool
|
||||
if err := token.Get("id", &id); err != nil || id != 42 {
|
||||
t.Fatalf("id claim=%v err=%v", id, err)
|
||||
}
|
||||
if err := token.Get("name", &name); err != nil || name != "Alice Example" {
|
||||
t.Fatalf("name claim=%q err=%v", name, err)
|
||||
}
|
||||
if err := token.Get("phone_number", &phone); err != nil || phone != "15551234567" {
|
||||
t.Fatalf("phone claim=%q err=%v", phone, err)
|
||||
}
|
||||
if err := token.Get("phone_number_verified", &verified); err != nil || !verified {
|
||||
t.Fatalf("phone verified=%v err=%v", verified, err)
|
||||
}
|
||||
if err := token.Get("nonce", &nonce); err != nil || nonce != "request-nonce" {
|
||||
t.Fatalf("nonce=%q err=%v", nonce, err)
|
||||
}
|
||||
|
||||
openidOnly := profileRequest
|
||||
openidOnly.SigningAlgorithm = domain.TelegramLoginSigningEdDSA
|
||||
openidOnly.Scopes = []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID}
|
||||
openidOnly.ProfileName = ""
|
||||
openidOnly.GivenName = ""
|
||||
openidOnly.FamilyName = ""
|
||||
openidOnly.PreferredUsername = ""
|
||||
openidOnly.Picture = ""
|
||||
openidOnly.PhoneNumber = ""
|
||||
openidOnly.PhoneShared = false
|
||||
signed, err = issuer.Issue(openidOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err = jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify EdDSA token: %v", err)
|
||||
}
|
||||
if token.Has("id") || token.Has("name") || token.Has("phone_number") {
|
||||
t.Fatalf("openid-only token leaked optional claims: %#v", token.Keys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) {
|
||||
now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil {
|
||||
t.Fatal("HTTP issuer was accepted while AllowHTTP was false")
|
||||
}
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issuer.Issuer() != "http://192.0.2.25:2401" {
|
||||
t.Fatalf("issuer=%q", issuer.Issuer())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
|
||||
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256, PrivateKey: p384, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("P-384 key unexpectedly accepted for ES256")
|
||||
}
|
||||
key1, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key2, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key1, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key2, Active: true},
|
||||
}, nil); err == nil {
|
||||
t.Fatal("two active RS256 keys unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
186
internal/app/telegramlogin/keyfiles.go
Normal file
186
internal/app/telegramlogin/keyfiles.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTelegramLoginManifestBytes = 1 << 20
|
||||
maxTelegramLoginKeyBytes = 256 << 10
|
||||
)
|
||||
|
||||
type signingKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Keys []signingKeyManifestEntry `json:"keys"`
|
||||
}
|
||||
|
||||
type signingKeyManifestEntry struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
|
||||
KeyID string `json:"kid,omitempty"`
|
||||
PrivateKeyFile string `json:"private_key_file"`
|
||||
Active bool `json:"active"`
|
||||
PublishUntil string `json:"publish_until,omitempty"`
|
||||
}
|
||||
|
||||
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
|
||||
// Relative key paths are resolved against the manifest directory. The caller
|
||||
// should atomically replace files and rebuild/swap the ring when rotating.
|
||||
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
|
||||
var manifest signingKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
|
||||
return nil, errors.New("telegram login signing manifest has invalid version or key count")
|
||||
}
|
||||
baseDir := filepath.Dir(path)
|
||||
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
|
||||
for index, entry := range manifest.Keys {
|
||||
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
|
||||
if !entry.Algorithm.Valid() || keyPath == "" {
|
||||
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
|
||||
}
|
||||
if !filepath.IsAbs(keyPath) {
|
||||
keyPath = filepath.Join(baseDir, keyPath)
|
||||
}
|
||||
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var parsed jwk.Key
|
||||
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
|
||||
parsed, err = jwk.ParseKey(data)
|
||||
} else {
|
||||
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var raw any
|
||||
if err := jwk.Export(parsed, &raw); err != nil {
|
||||
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var publishUntil time.Time
|
||||
if entry.PublishUntil != "" {
|
||||
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
|
||||
}
|
||||
}
|
||||
if entry.Active && !publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
|
||||
}
|
||||
if !entry.Active && publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
|
||||
}
|
||||
materials = append(materials, SigningKeyMaterial{
|
||||
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
|
||||
Active: entry.Active, PublishUntil: publishUntil,
|
||||
})
|
||||
}
|
||||
return NewSigningKeyRing(materials, now)
|
||||
}
|
||||
|
||||
type codeKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadCodeSealer(path string) (*CodeSealer, error) {
|
||||
var manifest codeKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
|
||||
}
|
||||
keys := make(map[string][]byte, len(manifest.Keys))
|
||||
for keyID, encoded := range manifest.Keys {
|
||||
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid key id")
|
||||
}
|
||||
raw, err := decodeBase64Key(encoded)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
|
||||
}
|
||||
keys[keyID] = raw
|
||||
}
|
||||
return NewCodeSealer(manifest.Active, keys)
|
||||
}
|
||||
|
||||
func LoadClientSecretPepper(path string) ([]byte, error) {
|
||||
data, err := readBoundedFile(path, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
|
||||
}
|
||||
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeBase64Key(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
|
||||
} {
|
||||
if raw, err := encoding.DecodeString(value); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid base64")
|
||||
}
|
||||
|
||||
func readStrictJSONFile(path string, maxBytes int64, target any) error {
|
||||
data, err := readBoundedFile(path, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
||||
return nil, errors.New("invalid file path or size bound")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > maxBytes {
|
||||
return nil, errors.New("file is not regular or exceeds size bound")
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
}
|
||||
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSigningKeyRingAndSymmetricKeyFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
|
||||
if err := os.WriteFile(filepath.Join(dir, "rsa.pem"), pemBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"version": 1,
|
||||
"keys": []map[string]any{{
|
||||
"algorithm": "RS256", "kid": "rsa-test", "private_key_file": "rsa.pem", "active": true,
|
||||
}},
|
||||
}
|
||||
manifestBytes, _ := json.Marshal(manifest)
|
||||
manifestPath := filepath.Join(dir, "signing.json")
|
||||
if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := LoadSigningKeyRing(manifestPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 1 || got[0] != "RS256" {
|
||||
t.Fatalf("algorithms=%#v", got)
|
||||
}
|
||||
|
||||
codeKey := make([]byte, 32)
|
||||
pepper := make([]byte, 32)
|
||||
_, _ = rand.Read(codeKey)
|
||||
_, _ = rand.Read(pepper)
|
||||
codeManifest, _ := json.Marshal(map[string]any{
|
||||
"version": 1, "active": "2026-07", "keys": map[string]string{"2026-07": base64.RawURLEncoding.EncodeToString(codeKey)},
|
||||
})
|
||||
codePath := filepath.Join(dir, "code-keys.json")
|
||||
pepperPath := filepath.Join(dir, "pepper")
|
||||
if err := os.WriteFile(codePath, codeManifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pepperPath, []byte(base64.RawURLEncoding.EncodeToString(pepper)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealer, err := LoadCodeSealer(codePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, kid, err := sealer.Seal("code", []byte("aad"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened, err := sealer.Open(sealed, nonce, kid, []byte("aad")); err != nil || opened != "code" {
|
||||
t.Fatalf("open=%q err=%v", opened, err)
|
||||
}
|
||||
loadedPepper, err := LoadClientSecretPepper(pepperPath)
|
||||
if err != nil || string(loadedPepper) != string(pepper) {
|
||||
t.Fatalf("pepper len=%d err=%v", len(loadedPepper), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSigningKeyRingRejectsUnknownManifestFieldAndUnboundedRetiringKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"keys":[],"unknown":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadSigningKeyRing(path, func() time.Time { return time.Now() }); err == nil {
|
||||
t.Fatal("unknown manifest field unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
80
internal/app/telegramlogin/native.go
Normal file
80
internal/app/telegramlogin/native.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var nativeApplicationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,254}$`)
|
||||
|
||||
func normalizeNativeApplicationID(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !nativeApplicationIDPattern.MatchString(raw) || !strings.Contains(raw, ".") || strings.Contains(raw, "..") {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, raw string) (string, error) {
|
||||
raw = strings.ToUpper(strings.TrimSpace(raw))
|
||||
switch platform {
|
||||
case domain.TelegramLoginNativeIOS:
|
||||
if len(raw) != 10 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
case domain.TelegramLoginNativeAndroid:
|
||||
raw = strings.ReplaceAll(raw, ":", "")
|
||||
if len(raw) != 64 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'F') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// NormalizeNativeCallbackURI accepts the exact HTTPS universal/app link or a
|
||||
// non-web custom scheme registered for a native application. Query and
|
||||
// fragment components are forbidden because OAuth response fields are
|
||||
// appended by the provider and must not collide with application input.
|
||||
func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
|
||||
normalized, _, err := NormalizeRedirectURI(raw, allowHTTP)
|
||||
return normalized, err
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if !validAppScheme(scheme) || scheme == "tg" || scheme == "javascript" || scheme == "data" || scheme == "file" || u.Port() != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
if host == "" || strings.IndexFunc(host, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.')
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme, u.Host = scheme, host
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func normalizeNativeDisplayName(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > 128 || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
1379
internal/app/telegramlogin/service.go
Normal file
1379
internal/app/telegramlogin/service.go
Normal file
File diff suppressed because it is too large
Load diff
424
internal/app/telegramlogin/service_test.go
Normal file
424
internal/app/telegramlogin/service_test.go
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestService(t, &now)
|
||||
|
||||
const contenders = 24
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var created atomic.Int32
|
||||
var conflicts atomic.Int32
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("CreateClient: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("create winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
|
||||
client, found, err := loginStore.GetTelegramLoginClientByBot(ctx, 9010)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetTelegramLoginClientByBot: found=%v err=%v", found, err)
|
||||
}
|
||||
start = make(chan struct{})
|
||||
created.Store(0)
|
||||
conflicts.Store(0)
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(seed byte) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = seed
|
||||
_, err := loginStore.RotateTelegramLoginClientSecret(ctx, client.BotUserID, client.SecretVersion, hash, now.Add(time.Second))
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("RotateTelegramLoginClientSecret: %v", err)
|
||||
}
|
||||
}(byte(i + 1))
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("rotate winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, nil, "")
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, algorithms, "")
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithAppLinkBase(t *testing.T, now *time.Time, appLinkBase string) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, nil, appLinkBase)
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithConfig(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm, appLinkBase string) (*Service, *memory.TelegramLoginStore) {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
key[0] = 7
|
||||
sealer, err := NewCodeSealer("test", map[string][]byte{"test": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginStore := memory.NewTelegramLoginStore(nil)
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 9
|
||||
service, err := NewService(loginStore, sealer, Config{
|
||||
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", AppLinkBase: appLinkBase,
|
||||
AllowHTTP: true, ClientSecretPepper: pepper,
|
||||
SupportedSigningAlgorithms: algorithms,
|
||||
Now: func() time.Time { return *now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, loginStore
|
||||
}
|
||||
|
||||
func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestServiceWithAppLinkBase(t, &now, "owpg://tenant.example.test")
|
||||
credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9030, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(created.DeepLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := parsed.Query().Get("token")
|
||||
if got, want := parsed.Scheme+"://"+parsed.Host+parsed.Path, "owpg://tenant.example.test/oauth"; got != want {
|
||||
t.Fatalf("generated deep link root = %q, want %q", got, want)
|
||||
}
|
||||
valid := []string{
|
||||
created.DeepLink,
|
||||
"telesrv://oauth?token=" + url.QueryEscape(token),
|
||||
"telesrv://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"tg://oauth?token=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"https://t.me/oauth?startapp=" + url.QueryEscape(token),
|
||||
}
|
||||
for _, deepLink := range valid {
|
||||
request, err := service.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil || request.ID != created.Request.ID {
|
||||
t.Fatalf("RequestByDeepLink(%q) request=%#v err=%v", deepLink, request, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"telegram://oauth?token=" + url.QueryEscape(token),
|
||||
"owpg://other.example.test/oauth?token=" + url.QueryEscape(token),
|
||||
"owpg://tenant.example.test/resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"owpg://tenant.example.test/oauth/extra?token=" + url.QueryEscape(token),
|
||||
"tg://oauth/path?token=" + url.QueryEscape(token),
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "&token=other",
|
||||
"tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token) + "&startapp=other",
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "#fragment",
|
||||
}
|
||||
for _, deepLink := range invalid {
|
||||
if _, err := service.RequestByDeepLink(ctx, deepLink); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("RequestByDeepLink(%q) error=%v, want URL invalid", deepLink, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsSigningAlgorithmsWithoutActiveKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestServiceWithAlgorithms(t, &now, []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningES256,
|
||||
})
|
||||
if _, err := service.CreateClient(ctx, 9020, domain.TelegramLoginSigningRS256); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("CreateClient unsupported algorithm error=%v", err)
|
||||
}
|
||||
credentials, created, err := service.EnsureClient(ctx, 9020)
|
||||
if err != nil || !created || credentials.Client.SigningAlgorithm != domain.TelegramLoginSigningES256 {
|
||||
t.Fatalf("EnsureClient credentials=%#v created=%v err=%v", credentials, created, err)
|
||||
}
|
||||
if _, err := service.SetClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningEdDSA); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientSigningAlgorithm unsupported error=%v", err)
|
||||
}
|
||||
|
||||
// Simulate configuration drift from a previous deployment. Authorization
|
||||
// must fail before a request is persisted instead of failing after consent.
|
||||
if _, err := loginStore.SetTelegramLoginClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningRS256, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, true); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientEnabled unavailable algorithm error=%v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9020, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: "https://rp.example/", ResponseType: "post_message",
|
||||
Scope: "openid profile",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
t.Fatalf("CreateAuthorization unavailable algorithm error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9001, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateClient: %v", err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatalf("AddAllowedURL redirect: %v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatalf("AddAllowedURL origin: %v", err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI,
|
||||
ResponseType: "code", Scope: "openid profile phone telegram:bot_access",
|
||||
State: "opaque-state", Nonce: "nonce", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.10", Region: "Test Region",
|
||||
IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthorization: %v", err)
|
||||
}
|
||||
if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 {
|
||||
t.Fatalf("created authorization = %#v", created)
|
||||
}
|
||||
if !strings.HasPrefix(created.DeepLink, "telesrv://oauth?token=") {
|
||||
t.Fatalf("default deep link = %q, want legacy telesrv:// OAuth form", created.DeepLink)
|
||||
}
|
||||
if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode {
|
||||
t.Fatal("wrong match code unexpectedly accepted")
|
||||
}
|
||||
if ok, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCode); err != nil || !ok {
|
||||
t.Fatalf("CheckMatchCode correct = %v,%v", ok, err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
}
|
||||
approved, web, err := service.Approve(ctx, created.DeepLink, identity, true, false, created.Request.MatchCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
if approved.Status != domain.TelegramLoginRequestApproved || web.PhoneShared || !web.BotAccessGranted {
|
||||
t.Fatalf("approved=%#v web=%#v", approved, web)
|
||||
}
|
||||
if approved.ProfileName != "Alice Example" || approved.PhoneNumber != "" {
|
||||
t.Fatalf("identity snapshot = %#v", approved)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeByBrowserToken: %v", err)
|
||||
}
|
||||
redirect, err := url.Parse(finalized.RedirectURL)
|
||||
if err != nil || redirect.Query().Get("code") != finalized.Code || redirect.Query().Get("state") != "opaque-state" {
|
||||
t.Fatalf("final redirect = %q,%v", finalized.RedirectURL, err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier + "x",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("exchange wrong verifier error = %v, want code invalid", err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
exchanged, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthorizationCode: %v", err)
|
||||
}
|
||||
if exchanged.Request.AuthorizedUserID != 42 || exchanged.WebAuthorization.Hash != web.Hash {
|
||||
t.Fatalf("exchanged = %#v", exchanged)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeConsumed) {
|
||||
t.Fatalf("replay exchange error = %v, want consumed", err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); err != nil {
|
||||
t.Fatalf("RevokeWebAuthorization: %v", err)
|
||||
}
|
||||
if list, err := service.ListWebAuthorizations(ctx, 42); err != nil || len(list) != 0 {
|
||||
t.Fatalf("ListWebAuthorizations after revoke = %#v,%v", list, err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); !errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
|
||||
t.Fatalf("second revoke error = %v, want hash invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizationRetryRechecksLiveAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://retry.example/callback"
|
||||
const origin = "https://retry.example"
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedWebOrigin, origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codeRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, codeWeb, err := service.Approve(ctx, codeRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 51}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 51, codeWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("authorization-code retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
|
||||
miniRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: origin + "/", ResponseType: "post_message", Scope: "openid",
|
||||
Origin: origin, InAppOrigin: origin, Source: domain.TelegramLoginRequestMiniApp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, miniWeb, err := service.Approve(ctx, miniRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 52}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 52, miniWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("Mini App token retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSecretRotationClosesExchangeTOCTOU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
oldCredentials, err := service.CreateClient(ctx, 9002, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rotate.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9002, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: oldCredentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if _, _, err := service.Approve(ctx, created.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 43}, false, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCredentials, err := service.RotateClientSecret(ctx, 9002)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: oldCredentials.Client.ClientID, ClientSecret: oldCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginSecretInvalid) {
|
||||
t.Fatalf("old secret exchange error = %v", err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: newCredentials.Client.ClientID, ClientSecret: newCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); err != nil {
|
||||
t.Fatalf("new secret exchange: %v", err)
|
||||
}
|
||||
}
|
||||
133
internal/app/telegramlogin/url.go
Normal file
133
internal/app/telegramlogin/url.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxTelegramLoginURLLength = 4096
|
||||
|
||||
func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) {
|
||||
u, err := parseWebURL(raw, allowHTTP)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
query := u.Query()
|
||||
for _, reserved := range []string{"code", "state", "error", "error_description"} {
|
||||
if _, exists := query[reserved]; exists {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
return u.String(), u.Hostname(), nil
|
||||
}
|
||||
|
||||
func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) {
|
||||
u, err := parseWebURL(raw, allowHTTP)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Path = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
||||
if host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if ip := net.ParseIP(host); ip == nil {
|
||||
host, err = idna.Lookup.ToASCII(host)
|
||||
if err != nil || host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
port := u.Port()
|
||||
if port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
if port == "443" {
|
||||
port = ""
|
||||
}
|
||||
case "http":
|
||||
if !allowHTTP {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "80" {
|
||||
port = ""
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "" {
|
||||
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
|
||||
u.Host = "[" + host + "]"
|
||||
} else {
|
||||
u.Host = host
|
||||
}
|
||||
} else {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() || code == "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func AppendAuthorizationError(redirectURI, errorCode, state string) (string, error) {
|
||||
switch errorCode {
|
||||
case "access_denied", "temporarily_unavailable", "server_error", "invalid_request", "invalid_scope", "unsupported_response_type":
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", errorCode)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
116
internal/app/telegramlogin/url_test.go
Normal file
116
internal/app/telegramlogin/url_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
allowHTTP bool
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
|
||||
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
|
||||
{name: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true},
|
||||
{name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true},
|
||||
{name: "http disabled", raw: "http://example.com/callback"},
|
||||
{name: "userinfo", raw: "https://user@example.com/callback"},
|
||||
{name: "fragment", raw: "https://example.com/callback#token"},
|
||||
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
|
||||
{name: "leading whitespace", raw: " https://example.com/callback"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := NormalizeRedirectURI(test.raw, test.allowHTTP)
|
||||
if test.valid {
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("NormalizeRedirectURI() = %q,%v, want %q,nil", got, err, test.want)
|
||||
}
|
||||
} else if !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeRedirectURI() error = %v, want URL invalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAuthorizationErrorPreservesState(t *testing.T) {
|
||||
got, err := AppendAuthorizationError("https://example.com/callback?tenant=one", "access_denied", "opaque")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := url.Parse(got)
|
||||
if u.Query().Get("tenant") != "one" || u.Query().Get("error") != "access_denied" || u.Query().Get("state") != "opaque" {
|
||||
t.Fatalf("error redirect = %q", got)
|
||||
}
|
||||
if _, err := AppendAuthorizationError("https://example.com/callback", "invalid_client", ""); err == nil {
|
||||
t.Fatal("unsafe authorization error unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
|
||||
if got, err := NormalizeWebOrigin("https://Example.com/", false); err != nil || got != "https://example.com" {
|
||||
t.Fatalf("NormalizeWebOrigin = %q,%v", got, err)
|
||||
}
|
||||
for _, raw := range []string{"https://example.com/path", "https://example.com/?x=1", "https://example.com/#x"} {
|
||||
if _, err := NormalizeWebOrigin(raw, false); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeWebOrigin(%q) error = %v, want URL invalid", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) {
|
||||
origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "http://[2001:db8::25]" {
|
||||
t.Fatalf("origin=%q", origin)
|
||||
}
|
||||
redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" {
|
||||
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCERFC7636Vector(t *testing.T) {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
const want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
got, err := PKCEChallenge(verifier)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("PKCEChallenge = %q,%v, want %q,nil", got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSealerUsesAADAndRetiringKeys(t *testing.T) {
|
||||
oldKey := make([]byte, 32)
|
||||
newKey := make([]byte, 32)
|
||||
oldKey[0], newKey[0] = 1, 2
|
||||
old, err := NewCodeSealer("old", map[string][]byte{"old": oldKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, keyID, err := old.Seal("authorization-code", []byte("request-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := NewCodeSealer("new", map[string][]byte{"old": oldKey, "new": newKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := rotated.Open(sealed, nonce, keyID, []byte("request-1")); err != nil || got != "authorization-code" {
|
||||
t.Fatalf("Open after rotation = %q,%v", got, err)
|
||||
}
|
||||
if _, err := rotated.Open(sealed, nonce, keyID, []byte("request-2")); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("Open with wrong AAD error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -451,6 +451,12 @@ func cloneCachedUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.ContactNoteEntities != nil {
|
||||
in.ContactNoteEntities = append([]domain.MessageEntity(nil), in.ContactNoteEntities...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,13 @@ func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, con
|
|||
func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice", Phone: "111"}); err != nil {
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{
|
||||
ContactUserID: 2,
|
||||
FirstName: "Alice",
|
||||
Phone: "111",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
|
|
@ -126,15 +132,16 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get many first: %v", err)
|
||||
}
|
||||
if first[2].FirstName != "Alice" {
|
||||
t.Fatalf("first contact = %+v, want Alice", first[2])
|
||||
if first[2].FirstName != "Alice" || first[2].Note != "private note" || len(first[2].NoteEntities) != 1 {
|
||||
t.Fatalf("first contact = %+v, want Alice with private note", first[2])
|
||||
}
|
||||
first[2].NoteEntities[0].Length = 99
|
||||
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("get many second: %v", err)
|
||||
}
|
||||
if second[2].FirstName != "Alice" {
|
||||
t.Fatalf("second contact = %+v, want Alice", second[2])
|
||||
if second[2].FirstName != "Alice" || second[2].Note != "private note" || len(second[2].NoteEntities) != 1 || second[2].NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("second contact = %+v, want isolated cached Alice note", second[2])
|
||||
}
|
||||
if counting.listCalls != 1 {
|
||||
t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ type PrivacyEvaluator interface {
|
|||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// AccountFreezeProvider returns durable account freeze facts for a bounded
|
||||
// batch. The projector only exposes them to viewers other than the frozen user.
|
||||
type AccountFreezeProvider interface {
|
||||
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
||||
}
|
||||
|
||||
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
||||
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
||||
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
||||
|
|
@ -52,6 +58,7 @@ type Projector struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy PrivacyEvaluator
|
||||
freezes AccountFreezeProvider
|
||||
}
|
||||
|
||||
// Option configures a Projector.
|
||||
|
|
@ -72,6 +79,11 @@ func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
|
|||
return func(p *Projector) { p.privacy = privacy }
|
||||
}
|
||||
|
||||
// WithAccountFreezeProvider enables viewer-scoped frozen-account visibility.
|
||||
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
||||
return func(p *Projector) { p.freezes = provider }
|
||||
}
|
||||
|
||||
// New creates a user projector.
|
||||
func New(opts ...Option) *Projector {
|
||||
p := &Projector{}
|
||||
|
|
@ -87,7 +99,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d
|
|||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users)
|
||||
}
|
||||
|
||||
// One applies ForViewer to a single user.
|
||||
|
|
@ -136,6 +148,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactsByViewer map[int64]map[int64]domain.Contact
|
||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
|
||||
|
|
@ -159,6 +172,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return err
|
||||
})
|
||||
}
|
||||
if p.freezes != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
freezes, err = p.freezes.AccountFreezes(gctx, ids)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -194,6 +214,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return nil, perr
|
||||
}
|
||||
}
|
||||
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
|
||||
cache[u.ID] = pj
|
||||
projected[i] = pj
|
||||
}
|
||||
|
|
@ -260,6 +281,10 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
for i := range out {
|
||||
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
|
||||
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +379,7 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u
|
|||
return projected[0], nil
|
||||
}
|
||||
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -368,6 +393,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
personalRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
contactsByID map[int64]domain.Contact
|
||||
visibility map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
||||
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
||||
|
|
@ -430,6 +456,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
visibility = v
|
||||
return nil
|
||||
})
|
||||
if freezesProvider != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
m, err := freezesProvider.AccountFreezes(gctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
freezes = m
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -459,12 +495,23 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
|
||||
cache[u.ID] = projected
|
||||
out[i] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
|
||||
// Base users and self users must never retain a viewer-scoped restriction.
|
||||
user.RestrictionReasons = nil
|
||||
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
|
||||
return user
|
||||
}
|
||||
user.RestrictionReasons = domain.AccountFrozenRestrictionReasons()
|
||||
return user
|
||||
}
|
||||
|
||||
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
if privacy == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -499,30 +546,7 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
return user, nil
|
||||
}
|
||||
projected := user
|
||||
projected.Contact = true
|
||||
projected.Mutual = contact.Mutual || contact.User.Mutual
|
||||
projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
if contact.User.Phone != "" {
|
||||
projected.Phone = contact.User.Phone
|
||||
} else {
|
||||
projected.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
projected.FirstName = contact.User.FirstName
|
||||
projected.LastName = contact.User.LastName
|
||||
} else if contact.FirstName != "" || contact.LastName != "" {
|
||||
projected.FirstName = contact.FirstName
|
||||
projected.LastName = contact.LastName
|
||||
}
|
||||
return projected, nil
|
||||
return applyContactProjection(user, contact, found), nil
|
||||
}
|
||||
|
||||
func uniqueUserIDs(users []domain.User) []int64 {
|
||||
|
|
@ -575,11 +599,15 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
user.ContactNote = ""
|
||||
user.ContactNoteEntities = nil
|
||||
return user
|
||||
}
|
||||
user.Contact = true
|
||||
user.Mutual = contact.Mutual || contact.User.Mutual
|
||||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
user.ContactNote = contact.Note
|
||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
Phone: "1111",
|
||||
FirstName: "Alice",
|
||||
LastName: "Contact",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
|
@ -47,12 +49,15 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
|
||||
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
|
||||
}
|
||||
if friend.ContactNote != "private note" || len(friend.ContactNoteEntities) != 1 || friend.ContactNoteEntities[0].Type != domain.MessageEntityBold {
|
||||
t.Fatalf("friend contact note = %q %+v, want owner-scoped note", friend.ContactNote, friend.ContactNoteEntities)
|
||||
}
|
||||
if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) {
|
||||
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
|
||||
}
|
||||
stranger := projectionUser(t, users, strangerID)
|
||||
if stranger.Phone != "" || stranger.Contact {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and non-contact", stranger)
|
||||
if stranger.Phone != "" || stranger.Contact || stranger.ContactNote != "" || len(stranger.ContactNoteEntities) != 0 {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and no contact note", stranger)
|
||||
}
|
||||
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
|
||||
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
|
||||
|
|
@ -114,6 +119,64 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
frozenUserID = int64(4001)
|
||||
otherViewer = int64(4002)
|
||||
)
|
||||
freezes := &fakeAccountFreezes{items: map[int64]domain.AccountFreeze{
|
||||
frozenUserID: {UserID: frozenUserID, Frozen: true, Version: 3},
|
||||
}}
|
||||
projector := New(WithAccountFreezeProvider(freezes))
|
||||
base := []domain.User{{
|
||||
ID: frozenUserID,
|
||||
FirstName: "Frozen",
|
||||
// Viewer-scoped fields must never be trusted from a reused base object.
|
||||
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "stale", Text: "stale"}},
|
||||
}}
|
||||
|
||||
otherView, err := projector.ForViewer(ctx, otherViewer, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(other): %v", err)
|
||||
}
|
||||
got := projectionUser(t, otherView, frozenUserID)
|
||||
if !reflect.DeepEqual(got.RestrictionReasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("other-view restriction = %+v, want frozen restriction", got.RestrictionReasons)
|
||||
}
|
||||
if base[0].RestrictionReasons[0].Reason != "stale" {
|
||||
t.Fatalf("projection mutated base user: %+v", base[0])
|
||||
}
|
||||
|
||||
selfView, err := projector.ForViewer(ctx, frozenUserID, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(self): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, selfView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
batch, err := projector.ForViewers(ctx, []int64{otherViewer, frozenUserID}, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[otherViewer], frozenUserID).RestrictionReasons; !reflect.DeepEqual(reasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("batch other-view restriction = %+v", reasons)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[frozenUserID], frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("batch self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
freezes.items = nil
|
||||
unfrozenView, err := projector.ForViewer(ctx, otherViewer, otherView)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(after unfreeze): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, unfrozenView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("unfrozen projection retained restriction = %+v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users)
|
||||
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
|
||||
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做
|
||||
|
|
@ -238,6 +301,20 @@ type fakeProfilePhotos struct {
|
|||
fallback map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
type fakeAccountFreezes struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezes) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range ids {
|
||||
if freeze, ok := f.items[id]; ok {
|
||||
out[id] = freeze
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +56,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
|
|
@ -77,6 +82,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
@ -359,6 +365,56 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
|
|||
return s.projectOne(ctx, userID, updated)
|
||||
}
|
||||
|
||||
// SetScamFake 设置/取消用户的 scam 与 fake 标记(bot 复用同一路径)。scam/fake
|
||||
// 是账号基础事实,所有 user 投影统一消费;写后刷新基础缓存以便投影即时可见。
|
||||
func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Scam == scam && u.Fake == fake {
|
||||
return u, nil
|
||||
}
|
||||
updated, err := s.users.SetScamFake(ctx, userID, scam, fake)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
|
||||
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Support == support {
|
||||
return u, nil
|
||||
}
|
||||
updated, err := s.users.SetSupport(ctx, userID, support)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SweepExpiredPremium 清理到期会员(store 把过期行清 NULL)并失效用户缓存,
|
||||
// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发
|
||||
// 正确性由读取路径即时派生保证,这里只做收尾与通知。
|
||||
|
|
|
|||
|
|
@ -347,6 +347,9 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
|
|||
if button.CopyTextSet {
|
||||
constructors++
|
||||
}
|
||||
if button.LoginURLSet {
|
||||
constructors++
|
||||
}
|
||||
if constructors != 1 {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
|
|
@ -357,6 +360,13 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
|
|||
if button.URLSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.LoginURLSet {
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: button.Text, URL: button.LoginURL,
|
||||
ForwardText: button.LoginForwardText, LoginBotUsername: button.LoginBotUsername,
|
||||
RequestWriteAccess: button.LoginRequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
}
|
||||
if button.CallbackDataSet {
|
||||
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
|
||||
|
|
@ -689,23 +699,28 @@ type apiForceReply struct {
|
|||
}
|
||||
|
||||
type apiInlineKeyboardButton struct {
|
||||
Text string
|
||||
URL string
|
||||
URLSet bool
|
||||
CallbackData string
|
||||
CallbackDataSet bool
|
||||
Style string
|
||||
IconCustomEmojiID string
|
||||
IconCustomEmojiIDSet bool
|
||||
Unsupported bool
|
||||
WebAppURL string
|
||||
WebAppSet bool
|
||||
SwitchInlineQuery string
|
||||
SwitchInlineSet bool
|
||||
SwitchInlineSamePeer bool
|
||||
SwitchInlinePeerTypes []string
|
||||
CopyText string
|
||||
CopyTextSet bool
|
||||
Text string
|
||||
URL string
|
||||
URLSet bool
|
||||
CallbackData string
|
||||
CallbackDataSet bool
|
||||
Style string
|
||||
IconCustomEmojiID string
|
||||
IconCustomEmojiIDSet bool
|
||||
Unsupported bool
|
||||
WebAppURL string
|
||||
WebAppSet bool
|
||||
SwitchInlineQuery string
|
||||
SwitchInlineSet bool
|
||||
SwitchInlineSamePeer bool
|
||||
SwitchInlinePeerTypes []string
|
||||
CopyText string
|
||||
CopyTextSet bool
|
||||
LoginURL string
|
||||
LoginForwardText string
|
||||
LoginBotUsername string
|
||||
LoginRequestWriteAccess bool
|
||||
LoginURLSet bool
|
||||
}
|
||||
|
||||
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
||||
|
|
@ -739,6 +754,20 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
b.WebAppURL = app.URL
|
||||
}
|
||||
if raw, ok := fields["login_url"]; ok {
|
||||
b.LoginURLSet = true
|
||||
var login struct {
|
||||
URL string `json:"url"`
|
||||
ForwardText string `json:"forward_text"`
|
||||
BotUsername string `json:"bot_username"`
|
||||
RequestWriteAccess bool `json:"request_write_access"`
|
||||
}
|
||||
if json.Unmarshal(raw, &login) != nil {
|
||||
return errors.New("invalid login url")
|
||||
}
|
||||
b.LoginURL, b.LoginForwardText = login.URL, login.ForwardText
|
||||
b.LoginBotUsername, b.LoginRequestWriteAccess = login.BotUsername, login.RequestWriteAccess
|
||||
}
|
||||
switchActions := 0
|
||||
if raw, ok := fields["switch_inline_query"]; ok {
|
||||
switchActions++
|
||||
|
|
@ -807,7 +836,7 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
for key := range fields {
|
||||
switch key {
|
||||
case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
|
||||
case "text", "url", "callback_data", "web_app", "login_url", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
|
||||
default:
|
||||
b.Unsupported = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ func apiMessageProjectable(msg domain.Message) bool {
|
|||
if msg.Out || msg.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
|
||||
return msg.Body != "" || (msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0) || len(apiMessageMedia(msg.Media, nil, nil)) > 0
|
||||
}
|
||||
|
||||
func apiUser(u domain.User) map[string]any {
|
||||
|
|
@ -320,6 +320,12 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
|
|||
out["entities"] = entities
|
||||
}
|
||||
}
|
||||
if msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0 {
|
||||
var richMessage any
|
||||
if json.Unmarshal(msg.RichMessage.BotAPIProjection, &richMessage) == nil && richMessage != nil {
|
||||
out["rich_message"] = richMessage
|
||||
}
|
||||
}
|
||||
if msg.EditDate > 0 {
|
||||
out["edit_date"] = msg.EditDate
|
||||
}
|
||||
|
|
@ -503,6 +509,18 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
|
|||
switch button.Type {
|
||||
case domain.MarkupButtonURL:
|
||||
item["url"] = button.URL
|
||||
case domain.MarkupButtonLoginURL:
|
||||
login := map[string]any{"url": button.URL}
|
||||
if button.ForwardText != "" {
|
||||
login["forward_text"] = button.ForwardText
|
||||
}
|
||||
if button.LoginBotUsername != "" {
|
||||
login["bot_username"] = button.LoginBotUsername
|
||||
}
|
||||
if button.RequestWriteAccess {
|
||||
login["request_write_access"] = true
|
||||
}
|
||||
item["login_url"] = login
|
||||
case domain.MarkupButtonCallback:
|
||||
item["callback_data"] = string(button.Data)
|
||||
case domain.MarkupButtonWebView:
|
||||
|
|
|
|||
99
internal/botapi/rich_message.go
Normal file
99
internal/botapi/rich_message.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxBotAPIRichSourceBytes = 256 << 10
|
||||
|
||||
func richMessageInputFromAPI(raw string) (domain.BotAPIRichMessageInput, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > maxBotAPIRichSourceBytes {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
var out domain.BotAPIRichMessageInput
|
||||
sources := 0
|
||||
if value, ok := fields["html"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if err := json.Unmarshal(value, &out.HTML); err != nil || out.HTML == "" {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
sources++
|
||||
}
|
||||
if value, ok := fields["markdown"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if err := json.Unmarshal(value, &out.Markdown); err != nil || out.Markdown == "" {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
sources++
|
||||
}
|
||||
if value, ok := fields["blocks"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if len(bytes.TrimSpace(value)) == 0 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
out.BlocksJSON = append([]byte(nil), value...)
|
||||
sources++
|
||||
}
|
||||
if sources != 1 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
if len(out.BlocksJSON) != 0 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_BLOCKS_UNSUPPORTED")
|
||||
}
|
||||
if value, ok := fields["media"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) && !bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
out.MediaJSON = append([]byte(nil), value...)
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_MEDIA_UNSUPPORTED")
|
||||
}
|
||||
if value, ok := fields["is_rtl"]; ok {
|
||||
if err := json.Unmarshal(value, &out.RTL); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
}
|
||||
if value, ok := fields["skip_entity_detection"]; ok {
|
||||
if err := json.Unmarshal(value, &out.SkipEntityDetection); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func richReplyMessageID(values map[string]string) (int, error) {
|
||||
legacy := apiInt(values["reply_to_message_id"], 0)
|
||||
raw := strings.TrimSpace(values["reply_parameters"])
|
||||
if raw == "" {
|
||||
if legacy < 0 {
|
||||
return 0, errors.New("REPLY_MESSAGE_ID_INVALID")
|
||||
}
|
||||
return legacy, nil
|
||||
}
|
||||
if legacy != 0 {
|
||||
return 0, errors.New("REPLY_PARAMETERS_INVALID")
|
||||
}
|
||||
var payload struct {
|
||||
MessageID int `json:"message_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload.MessageID <= 0 {
|
||||
return 0, errors.New("REPLY_PARAMETERS_INVALID")
|
||||
}
|
||||
return payload.MessageID, nil
|
||||
}
|
||||
|
||||
func apiInt64(raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0, errors.New("VALUE_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
|
@ -43,9 +43,12 @@ type GatewayService interface {
|
|||
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
|
||||
BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error)
|
||||
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPISendRichMessage(ctx context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error)
|
||||
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
|
||||
BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error)
|
||||
BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error)
|
||||
BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error)
|
||||
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
|
||||
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
|
||||
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
|
||||
|
|
@ -204,6 +207,8 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
|||
h.getUpdates(w, r, botID)
|
||||
case "sendmessage":
|
||||
h.sendMessage(w, r, botID)
|
||||
case "sendrichmessage":
|
||||
h.sendRichMessage(w, r, botID)
|
||||
case "sendphoto":
|
||||
h.sendMedia(w, r, botID, "photo")
|
||||
case "sendanimation":
|
||||
|
|
@ -577,6 +582,71 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
|
|||
writeAPIOK(w, apiMessage(msg, users))
|
||||
}
|
||||
|
||||
func (h *handler) sendRichMessage(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["business_connection_id"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "BUSINESS_CONNECTION_INVALID")
|
||||
return
|
||||
}
|
||||
if apiInt(values["message_thread_id"], 0) != 0 || apiInt(values["direct_messages_topic_id"], 0) != 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_THREAD_INVALID")
|
||||
return
|
||||
}
|
||||
if apiBool(values["allow_paid_broadcast"]) || strings.TrimSpace(values["suggested_post_parameters"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "RICH_MESSAGE_OPTION_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
rich, err := richMessageInputFromAPI(values["rich_message"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var markup *domain.MessageReplyMarkup
|
||||
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
|
||||
markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
replyTo, err := richReplyMessageID(values)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
effectID, err := apiInt64(values["message_effect_id"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "EFFECT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
msg, err := h.gateway.BotAPISendRichMessage(
|
||||
r.Context(), botID, chatID, rich, markup,
|
||||
apiBool(values["disable_notification"]), apiBool(values["protect_content"]), replyTo, effectID,
|
||||
)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
users := []domain.User(nil)
|
||||
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
|
||||
users = append(users, self)
|
||||
}
|
||||
writeAPIOK(w, apiMessage(msg, users))
|
||||
}
|
||||
|
||||
func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, kind string) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
|
|
@ -695,7 +765,26 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
|||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
|
||||
return
|
||||
}
|
||||
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
|
||||
rawRich := strings.TrimSpace(values["rich_message"])
|
||||
_, textSpecified := values["text"]
|
||||
if rawRich != "" && textSpecified {
|
||||
writeAPIError(w, http.StatusBadRequest, "RICH_MESSAGE_INVALID")
|
||||
return
|
||||
}
|
||||
var (
|
||||
text string
|
||||
entities []domain.MessageEntity
|
||||
rich domain.BotAPIRichMessageInput
|
||||
)
|
||||
if rawRich != "" {
|
||||
rich, err = richMessageInputFromAPI(rawRich)
|
||||
} else {
|
||||
if !textSpecified {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
|
||||
return
|
||||
}
|
||||
text, entities, err = botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
|
@ -715,7 +804,12 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
|||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
var ok bool
|
||||
if rawRich != "" {
|
||||
ok, err = h.gateway.BotAPIEditInlineRichMessage(r.Context(), botID, inlineID, rich, setReplyMarkup, markup)
|
||||
} else {
|
||||
ok, err = h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
|
|
@ -723,7 +817,12 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
|||
writeAPIOK(w, ok)
|
||||
return
|
||||
}
|
||||
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
var msg domain.Message
|
||||
if rawRich != "" {
|
||||
msg, err = h.gateway.BotAPIEditRichMessage(r.Context(), botID, chatID, messageID, rich, setReplyMarkup, markup)
|
||||
} else {
|
||||
msg, err = h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
|
|
@ -978,11 +1077,18 @@ func validateWebhookURL(raw string) error {
|
|||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
u, err := neturl.ParseRequestURI(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||
if err != nil {
|
||||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
|
||||
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if (scheme != "http" && scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1338,6 +1444,14 @@ func apiErrorDescription(err error) string {
|
|||
"RESULT_TYPE_INVALID",
|
||||
"MESSAGE_EMPTY",
|
||||
"MESSAGE_TOO_LONG",
|
||||
"RICH_MESSAGE_INVALID",
|
||||
"RICH_MESSAGE_TOO_LONG",
|
||||
"RICH_MESSAGE_DATE_INVALID",
|
||||
"RICH_MESSAGE_BLOCKS_UNSUPPORTED",
|
||||
"RICH_MESSAGE_MEDIA_UNSUPPORTED",
|
||||
"RICH_MESSAGE_OPTION_UNSUPPORTED",
|
||||
"WEBPAGE_MEDIA_EMPTY",
|
||||
"EFFECT_ID_INVALID",
|
||||
"BUTTON_INVALID",
|
||||
"BUTTON_DATA_INVALID",
|
||||
"BUTTON_URL_INVALID",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -600,6 +601,101 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendRichMessageAndEditPreserveInlineKeyboardAndProjection(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
projection := json.RawMessage(`{"blocks":[{"type":"heading","size":4,"text":"Admin"}],"is_rtl":true}`)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
|
||||
}}}}
|
||||
message := domain.Message{
|
||||
ID: 21, OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Date: 1700000021, Out: true, ReplyMarkup: markup,
|
||||
RichMessage: &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1}, BotAPIProjection: projection},
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
self: domain.User{ID: 1001, FirstName: "Bedolaga", Username: "bedolaga_bot", Bot: true},
|
||||
sendMessage: message,
|
||||
editMessage: message,
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "sendRichMessage", `{
|
||||
"chat_id":2001,
|
||||
"rich_message":{"html":"<h4>Admin</h4>","is_rtl":true,"skip_entity_detection":true},
|
||||
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]},
|
||||
"disable_notification":true,
|
||||
"protect_content":true,
|
||||
"reply_parameters":{"message_id":7}
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sendRichMessage status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !gateway.sendRichCalled || gateway.sendChatID != 2001 || gateway.sendRichInput.HTML != "<h4>Admin</h4>" ||
|
||||
!gateway.sendRichInput.RTL || !gateway.sendRichInput.SkipEntityDetection || !gateway.sendSilent || gateway.sendReplyTo != 7 {
|
||||
t.Fatalf("send rich call = %#v", gateway)
|
||||
}
|
||||
if gateway.sendRichMarkup == nil || len(gateway.sendRichMarkup.Inline) != 1 ||
|
||||
string(gateway.sendRichMarkup.Inline[0][0].Data) != "menu:info" {
|
||||
t.Fatalf("send rich markup = %#v", gateway.sendRichMarkup)
|
||||
}
|
||||
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
|
||||
|
||||
gateway.editMessage.RichMessage.BotAPIProjection = json.RawMessage(`{"blocks":[{"type":"paragraph","text":"Updated"}]}`)
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
|
||||
"chat_id":2001,
|
||||
"message_id":21,
|
||||
"rich_message":{"markdown":"**Updated**","skip_entity_detection":true},
|
||||
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]}
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("editMessageText rich status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !gateway.editRichCalled || gateway.editRichInput.Markdown != "**Updated**" || !gateway.editRichInput.SkipEntityDetection || !gateway.editSetMarkup {
|
||||
t.Fatalf("edit rich call = %#v", gateway)
|
||||
}
|
||||
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
|
||||
}
|
||||
|
||||
func TestEditMessageTextRejectsTextAndRichMessageTogether(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
|
||||
"chat_id":2001,"message_id":21,"text":"plain","rich_message":{"html":"<p>rich</p>"}
|
||||
}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "RICH_MESSAGE_INVALID") {
|
||||
t.Fatalf("edit text+rich status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertBotAPIRichMenuResponse(t *testing.T, raw []byte, messageID int) {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
MessageID int `json:"message_id"`
|
||||
RichMessage struct {
|
||||
Blocks []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"blocks"`
|
||||
} `json:"rich_message"`
|
||||
ReplyMarkup struct {
|
||||
InlineKeyboard [][]struct {
|
||||
CallbackData string `json:"callback_data"`
|
||||
} `json:"inline_keyboard"`
|
||||
} `json:"reply_markup"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatalf("decode rich response: %v", err)
|
||||
}
|
||||
if !response.OK || response.Result.MessageID != messageID || len(response.Result.RichMessage.Blocks) != 1 ||
|
||||
len(response.Result.ReplyMarkup.InlineKeyboard) != 1 || response.Result.ReplyMarkup.InlineKeyboard[0][0].CallbackData != "menu:info" {
|
||||
t.Fatalf("rich response = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
markup := &domain.MessageReplyMarkup{
|
||||
|
|
@ -791,6 +887,19 @@ func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
|
|||
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
|
||||
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
|
||||
}
|
||||
login, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Log in","login_url":{"url":"https://example.com/login","forward_text":"Open","bot_username":"auth_bot","request_write_access":true}}]]}`))
|
||||
if err != nil || login == nil {
|
||||
t.Fatalf("login_url inline button = %#v err=%v", login, err)
|
||||
}
|
||||
loginButton := login.Inline[0][0]
|
||||
if loginButton.Type != domain.MarkupButtonLoginURL || loginButton.URL != "https://example.com/login" || loginButton.ForwardText != "Open" ||
|
||||
loginButton.LoginBotUsername != "auth_bot" || !loginButton.RequestWriteAccess {
|
||||
t.Fatalf("login_url button = %#v", loginButton)
|
||||
}
|
||||
projectedLogin := apiReplyMarkup(login)["inline_keyboard"].([][]map[string]any)[0][0]["login_url"].(map[string]any)
|
||||
if projectedLogin["url"] != "https://example.com/login" || projectedLogin["bot_username"] != "auth_bot" || projectedLogin["request_write_access"] != true {
|
||||
t.Fatalf("projected login_url = %#v", projectedLogin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {
|
||||
|
|
@ -869,6 +978,23 @@ func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetWebhookAcceptsHTTPHostIPAndArbitraryPort(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
for _, rawURL := range []string{
|
||||
"http://bot.example.test:3000/hook",
|
||||
"http://192.0.2.25:18080/hook",
|
||||
"http://[2001:db8::25]:28080/hook",
|
||||
"HTTP://bot.example.test:3100/hook",
|
||||
} {
|
||||
gateway := &fakeBotAPIGateway{}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", fmt.Sprintf(`{"url":%q}`, rawURL))
|
||||
if rec.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != rawURL {
|
||||
t.Fatalf("setWebhook url=%q status=%d body=%s config=%#v", rawURL, rec.Code, rec.Body.String(), gateway.webhook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
|
||||
|
|
@ -876,8 +1002,9 @@ func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
|
|||
body string
|
||||
want string
|
||||
}{
|
||||
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
|
||||
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
|
||||
{`{"url":"ftp://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
|
||||
{`{"url":"http://user@example.test/hook"}`, "WEBHOOK_URL_INVALID"},
|
||||
{`{"url":"http://example.test:0/hook"}`, "WEBHOOK_URL_INVALID"},
|
||||
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
|
||||
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
|
||||
}
|
||||
|
|
@ -1329,6 +1456,9 @@ type fakeBotAPIGateway struct {
|
|||
sendSilent bool
|
||||
sendReplyTo int
|
||||
sendMessage domain.Message
|
||||
sendRichCalled bool
|
||||
sendRichInput domain.BotAPIRichMessageInput
|
||||
sendRichMarkup *domain.MessageReplyMarkup
|
||||
sendMediaCalled bool
|
||||
sendMediaKind string
|
||||
sendMediaChatID int64
|
||||
|
|
@ -1342,6 +1472,8 @@ type fakeBotAPIGateway struct {
|
|||
editEntities []domain.MessageEntity
|
||||
editSetMarkup bool
|
||||
editMessage domain.Message
|
||||
editRichCalled bool
|
||||
editRichInput domain.BotAPIRichMessageInput
|
||||
editInlineCalled bool
|
||||
editInlineID domain.BotInlineMessageID
|
||||
editInlineText string
|
||||
|
|
@ -1448,6 +1580,17 @@ func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID i
|
|||
return f.sendMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendRichMessage(_ context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
|
||||
f.sendRichCalled = true
|
||||
f.sendBotID = botID
|
||||
f.sendChatID = chatID
|
||||
f.sendRichInput = rich
|
||||
f.sendRichMarkup = replyMarkup
|
||||
f.sendSilent = silent
|
||||
f.sendReplyTo = replyToMessageID
|
||||
return f.sendMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
f.sendMediaCalled = true
|
||||
f.sendMediaKind = kind
|
||||
|
|
@ -1467,6 +1610,13 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
|
|||
return f.editMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditRichMessage(_ context.Context, botID, chatID int64, messageID int, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
|
||||
f.editRichCalled = true
|
||||
f.editRichInput = rich
|
||||
f.editSetMarkup = setReplyMarkup
|
||||
return f.editMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
|
||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||
f.editInlineText = text
|
||||
|
|
@ -1474,6 +1624,12 @@ func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditInlineRichMessage(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, rich domain.BotAPIRichMessageInput, _ bool, _ *domain.MessageReplyMarkup) (bool, error) {
|
||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||
f.editRichInput = rich
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
|
||||
f.deleteCalled = true
|
||||
return true, nil
|
||||
|
|
|
|||
|
|
@ -255,5 +255,5 @@ func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhoo
|
|||
d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
|
||||
d.logger.Warn("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -115,7 +116,8 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
|
|||
webhookFound: true,
|
||||
}
|
||||
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
|
||||
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
|
||||
logCore, observedLogs := observer.New(zap.WarnLevel)
|
||||
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.New(logCore), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
|
||||
d.deliver(context.Background(), base.webhook)
|
||||
|
||||
gateway.mu.Lock()
|
||||
|
|
@ -124,4 +126,18 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
|
|||
if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) {
|
||||
t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt)
|
||||
}
|
||||
entries := observedLogs.FilterMessage("bot api webhook delivery failed").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("delivery failure warning count = %d, want 1", len(entries))
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
if fields["bot_user_id"] != int64(1001) || fields["reason"] != "webhook returned HTTP 503" {
|
||||
t.Fatalf("delivery failure warning fields = %#v", fields)
|
||||
}
|
||||
if _, ok := fields["url"]; ok {
|
||||
t.Fatalf("delivery failure warning must not include webhook URL: %#v", fields)
|
||||
}
|
||||
if _, ok := fields["secret_token"]; ok {
|
||||
t.Fatalf("delivery failure warning must not include webhook secret: %#v", fields)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func TestUpgradePrivateLayerRPCOnlyAcceptsAuditedAndroidConstructors(t *testing.
|
|||
}
|
||||
|
||||
func TestGeneratedPrivateLayerRPCOverlayHasAllAuditedMethods(t *testing.T) {
|
||||
if got, want := tlprofile.ClientRPCOverlayMethodCount(tlprofile.ClientRPCOverlayDrkloAndroid), 15; got != want {
|
||||
if got, want := tlprofile.ClientRPCOverlayMethodCount(tlprofile.ClientRPCOverlayDrkloAndroid), 17; got != want {
|
||||
t.Fatalf("generated DrKLO method count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package config
|
|||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
|
@ -88,15 +89,43 @@ type Config struct {
|
|||
// PublicAppScheme 是公开落地页自动唤起自建客户端时使用的 URL scheme。
|
||||
// 必须与 TDesktop/Android 客户端构建时注册的 scheme 一致,且不能占用 tg/http/https。
|
||||
PublicAppScheme string
|
||||
// PublicAppLinkBase 是可选的 host-based 自建客户端链接根,例如
|
||||
// owpg://example.com。为空时继续生成 PublicAppScheme://<route>;非空时
|
||||
// 生成 <base>/<route>,同时保留旧 scheme 作为服务端输入兼容。
|
||||
PublicAppLinkBase string
|
||||
// PublicWebBaseURL 是公开 username 页面“Open in Web”按钮指向的 Web 客户端根 URL。
|
||||
PublicWebBaseURL string
|
||||
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
|
||||
PublicAppName string
|
||||
// PublicDownloadURL 是公开落地页头部“Download”按钮指向的产品官网/下载页 URL。
|
||||
PublicDownloadURL string
|
||||
// ScamWarning / FakeWarning override the profile warning text injected into
|
||||
// getFullUser/getFullChannel About for scam/fake peers. Empty keeps the
|
||||
// built-in per-peer-type English defaults. Clients cannot localize
|
||||
// server-provided text, so operators set these to their audience language.
|
||||
ScamWarning string
|
||||
FakeWarning string
|
||||
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
||||
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
|
||||
PublicLinkWebAddr string
|
||||
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
|
||||
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
|
||||
// process listings or accidentally copied into tracked .env templates.
|
||||
TelegramLoginEnabled bool
|
||||
TelegramLoginIssuer string
|
||||
// TelegramLoginAllowHTTP permits HTTP issuers and registered Login URLs on
|
||||
// any valid host/IP and port. HTTPS remains mandatory when false.
|
||||
TelegramLoginAllowHTTP bool
|
||||
TelegramLoginSigningKeysFile string
|
||||
TelegramLoginCodeKeysFile string
|
||||
TelegramLoginSecretPepperFile string
|
||||
TelegramLoginRequestTTL time.Duration
|
||||
TelegramLoginCodeTTL time.Duration
|
||||
TelegramLoginIDTokenTTL time.Duration
|
||||
TelegramLoginTrustedProxyCIDRs []string
|
||||
TelegramLoginRetention time.Duration
|
||||
TelegramLoginSweepInterval time.Duration
|
||||
TelegramLoginSweepBatch int
|
||||
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
||||
AdminUIAddr string
|
||||
AdminUIPassword string
|
||||
|
|
@ -445,6 +474,10 @@ func Load() (Config, error) {
|
|||
if err != nil {
|
||||
return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_SCHEME: %w", err)
|
||||
}
|
||||
publicAppLinkBase, err := links.ValidateAppLinkBase(envAllowEmptyOr("TELESRV_PUBLIC_APP_LINK_BASE", ""))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_LINK_BASE: %w", err)
|
||||
}
|
||||
// TELESRV_PUBLIC_WEB_BASE_URL is nullable: an explicitly empty value disables
|
||||
// the "Open in Web" button on public landing pages instead of falling back
|
||||
// to the default telesrv Web client URL.
|
||||
|
|
@ -508,10 +541,26 @@ func Load() (Config, error) {
|
|||
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
||||
PublicBaseURL: publicBaseURL,
|
||||
PublicAppScheme: publicAppScheme,
|
||||
PublicAppLinkBase: publicAppLinkBase,
|
||||
PublicWebBaseURL: publicWebBaseURL,
|
||||
PublicAppName: publicAppName,
|
||||
PublicDownloadURL: publicDownloadURL,
|
||||
ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""),
|
||||
FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""),
|
||||
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
||||
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
|
||||
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
|
||||
TelegramLoginAllowHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", false),
|
||||
TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"),
|
||||
TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"),
|
||||
TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"),
|
||||
TelegramLoginRequestTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", 5*time.Minute),
|
||||
TelegramLoginCodeTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_CODE_TTL", 2*time.Minute),
|
||||
TelegramLoginIDTokenTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", time.Hour),
|
||||
TelegramLoginTrustedProxyCIDRs: envListOr("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", nil),
|
||||
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
|
||||
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
|
||||
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
|
||||
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
|
||||
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
||||
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
||||
|
|
@ -673,9 +722,54 @@ func Load() (Config, error) {
|
|||
if err := validateStarGiftConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateTelegramLoginConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateTelegramLoginConfig(cfg Config) error {
|
||||
if !cfg.TelegramLoginEnabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.PublicLinkWebAddr) == "" {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ENABLE requires TELESRV_PUBLIC_LINK_WEB_ADDR")
|
||||
}
|
||||
issuer, err := url.Parse(strings.TrimSpace(cfg.TelegramLoginIssuer))
|
||||
if err != nil || issuer.User != nil || issuer.Host == "" || issuer.RawQuery != "" || issuer.Fragment != "" ||
|
||||
(issuer.Path != "" && issuer.Path != "/") {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must be an absolute origin URL")
|
||||
}
|
||||
switch issuer.Scheme {
|
||||
case "https":
|
||||
case "http":
|
||||
if !cfg.TelegramLoginAllowHTTP {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http requires TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https")
|
||||
}
|
||||
if strings.TrimSpace(cfg.TelegramLoginSigningKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginCodeKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginSecretPepperFile) == "" {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_* key and pepper files are required")
|
||||
}
|
||||
if cfg.TelegramLoginRequestTTL < time.Minute || cfg.TelegramLoginRequestTTL > 15*time.Minute ||
|
||||
cfg.TelegramLoginCodeTTL < 30*time.Second || cfg.TelegramLoginCodeTTL > 10*time.Minute ||
|
||||
cfg.TelegramLoginIDTokenTTL < time.Minute || cfg.TelegramLoginIDTokenTTL > 24*time.Hour {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN TTL values are outside their bounded ranges")
|
||||
}
|
||||
if cfg.TelegramLoginRetention < time.Hour || cfg.TelegramLoginRetention > 90*24*time.Hour ||
|
||||
cfg.TelegramLoginSweepInterval < 10*time.Second || cfg.TelegramLoginSweepInterval > time.Hour ||
|
||||
cfg.TelegramLoginSweepBatch <= 0 || cfg.TelegramLoginSweepBatch > 1000 {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN retention must be 1h..90d, sweep interval 10s..1h, and sweep batch 1..1000")
|
||||
}
|
||||
for _, raw := range cfg.TelegramLoginTrustedProxyCIDRs {
|
||||
if _, err := netip.ParsePrefix(strings.TrimSpace(raw)); err != nil {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS contains invalid CIDR %q: %w", raw, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStarGiftConfig(cfg Config) error {
|
||||
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
|||
if cfg.PublicAppScheme != "telesrv" {
|
||||
t.Fatalf("PublicAppScheme = %q, want telesrv", cfg.PublicAppScheme)
|
||||
}
|
||||
if cfg.PublicAppLinkBase != "" {
|
||||
t.Fatalf("PublicAppLinkBase = %q, want disabled", cfg.PublicAppLinkBase)
|
||||
}
|
||||
if cfg.PublicWebBaseURL != "https://web.telesrv.net" {
|
||||
t.Fatalf("PublicWebBaseURL = %q, want https://web.telesrv.net", cfg.PublicWebBaseURL)
|
||||
}
|
||||
|
|
@ -35,13 +38,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
|||
|
||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AdvertiseIP != "192.0.2.10" {
|
||||
if cfg.AdvertiseIP != "203.0.113.10" {
|
||||
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
|
||||
}
|
||||
}
|
||||
|
|
@ -413,6 +416,7 @@ TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
|||
TELESRV_CALL_RING_TIMEOUT=2m
|
||||
TELESRV_PUBLIC_BASE_URL=links.example.test/root
|
||||
TELESRV_PUBLIC_APP_SCHEME=example-chat
|
||||
TELESRV_PUBLIC_APP_LINK_BASE=OWPG://Tenant.Example.Test/
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=web.example.test/client
|
||||
TELESRV_PUBLIC_APP_NAME=Example Chat
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
||||
|
|
@ -444,6 +448,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
|||
if cfg.PublicAppScheme != "example-chat" {
|
||||
t.Fatalf("PublicAppScheme = %q, want example-chat", cfg.PublicAppScheme)
|
||||
}
|
||||
if cfg.PublicAppLinkBase != "owpg://tenant.example.test" {
|
||||
t.Fatalf("PublicAppLinkBase = %q, want owpg://tenant.example.test", cfg.PublicAppLinkBase)
|
||||
}
|
||||
if cfg.PublicWebBaseURL != "https://web.example.test/client" {
|
||||
t.Fatalf("PublicWebBaseURL = %q, want https://web.example.test/client", cfg.PublicWebBaseURL)
|
||||
}
|
||||
|
|
@ -465,6 +472,95 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadTelegramLoginConfig(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://192.0.2.25:2401/")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", "true")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", "7m")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_TTL", "90s")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", "45m")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", "127.0.0.0/8,10.0.0.0/8")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_RETENTION", "48h")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", "30s")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", "73")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://192.0.2.25:2401" || !cfg.TelegramLoginAllowHTTP {
|
||||
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q allow_http:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowHTTP)
|
||||
}
|
||||
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
|
||||
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
|
||||
}
|
||||
if cfg.TelegramLoginRequestTTL != 7*time.Minute || cfg.TelegramLoginCodeTTL != 90*time.Second || cfg.TelegramLoginIDTokenTTL != 45*time.Minute ||
|
||||
cfg.TelegramLoginRetention != 48*time.Hour || cfg.TelegramLoginSweepInterval != 30*time.Second || cfg.TelegramLoginSweepBatch != 73 {
|
||||
t.Fatalf("telegram login durations/batch = %v / %v / %v / %v / %v / %d", cfg.TelegramLoginRequestTTL, cfg.TelegramLoginCodeTTL,
|
||||
cfg.TelegramLoginIDTokenTTL, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch)
|
||||
}
|
||||
if len(cfg.TelegramLoginTrustedProxyCIDRs) != 2 || cfg.TelegramLoginTrustedProxyCIDRs[1] != "10.0.0.0/8" {
|
||||
t.Fatalf("trusted proxy CIDRs = %#v", cfg.TelegramLoginTrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.T) {
|
||||
valid := Config{
|
||||
TelegramLoginEnabled: true, PublicLinkWebAddr: "127.0.0.1:2401", TelegramLoginIssuer: "https://login.example.test",
|
||||
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
|
||||
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
|
||||
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
|
||||
}
|
||||
if err := validateTelegramLoginConfig(valid); err != nil {
|
||||
t.Fatalf("valid config: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
}{
|
||||
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
|
||||
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
|
||||
{name: "http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://192.0.2.25:2401" }},
|
||||
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
|
||||
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
|
||||
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
|
||||
{name: "id token ttl too long", mutate: func(c *Config) { c.TelegramLoginIDTokenTTL = 25 * time.Hour }},
|
||||
{name: "retention too short", mutate: func(c *Config) { c.TelegramLoginRetention = 59 * time.Minute }},
|
||||
{name: "sweep unbounded", mutate: func(c *Config) { c.TelegramLoginSweepBatch = 1001 }},
|
||||
{name: "invalid proxy CIDR", mutate: func(c *Config) { c.TelegramLoginTrustedProxyCIDRs = []string{"10.0.0.0/33"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := valid
|
||||
tc.mutate(&cfg)
|
||||
if err := validateTelegramLoginConfig(cfg); err == nil {
|
||||
t.Fatal("unsafe Telegram Login config was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTelegramLoginConfigAcceptsHTTPHostAndIPWhenEnabled(t *testing.T) {
|
||||
valid := Config{
|
||||
TelegramLoginEnabled: true, TelegramLoginAllowHTTP: true, PublicLinkWebAddr: "127.0.0.1:2401",
|
||||
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
|
||||
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
|
||||
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
|
||||
}
|
||||
for _, issuer := range []string{"http://login.example.test:3000", "http://192.0.2.25:2401", "http://[2001:db8::25]:2401"} {
|
||||
cfg := valid
|
||||
cfg.TelegramLoginIssuer = issuer
|
||||
if err := validateTelegramLoginConfig(cfg); err != nil {
|
||||
t.Fatalf("issuer %q was rejected: %v", issuer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")
|
||||
|
|
@ -482,6 +578,10 @@ func TestLoadRejectsInvalidPublicLinkClientConfig(t *testing.T) {
|
|||
}{
|
||||
{name: "official scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "tg"},
|
||||
{name: "malformed scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "bad scheme"},
|
||||
{name: "app link base official scheme", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "tg://links.example.test"},
|
||||
{name: "app link base missing host", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://"},
|
||||
{name: "app link base path", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://links.example.test/root"},
|
||||
{name: "app link base query", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://links.example.test?tenant=one"},
|
||||
{name: "invalid web base", key: "TELESRV_PUBLIC_WEB_BASE_URL", value: "file:///tmp/client"},
|
||||
{name: "empty app name after trim", key: "TELESRV_PUBLIC_APP_NAME", value: " "},
|
||||
{name: "control in app name", key: "TELESRV_PUBLIC_APP_NAME", value: "bad\nname"},
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type AdminCommand struct {
|
|||
type AccountFreeze struct {
|
||||
UserID int64
|
||||
Frozen bool
|
||||
Version int64
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
AppealURL string
|
||||
|
|
@ -40,3 +41,15 @@ type AccountFreeze struct {
|
|||
CommandID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AccountFreezeNotification is a durable, coalesced online refresh for one
|
||||
// viewer. UpdateUser itself has no pts; offline clients always recover from the
|
||||
// authoritative viewer-scoped user projection instead of replaying this row.
|
||||
type AccountFreezeNotification struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
FrozenUserID int64
|
||||
Version int64
|
||||
Frozen bool
|
||||
Attempts int
|
||||
}
|
||||
|
|
|
|||
29
internal/domain/botapi_rich_message.go
Normal file
29
internal/domain/botapi_rich_message.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package domain
|
||||
|
||||
// BotAPIRichMessageInput is the protocol-neutral HTTP Bot API input passed to
|
||||
// the RPC edge. Exactly one of HTML, Markdown, or BlocksJSON must be present.
|
||||
// BlocksJSON and MediaJSON are retained in the DTO so unsupported Bot API 10.2
|
||||
// shapes are rejected explicitly at the conversion boundary instead of being
|
||||
// flattened or silently dropped.
|
||||
type BotAPIRichMessageInput struct {
|
||||
HTML string
|
||||
Markdown string
|
||||
BlocksJSON []byte
|
||||
MediaJSON []byte
|
||||
RTL bool
|
||||
SkipEntityDetection bool
|
||||
}
|
||||
|
||||
func (m BotAPIRichMessageInput) SourceCount() int {
|
||||
n := 0
|
||||
if m.HTML != "" {
|
||||
n++
|
||||
}
|
||||
if m.Markdown != "" {
|
||||
n++
|
||||
}
|
||||
if len(m.BlocksJSON) != 0 {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
@ -415,6 +415,9 @@ type Channel struct {
|
|||
About string
|
||||
Username string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
Gigagroup bool
|
||||
Broadcast bool
|
||||
Megagroup bool
|
||||
Forum bool
|
||||
|
|
@ -505,6 +508,26 @@ type ChannelMember struct {
|
|||
Guest bool
|
||||
}
|
||||
|
||||
// CanManageDirectMessages reports whether this active parent-channel member may
|
||||
// see and address every subscriber topic in the linked direct-messages
|
||||
// monoforum. Telegram deliberately does not grant this capability to an
|
||||
// ordinary channel administrator: the explicit manage_direct_messages right is
|
||||
// required (creators have the capability implicitly).
|
||||
func (m ChannelMember) CanManageDirectMessages() bool {
|
||||
return m.Status == ChannelMemberActive &&
|
||||
(m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && m.AdminRights.ManageDirectMessages))
|
||||
}
|
||||
|
||||
// CanPostChannelMessages reports whether this active member may publish a post
|
||||
// to a broadcast channel. Suggested-post managers need this in addition to
|
||||
// CanManageDirectMessages when approving a subscriber-authored suggestion.
|
||||
func (m ChannelMember) CanPostChannelMessages() bool {
|
||||
return m.Status == ChannelMemberActive &&
|
||||
(m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && m.AdminRights.PostMessages))
|
||||
}
|
||||
|
||||
// ChannelDialog is the current user's owner-view dialog state for a channel.
|
||||
type ChannelDialog struct {
|
||||
UserID int64
|
||||
|
|
@ -570,7 +593,10 @@ const (
|
|||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
|
||||
// CommunityID means linked; zero means unlinked.
|
||||
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
|
||||
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
|
||||
ChannelActionSuggestedPostApproval ChannelMessageActionType = "suggested_post_approval"
|
||||
ChannelActionSuggestedPostSuccess ChannelMessageActionType = "suggested_post_success"
|
||||
ChannelActionSuggestedPostRefund ChannelMessageActionType = "suggested_post_refund"
|
||||
)
|
||||
|
||||
// ChannelMessageAction describes a service action without depending on tg.*.
|
||||
|
|
@ -609,6 +635,15 @@ type ChannelMessageAction struct {
|
|||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
Photo *Photo
|
||||
// Suggested-post lifecycle actions share the immutable price snapshot. The
|
||||
// approval action additionally uses the reject/balance/schedule fields;
|
||||
// refund uses PayerInitiated.
|
||||
SuggestedPostRejected bool
|
||||
SuggestedPostBalanceTooLow bool
|
||||
SuggestedPostRejectComment string
|
||||
SuggestedPostScheduleDate int
|
||||
SuggestedPostPrice *SuggestedPostPrice
|
||||
SuggestedPostPayerInitiated bool
|
||||
}
|
||||
|
||||
// ChannelMessage is a single stored message in a channel/supergroup.
|
||||
|
|
@ -643,7 +678,7 @@ type ChannelMessage struct {
|
|||
Reactions *ChannelMessageReactions
|
||||
Action *ChannelMessageAction
|
||||
Media *MessageMedia
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
// RichMessage 是 Layer 228 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
// FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。
|
||||
FromBoostsApplied int
|
||||
|
|
@ -1411,6 +1446,25 @@ type UpdateChannelUsernameRequest struct {
|
|||
Username string
|
||||
}
|
||||
|
||||
// ChannelAdminSettings is an admin-direct patch of channel moderation settings.
|
||||
// nil fields are left unchanged; set fields are applied verbatim (no membership
|
||||
// or permission checks — this is the operator/admin path).
|
||||
type ChannelAdminSettings struct {
|
||||
Gigagroup *bool
|
||||
AntiSpam *bool
|
||||
ParticipantsHidden *bool
|
||||
NoForwards *bool
|
||||
JoinToSend *bool
|
||||
JoinRequest *bool
|
||||
SlowmodeSeconds *int
|
||||
}
|
||||
|
||||
// Empty reports whether the patch changes nothing.
|
||||
func (p ChannelAdminSettings) Empty() bool {
|
||||
return p.Gigagroup == nil && p.AntiSpam == nil && p.ParticipantsHidden == nil &&
|
||||
p.NoForwards == nil && p.JoinToSend == nil && p.JoinRequest == nil && p.SlowmodeSeconds == nil
|
||||
}
|
||||
|
||||
// SetChannelPhotoResult describes a channel avatar mutation and its durable
|
||||
// service message.
|
||||
type SetChannelPhotoResult struct {
|
||||
|
|
@ -1491,6 +1545,59 @@ type SendMonoforumMessageRequest struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// ToggleSuggestedPostApprovalRequest is the domain command behind
|
||||
// messages.toggleSuggestedPostApproval. MessageID addresses the immutable
|
||||
// suggestion in one monoforum subscriber sub-dialog.
|
||||
type ToggleSuggestedPostApprovalRequest struct {
|
||||
UserID int64
|
||||
MonoforumID int64
|
||||
MessageID int
|
||||
Reject bool
|
||||
RejectComment string
|
||||
ScheduleDate int
|
||||
Date int
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleState is persisted so approval, scheduled publication,
|
||||
// settlement and refund remain idempotent across restarts.
|
||||
type SuggestedPostLifecycleState string
|
||||
|
||||
const (
|
||||
SuggestedPostStateBalanceLow SuggestedPostLifecycleState = "balance_low"
|
||||
SuggestedPostStateRejected SuggestedPostLifecycleState = "rejected"
|
||||
SuggestedPostStateScheduled SuggestedPostLifecycleState = "scheduled"
|
||||
SuggestedPostStatePublished SuggestedPostLifecycleState = "published"
|
||||
SuggestedPostStateCompleted SuggestedPostLifecycleState = "completed"
|
||||
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
|
||||
)
|
||||
|
||||
// ToggleSuggestedPostApprovalResult contains every durable update produced by
|
||||
// one command or lifecycle transition. OriginalEvent is an edit in the
|
||||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
// optional Published result is the broadcast post.
|
||||
type ToggleSuggestedPostApprovalResult struct {
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
PayerStarsBalance *StarsBalance
|
||||
PayerTONBalance *int64
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
|
||||
// indexed seek and row locks rather than scanning every approval.
|
||||
type SuggestedPostLifecycleRequest struct {
|
||||
Now int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
|
||||
// monoforum sub-dialog send (SavedPeer is the subscriber scope). Lookup is read-only and must
|
||||
// never re-run membership/permission checks or allocate pts/message ids.
|
||||
|
|
|
|||
|
|
@ -6,38 +6,41 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
ErrUserNotParticipant = errors.New("user not participant")
|
||||
ErrBotGroupsBlocked = errors.New("bot groups blocked")
|
||||
ErrReactionInvalid = errors.New("reaction invalid")
|
||||
ErrReactionsTooMany = errors.New("reactions too many")
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
ErrUserNotParticipant = errors.New("user not participant")
|
||||
ErrBotGroupsBlocked = errors.New("bot groups blocked")
|
||||
ErrReactionInvalid = errors.New("reaction invalid")
|
||||
ErrReactionsTooMany = errors.New("reactions too many")
|
||||
ErrSuggestedPostInvalid = errors.New("suggested post invalid")
|
||||
ErrSuggestedPostAlreadyHandled = errors.New("suggested post already handled")
|
||||
ErrSuggestedPostApprovalForbidden = errors.New("suggested post approval forbidden")
|
||||
)
|
||||
|
||||
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ type Message struct {
|
|||
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
|
||||
// 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
// RichMessage 是 Layer 228 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
|
||||
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
|
||||
|
|
@ -165,15 +165,14 @@ type Message struct {
|
|||
SavedPeer Peer
|
||||
}
|
||||
|
||||
// MessageRichMessage 是 Layer 227 富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
// MessageRichMessage 是 Layer 228 富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
// PageBlock(Blocks)+ 内嵌已解析的 Photos/Documents。
|
||||
//
|
||||
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
|
||||
// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在
|
||||
// domain 逐类型建模;rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化(domain 不依赖 tg)。
|
||||
// 与 message media 同理,Photos/Documents 存已解析快照(含 viewer 无关的 access_hash),
|
||||
// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessage(blocks 形态),不解析
|
||||
// HTML/Markdown 变体。
|
||||
// 投影复用 tgPhoto/tgDocument。HTML/Markdown 输入也会在 RPC 边界归一为同一组 Blocks。
|
||||
//
|
||||
// 已知局限:Blocks 是 gotd 线格式不透明字节,跨 gotd 版本(PageBlock 构造器变更)可能
|
||||
// 失效——富文本消息为全新实验特性、无存量数据,Phase 1 接受该耦合。
|
||||
|
|
@ -183,6 +182,10 @@ type MessageRichMessage struct {
|
|||
Blocks []byte `json:"blocks,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
// BotAPIProjection 是由 RPC 边界从同一组已校验 PageBlock 派生出的
|
||||
// Bot API RichMessage JSON。它不是第二事实源:写入边界只允许从 Blocks
|
||||
// 生成,HTTP Bot API 投影只读,避免 botapi 包反向依赖 tg 类型。
|
||||
BotAPIProjection []byte `json:"bot_api_projection,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message)。
|
||||
|
|
@ -289,7 +292,7 @@ type SendPrivateTextRequest struct {
|
|||
BusinessAutomationKind BusinessAutomationKind
|
||||
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
// RichMessage 是 Layer 228 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,12 @@ const (
|
|||
// MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。
|
||||
MarkupButtonCallback MarkupButtonType = "callback"
|
||||
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
// MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth.
|
||||
// The target bot is resolved and the linked origin is verified before the
|
||||
// message is persisted; ButtonID is the stable flattened keyboard index
|
||||
// returned to clients as keyboardButtonUrlAuth.button_id.
|
||||
MarkupButtonLoginURL MarkupButtonType = "login_url"
|
||||
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
|
||||
MarkupButtonRequestLocation MarkupButtonType = "request_location"
|
||||
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
|
||||
|
|
@ -122,6 +127,13 @@ type MarkupButton struct {
|
|||
Data []byte `json:"data,omitempty"`
|
||||
// URL 仅 url 使用。
|
||||
URL string `json:"url,omitempty"`
|
||||
// Login URL-only fields. LoginBotUserID=0 means the sending bot until the
|
||||
// RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be
|
||||
// cleared before persistence.
|
||||
ForwardText string `json:"forward_text,omitempty"`
|
||||
LoginBotUserID int64 `json:"login_bot_user_id,omitempty"`
|
||||
LoginBotUsername string `json:"login_bot_username,omitempty"`
|
||||
RequestWriteAccess bool `json:"request_write_access,omitempty"`
|
||||
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
|
||||
// 2FA SRP 校验 P3 stub)。
|
||||
RequiresPassword bool `json:"requires_password,omitempty"`
|
||||
|
|
@ -343,6 +355,14 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
|
|||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
case MarkupButtonLoginURL:
|
||||
if err := validateLoginButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen ||
|
||||
utf8.RuneCountInString(b.LoginBotUsername) > 64 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
case MarkupButtonWebView:
|
||||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
|
|
@ -374,6 +394,27 @@ func validateButtonURL(raw string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// validateLoginButtonURL performs only the protocol-shape validation shared by
|
||||
// Bot API and MTProto input buttons. The Telegram Login service remains the
|
||||
// authority for the deployment policy: HTTP is accepted here as a protocol
|
||||
// shape, then allowed only when the Login HTTP switch is enabled and the exact
|
||||
// origin is registered.
|
||||
func validateLoginButtonURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || u.User != nil {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer →
|
||||
// 解挂等待中的 getBotCallbackAnswer)。
|
||||
type BotCallbackAnswer struct {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ func TestValidateReplyMarkup(t *testing.T) {
|
|||
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
|
||||
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
|
||||
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
|
||||
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
|
||||
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
|
||||
{"login url public http host ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com:3000/login"}}}}, nil},
|
||||
{"login url public http ip ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://192.0.2.25:18080/login"}}}}, nil},
|
||||
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
|
||||
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package domain
|
|||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -139,6 +140,7 @@ func (k StarGiftAttributeRarityKind) Valid() bool {
|
|||
|
||||
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille
|
||||
// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。
|
||||
// 每类仍必须提供至少两个客户端可区分的普通升级属性,否则 TDesktop 的升级滚动无法结束。
|
||||
type StarGiftCollectibleAttribute struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
|
|
@ -325,6 +327,45 @@ type StarGiftUpgradeRequest struct {
|
|||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
|
||||
// Admin-controlled attribute overrides. When non-zero these pin the specific
|
||||
// collectible model/pattern/backdrop instead of the random pool draw. They
|
||||
// are only honoured on the admin grant path; the DB FK (attribute must belong
|
||||
// to the revision) remains the source of truth. The collectible number is
|
||||
// always assigned automatically (sequential).
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
}
|
||||
|
||||
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
|
||||
// Recipient from the official system account 777000 at no charge.
|
||||
// When Upgrade is set the gift is minted as a collectible; the optional
|
||||
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
|
||||
// collectible number is always assigned automatically.
|
||||
type AdminStarGiftGrant struct {
|
||||
SenderID int64
|
||||
Recipient Peer
|
||||
GiftID int64
|
||||
HideName bool
|
||||
Message string
|
||||
Upgrade bool
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
}
|
||||
|
||||
// AdminStarGiftGrantResult is the committed direct collectible assignment.
|
||||
// The saved gift, unique issuance, private message and replay receipt are one
|
||||
// aggregate transaction.
|
||||
type AdminStarGiftGrantResult struct {
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftPurchaseRequest struct {
|
||||
|
|
@ -879,6 +920,9 @@ const (
|
|||
MaxStarGiftCollectionTitleRunes = 12
|
||||
MaxStarGiftCollectionsPerPeer = 100
|
||||
MaxStarGiftCollectionItems = 1000
|
||||
// MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to
|
||||
// official clients. Pin requests are complete replacement vectors.
|
||||
MaxPinnedStarGifts = 6
|
||||
)
|
||||
|
||||
// Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。
|
||||
|
|
@ -932,7 +976,10 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
|
|||
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false)
|
||||
if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStarGiftUpgradePreviewPool(write, false)
|
||||
}
|
||||
|
||||
// ValidateStarGiftCollectibleWrite validates a complete publish command. Published pools are
|
||||
|
|
@ -947,7 +994,62 @@ func ValidateStarGiftCollectibleWrite(write StarGiftCollectibleWrite) error {
|
|||
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true)
|
||||
if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStarGiftUpgradePreviewPool(write, true)
|
||||
}
|
||||
|
||||
// validateStarGiftUpgradePreviewPool protects the official-client animation contract. The
|
||||
// preview response includes the target attribute plus the published selectable pool; TDesktop
|
||||
// deduplicates models and patterns by document identity and needs a non-target item in every
|
||||
// category before its spinner can transition to the finished state.
|
||||
func validateStarGiftUpgradePreviewPool(write StarGiftCollectibleWrite, requireStoredAsset bool) error {
|
||||
validateAnimated := func(kind StarGiftCollectibleAttributeKind, attributes []StarGiftCollectibleAttribute) error {
|
||||
selectable := 0
|
||||
documents := make(map[int64]struct{}, len(attributes))
|
||||
for _, attribute := range attributes {
|
||||
if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted {
|
||||
continue
|
||||
}
|
||||
selectable++
|
||||
if requireStoredAsset {
|
||||
if attribute.Document == nil {
|
||||
return fmt.Errorf("%w: %s preview attribute has no document", ErrStarGiftCollectibleInvalid, kind)
|
||||
}
|
||||
documents[attribute.Document.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if selectable < 2 {
|
||||
return fmt.Errorf("%w: %s preview requires at least two selectable attributes", ErrStarGiftCollectibleInvalid, kind)
|
||||
}
|
||||
if requireStoredAsset && len(documents) < 2 {
|
||||
return fmt.Errorf("%w: %s preview requires at least two distinct documents", ErrStarGiftCollectibleInvalid, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateAnimated(StarGiftCollectibleModel, write.Models); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAnimated(StarGiftCollectiblePattern, write.Patterns); err != nil {
|
||||
return err
|
||||
}
|
||||
seenBackdropIDs := make(map[int]struct{}, len(write.Backdrops))
|
||||
selectableBackdrops := 0
|
||||
for _, attribute := range write.Backdrops {
|
||||
if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted {
|
||||
continue
|
||||
}
|
||||
selectableBackdrops++
|
||||
if _, exists := seenBackdropIDs[attribute.BackdropID]; exists {
|
||||
return fmt.Errorf("%w: duplicate backdrop_id %d", ErrStarGiftCollectibleInvalid, attribute.BackdropID)
|
||||
}
|
||||
seenBackdropIDs[attribute.BackdropID] = struct{}{}
|
||||
}
|
||||
if selectableBackdrops < 2 {
|
||||
return fmt.Errorf("%w: backdrop preview requires at least two selectable attributes", ErrStarGiftCollectibleInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind StarGiftCollectibleAttributeKind, requireStoredAsset bool) error {
|
||||
|
|
|
|||
|
|
@ -48,13 +48,16 @@ func validCollectibleDraft() StarGiftCollectibleWrite {
|
|||
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
|
||||
Models: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -103,15 +106,59 @@ func storedCollectibleWrite() StarGiftCollectibleWrite {
|
|||
}
|
||||
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
|
||||
}
|
||||
write.Patterns[0].Document = &Document{
|
||||
ID: 200, MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
|
||||
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
|
||||
for i := range write.Patterns {
|
||||
write.Patterns[i].Document = &Document{
|
||||
ID: int64(200 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
|
||||
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
|
||||
}
|
||||
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
|
||||
}
|
||||
write.Patterns[0].Blob = &FileBlob{LocationKey: "pattern"}
|
||||
return write
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"one selectable model": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models = append(write.Models[:1], write.Models[2:]...)
|
||||
},
|
||||
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns = write.Patterns[:1]
|
||||
},
|
||||
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops = write.Backdrops[:1]
|
||||
},
|
||||
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
|
||||
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
if kind == StarGiftCollectibleModel {
|
||||
write.Models[1].Document = write.Models[0].Document
|
||||
} else {
|
||||
write.Patterns[1].Document = write.Patterns[0].Document
|
||||
}
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
|
||||
t.Fatalf("valid stored collectible: %v", err)
|
||||
|
|
|
|||
|
|
@ -21,20 +21,21 @@ type StarsBalance struct {
|
|||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
|
|
|
|||
506
internal/domain/telegram_login.go
Normal file
506
internal/domain/telegram_login.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTelegramLoginClientInvalid = errors.New("telegram login client invalid")
|
||||
ErrTelegramLoginClientDisabled = errors.New("telegram login client disabled")
|
||||
ErrTelegramLoginURLInvalid = errors.New("telegram login url invalid")
|
||||
ErrTelegramLoginRequestInvalid = errors.New("telegram login request invalid")
|
||||
ErrTelegramLoginRequestExpired = errors.New("telegram login request expired")
|
||||
ErrTelegramLoginRequestConflict = errors.New("telegram login request conflict")
|
||||
ErrTelegramLoginMatchCodeInvalid = errors.New("telegram login match code invalid")
|
||||
ErrTelegramLoginScopeInvalid = errors.New("telegram login scope invalid")
|
||||
ErrTelegramLoginCodeInvalid = errors.New("telegram login code invalid")
|
||||
ErrTelegramLoginCodeConsumed = errors.New("telegram login code consumed")
|
||||
ErrTelegramLoginWebAuthHashInvalid = errors.New("telegram login web authorization hash invalid")
|
||||
ErrTelegramLoginRedirectNotAllowed = errors.New("telegram login redirect not allowed")
|
||||
ErrTelegramLoginOriginNotAllowed = errors.New("telegram login origin not allowed")
|
||||
ErrTelegramLoginSecretInvalid = errors.New("telegram login client secret invalid")
|
||||
ErrTelegramLoginPKCEInvalid = errors.New("telegram login pkce invalid")
|
||||
ErrTelegramLoginAuthorizationsTooMany = errors.New("telegram login authorizations too many")
|
||||
)
|
||||
|
||||
const MaxTelegramLoginWebAuthorizations = 1000
|
||||
|
||||
type TelegramLoginSigningAlgorithm string
|
||||
|
||||
const (
|
||||
TelegramLoginSigningRS256 TelegramLoginSigningAlgorithm = "RS256"
|
||||
TelegramLoginSigningES256 TelegramLoginSigningAlgorithm = "ES256"
|
||||
TelegramLoginSigningEdDSA TelegramLoginSigningAlgorithm = "EdDSA"
|
||||
TelegramLoginSigningES256K TelegramLoginSigningAlgorithm = "ES256K"
|
||||
)
|
||||
|
||||
func (a TelegramLoginSigningAlgorithm) Valid() bool {
|
||||
switch a {
|
||||
case TelegramLoginSigningRS256, TelegramLoginSigningES256, TelegramLoginSigningEdDSA, TelegramLoginSigningES256K:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramLoginScope string
|
||||
|
||||
const (
|
||||
TelegramLoginScopeOpenID TelegramLoginScope = "openid"
|
||||
TelegramLoginScopeProfile TelegramLoginScope = "profile"
|
||||
TelegramLoginScopePhone TelegramLoginScope = "phone"
|
||||
TelegramLoginScopeBotAccess TelegramLoginScope = "telegram:bot_access"
|
||||
)
|
||||
|
||||
func (s TelegramLoginScope) Valid() bool {
|
||||
switch s {
|
||||
case TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone, TelegramLoginScopeBotAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramLoginClient struct {
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
SecretHash []byte
|
||||
SecretVersion int64
|
||||
SigningAlgorithm TelegramLoginSigningAlgorithm
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (c TelegramLoginClient) Clone() TelegramLoginClient {
|
||||
out := c
|
||||
out.SecretHash = append([]byte(nil), c.SecretHash...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c TelegramLoginClient) Validate() error {
|
||||
if c.BotUserID <= 0 || c.ClientID == "" || len(c.SecretHash) != 32 || c.SecretVersion <= 0 || !c.SigningAlgorithm.Valid() {
|
||||
return ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginAllowedURLKind string
|
||||
|
||||
const (
|
||||
TelegramLoginAllowedWebOrigin TelegramLoginAllowedURLKind = "web_origin"
|
||||
TelegramLoginAllowedRedirectURI TelegramLoginAllowedURLKind = "redirect_uri"
|
||||
)
|
||||
|
||||
type TelegramLoginAllowedURL struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Kind TelegramLoginAllowedURLKind
|
||||
NormalizedURL string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TelegramLoginNativePlatform string
|
||||
|
||||
const (
|
||||
TelegramLoginNativeIOS TelegramLoginNativePlatform = "ios"
|
||||
TelegramLoginNativeAndroid TelegramLoginNativePlatform = "android"
|
||||
)
|
||||
|
||||
type TelegramLoginNativeApp struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Platform TelegramLoginNativePlatform
|
||||
ApplicationID string
|
||||
// VerificationID is the 10-character Apple Team ID on iOS and the
|
||||
// normalized 64-hex SHA-256 signing-certificate fingerprint on Android.
|
||||
VerificationID string
|
||||
CallbackURI string
|
||||
VerifiedDisplayName string
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const MaxTelegramLoginNativeApps = 20
|
||||
|
||||
func (p TelegramLoginNativePlatform) Valid() bool {
|
||||
return p == TelegramLoginNativeIOS || p == TelegramLoginNativeAndroid
|
||||
}
|
||||
|
||||
func (a TelegramLoginNativeApp) Validate() error {
|
||||
if a.BotUserID <= 0 || !a.Platform.Valid() || a.ApplicationID == "" || len(a.ApplicationID) > 255 ||
|
||||
a.VerificationID == "" || a.CallbackURI == "" || len(a.CallbackURI) > 4096 ||
|
||||
a.VerifiedDisplayName == "" || len(a.VerifiedDisplayName) > 128 ||
|
||||
a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
|
||||
return ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginRequestSource string
|
||||
|
||||
const (
|
||||
TelegramLoginRequestWeb TelegramLoginRequestSource = "web"
|
||||
TelegramLoginRequestJavaScript TelegramLoginRequestSource = "javascript"
|
||||
TelegramLoginRequestNative TelegramLoginRequestSource = "native"
|
||||
TelegramLoginRequestMiniApp TelegramLoginRequestSource = "mini_app"
|
||||
TelegramLoginRequestMessageButton TelegramLoginRequestSource = "message_button"
|
||||
)
|
||||
|
||||
type TelegramLoginRequestState string
|
||||
|
||||
const (
|
||||
TelegramLoginRequestPending TelegramLoginRequestState = "pending"
|
||||
TelegramLoginRequestApproved TelegramLoginRequestState = "approved"
|
||||
TelegramLoginRequestDeclined TelegramLoginRequestState = "declined"
|
||||
TelegramLoginRequestExpired TelegramLoginRequestState = "expired"
|
||||
)
|
||||
|
||||
func (s TelegramLoginRequestState) Terminal() bool {
|
||||
return s == TelegramLoginRequestApproved || s == TelegramLoginRequestDeclined || s == TelegramLoginRequestExpired
|
||||
}
|
||||
|
||||
func CanTransitionTelegramLoginRequest(from, to TelegramLoginRequestState) bool {
|
||||
if from != TelegramLoginRequestPending {
|
||||
return false
|
||||
}
|
||||
return to == TelegramLoginRequestApproved || to == TelegramLoginRequestDeclined || to == TelegramLoginRequestExpired
|
||||
}
|
||||
|
||||
type TelegramLoginRequest struct {
|
||||
ID int64
|
||||
RequestTokenHash []byte
|
||||
BrowserTokenHash []byte
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
SigningAlgorithm TelegramLoginSigningAlgorithm
|
||||
Source TelegramLoginRequestSource
|
||||
ResponseType string
|
||||
RedirectURI string
|
||||
Origin string
|
||||
Domain string
|
||||
Scopes []TelegramLoginScope
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
InAppOrigin string
|
||||
IsApp bool
|
||||
VerifiedAppName string
|
||||
MatchCodes []string
|
||||
MatchCode string
|
||||
MatchCodesFirst bool
|
||||
UserIDHint int64
|
||||
PeerType PeerType
|
||||
PeerID int64
|
||||
MessageID int
|
||||
ButtonID int
|
||||
Status TelegramLoginRequestState
|
||||
AuthorizedUserID int64
|
||||
ProfileName string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
PreferredUsername string
|
||||
Picture string
|
||||
PhoneNumber string
|
||||
WriteAllowed bool
|
||||
PhoneShared bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ApprovedAt time.Time
|
||||
DeclinedAt time.Time
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Clone() TelegramLoginRequest {
|
||||
out := r
|
||||
out.RequestTokenHash = append([]byte(nil), r.RequestTokenHash...)
|
||||
out.BrowserTokenHash = append([]byte(nil), r.BrowserTokenHash...)
|
||||
out.Scopes = append([]TelegramLoginScope(nil), r.Scopes...)
|
||||
out.MatchCodes = append([]string(nil), r.MatchCodes...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Requests(scope TelegramLoginScope) bool {
|
||||
return slices.Contains(r.Scopes, scope)
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Validate() error {
|
||||
if len(r.RequestTokenHash) != 32 || len(r.BrowserTokenHash) != 32 || r.BotUserID <= 0 || r.ClientID == "" || r.ClientID != strings.TrimSpace(r.ClientID) || r.RedirectURI == "" || r.Domain == "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if !r.SigningAlgorithm.Valid() || !r.Source.Valid() || (r.ResponseType != "code" && r.ResponseType != "post_message" && r.ResponseType != "legacy_url") ||
|
||||
r.Status != TelegramLoginRequestPending || r.CreatedAt.IsZero() || !r.ExpiresAt.After(r.CreatedAt) {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
switch r.Source {
|
||||
case TelegramLoginRequestWeb:
|
||||
if r.ResponseType != "code" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestJavaScript:
|
||||
if r.ResponseType != "post_message" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestNative:
|
||||
if r.ResponseType != "code" || !r.IsApp || r.VerifiedAppName == "" || r.Origin != "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestMiniApp:
|
||||
if r.ResponseType != "post_message" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestMessageButton:
|
||||
if r.ResponseType != "legacy_url" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
default:
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.Source != TelegramLoginRequestNative && (r.IsApp || r.VerifiedAppName != "" || r.Origin == "") {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.AuthorizedUserID != 0 || r.ProfileName != "" || r.GivenName != "" || r.FamilyName != "" ||
|
||||
r.PreferredUsername != "" || r.Picture != "" || r.PhoneNumber != "" || r.WriteAllowed || r.PhoneShared ||
|
||||
!r.ApprovedAt.IsZero() || !r.DeclinedAt.IsZero() {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if len(r.RedirectURI) > 4096 || len(r.Origin) > 4096 || len(r.Domain) > 255 || len(r.InAppOrigin) > 4096 ||
|
||||
len(r.State) > 2048 || len(r.Nonce) > 1024 || len(r.Browser) == 0 || len(r.Browser) > 255 ||
|
||||
len(r.Platform) == 0 || len(r.Platform) > 255 || len(r.IP) == 0 || len(r.IP) > 128 ||
|
||||
len(r.Region) == 0 || len(r.Region) > 255 || len(r.VerifiedAppName) > 128 || r.UserIDHint < 0 ||
|
||||
r.PeerID < 0 || r.MessageID < 0 || r.ButtonID < 0 || len(r.MatchCodes) > 8 {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.ResponseType == "legacy_url" {
|
||||
if r.Source != TelegramLoginRequestMessageButton || r.PeerID <= 0 || r.MessageID <= 0 ||
|
||||
(r.PeerType != PeerTypeUser && r.PeerType != PeerTypeChannel) || r.CodeChallenge != "" || r.CodeChallengeMethod != "" ||
|
||||
len(r.MatchCodes) != 0 || r.MatchCode != "" || r.MatchCodesFirst {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if !slices.Contains(r.Scopes, TelegramLoginScopeOpenID) || !slices.Contains(r.Scopes, TelegramLoginScopeProfile) {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen := make(map[TelegramLoginScope]struct{}, len(r.Scopes))
|
||||
for _, scope := range r.Scopes {
|
||||
if !scope.Valid() || scope == TelegramLoginScopePhone {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if _, duplicate := seen[scope]; duplicate {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
} else if r.ResponseType == "code" {
|
||||
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
|
||||
return ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
} else {
|
||||
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
|
||||
return err
|
||||
}
|
||||
// Telegram's official JavaScript SDK returns an ID token directly and
|
||||
// therefore sends no authorization-code PKCE parameters. Accept a PKCE
|
||||
// pair for generic callers, but never a partial pair.
|
||||
if r.CodeChallenge == "" && r.CodeChallengeMethod == "" {
|
||||
// Official post_message/Mini App shape.
|
||||
} else if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
|
||||
return ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
if r.Source == TelegramLoginRequestMiniApp {
|
||||
if r.ResponseType != "post_message" || r.InAppOrigin == "" || r.Origin != r.InAppOrigin {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
} else if r.InAppOrigin != "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.MatchCodesFirst && len(r.MatchCodes) == 0 {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if len(r.MatchCodes) > 0 && (r.MatchCode == "" || !slices.Contains(r.MatchCodes, r.MatchCode)) {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TelegramLoginMessageButtonAuthorization is the domain-only input for the
|
||||
// legacy login_url consent path. BotToken is used transiently to produce the
|
||||
// official HMAC response and is never persisted in the login aggregate.
|
||||
type TelegramLoginMessageButtonAuthorization struct {
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
BotToken string
|
||||
URL string
|
||||
RequestWriteAccess bool
|
||||
WriteAllowed bool
|
||||
Peer Peer
|
||||
MessageID int
|
||||
ButtonID int
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
Identity TelegramLoginIdentitySnapshot
|
||||
}
|
||||
|
||||
type TelegramLoginMessageButtonResult struct {
|
||||
URL string
|
||||
Request TelegramLoginRequest
|
||||
WebAuthorization TelegramLoginWebAuthorization
|
||||
}
|
||||
|
||||
func (s TelegramLoginRequestSource) Valid() bool {
|
||||
switch s {
|
||||
case TelegramLoginRequestWeb, TelegramLoginRequestJavaScript, TelegramLoginRequestNative,
|
||||
TelegramLoginRequestMiniApp, TelegramLoginRequestMessageButton:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TelegramLoginIdentitySnapshot is the immutable identity presented on the
|
||||
// approval screen and later signed into the ID token. It is written together
|
||||
// with the pending->approved transition so a profile/phone mutation between
|
||||
// approval and code exchange cannot change what the relying party receives.
|
||||
type TelegramLoginIdentitySnapshot struct {
|
||||
UserID int64
|
||||
Name string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
PreferredUsername string
|
||||
Picture string
|
||||
PhoneNumber string
|
||||
}
|
||||
|
||||
func (s TelegramLoginIdentitySnapshot) Sanitized(includeProfile, includePhone bool) (TelegramLoginIdentitySnapshot, error) {
|
||||
if s.UserID <= 0 {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
out := TelegramLoginIdentitySnapshot{UserID: s.UserID}
|
||||
if includeProfile {
|
||||
out.Name = strings.TrimSpace(s.Name)
|
||||
out.GivenName = strings.TrimSpace(s.GivenName)
|
||||
out.FamilyName = strings.TrimSpace(s.FamilyName)
|
||||
out.PreferredUsername = strings.TrimSpace(s.PreferredUsername)
|
||||
out.Picture = strings.TrimSpace(s.Picture)
|
||||
if out.Name == "" || out.GivenName == "" {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
}
|
||||
if includePhone {
|
||||
out.PhoneNumber = NormalizePhone(s.PhoneNumber)
|
||||
if !ValidPhone(out.PhoneNumber) {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrPhoneNumberInvalid
|
||||
}
|
||||
}
|
||||
if !boundedUTF8(out.Name, 255) || !boundedUTF8(out.GivenName, 255) || !boundedUTF8(out.FamilyName, 255) ||
|
||||
!boundedUTF8(out.PreferredUsername, 64) || !boundedUTF8(out.Picture, 4096) || len(out.PhoneNumber) > 32 {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func boundedUTF8(value string, maxBytes int) bool {
|
||||
return utf8.ValidString(value) && len(value) <= maxBytes
|
||||
}
|
||||
|
||||
func ValidateTelegramLoginScopes(scopes []TelegramLoginScope, alg TelegramLoginSigningAlgorithm) error {
|
||||
if !alg.Valid() || len(scopes) == 0 || !slices.Contains(scopes, TelegramLoginScopeOpenID) {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen := make(map[TelegramLoginScope]struct{}, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
if !scope.Valid() {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if _, duplicate := seen[scope]; duplicate {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
if alg == TelegramLoginSigningEdDSA || alg == TelegramLoginSigningES256K {
|
||||
if len(scopes) != 1 || scopes[0] != TelegramLoginScopeOpenID {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginApproval struct {
|
||||
RequestID int64
|
||||
Identity TelegramLoginIdentitySnapshot
|
||||
WriteAllowed bool
|
||||
PhoneShared bool
|
||||
MatchCode string
|
||||
ApprovedAt time.Time
|
||||
}
|
||||
|
||||
type TelegramLoginAuthorizationCode struct {
|
||||
ID int64
|
||||
RequestID int64
|
||||
CodeHash []byte
|
||||
SealedCode []byte
|
||||
SealNonce []byte
|
||||
SealKeyID string
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ConsumedAt time.Time
|
||||
}
|
||||
|
||||
// TelegramLoginCodeExchange carries the values already normalized/hashed by
|
||||
// the application service. The durable store compares them again while the
|
||||
// code/request/client rows are locked, closing redirect, PKCE and secret-
|
||||
// rotation TOCTOU gaps between HTTP validation and one-time consumption.
|
||||
type TelegramLoginCodeExchange struct {
|
||||
CodeHash []byte
|
||||
ClientID string
|
||||
ClientSecretVersion int64
|
||||
RedirectURI string
|
||||
CodeChallenge string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func (c TelegramLoginAuthorizationCode) Clone() TelegramLoginAuthorizationCode {
|
||||
out := c
|
||||
out.CodeHash = append([]byte(nil), c.CodeHash...)
|
||||
out.SealedCode = append([]byte(nil), c.SealedCode...)
|
||||
out.SealNonce = append([]byte(nil), c.SealNonce...)
|
||||
return out
|
||||
}
|
||||
|
||||
type TelegramLoginWebAuthorization struct {
|
||||
Hash int64
|
||||
RequestID int64
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
Domain string
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
Scopes []TelegramLoginScope
|
||||
PhoneShared bool
|
||||
BotAccessGranted bool
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
func (a TelegramLoginWebAuthorization) Clone() TelegramLoginWebAuthorization {
|
||||
out := a
|
||||
out.Scopes = append([]TelegramLoginScope(nil), a.Scopes...)
|
||||
return out
|
||||
}
|
||||
112
internal/domain/telegram_login_test.go
Normal file
112
internal/domain/telegram_login_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateTelegramLoginScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes []TelegramLoginScope
|
||||
alg TelegramLoginSigningAlgorithm
|
||||
valid bool
|
||||
}{
|
||||
{name: "rs profile phone", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone}, alg: TelegramLoginSigningRS256, valid: true},
|
||||
{name: "missing openid", scopes: []TelegramLoginScope{TelegramLoginScopeProfile}, alg: TelegramLoginSigningRS256},
|
||||
{name: "duplicate", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeOpenID}, alg: TelegramLoginSigningRS256},
|
||||
{name: "unknown", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, "admin"}, alg: TelegramLoginSigningRS256},
|
||||
{name: "eddsa openid", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, alg: TelegramLoginSigningEdDSA, valid: true},
|
||||
{name: "eddsa profile forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile}, alg: TelegramLoginSigningEdDSA},
|
||||
{name: "es256k phone forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopePhone}, alg: TelegramLoginSigningES256K},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateTelegramLoginScopes(test.scopes, test.alg)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("ValidateTelegramLoginScopes() error = %v, valid = %v", err, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRequestTransitions(t *testing.T) {
|
||||
for _, terminal := range []TelegramLoginRequestState{
|
||||
TelegramLoginRequestApproved,
|
||||
TelegramLoginRequestDeclined,
|
||||
TelegramLoginRequestExpired,
|
||||
} {
|
||||
if !CanTransitionTelegramLoginRequest(TelegramLoginRequestPending, terminal) {
|
||||
t.Fatalf("pending -> %s must be valid", terminal)
|
||||
}
|
||||
if CanTransitionTelegramLoginRequest(terminal, TelegramLoginRequestPending) {
|
||||
t.Fatalf("%s -> pending must be forbidden", terminal)
|
||||
}
|
||||
}
|
||||
if CanTransitionTelegramLoginRequest(TelegramLoginRequestApproved, TelegramLoginRequestDeclined) {
|
||||
t.Fatal("approved -> declined must be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRequestSourceShapeMatrix(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
base := TelegramLoginRequest{
|
||||
RequestTokenHash: make([]byte, 32), BrowserTokenHash: make([]byte, 32),
|
||||
BotUserID: 9001, ClientID: "9001", SigningAlgorithm: TelegramLoginSigningRS256,
|
||||
Source: TelegramLoginRequestWeb, ResponseType: "code", RedirectURI: "https://rp.example/callback",
|
||||
Origin: "https://rp.example", Domain: "rp.example", Scopes: []TelegramLoginScope{TelegramLoginScopeOpenID},
|
||||
CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.1", Region: "Test",
|
||||
Status: TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
}
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatalf("valid web request: %v", err)
|
||||
}
|
||||
invalid := []struct {
|
||||
name string
|
||||
mutate func(*TelegramLoginRequest)
|
||||
}{
|
||||
{name: "web post message", mutate: func(r *TelegramLoginRequest) {
|
||||
r.ResponseType = "post_message"
|
||||
r.CodeChallenge = ""
|
||||
r.CodeChallengeMethod = ""
|
||||
}},
|
||||
{name: "javascript code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestJavaScript }},
|
||||
{name: "message button code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestMessageButton }},
|
||||
{name: "web app flag", mutate: func(r *TelegramLoginRequest) { r.IsApp = true; r.VerifiedAppName = "Forged" }},
|
||||
{name: "web missing origin", mutate: func(r *TelegramLoginRequest) { r.Origin = "" }},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := base.Clone()
|
||||
tc.mutate(&request)
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Fatal("forbidden source shape was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
native := base.Clone()
|
||||
native.Source, native.Origin, native.Domain = TelegramLoginRequestNative, "", "dev.bedolaga.demo"
|
||||
native.IsApp, native.VerifiedAppName = true, "Bedolaga"
|
||||
if err := native.Validate(); err != nil {
|
||||
t.Fatalf("valid native request: %v", err)
|
||||
}
|
||||
native.IsApp = false
|
||||
if err := native.Validate(); err == nil {
|
||||
t.Fatal("native request without verified app state was accepted")
|
||||
}
|
||||
|
||||
mini := base.Clone()
|
||||
mini.Source, mini.ResponseType = TelegramLoginRequestMiniApp, "post_message"
|
||||
mini.CodeChallenge, mini.CodeChallengeMethod = "", ""
|
||||
mini.RedirectURI, mini.InAppOrigin = "https://rp.example/", mini.Origin
|
||||
if err := mini.Validate(); err != nil {
|
||||
t.Fatalf("valid Mini App request: %v", err)
|
||||
}
|
||||
mini.InAppOrigin = "https://other.example"
|
||||
if err := mini.Validate(); err == nil {
|
||||
t.Fatal("Mini App origin mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -105,10 +105,21 @@ type User struct {
|
|||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
// RestrictionReasons are transient, viewer-scoped unavailability reasons.
|
||||
// They are produced after loading the viewer-independent base user and must
|
||||
// never be persisted in users or the base-user cache.
|
||||
RestrictionReasons []UserRestrictionReason
|
||||
// ContactNote/ContactNoteEntities are transient viewer-scoped contact
|
||||
// projection fields. They must never be persisted into users or a
|
||||
// viewer-independent base-user cache.
|
||||
ContactNote string
|
||||
ContactNoteEntities []MessageEntity
|
||||
// Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1(TDesktop 只认
|
||||
// user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14)。
|
||||
Bot bool
|
||||
|
|
@ -153,6 +164,23 @@ type User struct {
|
|||
AccountDeleteAt time.Time
|
||||
}
|
||||
|
||||
// UserRestrictionReason is the protocol-neutral form of Telegram's
|
||||
// restrictionReason. Platform "all" applies to TDesktop and official mobile
|
||||
// clients; Text is intentionally server supplied and directly user-visible.
|
||||
type UserRestrictionReason struct {
|
||||
Platform string
|
||||
Reason string
|
||||
Text string
|
||||
}
|
||||
|
||||
func AccountFrozenRestrictionReasons() []UserRestrictionReason {
|
||||
return []UserRestrictionReason{{
|
||||
Platform: "all",
|
||||
Reason: "frozen",
|
||||
Text: "This account is frozen.",
|
||||
}}
|
||||
}
|
||||
|
||||
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。
|
||||
func (u User) PremiumActiveAt(now int64) bool {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ var (
|
|||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserFrozen = errors.New("user account frozen")
|
||||
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
|
||||
// ErrPeerModerationFlagsInvalid rejects the impossible scam+fake state at
|
||||
// every write boundary shared by user, bot and channel projections.
|
||||
ErrPeerModerationFlagsInvalid = errors.New("peer moderation flags invalid")
|
||||
// ErrPremiumRequired 表示该操作仅限有效会员(PREMIUM_ACCOUNT_REQUIRED)。
|
||||
ErrPremiumRequired = errors.New("premium account required")
|
||||
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ const (
|
|||
)
|
||||
const MaxChatlistSlugBytes = 128
|
||||
|
||||
// AppLinkBuilder builds client-visible custom-scheme links. Without an
|
||||
// explicit base it preserves Telegram's route-as-host shape, for example
|
||||
// telesrv://oauth?token=... . A configured base uses an exact server host and
|
||||
// moves the route into the path, for example owpg://example.test/oauth?token=...
|
||||
// . The legacy scheme remains accepted so in-flight links survive a rollout.
|
||||
type AppLinkBuilder struct {
|
||||
legacyScheme string
|
||||
baseScheme string
|
||||
baseHost string
|
||||
}
|
||||
|
||||
// ValidateAppScheme normalizes the client-visible custom URL scheme used by
|
||||
// public landing pages. Standard Web schemes and Telegram's official tg scheme
|
||||
// are deliberately rejected: the latter remains a manual compatibility link
|
||||
|
|
@ -37,6 +48,123 @@ func ValidateAppScheme(raw string) (string, error) {
|
|||
return scheme, nil
|
||||
}
|
||||
|
||||
// ValidateAppLinkBase validates the optional host-based custom app-link root.
|
||||
// The base is deliberately limited to <custom-scheme>://<host>: routes, query
|
||||
// parameters, and fragments are owned by the individual link builders.
|
||||
func ValidateAppLinkBase(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse URL: %w", err)
|
||||
}
|
||||
if parsed.Opaque != "" {
|
||||
return "", fmt.Errorf("opaque URLs are not allowed")
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
return "", fmt.Errorf("scheme is required")
|
||||
}
|
||||
scheme, err := ValidateAppScheme(parsed.Scheme)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Host == "" || parsed.Hostname() == "" {
|
||||
return "", fmt.Errorf("host is required")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("credentials are not allowed")
|
||||
}
|
||||
if parsed.Port() != "" {
|
||||
return "", fmt.Errorf("port is not allowed")
|
||||
}
|
||||
if (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" {
|
||||
return "", fmt.Errorf("path is not allowed")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.ForceQuery {
|
||||
return "", fmt.Errorf("query parameters are not allowed")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment is not allowed")
|
||||
}
|
||||
parsed.Scheme = scheme
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Path = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func NewAppLinkBuilder(legacyScheme, rawBase string) (AppLinkBuilder, error) {
|
||||
legacyScheme, err := ValidateAppScheme(legacyScheme)
|
||||
if err != nil {
|
||||
return AppLinkBuilder{}, fmt.Errorf("legacy scheme: %w", err)
|
||||
}
|
||||
base, err := ValidateAppLinkBase(rawBase)
|
||||
if err != nil {
|
||||
return AppLinkBuilder{}, fmt.Errorf("app link base: %w", err)
|
||||
}
|
||||
builder := AppLinkBuilder{legacyScheme: legacyScheme}
|
||||
if base != "" {
|
||||
parsed, _ := url.Parse(base)
|
||||
builder.baseScheme = parsed.Scheme
|
||||
builder.baseHost = parsed.Host
|
||||
}
|
||||
return builder, nil
|
||||
}
|
||||
|
||||
func (b AppLinkBuilder) Build(route string, query url.Values) string {
|
||||
if b.baseHost != "" {
|
||||
return (&url.URL{
|
||||
Scheme: b.baseScheme,
|
||||
Host: b.baseHost,
|
||||
Path: "/" + strings.Trim(route, "/"),
|
||||
RawQuery: query.Encode(),
|
||||
}).String()
|
||||
}
|
||||
return (&url.URL{Scheme: b.legacyScheme, Host: route, RawQuery: query.Encode()}).String()
|
||||
}
|
||||
|
||||
// BuildUsername preserves the official resolve query in legacy mode while a
|
||||
// host-based multi-server client receives the public username as the path.
|
||||
func (b AppLinkBuilder) BuildUsername(username string, query url.Values) string {
|
||||
query = cloneValues(query)
|
||||
if b.baseHost != "" {
|
||||
query.Del("domain")
|
||||
return b.Build(username, query)
|
||||
}
|
||||
query.Set("domain", username)
|
||||
return b.Build("resolve", query)
|
||||
}
|
||||
|
||||
// MatchesRoute accepts the exact configured host-path form and the retained
|
||||
// legacy route-as-host form. Query validation remains the caller's concern.
|
||||
func (b AppLinkBuilder) MatchesRoute(parsed *url.URL, route string) bool {
|
||||
if parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawPath != "" {
|
||||
return false
|
||||
}
|
||||
if b.MatchesLegacyRoute(parsed, route) {
|
||||
return true
|
||||
}
|
||||
return b.baseHost != "" &&
|
||||
strings.EqualFold(parsed.Scheme, b.baseScheme) &&
|
||||
strings.EqualFold(parsed.Host, b.baseHost) &&
|
||||
parsed.Path == "/"+route
|
||||
}
|
||||
|
||||
func (b AppLinkBuilder) MatchesLegacyRoute(parsed *url.URL, route string) bool {
|
||||
return parsed != nil && parsed.Opaque == "" && parsed.User == nil && parsed.Fragment == "" && parsed.RawPath == "" &&
|
||||
strings.EqualFold(parsed.Scheme, b.legacyScheme) &&
|
||||
strings.EqualFold(parsed.Host, route) && parsed.Path == ""
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, entries := range values {
|
||||
cloned[key] = append([]string(nil), entries...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func ValidateAppName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,79 @@ func TestValidateAppScheme(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateAppLinkBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "disabled", raw: "", want: ""},
|
||||
{name: "normalized", raw: " OWPG://Example.Test/ ", want: "owpg://example.test"},
|
||||
{name: "missing host", raw: "owpg://", wantErr: true},
|
||||
{name: "reserved scheme", raw: "https://example.test", wantErr: true},
|
||||
{name: "credentials", raw: "owpg://user@example.test", wantErr: true},
|
||||
{name: "port", raw: "owpg://example.test:443", wantErr: true},
|
||||
{name: "path", raw: "owpg://example.test/root", wantErr: true},
|
||||
{name: "query", raw: "owpg://example.test?tenant=one", wantErr: true},
|
||||
{name: "fragment", raw: "owpg://example.test#root", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ValidateAppLinkBase(tc.raw)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("ValidateAppLinkBase(%q) error = %v, wantErr %v", tc.raw, err, tc.wantErr)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("ValidateAppLinkBase(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLinkBuilderPreservesLegacyAndSupportsHostBase(t *testing.T) {
|
||||
legacy, err := NewAppLinkBuilder("telesrv", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := legacy.Build("oauth", url.Values{"token": {"a+b"}}), "telesrv://oauth?token=a%2Bb"; got != want {
|
||||
t.Fatalf("legacy OAuth = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := legacy.BuildUsername("Alice", url.Values{"start": {"hello"}}), "telesrv://resolve?domain=Alice&start=hello"; got != want {
|
||||
t.Fatalf("legacy username = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
hosted, err := NewAppLinkBuilder("telesrv", "owpg://links.example.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := hosted.Build("oauth", url.Values{"token": {"a+b"}}), "owpg://links.example.test/oauth?token=a%2Bb"; got != want {
|
||||
t.Fatalf("hosted OAuth = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := hosted.BuildUsername("Alice", url.Values{"domain": {"spoofed"}, "start": {"hello"}}), "owpg://links.example.test/Alice?start=hello"; got != want {
|
||||
t.Fatalf("hosted username = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{raw: "telesrv://oauth?token=x", want: true},
|
||||
{raw: "owpg://links.example.test/oauth?token=x", want: true},
|
||||
{raw: "owpg://other.example.test/oauth?token=x", want: false},
|
||||
{raw: "owpg://links.example.test/oauth/extra?token=x", want: false},
|
||||
{raw: "owpg://links.example.test/resolve?token=x", want: false},
|
||||
} {
|
||||
parsed, err := url.Parse(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hosted.MatchesRoute(parsed, "oauth"); got != tc.want {
|
||||
t.Fatalf("MatchesRoute(%q) = %v, want %v", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppName(t *testing.T) {
|
||||
if got, err := ValidateAppName(" Example Chat "); err != nil || got != "Example Chat" {
|
||||
t.Fatalf("ValidateAppName valid = %q, %v", got, err)
|
||||
|
|
|
|||
|
|
@ -391,14 +391,10 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
ID)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountGetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountGetWebAuthorizationsRequest) (any, error) {
|
||||
return tdesktop.WebAuthorizations(), nil
|
||||
return r.onAccountGetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorization, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
Hash
|
||||
_ = hash
|
||||
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorization(ctx, layerRequest.Hash)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationsRequest) (
|
||||
|
||||
|
|
@ -406,7 +402,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
// (无内置浏览器例外、不强制外部浏览器)。Android 启动时会拉取,缺它会反复 500
|
||||
// NOT_IMPLEMENTED。空结构 Hash=0,客户端按默认(内置浏览器、无例外)渲染。
|
||||
any, error) {
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebBrowserSettingsRequest](d, tlprofile.SemanticMethodAccountGetWebBrowserSettings, func(ctx context.Context, layerRequest *tg.AccountGetWebBrowserSettingsRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser
|
|||
settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser)
|
||||
}
|
||||
if settings.BusinessBotManageURL == "" {
|
||||
settings.BusinessBotManageURL = "telesrv://business-bot"
|
||||
settings.BusinessBotManageURL = r.publicAppLink("business-bot")
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
|
|
|||
107
internal/rpc/account_freeze_worker.go
Normal file
107
internal/rpc/account_freeze_worker.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type accountFreezeNotificationService interface {
|
||||
ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
|
||||
CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error
|
||||
}
|
||||
|
||||
// RunAccountFreezeNotifications drains the crash-safe, coalesced non-pts
|
||||
// updateUser queue. One attempt is enough for online delivery; offline clients
|
||||
// recover the current state from viewer-scoped user hydration.
|
||||
func (r *Router) RunAccountFreezeNotifications(ctx context.Context, interval time.Duration, batch int) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 500
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
r.drainAccountFreezeNotifications(ctx, batch)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-r.accountFreezeWake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int) {
|
||||
svc, ok := r.deps.AccountFreeze.(accountFreezeNotificationService)
|
||||
if !ok || r.deps.Users == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
now := r.clock.Now().UTC()
|
||||
claimCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
notifications, err := svc.ClaimAccountFreezeNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.log.Warn("claim account freeze notifications failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
r.dispatchAccountFreezeNotification(ctx, svc, notification)
|
||||
}
|
||||
if len(notifications) < batch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc accountFreezeNotificationService, notification domain.AccountFreezeNotification) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: notification.FrozenUserID}
|
||||
if contacts, ok := r.deps.Contacts.(interface{ InvalidateViewers(...int64) }); ok {
|
||||
contacts.InvalidateViewers(notification.TargetUserID)
|
||||
}
|
||||
if dialogs, ok := r.deps.Dialogs.(interface {
|
||||
InvalidateDialog(int64, domain.Peer)
|
||||
}); ok {
|
||||
dialogs.InvalidateDialog(notification.TargetUserID, peer)
|
||||
}
|
||||
r.invalidateRPCProjectionForPeer(notification.TargetUserID, peer)
|
||||
|
||||
loadCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
user, found, err := r.deps.Users.ByID(loadCtx, notification.TargetUserID, notification.FrozenUserID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.log.Warn("load frozen user projection for notification failed",
|
||||
zap.Int64("target_user_id", notification.TargetUserID),
|
||||
zap.Int64("frozen_user_id", notification.FrozenUserID),
|
||||
zap.Int64("version", notification.Version),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
user = domain.User{ID: notification.FrozenUserID, Deleted: true}
|
||||
}
|
||||
pushCtx, pushCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
r.pushUserUpdates(pushCtx, notification.TargetUserID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.FrozenUserID}},
|
||||
Users: r.tgUsersForViewer(notification.TargetUserID, []domain.User{user}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
pushCancel()
|
||||
|
||||
completeCtx, completeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err = svc.CompleteAccountFreezeNotification(completeCtx, notification.ID, notification.Version, r.clock.Now().UTC())
|
||||
completeCancel()
|
||||
if err != nil {
|
||||
r.log.Warn("complete account freeze notification failed",
|
||||
zap.Int64("notification_id", notification.ID),
|
||||
zap.Int64("version", notification.Version),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
136
internal/rpc/account_freeze_worker_test.go
Normal file
136
internal/rpc/account_freeze_worker_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestAccountFreezeNotificationPushesCurrentViewerProjection(t *testing.T) {
|
||||
const (
|
||||
viewerID = int64(1001)
|
||||
frozenID = int64(1002)
|
||||
)
|
||||
sessions := &captureSessions{}
|
||||
freezeSvc := &freezeWorkerService{}
|
||||
users := &freezeWorkerUsers{user: domain.User{
|
||||
ID: frozenID,
|
||||
FirstName: "Frozen",
|
||||
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
|
||||
}}
|
||||
r := New(Config{}, Deps{
|
||||
AccountFreeze: freezeSvc,
|
||||
Users: users,
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, domain.AccountFreezeNotification{
|
||||
ID: 7, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 4, Frozen: true,
|
||||
})
|
||||
|
||||
if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{7, 4} {
|
||||
t.Fatalf("completed = %v, want [[7 4]]", freezeSvc.completed)
|
||||
}
|
||||
if got := sessions.pushedUserIDs(); len(got) != 1 || got[0] != viewerID {
|
||||
t.Fatalf("pushed user IDs = %v, want [%d]", got, viewerID)
|
||||
}
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 || len(updates.Users) != 1 {
|
||||
t.Fatalf("push = %#v, want updateUser plus projected user", sessions.lastUserPush())
|
||||
}
|
||||
if update, ok := updates.Updates[0].(*tg.UpdateUser); !ok || update.UserID != frozenID {
|
||||
t.Fatalf("update = %#v, want updateUser(%d)", updates.Updates[0], frozenID)
|
||||
}
|
||||
projected, ok := updates.Users[0].(*tg.User)
|
||||
if !ok || !projected.Restricted {
|
||||
t.Fatalf("projected user = %#v, want restricted user", updates.Users[0])
|
||||
}
|
||||
reasons, ok := projected.GetRestrictionReason()
|
||||
if !ok || len(reasons) != 1 || reasons[0].Reason != "frozen" {
|
||||
t.Fatalf("projected restriction = %+v ok=%v", reasons, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezeNotificationLoadsCurrentStateAndRetriesLoadFailure(t *testing.T) {
|
||||
const (
|
||||
viewerID = int64(2001)
|
||||
frozenID = int64(2002)
|
||||
)
|
||||
sessions := &captureSessions{}
|
||||
freezeSvc := &freezeWorkerService{}
|
||||
users := &freezeWorkerUsers{err: errors.New("projection unavailable")}
|
||||
r := New(Config{}, Deps{
|
||||
AccountFreeze: freezeSvc,
|
||||
Users: users,
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
notification := domain.AccountFreezeNotification{
|
||||
ID: 8, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 5, Frozen: true,
|
||||
}
|
||||
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification)
|
||||
if len(freezeSvc.completed) != 0 || len(sessions.pushedUserIDs()) != 0 {
|
||||
t.Fatalf("failed load completed=%v pushes=%v, want retry without push", freezeSvc.completed, sessions.pushedUserIDs())
|
||||
}
|
||||
|
||||
// The queued payload may say frozen, but delivery must hydrate the latest
|
||||
// viewer projection so a newer unfreeze can never be overwritten by stale work.
|
||||
users.err = nil
|
||||
users.user = domain.User{ID: frozenID, FirstName: "Active"}
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification)
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Users) != 1 {
|
||||
t.Fatalf("push = %#v", sessions.lastUserPush())
|
||||
}
|
||||
projected, ok := updates.Users[0].(*tg.User)
|
||||
if !ok || projected.Restricted {
|
||||
t.Fatalf("latest projected user = %#v, want unrestricted", updates.Users[0])
|
||||
}
|
||||
if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{8, 5} {
|
||||
t.Fatalf("completed = %v, want [[8 5]]", freezeSvc.completed)
|
||||
}
|
||||
}
|
||||
|
||||
type freezeWorkerService struct {
|
||||
completed [][2]int64
|
||||
}
|
||||
|
||||
func (*freezeWorkerService) AccountFreeze(context.Context, int64) (domain.AccountFreeze, bool, error) {
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
|
||||
func (*freezeWorkerService) ClaimAccountFreezeNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountFreezeNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *freezeWorkerService) CompleteAccountFreezeNotification(_ context.Context, id, version int64, _ time.Time) error {
|
||||
s.completed = append(s.completed, [2]int64{id, version})
|
||||
return nil
|
||||
}
|
||||
|
||||
type freezeWorkerUsers struct {
|
||||
user domain.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) Self(context.Context, int64) (domain.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) ByID(context.Context, int64, int64) (domain.User, bool, error) {
|
||||
return s.user, s.err == nil, s.err
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) ByIDs(context.Context, int64, []int64) ([]domain.User, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return []domain.User{s.user}, nil
|
||||
}
|
||||
|
|
@ -46,3 +46,20 @@ func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.S
|
|||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAccountFreezeChanged invalidates target-scoped projections immediately
|
||||
// and wakes the durable audience nudge worker. Cross-instance cache invalidation
|
||||
// is also carried by the committed user_visibility read-model notification.
|
||||
func (r *Router) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error {
|
||||
if r == nil || freeze.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(freeze.UserID)
|
||||
if r.accountFreezeWake != nil {
|
||||
select {
|
||||
case r.accountFreezeWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ import (
|
|||
)
|
||||
|
||||
type androidPrivateLayerFixture struct {
|
||||
name string
|
||||
privateID uint32
|
||||
semantic tlprofile.SemanticID
|
||||
method string
|
||||
wire func(*testing.T) []byte
|
||||
name string
|
||||
privateID uint32
|
||||
semantic tlprofile.SemanticID
|
||||
method string
|
||||
currentJoinResult bool
|
||||
wire func(*testing.T) []byte
|
||||
}
|
||||
|
||||
// TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary is the production
|
||||
|
|
@ -34,7 +35,7 @@ type androidPrivateLayerFixture struct {
|
|||
func TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
fixtures := androidPrivateLayerFixtures()
|
||||
if got, want := len(fixtures), 15; got != want {
|
||||
if got, want := len(fixtures), 17; got != want {
|
||||
t.Fatalf("private fixture count = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +87,17 @@ func TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary(t *testing.T) {
|
|||
if !ok || call.WireID() != wantWireID {
|
||||
t.Fatalf("admitted exact id = %#x, want %#x (ok=%v)", call.WireID(), wantWireID, ok)
|
||||
}
|
||||
if fixture.currentJoinResult {
|
||||
var result bin.Buffer
|
||||
if err := call.EncodeResult(&tg.MessagesChatInviteJoinResultOk{
|
||||
Updates: &tg.UpdatesTooLong{},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("encode current join result: %v", err)
|
||||
}
|
||||
if wireID, err := result.PeekID(); err != nil || wireID != 0x445663a7 {
|
||||
t.Fatalf("result wire = %#x err=%v, want chatInviteJoinResultOk#445663a7", wireID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -109,6 +121,24 @@ func androidPrivateLayerFixtures() []androidPrivateLayerFixture {
|
|||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "messages.importChatInvite_alias", privateID: 0x6c50051c,
|
||||
semantic: tlprofile.SemanticMethodMessagesImportChatInvite, method: "messages.importChatInvite",
|
||||
currentJoinResult: true,
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x6c50051c, &tg.MessagesImportChatInviteRequest{Hash: "private-invite"})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "channels.joinChannel_alias", privateID: 0x24b524c5,
|
||||
semantic: tlprofile.SemanticMethodChannelsJoinChannel, method: "channels.joinChannel",
|
||||
currentJoinResult: true,
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x24b524c5, &tg.ChannelsJoinChannelRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: 45, AccessHash: 46},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updates.getDifference_alias", privateID: 0x25939651,
|
||||
semantic: tlprofile.SemanticMethodUpdatesGetDifference, method: "updates.getDifference",
|
||||
|
|
@ -121,7 +151,6 @@ func androidPrivateLayerFixtures() []androidPrivateLayerFixture {
|
|||
semantic: tlprofile.SemanticMethodMessagesCreateChat, method: "messages.createChat",
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x0034a818, &tg.MessagesCreateChatRequest{
|
||||
Users: []tg.InputUserClass{&tg.InputUser{UserID: 51, AccessHash: 52}},
|
||||
Title: "private group",
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, replyMarkup, silent, reply)
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, nil, replyMarkup, silent, false, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
|
|
@ -229,6 +229,66 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
// BotAPISendRichMessage sends one durable rich message through the same
|
||||
// private/channel state machines as messages.sendMessage. The HTTP input is
|
||||
// parsed into canonical PageBlocks before any message row, pts or outbox entry
|
||||
// is written.
|
||||
func (r *Router) BotAPISendRichMessage(ctx context.Context, botID, chatID int64, input domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if effectID != 0 && (peer.Type != domain.PeerTypeUser || r.messageEffectInvalid(ctx, effectID)) {
|
||||
return domain.Message{}, effectIDInvalidErr()
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return domain.Message{}, richMessageInvalidErr()
|
||||
}
|
||||
var reply *domain.MessageReply
|
||||
if replyToMessageID > 0 {
|
||||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, "", nil, nil, richMessage, replyMarkup, silent, noForwards, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if r.deps.Users != nil && peer.ID != botID {
|
||||
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
|
||||
return domain.Message{}, err
|
||||
} else if !found {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botID, RecipientUserID: peer.ID, RandomID: randomNonZeroInt64(),
|
||||
RichMessage: richMessage, Silent: silent, NoForwards: noForwards, ReplyTo: reply,
|
||||
Date: int(time.Now().Unix()), ReplyMarkup: replyMarkup, Effect: effectID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
// BotAPISendMedia sends a photo/document message through the same files service
|
||||
// and private/channel message state machines used by MTProto sendMedia.
|
||||
func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
|
|
@ -257,7 +317,7 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, replyMarkup, silent, reply)
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, nil, replyMarkup, silent, false, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
|
|
@ -569,7 +629,7 @@ func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
|
|||
return domain.Peer{}, false
|
||||
}
|
||||
|
||||
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, replyMarkup *domain.MessageReplyMarkup, silent bool, reply *domain.MessageReply) (domain.Message, error) {
|
||||
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, richMessage *domain.MessageRichMessage, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, reply *domain.MessageReply) (domain.Message, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
|
|
@ -581,10 +641,12 @@ func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID
|
|||
Message: text,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
Media: media,
|
||||
RichMessage: richMessage,
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SkipRecipientLookup: true,
|
||||
PostAuthor: r.channelPostAuthorName(ctx, botID),
|
||||
Silent: silent,
|
||||
NoForwards: noForwards,
|
||||
ReplyTo: reply,
|
||||
ReplyMarkup: replyMarkup,
|
||||
Date: int(time.Now().Unix()),
|
||||
|
|
@ -772,6 +834,14 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID}
|
||||
if setReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID,
|
||||
Peer: peer,
|
||||
|
|
@ -781,6 +851,9 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
EditDate: int(time.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
// An explicit plain-text edit replaces a previous rich payload. Keeping
|
||||
// both would create a state that neither Bot API nor TDesktop permits.
|
||||
SetRichMessage: true,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
|
|
@ -792,6 +865,70 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return self.Message, nil
|
||||
}
|
||||
|
||||
// BotAPIEditRichMessage replaces message content with one rich payload while
|
||||
// preserving the existing durable edit/pts/outbox semantics.
|
||||
func (r *Router) BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return domain.Message{}, richMessageInvalidErr()
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
res, err := r.deps.Channels.EditMessage(ctx, botID, domain.EditChannelMessageRequest{
|
||||
UserID: botID, ChannelID: peer.ID, ID: messageID, Message: "",
|
||||
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: true, RichMessage: richMessage, EditDate: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, channelEditErr(err)
|
||||
}
|
||||
r.enqueueChannelEditMessageFanout(ctx, botID, res)
|
||||
return botAPIMessageFromChannel(botID, res.Message), nil
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID, Peer: peer, ID: messageID, Message: "", EditDate: int(time.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: true, RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
r.enqueueBotAPIPrivateEditUpdatesAsync(ctx, res)
|
||||
self := res.Self()
|
||||
if self.Message.ID == 0 {
|
||||
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
return self.Message, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
|
|
@ -805,6 +942,11 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
|
|||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
|
|
@ -823,6 +965,34 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
|
|||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{ID: tgInputBotInlineMessageID(inlineMessageID)}
|
||||
req.SetRichMessage(wire)
|
||||
if setReplyMarkup {
|
||||
markup := tgReplyMarkup(replyMarkup)
|
||||
if markup == nil {
|
||||
markup = &tg.ReplyInlineMarkup{}
|
||||
}
|
||||
req.SetReplyMarkup(markup)
|
||||
}
|
||||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
|
||||
// the target user's MTProto clients observe the normal delete update.
|
||||
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -249,6 +249,112 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessagePrivateSendEditAndPlainReplacement(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Admin</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage: %v", err)
|
||||
}
|
||||
if sent.ID <= 0 || sent.Pts <= 0 || sent.Body != "" || sent.RichMessage == nil || len(sent.RichMessage.BotAPIProjection) == 0 ||
|
||||
sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "menu:info" {
|
||||
t.Fatalf("sent rich message = %+v", sent)
|
||||
}
|
||||
|
||||
botHistory := privateBotAPIHistory(t, fixture, fixture.bot.ID, fixture.owner.ID)
|
||||
if botHistory.ID != sent.ID || botHistory.RichMessage == nil || botHistory.Body != "" {
|
||||
t.Fatalf("bot rich history = %+v", botHistory)
|
||||
}
|
||||
ownerHistory := privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || len(ownerHistory.RichMessage.BotAPIProjection) == 0 || ownerHistory.ReplyMarkup == nil {
|
||||
t.Fatalf("owner rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated\n\nSubscription: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("edited rich message = %+v projection=%s", edited, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || !strings.Contains(string(ownerHistory.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("owner edited rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
plain, err := fixture.router.BotAPIEditMessageText(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, "Classic menu", nil, false, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditMessageText replacing rich: %v", err)
|
||||
}
|
||||
if plain.Body != "Classic menu" || plain.RichMessage != nil {
|
||||
t.Fatalf("plain replacement = %+v", plain)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.Body != "Classic menu" || ownerHistory.RichMessage != nil {
|
||||
t.Fatalf("owner plain replacement history = %+v", ownerHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessageSupergroupSendAndEdit(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
chatID := -botAPIChannelChatIDBase - fixture.channel.ID
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Status", Data: []byte("channel:status"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, chatID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Group menu</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage channel: %v", err)
|
||||
}
|
||||
if sent.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || sent.ID <= 0 || sent.Pts <= 0 ||
|
||||
sent.RichMessage == nil || sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "channel:status" {
|
||||
t.Fatalf("sent channel rich message = %+v", sent)
|
||||
}
|
||||
history, err := fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil || history.Messages[0].Body != "" {
|
||||
t.Fatalf("channel rich history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, chatID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated group menu\n\nStatus: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage channel: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel rich message = %+v sent_pts=%d projection=%s", edited, sent.Pts, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
history, err = fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil ||
|
||||
!strings.Contains(string(history.Messages[0].RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func privateBotAPIHistory(t *testing.T, fixture botAPIReceiveFixture, ownerID, peerID int64) domain.Message {
|
||||
t.Helper()
|
||||
history, err := fixture.messages.GetHistory(fixture.ctx, ownerID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("GetHistory owner=%d peer=%d len=%d err=%v", ownerID, peerID, len(history.Messages), err)
|
||||
}
|
||||
return history.Messages[0]
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
|
|
|
|||
305
internal/rpc/botapi_rich_message.go
Normal file
305
internal/rpc/botapi_rich_message.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
richbuilder "github.com/iamxvbaba/td/telegram/message/rich"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
botAPIRichSentinelScheme = "telesrv-rich"
|
||||
botAPIRichDateMaxUnix = int64(1<<31 - 1)
|
||||
)
|
||||
|
||||
type botAPIHTMLTableSpec struct {
|
||||
bordered bool
|
||||
striped bool
|
||||
cells []botAPIHTMLTableCellSpec
|
||||
}
|
||||
|
||||
type botAPIHTMLTableCellSpec struct {
|
||||
align string
|
||||
valign string
|
||||
}
|
||||
|
||||
func tgInputRichMessageFromBotAPI(input domain.BotAPIRichMessageInput) (tg.InputRichMessageClass, error) {
|
||||
if input.SourceCount() != 1 || len(input.BlocksJSON) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if len(input.MediaJSON) != 0 {
|
||||
return nil, richMessageMediaUnsupportedErr()
|
||||
}
|
||||
if input.HTML != "" {
|
||||
return &tg.InputRichMessageHTML{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, HTML: input.HTML,
|
||||
}, nil
|
||||
}
|
||||
if input.Markdown != "" {
|
||||
return &tg.InputRichMessageMarkdown{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, Markdown: input.Markdown,
|
||||
}, nil
|
||||
}
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
|
||||
func parseBotAPIRichHTML(source string) ([]tg.PageBlockClass, error) {
|
||||
doc, err := html.Parse(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
tables := make([]botAPIHTMLTableSpec, 0)
|
||||
var transform func(*html.Node) error
|
||||
transform = func(node *html.Node) error {
|
||||
if node.Type == html.ElementNode {
|
||||
switch node.Data {
|
||||
case "img", "video", "audio", "tg-map", "tg-collage", "tg-slideshow":
|
||||
// The current local blob backend cannot materialize an arbitrary
|
||||
// rich HTML media URL atomically. Fail explicitly so Bedolaga's
|
||||
// documented one-shot no-logo retry is used instead of losing media.
|
||||
return webpageMediaEmptyErr()
|
||||
case "tg-time":
|
||||
unixTime, err := strconv.ParseInt(htmlNodeAttr(node, "unix"), 10, 64)
|
||||
if err != nil || unixTime <= 0 || unixTime > botAPIRichDateMaxUnix {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
format := htmlNodeAttr(node, "format")
|
||||
if _, ok := botAPIRichDateFlags(format); !ok {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
node.Data = "a"
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: fmt.Sprintf("%s://time?unix=%d&format=%s", botAPIRichSentinelScheme, unixTime, url.QueryEscape(format))}}
|
||||
case "footer":
|
||||
node.Data = "p"
|
||||
node.Attr = nil
|
||||
anchor := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{{Key: "href", Val: botAPIRichSentinelScheme + "://footer"}}}
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
anchor.AppendChild(child)
|
||||
child = next
|
||||
}
|
||||
node.AppendChild(anchor)
|
||||
case "table":
|
||||
tables = append(tables, botAPIHTMLTableSpecFromNode(node))
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if err := transform(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := transform(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized bytes.Buffer
|
||||
if err := html.Render(&normalized, doc); err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := richbuilder.ParseHTML(strings.NewReader(normalized.String()))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
postProcessBotAPIRichHTML(blocks, tables)
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func parseBotAPIRichMarkdown(source string) ([]tg.PageBlockClass, error) {
|
||||
blocks, err := richbuilder.ParseMarkdown(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func botAPIHTMLTableSpecFromNode(table *html.Node) botAPIHTMLTableSpec {
|
||||
spec := botAPIHTMLTableSpec{bordered: htmlNodeHasAttr(table, "bordered"), striped: htmlNodeHasAttr(table, "striped")}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
|
||||
spec.cells = append(spec.cells, botAPIHTMLTableCellSpec{
|
||||
align: strings.ToLower(htmlNodeAttr(child, "align")), valign: strings.ToLower(htmlNodeAttr(child, "valign")),
|
||||
})
|
||||
}
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(table)
|
||||
return spec
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichHTML(blocks []tg.PageBlockClass, tables []botAPIHTMLTableSpec) {
|
||||
tableIndex := 0
|
||||
var visit func([]tg.PageBlockClass)
|
||||
visit = func(items []tg.PageBlockClass) {
|
||||
for index, block := range items {
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
if footer, ok := botAPIRichFooterText(value.Text); ok {
|
||||
items[index] = &tg.PageBlockFooter{Text: postProcessBotAPIRichText(footer)}
|
||||
} else {
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
case *tg.PageBlockHeading1:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockFooter:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockBlockquote:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockDetails:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockTable:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
if tableIndex < len(tables) {
|
||||
spec := tables[tableIndex]
|
||||
tableIndex++
|
||||
value.Bordered, value.Striped = spec.bordered, spec.striped
|
||||
cellIndex := 0
|
||||
for rowIndex := range value.Rows {
|
||||
for columnIndex := range value.Rows[rowIndex].Cells {
|
||||
cell := &value.Rows[rowIndex].Cells[columnIndex]
|
||||
cell.Text = postProcessBotAPIRichText(cell.Text)
|
||||
if cellIndex < len(spec.cells) {
|
||||
cellSpec := spec.cells[cellIndex]
|
||||
cell.AlignCenter = cellSpec.align == "center"
|
||||
cell.AlignRight = cellSpec.align == "right"
|
||||
cell.ValignMiddle = cellSpec.valign == "middle"
|
||||
cell.ValignBottom = cellSpec.valign == "bottom"
|
||||
}
|
||||
cellIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichText(text tg.RichTextClass) tg.RichTextClass {
|
||||
switch value := text.(type) {
|
||||
case *tg.TextConcat:
|
||||
for i := range value.Texts {
|
||||
value.Texts[i] = postProcessBotAPIRichText(value.Texts[i])
|
||||
}
|
||||
case *tg.TextBold:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextItalic:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextStrike:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextFixed:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMarked:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextURL:
|
||||
parsed, err := url.Parse(value.URL)
|
||||
if err == nil && parsed.Scheme == botAPIRichSentinelScheme && parsed.Host == "time" {
|
||||
unixTime, unixErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
|
||||
flags, ok := botAPIRichDateFlags(parsed.Query().Get("format"))
|
||||
if unixErr == nil && ok {
|
||||
return richbuilder.Date(postProcessBotAPIRichText(value.Text), int(unixTime), flags)
|
||||
}
|
||||
}
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextEmail:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextPhone:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextDate:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func botAPIRichFooterText(text tg.RichTextClass) (tg.RichTextClass, bool) {
|
||||
link, ok := text.(*tg.TextURL)
|
||||
if !ok || link.URL != botAPIRichSentinelScheme+"://footer" {
|
||||
return nil, false
|
||||
}
|
||||
return link.Text, true
|
||||
}
|
||||
|
||||
func botAPIRichDateFlags(format string) (richbuilder.DateFlags, bool) {
|
||||
if format == "r" || format == "R" {
|
||||
return richbuilder.DateFlags{Relative: true}, true
|
||||
}
|
||||
var flags richbuilder.DateFlags
|
||||
if format == "" {
|
||||
return flags, false
|
||||
}
|
||||
for _, value := range format {
|
||||
switch value {
|
||||
case 't':
|
||||
flags.ShortTime = true
|
||||
case 'T':
|
||||
flags.LongTime = true
|
||||
case 'd':
|
||||
flags.ShortDate = true
|
||||
case 'D':
|
||||
flags.LongDate = true
|
||||
case 'w', 'W':
|
||||
flags.DayOfWeek = true
|
||||
default:
|
||||
return richbuilder.DateFlags{}, false
|
||||
}
|
||||
}
|
||||
return flags, true
|
||||
}
|
||||
|
||||
func htmlNodeAttr(node *html.Node, key string) string {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return attribute.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func htmlNodeHasAttr(node *html.Node, key string) bool {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
503
internal/rpc/botapi_rich_projection.go
Normal file
503
internal/rpc/botapi_rich_projection.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func botAPIRichMessageProjection(blocks []tg.PageBlockClass, rtl bool) ([]byte, error) {
|
||||
projected, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(projected) == 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
out := map[string]any{"blocks": projected}
|
||||
if rtl {
|
||||
out["is_rtl"] = true
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func botAPIRichBlocks(blocks []tg.PageBlockClass) ([]any, error) {
|
||||
out := make([]any, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
projected, err := botAPIRichBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projected != nil {
|
||||
out = append(out, projected)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlock(block tg.PageBlockClass) (map[string]any, error) {
|
||||
textBlock := func(kind string, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
heading := func(size int, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": "heading", "text": value, "size": size}, nil
|
||||
}
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
return textBlock("paragraph", value.Text)
|
||||
case *tg.PageBlockTitle:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockSubtitle:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeader:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockSubheader:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockKicker:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockHeading1:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
return heading(4, value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
return heading(5, value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
out, err := textBlock("pre", value.Text)
|
||||
if err == nil && value.Language != "" {
|
||||
out["language"] = value.Language
|
||||
}
|
||||
return out, err
|
||||
case *tg.PageBlockFooter:
|
||||
return textBlock("footer", value.Text)
|
||||
case *tg.PageBlockDivider:
|
||||
return map[string]any{"type": "divider"}, nil
|
||||
case *tg.PageBlockMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.PageBlockAnchor:
|
||||
return map[string]any{"type": "anchor", "name": value.Name}, nil
|
||||
case *tg.PageBlockDetails:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := botAPIRichText(value.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "details", "summary": summary, "blocks": blocks}
|
||||
if value.Open {
|
||||
out["is_open"] = true
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquote:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": []any{map[string]any{"type": "paragraph", "text": text}}}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": blocks}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockPullquote:
|
||||
out, err := textBlock("pullquote", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockList:
|
||||
return botAPIUnorderedRichList(value)
|
||||
case *tg.PageBlockOrderedList:
|
||||
return botAPIOrderedRichList(value)
|
||||
case *tg.PageBlockTable:
|
||||
return botAPIRichTable(value)
|
||||
case *tg.PageBlockCollage:
|
||||
return botAPIRichBlockCollection("collage", value.Items, value.Caption)
|
||||
case *tg.PageBlockSlideshow:
|
||||
return botAPIRichBlockCollection("slideshow", value.Items, value.Caption)
|
||||
case *tg.PageBlockCover:
|
||||
return botAPIRichBlock(value.Cover)
|
||||
case *tg.PageBlockThinking:
|
||||
return textBlock("thinking", value.Text)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIUnorderedRichList(list *tg.PageBlockList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for _, raw := range list.Items {
|
||||
item := map[string]any{"label": "•"}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
if value.Checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if value.Checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
case *tg.PageListItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIOrderedRichList(list *tg.PageBlockOrderedList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for index, raw := range list.Items {
|
||||
item := map[string]any{"label": strconv.Itoa(index + 1)}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIFillOrderedListItem(item map[string]any, label string, value int, kind string, checkbox, checked bool) {
|
||||
if label != "" {
|
||||
item["label"] = label
|
||||
}
|
||||
if value != 0 {
|
||||
item["value"] = value
|
||||
}
|
||||
if kind != "" {
|
||||
item["type"] = kind
|
||||
}
|
||||
if checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichTable(table *tg.PageBlockTable) (map[string]any, error) {
|
||||
rows := make([]any, 0, len(table.Rows))
|
||||
for _, row := range table.Rows {
|
||||
cells := make([]any, 0, len(row.Cells))
|
||||
for _, cell := range row.Cells {
|
||||
item := map[string]any{"align": "left", "valign": "top"}
|
||||
if !botAPIRichTextEmpty(cell.Text) {
|
||||
text, err := botAPIRichText(cell.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["text"] = text
|
||||
}
|
||||
if cell.Header {
|
||||
item["is_header"] = true
|
||||
}
|
||||
if cell.Colspan > 1 {
|
||||
item["colspan"] = cell.Colspan
|
||||
}
|
||||
if cell.Rowspan > 1 {
|
||||
item["rowspan"] = cell.Rowspan
|
||||
}
|
||||
if cell.AlignCenter {
|
||||
item["align"] = "center"
|
||||
} else if cell.AlignRight {
|
||||
item["align"] = "right"
|
||||
}
|
||||
if cell.ValignMiddle {
|
||||
item["valign"] = "middle"
|
||||
} else if cell.ValignBottom {
|
||||
item["valign"] = "bottom"
|
||||
}
|
||||
cells = append(cells, item)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
out := map[string]any{"type": "table", "cells": rows}
|
||||
if table.Bordered {
|
||||
out["is_bordered"] = true
|
||||
}
|
||||
if table.Striped {
|
||||
out["is_striped"] = true
|
||||
}
|
||||
if !botAPIRichTextEmpty(table.Title) {
|
||||
caption, err := botAPIRichText(table.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = caption
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlockCollection(kind string, blocks []tg.PageBlockClass, caption tg.PageCaption) (map[string]any, error) {
|
||||
items, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": kind, "blocks": items}
|
||||
if !botAPIRichTextEmpty(caption.Text) || !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected := map[string]any{}
|
||||
if !botAPIRichTextEmpty(caption.Text) {
|
||||
projected["text"], err = botAPIRichText(caption.Text)
|
||||
}
|
||||
if err == nil && !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected["credit"], err = botAPIRichText(caption.Credit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichText(text tg.RichTextClass) (any, error) {
|
||||
wrapped := func(kind string, child tg.RichTextClass) (any, error) {
|
||||
value, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
valued := func(kind, field, value string, child tg.RichTextClass) (any, error) {
|
||||
out, err := wrapped(kind, child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)[field] = value
|
||||
return out, nil
|
||||
}
|
||||
switch value := text.(type) {
|
||||
case nil, *tg.TextEmpty:
|
||||
return "", nil
|
||||
case *tg.TextPlain:
|
||||
return value.Text, nil
|
||||
case *tg.TextConcat:
|
||||
items := make([]any, 0, len(value.Texts))
|
||||
for _, child := range value.Texts {
|
||||
item, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
case *tg.TextBold:
|
||||
return wrapped("bold", value.Text)
|
||||
case *tg.TextItalic:
|
||||
return wrapped("italic", value.Text)
|
||||
case *tg.TextUnderline:
|
||||
return wrapped("underline", value.Text)
|
||||
case *tg.TextStrike:
|
||||
return wrapped("strikethrough", value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
return wrapped("spoiler", value.Text)
|
||||
case *tg.TextFixed:
|
||||
return wrapped("code", value.Text)
|
||||
case *tg.TextSubscript:
|
||||
return wrapped("subscript", value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
return wrapped("superscript", value.Text)
|
||||
case *tg.TextMarked:
|
||||
return wrapped("marked", value.Text)
|
||||
case *tg.TextDate:
|
||||
out, err := wrapped("date_time", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := out.(map[string]any)
|
||||
item["unix_time"] = value.Date
|
||||
item["date_time_format"] = botAPIRichDateFormat(value)
|
||||
return item, nil
|
||||
case *tg.TextCustomEmoji:
|
||||
return map[string]any{"type": "custom_emoji", "custom_emoji_id": strconv.FormatInt(value.DocumentID, 10), "alternative_text": value.Alt}, nil
|
||||
case *tg.TextMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.TextURL:
|
||||
if strings.HasPrefix(value.URL, "#") {
|
||||
return valued("anchor_link", "anchor_name", strings.TrimPrefix(value.URL, "#"), value.Text)
|
||||
}
|
||||
return valued("url", "url", value.URL, value.Text)
|
||||
case *tg.TextEmail:
|
||||
return valued("email_address", "email_address", value.Email, value.Text)
|
||||
case *tg.TextPhone:
|
||||
return valued("phone_number", "phone_number", value.Phone, value.Text)
|
||||
case *tg.TextBankCard:
|
||||
return valued("bank_card_number", "bank_card_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMention:
|
||||
return valued("mention", "username", strings.TrimPrefix(botAPIRichPlainText(value.Text), "@"), value.Text)
|
||||
case *tg.TextHashtag:
|
||||
return valued("hashtag", "hashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "#"), value.Text)
|
||||
case *tg.TextCashtag:
|
||||
return valued("cashtag", "cashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "$"), value.Text)
|
||||
case *tg.TextBotCommand:
|
||||
return valued("bot_command", "bot_command", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoURL:
|
||||
return valued("url", "url", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoEmail:
|
||||
return valued("email_address", "email_address", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoPhone:
|
||||
return valued("phone_number", "phone_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMentionName:
|
||||
out, err := wrapped("text_mention", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)["user"] = map[string]any{"id": value.UserID, "is_bot": false, "first_name": "User " + strconv.FormatInt(value.UserID, 10)}
|
||||
return out, nil
|
||||
case *tg.TextAnchor:
|
||||
anchor := map[string]any{"type": "anchor", "name": value.Name}
|
||||
if botAPIRichTextEmpty(value.Text) {
|
||||
return anchor, nil
|
||||
}
|
||||
inner, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []any{anchor, inner}, nil
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichDateFormat(date *tg.TextDate) string {
|
||||
if date.Relative {
|
||||
return "r"
|
||||
}
|
||||
var out strings.Builder
|
||||
if date.ShortTime {
|
||||
out.WriteByte('t')
|
||||
}
|
||||
if date.LongTime {
|
||||
out.WriteByte('T')
|
||||
}
|
||||
if date.ShortDate {
|
||||
out.WriteByte('d')
|
||||
}
|
||||
if date.LongDate {
|
||||
out.WriteByte('D')
|
||||
}
|
||||
if date.DayOfWeek {
|
||||
out.WriteByte('w')
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func botAPIRichTextEmpty(text tg.RichTextClass) bool {
|
||||
return text == nil || botAPIRichPlainText(text) == ""
|
||||
}
|
||||
|
||||
func botAPIRichPlainText(text tg.RichTextClass) string {
|
||||
var out strings.Builder
|
||||
var walk func(tg.RichTextClass)
|
||||
walk = func(value tg.RichTextClass) {
|
||||
switch value := value.(type) {
|
||||
case *tg.TextPlain:
|
||||
out.WriteString(value.Text)
|
||||
case *tg.TextConcat:
|
||||
for _, child := range value.Texts {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.TextBold:
|
||||
walk(value.Text)
|
||||
case *tg.TextItalic:
|
||||
walk(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
walk(value.Text)
|
||||
case *tg.TextStrike:
|
||||
walk(value.Text)
|
||||
case *tg.TextFixed:
|
||||
walk(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextMarked:
|
||||
walk(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
walk(value.Text)
|
||||
case *tg.TextURL:
|
||||
walk(value.Text)
|
||||
case *tg.TextEmail:
|
||||
walk(value.Text)
|
||||
case *tg.TextPhone:
|
||||
walk(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
walk(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
walk(value.Text)
|
||||
case *tg.TextDate:
|
||||
walk(value.Text)
|
||||
case *tg.TextCustomEmoji:
|
||||
out.WriteString(value.Alt)
|
||||
}
|
||||
}
|
||||
walk(text)
|
||||
return out.String()
|
||||
}
|
||||
|
|
@ -300,6 +300,9 @@ func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req
|
|||
if err != nil {
|
||||
return domain.BotInlineResults{}, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil {
|
||||
return domain.BotInlineResults{}, replyMarkupErr(err)
|
||||
}
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
return domain.BotInlineResults{}, resultIDDuplicateErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
|||
zap.Int("member_ids", len(memberIDs)),
|
||||
zap.Int64s("member_user_ids", memberIDs),
|
||||
)
|
||||
if len(memberIDs) == 0 {
|
||||
return nil, usersTooFewErr()
|
||||
}
|
||||
createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{
|
||||
CreatorUserID: userID,
|
||||
Title: req.Title,
|
||||
|
|
@ -63,16 +60,25 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
|||
}
|
||||
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
canonicalUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
var inviteUpdates *tg.Updates
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
inviteUpdates = r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache)
|
||||
if inviteUpdates != nil {
|
||||
canonicalUpdates.Updates = append(canonicalUpdates.Updates, inviteUpdates.Updates...)
|
||||
}
|
||||
}
|
||||
updates := canonicalUpdates
|
||||
if createChatNeedsLegacyChat(ctx) {
|
||||
updates = r.tdesktopCreateChatUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
}
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
inviteUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache)
|
||||
if inviteUpdates != nil {
|
||||
updates.Updates = append(updates.Updates, inviteUpdates.Updates...)
|
||||
}
|
||||
}
|
||||
// The rpc_result reaches only the calling session. Keep the creator's other
|
||||
// sessions in sync with the same canonical channel state; compatibility-only
|
||||
// legacy chat projection is needed solely by the synchronous create callback.
|
||||
r.pushUserUpdates(ctx, userID, canonicalUpdates)
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates {
|
||||
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
|
||||
|
|
|
|||
|
|
@ -594,7 +594,11 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
|
|||
}
|
||||
items := make([]domain.ContactInput, 0, len(input))
|
||||
for _, item := range input {
|
||||
note, entities := contactNote(item.GetNote())
|
||||
rawNote, hasNote := item.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -666,7 +670,11 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if !found {
|
||||
return nil, contactIDInvalidErr()
|
||||
}
|
||||
note, entities := contactNote(req.GetNote())
|
||||
rawNote, hasNote := req.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -682,19 +690,20 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if err != nil {
|
||||
return nil, contactErr(err)
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
|
||||
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
if hasNote {
|
||||
// TDesktop does not copy the submitted note into Data::User after
|
||||
// contacts.addContact. updateUser is the lightweight full-info refresh
|
||||
// signal; the private note itself remains available only from
|
||||
// users.getFullUser for this viewer.
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
}
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -709,6 +718,9 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
|
||||
if hasNote {
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
|
|
@ -736,13 +748,7 @@ func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserCla
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
|
|
@ -838,17 +844,27 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac
|
|||
if !found {
|
||||
return false, contactIDInvalidErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount {
|
||||
return false, limitInvalidErr()
|
||||
note, entities, err := contactNote(userID, req.Note, true)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil {
|
||||
contact, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, note, entities)
|
||||
if err != nil {
|
||||
return false, contactErr(err)
|
||||
}
|
||||
if err := r.recordContactsReset(ctx, userID); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushContactsReset(ctx, userID)
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
if r.hasReliableUpdateDispatch() {
|
||||
// contactsReset is already delivered by the durable outbox. updateUser
|
||||
// is intentionally a transient online refresh hint and must not copy a
|
||||
// private note into the shared update log.
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
} else {
|
||||
r.pushUserUpdates(ctx, userID, r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), true))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -983,11 +999,86 @@ func validContactInput(phone, firstName, lastName, note string, entities int) bo
|
|||
return true
|
||||
}
|
||||
|
||||
func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) {
|
||||
func contactNote(ownerUserID int64, note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity, error) {
|
||||
if !ok {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
return note.Text, domainMessageEntities(note.Entities)
|
||||
if !utf8.ValidString(note.Text) || utf8.RuneCountInString(note.Text) > maxContactNoteLength || len(note.Entities) > maxMessageEntityCount {
|
||||
return "", nil, limitInvalidErr()
|
||||
}
|
||||
limit := utf16CodeUnitLen(note.Text)
|
||||
for _, entity := range note.Entities {
|
||||
if messageEntityClassNil(entity) || !storyCaptionEntitySupported(entity) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
offset, length := entity.GetOffset(), entity.GetLength()
|
||||
if offset < 0 || length <= 0 || offset > limit || length > limit-offset {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
switch typed := entity.(type) {
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
if typed.DocumentID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.MessageEntityMentionName:
|
||||
if typed.UserID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.InputMessageEntityMentionName:
|
||||
if inputUserClassNil(typed.UserID) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
entities := domainMessageEntitiesForViewer(ownerUserID, note.Entities)
|
||||
if len(entities) != len(note.Entities) || !validEphemeralEntityBounds(note.Text, entities) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
return note.Text, entities, nil
|
||||
}
|
||||
|
||||
func contactUserForUpdates(contact domain.Contact) domain.User {
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
// contact.Phone must not overwrite peerUser.Phone here: it bypasses the
|
||||
// phone-number privacy check entirely (fixed upstream PR owpengram/owpengram-server#1 -
|
||||
// exposed the raw number to addContact/acceptContact callers even with
|
||||
// "Nobody" set). peerUser.Phone already carries the privacy-filtered value.
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
return peerUser
|
||||
}
|
||||
|
||||
func (r *Router) contactNoteRefreshUpdates(peerUser domain.User, date int, includeContactsReset bool) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 2)
|
||||
if includeContactsReset {
|
||||
updates = append(updates, &tg.UpdateContactsReset{})
|
||||
}
|
||||
updates = append(updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: []tg.UserClass{r.tgUser(peerUser)},
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
|
||||
// pushContactNoteRefreshIfReliableDispatch complements the durable
|
||||
// contactsReset event. Reliable dispatch already owns the reset, while this
|
||||
// best-effort online nudge makes other loaded TDesktop profiles refetch
|
||||
// users.getFullUser immediately. Offline correctness does not depend on it.
|
||||
func (r *Router) pushContactNoteRefreshIfReliableDispatch(ctx context.Context, userID int64, peerUser domain.User) {
|
||||
if !r.hasReliableUpdateDispatch() || peerUser.ID == 0 {
|
||||
return
|
||||
}
|
||||
r.pushUserMessageTransient(
|
||||
ctx,
|
||||
userID,
|
||||
"push contact note full-user refresh",
|
||||
r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), false),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
appprivacy "telesrv/internal/app/privacy"
|
||||
appstories "telesrv/internal/app/stories"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/userprojection"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
|
|
@ -724,6 +725,202 @@ func TestAccountUpdateProfileRPC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUsersGetFullUserProjectsOwnerScopedContactNoteAcrossCacheUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
rawContacts := memory.NewContactStore()
|
||||
cachedContacts := userprojection.NewCachedContactStore(rawContacts, time.Hour)
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
altOwner, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Alt"})
|
||||
if err != nil {
|
||||
t.Fatalf("create alternate owner: %v", err)
|
||||
}
|
||||
friend, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Friend"})
|
||||
if err != nil {
|
||||
t.Fatalf("create friend: %v", err)
|
||||
}
|
||||
contactsService := appcontacts.NewService(cachedContacts, userStore)
|
||||
usersService := appusers.NewService(userStore, appusers.WithContactStore(cachedContacts))
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{Users: usersService, Contacts: contactsService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
hasUserRefresh := func(updates *tg.Updates, userID int64) bool {
|
||||
t.Helper()
|
||||
if updates == nil {
|
||||
return false
|
||||
}
|
||||
for _, update := range updates.Updates {
|
||||
if changed, ok := update.(*tg.UpdateUser); ok && changed.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
add := &tg.ContactsAddContactRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
FirstName: "Friend",
|
||||
}
|
||||
add.SetNote(tg.TextWithEntities{
|
||||
Text: "owner note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 5}},
|
||||
})
|
||||
addedClass, err := r.onContactsAddContact(WithUserID(ctx, owner.ID), add)
|
||||
if err != nil {
|
||||
t.Fatalf("add owner contact through RPC: %v", err)
|
||||
}
|
||||
added, ok := addedClass.(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(added, friend.ID) {
|
||||
t.Fatalf("add contact updates = %T %+v, want updateUser refresh for note", addedClass, addedClass)
|
||||
}
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("add contact push = %T %+v, want other-session updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
if _, err := contactsService.AddContact(ctx, altOwner.ID, domain.ContactInput{
|
||||
ContactUserID: friend.ID,
|
||||
FirstName: "Friend",
|
||||
Note: "alternate note",
|
||||
}); err != nil {
|
||||
t.Fatalf("add alternate owner contact: %v", err)
|
||||
}
|
||||
getNote := func(viewer domain.User) (tg.TextWithEntities, bool) {
|
||||
t.Helper()
|
||||
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get full user for viewer %d: %v", viewer.ID, err)
|
||||
}
|
||||
return full.FullUser.GetNote()
|
||||
}
|
||||
|
||||
note, ok := getNote(owner)
|
||||
if !ok || note.Text != "owner note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("owner note = %+v present=%v, want owner note with entity", note, ok)
|
||||
}
|
||||
bold, ok := note.Entities[0].(*tg.MessageEntityBold)
|
||||
if !ok || bold.Offset != 0 || bold.Length != 5 {
|
||||
t.Fatalf("owner note entity = %T %+v, want bold 0/5", note.Entities[0], note.Entities[0])
|
||||
}
|
||||
// The large UserFull LRU intentionally excludes private notes; every response
|
||||
// overlays one from the already-loaded viewer contact projection.
|
||||
cachedFull, ok := r.userFullProjectionCache.Lookup(owner.ID, friend.ID)
|
||||
if !ok {
|
||||
t.Fatal("user full projection was not cached")
|
||||
}
|
||||
if cachedNote, present := cachedFull.GetNote(); present {
|
||||
t.Fatalf("cached user full leaked private note: %+v", cachedNote)
|
||||
}
|
||||
// Mutating one response must not leak through the cache or contact snapshot.
|
||||
bold.Length = 99
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Entities[0].(*tg.MessageEntityBold).Length != 5 {
|
||||
t.Fatalf("owner note after response mutation = %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
altNote, ok := getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate owner note = %+v present=%v, want isolated value", altNote, ok)
|
||||
}
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "bad",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 3, Length: 1}},
|
||||
},
|
||||
}); err == nil || ok || !strings.Contains(err.Error(), "ENTITY_BOUNDS_INVALID") {
|
||||
t.Fatalf("invalid contact note ok=%v err=%v, want ENTITY_BOUNDS_INVALID", ok, err)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "owner note" {
|
||||
t.Fatalf("invalid update mutated owner note: %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
updated := &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "fresh note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityItalic{Offset: 0, Length: 5}},
|
||||
},
|
||||
}
|
||||
sessions.clearMessages()
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), updated); err != nil || !ok {
|
||||
t.Fatalf("update contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
pushed, ok = sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("update contact note push = %T %+v, want updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
hasReset := false
|
||||
for _, update := range pushed.Updates {
|
||||
if _, ok := update.(*tg.UpdateContactsReset); ok {
|
||||
hasReset = true
|
||||
}
|
||||
}
|
||||
if !hasReset {
|
||||
t.Fatalf("update contact note push = %+v, want contactsReset for non-reliable dispatch", pushed)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "fresh note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("fresh owner note = %+v present=%v", note, ok)
|
||||
}
|
||||
if _, ok := note.Entities[0].(*tg.MessageEntityItalic); !ok {
|
||||
t.Fatalf("fresh owner note entity = %T, want italic", note.Entities[0])
|
||||
}
|
||||
altNote, ok = getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate note changed with owner update: %+v present=%v", altNote, ok)
|
||||
}
|
||||
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{},
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("clear contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
if note, present := getNote(owner); present {
|
||||
t.Fatalf("cleared contact note still present: %+v", note)
|
||||
}
|
||||
|
||||
// Simulate a write committed by another instance: both the shared contact
|
||||
// snapshot and RPC projection receive the existing contact_account NOTIFY.
|
||||
if _, found, err := rawContacts.UpdateNote(ctx, owner.ID, friend.ID, "remote note", nil); err != nil || !found {
|
||||
t.Fatalf("remote update found=%v err=%v", found, err)
|
||||
}
|
||||
cachedContacts.InvalidateViewers(owner.ID)
|
||||
r.InvalidateRPCProjectionReadModelForViewer(owner.ID)
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "remote note" {
|
||||
t.Fatalf("note after cross-instance invalidation = %+v present=%v", note, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactNoteReliableDispatchPushesOnlyTransientUserRefresh(t *testing.T) {
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{
|
||||
Sessions: sessions,
|
||||
Updates: &captureUpdates{reliableDispatch: true},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
peer := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Friend", Contact: true}
|
||||
|
||||
r.pushContactNoteRefreshIfReliableDispatch(WithUserID(context.Background(), 1000000001), 1000000001, peer)
|
||||
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("contact note refresh = %T, want *tg.Updates", sessions.lastUserPush())
|
||||
}
|
||||
if len(pushed.Updates) != 1 {
|
||||
t.Fatalf("contact note refresh updates = %+v, want one updateUser without duplicate contactsReset", pushed.Updates)
|
||||
}
|
||||
changed, ok := pushed.Updates[0].(*tg.UpdateUser)
|
||||
if !ok || changed.UserID != peer.ID {
|
||||
t.Fatalf("contact note refresh update = %T %+v, want updateUser(%d)", pushed.Updates[0], pushed.Updates[0], peer.ID)
|
||||
}
|
||||
if len(pushed.Users) != 1 || pushed.Users[0].GetID() != peer.ID {
|
||||
t.Fatalf("contact note refresh users = %+v, want peer companion", pushed.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersSavedMusicStubsValidateInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if msg.Action == nil {
|
||||
msg.Action = &tg.MessageActionEmpty{}
|
||||
}
|
||||
if m.SavedPeer.ID != 0 {
|
||||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if reply := tgMessageReplyHeader(domain.Message{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
|
||||
ReplyTo: m.ReplyTo,
|
||||
|
|
@ -140,7 +143,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
if m.Post {
|
||||
if m.SuggestedPost != nil && m.SuggestedPost.Accepted && m.SuggestedPost.Price != nil {
|
||||
switch m.SuggestedPost.Price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
msg.SetPaidSuggestedPostStars(true)
|
||||
case domain.SuggestedPostPriceTON:
|
||||
msg.SetPaidSuggestedPostTon(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
}
|
||||
}
|
||||
if m.PaidMessageStars > 0 {
|
||||
msg.SetPaidMessageStars(m.PaidMessageStars)
|
||||
|
|
@ -303,6 +317,42 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
out.SetCommunityID(action.CommunityID)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostApproval:
|
||||
out := &tg.MessageActionSuggestedPostApproval{
|
||||
Rejected: action.SuggestedPostRejected,
|
||||
BalanceTooLow: action.SuggestedPostBalanceTooLow,
|
||||
}
|
||||
if action.SuggestedPostRejectComment != "" {
|
||||
out.SetRejectComment(action.SuggestedPostRejectComment)
|
||||
}
|
||||
if action.SuggestedPostScheduleDate > 0 {
|
||||
out.SetScheduleDate(action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
out.SetPrice(price)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostSuccess:
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
return &tg.MessageActionSuggestedPostSuccess{Price: price}
|
||||
}
|
||||
return nil
|
||||
case domain.ChannelActionSuggestedPostRefund:
|
||||
return &tg.MessageActionSuggestedPostRefund{PayerInitiated: action.SuggestedPostPayerInitiated}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tgSuggestedPostPrice(price *domain.SuggestedPostPrice) tg.StarsAmountClass {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
return &tg.StarsAmount{Amount: price.Amount, Nanos: price.Nanos}
|
||||
case domain.SuggestedPostPriceTON:
|
||||
return &tg.StarsTonAmount{Amount: price.Amount}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -400,6 +450,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
out := &tg.Channel{
|
||||
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
Verified: ch.Verified,
|
||||
Scam: ch.Scam,
|
||||
Fake: ch.Fake,
|
||||
Gigagroup: ch.Gigagroup,
|
||||
Broadcast: ch.Broadcast,
|
||||
Megagroup: ch.Megagroup,
|
||||
Forum: ch.Forum,
|
||||
|
|
@ -490,6 +543,16 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
return out
|
||||
}
|
||||
|
||||
// channelAboutWithModerationWarning decorates the projected channel/supergroup
|
||||
// About with the scam/fake warning when set (group vs channel wording).
|
||||
func channelAboutWithModerationWarning(ch domain.Channel) string {
|
||||
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
|
||||
if ch.Megagroup && !ch.Broadcast {
|
||||
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
|
||||
}
|
||||
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
|
||||
}
|
||||
|
||||
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
|
||||
ch := view.Channel
|
||||
full := &tg.ChannelFull{
|
||||
|
|
@ -500,7 +563,7 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
|||
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
|
||||
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
|
||||
ID: ch.ID,
|
||||
About: ch.About,
|
||||
About: channelAboutWithModerationWarning(ch),
|
||||
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
|
||||
UnreadCount: view.Dialog.UnreadCount,
|
||||
|
|
|
|||
81
internal/rpc/convert_flags.go
Normal file
81
internal/rpc/convert_flags.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Scam/fake profile warnings surfaced in the full-profile About text.
|
||||
//
|
||||
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
|
||||
// warning paragraph, while iOS/Android show a localized warning. To make the
|
||||
// warning visible on every client, the server injects it into the projected
|
||||
// getFullUser/getFullChannel About field. Injection is non-destructive: the
|
||||
// stored bio/description is never overwritten, only the response is decorated,
|
||||
// so clearing the flag restores the original text and the warning survives the
|
||||
// owner editing their bio/description (it is re-applied from the flag on every
|
||||
// read).
|
||||
//
|
||||
// The text is server-provided (clients cannot localize it). Operators override
|
||||
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
|
||||
// per-peer-type English defaults are used. scam takes precedence over fake.
|
||||
const (
|
||||
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
|
||||
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
|
||||
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
|
||||
)
|
||||
|
||||
// moderationWarningOverrides holds the operator-configured texts. They are set
|
||||
// once at startup (SetModerationWarnings) before any request is served, and
|
||||
// read on the hot path; atomic.Pointer keeps that race-free without locking.
|
||||
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
|
||||
|
||||
type moderationWarningConfig struct {
|
||||
scam string
|
||||
fake string
|
||||
}
|
||||
|
||||
// SetModerationWarnings installs operator overrides for the scam/fake profile
|
||||
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
|
||||
// override applies to every peer type (user/channel/group).
|
||||
func SetModerationWarnings(scam, fake string) {
|
||||
moderationWarningOverrides.Store(&moderationWarningConfig{
|
||||
scam: strings.TrimSpace(scam),
|
||||
fake: strings.TrimSpace(fake),
|
||||
})
|
||||
}
|
||||
|
||||
func moderationOverride() moderationWarningConfig {
|
||||
if cfg := moderationWarningOverrides.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return moderationWarningConfig{}
|
||||
}
|
||||
|
||||
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
|
||||
// It returns about unchanged when neither flag is set. The operator override
|
||||
// wins over the per-type default; scam wins over fake when both are set.
|
||||
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
|
||||
override := moderationOverride()
|
||||
warning := ""
|
||||
switch {
|
||||
case scam:
|
||||
if warning = override.scam; warning == "" {
|
||||
warning = scamDefault
|
||||
}
|
||||
case fake:
|
||||
if warning = override.fake; warning == "" {
|
||||
warning = fakeDefault
|
||||
}
|
||||
}
|
||||
if warning == "" {
|
||||
return about
|
||||
}
|
||||
if about = strings.TrimSpace(about); about == "" {
|
||||
return warning
|
||||
}
|
||||
return warning + "\n\n" + about
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
|
@ -14,6 +15,9 @@ import (
|
|||
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
|
||||
// valid in both megagroups and broadcasts.
|
||||
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, userID, markup); err != nil {
|
||||
return replyMarkupErr(err)
|
||||
}
|
||||
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -30,6 +34,92 @@ func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, p
|
|||
return nil
|
||||
}
|
||||
|
||||
// prepareTelegramLoginMarkup resolves every login_url target and validates its
|
||||
// linked web origin before persistence. It mutates only the freshly parsed
|
||||
// request DTO and assigns a deterministic flattened button id, which is later
|
||||
// re-read by messages.requestUrlAuth.
|
||||
func (r *Router) prepareTelegramLoginMarkup(ctx context.Context, senderBotID int64, markup *domain.MessageReplyMarkup) error {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return nil
|
||||
}
|
||||
hasLoginButton := false
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
if markup.Inline[rowIndex][buttonIndex].Type == domain.MarkupButtonLoginURL {
|
||||
hasLoginButton = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasLoginButton {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLoginButton {
|
||||
return nil
|
||||
}
|
||||
if r == nil || r.deps.TelegramLogin == nil || r.deps.Users == nil || senderBotID <= 0 {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
sender, found, err := r.deps.Users.ByID(ctx, senderBotID, senderBotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !sender.Bot || sender.Deleted {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
flatID := 0
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
button := &markup.Inline[rowIndex][buttonIndex]
|
||||
if button.Type != domain.MarkupButtonLoginURL {
|
||||
flatID++
|
||||
continue
|
||||
}
|
||||
botID := button.LoginBotUserID
|
||||
if button.LoginBotUsername != "" {
|
||||
resolver, ok := r.deps.Users.(UserIdentityService)
|
||||
if !ok {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
bot, found, err := resolver.ResolveUsername(ctx, senderBotID, strings.TrimPrefix(button.LoginBotUsername, "@"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
botID = bot.ID
|
||||
}
|
||||
if botID == 0 {
|
||||
botID = senderBotID
|
||||
}
|
||||
bot, found, err := r.deps.Users.ByID(ctx, senderBotID, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
normalized, _, err := r.deps.TelegramLogin.ValidateMessageButton(ctx, botID, button.URL)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginURLInvalid) || errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed) {
|
||||
return domain.ErrButtonURLInvalid
|
||||
}
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
button.URL = normalized
|
||||
button.LoginBotUserID = botID
|
||||
button.LoginBotUsername = ""
|
||||
button.ButtonID = flatID
|
||||
flatID++
|
||||
}
|
||||
}
|
||||
return domain.ValidateReplyMarkup(markup)
|
||||
}
|
||||
|
||||
// P3 reply_markup 错误码(对齐官方)。
|
||||
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
|
||||
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
|
||||
|
|
@ -175,21 +265,23 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt
|
|||
|
||||
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
|
||||
buttonID := 0
|
||||
for _, row := range inline.Rows {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
|
||||
for _, btn := range row.Buttons {
|
||||
db, err := domainMarkupButton(btn)
|
||||
db, err := domainMarkupButton(btn, buttonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRow = append(domainRow, db)
|
||||
buttonID++
|
||||
}
|
||||
out.Inline = append(out.Inline, domainRow)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
|
|
@ -209,6 +301,26 @@ func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error)
|
|||
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.InputKeyboardButtonURLAuth:
|
||||
botUserID := int64(0)
|
||||
switch bot := b.Bot.(type) {
|
||||
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
|
||||
case *tg.InputUser:
|
||||
botUserID = bot.UserID
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
|
||||
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURLAuth:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: b.ButtonID,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonWebView:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonSwitchInline:
|
||||
|
|
@ -322,6 +434,15 @@ func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
|||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonLoginURL:
|
||||
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
|
||||
if btn.ForwardText != "" {
|
||||
out.SetFwdText(btn.ForwardText)
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonWebView:
|
||||
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,26 @@ func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonURLAuth{
|
||||
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
|
||||
}
|
||||
button.SetRequestWriteAccess(true)
|
||||
button.SetFwdText("Open login")
|
||||
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := markup.Inline[0][0]
|
||||
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
|
||||
t.Fatalf("domain login_url = %#v", got)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
|
||||
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
|
||||
t.Fatalf("wire login_url = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
||||
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t
|
|||
if action.DropOriginalDetailsStars > 0 {
|
||||
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
|
||||
}
|
||||
if action.CanCraftAt > 0 {
|
||||
// Channel Craft is not executable yet. Gate on the authoritative gift owner
|
||||
// as a final wire boundary so historical JSON/admin-log actions or a future
|
||||
// constructor cannot accidentally expose Android's Craft entry marker.
|
||||
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件集中 Layer 227 富文本消息(richMessage)的 tg.* ↔ domain 转换。
|
||||
// Phase 1:仅支持 inputRichMessage(blocks 形态);HTML/Markdown 变体(需服务端解析为
|
||||
// PageBlock)尚未实现,直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain(详见
|
||||
// domain.MessageRichMessage)。
|
||||
// 本文件集中 Layer 228 富文本消息(richMessage)的 tg.* ↔ domain 转换。
|
||||
// inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock;
|
||||
// blocks 以 TL 向量序列化为不透明字节存 domain(详见 domain.MessageRichMessage)。
|
||||
|
||||
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。
|
||||
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
|
||||
|
|
@ -127,22 +126,51 @@ func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
|
|||
}
|
||||
|
||||
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
|
||||
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
|
||||
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
|
||||
// HTML/Markdown 先在服务端解析为 PageBlock,再与 blocks 形态共用限额校验、
|
||||
// 序列化和 Bot API 输出投影;内嵌 photos/documents 复用 sendMedia 同款媒体解析。
|
||||
// 返回 nil 表示无富文本载荷。
|
||||
func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) {
|
||||
if input == nil {
|
||||
return nil, nil
|
||||
}
|
||||
in, ok := input.(*tg.InputRichMessage)
|
||||
if !ok {
|
||||
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
|
||||
return nil, mediaInvalidErr()
|
||||
var (
|
||||
in *tg.InputRichMessage
|
||||
sourceParsed bool
|
||||
)
|
||||
switch value := input.(type) {
|
||||
case *tg.InputRichMessage:
|
||||
in = value
|
||||
case *tg.InputRichMessageHTML:
|
||||
if value == nil || value.HTML == "" || len(value.Files) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := parseBotAPIRichHTML(value.HTML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
|
||||
sourceParsed = true
|
||||
case *tg.InputRichMessageMarkdown:
|
||||
if value == nil || value.Markdown == "" || len(value.Files) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := parseBotAPIRichMarkdown(value.Markdown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
|
||||
sourceParsed = true
|
||||
default:
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if len(in.Blocks) == 0 {
|
||||
if len(in.Photos) == 0 && len(in.Documents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, mediaInvalidErr()
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if err := validateRichMessageBlocks(in.Blocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
|
||||
return nil, notImplementedErr()
|
||||
|
|
@ -156,6 +184,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
|
|||
Rtl: in.Rtl,
|
||||
Blocks: blocks,
|
||||
}
|
||||
projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl)
|
||||
if projectionErr != nil && sourceParsed {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if projectionErr == nil {
|
||||
rich.BotAPIProjection = projection
|
||||
}
|
||||
for _, p := range in.Photos {
|
||||
id, ok := inputPhotoID(p)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func tgSelfUser(u domain.User) *tg.User {
|
|||
applyTgUserBotFields(out, u)
|
||||
applyTgUserPremiumFields(out, u)
|
||||
applyTgUserColorFields(out, u)
|
||||
applyTgUserRestrictionFields(out, u)
|
||||
if u.LinkedCommunityID != 0 {
|
||||
out.SetLinkedCommunityID(u.LinkedCommunityID)
|
||||
}
|
||||
|
|
@ -51,6 +52,8 @@ func tgUser(u domain.User) *tg.User {
|
|||
Username: u.Username,
|
||||
Phone: u.Phone,
|
||||
Verified: u.Verified,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
Support: u.Support,
|
||||
Contact: u.Contact,
|
||||
MutualContact: u.Mutual,
|
||||
|
|
@ -60,6 +63,7 @@ func tgUser(u domain.User) *tg.User {
|
|||
applyTgUserBotFields(out, u)
|
||||
applyTgUserPremiumFields(out, u)
|
||||
applyTgUserColorFields(out, u)
|
||||
applyTgUserRestrictionFields(out, u)
|
||||
if u.LinkedCommunityID != 0 {
|
||||
out.SetLinkedCommunityID(u.LinkedCommunityID)
|
||||
}
|
||||
|
|
@ -69,6 +73,28 @@ func tgUser(u domain.User) *tg.User {
|
|||
return out
|
||||
}
|
||||
|
||||
func applyTgUserRestrictionFields(out *tg.User, u domain.User) {
|
||||
if out == nil || len(u.RestrictionReasons) == 0 {
|
||||
return
|
||||
}
|
||||
reasons := make([]tg.RestrictionReason, 0, len(u.RestrictionReasons))
|
||||
for _, reason := range u.RestrictionReasons {
|
||||
if reason.Platform == "" || reason.Reason == "" || reason.Text == "" {
|
||||
continue
|
||||
}
|
||||
reasons = append(reasons, tg.RestrictionReason{
|
||||
Platform: reason.Platform,
|
||||
Reason: reason.Reason,
|
||||
Text: reason.Text,
|
||||
})
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return
|
||||
}
|
||||
out.Restricted = true
|
||||
out.SetRestrictionReason(reasons)
|
||||
}
|
||||
|
||||
// applyTgUserPremiumFields 由到期时间即时派生 premium flag(bit28,独立位)与
|
||||
// emoji status。判断用真实时钟:premium 的权威来源是 premium_expires_at 本身,
|
||||
// 到期即停发,正确性不依赖后台 sweeper(它只负责清理与 updateUser 通知);
|
||||
|
|
|
|||
62
internal/rpc/convert_users_restriction_test.go
Normal file
62
internal/rpc/convert_users_restriction_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestTgUserEncodesFrozenRestriction(t *testing.T) {
|
||||
user := tgUser(domain.User{
|
||||
ID: 1001,
|
||||
FirstName: "Frozen",
|
||||
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
|
||||
})
|
||||
if !user.Restricted {
|
||||
t.Fatal("tg user restricted=false, want true")
|
||||
}
|
||||
reasons, ok := user.GetRestrictionReason()
|
||||
if !ok || len(reasons) != 1 {
|
||||
t.Fatalf("restriction_reason = %+v ok=%v, want one reason", reasons, ok)
|
||||
}
|
||||
if got := reasons[0]; got.Platform != "all" || got.Reason != "frozen" || got.Text != "This account is frozen." {
|
||||
t.Fatalf("restriction_reason = %+v", got)
|
||||
}
|
||||
|
||||
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, user, wire); err != nil {
|
||||
t.Fatalf("encode layer %d frozen user: %v", profile, err)
|
||||
}
|
||||
decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode layer %d frozen user: %v", profile, err)
|
||||
}
|
||||
exact, ok := decoded.(*tg.User)
|
||||
if !ok || !exact.Restricted {
|
||||
t.Fatalf("layer %d user = %#v, want restricted", profile, decoded)
|
||||
}
|
||||
exactReasons, ok := exact.GetRestrictionReason()
|
||||
if !ok || len(exactReasons) != 1 || exactReasons[0].Reason != "frozen" {
|
||||
t.Fatalf("layer %d restriction = %+v ok=%v", profile, exactReasons, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgUserSkipsIncompleteRestriction(t *testing.T) {
|
||||
user := tgUser(domain.User{
|
||||
ID: 1001,
|
||||
FirstName: "Active",
|
||||
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "frozen"}},
|
||||
})
|
||||
if user.Restricted {
|
||||
t.Fatal("incomplete restriction was encoded")
|
||||
}
|
||||
if reasons, ok := user.GetRestrictionReason(); ok || len(reasons) != 0 {
|
||||
t.Fatalf("restriction_reason = %+v ok=%v, want omitted", reasons, ok)
|
||||
}
|
||||
}
|
||||
|
|
@ -253,6 +253,24 @@ type UsersService interface {
|
|||
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
|
||||
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
|
||||
// consent transitions; the RPC layer only projects domain state to TL.
|
||||
type TelegramLoginService interface {
|
||||
ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error)
|
||||
AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error)
|
||||
RequestByDeepLink(ctx context.Context, deepLink string) (domain.TelegramLoginRequest, error)
|
||||
RequestByDeepLinkForOrigin(ctx context.Context, deepLink, inAppOrigin string) (domain.TelegramLoginRequest, error)
|
||||
CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error)
|
||||
Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error)
|
||||
ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
|
||||
RevokeWebAuthorization(ctx context.Context, userID, hash int64) error
|
||||
RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user
|
||||
// (fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer
|
||||
// 与 ByIDs(viewer, ids) 字节等价(personal photo overlay 除外,见 users.ByIDsForViewers)。
|
||||
|
|
@ -885,6 +903,7 @@ type Deps struct {
|
|||
EphemeralPush store.EphemeralPushBroker
|
||||
EphemeralReports store.EphemeralReportStore
|
||||
Users UsersService
|
||||
TelegramLogin TelegramLoginService
|
||||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
|
|
|
|||
51
internal/rpc/deps_validation_test.go
Normal file
51
internal/rpc/deps_validation_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
)
|
||||
|
||||
func TestAssertNoTypedNilDepsRejectsTelegramLogin(t *testing.T) {
|
||||
var service *telegramloginapp.Service
|
||||
defer func() {
|
||||
value := recover()
|
||||
if value == nil {
|
||||
t.Fatal("assertNoTypedNilDeps accepted a typed-nil Telegram Login service")
|
||||
}
|
||||
message := fmt.Sprint(value)
|
||||
if !strings.Contains(message, "dependency TelegramLogin is a typed nil *telegramlogin.Service") {
|
||||
t.Fatalf("panic = %q, want TelegramLogin typed-nil diagnostic", message)
|
||||
}
|
||||
}()
|
||||
New(Config{}, Deps{TelegramLogin: service}, zap.NewNop(), clock.System)
|
||||
}
|
||||
|
||||
func TestAssertNoTypedNilDepsAcceptsAbsentTelegramLogin(t *testing.T) {
|
||||
assertNoTypedNilDeps(Deps{})
|
||||
}
|
||||
|
||||
func TestDisabledTelegramLoginWebAuthorizationRPCs(t *testing.T) {
|
||||
router := New(Config{}, Deps{}, zap.NewNop(), clock.System)
|
||||
ctx := WithUserID(context.Background(), 42)
|
||||
|
||||
listed, err := router.onAccountGetWebAuthorizations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get disabled web authorizations: %v", err)
|
||||
}
|
||||
if len(listed.Authorizations) != 0 || len(listed.Users) != 0 {
|
||||
t.Fatalf("disabled web authorizations = %#v, want empty vectors", listed)
|
||||
}
|
||||
if reset, err := router.onAccountResetWebAuthorization(ctx, 123); err != nil || !reset {
|
||||
t.Fatalf("reset disabled web authorization = %v, %v; want true, nil", reset, err)
|
||||
}
|
||||
if reset, err := router.onAccountResetWebAuthorizations(ctx); err != nil || !reset {
|
||||
t.Fatalf("reset all disabled web authorizations = %v, %v; want true, nil", reset, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,18 @@ func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") }
|
|||
|
||||
func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") }
|
||||
|
||||
func richMessageInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_INVALID") }
|
||||
|
||||
func richMessageTooLongErr() error { return tgerr.New(400, "RICH_MESSAGE_TOO_LONG") }
|
||||
|
||||
func richMessageDateInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_DATE_INVALID") }
|
||||
|
||||
func richMessageMediaUnsupportedErr() error {
|
||||
return tgerr.New(400, "RICH_MESSAGE_MEDIA_UNSUPPORTED")
|
||||
}
|
||||
|
||||
func webpageMediaEmptyErr() error { return tgerr.New(400, "WEBPAGE_MEDIA_EMPTY") }
|
||||
|
||||
func mediaTypeInvalidErr() error { return tgerr.New(400, "MEDIA_TYPE_INVALID") }
|
||||
|
||||
func urlInvalidErr() error { return tgerr.New(400, "URL_INVALID") }
|
||||
|
|
@ -231,8 +243,6 @@ func authTokenExceptionErr() error { return tgerr.New(400, "AUTH_TOKEN_EXCEPTION
|
|||
|
||||
func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") }
|
||||
|
||||
func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") }
|
||||
|
||||
func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") }
|
||||
|
||||
func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *t
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -114,7 +117,23 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message := target.Body
|
||||
entities := append([]domain.MessageEntity(nil), target.Entities...)
|
||||
if rawMessage, ok := req.GetMessage(); ok {
|
||||
richMessage := target.RichMessage
|
||||
setRichMessage := false
|
||||
rawRichMessage, hasRichMessage := req.GetRichMessage()
|
||||
rawMessage, hasMessage := req.GetMessage()
|
||||
if hasMessage && hasRichMessage {
|
||||
return false, mediaInvalidErr()
|
||||
}
|
||||
if hasRichMessage {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return false, richMessageInvalidErr()
|
||||
}
|
||||
message, entities, setRichMessage = "", nil, true
|
||||
} else if hasMessage {
|
||||
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
|
||||
return false, messageEmptyErr()
|
||||
}
|
||||
|
|
@ -127,6 +146,7 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message = rawMessage
|
||||
entities = domainMessageEntitiesForViewer(botID, rawEntities)
|
||||
richMessage, setRichMessage = nil, true
|
||||
} else if req.ReplyMarkup == nil && newMedia == nil {
|
||||
return false, messageNotModifiedErr()
|
||||
}
|
||||
|
|
@ -142,6 +162,11 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
_, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{
|
||||
OwnerUserID: target.OwnerUserID,
|
||||
Peer: target.Peer,
|
||||
|
|
@ -152,6 +177,8 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
EditDate: int(r.clock.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ViaBotEditBotID: botID,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -171,7 +198,23 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
message := target.Body
|
||||
entities := append([]domain.MessageEntity(nil), target.Entities...)
|
||||
var mentionUserIDs []int64
|
||||
if rawMessage, ok := req.GetMessage(); ok {
|
||||
richMessage := target.RichMessage
|
||||
setRichMessage := false
|
||||
rawRichMessage, hasRichMessage := req.GetRichMessage()
|
||||
rawMessage, hasMessage := req.GetMessage()
|
||||
if hasMessage && hasRichMessage {
|
||||
return false, mediaInvalidErr()
|
||||
}
|
||||
if hasRichMessage {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return false, richMessageInvalidErr()
|
||||
}
|
||||
message, entities, setRichMessage = "", nil, true
|
||||
} else if hasMessage {
|
||||
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
|
||||
return false, messageEmptyErr()
|
||||
}
|
||||
|
|
@ -184,6 +227,7 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message = rawMessage
|
||||
entities = domainMessageEntitiesForViewer(botID, rawEntities)
|
||||
richMessage, setRichMessage = nil, true
|
||||
var err error
|
||||
mentionUserIDs, err = r.mentionedUserIDsFromMessage(ctx, botID, message, rawEntities)
|
||||
if err != nil {
|
||||
|
|
@ -210,6 +254,11 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{
|
||||
UserID: target.SenderUserID,
|
||||
ChannelID: target.ChannelID,
|
||||
|
|
@ -221,6 +270,8 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
EditDate: int(r.clock.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ViaBotEditBotID: botID,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ func (r *Router) webPagePreviewMedia(ctx context.Context, message string, entiti
|
|||
|
||||
// resolveWebPageForRequest 为交互式读 RPC 解析链接预览:先查缓存(LookupWebPage,命中即返回,
|
||||
// 不抓取不阻塞);未命中才同步抓取,但用受限短预算(webpageRequestResolveBudget)而非异步解析
|
||||
// 的 20s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
|
||||
// 的 30s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
|
||||
// 决定;抓取失败返回 false。未启用返回 false。
|
||||
func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) {
|
||||
if page, ok := r.resolveAIComposeStyleWebPage(ctx, url); ok {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"strings"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appdialogs "telesrv/internal/app/dialogs"
|
||||
appusers "telesrv/internal/app/users"
|
||||
|
|
@ -120,22 +119,198 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesCreateChatRejectsEmptyInviteListRPC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550001021", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phone string
|
||||
users func(domain.User) []tg.InputUserClass
|
||||
}{
|
||||
{
|
||||
name: "empty vector",
|
||||
phone: "15550001021",
|
||||
users: func(domain.User) []tg.InputUserClass { return nil },
|
||||
},
|
||||
{
|
||||
name: "self references normalize to empty",
|
||||
phone: "15550001022",
|
||||
users: func(owner domain.User) []tg.InputUserClass {
|
||||
return []tg.InputUserClass{
|
||||
&tg.InputUserSelf{},
|
||||
&tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
|
||||
&tg.InputUserSelf{},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(memory.NewChannelStore()),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
if _, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
|
||||
Title: "No Invitees",
|
||||
}); err == nil || !strings.Contains(err.Error(), "USERS_TOO_FEW") {
|
||||
t.Fatalf("create chat without users err = %v, want USERS_TOO_FEW", err)
|
||||
for index, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: int64(21 + index), Phone: tc.phone, FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channels,
|
||||
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
authKeyID := [8]byte{0x60, byte(index + 1)}
|
||||
sessionID := int64(70 + index)
|
||||
requestCtx := WithClientInfo(
|
||||
WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), sessionID),
|
||||
ClientInfo{DeviceModel: "Android", AppVersion: "12.7.3"},
|
||||
)
|
||||
invited, err := r.onMessagesCreateChat(requestCtx, &tg.MessagesCreateChatRequest{
|
||||
Users: tc.users(owner),
|
||||
Title: "Owner Only Group",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner-only chat: %v", err)
|
||||
}
|
||||
if len(invited.MissingInvitees) != 0 {
|
||||
t.Fatalf("missing invitees = %+v, want empty", invited.MissingInvitees)
|
||||
}
|
||||
|
||||
updates, ok := invited.Updates.(*tg.Updates)
|
||||
if !ok || len(updates.Chats) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates)
|
||||
}
|
||||
legacy, ok := updates.Chats[0].(*tg.Chat)
|
||||
if !ok || !legacy.Deactivated || !legacy.Creator || legacy.ParticipantsCount != 1 {
|
||||
t.Fatalf("legacy chat = %#v, want migrated creator-only chat", updates.Chats[0])
|
||||
}
|
||||
channel, ok := updates.Chats[1].(*tg.Channel)
|
||||
if !ok || !channel.Megagroup || channel.Broadcast || !channel.Creator || channel.ParticipantsCount != 1 {
|
||||
t.Fatalf("channel = %#v, want owner-only megagroup", updates.Chats[1])
|
||||
}
|
||||
migrated, ok := legacy.GetMigratedTo()
|
||||
if !ok {
|
||||
t.Fatal("legacy chat missing migrated_to")
|
||||
}
|
||||
migratedChannel, ok := migrated.(*tg.InputChannel)
|
||||
if !ok || migratedChannel.ChannelID != channel.ID || migratedChannel.AccessHash != channel.AccessHash {
|
||||
t.Fatalf("migrated_to = %#v, want channel %d/%d", migrated, channel.ID, channel.AccessHash)
|
||||
}
|
||||
if len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates len = %d, want create service message + channel refresh only", len(updates.Updates))
|
||||
}
|
||||
created, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
|
||||
if !ok || created.Pts != 1 || created.PtsCount != 1 {
|
||||
t.Fatalf("create update = %#v, want pts=1/count=1", updates.Updates[0])
|
||||
}
|
||||
createdMessage, ok := created.Message.(*tg.MessageService)
|
||||
if !ok {
|
||||
t.Fatalf("create message = %T, want messageService", created.Message)
|
||||
}
|
||||
if _, ok := createdMessage.Action.(*tg.MessageActionChannelCreate); !ok {
|
||||
t.Fatalf("create action = %T, want messageActionChannelCreate", createdMessage.Action)
|
||||
}
|
||||
if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
|
||||
t.Fatalf("refresh = %#v, want channel %d", updates.Updates[1], channel.ID)
|
||||
}
|
||||
if len(updates.Users) != 1 {
|
||||
t.Fatalf("updates users len = %d, want creator only", len(updates.Users))
|
||||
}
|
||||
if user, ok := updates.Users[0].(*tg.User); !ok || user.ID != owner.ID {
|
||||
t.Fatalf("updates user = %#v, want owner %d", updates.Users[0], owner.ID)
|
||||
}
|
||||
|
||||
pushedUserIDs := sessions.pushedUserIDs()
|
||||
if len(pushedUserIDs) != 1 || pushedUserIDs[0] != owner.ID {
|
||||
t.Fatalf("push user ids = %v, want creator's other sessions", pushedUserIDs)
|
||||
}
|
||||
push := sessions.snapshot()
|
||||
if push.sessionID != sessionID || sessions.scopedAuthKey() != authKeyID {
|
||||
t.Fatalf("push exclusion = auth_key %x session %d, want %x/%d", sessions.scopedAuthKey(), push.sessionID, authKeyID, sessionID)
|
||||
}
|
||||
canonicalPush, ok := sessions.userMessage.(*tg.Updates)
|
||||
if !ok || len(canonicalPush.Chats) != 1 {
|
||||
t.Fatalf("creator push = %T %+v, want canonical channel updates", sessions.userMessage, sessions.userMessage)
|
||||
}
|
||||
if pushedChannel, ok := canonicalPush.Chats[0].(*tg.Channel); !ok || pushedChannel.ID != channel.ID {
|
||||
t.Fatalf("creator pushed chat = %#v, want channel %d", canonicalPush.Chats[0], channel.ID)
|
||||
}
|
||||
|
||||
participants, err := r.onChannelsGetParticipants(requestCtx, &tg.ChannelsGetParticipantsRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Filter: &tg.ChannelParticipantsRecent{},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get participants: %v", err)
|
||||
}
|
||||
participantList, ok := participants.(*tg.ChannelsChannelParticipants)
|
||||
if !ok || participantList.Count != 1 || len(participantList.Participants) != 1 || len(participantList.Users) != 1 {
|
||||
t.Fatalf("participants = %T %+v, want creator only", participants, participants)
|
||||
}
|
||||
if creator, ok := participantList.Participants[0].(*tg.ChannelParticipantCreator); !ok || creator.UserID != owner.ID {
|
||||
t.Fatalf("participant = %#v, want creator %d", participantList.Participants[0], owner.ID)
|
||||
}
|
||||
|
||||
view, err := channels.GetChannel(ctx, owner.ID, channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get created channel: %v", err)
|
||||
}
|
||||
if view.Self.Role != domain.ChannelRoleCreator || view.Self.Status != domain.ChannelMemberActive {
|
||||
t.Fatalf("self membership = %+v, want active creator", view.Self)
|
||||
}
|
||||
if view.Dialog.TopMessageID != createdMessage.ID || view.Dialog.ReadInboxMaxID != createdMessage.ID || view.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("creator dialog = %+v, want creation message %d read", view.Dialog, createdMessage.ID)
|
||||
}
|
||||
|
||||
var dialogsBuffer bin.Buffer
|
||||
if err := (&tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}).Encode(&dialogsBuffer); err != nil {
|
||||
t.Fatalf("encode getDialogs: %v", err)
|
||||
}
|
||||
dialogsResult, err := r.Dispatch(requestCtx, authKeyID, sessionID, &dialogsBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getDialogs: %v", err)
|
||||
}
|
||||
dialogs, ok := dialogsResult.(*tg.MessagesDialogs)
|
||||
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Chats) != 1 || len(dialogs.Messages) != 1 {
|
||||
t.Fatalf("dialogs = %T %+v, want persisted owner-only group", dialogsResult, dialogsResult)
|
||||
}
|
||||
|
||||
var historyBuffer bin.Buffer
|
||||
if err := (&tg.MessagesGetHistoryRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Limit: 20,
|
||||
}).Encode(&historyBuffer); err != nil {
|
||||
t.Fatalf("encode getHistory: %v", err)
|
||||
}
|
||||
historyResult, err := r.Dispatch(requestCtx, authKeyID, sessionID, &historyBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getHistory: %v", err)
|
||||
}
|
||||
history, ok := historyResult.(*tg.MessagesChannelMessages)
|
||||
if !ok || len(history.Messages) != 1 {
|
||||
t.Fatalf("history = %T %+v, want creation service message", historyResult, historyResult)
|
||||
}
|
||||
if message, ok := history.Messages[0].(*tg.MessageService); !ok || message.ID != createdMessage.ID {
|
||||
t.Fatalf("history message = %#v, want creation service %d", history.Messages[0], createdMessage.ID)
|
||||
}
|
||||
|
||||
difference, err := r.onUpdatesGetChannelDifference(requestCtx, &tg.UpdatesGetChannelDifferenceRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Filter: &tg.ChannelMessagesFilterEmpty{},
|
||||
Pts: 0,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getChannelDifference from pts=0: %v", err)
|
||||
}
|
||||
fullDifference, ok := difference.(*tg.UpdatesChannelDifference)
|
||||
if !ok || fullDifference.Pts != 1 || len(fullDifference.NewMessages) != 1 {
|
||||
t.Fatalf("difference = %T %+v, want creation event at pts=1", difference, difference)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,8 +521,8 @@ func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) {
|
|||
sessions.mu.Lock()
|
||||
pushUserIDs := append([]int64(nil), sessions.pushUserIDs...)
|
||||
sessions.mu.Unlock()
|
||||
if len(pushUserIDs) != 1 || pushUserIDs[0] != friend.ID {
|
||||
t.Fatalf("push user ids = %v, want only invited friend %d", pushUserIDs, friend.ID)
|
||||
if len(pushUserIDs) != 2 || pushUserIDs[0] != owner.ID || pushUserIDs[1] != friend.ID {
|
||||
t.Fatalf("push user ids = %v, want creator then invited friend %d/%d", pushUserIDs, owner.ID, friend.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
return nil, richErr
|
||||
}
|
||||
}
|
||||
if hasMessage && hasRichMessage {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
// Explicit text and rich edits are replacement operations. A text edit must
|
||||
// clear a previously stored rich payload; a rich edit already replaces it.
|
||||
replaceRichMessage := hasRichMessage || hasMessage
|
||||
if hasMessage && richMessage == nil {
|
||||
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
|
||||
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
|
||||
|
|
@ -61,7 +67,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, replaceRichMessage, scheduleDate)
|
||||
}
|
||||
if media, ok := req.GetMedia(); ok {
|
||||
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
|
||||
|
|
@ -104,6 +110,11 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
|
|
@ -119,7 +130,9 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SetRichMessage: hasRichMessage,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: replaceRichMessage,
|
||||
RichMessage: richMessage,
|
||||
EditDate: int(r.clock.Now().Unix()),
|
||||
})
|
||||
|
|
@ -154,7 +167,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
OriginSessionID: sessionID,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: hasRichMessage,
|
||||
SetRichMessage: replaceRichMessage,
|
||||
RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// resolveMonoforumForAdmin 解析 parent_peer 指向的 monoforum 虚拟频道,并校验当前用户是其母广播频道
|
||||
// 的管理员/创建者(频道私信只有频道管理员可读/回复)。monoforum 是私有零成员频道,管理员并非其成员,
|
||||
// 故走 store 的 membership-agnostic 解析,在母频道上做授权。
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效频道但非 monoforum 时返回 (零, false, nil),
|
||||
// 由调用方回退良性空响应(兼容对普通频道传 parent_peer 的被动探测);是 monoforum 但非管理员→CHAT_ADMIN_REQUIRED。
|
||||
// resolveMonoforumForAdmin 解析 TDesktop messages.getSavedDialogs/getSavedHistory 的 parent_peer,
|
||||
// 并校验当前用户可管理母广播频道的 Direct Messages。TDesktop 的 SavedSublist 实际会把
|
||||
// parentChat()->input() 作为 parent_peer;根据客户端 materialize 路径,它既可能是 monoforum
|
||||
// 虚拟频道,也可能是与之关联的母广播频道。因此这里把两种 wire peer 归一到同一个 monoforum,
|
||||
// 授权仍只认母频道的 creator / ManageDirectMessages,绝不能把普通 admin 放进管理者视图。
|
||||
//
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效但未关联 Direct Messages 的普通频道时
|
||||
// 返回 (零, false, nil),由调用方保留良性空响应;关联频道的非管理者返回 CHAT_ADMIN_REQUIRED。
|
||||
func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Channel{}, false, notImplementedErr()
|
||||
|
|
@ -22,9 +25,30 @@ func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, par
|
|||
return domain.Channel{}, false, parentPeerInvalidErr()
|
||||
}
|
||||
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID)
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// TDesktop 当前的 Direct Messages subsection 会传母广播频道。只接受显式的
|
||||
// linked_monoforum 关系,不能把任意普通频道猜成 monoforum。
|
||||
views, viewErr := r.deps.Channels.GetChannels(ctx, userID, []int64{parent.ID})
|
||||
if viewErr != nil {
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
if len(views) != 1 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
parentChannel := views[0].Channel
|
||||
if parentChannel.ID != parent.ID || parentChannel.Deleted || parentChannel.Monoforum || parentChannel.LinkedMonoforumID == 0 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
mono, isAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, parentChannel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
// A visible parent that advertises linked_monoforum_id but cannot resolve
|
||||
// that target violates the durable channel-link invariant. Do not disguise
|
||||
// it as an ordinary channel probe.
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// 非 monoforum 频道(或不存在):非错误,交由调用方回退良性空响应。
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return domain.Channel{}, false, internalErr()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
|
||||
// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs/getSavedHistory 看订阅者子会话,parent_peer 同时兼容 TDesktop 实际发送的
|
||||
// 母广播频道和虚拟 monoforum;订阅者经普通 getHistory 只看自己的子会话。
|
||||
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -64,6 +64,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("get monoforum: %v", err)
|
||||
}
|
||||
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
|
||||
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
|
||||
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
|
||||
|
|
@ -129,7 +130,8 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看私信列表。
|
||||
dreq := &tg.MessagesGetSavedDialogsRequest{}
|
||||
dreq.SetParentPeer(monoInput)
|
||||
// TDesktop SavedSublist::loadAround() 的 parentChat()->input() 是母广播频道。
|
||||
dreq.SetParentPeer(parentInput)
|
||||
dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedDialogs(monoforum): %v", err)
|
||||
|
|
@ -160,7 +162,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看某订阅者会话历史。
|
||||
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
hreq.SetParentPeer(monoInput)
|
||||
hreq.SetParentPeer(parentInput)
|
||||
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(monoforum): %v", err)
|
||||
|
|
@ -194,6 +196,18 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID)
|
||||
}
|
||||
|
||||
// 虚拟 monoforum peer 仍是合法的等价入口,两个 parent 不能落到不同数据集。
|
||||
directMonoReq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
directMonoReq.SetParentPeer(monoInput)
|
||||
directMonoRes, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), directMonoReq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(direct monoforum): %v", err)
|
||||
}
|
||||
directMonoSlice, ok := directMonoRes.(*tg.MessagesMessagesSlice)
|
||||
if !ok || len(directMonoSlice.Messages) != 1 {
|
||||
t.Fatalf("getSavedHistory(direct monoforum) = %#v, want same single-message topic", directMonoRes)
|
||||
}
|
||||
|
||||
// 非管理员(订阅者本人)经管理员入口看列表被拒。
|
||||
if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil {
|
||||
t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ import (
|
|||
|
||||
// registerMessages 注册 messages.* RPC handler。
|
||||
func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.MessagesRequestURLAuthRequest](d, tlprofile.SemanticMethodMessagesRequestURLAuth, func(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (any, error) {
|
||||
return r.onMessagesRequestURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesAcceptURLAuthRequest](d, tlprofile.SemanticMethodMessagesAcceptURLAuth, func(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (any, error) {
|
||||
return r.onMessagesAcceptURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesDeclineURLAuthRequest](d, tlprofile.SemanticMethodMessagesDeclineURLAuth, func(ctx context.Context, req *tg.MessagesDeclineURLAuthRequest) (any, error) {
|
||||
return r.onMessagesDeclineURLAuth(ctx, req.URL)
|
||||
})
|
||||
registerRPC[*tg.MessagesCheckURLAuthMatchCodeRequest](d, tlprofile.SemanticMethodMessagesCheckURLAuthMatchCode, func(ctx context.Context, req *tg.MessagesCheckURLAuthMatchCodeRequest) (any, error) {
|
||||
return r.onMessagesCheckURLAuthMatchCode(ctx, req.URL, req.MatchCode)
|
||||
})
|
||||
registerRPC[*tg.MessagesReceivedMessagesRequest](d, tlprofile.SemanticMethodMessagesReceivedMessages, func(ctx context.Context, layerRequest *tg.MessagesReceivedMessagesRequest) (any, error) {
|
||||
return r.onMessagesReceivedMessages(ctx, layerRequest.
|
||||
MaxID)
|
||||
|
|
@ -79,6 +91,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.MessagesSendMessageRequest](d, tlprofile.SemanticMethodMessagesSendMessage, func(ctx context.Context, layerRequest *tg.MessagesSendMessageRequest) (any, error) {
|
||||
return r.onMessagesSendMessage(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesToggleSuggestedPostApprovalRequest](d, tlprofile.SemanticMethodMessagesToggleSuggestedPostApproval, func(ctx context.Context, layerRequest *tg.MessagesToggleSuggestedPostApprovalRequest) (any, error) {
|
||||
return r.onMessagesToggleSuggestedPostApproval(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesForwardMessagesRequest](d, tlprofile.SemanticMethodMessagesForwardMessages, func(ctx context.Context, layerRequest *tg.MessagesForwardMessagesRequest) (any, error) {
|
||||
return r.onMessagesForwardMessages(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue