Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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 并设置 joingroupsjoin=true或 privacyjoin=false
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool

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

View file

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

View file

@ -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 tokenrevoke=true 时先轮换 secret 并撤销已登录 session。
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
if revoke {

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View file

@ -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 提供客户端启动配置与国家区号目录。
//

View file

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

View file

@ -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 启用服务端内置 botBotFather对私聊消息的自动应答。
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
}

View file

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

View file

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

View 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
}

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

View file

@ -0,0 +1,5 @@
//go:build !jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = false

View 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")
}
}

View file

@ -0,0 +1,5 @@
//go:build jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = true

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

View 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")
}
}

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

View 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")
}
}

View 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
}

File diff suppressed because it is too large Load diff

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

View 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
}

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

View file

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

View file

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

View file

@ -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。可选实现了它的 evaluatorprivacy.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 {

View file

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

View file

@ -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 下发
// 正确性由读取路径即时派生保证,这里只做收尾与通知。

View file

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

View file

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

View 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
}

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"},

View file

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

View 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
}

View file

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

View file

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

View file

@ -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
// PageBlockBlocks+ 内嵌已解析的 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 仅支持 inputRichMessageblocks 形态),不解析
// 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View 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")
}
}

View file

@ -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 必须 ≥1TDesktop 只认
// 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 报告用户在 nowUnix 秒)时刻是否为有效会员。
// bot 永不为会员(官方语义;授予路径同样排除 bot这里是双保险
func (u User) PremiumActiveAt(now int64) bool {

View file

@ -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 账号不可被授予会员(官方语义)。

View file

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

View file

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

View file

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

View file

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

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

View 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
}

View file

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

View file

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

View file

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

View file

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

View 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
}

View 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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View file

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

View file

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

View file

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

View file

@ -10,10 +10,9 @@ import (
"telesrv/internal/domain"
)
// 本文件集中 Layer 227 富文本消息richMessage的 tg.* ↔ domain 转换。
// Phase 1仅支持 inputRichMessageblocks 形态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 1HTML/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 {

View file

@ -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 flagbit28独立位
// emoji status。判断用真实时钟premium 的权威来源是 premium_expires_at 本身,
// 到期即停发,正确性不依赖后台 sweeper它只负责清理与 updateUser 通知);

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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