Merge remote-tracking branch 'upstream/main' into dev

This commit is contained in:
onysd 2026-07-18 09:19:27 +03:00
commit 6b29556ef8
836 changed files with 1598388 additions and 64684 deletions

View file

@ -2,8 +2,12 @@ package admin
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"sort"
"strings"
"time"
@ -12,21 +16,26 @@ import (
)
const (
ActionSetSendFrozen = "account.set_send_frozen"
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionSetAccountFrozen = "account.set_frozen"
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import"
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
maxCommandIDLength = 128
maxActorLength = 128
maxReasonLength = 1000
maxHistoryBatches = 100
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxCommandIDLength = 128
maxActorLength = 128
maxReasonLength = 1000
maxHistoryBatches = 100
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxFreezeAppealURLLength = 2048
)
type CommandRepository interface {
@ -35,9 +44,8 @@ type CommandRepository interface {
}
type RestrictionStore interface {
GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error)
SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error)
IsSendFrozen(ctx context.Context, userID int64) (bool, error)
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
}
type AuthService interface {
@ -84,6 +92,17 @@ type MessagesService interface {
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
}
type GiftsService interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error)
CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -96,6 +115,7 @@ type Dependencies struct {
Channels ChannelsService
ChannelNotifier ChannelNotifier
Messages MessagesService
Gifts GiftsService
Now func() time.Time
}
@ -111,6 +131,7 @@ type Service struct {
channels ChannelsService
channelNotifier ChannelNotifier
messages MessagesService
gifts GiftsService
now func() time.Time
}
@ -153,6 +174,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Messages != nil {
s.messages = deps.Messages
}
if deps.Gifts != nil {
s.gifts = deps.Gifts
}
if deps.Now != nil {
s.now = deps.Now
}
@ -182,10 +206,69 @@ type CommandResult struct {
Error string `json:"error,omitempty"`
}
type SetSendFrozenRequest struct {
type ImportStarGiftRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
GiftID int64 `json:"gift_id,omitempty"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
FileName string `json:"file_name"`
ContentSHA string `json:"content_sha256"`
Data []byte `json:"-"`
}
type SetStarGiftEnabledRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
Enabled bool `json:"enabled"`
}
type SetStarGiftSortOrderRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
SortOrder int `json:"sort_order"`
}
type StarGiftCollectibleAnimationUpload struct {
Name string `json:"name"`
RarityPermille int `json:"rarity_permille"`
SortOrder int `json:"sort_order"`
FileKey string `json:"file_key"`
FileName string `json:"file_name,omitempty"`
ContentSHA string `json:"content_sha256,omitempty"`
Data []byte `json:"-"`
}
type StarGiftCollectibleBackdropInput struct {
Name string `json:"name"`
BackdropID int `json:"backdrop_id"`
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
PatternColor int `json:"pattern_color"`
TextColor int `json:"text_color"`
RarityPermille int `json:"rarity_permille"`
SortOrder int `json:"sort_order"`
}
type PublishStarGiftCollectiblesRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
UpgradeStars int64 `json:"upgrade_stars"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []StarGiftCollectibleAnimationUpload `json:"models"`
Patterns []StarGiftCollectibleAnimationUpload `json:"patterns"`
Backdrops []StarGiftCollectibleBackdropInput `json:"backdrops"`
}
type SetAccountFrozenRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
Until time.Time `json:"freeze_until,omitempty"`
AppealURL string `json:"freeze_appeal_url,omitempty"`
}
type GrantPremiumRequest struct {
@ -240,52 +323,127 @@ type DeletePrivateHistoryRequest struct {
MaxBatches int `json:"max_batches,omitempty"`
}
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
// AccountFreeze returns the durable account-level freeze state. A missing row
// is the only non-frozen default; invalid active rows are rejected by the
// store/schema instead of normalized on read.
func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if s == nil || s.restrictions == nil || userID == 0 {
return domain.AccountFreeze{}, false, nil
}
freeze, found, err := s.restrictions.GetAccountFreeze(ctx, userID)
if err != nil || !found {
return freeze, found, err
}
if err := validateAccountFreeze(freeze); err != nil {
return domain.AccountFreeze{}, false, fmt.Errorf("invalid durable account freeze for user %d: %w", userID, err)
}
return freeze, true, nil
}
func validateAccountFreeze(freeze domain.AccountFreeze) error {
if !freeze.Frozen {
if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
return fmt.Errorf("inactive freeze retains client-visible state")
}
return nil
}
frozen, err := s.restrictions.IsSendFrozen(ctx, userID)
if err != nil {
return err
if freeze.Since.IsZero() || freeze.Until.IsZero() || !freeze.Until.After(freeze.Since) ||
freeze.Since.Unix() <= 0 || freeze.Until.Unix() > math.MaxInt32 {
return fmt.Errorf("active freeze has invalid since/until")
}
if frozen {
return domain.ErrUserSendRestricted
if len(freeze.AppealURL) > maxFreezeAppealURLLength {
return fmt.Errorf("active freeze appeal URL is too long")
}
parsed, err := url.ParseRequestURI(freeze.AppealURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("active freeze has invalid appeal URL")
}
return nil
}
func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) {
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
freeze, found, err := s.AccountFreeze(ctx, userID)
if err != nil {
return err
}
if found && freeze.Frozen {
return domain.ErrUserFrozen
}
return nil
}
func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.restrictions == nil {
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetSendFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
prev, found, err := s.restrictions.GetSendRestriction(ctx, req.UserID)
now := s.now().UTC()
appealURL := strings.TrimSpace(req.AppealURL)
if req.Frozen {
if req.Until.IsZero() || req.Until.Unix() > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("freeze_until must be a non-zero int32 Unix timestamp")
}
if len(appealURL) > maxFreezeAppealURLLength {
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be <= %d bytes", maxFreezeAppealURLLength)
}
parsed, err := url.ParseRequestURI(appealURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be an absolute HTTP(S) URL")
}
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
// Keep this time-relative check inside runCommand: a completed command ID
// must remain replayable after its deadline, while a new stale request is
// recorded as failed and cannot mutate the restriction row.
if req.Frozen && !req.Until.After(now) {
return CommandResult{}, fmt.Errorf("freeze_until must be in the future")
}
prev, found, err := s.restrictions.GetAccountFreeze(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"previous_frozen": found && prev.Frozen,
"new_frozen": req.Frozen,
"would_change": !found || prev.Frozen != req.Frozen,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.restrictions.SetSendRestriction(ctx, domain.AccountSendRestriction{
next := domain.AccountFreeze{
UserID: req.UserID,
Frozen: req.Frozen,
Reason: req.Reason,
Actor: req.Actor,
CommandID: req.CommandID,
})
}
if req.Frozen {
next.Since = now
if found && prev.Frozen {
next.Since = prev.Since
}
next.Until = req.Until.UTC()
next.AppealURL = appealURL
if !next.Until.After(next.Since) {
return CommandResult{}, fmt.Errorf("freeze_until must be after freeze_since")
}
}
wouldChange := !found || prev.Frozen != next.Frozen ||
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) ||
prev.AppealURL != next.AppealURL
details := map[string]any{
"previous_frozen": found && prev.Frozen,
"new_frozen": req.Frozen,
"would_change": wouldChange,
}
if req.Frozen {
details["freeze_since"] = next.Since.Format(time.RFC3339)
details["freeze_until"] = next.Until.Format(time.RFC3339)
details["freeze_appeal_url"] = next.AppealURL
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.restrictions.SetAccountFreeze(ctx, next)
if err != nil {
return CommandResult{}, err
}
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
return CommandResult{Message: "send restriction updated", Details: details}, nil
return CommandResult{Message: "account freeze updated", Details: details}, nil
})
}
@ -622,6 +780,182 @@ func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHis
})
}
func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured")
}
if req.GiftID < 0 || req.Stars <= 0 || req.ConvertStars < 0 || req.ConvertStars > req.Stars ||
req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 ||
len([]rune(strings.TrimSpace(req.Title))) > domain.MaxStarGiftTitleRunes {
return CommandResult{}, domain.ErrStarGiftInvalid
}
animation, err := s.gifts.PrepareAnimation(req.FileName, req.Data)
if err != nil {
return CommandResult{}, err
}
req.ContentSHA = hex.EncodeToString(animation.SHA256)
return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars,
"convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder,
"source_format": animation.SourceFormat, "source_name": animation.SourceName,
"sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height,
"frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON),
}
if req.DryRun {
return CommandResult{Message: "star gift import validated", Details: details}, nil
}
entry, err := s.gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: animation,
Actor: req.Actor, CommandID: req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, err
}
details["gift_id"] = entry.Gift.ID
details["revision_id"] = entry.Gift.RevisionID
details["revision"] = entry.Revision
return CommandResult{Message: "star gift imported", Details: details}, nil
})
}
func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured")
}
toAttributes := func(kind domain.StarGiftCollectibleAttributeKind, uploads []StarGiftCollectibleAnimationUpload) ([]domain.StarGiftCollectibleAttribute, error) {
attributes := make([]domain.StarGiftCollectibleAttribute, len(uploads))
for i := range uploads {
animation, err := s.gifts.PrepareAnimation(uploads[i].FileName, uploads[i].Data)
if err != nil {
return nil, fmt.Errorf("prepare %s %q: %w", kind, uploads[i].Name, err)
}
uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256)
attributes[i] = domain.StarGiftCollectibleAttribute{
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation,
}
}
return attributes, nil
}
models, err := toAttributes(domain.StarGiftCollectibleModel, req.Models)
if err != nil {
return CommandResult{}, err
}
patterns, err := toAttributes(domain.StarGiftCollectiblePattern, req.Patterns)
if err != nil {
return CommandResult{}, err
}
backdrops := make([]domain.StarGiftCollectibleAttribute, len(req.Backdrops))
for i, backdrop := range req.Backdrops {
backdrops[i] = domain.StarGiftCollectibleAttribute{
Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID,
CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor,
TextColor: backdrop.TextColor, RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder,
}
}
write := domain.StarGiftCollectibleWrite{
GiftID: req.GiftID, UpgradeStars: req.UpgradeStars, SupplyTotal: req.SupplyTotal,
SlugPrefix: strings.ToLower(strings.TrimSpace(req.SlugPrefix)), Models: models, Patterns: patterns, Backdrops: backdrops,
Actor: req.Actor, CommandID: req.CommandID,
}
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return CommandResult{}, err
}
// Persist normalized content hashes in the command payload so retries with changed files are
// rejected by the shared idempotency boundary even though raw file bytes are not audit-logged.
for i := range req.Models {
req.Models[i].ContentSHA = hex.EncodeToString(models[i].Animation.SHA256)
}
for i := range req.Patterns {
req.Patterns[i].ContentSHA = hex.EncodeToString(patterns[i].Animation.SHA256)
}
return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal,
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models),
"patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops),
}
if req.DryRun {
return CommandResult{Message: "star gift collectible pool validated", Details: details}, nil
}
revision, err := s.gifts.CreateCollectibleRevision(ctx, write)
if err != nil {
return CommandResult{Details: details}, err
}
details["revision_id"] = revision.ID
details["revision"] = revision.Revision
details["published"] = revision.Published
return CommandResult{Message: "star gift collectible pool published", Details: details}, nil
})
}
func collectibleUploadDetails(uploads []StarGiftCollectibleAnimationUpload) []map[string]any {
details := make([]map[string]any, 0, len(uploads))
for _, upload := range uploads {
details = append(details, map[string]any{
"name": strings.TrimSpace(upload.Name), "rarity_permille": upload.RarityPermille,
"sort_order": upload.SortOrder, "source_name": upload.FileName, "sha256": upload.ContentSHA,
})
}
return details
}
func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabledRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || req.GiftID <= 0 {
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"gift_id": req.GiftID, "enabled": req.Enabled}
if req.DryRun {
return CommandResult{Message: "star gift state change validated", Details: details}, nil
}
changed, err := s.gifts.SetCatalogEnabled(ctx, req.GiftID, req.Enabled)
details["changed"] = changed
return CommandResult{Message: "star gift state updated", Details: details}, err
})
}
func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortOrderRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || req.GiftID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"gift_id": req.GiftID, "sort_order": req.SortOrder}
if req.DryRun {
return CommandResult{Message: "star gift order change validated", Details: details}, nil
}
changed, err := s.gifts.SetCatalogSortOrder(ctx, req.GiftID, req.SortOrder)
details["changed"] = changed
return CommandResult{Message: "star gift order updated", Details: details}, err
})
}
func (s *Service) StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return nil, false, nil
}
return s.gifts.AnimationJSON(ctx, giftID)
}
func (s *Service) StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
return s.gifts.CollectiblePreview(ctx, giftID)
}
func (s *Service) StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 || attributeID <= 0 {
return nil, false, nil
}
if kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern {
return nil, false, domain.ErrStarGiftCollectibleInvalid
}
return s.gifts.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) {
if s == nil || s.commands == nil {
return CommandResult{}, fmt.Errorf("admin command store is not configured")
@ -658,6 +992,9 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
return CommandResult{}, err
}
if !created {
if cmd.Action != action || cmd.DryRun != meta.DryRun || !sameJSON(cmd.RequestJSON, requestJSON) {
return CommandResult{CommandID: meta.CommandID, Action: action, Status: string(domain.AdminCommandFailed), Error: "COMMAND_ID_CONFLICT", Message: "command_id is already bound to a different request"}, fmt.Errorf("COMMAND_ID_CONFLICT")
}
return resultFromCommand(cmd), nil
}
result, opErr := fn()
@ -691,6 +1028,14 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
return result, opErr
}
func sameJSON(a, b []byte) bool {
var left, right any
if json.Unmarshal(a, &left) != nil || json.Unmarshal(b, &right) != nil {
return string(a) == string(b)
}
return reflect.DeepEqual(left, right)
}
func resultFromCommand(cmd domain.AdminCommand) CommandResult {
var result CommandResult
if len(cmd.ResultJSON) > 0 {

View file

@ -2,15 +2,17 @@ package admin
import (
"context"
"crypto/sha256"
"errors"
"reflect"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
restrictions := &fakeRestrictionStore{}
@ -20,10 +22,12 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
Now: fixedNow,
})
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
})
if err != nil {
t.Fatalf("dry-run freeze: %v", err)
@ -32,23 +36,29 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
}
execReq := SetSendFrozenRequest{
execReq := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
}
exec, err := svc.SetSendFrozen(ctx, execReq)
exec, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("execute freeze: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
}
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err)
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Frozen || !freeze.Since.Equal(fixedNow()) || freeze.AppealURL != execReq.AppealURL {
t.Fatalf("AccountFreeze = %+v found=%v err=%v", freeze, found, err)
}
again, err := svc.SetSendFrozen(ctx, execReq)
again, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("duplicate freeze: %v", err)
}
@ -57,6 +67,101 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
}
}
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
ctx := context.Background()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Restrictions: restrictions, Now: fixedNow})
for _, req := range []SetAccountFrozenRequest{
{CommandMeta: CommandMeta{CommandID: "bad-until", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow(), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "too-far", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: time.Unix(1<<31, 0), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "bad-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "javascript:bad"},
{CommandMeta: CommandMeta{CommandID: "long-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "https://appeals.example.test/" + strings.Repeat("x", maxFreezeAppealURLLength)},
} {
if _, err := svc.SetAccountFrozen(ctx, req); err == nil {
t.Fatalf("SetAccountFrozen(%+v) succeeded", req)
}
}
freezeReq := SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "freeze", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(24 * time.Hour), AppealURL: "https://appeals.example.test"}
if _, err := svc.SetAccountFrozen(ctx, freezeReq); err != nil {
t.Fatal(err)
}
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "unfreeze", Actor: "ops", Reason: "accepted"}, UserID: 1001}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || freeze.Frozen || !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
t.Fatalf("unfrozen state = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenUpdatePreservesOriginalSince(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-initial", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/initial",
}); err != nil {
t.Fatal(err)
}
originalSince := now
now = now.Add(2 * time.Hour)
updatedUntil := now.Add(72 * time.Hour)
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-update", Actor: "ops", Reason: "extend review"},
UserID: 1001,
Frozen: true,
Until: updatedUntil,
AppealURL: "https://appeals.example.test/updated",
}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Since.Equal(originalSince) || !freeze.Until.Equal(updatedUntil) ||
freeze.AppealURL != "https://appeals.example.test/updated" {
t.Fatalf("updated freeze = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenReplayRemainsIdempotentAfterDeadline(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
req := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-expiring", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(time.Hour),
AppealURL: "https://appeals.example.test/expiring",
}
if _, err := svc.SetAccountFrozen(ctx, req); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
replayed, err := svc.SetAccountFrozen(ctx, req)
if err != nil || !replayed.AlreadyExecuted || restrictions.setCalls != 1 {
t.Fatalf("expired replay = %+v err=%v setCalls=%d", replayed, err, restrictions.setCalls)
}
stale := req
stale.CommandID = "new-stale-freeze"
if _, err := svc.SetAccountFrozen(ctx, stale); err == nil || restrictions.setCalls != 1 {
t.Fatalf("new stale request err=%v setCalls=%d, want rejection without state mutation", err, restrictions.setCalls)
}
}
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
@ -365,21 +470,21 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountSendRestriction
items map[int64]domain.AccountFreeze
setCalls int
}
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if f.items == nil {
return domain.AccountSendRestriction{}, false, nil
return domain.AccountFreeze{}, false, nil
}
r, ok := f.items[userID]
return r, ok, nil
}
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.AccountFreeze) (domain.AccountFreeze, error) {
if f.items == nil {
f.items = map[int64]domain.AccountSendRestriction{}
f.items = map[int64]domain.AccountFreeze{}
}
f.setCalls++
r.UpdatedAt = fixedNow()
@ -387,13 +492,6 @@ func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.Ac
return r, nil
}
func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) {
if f.items == nil {
return false, nil
}
return f.items[userID].Frozen, nil
}
type fakeMessagesService struct {
byID []domain.Message
deleteCalls int
@ -598,6 +696,95 @@ type fakeChannelNotifier struct {
channels []int64
}
func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
base := ImportStarGiftRequest{
Title: "Cake", Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 3,
FileName: "cake.lottie", Data: []byte(`{"v":"5.7"}`),
}
base.CommandMeta = CommandMeta{CommandID: "dry-gift", Actor: "ops", Reason: "catalog", DryRun: true}
preview, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["source_format"] != domain.StarGiftAnimationLottie {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
result, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(22) {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
func TestCommandIDConflictRejectsDifferentGiftBytes(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
req := ImportStarGiftRequest{
CommandMeta: CommandMeta{CommandID: "same", Actor: "ops", Reason: "catalog", DryRun: true},
Title: "Gift", Stars: 10, ConvertStars: 5, Enabled: true, FileName: "a.lottie", Data: []byte("one"),
}
if _, err := svc.ImportStarGift(context.Background(), req); err != nil {
t.Fatal(err)
}
req.Data = []byte("two")
if _, err := svc.ImportStarGift(context.Background(), req); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
t.Fatalf("conflict err=%v", err)
}
}
func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
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}},
}
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["models"] == nil {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
result, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(33) || result.Details["published"] != true {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
type fakeGiftsService struct{ createCalls int }
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data)
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationLottie,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil
}
func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
f.createCalls++
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil
}
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}
func (*fakeGiftsService) SetCatalogSortOrder(context.Context, int64, int) (bool, error) {
return true, nil
}
func (*fakeGiftsService) AnimationJSON(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeGiftsService) CreateCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
f.createCalls++
return domain.StarGiftCollectibleRevision{ID: 33, GiftID: write.GiftID, Revision: 2, Published: true}, nil
}
func (*fakeGiftsService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}
func (*fakeGiftsService) CollectibleAnimationJSON(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
f.channels = append(f.channels, ch.ID)
return nil

View file

@ -5,13 +5,16 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
type Config struct {
@ -20,7 +23,7 @@ type Config struct {
}
type Service interface {
SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error)
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
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)
@ -28,6 +31,13 @@ type Service interface {
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)
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error)
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
StarGiftAnimation(ctx context.Context, giftID 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)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -76,7 +86,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/accounts/freeze-send", s.authenticated(s.handleFreezeSend))
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
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))
@ -84,6 +94,13 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
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))
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
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
}
@ -98,12 +115,12 @@ func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
}
}
func (s *Server) handleFreezeSend(w http.ResponseWriter, r *http.Request) {
var req admin.SetSendFrozenRequest
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetSendFrozen(r.Context(), req)
result, err := s.svc.SetAccountFrozen(r.Context(), req)
writeCommandResult(w, result, err)
}
@ -170,6 +187,222 @@ func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.ImportStarGiftRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "animation file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
writeError(w, http.StatusBadRequest, "animation file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.ImportStarGift(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.PublishStarGiftCollectiblesRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
req.GiftID = giftID
seen := make(map[string]struct{}, len(req.Models)+len(req.Patterns))
if len(req.Models)+len(req.Patterns) > 128 {
writeError(w, http.StatusBadRequest, "too many collectible animation files")
return
}
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
upload.FileKey = strings.TrimSpace(upload.FileKey)
if upload.FileKey == "" {
return fmt.Errorf("animation file key is required")
}
if _, ok := seen[upload.FileKey]; ok {
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
}
seen[upload.FileKey] = struct{}{}
file, header, err := r.FormFile(upload.FileKey)
if err != nil {
return fmt.Errorf("animation file %q is required", upload.FileKey)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
}
upload.FileName = header.Filename
upload.Data = data
return nil
}
for i := range req.Models {
if err := load(&req.Models[i]); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
for i := range req.Patterns {
if err := load(&req.Patterns[i]); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
result, err := s.svc.PublishStarGiftCollectibles(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStarGiftEnabled(w http.ResponseWriter, r *http.Request) {
var req admin.SetStarGiftEnabledRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStarGiftEnabled(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Request) {
var req admin.SetStarGiftSortOrderRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStarGiftSortOrder(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 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
raw, found, err := s.svc.StarGiftAnimation(r.Context(), giftID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "gift 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 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
preview, found, err := s.svc.StarGiftCollectibles(r.Context(), giftID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID})
return
}
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
}
func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
result := map[string]any{
"id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille,
"sort_order": value.SortOrder, "kind": value.Kind,
}
if value.Animation != nil {
result["source_name"] = value.Animation.SourceName
result["source_format"] = value.Animation.SourceFormat
}
if value.Kind == domain.StarGiftCollectibleBackdrop {
result["backdrop_id"] = value.BackdropID
result["center_color"] = value.CenterColor
result["edge_color"] = value.EdgeColor
result["pattern_color"] = value.PatternColor
result["text_color"] = value.TextColor
}
return result
}
mapAttributes := func(values []domain.StarGiftCollectibleAttribute) []map[string]any {
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, attribute(value))
}
return result
}
return map[string]any{
"found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars,
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
"slug_prefix": preview.SlugPrefix,
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
"backdrops": mapAttributes(preview.Backdrops),
}
}
func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
kind := domain.StarGiftCollectibleAttributeKind(r.PathValue("kind"))
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 ||
(kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern) {
writeError(w, http.StatusBadRequest, "invalid collectible animation")
return
}
raw, found, err := s.svc.StarGiftCollectibleAnimation(r.Context(), giftID, kind, attributeID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "collectible 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 decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -1,18 +1,21 @@
package adminapi
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
func TestAdminAPIRequiresBearerToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{}`))
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-frozen", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
@ -20,9 +23,10 @@ func TestAdminAPIRequiresBearerToken(t *testing.T) {
}
}
func TestAdminAPIFreezeSend(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{"command_id":"c1","actor":"ops","reason":"test","dry_run":true,"user_id":1001,"frozen":true}`))
func TestAdminAPISetAccountFrozen(t *testing.T) {
svc := &captureFreezeService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-frozen", strings.NewReader(`{"command_id":"c1","actor":"ops","reason":"test","dry_run":true,"user_id":1001,"frozen":true,"freeze_until":"2030-01-02T00:00:00Z","freeze_appeal_url":"https://appeals.example.test"}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
@ -32,6 +36,9 @@ func TestAdminAPIFreezeSend(t *testing.T) {
if !strings.Contains(rec.Body.String(), `"command_id":"c1"`) {
t.Fatalf("body=%s", rec.Body.String())
}
if svc.req.UserID != 1001 || !svc.req.Frozen || svc.req.Until.IsZero() || svc.req.AppealURL != "https://appeals.example.test" {
t.Fatalf("decoded freeze request = %+v", svc.req)
}
}
func TestAdminAPISetVerified(t *testing.T) {
@ -76,9 +83,107 @@ func TestAdminAPISetChannelVerified(t *testing.T) {
}
}
func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("metadata", `{"command_id":"gift-1","actor":"ops","reason":"catalog","dry_run":true,"title":"Gift","stars":50,"convert_stars":25,"enabled":true,"sort_order":3}`); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "gift.lottie")
if err != nil {
t.Fatal(err)
}
animation := []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`)
if _, err := part.Write(animation); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
svc := &captureGiftService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/import", &body)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.req.CommandID != "gift-1" || svc.req.FileName != "gift.lottie" || !bytes.Equal(svc.req.Data, animation) || svc.req.Stars != 50 || svc.req.ConvertStars != 25 {
t.Fatalf("decoded gift request = %+v", svc.req)
}
}
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}]}`
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"} {
part, err := writer.CreateFormFile(key, name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(key)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
svc := &captureCollectibleService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/11/collectibles/publish", &body)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
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 {
t.Fatalf("decoded collectible request = %+v", svc.req)
}
}
type fakeService struct{}
func (fakeService) SetSendFrozen(_ context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error) {
type captureFreezeService struct {
fakeService
req admin.SetAccountFrozenRequest
}
type captureGiftService struct {
fakeService
req admin.ImportStarGiftRequest
}
type captureCollectibleService struct {
fakeService
req admin.PublishStarGiftCollectiblesRequest
}
func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureGiftService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
@ -109,3 +214,31 @@ func (fakeService) DeletePrivateMessages(context.Context, admin.DeletePrivateMes
func (fakeService) DeletePrivateHistory(context.Context, admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}
func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetStarGiftEnabled(_ context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetStarGiftSortOrder(_ context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}

View file

@ -9,6 +9,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -30,8 +31,10 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User
}
type captureMailSender struct {
to string
code string
to string
code string
requests []otpdelivery.Request
err error
}
type blockingCodeCAS struct {
@ -116,10 +119,102 @@ func (s *blockingCodeCAS) CompareAndDelete(ctx context.Context, key, revision st
return s.CodeStore.CompareAndDelete(ctx, key, revision)
}
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
func (s *captureMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
s.to = req.Recipient
s.code = req.Code
s.requests = append(s.requests, req)
return otpdelivery.Result{}, s.err
}
func TestLoginEmailDeliveryCarriesPurposeAndStableID(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010150")
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "Alice@Example.Test", false)
if err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
if pattern == "" || length != 6 || len(sender.requests) != 1 {
t.Fatalf("pattern=%q length=%d requests=%d", pattern, length, len(sender.requests))
}
req := sender.requests[0]
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginEmailChange || req.Channel != otpdelivery.ChannelEmail ||
req.Recipient != "alice@example.test" || len(req.Code) != 6 {
t.Fatalf("request = %+v", req)
}
snapshot, found, err := codes.GetSnapshot(ctx, loginEmailVerifyChangePrefix+fmt.Sprint(u.ID))
if err != nil || !found || snapshot.Record.DeliveryID != req.DeliveryID || snapshot.Record.Code != req.Code {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
}
func TestLoginEmailSetupDeliveryUsesSetupPurpose(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
phone := "15550010151"
phoneHash := "setup-purpose-hash"
if err := codes.Set(ctx, phoneHash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Channel: codeChannelEmailSetupRequired,
}, time.Minute); err != nil {
t.Fatalf("seed setup code: %v", err)
}
svc := NewService(memory.NewPasswordStore(),
WithUsers(memory.NewUserStore()),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
if _, _, err := svc.SendLoginEmailCode(ctx, 0, phone, phoneHash, "new@example.test", true); err != nil {
t.Fatalf("SendLoginEmailCode setup: %v", err)
}
if len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposeLoginEmailSetup {
t.Fatalf("requests = %+v", sender.requests)
}
}
func TestLoginEmailExplicitRejectionDeletesOnlyCurrentAttempt(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 400, Code: "RECIPIENT_INVALID"}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010152")
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "bad@example.test", false); err == nil {
t.Fatal("explicit rejection succeeded")
}
if _, found, err := codes.Get(ctx, key); err != nil || found {
t.Fatalf("rejected code found=%v err=%v", found, err)
}
}
func TestLoginEmailUnknownOutcomeReturnsSuccessAndKeepsCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("ack lost")}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010153")
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "unknown@example.test", false); err != nil {
t.Fatalf("unknown outcome: %v", err)
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
if rec, found, err := codes.Get(ctx, key); err != nil || !found || rec.Code != sender.code {
t.Fatalf("unknown code=%+v found=%v err=%v", rec, found, err)
}
}
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后GetPassword 下发掩码 pattern原始

View file

@ -5,11 +5,13 @@ import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -53,27 +55,60 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
if s.emailSignupEnabled {
return s.sendChangePhoneCodeByEmail(ctx, userID, authKeyID, sessionID, phone)
}
if strings.TrimSpace(s.phoneChangeCode) == "" {
if s.phoneCodeSender == nil && strings.TrimSpace(s.phoneChangeCode) == "" {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
}
hash, err := phoneChangeHash()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
code := s.phoneChangeCode
channel := store.PhoneCodeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
channel = store.PhoneCodeChannelSMS
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: s.phoneChangeCode,
Channel: store.PhoneCodeChannelPhone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
}
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change code: %w", cleanupErr))
}
return "", domain.AuthCodeDelivery{}, err
}
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
}
@ -103,10 +138,15 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64,
return "", domain.AuthCodeDelivery{}, err
}
ttl := s.phoneChangeCodeTTL
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: store.PhoneCodeChannelEmailLogin,
Purpose: store.PhoneCodePurposeChangePhone,
Email: email,
@ -115,11 +155,24 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
}
expiresAt := time.Now().Add(ttl)
if err := s.codes.Set(ctx, hash, rec, ttl); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
}
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, ttl); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("send phone change email code: %w", err)
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelEmail,
Recipient: email,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change email code: %w", cleanupErr))
}
return "", domain.AuthCodeDelivery{}, err
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmail, EmailPattern: emailPattern(email), Length: len(code)}, nil
}
@ -165,7 +218,9 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
consumed := verified.Record
channelOK := consumed.Channel == store.PhoneCodeChannelPhone || consumed.Channel == store.PhoneCodeChannelEmailLogin
channelOK := consumed.Channel == store.PhoneCodeChannelPhone ||
consumed.Channel == store.PhoneCodeChannelSMS ||
consumed.Channel == store.PhoneCodeChannelEmailLogin
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || !channelOK {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
}

View file

@ -8,6 +8,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -30,6 +31,16 @@ type recordingPhoneChangeStore struct {
last domain.PhoneChangeRequest
}
type trackingPhoneCodeStore struct {
store.CodeStore
lastHash string
}
func (s *trackingPhoneCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
s.lastHash = hash
return s.CodeStore.Set(ctx, hash, code, ttl)
}
func (s *recordingPhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
s.mu.Lock()
s.last = req
@ -67,6 +78,53 @@ func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes}
}
func TestPhoneChangeWebhookDeliversRandomScopedCode(t *testing.T) {
f := newPhoneChangeFixture(t)
sender := &captureMailSender{}
f.service.phoneCodeSender = sender
f.service.phoneCodeLength = 6
f.service.phoneChangeCode = ""
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012020")
if err != nil {
t.Fatalf("SendChangePhoneCode: %v", err)
}
if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 || len(sender.requests) != 1 {
t.Fatalf("hash=%q delivery=%+v requests=%d", hash, delivery, len(sender.requests))
}
req := sender.requests[0]
if req.Purpose != otpdelivery.PurposeChangePhone || req.Channel != otpdelivery.ChannelSMS || req.Recipient != "15550012020" || req.DeliveryID == "" {
t.Fatalf("request = %+v", req)
}
rec, found, err := f.codes.Get(f.ctx, hash)
if err != nil || !found || rec.Channel != store.PhoneCodeChannelSMS || rec.DeliveryID != req.DeliveryID || rec.Code != req.Code {
t.Fatalf("record=%+v found=%v err=%v", rec, found, err)
}
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 78, req.Recipient, hash, req.Code, 1700000000); err != nil {
t.Fatalf("ChangePhone: %v", err)
}
}
func TestPhoneChangeWebhookRejectionRevokesScopedCode(t *testing.T) {
f := newPhoneChangeFixture(t)
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
tracked := &trackingPhoneCodeStore{CodeStore: f.codes}
f.service.codes = tracked
f.service.phoneCodeSender = sender
f.service.phoneCodeLength = 5
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012021")
if hash != "" || err == nil || len(sender.requests) != 1 {
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
}
if tracked.lastHash == "" {
t.Fatal("code was not stored before delivery")
}
if rec, found, getErr := f.codes.Get(f.ctx, tracked.lastHash); getErr != nil || found || rec.Code != "" {
t.Fatalf("post-rejection code rec=%+v found=%v err=%v", rec, found, getErr)
}
}
func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002")

View file

@ -4,13 +4,14 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/mail"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -47,7 +48,9 @@ type Service struct {
phoneChangeCode string
phoneChangeCodeTTL time.Duration
phoneChangeMaxAttempts int
loginEmailSender mail.Sender
loginEmailSender otpdelivery.Sender
phoneCodeSender otpdelivery.Sender
phoneCodeLength int
loginEmailCodeTTL time.Duration
loginEmailCodeMaxAttempts int
loginEmailCodeLength int
@ -144,7 +147,7 @@ func WithPublicBaseURL(baseURL string) ServiceOption {
}
}
func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
func WithLoginEmailVerification(codes store.CodeStore, sender otpdelivery.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
return func(s *Service) {
s.codes = codes
s.loginEmailSender = sender
@ -175,6 +178,17 @@ func WithEmailSignupPhonePrefixes(prefixes []string) ServiceOption {
}
}
// WithPhoneCodeDelivery replaces the fixed development code used by the
// change-phone flow with an externally delivered SMS code.
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption {
return func(s *Service) {
s.phoneCodeSender = sender
if length > 0 {
s.phoneCodeLength = length
}
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{
@ -185,6 +199,7 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
loginEmailCodeLength: 6,
phoneChangeCodeTTL: 5 * time.Minute,
phoneChangeMaxAttempts: 5,
phoneCodeLength: 5,
}
for _, opt := range opts {
opt(s)
@ -639,18 +654,54 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
return "", 0, err
}
rec.Code = code
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", 0, err
}
rec.DeliveryID = deliveryID
expiresAt := time.Now().Add(s.loginEmailCodeTTL)
if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil {
return "", 0, err
}
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
// Set does not expose its generated revision. A blind Del here could
// remove a newer concurrent resend; leave the unreachable random code
// to expire or be replaced by the retry instead.
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
if err != nil {
return "", 0, err
}
if !found || snapshot.Record.DeliveryID != deliveryID {
return "", 0, domain.ErrEmailCodeInvalid
}
purpose := otpdelivery.PurposeLoginEmailChange
if setup {
purpose = otpdelivery.PurposeLoginEmailSetup
}
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: purpose,
Channel: otpdelivery.ChannelEmail,
Recipient: email,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
deleted, cleanupErr := s.codes.CompareAndDelete(cleanupCtx, key, snapshot.Revision)
if cleanupErr != nil {
return "", 0, fmt.Errorf("%w; rollback email code: %v", err, cleanupErr)
}
_ = deleted // false means a newer concurrent resend owns the key.
return "", 0, err
}
return emailPattern(email), len(code), nil
}
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
_, err := sender.Deliver(ctx, req)
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
return nil
}
return err
}
func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) {
if s == nil || s.codes == nil {
return "", domain.ErrEmailNotAllowed

View file

@ -1,5 +1,5 @@
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
// 第一阶段用开发固定验证码2FA 配置由 account 服务持久化查询。
//
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。
// 输入输出在 RPC 边界使用 iamxvbaba/td/tg 类型,本包内部只用 internal/domain 模型。
package auth

View file

@ -7,6 +7,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -340,7 +341,7 @@ func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) {
}
}
func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
func TestConfiguredEmailLoginMirrorsSameCodeThroughAppDelivery(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil {
@ -360,7 +361,40 @@ func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
if mailSender.to != "secure@example.test" || mailSender.code == "" {
t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code)
}
if len(delivery.requests) != 0 {
t.Fatalf("email code leaked into app delivery: %+v", delivery.requests)
if len(delivery.requests) != 1 || delivery.requests[0].Code != mailSender.code || delivery.requests[0].PhoneCodeHash == "" {
t.Fatalf("email App-code delivery=%+v, want same code and non-empty hash", delivery.requests)
}
}
func TestConfiguredEmailLoginProviderFailureKeepsDurableAppCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009215"})
if err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{user.Phone: "fallback@example.test"}}
codes := memory.NewCodeStore()
mailSender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
delivery := &captureLoginCodeDelivery{}
var observed []error
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}),
WithLoginCodeDelivery(delivery),
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
observed = append(observed, err)
}),
)
hash, err := svc.SendCode(ctx, user.Phone)
if err != nil || hash == "" {
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
}
if len(delivery.requests) != 1 || len(mailSender.requests) != 1 || len(observed) != 1 ||
delivery.requests[0].Code != mailSender.requests[0].Code {
t.Fatalf("App=%+v provider=%+v observed=%d", delivery.requests, mailSender.requests, len(observed))
}
if rec, found, getErr := codes.Get(ctx, hash); getErr != nil || !found || rec.Code != delivery.requests[0].Code {
t.Fatalf("code=%+v found=%v err=%v", rec, found, getErr)
}
}

View file

@ -4,9 +4,9 @@ import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store/memory"
)
@ -28,13 +28,13 @@ type testMailSender struct {
code string
}
func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
func (s *testMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
s.to = req.Recipient
s.code = req.Code
return otpdelivery.Result{}, nil
}
func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
func TestConfiguredEmailLoginSharesAttemptsAcrossOfficialCodeCarriers(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
@ -43,7 +43,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}}
sender := &testMailSender{}
appDelivery := &captureLoginCodeDelivery{}
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 6,
@ -59,6 +61,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
if sender.to != "alice@example.test" || len(sender.code) != 6 {
t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != sender.code {
t.Fatalf("App-code delivery=%+v, want same email code/hash", appDelivery.requests)
}
delivery, found, err := svc.CodeDelivery(ctx, hash)
if err != nil || !found {
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
@ -71,14 +76,14 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
if bad2 == bad1 {
bad2 = wrongCode(sender.code, '2')
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("first bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("first bad WebK SignIn err = %v, want ErrCodeInvalid", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad2); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("second bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
t.Fatalf("second bad native SignInWithEmail err = %v, want ErrCodeInvalid", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
t.Fatalf("SignInWithEmail after max attempts err = %v, want ErrCodeExpired", err)
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
t.Fatalf("WebK SignIn after shared max attempts err = %v, want ErrCodeExpired", err)
}
}
@ -99,35 +104,160 @@ func wrongCode(code string, digit byte) string {
return string(out)
}
func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
func TestConfiguredEmailLoginAcceptsOfficialCodeCarriers(t *testing.T) {
tests := []struct {
name string
phone string
email string
webK bool
}{
{name: "webk_phone_code", phone: "15550009102", email: "webk@example.test", webK: true},
{name: "native_email_verification", phone: "15550009103", email: "native@example.test"},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
u, err := users.Create(ctx, domain.User{Phone: tc.phone, FirstName: "Email"})
if err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{tc.phone: tc.email}}
sender := &testMailSender{}
appDelivery := &captureLoginCodeDelivery{}
var key [8]byte
key[0] = byte(0x91 + i)
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 6,
Store: emails,
Sender: sender,
}))
hash, err := svc.SendCode(ctx, tc.phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].Code != sender.code {
t.Fatalf("App-code delivery=%+v, want same email code", appDelivery.requests)
}
var got domain.User
var needSignUp bool
if tc.webK {
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("WebK development code err=%v, want ErrCodeInvalid for random email channel", err)
}
got, _, needSignUp, err = svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, sender.code)
} else {
got, _, needSignUp, err = svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, tc.phone, hash, sender.code)
}
if err != nil {
t.Fatalf("sign in: %v", err)
}
if needSignUp || got.ID != u.ID {
t.Fatalf("sign in got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
}
})
}
}
func TestConfiguredEmailLoginViaWebKStillHonorsTwoFactor(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009102", FirstName: "Email"})
passwords := memory.NewPasswordStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009104", FirstName: "Email"})
if err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}}
if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil {
t.Fatalf("save password settings: %v", err)
}
sender := &testMailSender{}
var key [8]byte
key[0] = 0x91
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithPasswords(passwords),
WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 5,
Store: emails,
CodeLength: 6,
Store: &testLoginEmailStore{emails: map[string]string{u.Phone: "2fa@example.test"}},
Sender: sender,
}))
var key [8]byte
key[0] = 0x94
hash, err := svc.SendCode(ctx, "+15550009102")
hash, err := svc.SendCode(ctx, u.Phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code)
if err != nil {
t.Fatalf("SignInWithEmail: %v", err)
got, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, u.Phone, hash, sender.code)
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
t.Fatalf("WebK email SignIn err=%v, want ErrSessionPasswordNeeded", err)
}
if needSignUp || got.ID != u.ID {
t.Fatalf("SignInWithEmail got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
if got.ID != u.ID {
t.Fatalf("WebK email SignIn user=%d, want pending 2FA user %d", got.ID, u.ID)
}
if bound, found, err := svc.UserID(ctx, key); err != nil || found || bound != 0 {
t.Fatalf("UserID after WebK email SignIn with 2FA=%d found=%v err=%v, want not-found", bound, found, err)
}
}
func TestConfiguredEmailLoginHasSingleConsumerAcrossOfficialCodeCarriers(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009105", FirstName: "Email"})
if err != nil {
t.Fatalf("create user: %v", err)
}
sender := &testMailSender{}
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 6,
Store: &testLoginEmailStore{emails: map[string]string{u.Phone: "race@example.test"}},
Sender: sender,
}))
hash, err := svc.SendCode(ctx, u.Phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
start := make(chan struct{})
results := make(chan error, 2)
var webKKey, nativeKey [8]byte
webKKey[0] = 0x95
nativeKey[0] = 0x96
go func() {
<-start
_, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: webKKey}, u.Phone, hash, sender.code)
results <- err
}()
go func() {
<-start
_, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: nativeKey}, u.Phone, hash, sender.code)
results <- err
}()
close(start)
accepted, expired := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
accepted++
case errors.Is(err, ErrCodeExpired):
expired++
default:
t.Fatalf("concurrent sign in err=%v, want nil or ErrCodeExpired", err)
}
}
if accepted != 1 || expired != 1 {
t.Fatalf("concurrent results accepted=%d expired=%d, want 1/1", accepted, expired)
}
}

View file

@ -0,0 +1,163 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type captureOTPSender struct {
requests []otpdelivery.Request
err error
before func()
}
func (s *captureOTPSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
if s.before != nil {
s.before()
}
s.requests = append(s.requests, req)
return otpdelivery.Result{ProviderMessageID: "capture-message"}, s.err
}
func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009301", FirstName: "Webhook"})
if err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
appDelivery := &captureLoginCodeDelivery{}
sender := &captureOTPSender{before: func() {
if len(appDelivery.requests) != 1 {
t.Fatalf("provider called before durable App-code: requests=%d", len(appDelivery.requests))
}
}}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "fixed-code-must-not-leak",
WithLoginCodeDelivery(appDelivery),
WithPhoneCodeDelivery(sender, 6))
hash, err := svc.SendCode(ctx, "+1 555 000 9301")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if hash == "" || len(sender.requests) != 1 {
t.Fatalf("hash=%q requests=%d", hash, len(sender.requests))
}
req := sender.requests[0]
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginSMS || req.Channel != otpdelivery.ChannelSMS ||
req.Recipient != "15550009301" || len(req.Code) != 6 || req.Code == "fixed-code-must-not-leak" || time.Until(req.ExpiresAt) < 4*time.Minute {
t.Fatalf("request = %+v", req)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != req.Code {
t.Fatalf("App-code delivery=%+v, want same hash/code as provider", appDelivery.requests)
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != req.Code || rec.DeliveryID != req.DeliveryID || rec.Channel != store.PhoneCodeChannelSMS {
t.Fatalf("stored code=%+v found=%v err=%v", rec, found, err)
}
delivery, found, err := svc.CodeDelivery(ctx, hash)
if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 {
t.Fatalf("delivery=%+v found=%v err=%v", delivery, found, err)
}
got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{3}}, req.Recipient, hash, req.Code)
if err != nil || needSignUp || got.ID != user.ID {
t.Fatalf("SignIn user=%+v needSignUp=%v err=%v", got, needSignUp, err)
}
}
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009305", FirstName: "Fallback"})
if err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
appDelivery := &captureLoginCodeDelivery{}
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
var observed []error
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithPhoneCodeDelivery(sender, 6),
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
observed = append(observed, err)
}),
)
hash, err := svc.SendCode(ctx, user.Phone)
if err != nil || hash == "" {
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
}
if len(sender.requests) != 1 || len(appDelivery.requests) != 1 || len(observed) != 1 {
t.Fatalf("provider=%d App=%d observed=%d, want 1/1/1", len(sender.requests), len(appDelivery.requests), len(observed))
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != appDelivery.requests[0].Code || rec.Code != sender.requests[0].Code {
t.Fatalf("code=%+v found=%v err=%v App=%+v provider=%+v", rec, found, err, appDelivery.requests, sender.requests)
}
}
func TestWebhookPhoneLoginExplicitRejectionRollsBackCode(t *testing.T) {
ctx := context.Background()
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 5))
hash, err := svc.SendCode(ctx, "15550009302")
if hash != "" || err == nil || len(sender.requests) != 1 || codes.lastSetHash == "" {
t.Fatalf("hash=%q err=%v requests=%d set=%q", hash, err, len(sender.requests), codes.lastSetHash)
}
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
t.Fatalf("rejected code found=%v err=%v", found, getErr)
}
}
func TestWebhookPhoneLoginUnknownOutcomeKeepsUsableCode(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
sender := &captureOTPSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("response lost")}}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 5))
hash, err := svc.SendCode(ctx, "15550009303")
if err != nil || hash == "" || len(sender.requests) != 1 {
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != sender.requests[0].Code {
t.Fatalf("unknown outcome code=%+v found=%v err=%v", rec, found, err)
}
}
func TestWebhookPhoneResendRotatesCodeAndDeliveryID(t *testing.T) {
ctx := context.Background()
sender := &captureOTPSender{}
codes := memory.NewCodeStore()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 6))
firstHash, err := svc.SendCode(ctx, "15550009304")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
secondHash, err := svc.ResendCode(ctx, "15550009304", firstHash)
if err != nil {
t.Fatalf("ResendCode: %v", err)
}
if firstHash == secondHash || len(sender.requests) != 2 ||
sender.requests[0].DeliveryID == sender.requests[1].DeliveryID {
t.Fatalf("hashes=%q/%q requests=%+v", firstHash, secondHash, sender.requests)
}
if _, found, err := codes.Get(ctx, firstHash); err != nil || found {
t.Fatalf("old code found=%v err=%v", found, err)
}
}

View file

@ -14,11 +14,11 @@ import (
"unicode/utf8"
"github.com/gotd/ige"
"github.com/gotd/td/bin"
mtcrypto "github.com/gotd/td/crypto"
"github.com/iamxvbaba/td/bin"
mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/mail"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -27,6 +27,10 @@ var (
ErrCodeExpired = errors.New("phone code expired or not found")
ErrCodeInvalid = errors.New("phone code invalid")
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
ErrExpiresAtInvalid = errors.New("temporary auth key request expiry invalid")
ErrTempAuthKeyEmpty = errors.New("temporary auth key missing or expired")
ErrTempAuthKeyAlreadyBound = errors.New("temporary auth key already bound")
ErrAuthKeyPermEmpty = errors.New("permanent auth key required")
// ErrLoginCodeDeliveryUnavailable 表示已有账号的 app-code 没有可用的
// durable message/event/outbox 投递边界。这是服务端配置错误,不能降级成
// “继续返回 sentCode等 signIn 后补发”。
@ -44,9 +48,10 @@ var (
)
const (
codeChannelPhone = "phone"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
codeChannelPhone = store.PhoneCodeChannelPhone
codeChannelSMS = store.PhoneCodeChannelSMS
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
loginCodeRollbackTimeout = 2 * time.Second
)
@ -66,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool {
return ok
}
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
type Service struct {
users store.UserStore
auths store.AuthorizationStore
@ -82,7 +89,10 @@ type Service struct {
codeTTL time.Duration
codeMaxAttempts int
loginEmails loginEmailStore
loginEmailSender mail.Sender
loginEmailSender otpdelivery.Sender
phoneCodeSender otpdelivery.Sender
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
phoneCodeLength int
loginEmailEnabled bool
loginEmailRequireSetup bool
loginEmailCodeLength int
@ -109,7 +119,7 @@ type LoginEmailOptions struct {
RequireSetup bool
CodeLength int
Store loginEmailStore
Sender mail.Sender
Sender otpdelivery.Sender
}
type authorizationRevoker interface {
@ -207,9 +217,34 @@ func WithEmailSignupPhonePrefixes(prefixes []string) Option {
}
}
// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing
// accounts keep their durable 777000 App-code and receive the same code through
// the provider as an additional channel. A nil sender preserves development
// behavior.
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option {
return func(s *Service) {
s.phoneCodeSender = sender
if length > 0 {
s.phoneCodeLength = length
}
}
}
// WithOTPDeliveryFailureObserver observes failures of an additional provider
// delivery after an existing account already has a durable 777000 App-code.
// Observers must not log the recipient or code.
func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option {
return func(s *Service) {
s.otpDeliveryFailure = observer
}
}
// NewService 创建登录服务。fixedCode 为开发固定验证码。
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5}
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
linker.LinkAuthKeyAuthority(authKeys)
}
for _, opt := range opts {
opt(s)
}
@ -219,26 +254,44 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
if s.authKeys != nil {
inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
if err != nil {
return err
}
binding.TempSessionID = inner.TempSessionID
// The bind request's expires_at is a signed client assertion. TDesktop
// intentionally adds a small grace interval, while Android derives its
// value at handshake completion. Retention and edge admission must use the
// server's p_q_inner_data_temp lifetime, never the client value.
binding.ExpiresAt = protocolExpiresAt
}
if binding.ExpiresAt <= int(time.Now().Unix()) {
// The edge may admit the frame immediately before the temporary key's
// absolute boundary and the encrypted proof may cross it. This is a temp-key
// rotation condition, never a destructive permanent-key proof failure.
return ErrTempAuthKeyEmpty
}
if s.tempKeys == nil {
return nil
}
return s.tempKeys.Save(ctx, binding)
if err := s.tempKeys.Save(ctx, binding); err != nil {
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
return ErrTempAuthKeyAlreadyBound
}
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
return s.classifyBindingStoreInvalid(ctx, binding)
}
return err
}
return nil
}
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
//
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝perm 仍授权则继续解析,
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
// (不以 perm 授权豁免但收紧前需先核实目标客户端TDesktop/DrKLO会在过期前主动
// 轮换 temp key 并优雅处理拒绝否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
// 已把残留窗口限制在 expires_at + 宽限(约 24h内。收紧为显式硬化任务需客户端验证。
// temp→perm 是握手/绑定形成的协议身份关系,与 perm 当前是否登录完全无关。即使
// auth.logOut 已删除 authorization只要绑定仍存在后续登录 RPC 也必须继续落到同一
// perm key绝不能把 raw temp key 当成新的业务身份。协议过期由 mtprotoedge 在解密/RPC
// 之前返回 -404 并关闭连接;这里不再用 authorization 状态猜测 key 类型。
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
if s == nil || s.tempKeys == nil {
return [8]byte{}, false, nil
@ -247,19 +300,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
if err != nil || !found {
return [8]byte{}, found, err
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
return [8]byte{}, false, nil
}
return permID, true, nil
}
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
if s == nil || s.auths == nil {
return false
}
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
return err == nil && found
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
}
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
@ -430,32 +471,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{
code := s.fixedCode
channel := codeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
channel = codeChannelSMS
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: existingUserID,
Phone: phone,
Code: s.fixedCode,
Channel: codeChannelPhone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
MaxAttempts: s.codeMaxAttempts,
}, s.codeTTL); err != nil {
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog只能在 SignUp 创建用户后写第一条
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
if existingUserID == 0 {
// Existing accounts always retain the original durable App-code path. Commit
// it before attempting the external mirror so a provider cannot replace the
// message fact or leave an externally disclosed code without local state.
if existingUserID != 0 {
if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.phoneCodeSender != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginSMS,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}
if existingUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request)
} else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
return hash, nil
}
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog不能在签发阶段创建 777000 消息;
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
return hash, nil
}
@ -521,25 +598,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
if err != nil {
return "", err
}
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: issuedUserID,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: codeChannelEmailLogin,
Email: strings.TrimSpace(email),
MaxAttempts: s.codeMaxAttempts,
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store email code: %w", err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
if issuedUserID != 0 {
if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.loginEmailSender == nil {
if issuedUserID != 0 {
s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}, fmt.Errorf("login email sender is not configured"))
return hash, nil
}
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
}
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}
if issuedUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request)
} else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
@ -548,6 +660,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
return hash, nil
}
// deliverOTPWithAppFallback performs an additional provider delivery only
// after the same code is durably visible through 777000. A provider failure
// must not invalidate that visible code or fail the RPC; it remains observable
// through the injected failure observer.
func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) {
if _, err := sender.Deliver(ctx, req); err != nil {
s.reportOTPDeliveryFailure(ctx, req, err)
}
}
func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) {
if s.otpDeliveryFailure != nil && err != nil {
s.otpDeliveryFailure(ctx, req, err)
}
}
// deliverOTP treats a transport-level unknown outcome as a successful issue:
// the provider may already have accepted the request, so the code must remain
// usable and the client needs the hash in order to verify or explicitly resend
// it. Only an explicit provider rejection is safe to roll back.
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
_, err := sender.Deliver(ctx, req)
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
return nil
}
return err
}
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil || !found {
@ -561,6 +701,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
}
switch rec.Channel {
case codeChannelSMS:
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
case codeChannelEmailLogin:
return domain.AuthCodeDelivery{
Kind: domain.AuthCodeDeliveryEmail,
@ -640,7 +782,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
if rec.Channel == codeChannelEmailSetupRequired {
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
}
if rec.Channel != codeChannelPhone {
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
return "", ErrCodeInvalid
}
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
@ -652,14 +794,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo
return "", err
}
rec.Code = s.fixedCode
rec.DeliveryID = ""
rec.Channel = codeChannelPhone
if s.phoneCodeSender != nil {
rec.Code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
rec.DeliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec.Channel = codeChannelSMS
}
rec.Attempts = 0
if rec.MaxAttempts <= 0 {
rec.MaxAttempts = s.codeMaxAttempts
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store resent phone change code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: rec.DeliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelSMS,
Recipient: rec.Phone,
Code: rec.Code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr))
}
return "", err
}
}
return hash, nil
}
@ -794,7 +966,7 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code, false)
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code)
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
@ -804,17 +976,17 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
return s.finishSignIn(ctx, auth, existing)
}
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
// email_verification但必须精确匹配该 phone code不能再接受任意非空值。
// 两条路径共用 owner 绑定、原子尝试计数与 2FA 门控。
// SignInWithEmail 处理带 email_verification 的 auth.signIn。它与 SignIn
// 共享同一个登录凭证状态机TDesktop/Android 把邮箱码放在
// email_verificationWebK 把同一邮箱码放在 phone_codeTL 字段只是 proof
// carrier服务端签发记录的 channel 才表示实际投递渠道。所有渠道都必须精确
// 匹配签发码,并共用 owner 绑定、原子尝试计数、一次性消费与 2FA 门控。
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
phone = normalizePhone(phone)
if systemLoginPhoneForbidden(phone) {
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
}
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code), true)
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code))
if err != nil {
return domain.User{}, domain.Message{}, false, err
}
@ -828,7 +1000,7 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
// CodeStore verification. The phone owner is read both before and after that
// linearization point. A hash issued for an unregistered number therefore can
// never authorize whichever account happens to acquire that number later.
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string, emailPath bool) (store.PhoneCode, domain.User, bool, error) {
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string) (store.PhoneCode, domain.User, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return store.PhoneCode{}, domain.User{}, false, err
@ -843,11 +1015,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod
if rec.Phone != phone || rec.Purpose != "" {
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
}
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
if emailPath {
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
}
if !channelAllowed {
if !store.LoginCodeChannelVerifiable(rec.Channel) {
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
}
@ -990,7 +1158,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
if !store.LoginCodeChannelVerifiable(rec.Channel) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
// Email-signup accounts (888-encoded phone) already proved ownership of
@ -1017,7 +1185,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, ErrCodeExpired
}
rec = consumed
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
if rec.IssuedUserID != 0 || !rec.SignUpVerified || !store.LoginCodeChannelVerifiable(rec.Channel) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
@ -1081,10 +1249,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, err
}
loginMessage := domain.Message{}
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
// the normal phone/app-code registration path creates the bootstrap dialog
// carrying the actual code; every account additionally gets the
// welcome message below regardless of channel.
// A new account has no owner/dialog at issuance time. Only the development
// phone/App registration path creates its bootstrap 777000 message here;
// external SMS, email setup, and email-signup registration retain only
// their verified fact — every account additionally gets the welcome
// message below regardless of channel.
if rec.Channel == codeChannelPhone {
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {
@ -1204,13 +1373,6 @@ func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.
return s.auths.ByAuthKey(ctx, authKeyID)
}
func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error {
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) || layer <= 0 {
return nil
}
return s.auths.UpdateLayer(ctx, authKeyID, layer)
}
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return domain.AuthKeyClientInfo{}, false, nil
@ -1220,12 +1382,13 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
return domain.AuthKeyClientInfo{}, found, err
}
info := domain.AuthKeyClientInfo{
Layer: key.Layer,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
Layer: key.Layer,
LayerObservationID: key.LayerObservationID,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
}
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
@ -1249,7 +1412,15 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
return err
}
if s.auths != nil {
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
// Layer is an ordered protocol fact. Its authorization-table mirror is
// advanced atomically by the durable Layer evidence/bind transactions.
// A generic metadata update is deliberately two-store and can race such
// a transaction, so it must never write an older Layer after the primary
// auth_keys row has already advanced.
authorizationInfo := info
authorizationInfo.Layer = 0
authorizationInfo.LayerObservationID = 0
return s.auths.UpdateClientInfo(ctx, authKeyID, authorizationInfo)
}
return nil
}
@ -1340,11 +1511,29 @@ func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64,
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
if s.authKeys != nil {
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
if err != nil {
return err
}
// Defense in depth: Router normally converts a bound temp key to its perm
// identity and edge rejects expired temp keys. Never let an unbound/sticky
// temp key create authorization even if either outer boundary regresses.
if !found || key.ExpiresAt != 0 {
return ErrAuthKeyPermEmpty
}
}
auth.UserID = userID
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
// update state再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
return s.auths.Bind(ctx, auth)
if err := s.auths.Bind(ctx, auth); err != nil {
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
return ErrAuthKeyPermEmpty
}
return err
}
return nil
}
func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error) {
@ -1418,32 +1607,60 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
})
}
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
if binding.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, err
}
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
// to a still-live binding. It may never create or reclassify a protocol key;
// the caller normalizes durable retention to this handshake-authoritative
// temp.ExpiresAt instead of trusting the client value.
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
perm, found, err := s.authKeys.Get(ctx, permID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, err
return mtcrypto.BindAuthKeyInner{}, 0, err
}
if !found {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
if !found || perm.ExpiresAt != 0 {
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
if inner.Nonce != binding.Nonce ||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
inner.TempSessionID != sessionID ||
inner.ExpiresAt != binding.ExpiresAt {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
}
return inner, nil
if temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
}
return inner, temp.ExpiresAt, nil
}
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
if s == nil || s.authKeys == nil {
return ErrEncryptedMessageInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
if err != nil {
return err
}
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
return ErrTempAuthKeyEmpty
}
return ErrEncryptedMessageInvalid
}
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {

View file

@ -8,7 +8,7 @@ import (
"testing"
"time"
mtcrypto "github.com/gotd/td/crypto"
mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -18,11 +18,12 @@ import (
func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
permKey := testAuthKey(0x11)
tempKey := testAuthKey(0x55)
expiresAt := int(time.Now().Add(time.Hour).Unix())
saveAuthKey(t, keys, permKey)
saveAuthKey(t, keys, tempKey)
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
@ -31,7 +32,6 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
sessionID = int64(0x1020304050)
msgID = int64(0x0102030405060708)
)
expiresAt := int(time.Now().Add(time.Hour).Unix())
encrypted, err := mtcrypto.EncryptBindMessage(
bytes.NewReader(bytes.Repeat([]byte{0xCD}, 128)),
permKey,
@ -69,9 +69,73 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
if !errors.Is(err, ErrEncryptedMessageInvalid) {
t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err)
}
// TDesktop intentionally adds a 30-second bind grace to the expiry it
// derived from p_q_inner_data_temp. The request is valid, but the durable
// binding must be normalized back to the server handshake expiry.
extendedExpiry := expiresAt + 30
extendedEncrypted, err := mtcrypto.EncryptBindMessage(
bytes.NewReader(bytes.Repeat([]byte{0xCE}, 128)),
permKey,
msgID+4,
&mtcrypto.BindAuthKeyInner{
Nonce: nonce,
TempAuthKeyID: tempKey.IntID(),
PermAuthKeyID: permKey.IntID(),
TempSessionID: sessionID,
ExpiresAt: extendedExpiry,
},
)
if err != nil {
t.Fatalf("encrypt extended bind message: %v", err)
}
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
Nonce: nonce,
ExpiresAt: extendedExpiry,
EncryptedMessage: extendedEncrypted,
})
if err != nil {
t.Fatalf("BindTempAuthKey TDesktop grace expiry: %v", err)
}
stored, found, getErr := tempBindings.GetByTemp(ctx, tempKey.ID)
if getErr != nil || !found || stored.ExpiresAt != expiresAt {
t.Fatalf("stored binding after extension attempt = %+v found=%v err=%v", stored, found, getErr)
}
}
func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
permKey := testAuthKey(0x31)
tempKey := testAuthKey(0x32)
saveAuthKey(t, keys, permKey)
saveAuthKeyWithExpiry(t, keys, tempKey, int(time.Now().Add(-time.Second).Unix()))
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
request := domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
}
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
}
request.TempAuthKeyID = testAuthKey(0x33).ID
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
}
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
t.Fatalf("expired request proof err = %v, want ErrExpiresAtInvalid", err)
}
}
func TestUpdateAuthKeyClientInfoConvergesMemoryAuthorizationToAuthKeyLayerAuthority(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
authz := memory.NewAuthorizationStore()
@ -80,6 +144,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
if err := authz.Bind(ctx, domain.Authorization{
AuthKeyID: key.ID,
UserID: 1780243200,
Layer: 220,
Platform: "unknown",
}); err != nil {
t.Fatalf("bind authorization: %v", err)
@ -107,6 +172,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
t.Fatalf("get authorization: found=%v err=%v", found, err)
}
if storedKey.Platform != "ios" || storedAuth.Platform != "ios" ||
storedKey.Layer != info.Layer || storedAuth.Layer != info.Layer ||
storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel ||
storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion {
t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth)
@ -115,15 +181,19 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
permKey := testAuthKey(0x11)
tempKey := testAuthKey(0x55)
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
expiresAt := int(time.Now().Add(time.Hour).Unix())
saveAuthKey(t, keys, permKey)
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -139,11 +209,15 @@ func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
authz := memory.NewAuthorizationStore()
permKey := testAuthKey(0x21)
tempKey := testAuthKey(0x65)
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, tempBindings, "12345")
expiresAt := int(time.Now().Add(-time.Minute).Unix())
saveAuthKey(t, keys, permKey)
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, tempBindings, "12345")
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: 1000000001}); err != nil {
t.Fatalf("bind authorization: %v", err)
@ -151,7 +225,7 @@ func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -165,17 +239,21 @@ func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T
}
}
func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *testing.T) {
func TestResolveAuthKeyKeepsExpiredBindingCanonicalWithoutAuthorization(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
permKey := testAuthKey(0x31)
tempKey := testAuthKey(0x75)
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
expiresAt := int(time.Now().Add(-time.Minute).Unix())
saveAuthKey(t, keys, permKey)
saveAuthKeyWithExpiry(t, keys, tempKey, expiresAt)
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -184,8 +262,87 @@ func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *test
if err != nil {
t.Fatalf("ResolveAuthKey: %v", err)
}
if ok || got != ([8]byte{}) {
t.Fatalf("resolved = %x ok=%v, want expired unresolved", got, ok)
if !ok || got != permKey.ID {
t.Fatalf("resolved = %x ok=%v, want canonical perm %x even while logged out", got, ok, permKey.ID)
}
}
func TestExpiredTempLogoutReloginNeverAuthorizesRawTempKey(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
tempBindings := memory.NewTempAuthKeyBindingStore(keys)
permKey := testAuthKey(0x41)
tempKey := testAuthKey(0x81)
expiresAt := int(time.Now().Add(-time.Minute).Unix())
if err := keys.Save(ctx, store.AuthKeyData{ID: permKey.ID}); err != nil {
t.Fatalf("save perm key: %v", err)
}
if err := keys.Save(ctx, store.AuthKeyData{
ID: tempKey.ID, ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp key: %v", err)
}
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "15550008101", FirstName: "Bob"})
if err != nil {
t.Fatalf("create Bob: %v", err)
}
alice, err := users.Create(ctx, domain.User{Phone: "15550008102", FirstName: "Alice"})
if err != nil {
t.Fatalf("create Alice: %v", err)
}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: bob.ID}); err != nil {
t.Fatalf("authorize Bob: %v", err)
}
svc := NewService(users, authz, memory.NewCodeStore(), keys, tempBindings, "12345")
if err := svc.LogOut(ctx, permKey.ID); err != nil {
t.Fatalf("logout Bob: %v", err)
}
resolved, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
if err != nil || !ok || resolved != permKey.ID {
t.Fatalf("resolve after logout = %x/%v/%v, want perm", resolved, ok, err)
}
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: resolved}, alice.ID); err != nil {
t.Fatalf("relogin Alice on canonical perm: %v", err)
}
if a, found, err := authz.ByAuthKey(ctx, permKey.ID); err != nil || !found || a.UserID != alice.ID {
t.Fatalf("perm authorization = %+v found=%v err=%v, want Alice", a, found, err)
}
if a, found, err := authz.ByAuthKey(ctx, tempKey.ID); err != nil || found {
t.Fatalf("temp authorization = %+v found=%v err=%v, want absent", a, found, err)
}
}
func TestAuthorizationBindRejectsTemporaryProtocolKey(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
tempKey := testAuthKey(0x82)
if err := keys.Save(ctx, store.AuthKeyData{
ID: tempKey.ID, ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
}); err != nil {
t.Fatalf("save temp key: %v", err)
}
u, err := users.Create(ctx, domain.User{Phone: "15550008201", FirstName: "Alice"})
if err != nil {
t.Fatalf("create user: %v", err)
}
svc := NewService(users, authz, memory.NewCodeStore(), keys, memory.NewTempAuthKeyBindingStore(keys), "12345")
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: tempKey.ID}, u.ID); !errors.Is(err, ErrAuthKeyPermEmpty) {
t.Fatalf("bind temp authorization err = %v, want ErrAuthKeyPermEmpty", err)
}
if _, found, err := authz.ByAuthKey(ctx, tempKey.ID); err != nil || found {
t.Fatalf("temp authorization found=%v err=%v, want absent", found, err)
}
}
@ -668,10 +825,14 @@ func testAuthKey(seed byte) mtcrypto.AuthKey {
}
func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) {
saveAuthKeyWithExpiry(t, keys, key, 0)
}
func saveAuthKeyWithExpiry(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey, expiresAt int) {
t.Helper()
var value [256]byte
copy(value[:], key.Value[:])
if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value}); err != nil {
if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value, ExpiresAt: expiresAt}); err != nil {
t.Fatalf("save auth key: %v", err)
}
}

View file

@ -342,6 +342,12 @@ func TestEmailSetupVerificationAuthorizesSignUpWithWelcomeMessageOnlyNoCodeEcho(
if _, _, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Email"); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("SignUp before email setup err=%v, want ErrCodeInvalid", err)
}
if _, _, _, err := authSvc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("WebK SignIn with setup-required placeholder err=%v, want ErrCodeInvalid", err)
}
if _, _, _, err := authSvc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("native SignInWithEmail with setup-required placeholder err=%v, want ErrCodeInvalid", err)
}
if _, _, err := accountSvc.SendLoginEmailCode(ctx, 0, phone, hash, "new@example.test", true); err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}

View file

@ -25,8 +25,8 @@ func TestServiceSendMessageHonorsSendPermissionGate(t *testing.T) {
ChannelID: 2001,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMessage err=%v, want ErrUserFrozen", err)
}
}
@ -79,8 +79,8 @@ func TestServiceSendMonoforumMessageHonorsSendPermissionGate(t *testing.T) {
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserFrozen", err)
}
}
@ -133,7 +133,7 @@ func TestServiceMonoforumReplayPrecedesCurrentSendPermissionGate(t *testing.T) {
type channelDenySendChecker struct{}
func (channelDenySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {

View file

@ -10,7 +10,7 @@ import (
"image"
"image/color"
stddraw "image/draw"
_ "image/jpeg" // 注册 jpeg DecodeConfig用于读取上传头像/图片尺寸
"image/jpeg"
"image/png"
"io"
"math"
@ -24,8 +24,8 @@ import (
_ "golang.org/x/image/webp" // 注册 webp Decode用于 custom emoji / sticker 静态缩略图合成
)
// 头像与图片消息共用的尺寸 type'a' 小图≤160'c' 大图,'x' 通用下载尺寸。
// 同一份上传字节在多个 location_key 下建 blob不做实际缩放dev 主路径足够)
// 头像使用真实的 's'(≤150)/'a'(≤160)/'c'(原图) rendition图片消息使用
// 'm' 缩略与 'x' 大图。每个头像 location_key 的元数据尺寸必须与实际 blob 一致
// UploadProfilePhoto 把已上传文件组装成头像 Photo落 blob/photos/profile_photos并设为当前头像。
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
@ -103,8 +103,8 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
return s.media.GetDocument(ctx, id)
}
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo'a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation
// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo's'/'a'/'c' 尺寸,'a'/'c' 匹配
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
@ -113,7 +113,7 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
return s.createAvatarPhoto(ctx, data)
}
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
@ -151,7 +151,7 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
}
s.blobCache.put(blob.LocationKey, blob)
stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
@ -192,7 +192,7 @@ func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize)
}
photoID := randomID()
stillBytes := s.generatedAvatarStill(ctx, size)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil {
return domain.Photo{}, err
}
@ -605,6 +605,26 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
return photo, nil
}
func (s *Service) createAvatarPhoto(ctx context.Context, data []byte) (domain.Photo, error) {
photoID := randomID()
sizes, err := s.putAvatarStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
if err != nil {
return domain.Photo{}, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
@ -630,6 +650,87 @@ func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data [
return sizes, nil
}
// putAvatarStaticSizes stores independently rendered avatar sizes. DrKLO uses the
// photos.uploadProfilePhoto response's closest 150px size as an immediate local
// location, while UserProfilePhoto updates synthesize the canonical 'a' location.
// Keeping a real 's' rendition therefore gives those two states distinct keys and,
// more importantly, keeps every advertised size backed by matching image bytes.
func (s *Service) putAvatarStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil || src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 {
return nil, domain.ErrPhotoInvalid
}
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
rendition, err := avatarRendition(data, src, format, spec)
if err != nil {
return nil, err
}
objectKey, err := s.blobs.Put(ctx, rendition)
if err != nil {
return nil, err
}
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(rendition)),
MimeType: imageMimeType(rendition),
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(objectKey, rendition)
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindDefault,
Type: spec.Type,
W: spec.W,
H: spec.H,
Size: len(rendition),
})
}
return sizes, nil
}
func avatarRendition(original []byte, src image.Image, format string, spec photoSizeSpec) ([]byte, error) {
bounds := src.Bounds()
if bounds.Dx() == spec.W && bounds.Dy() == spec.H {
return append([]byte(nil), original...), nil
}
if spec.W <= 0 || spec.H <= 0 {
return nil, domain.ErrPhotoInvalid
}
dst := image.NewRGBA(image.Rect(0, 0, spec.W, spec.H))
srcRect := centerCropRect(bounds, spec.W, spec.H)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcRect, xdraw.Src, nil)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 90}); err != nil {
return nil, err
}
} else if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func centerCropRect(bounds image.Rectangle, targetW, targetH int) image.Rectangle {
sourceW, sourceH := bounds.Dx(), bounds.Dy()
if int64(sourceW)*int64(targetH) > int64(sourceH)*int64(targetW) {
cropW := maxInt(1, sourceH*targetW/targetH)
x := bounds.Min.X + (sourceW-cropW)/2
return image.Rect(x, bounds.Min.Y, x+cropW, bounds.Max.Y)
}
if int64(sourceW)*int64(targetH) < int64(sourceH)*int64(targetW) {
cropH := maxInt(1, sourceW*targetH/targetW)
y := bounds.Min.Y + (sourceH-cropH)/2
return image.Rect(bounds.Min.X, y, bounds.Max.X, y+cropH)
}
return bounds
}
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
if len(thumbData) == 0 {
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
@ -723,12 +824,15 @@ type photoSizeSpec struct {
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
w, h := imageDimensions(data, 640, 640)
small := 160
if w < small {
small = w
shortSide := w
if h < shortSide {
shortSide = h
}
sSize := minInt(shortSide, 150)
aSize := minInt(shortSide, 160)
return []photoSizeSpec{
{Type: "a", W: small, H: small},
{Type: "s", W: sSize, H: sSize},
{Type: "a", W: aSize, H: aSize},
{Type: "c", W: w, H: h},
}
}
@ -767,10 +871,14 @@ const (
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
)
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像
// emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色
// emoji、圆角、布局都一致抽帧不可用时回退到按 markup 服务端合成
// avatarVideoStill 生成动画头像的静态尺寸字节。emoji/sticker markup 能解析到
// 服务端缩略图时优先合成DrKLO 生成的 MP4 第一帧可能只有背景渐变,直接抽第一帧
// 会让静态头像永久缺少 emoji。普通视频或 markup 资源不可用时才回退 ffmpeg 首帧
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
markup := avatarStillMarkup(extraSizes)
if still, ok := s.generatedAvatarMarkupStill(ctx, markup); ok {
return still
}
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err == nil && int64(len(data)) == total && total == body.Size {
@ -789,17 +897,30 @@ func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob
zap.Error(err))
}
}
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes))
return s.generatedAvatarStill(ctx, markup)
}
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
img := generatedAvatarBackground(markup.BackgroundColors)
if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok {
drawAvatarMarkup(img, overlay, tintWhite)
if still, ok := s.generatedAvatarMarkupStillOnBackground(ctx, markup, img); ok {
return still
}
return encodeAvatarPNG(img)
}
func (s *Service) generatedAvatarMarkupStill(ctx context.Context, markup domain.PhotoSize) ([]byte, bool) {
return s.generatedAvatarMarkupStillOnBackground(ctx, markup, generatedAvatarBackground(markup.BackgroundColors))
}
func (s *Service) generatedAvatarMarkupStillOnBackground(ctx context.Context, markup domain.PhotoSize, img *image.RGBA) ([]byte, bool) {
overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup)
if !ok {
return nil, false
}
drawAvatarMarkup(img, overlay, tintWhite)
return encodeAvatarPNG(img), true
}
func generatedAvatarBackground(colors []int) *image.RGBA {
if len(colors) == 0 {
colors = []int{0x5b8def, 0x53c6a4}
@ -865,6 +986,11 @@ func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSi
zap.Error(err))
return nil, false, false
}
// Seed 的 1x1 透明图只是“没有可用静态资源”的显式占位,不是可合成
// 内容。把它视为 unavailable交给调用方回退到 ffmpeg 视频首帧。
if img.Bounds().Dx() <= 1 || img.Bounds().Dy() <= 1 {
return nil, false, false
}
return img, documentIsTextColorEmoji(doc), true
}

View file

@ -223,6 +223,57 @@ func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) {
}
}
func TestCreateAvatarFromUploadStoresRealSizedRenditions(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
data := testJPEG(t, 640, 480)
if _, err := svc.SaveFilePart(ctx, 10, 301, 0, data); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarFromUpload(ctx, domain.UploadedFileRef{
OwnerUserID: 10,
FileID: 301,
Parts: 1,
Name: "avatar.jpg",
})
if err != nil {
t.Fatalf("CreateAvatarFromUpload: %v", err)
}
wants := map[string]image.Point{
"s": {X: 150, Y: 150},
"a": {X: 160, Y: 160},
"c": {X: 640, Y: 480},
}
if len(photo.Sizes) != len(wants) {
t.Fatalf("avatar sizes = %+v, want s/a/c", photo.Sizes)
}
objectKeys := map[string]struct{}{}
for _, size := range photo.Sizes {
want, ok := wants[size.Type]
if !ok || size.W != want.X || size.H != want.Y {
t.Fatalf("avatar size = %+v, want one of %v", size, wants)
}
assertAvatarImageSize(t, svc, photo.ID, size.Type, want.X, want.Y, "image/jpeg")
blob, found, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:%s", photo.ID, size.Type))
if err != nil || !found {
t.Fatalf("avatar %s blob found=%v err=%v", size.Type, found, err)
}
if blob.Size != int64(size.Size) {
t.Fatalf("avatar %s blob size=%d metadata size=%d", size.Type, blob.Size, size.Size)
}
objectKeys[blob.ObjectKey] = struct{}{}
}
if len(objectKeys) != 3 {
t.Fatalf("avatar object keys = %v, want distinct s/a/c renditions", objectKeys)
}
}
func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
@ -299,8 +350,9 @@ func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
if !domain.PhotoHasVideo(photo.Sizes) {
t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
}
// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为:
@ -433,8 +485,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:u", photo.ID),
Offset: 0,
@ -448,9 +501,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
}
}
// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取
// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still
func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
// TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame 守护 markup document/thumb
// 不可用时仍可从上传视频抽帧,不能让普通动画头像失去静态尺寸
func TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
@ -479,7 +532,7 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
}
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:a", photo.ID),
LocationKey: fmt.Sprintf("photo:%d:c", photo.ID),
Offset: 0,
Limit: 1 << 20,
})
@ -492,9 +545,112 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
if chunk.MimeType != "image/jpeg" {
t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType)
}
assertAvatarImageSize(t, svc, photo.ID, "s", 150, 150, "image/jpeg")
assertAvatarImageSize(t, svc, photo.ID, "a", 160, 160, "image/jpeg")
}
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) {
func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(78)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1,
Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
frame := testJPEG(t, 640, 640)
thumbnailer := &fakeVideoThumbnailer{thumb: frame}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 503, 0, []byte("profile-video-without-server-preview")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 503, Parts: 1, Name: "avatar.mp4"},
0,
domain.PhotoSize{Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: emojiID, BackgroundColors: []int{0x112233}})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 1 {
t.Fatalf("thumbnailer calls = %d, want synthetic preview rejected and video fallback used", thumbnailer.calls)
}
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("photo:%d:c", photo.ID), Limit: 1 << 20})
if err != nil || !found {
t.Fatalf("avatar c blob found=%v err=%v", found, err)
}
if !bytes.Equal(chunk.Bytes, frame) {
t.Fatal("avatar still did not use extracted video frame after rejecting synthetic preview")
}
}
// TestCreateAvatarVideoMarkupPrefersComposedStill 守护 DrKLO emoji 构造器边界:
// 客户端生成 MP4 的第一帧可能只有渐变背景;只要 markup thumb 可解析,静态头像
// 必须用服务端合成结果,确保 emoji 在当前 session 回显和冷启动中都可见。
func TestCreateAvatarVideoMarkupPrefersComposedStill(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(501)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached,
Type: "m",
W: 64,
H: 64,
Bytes: testTransparentThumbPNG(t),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
thumbnailer := &fakeVideoThumbnailer{thumb: testJPEG(t, 320, 320)}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 502, 0, []byte("background-only-profile-video")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 502, Parts: 1, Name: "avatar.mp4"},
0,
domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: emojiID,
BackgroundColors: []int{0x112233, 0x445566},
})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 0 {
t.Fatalf("thumbnailer calls = %d, want 0 when markup still is available", thumbnailer.calls)
}
r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID)
if r < 200 || g > 90 || b > 90 {
t.Fatalf("composed center pixel rgb=(%d,%d,%d), want visible red emoji overlay", r, g, b)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
}
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string, side int) {
t.Helper()
assertAvatarImageSize(t, svc, photoID, sizeType, side, side, "image/png")
}
func assertAvatarImageSize(t *testing.T, svc *Service, photoID int64, sizeType string, wantW, wantH int, wantMime string) {
t.Helper()
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType),
@ -504,8 +660,15 @@ func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, siz
if err != nil || !found {
t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err)
}
if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" {
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes))
if len(chunk.Bytes) == 0 || chunk.MimeType != wantMime {
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want %s bytes", sizeType, chunk.MimeType, len(chunk.Bytes), wantMime)
}
img, _, err := image.Decode(bytes.NewReader(chunk.Bytes))
if err != nil {
t.Fatalf("decode avatar %s: %v", sizeType, err)
}
if gotW, gotH := img.Bounds().Dx(), img.Bounds().Dy(); gotW != wantW || gotH != wantH {
t.Fatalf("avatar %s pixels=%dx%d, want %dx%d", sizeType, gotW, gotH, wantW, wantH)
}
}

View file

@ -81,6 +81,7 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
// 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。
// 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。
forceSticker := false
previewState := fmt.Sprintf("%s:dc=%d", seedStickerPreviewStateVersion, s.dc)
if n, err := s.media.CountStickerSets(ctx); err != nil {
return stats, err
} else if n > 0 {
@ -88,11 +89,18 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
if err != nil {
return stats, err
}
forceSticker = stale
migrated, err := s.seedStateMatches(ctx, seedStickerPreviewStateKey, previewState)
if err != nil {
return stats, err
}
forceSticker = stale || !migrated
}
if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil {
return stats, fmt.Errorf("seed sticker sets: %w", err)
}
if err := s.putSeedState(ctx, seedStickerPreviewStateKey, previewState); err != nil {
return stats, fmt.Errorf("record sticker preview seed state: %w", err)
}
s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats)
phaseStarted = time.Now()
@ -379,6 +387,10 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
if dj.ID == 0 {
return domain.Document{}, nil
}
existing, existingFound, err := s.media.GetDocument(ctx, dj.ID)
if err != nil {
return domain.Document{}, err
}
ref, _ := hex.DecodeString(dj.FileReference)
doc := domain.Document{
ID: dj.ID,
@ -436,27 +448,40 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
if err != nil {
return domain.Document{}, err
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, err
}
ps.Size = len(data)
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: seedThumbMimeType(data),
}); err != nil {
return domain.Document{}, err
}
ps = seedInlineCachedDocumentThumb(ps, data)
s.prewarmSmallBlob(objectKey, data)
stats.Blobs++
// A duplicate document can be present in several catalogs. Do not replace a
// better already-persisted preview (and its shared location key) with a lower
// quality rendition from the catalog imported later.
if prior, ok := seedDocumentThumbByType(existing.Thumbs, ps.Type); existingFound && ok && seedPhotoSizeBetter(prior, ps) {
ps = prior
} else {
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return domain.Document{}, err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: seedThumbMimeType(data),
}); err != nil {
return domain.Document{}, err
}
s.prewarmSmallBlob(objectKey, data)
stats.Blobs++
}
}
thumbs = append(thumbs, ps)
}
doc.Thumbs = thumbs
if existingFound {
doc.Thumbs = mergeSeedDocumentThumbs(existing.Thumbs, doc.Thumbs)
}
if err := s.ensureSeedCachedThumbBlobs(ctx, doc, stats); err != nil {
return domain.Document{}, err
}
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
return domain.Document{}, err
@ -700,6 +725,138 @@ func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.Phot
return ps
}
// mergeSeedDocumentThumbs makes duplicate seed imports monotonic for preview quality.
// PhotoSize.Type is also the blob location suffix, so only one winner per type may be
// advertised. Incoming metadata wins ties; a richer existing preview wins downgrades.
func mergeSeedDocumentThumbs(existing, incoming []domain.PhotoSize) []domain.PhotoSize {
out := append([]domain.PhotoSize(nil), incoming...)
byType := make(map[string]int, len(out))
for i, thumb := range out {
if thumb.Type != "" {
byType[thumb.Type] = i
}
}
for _, thumb := range existing {
if thumb.Type != "" {
if i, ok := byType[thumb.Type]; ok {
if seedPhotoSizeBetter(thumb, out[i]) {
out[i] = thumb
}
continue
}
byType[thumb.Type] = len(out)
}
out = append(out, thumb)
}
hasRealPreview := false
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) && seedPhotoSizePreviewTier(thumb) > 1 {
hasRealPreview = true
break
}
}
if !hasRealPreview {
return out
}
filtered := out[:0]
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) {
filtered = append(filtered, thumb)
}
}
return filtered
}
func seedDocumentThumbByType(thumbs []domain.PhotoSize, typ string) (domain.PhotoSize, bool) {
for _, thumb := range thumbs {
if thumb.Type == typ {
return thumb, true
}
}
return domain.PhotoSize{}, false
}
func seedPhotoSizeBetter(a, b domain.PhotoSize) bool {
aTier, bTier := seedPhotoSizePreviewTier(a), seedPhotoSizePreviewTier(b)
if aTier != bTier {
return aTier > bTier
}
aArea, bArea := int64(a.W)*int64(a.H), int64(b.W)*int64(b.H)
if aArea != bArea {
return aArea > bArea
}
aPayload, bPayload := len(a.Bytes)+a.Size, len(b.Bytes)+b.Size
return aPayload > bPayload
}
func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
if seedSyntheticTGStickerPreviewThumb(thumb) {
return 0
}
switch thumb.Kind {
case domain.PhotoSizeKindCached:
if len(thumb.Bytes) > 0 && thumb.W > 0 && thumb.H > 0 {
return 4
}
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
if thumb.Size > 0 && thumb.W > 0 && thumb.H > 0 {
return 4
}
case domain.PhotoSizeKindPath, domain.PhotoSizeKindStripped:
if len(thumb.Bytes) > 0 {
return 3
}
}
return 1
}
func seedSyntheticTGStickerPreviewThumb(thumb domain.PhotoSize) bool {
return thumb.Kind == domain.PhotoSizeKindCached &&
thumb.Type == seedSyntheticDocumentThumbType &&
thumb.W == 1 && thumb.H == 1 &&
bytes.Equal(thumb.Bytes, seedSyntheticTGStickerPreviewThumbPNG)
}
// ensureSeedCachedThumbBlobs keeps the RPC conversion invariant: document cached
// previews are exposed as downloadable PhotoSize entries, so every advertised type
// must have a matching blob even when the source JSON carried the bytes inline.
func (s *Service) ensureSeedCachedThumbBlobs(ctx context.Context, doc domain.Document, stats *SeedStats) error {
for _, thumb := range doc.Thumbs {
if thumb.Kind != domain.PhotoSizeKindCached || thumb.Type == "" || len(thumb.Bytes) == 0 {
continue
}
locationKey := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
mimeType := seedThumbMimeType(thumb.Bytes)
stored, found, err := s.media.GetFileBlob(ctx, locationKey)
if err != nil {
return err
}
if found && stored.Size == int64(len(thumb.Bytes)) && stored.MimeType == mimeType {
continue
}
if s.blobs == nil {
return fmt.Errorf("blob backend not configured for cached document thumb %s", locationKey)
}
objectKey, err := s.blobs.Put(ctx, thumb.Bytes)
if err != nil {
return err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: locationKey,
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(thumb.Bytes)),
MimeType: mimeType,
}); err != nil {
return err
}
s.prewarmSmallBlob(objectKey, thumb.Bytes)
stats.Blobs++
}
return nil
}
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
return nil
@ -826,11 +983,12 @@ func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (boo
if err != nil {
return false, err
}
if ok {
want := seedThumbMimeType(thumb.Bytes)
if want != "application/octet-stream" && blob.MimeType != want {
return true, nil
}
if !ok {
return true, nil
}
want := seedThumbMimeType(thumb.Bytes)
if blob.Size != int64(len(thumb.Bytes)) || (want != "application/octet-stream" && blob.MimeType != want) {
return true, nil
}
}
}

View file

@ -10,13 +10,20 @@ import (
"os"
"path/filepath"
"sort"
"telesrv/internal/domain"
)
const (
seedEffectsStateKey = "files.effects"
seedEffectsStateVersion = "effects-v2"
seedAppearanceStateKey = "files.appearance"
seedAppearanceStateVersion = "appearance-v1"
seedStickerPreviewStateKey = "files.sticker_previews"
// v2 explicitly rebuilds sticker documents written before duplicate seed imports
// became monotonic. Those databases may contain an effects-generated transparent
// 1x1 preview where the sticker catalog has a real static thumbnail.
seedStickerPreviewStateVersion = "sticker-previews-v2-monotonic"
seedAppearanceStateKey = "files.appearance"
seedAppearanceStateVersion = "appearance-v1"
)
func (s *Service) seedStateMatches(ctx context.Context, key, want string) (bool, error) {
@ -88,16 +95,17 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
}
for _, tj := range dj.Thumbs {
ps, downloadable := seedPhotoSize(tj)
if !downloadable || ps.Type == "" {
if ps.Type == "" {
continue
}
if _, ok := index.thumb[dj.ID][ps.Type]; ok {
if downloadable {
if _, ok := index.thumb[dj.ID][ps.Type]; ok {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
}
} else if ps.Kind == domain.PhotoSizeKindCached && len(ps.Bytes) > 0 && ps.W > 0 && ps.H > 0 {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
}
}
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, seedSyntheticDocumentThumbType))
}
return keys
}
@ -144,6 +152,28 @@ func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumen
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
return false, nil
}
// A catalog without its own thumbnail may share this document with a richer
// catalog. Readiness follows the preview that is actually stored instead of
// demanding the synthetic "m" key and repeatedly downgrading that richer
// document on every import.
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
if len(doc.Thumbs) == 0 {
return false, nil
}
for _, thumb := range doc.Thumbs {
switch thumb.Kind {
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive, domain.PhotoSizeKindCached:
if thumb.Type == "" {
return false, nil
}
key := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
if _, seen := seenLocationKeys[key]; !seen {
seenLocationKeys[key] = struct{}{}
locationKeys = append(locationKeys, key)
}
}
}
}
delete(expected, doc.ID)
}
if len(expected) > 0 {

View file

@ -1,6 +1,7 @@
package files
import (
"bytes"
"context"
"fmt"
"os"
@ -463,8 +464,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
if err != nil {
t.Fatalf("repair seed: %v", err)
}
if stats.Reactions != 1 || stats.Blobs != 3 || stats.Skipped {
t.Fatalf("repair stats = %+v, want repair import", stats)
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
}
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
t.Fatal("missing reaction blob was not repaired")
@ -609,8 +610,113 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
if err != nil {
t.Fatalf("repair seed: %v", err)
}
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 2 {
t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired)
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 {
t.Fatalf("repair stats = %+v, want missing main blob repaired without rewriting intact preview", repaired)
}
}
func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 7777777
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 29)
writeEffectsSeed(t, seedDir, sourceID)
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
first, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("first seed: %v", err)
}
if first.StickerSets != 1 || first.Effects != 1 {
t.Fatalf("first stats = %+v, want shared sticker and effect catalogs", first)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("shared document ok=%v err=%v", ok, err)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok {
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
}
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
if err != nil || !ok {
t.Fatalf("shared preview blob ok=%v err=%v", ok, err)
}
if blob.MimeType != "image/jpeg" || blob.Size != int64(len(realThumb)) {
t.Fatalf("shared preview blob = %+v, want real JPEG metadata", blob)
}
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("doc:%d:m", sourceID), Limit: 1024})
if err != nil || !ok {
t.Fatalf("get shared preview ok=%v err=%v", ok, err)
}
if !bytes.Equal(chunk.Bytes, realThumb) {
t.Fatalf("downloaded shared preview = %x, want %x", chunk.Bytes, realThumb)
}
second, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("second seed: %v", err)
}
if second.Documents != 0 || second.Blobs != 0 {
t.Fatalf("second stats = %+v, want shared rich preview to satisfy effects readiness", second)
}
}
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 8888888
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
media := newFakeMediaStore()
if err := media.PutDocument(ctx, domain.Document{
ID: sourceID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
}},
}); err != nil {
t.Fatalf("put stale document: %v", err)
}
if err := media.PutStickerSet(ctx, domain.StickerSet{
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
}); err != nil {
t.Fatalf("put stale sticker set: %v", err)
}
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
stats, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("migration seed: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 {
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("migrated document ok=%v err=%v", ok, err)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
}
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
}
}
@ -735,6 +841,28 @@ func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int6
}
}
func writeStatusPackWithThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) []byte {
t.Helper()
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
t.Fatal(err)
}
realThumb := []byte{0xff, 0xd8, 0xff, 0xdb, 0, 4, 0xff, 0xd9}
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[{"_":"PhotoPathSize","type":"j","bytes":"01"},{"_":"PhotoSize","type":"m","w":128,"h":128,"size":%d}]}]}}`, setHash, sourceID, sourceID, sourceID, len(realThumb))
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
t.Fatal(err)
}
thumbName := fmt.Sprintf("status_%d_thumb1_PhotoSize_typem_128x128.jpg", sourceID)
if err := os.WriteFile(filepath.Join(stickersDir, thumbName), realThumb, 0o644); err != nil {
t.Fatal(err)
}
return realThumb
}
func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) {
t.Helper()
docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents")
@ -992,7 +1120,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
if err := media.PutDocument(ctx, doc); err != nil {
t.Fatalf("put doc: %v", err)
}
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/jpeg"}); err != nil {
t.Fatalf("put blob: %v", err)
}
svc := NewService(media, nil, 2)
@ -1004,7 +1132,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
t.Fatal("expected stale mime to require repair")
}
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/webp"}); err != nil {
t.Fatalf("put repaired blob: %v", err)
}
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})

View file

@ -1,97 +0,0 @@
package files
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与
// blob不复制字节镜像 EnsureDefaultEmojiStatusSet。目录是静态的不入库。
// 礼物 ID 取明显隔离的常量段避免撞键。
const starGiftIDBase int64 = 8_888_000_000_000_000
type starGiftSeed struct {
id int64
emoticon string
stars int64
title string
}
// starGiftSeeds 是固定礼物目录emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。
// convert_stars = starsv1 全额转换,视作用新购 Stars 买入)。
var starGiftSeeds = []starGiftSeed{
{starGiftIDBase + 1, "❤", 15, "Heart"},
{starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂
{starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉
{starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥
{starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆
{starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎
{starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀
}
// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。
// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。
func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) {
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
if err != nil {
return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err)
}
if !found || len(source.Packs) == 0 {
return nil, nil
}
byEmoticon := make(map[string]int64, len(source.Packs))
for _, pack := range source.Packs {
key := normalizeStatusEmoticon(pack.Emoticon)
if key == "" || len(pack.DocumentIDs) == 0 {
continue
}
if _, ok := byEmoticon[key]; !ok {
byEmoticon[key] = pack.DocumentIDs[0]
}
}
// 收集要加载的文档 id去重
docIDs := make([]int64, 0, len(starGiftSeeds))
chosen := make([]starGiftSeed, 0, len(starGiftSeeds))
seen := make(map[int64]struct{})
for _, seed := range starGiftSeeds {
id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
if !ok || id == 0 {
continue
}
chosen = append(chosen, seed)
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
docIDs = append(docIDs, id)
}
}
if len(chosen) == 0 {
return nil, nil
}
docs, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
return nil, fmt.Errorf("load star gift sticker documents: %w", err)
}
docByID := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
docByID[d.ID] = d
}
catalog := make([]domain.StarGift, 0, len(chosen))
for _, seed := range chosen {
id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
doc, ok := docByID[id]
if !ok || doc.ID == 0 {
continue
}
catalog = append(catalog, domain.StarGift{
ID: seed.id,
Stars: seed.stars,
ConvertStars: seed.stars,
Title: seed.title,
Sticker: doc,
})
}
return catalog, nil
}

View file

@ -3,7 +3,9 @@ package help
import (
"context"
"encoding/json"
"fmt"
"hash/crc32"
"strconv"
"strings"
"sync"
@ -67,6 +69,7 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
type Service struct {
appConfigs store.AppConfigStore
countries store.CountryStore
accountFreeze AccountFreezeProvider
mapboxToken string
emailSignupEnable bool
emailSignupPhonePrefixes []string
@ -80,6 +83,18 @@ type Service struct {
// Option 配置 help 服务运行期默认目录。
type Option func(*Service)
// AccountFreezeProvider supplies account-specific read-only state without
// exposing protocol types to the help application service.
type AccountFreezeProvider interface {
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
}
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
return func(s *Service) {
s.accountFreeze = provider
}
}
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
func WithMapboxToken(token string) Option {
return func(s *Service) {
@ -156,12 +171,69 @@ func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSi
return h + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
}
// GetAppConfig 返回 TDesktop app confighash 命中时返回 notModified。首次调用加载一次后缓存。
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
// GetAppConfig returns the cached global app config plus an authenticated,
// per-account freeze overlay. The overlay owns its own deterministic hash so a
// FROZEN_METHOD_INVALID-triggered refresh can never be answered notModified.
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
cfg := s.loadAppConfig(ctx)
var err error
cfg, err = s.accountAppConfig(ctx, userID, cfg)
if err != nil {
return domain.AppConfig{}, false, err
}
return cfg, hash != 0 && hash == cfg.Hash, nil
}
func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domain.AppConfig) (domain.AppConfig, error) {
values := make(map[string]json.RawMessage)
if err := json.Unmarshal(base.JSON, &values); err != nil {
return domain.AppConfig{}, fmt.Errorf("decode base app config: %w", err)
}
changed := false
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
if userID > 0 {
// DrKLO applies only keys present in the new JSON object and retains old
// SharedPreferences values for missing keys. Authenticated non-frozen
// accounts therefore need an explicit zero/empty triplet to converge after
// an unfreeze; merely omitting the overlay works in TDesktop but leaves
// Android frozen indefinitely. Unauthenticated config remains unscoped.
values["freeze_since_date"] = json.RawMessage("0")
values["freeze_until_date"] = json.RawMessage("0")
values["freeze_appeal_url"] = json.RawMessage(`""`)
changed = true
if s != nil && s.accountFreeze != nil {
freeze, found, err := s.accountFreeze.AccountFreeze(ctx, userID)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("load account freeze: %w", err)
}
if found && freeze.Frozen {
values["freeze_since_date"] = json.RawMessage(strconv.FormatInt(freeze.Since.Unix(), 10))
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
appeal, _ := json.Marshal(freeze.AppealURL)
values["freeze_appeal_url"] = appeal
}
}
}
if !changed {
return base, nil
}
body, err := json.Marshal(values)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("encode account app config: %w", err)
}
hashInput := append([]byte(strconv.Itoa(base.Hash)+"\x00"), body...)
overlayHash := int(crc32.ChecksumIEEE(hashInput) & 0x7fffffff)
if overlayHash == 0 || overlayHash == base.Hash {
overlayHash = base.Hash + 1
}
return domain.AppConfig{Client: base.Client, Hash: overlayHash, JSON: body}, nil
}
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
if s == nil {
return defaultAppConfig("", false, nil)

View file

@ -14,7 +14,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
ctx := context.Background()
disabled := NewService(nil, nil)
cfg, _, err := disabled.GetAppConfig(ctx, 0)
cfg, _, err := disabled.GetAppConfig(ctx, 0, 0)
if err != nil {
t.Fatalf("GetAppConfig (disabled): %v", err)
}
@ -29,7 +29,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
enabled := NewService(nil, nil,
WithEmailSignupEnable(true),
WithEmailSignupPhonePrefixes([]string{"888", "380", "373"}))
cfg2, _, err := enabled.GetAppConfig(ctx, 0)
cfg2, _, err := enabled.GetAppConfig(ctx, 0, 0)
if err != nil {
t.Fatalf("GetAppConfig (enabled): %v", err)
}
@ -49,7 +49,7 @@ func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
other := NewService(nil, nil,
WithEmailSignupEnable(true),
WithEmailSignupPhonePrefixes([]string{"888"}))
cfg3, _, err := other.GetAppConfig(ctx, 0)
cfg3, _, err := other.GetAppConfig(ctx, 0, 0)
if err != nil {
t.Fatalf("GetAppConfig (different prefixes): %v", err)
}

View file

@ -0,0 +1,133 @@
package help
import (
"context"
"encoding/json"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
since := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC)
until := since.Add(7 * 24 * time.Hour)
provider := &fakeAccountFreezeProvider{items: map[int64]domain.AccountFreeze{
1001: {UserID: 1001, Frozen: true, Since: since, Until: until, AppealURL: "https://appeals.example.test/1001"},
}}
svc := NewService(nil, nil, WithAccountFreezeProvider(provider))
normal, notModified, err := svc.GetAppConfig(context.Background(), 1002, 0)
if err != nil || notModified {
t.Fatalf("normal GetAppConfig = %+v notModified=%v err=%v", normal, notModified, err)
}
frozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, normal.Hash)
if err != nil || notModified || frozen.Hash == normal.Hash {
t.Fatalf("frozen GetAppConfig = hash:%d normal:%d notModified=%v err=%v", frozen.Hash, normal.Hash, notModified, err)
}
assertFreezeConfig(t, frozen.JSON, since.Unix(), until.Unix(), "https://appeals.example.test/1001")
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash); err != nil || !notModified {
t.Fatalf("frozen hash replay = notModified:%v err:%v", notModified, err)
}
provider.items[1001] = domain.AccountFreeze{
UserID: 1001,
Frozen: true,
Since: since,
Until: until.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/1001/review",
}
updated, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash)
if err != nil || notModified || updated.Hash == frozen.Hash {
t.Fatalf("updated freeze config = hash:%d old:%d notModified=%v err=%v", updated.Hash, frozen.Hash, notModified, err)
}
assertFreezeConfig(t, updated.JSON, since.Unix(), until.Add(24*time.Hour).Unix(), "https://appeals.example.test/1001/review")
other, notModified, err := svc.GetAppConfig(context.Background(), 1002, frozen.Hash)
if err != nil || notModified || other.Hash != normal.Hash {
t.Fatalf("other user = hash:%d notModified:%v err:%v", other.Hash, notModified, err)
}
assertClearedFreezeConfig(t, other.JSON)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
provider.items[1001] = domain.AccountFreeze{UserID: 1001}
unfrozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, updated.Hash)
if err != nil || notModified || unfrozen.Hash != normal.Hash {
t.Fatalf("unfreeze refresh = hash:%d notModified:%v err:%v", unfrozen.Hash, notModified, err)
}
assertClearedFreezeConfig(t, unfrozen.JSON)
}
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(t *testing.T) {
svc := NewService(nil, nil)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, unauthorized.Hash)
if err != nil || notModified || authenticated.Hash == unauthorized.Hash {
t.Fatalf("authenticated clear config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
}
assertClearedFreezeConfig(t, authenticated.JSON)
}
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
svc := NewService(nil, nil)
base := domain.AppConfig{Client: "tdesktop", Hash: 9, JSON: []byte(`{"quote_length_max":1024,"freeze_since_date":1,"freeze_until_date":2,"freeze_appeal_url":"https://wrong.example"}`)}
cfg, err := svc.accountAppConfig(context.Background(), 0, base)
if err != nil {
t.Fatal(err)
}
if cfg.Hash == base.Hash {
t.Fatal("stripped config reused base hash")
}
assertNoFreezeConfig(t, cfg.JSON)
}
func assertFreezeConfig(t *testing.T, body []byte, since, until int64, appealURL string) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(since) || values["freeze_until_date"] != float64(until) || values["freeze_appeal_url"] != appealURL {
t.Fatalf("freeze config = %#v", values)
}
}
func assertNoFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
t.Fatalf("unexpected %s in config", key)
}
}
}
func assertClearedFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(0) || values["freeze_until_date"] != float64(0) || values["freeze_appeal_url"] != "" {
t.Fatalf("freeze clear config = %#v", values)
}
}
type fakeAccountFreezeProvider struct {
items map[int64]domain.AccountFreeze
}
func (f *fakeAccountFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
freeze, found := f.items[userID]
return freeze, found, nil
}

View file

@ -11,14 +11,14 @@ import (
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
func TestAppConfigPremiumKeys(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
}
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), defaultAppConfigHash-1)
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, defaultAppConfigHash-1)
if err != nil || oldNotModified || oldCfg.Hash != defaultAppConfigHash {
t.Fatalf("GetAppConfig(old hash) = hash %d notModified %v err %v, want refreshed config", oldCfg.Hash, oldNotModified, err)
}
@ -93,7 +93,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
@ -108,14 +108,14 @@ func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash == defaultAppConfigHash {
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
}
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
if _, notModified, err := svc.GetAppConfig(context.Background(), 0, cfg.Hash); err != nil || !notModified {
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
}
var decoded map[string]any

View file

@ -0,0 +1,238 @@
package langpack
import (
"container/list"
"sync"
"telesrv/internal/domain"
)
const (
defaultLangPackCacheMaxBytes = int64(128 << 20)
defaultLangPackCacheMaxEntries = 256
defaultLanguageListCacheMaxEntries = 32
langPackStringValueHeaderBytes = int64(144)
langPackFixedHeaderBytes = int64(80)
)
type langPackCacheKind uint8
const (
langPackCacheRaw langPackCacheKind = iota
langPackCacheEffective
)
type langPackCacheKey struct {
pack string
code string
kind langPackCacheKind
}
func (k langPackCacheKey) singleflightKey() string {
return string(rune(k.kind)) + "\x00" + k.pack + "\x00" + k.code
}
type langPackCache struct {
mu sync.Mutex
maxBytes int64
maxEntries int
usedBytes int64
epoch uint64
ll *list.List
items map[langPackCacheKey]*list.Element
}
type langPackCacheEntry struct {
key langPackCacheKey
pack domain.LangPack
size int64
}
func newLangPackCache(maxBytes int64, maxEntries int) *langPackCache {
if maxBytes <= 0 || maxEntries <= 0 {
return nil
}
return &langPackCache{
maxBytes: maxBytes,
maxEntries: maxEntries,
ll: list.New(),
items: make(map[langPackCacheKey]*list.Element),
}
}
func (c *langPackCache) get(key langPackCacheKey) (domain.LangPack, bool) {
if c == nil {
return domain.LangPack{}, false
}
c.mu.Lock()
defer c.mu.Unlock()
element, ok := c.items[key]
if !ok {
return domain.LangPack{}, false
}
c.ll.MoveToFront(element)
return cloneLangPack(element.Value.(*langPackCacheEntry).pack), true
}
func (c *langPackCache) loadEpoch() uint64 {
if c == nil {
return 0
}
c.mu.Lock()
epoch := c.epoch
c.mu.Unlock()
return epoch
}
// putIfEpoch 返回 false 仅表示 load 期间发生过 flush调用方必须重载。
// 超大单项不进入缓存,但仍可安全返回给当前请求,因此返回 true。
func (c *langPackCache) putIfEpoch(key langPackCacheKey, pack domain.LangPack, loadEpoch uint64) bool {
if c == nil {
return true
}
size := estimateLangPackBytes(pack)
c.mu.Lock()
defer c.mu.Unlock()
if c.epoch != loadEpoch {
return false
}
if existing, ok := c.items[key]; ok {
c.remove(existing)
}
if size > c.maxBytes {
return true
}
entry := &langPackCacheEntry{key: key, pack: cloneLangPack(pack), size: size}
c.items[key] = c.ll.PushFront(entry)
c.usedBytes += size
for c.usedBytes > c.maxBytes || c.ll.Len() > c.maxEntries {
oldest := c.ll.Back()
if oldest == nil {
break
}
c.remove(oldest)
}
return true
}
func (c *langPackCache) flush() {
if c == nil {
return
}
c.mu.Lock()
c.epoch++
c.usedBytes = 0
c.ll.Init()
clear(c.items)
c.mu.Unlock()
}
func (c *langPackCache) remove(element *list.Element) {
entry := element.Value.(*langPackCacheEntry)
delete(c.items, entry.key)
c.ll.Remove(element)
c.usedBytes -= entry.size
}
func estimateLangPackBytes(pack domain.LangPack) int64 {
size := langPackFixedHeaderBytes + int64(len(pack.LangPack)+len(pack.LangCode))
size += int64(len(pack.Strings)) * langPackStringValueHeaderBytes
for _, item := range pack.Strings {
size += int64(len(item.Key) + len(item.Value) + len(item.ZeroValue) + len(item.OneValue) +
len(item.TwoValue) + len(item.FewValue) + len(item.ManyValue) + len(item.OtherValue))
}
return size
}
func cloneLangPack(pack domain.LangPack) domain.LangPack {
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
return pack
}
type languageListCache struct {
mu sync.Mutex
maxEntries int
epoch uint64
ll *list.List
items map[string]*list.Element
}
type languageListCacheEntry struct {
pack string
languages []domain.LangPackLanguage
}
func newLanguageListCache(maxEntries int) *languageListCache {
if maxEntries <= 0 {
return nil
}
return &languageListCache{
maxEntries: maxEntries,
ll: list.New(),
items: make(map[string]*list.Element),
}
}
func (c *languageListCache) get(pack string) ([]domain.LangPackLanguage, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
element, ok := c.items[pack]
if !ok {
return nil, false
}
c.ll.MoveToFront(element)
return cloneLanguages(element.Value.(*languageListCacheEntry).languages), true
}
func (c *languageListCache) loadEpoch() uint64 {
if c == nil {
return 0
}
c.mu.Lock()
epoch := c.epoch
c.mu.Unlock()
return epoch
}
func (c *languageListCache) putIfEpoch(pack string, languages []domain.LangPackLanguage, loadEpoch uint64) bool {
if c == nil {
return true
}
c.mu.Lock()
defer c.mu.Unlock()
if c.epoch != loadEpoch {
return false
}
if existing, ok := c.items[pack]; ok {
c.ll.Remove(existing)
delete(c.items, pack)
}
entry := &languageListCacheEntry{pack: pack, languages: cloneLanguages(languages)}
c.items[pack] = c.ll.PushFront(entry)
if c.ll.Len() > c.maxEntries {
oldest := c.ll.Back()
if oldest != nil {
delete(c.items, oldest.Value.(*languageListCacheEntry).pack)
c.ll.Remove(oldest)
}
}
return true
}
func (c *languageListCache) flush() {
if c == nil {
return
}
c.mu.Lock()
c.epoch++
c.ll.Init()
clear(c.items)
c.mu.Unlock()
}
func cloneLanguages(languages []domain.LangPackLanguage) []domain.LangPackLanguage {
return append([]domain.LangPackLanguage(nil), languages...)
}

View file

@ -0,0 +1,35 @@
package langpack
import (
"testing"
"telesrv/internal/domain"
)
func TestLangPackCachesRejectLoadsAcrossFlush(t *testing.T) {
packCache := newLangPackCache(1<<20, 8)
key := langPackCacheKey{pack: "tdesktop", code: "en", kind: langPackCacheRaw}
packEpoch := packCache.loadEpoch()
packCache.flush()
if packCache.putIfEpoch(key, domain.LangPack{
LangPack: "tdesktop",
LangCode: "en",
Version: 1,
Strings: []domain.LangPackString{{Key: "key", Value: "stale"}},
}, packEpoch) {
t.Fatal("pre-flush pack load was accepted")
}
if _, ok := packCache.get(key); ok {
t.Fatal("pre-flush pack load became visible")
}
languageCache := newLanguageListCache(8)
languageEpoch := languageCache.loadEpoch()
languageCache.flush()
if languageCache.putIfEpoch("tdesktop", []domain.LangPackLanguage{{LangCode: "en"}}, languageEpoch) {
t.Fatal("pre-flush language-list load was accepted")
}
if _, ok := languageCache.get("tdesktop"); ok {
t.Fatal("pre-flush language-list load became visible")
}
}

View file

@ -2,6 +2,7 @@ package langpack
import (
"fmt"
"math"
"os"
"path/filepath"
"regexp"
@ -12,6 +13,8 @@ import (
)
var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`)
var langPackNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
var langCodeRE = regexp.MustCompile(`^[a-z0-9]{1,16}(?:-[a-z0-9]{1,16})*$`)
// ParseTDesktopFile 解析客户端 .strings 文件为 domain 语言包。
func ParseTDesktopFile(path string) (domain.LangPack, error) {
@ -82,6 +85,17 @@ func packFromFilename(path string) (domain.LangPack, error) {
if langPack == "" || langCode == "" {
return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path))
}
langPack = normalizePack(langPack)
langCode = normalizeCode(langCode)
if !langPackNameRE.MatchString(langPack) {
return domain.LangPack{}, fmt.Errorf("invalid langpack name %q in %q", langPack, filepath.Base(path))
}
if len(langCode) > 64 || !langCodeRE.MatchString(langCode) {
return domain.LangPack{}, fmt.Errorf("invalid language code %q in %q", langCode, filepath.Base(path))
}
if version <= 0 || version > math.MaxInt32 {
return domain.LangPack{}, fmt.Errorf("invalid langpack version %d in %q", version, filepath.Base(path))
}
return domain.LangPack{
LangPack: langPack,
LangCode: langCode,

View file

@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"telesrv/internal/store/memory"
@ -59,6 +60,23 @@ func TestParseClientLangPackFile(t *testing.T) {
}
}
func TestParseClientLangPackFileWithUnderscorePackName(t *testing.T) {
packDir := filepath.Join(t.TempDir(), "android_x")
if err := os.MkdirAll(packDir, 0o700); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
path := filepath.Join(packDir, "android_x_en_v42.strings")
writeLangPackFixture(t, path, `"TranslationMoreText" = "Translation Platform";`)
pack, err := ParseTDesktopFile(path)
if err != nil {
t.Fatalf("parse: %v", err)
}
if pack.LangPack != "android_x" || pack.LangCode != "en" || pack.Version != 42 {
t.Fatalf("pack meta = %+v", pack)
}
}
func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
root := t.TempDir()
for _, item := range []struct {
@ -98,13 +116,21 @@ func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
}
func TestBundledAndroidPersianLangPackParses(t *testing.T) {
path := filepath.Join("..", "..", "..", "data", "langpack", "android", "android_fa_v59634849.strings")
pack, err := ParseTDesktopFile(path)
root := filepath.Join("..", "..", "..", "data", "langpack", "android")
candidates, _, err := scanSeedCandidates(root)
if err != nil {
t.Fatalf("scan bundled android packs: %v", err)
}
candidate, ok := candidates["android\x00fa"]
if !ok {
t.Fatal("bundled android fa pack not found")
}
pack, err := ParseTDesktopFile(candidate.path)
if err != nil {
t.Fatalf("parse bundled android fa pack: %v", err)
}
if pack.LangPack != "android" || pack.LangCode != "fa" || pack.Version != 59634849 {
t.Fatalf("pack meta = %+v, want android/fa v59634849", pack)
if pack.LangPack != "android" || pack.LangCode != "fa" || pack.Version <= 0 {
t.Fatalf("pack meta = %+v, want versioned android/fa", pack)
}
if len(pack.Strings) < 10000 {
t.Fatalf("strings count = %d, want full android fa pack", len(pack.Strings))
@ -120,3 +146,120 @@ func TestBundledAndroidPersianLangPackParses(t *testing.T) {
}
t.Fatalf("TranslateLanguageFA not found in bundled android fa pack")
}
func TestSeedDirectoryReconcilesManifest(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
packDir := filepath.Join(root, "tdesktop")
if err := os.MkdirAll(packDir, 0o700); err != nil {
t.Fatalf("mkdir pack dir: %v", err)
}
v1 := filepath.Join(packDir, "tdesktop_pt-BR_v1.strings")
writeLangPackFixture(t, v1, `
"lng_language_name" = "Português (Brasil)";
"lng_old" = "old";
`)
store := memory.NewLangPackStore()
service := NewService(store)
seeded, err := service.SeedDirectory(ctx, root)
if err != nil || seeded != 2 {
t.Fatalf("seed v1 = %d, %v", seeded, err)
}
pack, err := service.GetLangPack(ctx, "TDESKTOP", "pt_BR")
if err != nil || pack.LangCode != "pt-br" || pack.Version != 1 || len(pack.Strings) != 2 {
t.Fatalf("normalized pack = %+v, err %v", pack, err)
}
languages, err := service.ListLanguages(ctx, "tdesktop")
if err != nil || findLanguage(languages, "pt-br") == nil {
t.Fatalf("languages = %+v, err %v", languages, err)
}
if seeded, err := service.SeedDirectory(ctx, root); err != nil || seeded != 0 {
t.Fatalf("unchanged seed = %d, %v", seeded, err)
}
v2 := filepath.Join(packDir, "tdesktop_pt-br_v2.strings")
writeLangPackFixture(t, v2, `
"lng_language_name" = "Português do Brasil";
"lng_new" = "new";
`)
seeded, err = service.SeedDirectory(ctx, root)
if err != nil || seeded != 2 {
t.Fatalf("seed v2 = %d, %v", seeded, err)
}
pack, err = service.GetLangPack(ctx, "tdesktop", "pt-br")
if err != nil || pack.Version != 2 || len(pack.Strings) != 2 || stringValue(pack.Strings, "lng_old") != "" || stringValue(pack.Strings, "lng_new") != "new" {
t.Fatalf("replaced pack = %+v, err %v", pack, err)
}
if err := os.Remove(v1); err != nil {
t.Fatalf("remove v1: %v", err)
}
if err := os.Remove(v2); err != nil {
t.Fatalf("remove v2: %v", err)
}
if err := os.Remove(packDir); err != nil {
t.Fatalf("remove pack dir: %v", err)
}
if seeded, err := service.SeedDirectory(ctx, root); err != nil || seeded != 0 {
t.Fatalf("reconcile removed file = %d, %v", seeded, err)
}
languages, err = service.ListLanguages(ctx, "tdesktop")
if err != nil || len(languages) != 0 {
t.Fatalf("languages after removal = %+v, err %v", languages, err)
}
}
func TestSeedDirectoryRejectsVersionInvariantViolations(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
packDir := filepath.Join(root, "tdesktop")
if err := os.MkdirAll(packDir, 0o700); err != nil {
t.Fatalf("mkdir pack dir: %v", err)
}
v2 := filepath.Join(packDir, "tdesktop_fr_v2.strings")
writeLangPackFixture(t, v2, `"lng_language_name" = "Français";`)
service := NewService(memory.NewLangPackStore())
if _, err := service.SeedDirectory(ctx, root); err != nil {
t.Fatalf("seed v2: %v", err)
}
writeLangPackFixture(t, v2, `"lng_language_name" = "Français modifié";`)
if _, err := service.SeedDirectory(ctx, root); err == nil || !strings.Contains(err.Error(), "without version bump") {
t.Fatalf("same-version mutation error = %v", err)
}
pack, err := service.GetLangPack(ctx, "tdesktop", "fr")
if err != nil || stringValue(pack.Strings, "lng_language_name") != "Français" {
t.Fatalf("pack changed after rejected mutation = %+v, err %v", pack, err)
}
if err := os.Remove(v2); err != nil {
t.Fatalf("remove v2: %v", err)
}
writeLangPackFixture(t, filepath.Join(packDir, "tdesktop_fr_v1.strings"), `"lng_language_name" = "Français";`)
if _, err := service.SeedDirectory(ctx, root); err == nil || !strings.Contains(err.Error(), "version rollback") {
t.Fatalf("version rollback error = %v", err)
}
}
func TestBundledLangPackDirectoryReconciles(t *testing.T) {
root := filepath.Join("..", "..", "..", "data", "langpack")
service := NewService(memory.NewLangPackStore())
seeded, err := service.SeedDirectory(context.Background(), root)
if err != nil {
t.Fatalf("seed bundled langpacks: %v", err)
}
if seeded < 50000 {
t.Fatalf("seeded bundled strings = %d, want full catalog", seeded)
}
if seeded, err := service.SeedDirectory(context.Background(), root); err != nil || seeded != 0 {
t.Fatalf("reconcile unchanged bundled langpacks = %d, %v", seeded, err)
}
}
func writeLangPackFixture(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write langpack fixture %q: %v", path, err)
}
}

View file

@ -2,14 +2,27 @@ package langpack
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// SeedDirectory 将导出的 .strings 文件导入 LangPackStore。
type seedCandidate struct {
path string
meta domain.LangPack
}
// SeedDirectory 将导出的 .strings 文件清单原子对账到 LangPackStore。
// root 可直接指向 data/langpack也可指向包含 .strings 的具体平台目录。
func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
if s == nil || s.packs == nil || root == "" {
@ -23,33 +36,201 @@ func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
return 0, fmt.Errorf("stat langpack seed dir: %w", err)
}
seeded := 0
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
candidates, scopes, err := scanSeedCandidates(dir)
if err != nil {
return 0, fmt.Errorf("scan langpack seed dir: %w", err)
}
keys := make([]string, 0, len(candidates))
for key := range candidates {
keys = append(keys, key)
}
sort.Strings(keys)
seed := domain.LangPackSeed{
Catalog: seedCatalogID(dir),
Scopes: scopes,
Packs: make([]domain.LangPackSeedEntry, 0, len(keys)),
}
previous, err := s.packs.GetSeedCatalog(ctx, seed.Catalog)
if err != nil {
return 0, fmt.Errorf("get previous langpack seed catalog: %w", err)
}
previousByKey := make(map[string]domain.LangPackSeedCatalogEntry, len(previous.Packs))
for _, entry := range previous.Packs {
previousByKey[entry.LangPack+"\x00"+entry.LangCode] = entry
}
for _, key := range keys {
candidate := candidates[key]
sourceHash, err := fileSHA256(candidate.path)
if err != nil {
return 0, fmt.Errorf("hash langpack source %q: %w", candidate.path, err)
}
if old, ok := previousByKey[key]; ok &&
old.Version == candidate.meta.Version &&
old.SourceHash == sourceHash &&
old.ContentHash != "" && old.StringsCount > 0 {
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
Pack: candidate.meta,
SourceHash: sourceHash,
ContentHash: old.ContentHash,
StringsCount: old.StringsCount,
ContentLoaded: false,
})
continue
}
pack, err := ParseTDesktopFile(candidate.path)
if err != nil {
return 0, err
}
pack, err = prepareSeedPack(pack)
if err != nil {
return 0, fmt.Errorf("prepare langpack %q: %w", candidate.path, err)
}
hash, err := langPackContentHash(pack)
if err != nil {
return 0, fmt.Errorf("hash langpack %q: %w", candidate.path, err)
}
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
Pack: pack,
SourceHash: sourceHash,
ContentHash: hash,
StringsCount: len(pack.Strings),
ContentLoaded: true,
})
}
seeded, err := s.packs.ReconcileSeed(ctx, seed)
if err != nil {
return 0, fmt.Errorf("reconcile langpack seed: %w", err)
}
s.flushCaches()
return seeded, nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func scanSeedCandidates(root string) (map[string]seedCandidate, []string, error) {
candidates := make(map[string]seedCandidate)
scopeSet := make(map[string]struct{})
hasChildDirs := false
hasFiles := false
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if entry.IsDir() {
if rel != "." && filepath.Dir(rel) == "." {
hasChildDirs = true
scope := normalizePack(entry.Name())
if langPackNameRE.MatchString(scope) {
scopeSet[scope] = struct{}{}
}
}
return nil
}
pack, err := ParseTDesktopFile(path)
if err != nil {
return err
}
existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version)
if err != nil {
return err
}
if existing.Version >= pack.Version {
if !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
return nil
}
if err := s.packs.UpsertPack(ctx, pack); err != nil {
hasFiles = true
meta, err := packFromFilename(path)
if err != nil {
return err
}
seeded += len(pack.Strings)
if relDir := filepath.Dir(rel); relDir != "." {
firstDir := strings.Split(relDir, string(filepath.Separator))[0]
scope := normalizePack(firstDir)
if !langPackNameRE.MatchString(scope) || scope != meta.LangPack {
return fmt.Errorf("langpack file %q is under pack directory %q", path, firstDir)
}
}
scopeSet[meta.LangPack] = struct{}{}
key := meta.LangPack + "\x00" + meta.LangCode
if previous, ok := candidates[key]; ok {
switch {
case meta.Version < previous.meta.Version:
return nil
case meta.Version == previous.meta.Version:
return fmt.Errorf("duplicate langpack version %s/%s v%d in %q and %q", meta.LangPack, meta.LangCode, meta.Version, previous.path, path)
}
}
candidates[key] = seedCandidate{path: path, meta: meta}
return nil
})
if err != nil {
return seeded, fmt.Errorf("walk langpack seed dir: %w", err)
return nil, nil, err
}
return seeded, nil
if !hasChildDirs && !hasFiles {
scope := normalizePack(filepath.Base(root))
if langPackNameRE.MatchString(scope) {
scopeSet[scope] = struct{}{}
}
}
scopes := make([]string, 0, len(scopeSet))
for scope := range scopeSet {
scopes = append(scopes, scope)
}
sort.Strings(scopes)
return candidates, scopes, nil
}
func prepareSeedPack(pack domain.LangPack) (domain.LangPack, error) {
pack.LangPack = normalizePack(pack.LangPack)
pack.LangCode = normalizeCode(pack.LangCode)
pack.FromVersion = 0
if len(pack.Strings) == 0 {
return domain.LangPack{}, errors.New("language file contains no strings")
}
deduplicated := make([]domain.LangPackString, 0, len(pack.Strings))
indexes := make(map[string]int, len(pack.Strings))
for _, item := range pack.Strings {
if item.Key == "" || utf8.RuneCountInString(item.Key) > 128 {
return domain.LangPack{}, fmt.Errorf("invalid string key %q", item.Key)
}
if index, exists := indexes[item.Key]; exists {
deduplicated[index] = item
continue
}
indexes[item.Key] = len(deduplicated)
deduplicated = append(deduplicated, item)
}
pack.Strings = deduplicated
sort.Slice(pack.Strings, func(i, j int) bool {
return pack.Strings[i].Key < pack.Strings[j].Key
})
return pack, nil
}
func langPackContentHash(pack domain.LangPack) (string, error) {
encoded, err := json.Marshal(pack)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func seedCatalogID(root string) string {
base := normalizePack(filepath.Base(root))
if langPackNameRE.MatchString(base) {
return base
}
sum := sha256.Sum256([]byte(filepath.Clean(root)))
return "path-" + hex.EncodeToString(sum[:8])
}

View file

@ -4,18 +4,38 @@ import (
"context"
"strings"
"golang.org/x/sync/singleflight"
"golang.org/x/text/unicode/bidi"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 提供客户端语言包查询。
type Service struct {
packs store.LangPackStore
packs store.LangPackStore
packCache *langPackCache
languageCache *languageListCache
packLoads singleflight.Group
languageLoads singleflight.Group
}
// NewService 创建 langpack 服务。
func NewService(packs store.LangPackStore) *Service {
return &Service{packs: packs}
return newServiceWithCacheLimits(
packs,
defaultLangPackCacheMaxBytes,
defaultLangPackCacheMaxEntries,
defaultLanguageListCacheMaxEntries,
)
}
func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service {
return &Service{
packs: packs,
packCache: newLangPackCache(maxBytes, maxEntries),
languageCache: newLanguageListCache(languageEntries),
}
}
// GetLangPack 返回完整语言包。
@ -30,11 +50,23 @@ func (s *Service) GetDifference(ctx context.Context, langPack, langCode string,
if s == nil || s.packs == nil {
return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil
}
pack, err := s.packs.GetPack(ctx, packName, code, fromVersion)
var (
pack domain.LangPack
err error
)
if fromVersion == 0 {
pack, err = s.effectivePack(ctx, packName, code)
} else {
pack, err = s.rawPack(ctx, packName, code)
}
if err != nil {
return domain.LangPack{}, err
}
return s.overlayWebAStrings(ctx, pack, packName, code, fromVersion)
pack.FromVersion = fromVersion
if pack.Version <= fromVersion {
pack.Strings = nil
}
return pack, nil
}
// GetStrings 返回指定 key 的语言包字符串。
@ -44,29 +76,42 @@ func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, key
if s == nil || s.packs == nil {
return domain.LangPack{LangPack: packName, LangCode: code}, nil
}
pack, err := s.packs.GetStrings(ctx, packName, code, keys)
pack, err := s.effectivePack(ctx, packName, code)
if err != nil {
return domain.LangPack{}, err
}
if len(keys) == 0 {
return s.overlayWebAStrings(ctx, pack, packName, code, 0)
}
missing := missingLangPackKeys(keys, pack.Strings)
if len(missing) == 0 || !shouldOverlayWebA(packName) {
return pack, nil
}
overlay, err := s.packs.GetStrings(ctx, "weba", code, missing)
if err != nil {
return domain.LangPack{}, err
wanted := make(map[string]struct{}, len(keys))
for _, key := range keys {
wanted[key] = struct{}{}
}
return mergeMissingLangPackStrings(pack, overlay), nil
selected := pack
selected.Strings = make([]domain.LangPackString, 0, len(keys))
for _, item := range pack.Strings {
if _, ok := wanted[item.Key]; ok {
selected.Strings = append(selected.Strings, item)
}
}
return selected, nil
}
// ListLanguages 返回已 seed 的语言包语言列表。
func (s *Service) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
packName := normalizePack(langPack)
if s == nil || s.packs == nil {
return nil, nil
}
return s.cachedLanguages(ctx, packName)
}
func normalizePack(langPack string) string {
if langPack == "" {
pack := strings.ToLower(strings.TrimSpace(langPack))
if pack == "" {
return "tdesktop"
}
return langPack
return pack
}
func normalizeCode(langCode string) string {
@ -74,6 +119,7 @@ func normalizeCode(langCode string) string {
if code == "" {
return "en"
}
code = strings.ReplaceAll(code, "_", "-")
return strings.TrimSuffix(code, "-raw")
}
@ -86,15 +132,117 @@ func shouldOverlayWebA(langPack string) bool {
}
}
func (s *Service) overlayWebAStrings(ctx context.Context, pack domain.LangPack, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
if fromVersion != 0 || !shouldOverlayWebA(langPack) {
return pack, nil
func (s *Service) rawPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheRaw}
return s.cachedPack(ctx, key, func() (domain.LangPack, error) {
return s.packs.GetPack(ctx, langPack, langCode, 0)
})
}
func (s *Service) effectivePack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
if !shouldOverlayWebA(langPack) {
return s.rawPack(ctx, langPack, langCode)
}
overlay, err := s.packs.GetPack(ctx, "weba", langCode, fromVersion)
if err != nil {
return domain.LangPack{}, err
key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheEffective}
return s.cachedPack(ctx, key, func() (domain.LangPack, error) {
pack, err := s.rawPack(ctx, langPack, langCode)
if err != nil {
return domain.LangPack{}, err
}
overlay, err := s.rawPack(ctx, "weba", langCode)
if err != nil {
return domain.LangPack{}, err
}
return mergeMissingLangPackStrings(pack, overlay), nil
})
}
type cachedPackLoadResult struct {
pack domain.LangPack
stable bool
}
func (s *Service) cachedPack(ctx context.Context, key langPackCacheKey, load func() (domain.LangPack, error)) (domain.LangPack, error) {
if s.packCache == nil {
return load()
}
return mergeMissingLangPackStrings(pack, overlay), nil
for {
if pack, ok := s.packCache.get(key); ok {
return pack, nil
}
value, err, _ := s.packLoads.Do(key.singleflightKey(), func() (any, error) {
if pack, ok := s.packCache.get(key); ok {
return cachedPackLoadResult{pack: pack, stable: true}, nil
}
loadEpoch := s.packCache.loadEpoch()
pack, err := load()
if err != nil {
return cachedPackLoadResult{}, err
}
return cachedPackLoadResult{
pack: pack,
stable: s.packCache.putIfEpoch(key, pack, loadEpoch),
}, nil
})
if err != nil {
return domain.LangPack{}, err
}
result := value.(cachedPackLoadResult)
if result.stable {
return cloneLangPack(result.pack), nil
}
if err := ctx.Err(); err != nil {
return domain.LangPack{}, err
}
}
}
type cachedLanguagesLoadResult struct {
languages []domain.LangPackLanguage
stable bool
}
func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
if languages, ok := s.languageCache.get(langPack); ok {
return languages, nil
}
for {
value, err, _ := s.languageLoads.Do(langPack, func() (any, error) {
if languages, ok := s.languageCache.get(langPack); ok {
return cachedLanguagesLoadResult{languages: languages, stable: true}, nil
}
loadEpoch := s.languageCache.loadEpoch()
languages, err := s.packs.ListLanguages(ctx, langPack)
if err != nil {
return cachedLanguagesLoadResult{}, err
}
for i := range languages {
languages[i] = completeLanguageMetadata(langPack, languages[i])
}
return cachedLanguagesLoadResult{
languages: languages,
stable: s.languageCache.putIfEpoch(langPack, languages, loadEpoch),
}, nil
})
if err != nil {
return nil, err
}
result := value.(cachedLanguagesLoadResult)
if result.stable {
return cloneLanguages(result.languages), nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
}
}
func (s *Service) flushCaches() {
if s == nil {
return
}
s.packCache.flush()
s.languageCache.flush()
}
func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack {
@ -121,6 +269,57 @@ func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack
return pack
}
func completeLanguageMetadata(langPack string, lang domain.LangPackLanguage) domain.LangPackLanguage {
if lang.LangPack == "" {
lang.LangPack = langPack
}
lang.LangCode = normalizeCode(lang.LangCode)
if lang.PluralCode == "" {
lang.PluralCode = pluralCode(lang.LangCode)
}
if lang.NativeName == "" {
lang.NativeName = lang.Name
}
if lang.Name == "" {
lang.Name = lang.NativeName
}
if lang.Name == "" {
lang.Name = lang.LangCode
}
if lang.NativeName == "" {
lang.NativeName = lang.Name
}
if lang.StringsCount == 0 {
lang.StringsCount = lang.TranslatedCount
}
if lang.TranslatedCount == 0 {
lang.TranslatedCount = lang.StringsCount
}
lang.Official = true
lang.Rtl = lang.Rtl || isRTLText(lang.NativeName)
return lang
}
func pluralCode(langCode string) string {
if idx := strings.IndexAny(langCode, "-_"); idx > 0 {
return langCode[:idx]
}
return langCode
}
func isRTLText(value string) bool {
for _, r := range value {
properties, _ := bidi.LookupRune(r)
switch properties.Class() {
case bidi.R, bidi.AL:
return true
case bidi.L:
return false
}
}
return false
}
func missingLangPackKeys(keys []string, strings []domain.LangPackString) []string {
if len(keys) == 0 {
return nil

View file

@ -2,9 +2,12 @@ package langpack
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -71,6 +74,258 @@ func TestServiceNormalizesWebARawLangCode(t *testing.T) {
}
}
func TestListLanguagesUsesSeededPacks(t *testing.T) {
ctx := context.Background()
packs := memory.NewLangPackStore()
svc := NewService(packs)
if err := packs.UpsertPack(ctx, domain.LangPack{
LangPack: "tdesktop",
LangCode: "fr",
Version: 7,
Strings: []domain.LangPackString{
{Key: "lng_language_name", Value: "Français"},
{Key: "lng_test", Value: "Test"},
},
}); err != nil {
t.Fatalf("seed fr langpack: %v", err)
}
if err := packs.UpsertPack(ctx, domain.LangPack{
LangPack: "android",
LangCode: "fa",
Version: 8,
Strings: []domain.LangPackString{
{Key: "LanguageName", Value: "انگلیسی"},
{Key: "TranslateLanguageFA", Value: "فارسی"},
{Key: "lng_test", Value: "Test"},
},
}); err != nil {
t.Fatalf("seed fa langpack: %v", err)
}
if err := packs.UpsertPack(ctx, domain.LangPack{
LangPack: "tdesktop",
LangCode: "ckb",
Version: 9,
Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "کوردی"}},
}); err != nil {
t.Fatalf("seed ckb langpack: %v", err)
}
tdesktop, err := svc.ListLanguages(ctx, "tdesktop")
if err != nil {
t.Fatalf("list tdesktop languages: %v", err)
}
fr := findLanguage(tdesktop, "fr")
if fr == nil || fr.Name != "Français" || fr.NativeName != "Français" || fr.PluralCode != "fr" || fr.StringsCount != 2 {
t.Fatalf("fr language = %+v", fr)
}
ckb := findLanguage(tdesktop, "ckb")
if ckb == nil || !ckb.Rtl {
t.Fatalf("ckb language = %+v, want file-derived rtl", ckb)
}
android, err := svc.ListLanguages(ctx, "android")
if err != nil {
t.Fatalf("list android languages: %v", err)
}
fa := findLanguage(android, "fa")
if fa == nil || fa.NativeName != "فارسی" || !fa.Rtl || fa.PluralCode != "fa" {
t.Fatalf("fa language = %+v", fa)
}
}
func TestServiceCachesLanguageResourcesAfterFirstRequest(t *testing.T) {
ctx := context.Background()
base := memory.NewLangPackStore()
for _, pack := range []domain.LangPack{
{
LangPack: "android",
LangCode: "en",
Version: 7,
Strings: []domain.LangPackString{
{Key: "LogOutTitle", Value: "Log Out"},
{Key: "NewMessageTitle", Value: "New Message"},
},
},
{
LangPack: "weba",
LangCode: "en",
Version: 12,
Strings: []domain.LangPackString{
{Key: "AccDescrPollVoteDown", Value: "Go to next unread poll vote"},
{Key: "NewMessageTitle", Value: "New Message from WebA"},
},
},
} {
if err := base.UpsertPack(ctx, pack); err != nil {
t.Fatalf("seed %s: %v", pack.LangPack, err)
}
}
counting := &countingLangPackStore{LangPackStore: base}
svc := NewService(counting)
first, err := svc.GetLangPack(ctx, "android", "en")
if err != nil {
t.Fatalf("first get langpack: %v", err)
}
first.Strings[0].Value = "caller mutation"
second, err := svc.GetLangPack(ctx, "android", "en")
if err != nil {
t.Fatalf("second get langpack: %v", err)
}
if got := stringValue(second.Strings, "LogOutTitle"); got != "Log Out" {
t.Fatalf("cached pack was mutated through caller alias: %q", got)
}
selected, err := svc.GetStrings(ctx, "android", "en", []string{"AccDescrPollVoteDown"})
if err != nil || stringValue(selected.Strings, "AccDescrPollVoteDown") == "" {
t.Fatalf("cached get strings = %+v, %v", selected, err)
}
if _, err := svc.GetDifference(ctx, "android", "en", 1); err != nil {
t.Fatalf("cached get difference: %v", err)
}
languages, err := svc.ListLanguages(ctx, "android")
if err != nil || len(languages) != 1 {
t.Fatalf("first list languages = %+v, %v", languages, err)
}
languages[0].Name = "caller mutation"
languages, err = svc.ListLanguages(ctx, "android")
if err != nil || len(languages) != 1 || languages[0].Name == "caller mutation" {
t.Fatalf("cached languages alias = %+v, %v", languages, err)
}
getPack, getStrings, listLanguages := counting.counts()
if getPack != 2 || getStrings != 0 || listLanguages != 1 {
t.Fatalf("store calls = getPack:%d getStrings:%d list:%d, want 2/0/1", getPack, getStrings, listLanguages)
}
}
func TestServiceCollapsesConcurrentLanguagePackLoads(t *testing.T) {
ctx := context.Background()
base := memory.NewLangPackStore()
if err := base.UpsertPack(ctx, domain.LangPack{
LangPack: "weba",
LangCode: "en",
Version: 3,
Strings: []domain.LangPackString{{Key: "NewMessageTitle", Value: "New Message"}},
}); err != nil {
t.Fatalf("seed weba: %v", err)
}
counting := &countingLangPackStore{LangPackStore: base, delay: 10 * time.Millisecond}
svc := NewService(counting)
start := make(chan struct{})
errs := make(chan error, 32)
var wg sync.WaitGroup
for range 32 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := svc.GetLangPack(ctx, "weba", "en")
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent get langpack: %v", err)
}
}
getPack, _, _ := counting.counts()
if getPack != 1 {
t.Fatalf("concurrent store getPack calls = %d, want 1", getPack)
}
}
func TestServiceLanguagePackCacheIsBounded(t *testing.T) {
ctx := context.Background()
base := memory.NewLangPackStore()
for _, code := range []string{"en", "fr"} {
if err := base.UpsertPack(ctx, domain.LangPack{
LangPack: "weba",
LangCode: code,
Version: 1,
Strings: []domain.LangPackString{{Key: "key", Value: code}},
}); err != nil {
t.Fatalf("seed %s: %v", code, err)
}
}
counting := &countingLangPackStore{LangPackStore: base}
svc := newServiceWithCacheLimits(counting, 1<<20, 1, 1)
for _, code := range []string{"en", "fr", "en"} {
if _, err := svc.GetLangPack(ctx, "weba", code); err != nil {
t.Fatalf("get %s: %v", code, err)
}
}
getPack, _, _ := counting.counts()
if getPack != 3 {
t.Fatalf("LRU store getPack calls = %d, want 3", getPack)
}
oversized := &countingLangPackStore{LangPackStore: base}
svc = newServiceWithCacheLimits(oversized, 1, 8, 1)
if _, err := svc.GetLangPack(ctx, "weba", "en"); err != nil {
t.Fatalf("first oversized get: %v", err)
}
if _, err := svc.GetLangPack(ctx, "weba", "en"); err != nil {
t.Fatalf("second oversized get: %v", err)
}
getPack, _, _ = oversized.counts()
if getPack != 2 {
t.Fatalf("oversized store getPack calls = %d, want 2", getPack)
}
}
type countingLangPackStore struct {
store.LangPackStore
mu sync.Mutex
delay time.Duration
getPack int
getStrings int
listLanguages int
}
func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
s.mu.Lock()
s.getPack++
delay := s.delay
s.mu.Unlock()
if delay > 0 {
time.Sleep(delay)
}
return s.LangPackStore.GetPack(ctx, langPack, langCode, fromVersion)
}
func (s *countingLangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
s.mu.Lock()
s.getStrings++
s.mu.Unlock()
return s.LangPackStore.GetStrings(ctx, langPack, langCode, keys)
}
func (s *countingLangPackStore) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
s.mu.Lock()
s.listLanguages++
s.mu.Unlock()
return s.LangPackStore.ListLanguages(ctx, langPack)
}
func (s *countingLangPackStore) counts() (getPack, getStrings, listLanguages int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.getPack, s.getStrings, s.listLanguages
}
func findLanguage(languages []domain.LangPackLanguage, code string) *domain.LangPackLanguage {
for i := range languages {
if languages[i].LangCode == code {
return &languages[i]
}
}
return nil
}
func stringValue(strings []domain.LangPackString, key string) string {
for _, item := range strings {
if item.Key == key {

View file

@ -12,11 +12,18 @@ type DispatchOutboxRetentionStore interface {
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
}
// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key 绑定
// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key(含未绑定 key
type TempAuthKeyRetentionStore interface {
DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error)
}
// AuthKeySessionLayerRetentionStore reclaims expired short-lived Layer
// watermarks. Selector freshness, not retention timing, is the correctness
// gate; this worker only bounds durable storage.
type AuthKeySessionLayerRetentionStore interface {
DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error)
}
// OrphanAuthKeyRetentionStore 回收从未形成授权/temp binding 的旧握手 key。
// protected 是当前连接注册表实际使用的 raw auth_key_id 快照。
type OrphanAuthKeyRetentionStore interface {
@ -64,9 +71,9 @@ type LoginCodeDeliveryRetentionStore interface {
// getUpdates 读取fromID 恒 > confirmed宽限仅防御 offset 回拨调试;回收目标是清堆积。
const botAPIConfirmedGrace = 15 * time.Minute
// tempAuthKeyExpiryGrace 是 temp key 过期后的回收宽限ResolveAuthKey 对
// 「已过期但 perm 已授权」的绑定是容忍的,立即删除会突然断掉这批宽限中的
// 连接;回收目标是清堆积,晚一天无妨
// tempAuthKeyExpiryGrace 只是一段数据库物理回收宽限。MTProto edge 在 expires_at
// 到点即停止入站 RPC、主动推送和重发并断开连接ResolveAuthKey 不容忍过期 key。
// 晚一天删除用于吸收客户端轮换/诊断窗口,不会延长协议有效期
const tempAuthKeyExpiryGrace = 24 * time.Hour
const (
@ -86,7 +93,8 @@ const (
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
type RetentionWorker struct {
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
@ -176,6 +184,13 @@ func (w *RetentionWorker) WithLoginCodeDeliveryRetention(store LoginCodeDelivery
return w
}
// WithAuthKeySessionLayerRetention enables bounded seek cleanup for expired
// per-session Layer evidence.
func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionLayerRetentionStore) *RetentionWorker {
w.authKeySessionLayers = store
return w
}
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key
// 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
@ -243,6 +258,14 @@ func (w *RetentionWorker) runOutboxPoisonOnce(ctx context.Context) {
}
func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
if w.authKeySessionLayers != nil {
deleted, err := w.authKeySessionLayers.DeleteExpiredSessionLayers(ctx, w.batch)
if err != nil {
w.logger.Warn("回收过期 auth-key session Layer 证据失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期 auth-key session Layer 证据完成", zap.Int("deleted", deleted))
}
}
if w.loginCodeDeliveries != nil {
deleted, err := w.loginCodeDeliveries.DeleteExpiredLoginCodeDeliveries(ctx, time.Now(), w.batch)
if err != nil {

View file

@ -98,7 +98,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
req.SenderUserID = userID
}
if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrUserSendRestricted
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req)

View file

@ -21,8 +21,8 @@ func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
RecipientUserID: 1002,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendPrivateText err=%v, want ErrUserFrozen", err)
}
if store.sends != 0 {
t.Fatalf("store sends=%d, want 0", store.sends)
@ -71,8 +71,8 @@ func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
ToUserID: 1003,
MessageIDs: []int{1},
RandomIDs: []int64{2},
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserFrozen", err)
}
if store.forwards != 0 {
t.Fatalf("store forwards=%d, want 0", store.forwards)
@ -502,7 +502,7 @@ type projectionMessageStore struct {
type denySendChecker struct{}
func (denySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
type gateMessageStore struct {

View file

@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/gotd/td/clock"
"github.com/iamxvbaba/td/clock"
"telesrv/internal/domain"
)

View file

@ -8,7 +8,7 @@ import (
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/iamxvbaba/td/clock"
"telesrv/internal/domain"
)

View file

@ -0,0 +1,190 @@
package stargifts
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"math"
"path/filepath"
"strings"
"time"
"telesrv/internal/domain"
)
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
// single canonical pair used by both the Telegram download path and admin preview.
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimation(fileName, data)
}
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
fileName = strings.TrimSpace(filepath.Base(fileName))
ext := strings.ToLower(filepath.Ext(fileName))
format := domain.StarGiftAnimationLottie
var rawJSON []byte
if ext == ".tgs" || isGzip(data) {
format = domain.StarGiftAnimationTGS
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
var err error
rawJSON, err = decompressSingleTGS(data)
if err != nil {
return domain.StarGiftAnimation{}, err
}
} else {
if ext != ".json" && ext != ".lottie" {
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
}
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
rawJSON = data
}
normalized, meta, err := normalizeAndValidateLottie(rawJSON)
if err != nil {
return domain.StarGiftAnimation{}, err
}
tgs, err := gzipLottie(normalized)
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
sum := sha256.Sum256(tgs)
return domain.StarGiftAnimation{
SourceName: fileName,
SourceFormat: format,
JSON: normalized,
TGS: tgs,
SHA256: append([]byte(nil), sum[:]...),
Width: meta.W,
Height: meta.H,
FrameRate: meta.FrameRate,
InPoint: meta.InPoint,
OutPoint: meta.OutPoint,
}, nil
}
type lottieMetadata struct {
Version string `json:"v"`
W int `json:"w"`
H int `json:"h"`
FrameRate float64 `json:"fr"`
InPoint float64 `json:"ip"`
OutPoint float64 `json:"op"`
Layers []json.RawMessage `json:"layers"`
Assets []json.RawMessage `json:"assets"`
}
func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var root any
if err := dec.Decode(&root); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if _, ok := root.(map[string]any); !ok {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if containsLottieExpression(root) {
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
}
var meta lottieMetadata
if err := json.Unmarshal(data, &meta); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
frameSpan := meta.OutPoint - meta.InPoint
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
// pre-composition assets with only an id/layers payload remain valid.
for _, raw := range meta.Assets {
var asset map[string]json.RawMessage
if json.Unmarshal(raw, &asset) != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
for _, key := range []string{"p", "u"} {
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
}
}
}
var compact bytes.Buffer
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
return compact.Bytes(), meta, nil
}
func containsLottieExpression(value any) bool {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
if key == "x" {
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
return true
}
}
if containsLottieExpression(child) {
return true
}
}
case []any:
for _, child := range node {
if containsLottieExpression(child) {
return true
}
}
}
return false
}
func isGzip(data []byte) bool {
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
}
func decompressSingleTGS(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
gz, err := gzip.NewReader(reader)
if err != nil {
return nil, domain.ErrStarGiftFileInvalid
}
gz.Multistream(false)
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
closeErr := gz.Close()
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
return nil, domain.ErrStarGiftFileInvalid
}
return raw, nil
}
func gzipLottie(data []byte) ([]byte, error) {
var out bytes.Buffer
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
if err != nil {
return nil, err
}
gz.Header.ModTime = time.Unix(0, 0)
gz.Header.OS = 255
if _, err := gz.Write(data); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}

View file

@ -0,0 +1,93 @@
package stargifts
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
if err != nil {
t.Fatalf("prepare lottie: %v", err)
}
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
t.Fatalf("prepared lottie = %+v", fromJSON)
}
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
if err != nil {
t.Fatalf("prepare tgs: %v", err)
}
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
}
}
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
for name, raw := range map[string]string{
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
}
})
}
}
type testGiftBlob struct{ data map[string][]byte }
func (b *testGiftBlob) Name() string { return "localfs" }
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation,
})
if err != nil {
t.Fatalf("create first: %v", err)
}
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
t.Fatalf("current=%+v found=%v", current, found)
}
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "First" {
t.Fatalf("historical=%+v found=%v", historical, found)
}
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
}
}

View file

@ -1,124 +1,413 @@
// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的
// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText
// 本层只管目录与持久化。
// Package stargifts implements the durable Star Gift catalog and received-gift state.
package stargifts
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// CatalogProvider 合成礼物目录app/files 实现)。
type CatalogProvider interface {
BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error)
// BlobBackend is the content-addressed media boundary used by the catalog importer.
type BlobBackend interface {
Name() string
Put(ctx context.Context, data []byte) (string, error)
Get(ctx context.Context, objectKey string) ([]byte, error)
}
// Service 是 Star 礼物应用服务。
type Service struct {
store store.StarGiftStore
catalog CatalogProvider
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
mu sync.Mutex
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
}
// NewService 创建 Star 礼物服务。
func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service {
return &Service{store: st, catalog: catalog}
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc}
for _, opt := range opts {
opt(service)
}
return service
}
// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。
func (s *Service) ensureCatalog(ctx context.Context) error {
s.mu.RLock()
built := s.built
s.mu.RUnlock()
if built {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.built {
return nil
}
gifts, err := s.catalog.BuildStarGiftCatalog(ctx)
if s.store == nil {
return fmt.Errorf("star gift store is not configured")
}
gifts, err := s.store.Catalog(ctx)
if err != nil {
return err
}
s.gifts = gifts
s.byID = make(map[int64]domain.StarGift, len(gifts))
for _, g := range gifts {
s.byID[g.ID] = g
for _, gift := range gifts {
s.byID[gift.ID] = gift
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
// Catalog 返回礼物目录。
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.StarGift, len(s.gifts))
copy(out, s.gifts)
return out, nil
s.mu.RLock()
defer s.mu.RUnlock()
return append([]domain.StarGift(nil), s.gifts...), nil
}
// CatalogHash 返回目录 hashgetStarGifts NotModified 判定)。
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash, nil
}
// GiftByID 返回目录中指定礼物,不存在返回 ok=false。
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
if err := s.ensureCatalog(ctx); err != nil {
return domain.StarGift{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.byID[id]
return g, ok, nil
s.mu.RLock()
defer s.mu.RUnlock()
gift, ok := s.byID[id]
return gift, ok, nil
}
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if s == nil || s.store == nil {
return domain.StarGift{}, false, nil
}
return s.store.CatalogRevision(ctx, revisionID)
}
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
func (s *Service) InvalidateStarGiftCatalog() {
if s == nil {
return
}
s.mu.Lock()
s.built = false
s.gifts = nil
s.byID = nil
s.hash = 0
s.mu.Unlock()
}
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
}
write.Title = strings.TrimSpace(write.Title)
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
AccessHash: accessHash,
FileReference: fileReference,
Date: int(time.Now().Unix()),
MimeType: "application/x-tgsticker",
Size: int64(len(write.Animation.TGS)),
DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
},
}
write.Blob = domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(write.Animation.TGS)),
SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker",
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
return s.store.AnimationJSON(ctx, giftID)
}
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
}
revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return revision, err
}
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
// atomically publishes the complete immutable attribute pool. Callers must pass animations
// produced by PrepareAnimation; partial revisions are never exposed to clients.
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
}
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
if err := materialize(write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := materialize(write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
return domain.StarGiftUpgradePreview{
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
SlugPrefix: revision.SlugPrefix,
}, true, nil
}
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
if s == nil || s.store == nil || len(giftIDs) == 0 {
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
}
return s.store.CollectibleAvailability(ctx, giftIDs)
}
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.store == nil {
return nil, false, nil
}
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueBySlug(ctx, slug)
}
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueByID(ctx, uniqueGiftID)
}
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
return map[int64]domain.UniqueStarGift{}, nil
}
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
}
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.UpgradeStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
}
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
}
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
return s.store.DeleteCollection(ctx, owner, collectionID)
}
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return s.store.ReorderCollections(ctx, owner, collectionIDs)
}
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return s.store.SetPinned(ctx, owner, savedGiftIDs)
}
// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
return s.store.Create(ctx, gift)
}
// ListSaved 分页返回某 owner 收到的礼物。
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
if len(offset) > domain.MaxStarGiftsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit)
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
offset := filter.Offset
if len(offset) > domain.MaxStarGiftsOffsetBytes {
filter.Offset = ""
}
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
filter.Limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwnerFiltered(ctx, filter)
}
// GetSaved 按协议引用取礼物实例。
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
// CountSaved 返回某 owner 展示在资料的礼物数full.stargifts_count
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
return s.store.ResolveSavedIDs(ctx, owner, refs)
}
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
return s.store.CountByOwner(ctx, owner)
}
// ToggleSaved 切换礼物在资料的展示saveStarGift
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert 把礼物标记为已转换convertStarGift返回该行供调用方据 ConvertStars 入账。
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, fmt.Errorf("generate star gift id: %w", err)
}
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if id == 0 {
id = 1
}
return id, nil
}

View file

@ -9,40 +9,28 @@ import (
"telesrv/internal/store/memory"
)
type fakeCatalog struct {
gifts []domain.StarGift
calls int
}
func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
f.calls++
return f.gifts, nil
}
func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) {
cat := &fakeCatalog{gifts: gifts}
return NewService(memory.NewStarGiftStore(), cat), cat
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
st := memory.NewStarGiftStore()
st.SeedCatalog(gifts)
return NewService(st, nil, 2), st
}
func TestCatalogCachedAndHash(t *testing.T) {
gifts := []domain.StarGift{
{ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"},
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
}
svc, cat := newTestService(gifts)
svc, _ := newTestService(gifts)
ctx := context.Background()
got, err := svc.Catalog(ctx)
if err != nil || len(got) != 2 {
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
}
// 再取一次不重新构建(缓存)
// 再取一次命中进程内目录缓存
if _, err := svc.Catalog(ctx); err != nil {
t.Fatalf("catalog#2: %v", err)
}
if cat.calls != 1 {
t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls)
}
hash, err := svc.CatalogHash(ctx)
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
@ -61,11 +49,15 @@ func TestSavedGiftLifecycle(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
})
if err != nil || id == 0 {
t.Fatalf("RecordSavedGift = %d err %v", id, err)
}
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
if err != nil || len(collection.GiftIDs) != 1 {
t.Fatalf("CreateCollection = %+v err %v", collection, err)
}
page, err := svc.ListSaved(ctx, owner, false, "", 100)
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
@ -99,6 +91,11 @@ func TestSavedGiftLifecycle(t *testing.T) {
if len(after.Gifts) != 0 {
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
}
collections, err := svc.ListCollections(ctx, owner)
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
}
// 重复转换被拒。
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
@ -111,7 +108,7 @@ func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0,
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
Date: 1700000000, ConvertStars: 15,
})
if err != nil || savedID == 0 {
@ -133,7 +130,7 @@ func TestSavedGiftPagination(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
for i := 0; i < 5; i++ {
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
}); err != nil {
t.Fatalf("record#%d: %v", i, err)
}

View file

@ -148,7 +148,14 @@ func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
if err != nil {
t.Fatalf("GetState after difference: %v", err)
}
if st.Pts != 2 {
t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts)
if st.Pts != 1 {
t.Fatalf("GetState before delivery commit pts=%d, want confirmed pts=1", st.Pts)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("CommitDeliveredState: %v", err)
}
st, err = svc.GetState(ctx, authKeyID, userID)
if err != nil || st.Pts != 2 {
t.Fatalf("GetState after delivery commit = %+v err=%v, want pts=2", st, err)
}
}

View file

@ -135,27 +135,34 @@ func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID in
return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0})
}
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
//
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的
// 离线数据由 getDialogs 快照承载TDesktop 不持久化 pts每次启动都走此路径
// 若改为返回设备旧确认水位,客户端会在 getDialogs 最新快照之上再重放历史差分,
// 造成未读重复累计、dialog 预览被旧消息抢占。持久化 pts 的客户端Android
// 启动时直接带本地 pts 调 getDifference不经过 getState不受影响。
func (s *Service) AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
st, err := s.currentState(ctx, userID)
// ObserveDifferenceRequest records only the cursor a client carried into this
// request. It is deliberately independent from response delivery: even when
// encoding or the socket write later fails, the request still proves the client
// already owned this (clamped) cursor before contacting us.
func (s *Service) ObserveDifferenceRequest(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateState, error) {
current, err := s.currentState(ctx, userID)
if err != nil {
return domain.UpdateState{}, err
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
from = clampDifferenceState(from, current)
if err := s.observeClientState(ctx, authKeyID, userID, from); err != nil {
return domain.UpdateState{}, err
}
// getState 明确建立“从当前快照开始同步”的 baseline即使响应丢失客户端也会
// 重试 getState/重新拉 snapshot而不会依赖 baseline 之前的 durable event。
if err := s.observeClientState(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateState{}, err
return from, nil
}
// CommitDeliveredState persists the exact cursor justified by a physically
// delivered RPC result. The store owns the atomic/monotonic invariant because
// delivery callbacks from different responses may complete out of order.
func (s *Service) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
if s.states == nil {
return nil
}
return st, nil
if mode != domain.UpdateStateCommitDeliveredOnly && mode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
return fmt.Errorf("invalid delivered update state commit mode %d", mode)
}
st.Seq = 0
return s.states.CommitDeliveredState(ctx, authKeyID, userID, st, mode)
}
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
@ -171,19 +178,9 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if err != nil {
return domain.UpdateDifference{}, err
}
// 只把客户端在本次请求中实际带回的 cursor 记为 observed。绝不能把本次将要
// 返回的 State 当确认:响应可能在 socket/进程故障中丢失。恶意/损坏客户端带来的
// 超前 pts 钳到账号当前连续水位,避免把 retention 安全边界推过 durable truth。
observed := from
if observed.Pts < 0 {
observed.Pts = 0
}
if observed.Pts > st.Pts {
observed.Pts = st.Pts
}
if err := s.observeClientState(ctx, authKeyID, userID, observed); err != nil {
return domain.UpdateDifference{}, err
}
// Computation is pure with respect to device confirmed/observed state. The
// request observer and physical-delivery commit are explicit caller phases.
from = clampDifferenceState(from, st)
// TDesktop 不支持账号级 updates.differenceTooLong。retention 只能删除所有授权
// 设备都已确认的共同前缀;当前设备若仍带更旧 pts用一个空的普通
// differenceSlice 把 IntermediateState 推进到已确认 checkpoint再从 live tail 续拉。
@ -196,9 +193,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if from.Date != 0 {
st.Date = from.Date
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{State: st}, nil
}
events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit)
@ -246,9 +240,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if len(contiguous) > 0 {
out.Date = contiguous[len(contiguous)-1].Date
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{
State: out,
Events: contiguous,
@ -276,12 +267,20 @@ func (s *Service) retainedPrefixCheckpoint(ctx context.Context, authKeyID [8]byt
} else if checkpoint.Date == 0 {
checkpoint.Date = current.Date
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, checkpoint); err != nil {
return domain.UpdateDifference{}, false, err
}
return domain.UpdateDifference{State: checkpoint, Partial: true}, true, nil
}
func clampDifferenceState(from, current domain.UpdateState) domain.UpdateState {
if from.Pts < 0 {
from.Pts = 0
}
if from.Pts > current.Pts {
from.Pts = current.Pts
}
from.Seq = 0
return from
}
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
current, err := s.currentPts(ctx, userID)
if err != nil {

View file

@ -490,11 +490,10 @@ func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
}
}
// TestAcknowledgeCurrentStateAdvancesConfirmedWatermark 验证 updates.getState
// 的语义:返回账号当前最新连续 pts而非设备旧确认水位并把确认水位推进
// 到此——TDesktop 不持久化 pts启动靠 getState+getDialogs 快照对齐,返回旧
// 水位会诱导其重放快照前差分未读重复累计、dialog 预览被旧消息抢占)。
func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
// TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery verifies that
// computing a getState result is side-effect free and that its physically
// delivered baseline advances confirmed+observed atomically.
func TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 11
@ -509,8 +508,8 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
t.Fatalf("append: %v", err)
}
// 设备确认水位停在 pts=1 后账号又推进两格。
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 1}); err != nil {
t.Fatalf("GetDifference: %v", err)
if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
t.Fatalf("seed confirmed state: %v", err)
}
for pts := 2; pts <= 3; pts++ {
if err := events.Append(ctx, userID, domain.UpdateEvent{
@ -521,23 +520,33 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
}
}
st, err := svc.AcknowledgeCurrentState(ctx, authKeyID, userID)
st, err := svc.CurrentState(ctx, userID)
if err != nil {
t.Fatalf("AcknowledgeCurrentState: %v", err)
t.Fatalf("CurrentState: %v", err)
}
if st.Pts != 3 {
t.Fatalf("acknowledged state pts = %d, want account current 3", st.Pts)
t.Fatalf("current state pts = %d, want account current 3", st.Pts)
}
confirmed, err := svc.GetState(ctx, authKeyID, userID)
confirmed, _, err := svc.ConfirmedState(ctx, authKeyID, userID)
if err != nil {
t.Fatalf("GetState after acknowledge: %v", err)
t.Fatalf("ConfirmedState before delivery: %v", err)
}
if confirmed.Pts != 3 {
t.Fatalf("confirmed watermark = %d, want advanced to 3", confirmed.Pts)
if confirmed.Pts != 1 {
t.Fatalf("confirmed before delivery = %d, want 1", confirmed.Pts)
}
if _, ok := states.ObservedClientState(authKeyID, userID); ok {
t.Fatal("computed getState unexpectedly advanced observed")
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, st, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
t.Fatalf("CommitDeliveredState: %v", err)
}
confirmed, _, err = svc.ConfirmedState(ctx, authKeyID, userID)
if err != nil || confirmed.Pts != 3 {
t.Fatalf("confirmed after delivery = %+v err=%v, want pts=3", confirmed, err)
}
observed, ok := states.ObservedClientState(authKeyID, userID)
if !ok || observed.Pts != 3 {
t.Fatalf("getState observed watermark = %+v/%v, want pts=3", observed, ok)
t.Fatalf("observed after delivered baseline = %+v/%v, want pts=3", observed, ok)
}
}
@ -559,7 +568,11 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
// 服务端把 pts=1..2 放进 response并不证明客户端收到了 responseobserved 只能
// 保持在本次 request 实际携带的 pts=0。
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100})
from, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100})
if err != nil {
t.Fatalf("observe first request: %v", err)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, from)
if err != nil {
t.Fatalf("first difference: %v", err)
}
@ -570,10 +583,23 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
if !ok || observed.Pts != 0 {
t.Fatalf("observed after merely sending response = %+v/%v, want pts=0", observed, ok)
}
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
t.Fatalf("computed response advanced confirmed: found=%v err=%v", found, err)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered difference: %v", err)
}
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
t.Fatalf("confirmed after delivery = %+v/%v err=%v, want pts=2", confirmed, found, err)
}
observed, _ = states.ObservedClientState(authKeyID, userID)
if observed.Pts != 0 {
t.Fatalf("delivered difference advanced observed to %d, want 0", observed.Pts)
}
// 客户端下一次明确带回 pts=2 后,才允许 retention 把共同安全水位推进到 2。
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil {
t.Fatalf("confirming difference: %v", err)
if _, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil {
t.Fatalf("observing next request: %v", err)
}
observed, ok = states.ObservedClientState(authKeyID, userID)
if !ok || observed.Pts != 2 {
@ -624,6 +650,15 @@ func TestGetDifferenceBelowRetainedFloorUsesEmptySliceCheckpoint(t *testing.T) {
if !checkpoint.Partial || len(checkpoint.Events) != 0 || checkpoint.State.Pts != 2 || checkpoint.State.Date != 1700000202 {
t.Fatalf("checkpoint difference = %+v, want empty differenceSlice at pts/date 2/1700000202", checkpoint)
}
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
t.Fatalf("computed checkpoint advanced confirmed: found=%v err=%v", found, err)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, checkpoint.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered checkpoint: %v", err)
}
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
t.Fatalf("confirmed checkpoint = %+v/%v err=%v, want pts=2", confirmed, found, err)
}
tail, err := svc.GetDifference(ctx, authKeyID, userID, checkpoint.State)
if err != nil {
@ -722,6 +757,10 @@ func (s *captureStateStore) Save(_ context.Context, authKeyID [8]byte, userID in
return nil
}
func (s *captureStateStore) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState, _ domain.UpdateStateCommitMode) error {
return s.Save(ctx, authKeyID, userID, state)
}
func (s *captureStateStore) ObserveClientState(_ context.Context, _ [8]byte, _ int64, _ domain.UpdateState) error {
return nil
}

View file

@ -0,0 +1,38 @@
package android
import (
"errors"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tlprofile"
)
var ErrPrivateLayerRPCInvalid = errors.New("android private layer RPC is invalid")
// AdaptPrivateLayerRPC invokes the provenance-locked static gotdgen overlay
// from the generated unknown-method view. Nested values decode with the exact
// connection profile and the canonical request is re-profiled by gotd core.
func AdaptPrivateLayerRPC(view tlprofile.UnknownMethodView) (tlprofile.OutboundCall, bool, error) {
outbound, handled, err := view.AdaptClientRPCOverlay(tlprofile.ClientRPCOverlayDrkloAndroid)
if err == nil && !handled {
outbound, handled, err = view.AdaptClientRPCOverlay(tlprofile.ClientRPCOverlayDrkloAndroidTheme)
}
if err != nil {
return tlprofile.OutboundCall{}, handled, errors.Join(ErrPrivateLayerRPCInvalid, err)
}
return outbound, handled, nil
}
// UpgradePrivateLayerRPC is retained only for Router.Dispatch's legacy test
// seam. Production admission uses AdaptPrivateLayerRPC above so its decode
// shares the outer generated request budget.
func UpgradePrivateLayerRPC(profile tlprofile.Profile, in *bin.Buffer, limits tlprofile.Limits) (*bin.Buffer, bool, error) {
upgraded, handled, err := tlprofile.AdaptClientRPCOverlayWithLimits(profile, tlprofile.ClientRPCOverlayDrkloAndroid, in, limits)
if err == nil && !handled {
upgraded, handled, err = tlprofile.AdaptClientRPCOverlayWithLimits(profile, tlprofile.ClientRPCOverlayDrkloAndroidTheme, in, limits)
}
if err != nil {
return nil, handled, errors.Join(ErrPrivateLayerRPCInvalid, err)
}
return upgraded, handled, nil
}

View file

@ -0,0 +1,54 @@
package android
import (
"errors"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tlprofile"
)
func TestUpgradePrivateLayerRPCOnlyAcceptsAuditedAndroidConstructors(t *testing.T) {
// DrKLO messages.forwardMessages private CRC has a body identical to the
// canonical request for the flags=0 empty-vector case.
private := bin.Buffer{}
private.PutID(0x41d41ade)
private.PutInt(0)
private.PutID(0x7f3b18ea) // inputPeerEmpty
private.PutVectorHeader(0)
private.PutVectorHeader(0)
private.PutID(0x7f3b18ea) // inputPeerEmpty
in := &bin.Buffer{Buf: private.Copy()}
upgraded, ok, err := UpgradePrivateLayerRPC(tlprofile.ProfileCanonical, in, tlprofile.Limits{})
if err != nil || !ok {
t.Fatalf("upgrade private method = ok:%v err:%v", ok, err)
}
if in.Len() != 0 {
t.Fatalf("successful private method left %d bytes", in.Len())
}
if id, peekErr := upgraded.PeekID(); peekErr != nil || id != 0x13704a7c {
t.Fatalf("canonical id = %#x err=%v", id, peekErr)
}
official := bin.Buffer{}
official.PutID(0xb921bd04) // arbitrary non-private/official constructor
if value, handled, err := UpgradePrivateLayerRPC(tlprofile.ProfileCanonical, &official, tlprofile.Limits{}); value != nil || handled || err != nil {
t.Fatalf("non-private method = value:%v handled:%v err:%v", value, handled, err)
}
}
func TestGeneratedPrivateLayerRPCOverlayHasAllAuditedMethods(t *testing.T) {
if got, want := tlprofile.ClientRPCOverlayMethodCount(tlprofile.ClientRPCOverlayDrkloAndroid), 15; got != want {
t.Fatalf("generated DrKLO method count = %d, want %d", got, want)
}
}
func TestUpgradePrivateLayerRPCRejectsMalformedBody(t *testing.T) {
malformed := bin.Buffer{}
malformed.PutID(0x41d41ade)
_, ok, err := UpgradePrivateLayerRPC(tlprofile.ProfileCanonical, &malformed, tlprofile.Limits{})
if !ok || !errors.Is(err, ErrPrivateLayerRPCInvalid) {
t.Fatalf("malformed private method = ok:%v err:%v", ok, err)
}
}

View file

@ -1,6 +1,6 @@
package ios
import "github.com/gotd/td/tg"
import "github.com/iamxvbaba/td/tg"
// NoAppUpdate is the bounded answer used until telesrv has an application
// release catalog. It makes iOS keep its installed build and retry on its

View file

@ -1,142 +0,0 @@
# layerwire 操作手册(多 Layer 向后兼容)
> 本文是 **怎么操作**runbook。**为什么这么设计**见 [`docs/layer-compat-220-227-design.md`](../../../docs/layer-compat-220-227-design.md)。
> 改这个包前请先读完本文 + 设计文档。所有命令都从 **telesrv 模块根目录**执行。
## 这个包是干什么的
让 telesrv 同时正确服务 **Layer 220227** 的客户端,而业务 handler / gotd 永远只跑 canonical(227)、一行不改。
- **出站**:把 227 对象降级成老客户端能解的 wire 形态(`Transcode`)。
- **入站**:把老客户端发来的旧构造器升级成 227 请求,再交正常 gotd dispatcher`UpgradeInbound`)。
两个正交维度(**务必分清**
- **官方层漂移**:构造器在 layer N 的字段/CRC 与 227 不同。真值=官方 TDesktop `api.tl` 各层。**自动从 schema 生成**。
- **客户端构造器漂移**:某客户端(如 DrKLO Android手维护的 TL 实际发了个旧 layer 的官方构造器,但它声明的整体 layer 却是新的。真值=该客户端源码TLRPC.java。**声明在 `client-drift.tl` / `client_aliases.go`**。
## 文件地图
| 文件 | 角色 | 谁改 |
|---|---|---|
| `schema/canonical-227.tl` | **embed**,运行期 walker 的 227 字段布局(= gotd `td/_schema/tdesktop.tl` 的副本) | gotd 升级时 re-sync |
| `_schema/layer-2NN.tl` | 历史层官方 schema从 TDesktop git 抽,**仅生成期用**,下划线=不编译/不 embed | 升级/下探 floor 时抽取 |
| `schema/client-drift.tl` | **声明式**客户端发的旧构造器老布局body 与 227 不同的) | 发现客户端漂移时 +1 行 |
| `schema/routable-compat.tl` | **仅结构预检**:已有 RPC fallback adapter 的非 canonical wire 布局(当前只含 4 个 DrKLO theme 构造器);与 canonical 图合并后完整 walk但不自动升级 | 收敛既有手写 adapter 时维护,禁止借此新增业务 fallback |
| `client_aliases.go` | 客户端漂移里 **body 与 227 字节一致**的,纯 `老CRC→227CRC` | 发现纯换 CRC 漂移时 +1 条 |
| `tables_gen.go` | **生成产物**(勿手改):官方层降级表 + 入站升级表 + 新类型集 | 跑 `gen` 重生成 |
| `gen/main.go` | 生成器:对拍 schema、证明机械性、产 `tables_gen.go` | 升级逻辑变更时 |
| `layout.go` `walk.go` `tables.go` | 通用解释器(读/丈量/递归转码)| 核心,少动 |
| `fallback.go` | 出站手写兜底(结构性 / 227-only 类型)| CoverageGate 报缺时 |
| `inbound.go` | 入站通用升级引擎 + `fieldConverters` + `driftFieldRenames` | DriftCoverage 报缺时 |
## 核心命令
```bash
# 复核 schema 差异数字(不改文件)
go run ./internal/compat/layerwire/gen -report
# 重新生成 tables_gen.go官方层漂移表
go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go
# 全部护栏(漂移门禁 + 对各历史层真实 schema 对拍 + 性能基准)
go test ./internal/compat/layerwire/
go test ./internal/compat/layerwire/ -run '^$' -bench . -benchmem # 性能
# 改完务必:
gofmt -w internal/compat/layerwire/ && go build ./... && go vet ./internal/...
```
---
## 操作 1gotd 升级canonical layer 上移,例 227 → 230
> gotd bump 是显式任务(见 AGENTS.md 铁律 #6。canonical schema 随之变化,按下列步骤同步。
1. **同步 canonical schema**gotd 的就是实际编出的字节):
```bash
cp ../td/_schema/tdesktop.tl internal/compat/layerwire/schema/canonical-230.tl
rm internal/compat/layerwire/schema/canonical-227.tl
```
`layout.go``//go:embed schema/canonical-230.tl``const CanonicalLayer = 230`
2. **把原 canonical 层并入历史 TO 层**:现在 227/228/229 成了"老层",从 TDesktop git 抽进 `_schema/`(见文末「抽取 api.tl@N」)。
3. **改生成期常量**`gen/main.go``canonicalLayer = 230`。(`supportedFloor` 不变。)
4. **重生成 + 复核**
```bash
go run ./internal/compat/layerwire/gen -report # 看 changed/new 数字是否合理
go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go
```
5. **跑护栏、按报告 triage**
```bash
go test ./internal/compat/layerwire/
```
- `TestCoverageGate` 失败 = 出现了 telesrv 可达但没处理的 227(新 canonical)-only / 结构性类型 → 去 `fallback.go` 加 by-type 兜底或结构性转换,或确认 telesrv 不发就加进 `unemittedAllowlist``gate_test.go`,附理由)。
- 生成器 `-report` 里 "structural" 列出的需手写转换(参照 `fallback.go transcodePollAnswerVoters`)。
6. `gofmt`/`build`/`vet`/全量 `go test`。真机 220/老层/新层各一台回归。
## 操作 2下探 floor支持更老客户端例 220 → 215
1. 从 TDesktop git 抽 `layer-215.tl … layer-219.tl``_schema/`(见文末)。
2. 改 `supportedFloor``layout.go``SupportedFloor = 215` **和** `gen/main.go``supportedFloor = 215`(两处都要)。
3. `go run ... -emit ...` 重生成 → `go test`
4. 越老的层结构性差异越多,按 `TestCoverageGate` / 生成器 report triage同操作 1 第 5 步)。
## 操作 3新增「客户端构造器漂移」最常见
触发:某客户端发的旧构造器导致 `NOT_IMPLEMENTED`(入站)或对端渲染异常;或主动审计客户端源码发现它发旧 CRC。
1. **拿到老构造器的精确 TL 定义**
- 优先看该客户端源码的序列化DrKLO Android`TMessagesProj/.../TLRPC.java``serializeToStream`,按 `writeInt32/writeString/...` 顺序还原字段)。
- 或它是某旧 layer 官方构造器:`git -C ../tdesktop/tdesktop log -S"#<crc>" -- <api.tl>` 找到所在层,再取该层定义。
2. **判断 body 是否与 227 字节一致**
- **一致**(只是 CRC 不同;典型=227 只追加了 flag-gated 可选字段而客户端不设)→ 往 `client_aliases.go clientMethodAliases``0x<老CRC>: 0x<227CRC>`
- **不一致**(缺 flags 整数 / 字段类型变了 / 缺必填字段)→ 往 `schema/client-drift.tl` 加**一行老布局 TL**(用 method 的限定名,结果类型随便填合法值,引擎只按名字匹配 227
3. **跑测试**
```bash
go test ./internal/compat/layerwire/ -run TestInbound
```
- 绿 = 通用引擎已能自动升级(复制共享字段 + 插 flags=0 + 按 kind 补默认)。**完事**。
- `TestInboundDriftCoverage``needs converter A->B` = 有字段类型变更 → 往 `inbound.go fieldConverters` 加一条 `"A->B"`(可复用,参照 `Vector<int>->Vector<InputMessage>`)。
- 报 `field X not defaultable` 或字段**改名** → 往 `inbound.go driftFieldRenames``"<method>\x00<227字段>": "<老字段>"`(参照 `bots.exportBotToken\x00bot`)。
4. **绝不**为此写一个新的 `handleLegacyXxx` 解码 handler——统一走数据 + 通用引擎。`routable-compat.tl` 只给既存 DrKLO theme fallback 补 dispatcher 前结构门禁,不是新增 adapter 的入口。
## 操作 4出站 `TestCoverageGate` 失败
说明 telesrv 现在会发某个"经保留字段可达"的 227-only / 结构性类型,但没处理。
- 该类**有同抽象类的老成员**可降级 → `fallback.go``newTypeFallbacksByType["<抽象类>"]`(如 `PageBlock→pageBlockUnsupported`)。
- 是**结构性变更类型**且 telesrv 真发 → `fallback.go structuralTransforms` 加手写转换。
- **确认 telesrv 不发** → 加进 `gate_test.go unemittedAllowlist`**必须附理由**,引用出站构造器审计)。
---
## 护栏:每个测试拦什么
| 测试 | 拦截 |
|---|---|
| `TestWalkConsumesCanonicalObjects` | 解释器读不全某个 227 类型(字段布局漏) |
| `TestTranscodeDowngradeValid` | 降级输出对 220..226 **真实 schema** 解析失败/有残留字节 |
| `TestTranscodeChangedTypeNestedInUnchangedContainer` | 「外层 CRC 不变但内含变更类型」被误整段拷贝 |
| `TestCoverageGate` | 出站 227-only/结构性类型无 handler 又不在 allowlistgotd bump/客户端升级引入新形态时报) |
| `TestInboundDriftCoverage` | `client-drift.tl` 某条目无法自动升级(缺 converter/rename |
| `TestInboundBodyTransforms` / `TestInboundCRCSwaps` | 入站升级产出不是合法 227 请求 |
| `TestNegotiatedLayerStickyContract` | layer 协商的 `(layer, ok)` 契约(避免缓存驱逐把老客户端误降回 227 |
**运行期 fail-safe**:出站遇未处理类型 → `Transcode` 返错 → 边界记日志并发 canonical 字节(连接存活,单对象可能渲染异常)。入站遇未覆盖旧 CRC → 落 gotd dispatcher → `NOT_IMPLEMENTED`(须按 AGENTS.md #5 进 compatibility trace + 矩阵)。**护栏的意义就是把这些从"线上撞见"提前到"提交期/测试期发现"。**
## 抽取 api.tl@N(操作 1/2 用)
```bash
TD=../tdesktop/tdesktop
APITL=Telegram/SourceFiles/mtproto/scheme/api.tl
# 找 layer N 的提交(取最后一个写入 "// LAYER N" 的;可能有初版+修订,选最全的)
git -C "$TD" log --oneline -S"// LAYER N" -- "$APITL"
# 抽取(务必校验文件末尾确是 "// LAYER N"
git -C "$TD" show <commit>:"$APITL" > internal/compat/layerwire/_schema/layer-N.tl
tail -1 internal/compat/layerwire/_schema/layer-N.tl # 应为: // LAYER N
```
各层→commit 对照见设计文档 §3 表220..227 的 canonical 抽取点)。`gotd/tl` 解析器能直接吃 TDesktop api.tl无需改格式。
## 稳态心法
**喂新 schemagotd 或更老层)→ 跑 `gen` + `go test` → 护栏吐出短清单 → 人只处理新出现的 fallback / 结构性 / converter / rename。** 不再有"运行时撞 NOT_IMPLEMENTED 再手写 handler"。

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,83 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func benchEncode(o bin.Encoder) []byte {
var b bin.Buffer
if err := o.Encode(&b); err != nil {
panic(err)
}
return b.Copy()
}
// BenchmarkTranscodeOutbound measures the outbound seam: the 227 passthrough
// (the overwhelmingly common case) vs a real message downgrade to 220.
func BenchmarkTranscodeOutbound(b *testing.B) {
richMessage := canonicalCorpus()[1]
raw := benchEncode(richMessage)
b.Run("identity_227", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(raw, 227); err != nil {
b.Fatal(err)
}
}
})
b.Run("downgrade_220_message", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(raw, 220); err != nil {
b.Fatal(err)
}
}
})
dialogs := benchEncode(canonicalCorpus()[11]) // messages.dialogs
b.Run("downgrade_220_dialogs", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(dialogs, 220); err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkUpgradeInbound measures the inbound seam: a 227 client (miss, the
// common case), a CRC-swap drift, and a body-transform drift.
func BenchmarkUpgradeInbound(b *testing.B) {
b.Run("miss_227", func(b *testing.B) {
// A canonical method id that needs no upgrade.
body := benchEncode(&tg.HelpGetConfigRequest{})
b.ReportAllocs()
for i := 0; i < b.N; i++ {
in := &bin.Buffer{Buf: body}
id, _ := in.PeekID()
if _, ok, _ := UpgradeInbound(id, in); ok {
b.Fatal("unexpected upgrade")
}
}
})
// uploadMedia body transform (peer+media -> flags+peer+media).
var um bin.Buffer
um.PutID(0x519bc2b1)
_ = (&tg.InputPeerSelf{}).Encode(&um)
_ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&um)
umRaw := um.Copy()
b.Run("drift_uploadMedia", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
in := &bin.Buffer{Buf: append([]byte(nil), umRaw...)}
if _, ok, err := UpgradeInbound(0x519bc2b1, in); !ok || err != nil {
b.Fatal(ok, err)
}
}
})
}

View file

@ -1,36 +0,0 @@
package layerwire
// clientMethodAliases maps method constructor ids emitted by a specific client's
// hand-maintained TL (constructor *drift*, NOT official api.tl layer drift) to
// the canonical (227) id — for the subset whose request body is byte-identical
// to canonical so a 4-byte id swap suffices.
//
// This is the second, hand-maintained half of the inbound compat table; the
// generated inboundMethodUpgrades (tables_gen.go) covers official layer drift
// derived from TDesktop api.tl, which by construction never contains these
// client-private ids. Entries here are sourced from client source (e.g. DrKLO
// TLRPC.java), each verified body-compatible against the canonical layout.
//
// Client-drift constructors whose body differs structurally (a missing flags
// integer, a different field type, or that need business logic such as
// access_hash resolution or a legacy-shaped response) are NOT here — they remain
// dedicated decode handlers in internal/rpc (dispatchCompat), because the body
// cannot be reused as-is and the transform needs more than an id swap.
var clientMethodAliases = map[uint32]uint32{
// DrKLO Android (post-Layer225) messages.forwardMessages. Wire layout is
// identical to canonical #13704a7c for every flag bit the client can set
// (the only schema delta is flags it never sets), so the body decodes as-is.
0x41d41ade: 0x13704a7c,
// DrKLO Android channels.inviteToChannel. Body is still
// channel:InputChannel users:Vector<InputUser> = canonical #c9e33d54.
0x199f3a6c: 0xc9e33d54,
// DrKLO Android updates.getDifference. Old layout only uses flags.0
// (pts_total_limit); canonical #19c2f763 adds pts_limit(flags.1)/
// qts_limit(flags.2) which the client leaves clear ⇒ zero wire bytes, so the
// old body decodes byte-for-byte as canonical.
0x25939651: 0x19c2f763,
// DrKLO Android messages.createChat. Body is byte-identical to canonical
// #92ceddd4; the legacy-shaped response is produced by ClientType==Android
// (createChatNeedsLegacyChat), so no dedicated handler is needed.
0x0034a818: 0x92ceddd4,
}

View file

@ -1,126 +0,0 @@
package layerwire
import (
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// canonicalCorpus is a diverse set of canonical (gotd, Layer 227) objects shared
// by the walker and transcoder tests. It deliberately exercises changed types
// (message, messageMediaPhoto, keyboardButton*, dialog, channelFull, userFull,
// pollResults/pollAnswerVoters), nested containers, vectors, and multi-flags.
func canonicalCorpus() []bin.Encoder {
photo := &tg.Photo{
ID: 10,
AccessHash: 11,
FileReference: []byte{1, 2, 3},
Date: 100,
Sizes: []tg.PhotoSizeClass{
&tg.PhotoSize{Type: "x", W: 100, H: 100, Size: 2048},
&tg.PhotoStrippedSize{Type: "i", Bytes: []byte{9, 8, 7}},
},
DCID: 2,
}
return []bin.Encoder{
&tg.Message{ID: 1, PeerID: &tg.PeerUser{UserID: 2}, Date: 100, Message: "hi"},
&tg.Message{
Out: true,
ID: 2,
FromID: &tg.PeerUser{UserID: 3},
PeerID: &tg.PeerChannel{ChannelID: 4},
Date: 101,
Message: "rich",
Media: &tg.MessageMediaPhoto{Photo: photo, TTLSeconds: 5},
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 2},
&tg.MessageEntityTextURL{Offset: 0, Length: 2, URL: "https://x"},
},
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{Text: "ok", Data: []byte("d")},
&tg.KeyboardButtonURL{Text: "go", URL: "https://y"},
}},
}},
ReplyTo: &tg.MessageReplyHeader{ReplyToMsgID: 1},
FwdFrom: tg.MessageFwdHeader{FromName: "n", Date: 99},
Views: 7,
Forwards: 2,
Reactions: tg.MessageReactions{Results: []tg.ReactionCount{{Reaction: &tg.ReactionEmoji{Emoticon: "👍"}, Count: 3}}},
GroupedID: 555,
},
&tg.MessageService{
ID: 3,
PeerID: &tg.PeerUser{UserID: 2},
Date: 102,
Action: &tg.MessageActionChatEditTitle{Title: "t"},
},
&tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdateNewMessage{Message: &tg.Message{ID: 9, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "u"}, Pts: 1, PtsCount: 1},
&tg.UpdateMessageID{ID: 9, RandomID: 123},
},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
Chats: []tg.ChatClass{&tg.Channel{ID: 4, AccessHash: 6, Title: "C", Photo: &tg.ChatPhotoEmpty{}}},
Date: 100,
Seq: 1,
},
&tg.User{
ID: 2,
AccessHash: 5,
FirstName: "A",
Username: "a",
Photo: &tg.UserProfilePhoto{PhotoID: 7, DCID: 2},
Status: &tg.UserStatusOnline{Expires: 999},
},
&tg.UserFull{
ID: 2,
About: "hi",
Settings: tg.PeerSettings{},
NotifySettings: tg.PeerNotifySettings{},
CommonChatsCount: 0,
},
&tg.Channel{ID: 4, AccessHash: 6, Title: "C", Megagroup: true, Photo: &tg.ChatPhotoEmpty{}},
&tg.ChannelFull{
ID: 4,
About: "about",
ReadInboxMaxID: 1,
ReadOutboxMaxID: 1,
UnreadCount: 0,
ChatPhoto: &tg.PhotoEmpty{ID: 0},
NotifySettings: tg.PeerNotifySettings{},
Pts: 1,
},
&tg.Dialog{
Peer: &tg.PeerUser{UserID: 2},
TopMessage: 2,
ReadInboxMaxID: 1,
NotifySettings: tg.PeerNotifySettings{},
},
&tg.Poll{
ID: 1,
Question: tg.TextWithEntities{Text: "q?"},
Answers: []tg.PollAnswerClass{
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "a"}, Option: []byte{0}},
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "b"}, Option: []byte{1}},
},
},
&tg.PollResults{
Results: []tg.PollAnswerVoters{
{Option: []byte{0}, Voters: 3, Chosen: true},
{Option: []byte{1}, Voters: 1},
},
TotalVoters: 4,
},
&tg.MessagesDialogs{
Dialogs: []tg.DialogClass{&tg.Dialog{Peer: &tg.PeerUser{UserID: 2}, TopMessage: 2, NotifySettings: tg.PeerNotifySettings{}}},
Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
},
&tg.MessagesMessages{
Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
},
}
}

View file

@ -1,144 +0,0 @@
package layerwire
import "github.com/gotd/td/bin"
// Hand-written transforms for the message subtree: structural changes that are
// not pure field drops, and 227-only constructors that older clients cannot
// decode. By-abstract-type fallbacks auto-cover future variants of the same
// class (e.g. a new MessageAction added in a later layer still degrades to
// messageActionEmpty). See docs/layer-compat-220-227-design.md §5.2.
const (
messageActionEmptyID = 0xb6aef7b0 // messageActionEmpty = MessageAction
messageEntityUnknownID = 0xbb92ba95 // messageEntityUnknown offset:int length:int = MessageEntity
pageBlockUnsupportedID = 0x13567e8a // pageBlockUnsupported = PageBlock
textEmptyID = 0xdc3d824f // textEmpty = RichText
)
func init() {
structuralTransforms["pollAnswerVoters"] = transcodePollAnswerVoters
// 227-only constructors degrade to a class member every supported layer has.
// Each target carries no body, so the replacement is a bare id (a new
// variant inside a Vector keeps its slot — no element drop needed).
newTypeFallbacksByType["MessageAction"] = replaceWithBare(messageActionEmptyID)
newTypeFallbacksByType["PageBlock"] = replaceWithBare(pageBlockUnsupportedID)
newTypeFallbacksByType["RichText"] = replaceWithBare(textEmptyID)
newTypeFallbacksByType["MessageEntity"] = fallbackMessageEntity
}
// replaceWithBare consumes the canonical (227-only) object and emits a
// bodyless constructor id the target layer understands.
func replaceWithBare(id uint32) fallbackFunc {
return func(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
if err := in.ConsumeID(cl.crc); err != nil {
return err
}
if err := walk.skipCtorBody(canonical, in, cl, depth); err != nil {
return err
}
out.PutID(id)
return nil
}
}
// peerVectorField is a synthetic Vector<Peer> layout used to consume the
// canonical recent_voters field.
var peerVectorField = fieldLayout{
kind: kindVector,
flagBit: -1,
elem: &fieldLayout{kind: kindObject, typeName: "Peer", flagBit: -1},
}
var pollOptionBytesField = fieldLayout{kind: kindBytes, flagBit: -1}
// transcodePollAnswerVoters downgrades pollAnswerVoters: canonical (227) made
// voters conditional (flags.2?int) and added recent_voters (flags.2?Vector<Peer>);
// older layers carry voters as a plain int. The leading CRC is already consumed.
//
// 227: flags:# chosen:flags.0?true correct:flags.1?true option:bytes
// voters:flags.2?int recent_voters:flags.2?Vector<Peer>
// <=226: flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int
func transcodePollAnswerVoters(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
flags, err := in.Uint32()
if err != nil {
return err
}
optionStart := in.Buf
if err := walk.skipValue(canonical, in, &pollOptionBytesField, cl, depth); err != nil {
return err
}
optionRaw := optionStart[:len(optionStart)-len(in.Buf)]
var voters int
if flags&(1<<2) != 0 {
if voters, err = in.Int(); err != nil {
return err
}
if err := walk.skipValue(canonical, in, &peerVectorField, cl, depth); err != nil {
return err
}
}
out.PutID(target)
out.PutUint32(flags & 0b11) // retain chosen/correct, clear the moved bit 2
out.Put(optionRaw)
out.PutInt(voters)
return nil
}
// fallbackMessageEntity replaces any 227-only MessageEntity with
// messageEntityUnknown, preserving offset/length so text positions stay valid.
func fallbackMessageEntity(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
id, err := in.PeekID()
if err != nil {
return err
}
if err := in.ConsumeID(id); err != nil {
return err
}
offset, length, err := canonical.decodeOffsetLength(in, cl, depth, walk)
if err != nil {
return err
}
out.PutID(messageEntityUnknownID)
out.PutInt(offset)
out.PutInt(length)
return nil
}
// decodeOffsetLength walks a constructor body (no leading CRC) per the canonical
// layout, returning its offset/length int fields and discarding the rest.
func (m *schemaModel) decodeOffsetLength(in *bin.Buffer, cl *ctorLayout, depth int, walk *walkState) (offset, length int, err error) {
var flags map[string]uint32
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, e := in.Uint32()
if e != nil {
return 0, 0, e
}
if flags == nil {
flags = make(map[string]uint32, 2)
}
flags[f.name] = v
continue
}
if f.conditional() && flags[f.flagName]&(1<<uint(f.flagBit)) == 0 {
continue
}
switch {
case f.kind == kindInt && f.name == "offset":
if offset, err = in.Int(); err != nil {
return
}
case f.kind == kindInt && f.name == "length":
if length, err = in.Int(); err != nil {
return
}
default:
if err = walk.skipValue(m, in, f, cl, depth); err != nil {
return
}
}
}
return
}

View file

@ -1,163 +0,0 @@
package layerwire
import (
"sort"
"strings"
"testing"
)
// isInbound reports whether a constructor is a client->server (Input*) type the
// server never emits, so it cannot appear in downgraded output.
func isInbound(cl *ctorLayout) bool {
return strings.HasPrefix(cl.result, "Input") || strings.HasPrefix(cl.name, "input")
}
// collectReachableTypes returns abstract/bare type names that can appear in
// downgraded output at a layer: those referenced by a *retained* field of a
// constructor that is itself emittable (not a function, not a 227-only type that
// is replaced wholesale, not an inbound Input* type).
func collectReachableTypes(lt *layerTables) map[string]bool {
refs := map[string]bool{}
var addField func(f *fieldLayout)
addField = func(f *fieldLayout) {
switch f.kind {
case kindObject, kindBareObject:
refs[f.typeName] = true
case kindVector, kindVectorBare:
addField(f.elem)
}
}
for crc, cl := range canonical.byCRC {
if cl.isFunc || isInbound(cl) || lt.newTypes[crc] {
continue
}
var keep map[string]bool
if r := lt.rules[crc]; r != nil && r.structural == "" {
keep = r.keep
}
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
continue
}
if keep != nil && !keep[f.name] {
continue // dropped by a mechanical rule
}
addField(f)
}
}
return refs
}
// unemittedAllowlist is the curated set of reachable-but-unhandled 227-only
// constructors that telesrv does not actually emit (confirmed against the
// outbound-constructor scoping audit, 2026-06-25). They live behind features the
// server lacks (instant-view rich pages, AI compose, managed bots, web-browser
// settings, guest chat, star-gift rarity/craft, join-chat bot results). The gate
// fails if a NEW reachable type appears that is neither handled nor listed here,
// forcing a human to triage on every gotd bump / client upgrade.
var unemittedAllowlist = map[string]bool{
"aiComposeTone": true,
"aiComposeToneDefault": true,
"aiComposeToneExample": true,
"botInlineMessageRichMessage": true,
"channelAdminLogEventActionParticipantEditRank": true,
"joinChatBotResultApproved": true,
"joinChatBotResultDeclined": true,
"joinChatBotResultQueued": true,
"joinChatBotResultWebView": true,
"messages.chatInviteJoinResultWebView": true,
"messages.emojiGameDiceInfo": true,
"messages.emojiGameUnavailable": true,
"requestPeerTypeCreateBot": true,
"richMessage": true,
"sendMessageRichMessageDraftAction": true,
"starGiftAttributeRarity": true,
"starGiftAttributeRarityEpic": true,
"starGiftAttributeRarityLegendary": true,
"starGiftAttributeRarityRare": true,
"starGiftAttributeRarityUncommon": true,
"topPeerCategoryBotsGuestChat": true,
"updateAiComposeTones": true,
"updateBotGuestChatQuery": true,
"updateChatParticipantRank": true,
"updateEmojiGameInfo": true,
"updateJoinChatWebViewDecision": true,
"updateManagedBot": true,
"updateNewBotConnection": true,
"updateStarGiftCraftFail": true,
"updateWebBrowserException": true,
"updateWebBrowserSettings": true,
"webDomainException": true,
"webPageAttributeAiComposeTone": true,
// Structural changed-types telesrv does not emit (see design Appendix C and
// the scoping audit); their hand transforms are deferred to CI-todo.
"pageListOrderedItemText": true,
"pageListOrderedItemBlocks": true,
"starGiftAttributeModel": true,
"starGiftAttributeBackdrop": true,
"starGiftAttributePattern": true,
"urlAuthResultAccepted": true,
"inputMediaPoll": true, // inbound only
}
func newTypeHandled(crc uint32, result string) bool {
if newTypeFallbacks[crc] != nil {
return true
}
return newTypeFallbacksByType[result] != nil
}
// TestCoverageGate is the drift gate. For every supported layer, each 227-only
// or structural constructor that can appear in downgraded output must be either
// handled (fallback / structural transform) or explicitly allowlisted as not
// emitted. A bare failure means new wire shape slipped in unhandled.
func TestCoverageGate(t *testing.T) {
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
lt := tables[layer]
if lt == nil {
t.Fatalf("no tables for layer %d", layer)
}
reach := collectReachableTypes(lt)
// Structural rules that are reachable need a registered transform.
for crc, r := range lt.rules {
if r.structural == "" {
continue
}
cl := canonical.byCRC[crc]
reachable := cl != nil && reach[cl.result] && !isInbound(cl)
handled := structuralTransforms[r.structural] != nil
if reachable && !handled && !unemittedAllowlist[nameOf(crc)] {
t.Errorf("layer %d: reachable structural %s (%#08x) has no transform", layer, nameOf(crc), crc)
}
}
// New constructors reachable through a retained field need a fallback.
var gaps []string
for crc := range lt.newTypes {
cl := canonical.byCRC[crc]
if cl == nil || cl.isFunc || isInbound(cl) {
continue
}
if !reach[cl.result] {
continue
}
if newTypeHandled(crc, cl.result) || unemittedAllowlist[cl.name] {
continue
}
gaps = append(gaps, cl.name)
}
if len(gaps) > 0 {
sort.Strings(gaps)
t.Errorf("layer %d: %d reachable 227-only types lack a fallback or allowlist entry:\n %v", layer, len(gaps), gaps)
}
}
}
func nameOf(crc uint32) string {
if cl := canonical.byCRC[crc]; cl != nil {
return cl.name
}
return "?"
}

View file

@ -1,450 +0,0 @@
// Command layerwire-gen diffs the canonical gotd schema (Layer 227, the bytes
// telesrv actually emits) against historical TDesktop api.tl layers (220..226)
// and classifies every per-constructor change as either MECHANICAL (a pure
// append-only delta that can be downgraded by dropping trailing/optional fields
// and masking flag bits) or STRUCTURAL (field reorder / reinterpretation that
// needs a hand-written transform).
//
// It is the generate-time half of the layer-compat design
// (docs/layer-compat-220-227-design.md). Run from the telesrv module root:
//
// go run ./internal/compat/layerwire/gen -report
//
// This first iteration only prints a report so the numbers can be validated
// against the design doc before any table is emitted.
package main
import (
"flag"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/gotd/tl"
)
// canonicalLayer is the layer telesrv's gotd is pinned to.
const canonicalLayer = 227
// supportedFloor is the oldest client layer telesrv aims to serve.
const supportedFloor = 220
// spec is a single TL constructor or method with field-level metadata.
type spec struct {
qname string // qualified name, e.g. "messages.dialogs" or "message"
crc uint32
params []tl.Parameter
isFunc bool
}
// schema indexes one parsed .tl file by qualified name and by CRC.
type schema struct {
layer int
byName map[string]*spec
byCRC map[uint32]*spec
ordered []*spec
}
func qualify(d tl.Definition) string {
if len(d.Namespace) == 0 {
return d.Name
}
return strings.Join(d.Namespace, ".") + "." + d.Name
}
func load(path string) (*schema, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
parsed, err := tl.Parse(f)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
s := &schema{
layer: parsed.Layer,
byName: make(map[string]*spec),
byCRC: make(map[uint32]*spec),
}
for i := range parsed.Definitions {
sd := parsed.Definitions[i]
d := sd.Definition
sp := &spec{
qname: qualify(d),
crc: d.ID,
params: d.Params,
isFunc: sd.Category == tl.CategoryFunction,
}
// Skip the implicit vector pseudo-definition if present.
if sp.qname == "vector" {
continue
}
s.byName[sp.qname] = sp
s.byCRC[sp.crc] = sp
s.ordered = append(s.ordered, sp)
}
return s, nil
}
// classifyResult describes how a changed constructor downgrades from canonical
// (227) to a target layer.
type classifyResult struct {
mechanical bool
drops []string // canonical fields absent at the target layer
reason string // populated when !mechanical
}
// classifyDowngrade aligns the target params as a subsequence (by name) of the
// canonical params. Success ⇒ mechanical drop of the unmatched canonical fields.
// Any name mismatch, type change, or flag-condition change ⇒ structural.
func classifyDowngrade(from, to *spec) classifyResult {
var drops []string
i, j := 0, 0
fp, tp := from.params, to.params
for j < len(tp) {
// Advance over canonical fields until we reach the target field name.
for i < len(fp) && fp[i].Name != tp[j].Name {
drops = append(drops, fp[i].Name)
i++
}
if i == len(fp) {
return classifyResult{reason: fmt.Sprintf("target field %q not found in canonical (reorder/insert)", tp[j].Name)}
}
if reason := compatible(fp[i], tp[j]); reason != "" {
return classifyResult{reason: fmt.Sprintf("field %q: %s", tp[j].Name, reason)}
}
i++
j++
}
for ; i < len(fp); i++ {
drops = append(drops, fp[i].Name)
}
return classifyResult{mechanical: true, drops: drops}
}
// compatible reports "" if a kept field is wire-compatible between canonical and
// target, or a reason string otherwise.
func compatible(f, t tl.Parameter) string {
if f.Flags != t.Flags {
return "flags-int vs field mismatch"
}
if f.Flags {
// Both are `#` flag integers; the name must match because conditional
// fields reference it by name.
if f.Name != t.Name {
return fmt.Sprintf("flags int renamed %q->%q", t.Name, f.Name)
}
return ""
}
// Conditional-ness must match exactly (no flag-bit remap supported yet).
fc, tc := f.Flag != nil, t.Flag != nil
if fc != tc {
return "conditional-ness changed"
}
if fc {
if f.Flag.Name != t.Flag.Name || f.Flag.Index != t.Flag.Index {
return fmt.Sprintf("flag moved %s.%d->%s.%d", t.Flag.Name, t.Flag.Index, f.Flag.Name, f.Flag.Index)
}
}
if f.Type.String() != t.Type.String() {
return fmt.Sprintf("type changed %s->%s", t.Type.String(), f.Type.String())
}
return ""
}
type changed struct {
qname string
fromCRC, toCRC uint32
res classifyResult
}
// diff compares canonical (from) against a single target layer (to).
type diffResult struct {
layer int
changedTypes []changed
changedMethods []changed
newTypes []string // exist in canonical, absent at target
newMethods []string
removedTypes []string // exist at target, absent in canonical
}
func diff(from, to *schema) diffResult {
r := diffResult{layer: to.layer}
for _, sp := range from.ordered {
other, ok := to.byName[sp.qname]
if !ok {
if sp.isFunc {
r.newMethods = append(r.newMethods, sp.qname)
} else {
r.newTypes = append(r.newTypes, sp.qname)
}
continue
}
if other.crc == sp.crc {
continue
}
c := changed{qname: sp.qname, fromCRC: sp.crc, toCRC: other.crc, res: classifyDowngrade(sp, other)}
if sp.isFunc {
r.changedMethods = append(r.changedMethods, c)
} else {
r.changedTypes = append(r.changedTypes, c)
}
}
for _, sp := range to.ordered {
if _, ok := from.byName[sp.qname]; !ok {
r.removedTypes = append(r.removedTypes, sp.qname)
}
}
return r
}
func main() {
var (
schemaDir = flag.String("schema", "internal/compat/layerwire/_schema", "dir with layer-NNN.tl")
canonical = flag.String("canonical", "internal/compat/layerwire/schema/canonical-227.tl", "gotd canonical 227 schema")
emit = flag.String("emit", "", "write generated tables_gen.go to this path")
_ = flag.Bool("report", true, "print report")
)
flag.Parse()
canon, err := load(*canonical)
if err != nil {
fmt.Fprintln(os.Stderr, "load canonical:", err)
os.Exit(1)
}
if *emit != "" {
if err := emitTables(canon, *schemaDir, *emit); err != nil {
fmt.Fprintln(os.Stderr, "emit:", err)
os.Exit(1)
}
fmt.Printf("wrote %s\n", *emit)
return
}
fmt.Printf("canonical (gotd) layer=%d defs=%d\n", canon.layer, len(canon.ordered))
// Per-layer diff + union across the supported range.
unionChangedTypes := map[string]bool{}
unionChangedMethods := map[string]bool{}
unionNewTypes := map[string]bool{}
unionNewMethods := map[string]bool{}
structuralTypes := map[string]string{} // qname -> reason (worst case seen)
for L := supportedFloor; L < canonicalLayer; L++ {
path := filepath.Join(*schemaDir, fmt.Sprintf("layer-%d.tl", L))
tgt, err := load(path)
if err != nil {
fmt.Fprintln(os.Stderr, "load", path, ":", err)
os.Exit(1)
}
r := diff(canon, tgt)
mech, struc := 0, 0
for _, c := range r.changedTypes {
unionChangedTypes[c.qname] = true
if c.res.mechanical {
mech++
} else {
struc++
structuralTypes[c.qname] = c.res.reason
}
}
for _, c := range r.changedMethods {
unionChangedMethods[c.qname] = true
}
for _, n := range r.newTypes {
unionNewTypes[n] = true
}
for _, n := range r.newMethods {
unionNewMethods[n] = true
}
fmt.Printf("layer %d: defs=%d changedTypes=%d (mech=%d struc=%d) changedMethods=%d newTypes=%d newMethods=%d removed=%d\n",
L, len(tgt.ordered), len(r.changedTypes), mech, struc, len(r.changedMethods), len(r.newTypes), len(r.newMethods), len(r.removedTypes))
}
fmt.Printf("\n=== UNION %d..%d vs %d ===\n", supportedFloor, canonicalLayer-1, canonicalLayer)
fmt.Printf("changed types: %d\n", len(unionChangedTypes))
fmt.Printf("changed methods: %d\n", len(unionChangedMethods))
fmt.Printf("new types: %d\n", len(unionNewTypes))
fmt.Printf("new methods: %d\n", len(unionNewMethods))
fmt.Printf("structural types (need hand transform): %d\n", len(structuralTypes))
for _, q := range sortedKeys(structuralTypes) {
fmt.Printf(" - %s : %s\n", q, structuralTypes[q])
}
// Detailed 220-vs-227 drop table (matches design doc Appendix A).
fmt.Printf("\n=== 220 vs 227 changed-type drop table ===\n")
tgt220, _ := load(filepath.Join(*schemaDir, "layer-220.tl"))
r := diff(canon, tgt220)
sort.Slice(r.changedTypes, func(a, b int) bool { return r.changedTypes[a].qname < r.changedTypes[b].qname })
for _, c := range r.changedTypes {
tag := "MECH"
detail := "drop: " + strings.Join(c.res.drops, ", ")
if !c.res.mechanical {
tag = "STRUCT"
detail = c.res.reason
}
fmt.Printf(" [%-6s] %-34s %#08x->%#08x %s\n", tag, c.qname, c.toCRC, c.fromCRC, detail)
}
}
// emitTables writes the runtime downgrade tables (tables_gen.go) for every
// supported layer: per changed constructor a mechanical keep-list or a
// structural marker, plus the set of canonical CRCs absent at that layer.
func emitTables(canon *schema, schemaDir, outPath string) error {
var b strings.Builder
b.WriteString("// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.\n")
b.WriteString("// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.\n\n")
b.WriteString("package layerwire\n\n")
b.WriteString("// generatedTables maps a supported client layer to its canonical(227)->layer\n")
b.WriteString("// downgrade table. See docs/layer-compat-220-227-design.md.\n")
b.WriteString("var generatedTables = map[int]layerRaw{\n")
// inbound 方法升级(扁平:老方法 CRC -> 227 CRC。老 CRC 本身编码了格式,故无需 layer 维度。
// 仅收"升级安全"的方法227 新增字段全为 flag-gated 条件字段(老客户端清零位=零字节,
// 其 body 本就是合法 227 body换 4 字节 CRC 即可交给 227 handler
inboundUpgrades := map[uint32]uint32{} // oldCRC -> 227CRC
inboundUnsafe := map[string]string{} // qname -> reason
for L := supportedFloor; L < canonicalLayer; L++ {
tgt, err := load(filepath.Join(schemaDir, fmt.Sprintf("layer-%d.tl", L)))
if err != nil {
return err
}
r := diff(canon, tgt)
for _, c := range r.changedMethods {
canonSpec := canon.byName[c.qname]
if reason := methodUpgradeSafe(canonSpec, c.res); reason == "" {
inboundUpgrades[c.toCRC] = c.fromCRC // client(old) -> canonical(227)
} else if _, done := inboundUpgrades[c.toCRC]; !done {
inboundUnsafe[c.qname] = reason
}
}
fmt.Fprintf(&b, "\t%d: {\n", L)
sort.Slice(r.changedTypes, func(i, j int) bool { return r.changedTypes[i].fromCRC < r.changedTypes[j].fromCRC })
b.WriteString("\t\trules: map[uint32]ruleRaw{\n")
for _, c := range r.changedTypes {
canonSpec := canon.byName[c.qname]
if c.res.mechanical {
dropSet := map[string]bool{}
for _, d := range c.res.drops {
dropSet[d] = true
}
var keep []string
for _, p := range canonSpec.params {
if !dropSet[p.Name] {
keep = append(keep, p.Name)
}
}
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, keep: %s}, // %s\n", c.fromCRC, c.toCRC, goStrSlice(keep), c.qname)
} else {
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, structural: %q}, // %s\n", c.fromCRC, c.toCRC, c.qname, c.res.reason)
}
}
b.WriteString("\t\t},\n")
var newCRC []uint32
for _, q := range r.newTypes {
if sp := canon.byName[q]; sp != nil {
newCRC = append(newCRC, sp.crc)
}
}
sort.Slice(newCRC, func(i, j int) bool { return newCRC[i] < newCRC[j] })
b.WriteString("\t\tnewTypes: []uint32{")
for i, c := range newCRC {
if i%6 == 0 {
b.WriteString("\n\t\t\t")
}
fmt.Fprintf(&b, "0x%08x, ", c)
}
if len(newCRC) > 0 {
b.WriteString("\n\t\t")
}
b.WriteString("},\n")
b.WriteString("\t},\n")
}
b.WriteString("}\n\n")
// Flat inbound method CRC upgrade table.
b.WriteString("// inboundMethodUpgrades maps an old client method constructor id to the\n")
b.WriteString("// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)\n")
b.WriteString("// are listed: rewriting the 4-byte id yields a valid 227 request body.\n")
if len(inboundUnsafe) > 0 {
b.WriteString("// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):\n")
for _, q := range sortedKeys(inboundUnsafe) {
fmt.Fprintf(&b, "// %s: %s\n", q, inboundUnsafe[q])
}
}
b.WriteString("var inboundMethodUpgrades = map[uint32]uint32{\n")
oldCRCs := make([]uint32, 0, len(inboundUpgrades))
for old := range inboundUpgrades {
oldCRCs = append(oldCRCs, old)
}
sort.Slice(oldCRCs, func(i, j int) bool { return oldCRCs[i] < oldCRCs[j] })
for _, old := range oldCRCs {
fmt.Fprintf(&b, "\t0x%08x: 0x%08x, // %s\n", old, inboundUpgrades[old], canon.byCRC[inboundUpgrades[old]].qname)
}
b.WriteString("}\n")
formatted, err := format.Source([]byte(b.String()))
if err != nil {
_ = os.WriteFile(outPath, []byte(b.String()), 0o644)
return fmt.Errorf("gofmt: %w", err)
}
return os.WriteFile(outPath, formatted, 0o644)
}
// methodUpgradeSafe reports "" if a layer-N request body for a changed method
// is also a valid 227 body after only swapping the constructor id — i.e. the
// downgrade is mechanical and every 227-only field is flag-gated (a conditional
// field the old client leaves clear ⇒ zero wire bytes). A 227-only non-conditional
// field or an inserted flags integer breaks the byte alignment ⇒ unsafe.
func methodUpgradeSafe(canonSpec *spec, res classifyResult) string {
if !res.mechanical {
return res.reason
}
byName := map[string]tl.Parameter{}
for _, p := range canonSpec.params {
byName[p.Name] = p
}
for _, d := range res.drops {
p, ok := byName[d]
if !ok {
return fmt.Sprintf("dropped field %q not in canonical", d)
}
if p.Flags {
return fmt.Sprintf("227 inserts flags integer %q", d)
}
if p.Flag == nil {
return fmt.Sprintf("227-only field %q is non-conditional", d)
}
}
return ""
}
func goStrSlice(ss []string) string {
var b strings.Builder
b.WriteString("[]string{")
for i, s := range ss {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(strconv.Quote(s))
}
b.WriteString("}")
return b.String()
}
func sortedKeys[V any](m map[string]V) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}

View file

@ -1,326 +0,0 @@
package layerwire
import (
_ "embed"
"encoding/binary"
"fmt"
"github.com/gotd/td/bin"
)
// Canonical ids used to synthesize converted/defaulted values.
const (
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
inputMessageID = 0xa676a322 // inputMessageID id:int
inputChannelEmptyID = 0xee8c1e86 // inputChannelEmpty
inputChannelID = 0xf35aec28 // inputChannel channel_id:long access_hash:long
inputChannelFromMessageID = 0x5b934f9d // inputChannelFromMessage peer:InputPeer msg_id:int channel_id:long
inputPeerEmptyID = 0x7f3b18ea // inputPeerEmpty
inputPeerChannelID = 0x27bcbbfc // inputPeerChannel channel_id:long access_hash:long
inputPeerChannelFromMessageID = 0xbd2a0840 // inputPeerChannelFromMessage peer:InputPeer msg_id:int channel_id:long
boolFalseID = 0xbc799737 // boolFalse
)
//go:embed schema/client-drift.tl
var clientDriftSchema string
// driftModel holds the declared old-layout of each client-drift constructor.
var driftModel = mustLoadDrift()
func mustLoadDrift() *schemaModel {
m, err := parseSchemaModel(clientDriftSchema)
if err != nil {
panic("layerwire: parse client-drift schema: " + err.Error())
}
return m
}
// driftFieldRenames maps a canonical field that was renamed from the client's
// old constructor: key "<qualified method>\x00<canonical field>" -> old field.
// Pure schema diff cannot recover a rename, so it is declared here (data, not a
// transform). It is the only thing a structural rename needs.
var driftFieldRenames = map[string]string{
"bots.exportBotToken\x00bot": "bot_id",
"messages.editChatCreator\x00peer": "channel",
}
// fieldConverter rewrites one field whose wire type changed between the old and
// canonical layout. Keyed by "<oldTypeSig>-><newTypeSig>"; raw is the old field's
// encoded bytes. Reusable across any method with the same type change.
type fieldConverter func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error
var fieldConverters = map[string]fieldConverter{
// id:Vector<int> -> id:Vector<InputMessage> (wrap each int in inputMessageID).
"Vector<int>->Vector<InputMessage>": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
n, err := in.VectorHeader()
if err != nil {
return err
}
if max := walk.vectorLimit(owner, field); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(field), n, max)
}
out.PutVectorHeader(n)
for i := 0; i < n; i++ {
v, err := in.Int()
if err != nil {
return err
}
out.PutID(inputMessageID)
out.PutInt(v)
}
if in.Len() != 0 {
return malformedf("%d trailing bytes in Vector<int> converter", in.Len())
}
return nil
},
// bot_id:long -> bot:InputUser{user_id, access_hash=0}.
"long->InputUser": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
id, err := in.Long()
if err != nil {
return err
}
out.PutID(inputUserID)
out.PutLong(id)
out.PutLong(0)
if in.Len() != 0 {
return malformedf("%d trailing bytes in long converter", in.Len())
}
return nil
},
// channel:InputChannel -> peer:InputPeer for the old channels.editCreator
// Android constructor. Concrete layouts are otherwise byte-compatible.
"InputChannel->InputPeer": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
id, err := in.ID()
if err != nil {
return err
}
switch id {
case inputChannelEmptyID:
out.PutID(inputPeerEmptyID)
case inputChannelID:
out.PutID(inputPeerChannelID)
out.Put(in.Buf)
case inputChannelFromMessageID:
out.PutID(inputPeerChannelFromMessageID)
out.Put(in.Buf)
default:
return bin.NewUnexpectedID(id)
}
return nil
},
}
// UpgradeInbound converts an old client's inbound request to canonical (227)
// form so the normal gotd dispatcher can handle it. It unifies three data-driven
// sources, all of which require no per-method handler code:
// - inboundMethodUpgrades (generated from api.tl diff): official layer drift.
// - clientMethodAliases (client_aliases.go): body-identical client drift.
// - driftModel (client-drift.tl): body-different client drift, upgraded by the
// generic engine below.
//
// ok=false means no upgrade applies. On ok=true the returned buffer (canonical
// id + body) is what to dispatch.
func UpgradeInbound(id uint32, in *bin.Buffer) (*bin.Buffer, bool, error) {
if newID, ok := UpgradeMethodCRC(id); ok {
target := canonical.byCRC[newID]
if target == nil || !target.isFunc {
return nil, true, malformedf("alias %#08x targets unknown canonical method %#08x", id, newID)
}
if err := validateAliasedMethod(id, target, in.Buf); err != nil {
return nil, true, err
}
// Copy rather than rewrite in place: never mutate the caller's buffer
// (matches the body-transform path, which also returns a fresh buffer).
out := &bin.Buffer{Buf: append([]byte(nil), in.Buf...)}
binary.LittleEndian.PutUint32(out.Buf[:4], newID)
return out, true, nil
}
if old := driftModel.byCRC[id]; old != nil {
out, err := upgradeFromDrift(old, in, newWalkState())
if err != nil {
return nil, true, classifyWalkError(fmt.Errorf("layerwire: upgrade %s (%#08x): %w", old.name, id, err))
}
return out, true, nil
}
return nil, false, nil
}
// validateAliasedMethod validates the old-id/canonical-body shape before
// allocating the replacement buffer. The body is walked against the canonical
// target layout while the original constructor id remains untouched.
func validateAliasedMethod(oldID uint32, target *ctorLayout, raw []byte) error {
walk := newWalkState()
if err := walk.enter(1, "constructor"); err != nil {
return err
}
b := &bin.Buffer{Buf: raw}
if err := b.ConsumeID(oldID); err != nil {
return classifyWalkError(err)
}
if err := walk.skipCtorBody(canonical, b, target, 1); err != nil {
return classifyWalkError(err)
}
if b.Len() != 0 {
return malformedf("%d trailing bytes after aliased method %s", b.Len(), target.name)
}
return nil
}
// IsClientDrift reports whether id is a client-private constructor (DrKLO
// constructor drift), as opposed to official layer drift from api.tl.
func IsClientDrift(id uint32) bool {
if _, ok := clientMethodAliases[id]; ok {
return true
}
return driftModel.byCRC[id] != nil
}
// upgradeFromDrift rebuilds a canonical (227) request from an old client-drift
// body, comparing the declared old layout to the canonical layout field by field.
func upgradeFromDrift(old *ctorLayout, in *bin.Buffer, walk *walkState) (*bin.Buffer, error) {
target := canonical.byName[old.name]
if target == nil {
return nil, fmt.Errorf("no canonical method %q", old.name)
}
if err := walk.enter(1, "constructor"); err != nil {
return nil, err
}
if err := in.ConsumeID(old.crc); err != nil {
return nil, err
}
// Decode the old body: capture each present field's raw bytes + flag ints.
vals := make(map[string][]byte, len(old.fields))
present := make(map[string]bool, len(old.fields))
oldFlags := make(map[string]uint32, 2)
oldByName := make(map[string]*fieldLayout, len(old.fields))
for i := range old.fields {
f := &old.fields[i]
oldByName[f.name] = f
if f.isFlags {
v, err := in.Uint32()
if err != nil {
return nil, err
}
oldFlags[f.name] = v
continue
}
if f.conditional() && oldFlags[f.flagName]&(1<<uint(f.flagBit)) == 0 {
continue
}
present[f.name] = true
if f.kind == kindTrue {
continue
}
pre := in.Buf
if err := walk.skipValue(canonical, in, f, old, 1); err != nil {
return nil, fmt.Errorf("decode old field %q: %w", f.name, err)
}
vals[f.name] = pre[:len(pre)-len(in.Buf)]
}
if in.Len() != 0 {
return nil, fmt.Errorf("%d trailing bytes after old body", in.Len())
}
// Emit the canonical body.
out := &bin.Buffer{}
out.PutID(target.crc)
for i := range target.fields {
nf := &target.fields[i]
if nf.isFlags {
out.PutUint32(oldFlags[nf.name]) // 0 when absent in old (new flags int)
continue
}
oldName := nf.name
if mapped, ok := driftFieldRenames[old.name+"\x00"+nf.name]; ok {
oldName = mapped
}
if present[oldName] {
of := oldByName[oldName]
if of != nil && typeSig(of) != typeSig(nf) {
conv := fieldConverters[typeSig(of)+"->"+typeSig(nf)]
if conv == nil {
return nil, fmt.Errorf("field %q: no converter %s->%s", nf.name, typeSig(of), typeSig(nf))
}
if err := conv(vals[oldName], out, walk, old, of); err != nil {
return nil, fmt.Errorf("field %q convert: %w", nf.name, err)
}
} else {
out.Put(vals[oldName]) // shared field, identical wire (kindTrue => no bytes)
}
continue
}
// Canonical-only field absent in old.
if nf.conditional() || nf.kind == kindTrue {
continue // optional: leave absent (its flag bit is clear)
}
if err := writeDefault(nf, out); err != nil {
return nil, fmt.Errorf("field %q default: %w", nf.name, err)
}
}
return out, nil
}
// writeDefault writes the zero value of a required canonical-only field.
func writeDefault(f *fieldLayout, out *bin.Buffer) error {
switch f.kind {
case kindInt:
out.PutInt(0)
case kindLong:
out.PutLong(0)
case kindDouble:
out.PutDouble(0)
case kindInt128:
out.PutInt128(bin.Int128{})
case kindInt256:
out.PutInt256(bin.Int256{})
case kindBytes:
out.PutBytes(nil)
case kindString:
out.PutString("")
case kindBool:
out.PutID(boolFalseID)
case kindVector:
out.PutVectorHeader(0)
case kindVectorBare:
out.PutInt(0)
default:
return fmt.Errorf("cannot default kind %d (boxed object needs a transform)", f.kind)
}
return nil
}
// typeSig is a stable wire-type signature for matching/converter lookup.
func typeSig(f *fieldLayout) string {
switch f.kind {
case kindInt:
return "int"
case kindLong:
return "long"
case kindDouble:
return "double"
case kindInt128:
return "int128"
case kindInt256:
return "int256"
case kindBytes:
return "bytes"
case kindString:
return "string"
case kindBool:
return "Bool"
case kindTrue:
return "true"
case kindVector:
return "Vector<" + typeSig(f.elem) + ">"
case kindVectorBare:
return "vector<" + typeSig(f.elem) + ">"
case kindObject, kindBareObject:
return f.typeName
default:
return fmt.Sprintf("kind%d", f.kind)
}
}

View file

@ -1,115 +0,0 @@
package layerwire
import "testing"
// TestInboundUpgradeTableWellFormed checks every inbound upgrade maps an old id
// to a real canonical method id, and that the old id is genuinely historical
// (not already a canonical constructor).
func TestInboundUpgradeTableWellFormed(t *testing.T) {
if len(inboundMethodUpgrades) == 0 {
t.Fatal("inboundMethodUpgrades is empty")
}
for oldID, newID := range inboundMethodUpgrades {
cl := canonical.byCRC[newID]
if cl == nil {
t.Errorf("upgrade target %#08x is not a canonical constructor", newID)
continue
}
if !cl.isFunc {
t.Errorf("upgrade target %s (%#08x) is not a method", cl.name, newID)
}
if oldID == newID {
t.Errorf("%s: old id equals canonical id %#08x", cl.name, oldID)
}
if prev := canonical.byCRC[oldID]; prev != nil {
t.Errorf("old id %#08x collides with canonical %s", oldID, prev.name)
}
}
}
// TestClientMethodAliasesWellFormed checks every hand-maintained client-drift
// alias maps to a real canonical method, and is reachable via UpgradeMethodCRC.
func TestClientMethodAliasesWellFormed(t *testing.T) {
for oldID, newID := range clientMethodAliases {
cl := canonical.byCRC[newID]
if cl == nil || !cl.isFunc {
t.Errorf("alias target %#08x is not a canonical method", newID)
}
if _, ok := inboundMethodUpgrades[oldID]; ok {
t.Errorf("alias %#08x duplicates a generated upgrade entry", oldID)
}
if got, ok := UpgradeMethodCRC(oldID); !ok || got != newID {
t.Errorf("UpgradeMethodCRC(%#08x) = (%#08x,%v), want (%#08x,true)", oldID, got, ok, newID)
}
}
}
// TestInboundDriftCoverage is the inbound drift gate: it statically proves every
// client-drift constructor in client-drift.tl can be upgraded to its canonical
// method — shared fields match (or have a converter), canonical-only required
// fields are defaultable, and renamed fields are mapped. Adding a TL line that
// isn't auto-upgradable fails here, telling the author exactly what converter or
// rename to declare (instead of discovering it at runtime).
func TestInboundDriftCoverage(t *testing.T) {
defaultable := map[wireKind]bool{
kindInt: true, kindLong: true, kindDouble: true, kindInt128: true, kindInt256: true,
kindBytes: true, kindString: true, kindBool: true, kindVector: true, kindVectorBare: true,
}
for crc, old := range driftModel.byCRC {
target := canonical.byName[old.name]
if target == nil {
t.Errorf("drift %s (%#08x): no canonical method of that name", old.name, crc)
continue
}
oldHas := map[string]*fieldLayout{}
for i := range old.fields {
oldHas[old.fields[i].name] = &old.fields[i]
}
for i := range target.fields {
nf := &target.fields[i]
if nf.isFlags {
continue
}
oldName := nf.name
if m, ok := driftFieldRenames[old.name+"\x00"+nf.name]; ok {
oldName = m
}
if of, ok := oldHas[oldName]; ok {
if typeSig(of) != typeSig(nf) && fieldConverters[typeSig(of)+"->"+typeSig(nf)] == nil {
t.Errorf("drift %s: field %q needs converter %s->%s", old.name, nf.name, typeSig(of), typeSig(nf))
}
continue
}
if nf.conditional() || nf.kind == kindTrue {
continue // optional canonical-only field — left absent
}
if !defaultable[nf.kind] {
t.Errorf("drift %s: canonical-only required field %q (kind %d) is not defaultable; declare a transform", old.name, nf.name, nf.kind)
}
}
}
}
// TestInboundUpgradeSendMessage validates the full chain for the highest-value
// method: a layer-220 client's messages.sendMessage id upgrades to the 227 id.
func TestInboundUpgradeSendMessage(t *testing.T) {
m220 := loadLayerModel(t, 220)
old, ok := m220.byName["messages.sendMessage"]
if !ok {
t.Fatal("messages.sendMessage missing from layer-220 schema")
}
canon, ok := canonical.byName["messages.sendMessage"]
if !ok {
t.Fatal("messages.sendMessage missing from canonical schema")
}
if old.crc == canon.crc {
t.Skip("sendMessage unchanged 220->227; nothing to upgrade")
}
newID, ok := UpgradeMethodCRC(old.crc)
if !ok {
t.Fatalf("sendMessage@220 (%#08x) not in upgrade table", old.crc)
}
if newID != canon.crc {
t.Fatalf("sendMessage upgrade = %#08x, want canonical %#08x", newID, canon.crc)
}
}

View file

@ -1,236 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// validateMethodRequest asserts that buf holds a single canonical (227) method
// request: its leading id equals wantCRC and the canonical walker consumes every
// byte (proving the rebuilt body matches the 227 layout).
func validateMethodRequest(t *testing.T, buf *bin.Buffer, wantCRC uint32, label string) {
t.Helper()
id, err := (&bin.Buffer{Buf: buf.Buf}).PeekID()
if err != nil {
t.Fatalf("%s: peek id: %v", label, err)
}
if id != wantCRC {
t.Fatalf("%s: id = %#08x, want %#08x", label, id, wantCRC)
}
probe := &bin.Buffer{Buf: append([]byte(nil), buf.Buf...)}
if err := canonical.skipObject(probe); err != nil {
t.Fatalf("%s: result not a valid 227 request: %v", label, err)
}
if probe.Len() != 0 {
t.Fatalf("%s: %d trailing bytes in rebuilt request", label, probe.Len())
}
}
func TestInboundBodyTransforms(t *testing.T) {
// uploadMedia: peer + media -> flags + peer + media.
t.Run("uploadMedia", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x519bc2b1)
_ = (&tg.InputPeerSelf{}).Encode(&in)
_ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&in)
out, ok, err := UpgradeInbound(0x519bc2b1, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x14967978, "uploadMedia")
})
t.Run("authSignUp", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x80eee427)
in.PutString("+15550000000")
in.PutString("hash")
in.PutString("First")
in.PutString("Last")
out, ok, err := UpgradeInbound(0x80eee427, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xaac7b717, "authSignUp")
})
t.Run("channelsGetMessages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x93d7b347)
_ = (&tg.InputChannel{ChannelID: 4, AccessHash: 5}).Encode(&in)
in.PutVectorHeader(2)
in.PutInt(11)
in.PutInt(12)
out, ok, err := UpgradeInbound(0x93d7b347, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xad8c9a23, "channelsGetMessages")
})
t.Run("messagesGetMessages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x4222fa74)
in.PutVectorHeader(2)
in.PutInt(21)
in.PutInt(22)
out, ok, err := UpgradeInbound(0x4222fa74, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, tg.MessagesGetMessagesRequestTypeID, "messagesGetMessages")
var req tg.MessagesGetMessagesRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded messages.getMessages: %v", err)
}
if len(req.ID) != 2 {
t.Fatalf("upgraded ids = %d, want 2", len(req.ID))
}
first, ok := req.ID[0].(*tg.InputMessageID)
if !ok || first.ID != 21 {
t.Fatalf("upgraded id[0] = %T %+v, want inputMessageID(21)", req.ID[0], req.ID[0])
}
})
t.Run("botsExportBotToken", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x0063b089)
in.PutLong(777)
in.PutID(0x997275b5) // boolTrue (revoke)
out, ok, err := UpgradeInbound(0x0063b089, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xbd0d99eb, "botsExportBotToken")
})
t.Run("accountRegisterDevice", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x637ea878)
in.PutInt(2)
in.PutString("token-blob")
out, ok, err := UpgradeInbound(0x637ea878, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xec86017a, "accountRegisterDevice")
})
t.Run("contactsSearch", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x11f812d8)
in.PutString("ngame")
in.PutInt(20)
out, ok, err := UpgradeInbound(0x11f812d8, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, tg.ContactsSearchRequestTypeID, "contactsSearch")
var req tg.ContactsSearchRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded contacts.search: %v", err)
}
if req.Flags != 0 || req.Q != "ngame" || req.Limit != 20 {
t.Fatalf("upgraded contacts.search = flags:%#x q:%q limit:%d", req.Flags, req.Q, req.Limit)
}
})
t.Run("langpackGetLangPack", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x9ab5c58e)
in.PutString("en")
out, ok, err := UpgradeInbound(0x9ab5c58e, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xf2f2330a, "langpackGetLangPack")
})
t.Run("langpackGetStrings", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x2e1ee318)
in.PutString("en")
in.PutVectorHeader(2)
in.PutString("key1")
in.PutString("key2")
out, ok, err := UpgradeInbound(0x2e1ee318, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xefea3803, "langpackGetStrings")
})
t.Run("langpackGetLanguages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x800fd57d)
out, ok, err := UpgradeInbound(0x800fd57d, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages")
})
t.Run("channelsEditCreatorToMessagesEditChatCreator", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x8f38cd1f)
_ = (&tg.InputChannel{ChannelID: 132, AccessHash: 8956724956393200600}).Encode(&in)
_ = (&tg.InputUser{UserID: 1780243211, AccessHash: 42}).Encode(&in)
_ = (&tg.InputCheckPasswordEmpty{}).Encode(&in)
out, ok, err := UpgradeInbound(0x8f38cd1f, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xf743b857, "messagesEditChatCreator")
var req tg.MessagesEditChatCreatorRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded editChatCreator: %v", err)
}
peer, ok := req.Peer.(*tg.InputPeerChannel)
if !ok || peer.ChannelID != 132 || peer.AccessHash != 8956724956393200600 {
t.Fatalf("upgraded peer = %T %+v, want inputPeerChannel", req.Peer, req.Peer)
}
user, ok := req.UserID.(*tg.InputUser)
if !ok || user.UserID != 1780243211 || user.AccessHash != 42 {
t.Fatalf("upgraded user = %T %+v, want inputUser", req.UserID, req.UserID)
}
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); !ok {
t.Fatalf("upgraded password = %T, want inputCheckPasswordEmpty", req.Password)
}
})
}
// TestInboundCRCSwaps covers the body-compatible client-drift methods that only
// need a 4-byte id swap.
func TestInboundCRCSwaps(t *testing.T) {
t.Run("updatesGetDifference", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x25939651)
in.PutUint32(0) // flags (no pts_total_limit)
in.PutInt(100) // pts
in.PutInt(200) // date
in.PutInt(0) // qts
out, ok, err := UpgradeInbound(0x25939651, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x19c2f763, "updatesGetDifference")
})
t.Run("createChat", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x0034a818)
if err := (&tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: 2, AccessHash: 3}},
Title: "Group",
}).EncodeBare(&in); err != nil {
t.Fatalf("encode createChat body: %v", err)
}
out, ok, err := UpgradeInbound(0x0034a818, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x92ceddd4, "createChat")
})
}

View file

@ -1,204 +0,0 @@
// Package layerwire downgrades canonical (Layer 227, the bytes gotd actually
// emits) TL objects to the wire shape expected by older clients (down to
// Layer 220), and is the runtime half of docs/layer-compat-220-227-design.md.
//
// The package is schema-driven: at init it parses the embedded canonical-227
// schema into a per-constructor field layout used by a generic walker; the
// generate-time tables (tables_gen.go, produced by ./gen) describe the
// per-layer downgrade rules. Business handlers and gotd are never touched —
// they always produce Layer 227, and transcoding happens only at the edge and
// only when the negotiated client layer is below 227.
package layerwire
import (
_ "embed"
"fmt"
"strings"
"github.com/gotd/tl"
)
// CanonicalLayer is the layer telesrv's pinned gotd emits.
const CanonicalLayer = 227
// SupportedFloor is the oldest client layer the transcoder targets.
const SupportedFloor = 220
// vectorTypeID is the boxed Vector constructor id.
const vectorTypeID = 0x1cb5c415
//go:embed schema/canonical-227.tl
var canonicalSchema string
// wireKind is the on-wire representation of a single TL value.
type wireKind uint8
const (
kindInt wireKind = iota // 4 bytes
kindLong // 8 bytes
kindDouble // 8 bytes
kindInt128 // 16 bytes
kindInt256 // 32 bytes
kindBytes // TL bytes (length-prefixed, padded)
kindString // TL string (same wire as bytes)
kindBool // boxed Bool (4-byte CRC)
kindTrue // flag-only pseudo value, 0 bytes
kindVector // boxed Vector<elem> (0x1cb5c415 + n + elems)
kindVectorBare // bare vector<elem> (n + elems, no id)
kindObject // boxed object (4-byte CRC + body)
kindBareObject // bare object (body only, resolved by typeName)
)
// fieldLayout is one parameter of a constructor with its decoded wire shape.
type fieldLayout struct {
name string
kind wireKind
isFlags bool // this is a `#` flags integer
flagName string // when conditional: which flags integer gates it
flagBit int // when conditional: bit index; -1 otherwise
elem *fieldLayout // vector element layout
typeName string // object/bareObject: (qualified) referenced type name
}
func (f fieldLayout) conditional() bool { return f.flagBit >= 0 }
// ctorLayout is the decoded field layout of a single constructor.
type ctorLayout struct {
crc uint32
name string // qualified TL name, e.g. "messages.dialogs" / "message"
result string // qualified result (abstract) type name
fields []fieldLayout
isFunc bool
}
// schemaModel is the parsed canonical schema indexed for the walker.
type schemaModel struct {
byCRC map[uint32]*ctorLayout
byName map[string]*ctorLayout // qualified ctor name -> layout
bareByT map[string]*ctorLayout // bare type name -> its single constructor
ctorsOfT map[string][]*ctorLayout // abstract result type -> constructors
}
// canonical is the parsed Layer 227 model, built once at init.
var canonical = mustLoadCanonical()
func mustLoadCanonical() *schemaModel {
m, err := parseSchemaModel(canonicalSchema)
if err != nil {
panic("layerwire: parse canonical schema: " + err.Error())
}
return m
}
func qualifyName(ns []string, name string) string {
if len(ns) == 0 {
return name
}
return strings.Join(ns, ".") + "." + name
}
func qualifyType(t tl.Type) string {
return qualifyName(t.Namespace, t.Name)
}
func parseSchemaModel(src string) (*schemaModel, error) {
parsed, err := tl.Parse(strings.NewReader(src))
if err != nil {
return nil, err
}
m := &schemaModel{
byCRC: make(map[uint32]*ctorLayout),
byName: make(map[string]*ctorLayout),
bareByT: make(map[string]*ctorLayout),
ctorsOfT: make(map[string][]*ctorLayout),
}
for i := range parsed.Definitions {
sd := parsed.Definitions[i]
d := sd.Definition
name := qualifyName(d.Namespace, d.Name)
if name == "vector" {
continue // implicit Vector pseudo-definition
}
cl := &ctorLayout{
crc: d.ID,
name: name,
result: qualifyType(d.Type),
isFunc: sd.Category == tl.CategoryFunction,
}
for _, p := range d.Params {
fl, err := toFieldLayout(p)
if err != nil {
return nil, fmt.Errorf("%s field %q: %w", name, p.Name, err)
}
cl.fields = append(cl.fields, fl)
}
if prev, ok := m.byCRC[cl.crc]; ok && prev.name != cl.name {
return nil, fmt.Errorf("crc collision %#08x: %s vs %s", cl.crc, prev.name, cl.name)
}
m.byCRC[cl.crc] = cl
m.byName[cl.name] = cl
if !cl.isFunc {
m.ctorsOfT[cl.result] = append(m.ctorsOfT[cl.result], cl)
// A bare type name is the lowercase constructor name itself.
m.bareByT[cl.name] = cl
}
}
return m, nil
}
func toFieldLayout(p tl.Parameter) (fieldLayout, error) {
if p.Flags {
return fieldLayout{name: p.Name, kind: kindInt, isFlags: true, flagBit: -1}, nil
}
fl := fieldLayout{name: p.Name, flagBit: -1}
if p.Flag != nil {
fl.flagName = p.Flag.Name
fl.flagBit = p.Flag.Index
}
kind, typeName, elem, err := resolveType(p.Type)
if err != nil {
return fieldLayout{}, err
}
fl.kind = kind
fl.typeName = typeName
fl.elem = elem
return fl, nil
}
func resolveType(t tl.Type) (kind wireKind, typeName string, elem *fieldLayout, err error) {
if t.GenericArg != nil {
ek, etn, eel, eerr := resolveType(*t.GenericArg)
if eerr != nil {
return 0, "", nil, eerr
}
el := &fieldLayout{kind: ek, typeName: etn, elem: eel, flagBit: -1}
if t.Name == "vector" { // bare vector
return kindVectorBare, "", el, nil
}
return kindVector, "", el, nil
}
switch t.Name {
case "int":
return kindInt, "", nil, nil
case "long":
return kindLong, "", nil, nil
case "double":
return kindDouble, "", nil, nil
case "int128":
return kindInt128, "", nil, nil
case "int256":
return kindInt256, "", nil, nil
case "bytes":
return kindBytes, "", nil, nil
case "string":
return kindString, "", nil, nil
case "Bool":
return kindBool, "", nil, nil
case "true":
return kindTrue, "", nil, nil
}
if t.Bare {
return kindBareObject, qualifyType(t), nil, nil
}
return kindObject, qualifyType(t), nil, nil
}

View file

@ -1,80 +0,0 @@
package layerwire
import (
_ "embed"
"fmt"
"github.com/gotd/td/bin"
)
const maxOpaqueRequestBytes = 16 << 20
//go:embed schema/routable-compat.tl
var routableCompatSchema string
// routable combines the canonical Layer 227 model with the small set of
// explicitly declared compatibility-only methods. Nested objects in those
// methods are canonical Input* constructors, so one combined graph is needed
// for the same depth/vector/bytes walker to validate the complete request.
var routable = mustLoadRoutable()
func mustLoadRoutable() *schemaModel {
compat, err := parseSchemaModel(routableCompatSchema)
if err != nil {
panic("layerwire: parse routable compat schema: " + err.Error())
}
m := &schemaModel{
byCRC: make(map[uint32]*ctorLayout, len(canonical.byCRC)+len(compat.byCRC)),
byName: make(map[string]*ctorLayout, len(canonical.byName)+len(compat.byName)),
bareByT: make(map[string]*ctorLayout, len(canonical.bareByT)),
ctorsOfT: make(map[string][]*ctorLayout, len(canonical.ctorsOfT)),
}
for id, cl := range canonical.byCRC {
m.byCRC[id] = cl
}
for name, cl := range canonical.byName {
m.byName[name] = cl
}
for name, cl := range canonical.bareByT {
m.bareByT[name] = cl
}
for name, ctors := range canonical.ctorsOfT {
m.ctorsOfT[name] = ctors
}
for id, cl := range compat.byCRC {
if existing := m.byCRC[id]; existing != nil {
panic(fmt.Sprintf("layerwire: routable compat crc %#08x collides with %s", id, existing.name))
}
m.byCRC[id] = cl
m.byName[cl.name] = cl
}
return m
}
// ValidateRoutableRequest validates every request shape the router knows how to
// decode, including compatibility-only fallback methods. known=false denotes
// a genuinely unknown top-level constructor. Such a request is never decoded:
// it is treated as opaque, word-aligned TL data, bounded by both this total-size
// cap and mtprotoedge's transport/RPC budgets, and must continue to the router's
// compatibility trace rather than being mislabeled as malformed input.
func ValidateRoutableRequest(body []byte) (known bool, err error) {
b := &bin.Buffer{Buf: body}
id, err := b.PeekID()
if err != nil {
return false, classifyWalkError(err)
}
cl := routable.byCRC[id]
if cl == nil {
if len(body) > maxOpaqueRequestBytes {
return false, limitf("opaque request length %d exceeds limit %d", len(body), maxOpaqueRequestBytes)
}
if len(body)%bin.Word != 0 {
return false, malformedf("opaque request length %d is not word aligned", len(body))
}
return false, nil
}
if !cl.isFunc {
return true, malformedf("constructor %s (%#08x) is not a method", cl.name, id)
}
return true, validateRequestLayout(routable, cl, body)
}

View file

@ -1,43 +0,0 @@
package layerwire
import (
"errors"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func TestValidateRoutableRequestCompatibilityAndUnknown(t *testing.T) {
t.Run("legacy theme is fully walked", func(t *testing.T) {
var b bin.Buffer
b.PutID(0x8d9d742b)
b.PutString("android")
(&tg.InputThemeSlug{Slug: "night"}).Encode(&b)
b.PutLong(42)
known, err := ValidateRoutableRequest(b.Buf)
if err != nil || !known {
t.Fatalf("legacy theme known=%v err=%v, want true/nil", known, err)
}
b.Buf = b.Buf[:len(b.Buf)-4]
known, err = ValidateRoutableRequest(b.Buf)
if !known || !errors.Is(err, ErrMalformed) {
t.Fatalf("truncated legacy theme known=%v err=%v, want true/malformed", known, err)
}
})
t.Run("unknown stays opaque and bounded", func(t *testing.T) {
var b bin.Buffer
b.PutID(0x12345678)
b.PutUint32(0xffffffff)
known, err := ValidateRoutableRequest(b.Buf)
if err != nil || known {
t.Fatalf("opaque unknown known=%v err=%v, want false/nil", known, err)
}
known, err = ValidateRoutableRequest(append(b.Buf, 1))
if known || !errors.Is(err, ErrMalformed) {
t.Fatalf("unaligned unknown known=%v err=%v, want false/malformed", known, err)
}
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
// Client constructor drift — method constructors emitted by a specific client's
// hand-maintained TL (DrKLO Android, TLRPC.java) that are absent from the
// canonical (227) schema. They are old-layer API versions the client never
// updated; from the server's view they request the SAME api method, only with an
// older wire shape.
//
// The generic inbound upgrader (inbound.go) matches each by qualified name to
// the canonical method and rebuilds a canonical request: copy shared fields,
// write 0 for inserted flags integers, synthesize defaults for new required
// fields, and convert changed field types via the converter registry. So adding
// support for a newly-observed drifted constructor is one line here + a `gen`
// run — never a runtime-discovered hand patch.
//
// Only bodies that differ structurally belong here. Body-identical drift (only
// the constructor id differs) is a plain id swap in clientMethodAliases
// (client_aliases.go).
---functions---
messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia;
auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization;
messages.getMessages#4222fa74 id:Vector<int> = messages.Messages;
channels.getMessages#93d7b347 channel:InputChannel id:Vector<int> = messages.Messages;
bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken;
account.registerDevice#637ea878 token_type:int token:string = Bool;
contacts.search#11f812d8 q:string limit:int = contacts.Found;
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
// DrKLO still emits old channels.editCreator#8f38cd1f; canonical 227 replaced
// that flow with messages.editChatCreator(peer:InputPeer,...). Keep the old
// constructor id here but target the canonical method name for generic upgrade.
messages.editChatCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;

View file

@ -1,11 +0,0 @@
// Hand-maintained request layouts that are intentionally handled by the RPC
// fallback instead of gotd's canonical ServerDispatcher. They still belong in
// the structural preflight model: fallback handlers must never become a way to
// bypass the canonical vector/depth/bytes budgets.
---functions---
compat.legacyCreateTheme#8432c21f flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Object;
compat.legacyUpdateTheme#5cb367d5 flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Object;
compat.legacyInstallTheme#7ae43737 flags:# dark:flags.0?true format:flags.1?string theme:flags.1?InputTheme = Object;
compat.legacyGetTheme#8d9d742b format:string theme:InputTheme document_id:long = Object;

View file

@ -1,374 +0,0 @@
package layerwire
import (
"fmt"
"github.com/gotd/td/bin"
)
// ruleRaw is the generated, compact form of a single changed-constructor
// downgrade (see tables_gen.go). Mechanical rules carry the target CRC plus the
// canonical field names retained at the target layer; structural rules name a
// hand-written transform registered in fallback.go.
type ruleRaw struct {
target uint32
keep []string
structural string
}
// layerRaw is the generated downgrade table for one target layer.
type layerRaw struct {
rules map[uint32]ruleRaw
newTypes []uint32 // canonical CRCs that do not exist at this layer
}
// downgradeRule is the runtime form of ruleRaw with keep as a set.
type downgradeRule struct {
target uint32
keep map[string]bool
structural string
}
// layerTables is the runtime downgrade model for one target layer.
type layerTables struct {
rules map[uint32]*downgradeRule
newTypes map[uint32]bool
dirty map[uint32]bool // ctor CRC needs a deep walk
dirtyT map[string]bool // abstract/bare type name reaches a dirty ctor
}
// tables holds the runtime model per supported layer, built lazily.
var tables = func() map[int]*layerTables {
out := make(map[int]*layerTables, len(generatedTables))
for layer, raw := range generatedTables {
out[layer] = buildLayerTables(raw)
}
return out
}()
func buildLayerTables(raw layerRaw) *layerTables {
lt := &layerTables{
rules: make(map[uint32]*downgradeRule, len(raw.rules)),
newTypes: make(map[uint32]bool, len(raw.newTypes)),
}
for crc, r := range raw.rules {
dr := &downgradeRule{target: r.target, structural: r.structural}
if r.structural == "" {
dr.keep = make(map[string]bool, len(r.keep))
for _, n := range r.keep {
dr.keep[n] = true
}
}
lt.rules[crc] = dr
}
for _, crc := range raw.newTypes {
lt.newTypes[crc] = true
}
lt.computeDirty()
return lt
}
// computeDirty marks every constructor (and abstract/bare type) that can
// transitively contain a changed, structural, or layer-absent constructor, so
// the transcoder can byte-copy the ~86% of the type graph that is unaffected.
func (lt *layerTables) computeDirty() {
lt.dirty = make(map[uint32]bool)
lt.dirtyT = make(map[string]bool)
// Seed: rules + new types are themselves dirty.
for crc := range lt.rules {
lt.dirty[crc] = true
}
for crc := range lt.newTypes {
lt.dirty[crc] = true
}
markType := func(name string) {
if name != "" && !lt.dirtyT[name] {
lt.dirtyT[name] = true
}
}
// Seed dirty types from seeded dirty ctors.
for crc := range lt.dirty {
if cl := canonical.byCRC[crc]; cl != nil {
markType(cl.result)
markType(cl.name) // bare reference
}
}
// Fixpoint: a ctor is dirty if any field's type is dirty; a type is dirty
// if any of its constructors is dirty.
for changed := true; changed; {
changed = false
for crc, cl := range canonical.byCRC {
if lt.dirty[crc] {
continue
}
if lt.ctorHasDirtyField(cl) {
lt.dirty[crc] = true
if !lt.dirtyT[cl.result] {
lt.dirtyT[cl.result] = true
}
if !lt.dirtyT[cl.name] {
lt.dirtyT[cl.name] = true
}
changed = true
}
}
}
}
func (lt *layerTables) ctorHasDirtyField(cl *ctorLayout) bool {
for i := range cl.fields {
if lt.fieldDirty(&cl.fields[i]) {
return true
}
}
return false
}
func (lt *layerTables) fieldDirty(f *fieldLayout) bool {
switch f.kind {
case kindObject, kindBareObject:
return lt.dirtyT[f.typeName]
case kindVector, kindVectorBare:
return lt.fieldDirty(f.elem)
default:
return false
}
}
// structuralFunc transforms a changed constructor whose downgrade is not a pure
// field drop. The leading CRC has already been consumed from in; the transform
// reads the canonical body from in and writes the target-layer object (whose
// constructor id is target) to out.
type structuralFunc func(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer, depth int, walk *walkState) error
// fallbackFunc replaces a layer-absent (227-only) constructor with an
// equivalent the target layer understands. The leading CRC is NOT yet consumed.
type fallbackFunc func(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error
// structuralTransforms and the newType fallback registries are populated in
// fallback.go. newTypeFallbacks is keyed by canonical CRC (specific override);
// newTypeFallbacksByType is keyed by the canonical abstract result type and
// covers every 227-only constructor of that class (e.g. any new MessageAction).
var (
structuralTransforms = map[string]structuralFunc{}
newTypeFallbacks = map[uint32]fallbackFunc{}
newTypeFallbacksByType = map[string]fallbackFunc{}
)
// Transcode downgrades a single canonical (Layer 227) boxed object to the wire
// shape of layer. layer >= CanonicalLayer (or unsupported) returns in verbatim.
// On any transform gap it returns an error so the edge can fall back to sending
// the canonical bytes rather than corrupting the stream.
func Transcode(canonicalBytes []byte, layer int) ([]byte, error) {
if layer >= CanonicalLayer {
return canonicalBytes, nil
}
lt := tables[layer]
if lt == nil {
return canonicalBytes, nil // unsupported floor: best-effort passthrough
}
// Top-level constructors that are not in the canonical tg schema are MTProto
// control/error objects (mt.*, e.g. rpc_error) — layer-invariant, so pass
// them through. A nested unknown id is still a hard error (real gap).
if id, err := (&bin.Buffer{Buf: canonicalBytes}).PeekID(); err != nil || canonical.byCRC[id] == nil {
return canonicalBytes, nil
}
in := &bin.Buffer{Buf: canonicalBytes}
out := &bin.Buffer{}
walk := newWalkState()
if err := lt.transcodeObject(in, out, layer, 1, walk); err != nil {
return nil, classifyWalkError(err)
}
if in.Len() != 0 {
return nil, malformedf("%d trailing bytes after transcode to layer %d", in.Len(), layer)
}
return out.Buf, nil
}
// UpgradeMethodCRC maps an old client's method constructor id to the canonical
// (227) id when the request body is byte-compatible — i.e. swapping the leading
// 4-byte id yields a valid 227 request. It unifies two sources: generated
// official layer drift (inboundMethodUpgrades) and hand-maintained client
// constructor drift (clientMethodAliases). Returns ok=false for unchanged
// methods and for changes that need a real decode (those stay as rpc handlers).
func UpgradeMethodCRC(oldID uint32) (uint32, bool) {
if newID, ok := inboundMethodUpgrades[oldID]; ok {
return newID, true
}
newID, ok := clientMethodAliases[oldID]
return newID, ok
}
func (lt *layerTables) transcodeObject(in, out *bin.Buffer, layer, depth int, walk *walkState) error {
if err := walk.enter(depth, "constructor"); err != nil {
return err
}
id, err := in.PeekID()
if err != nil {
return err
}
cl, ok := canonical.byCRC[id]
if !ok {
return fmt.Errorf("layerwire: unknown constructor %#08x", id)
}
if rule := lt.rules[id]; rule != nil {
if err := in.ConsumeID(id); err != nil {
return err
}
if rule.structural != "" {
fn := structuralTransforms[rule.structural]
if fn == nil {
return fmt.Errorf("layerwire: no structural transform %q for %s@%d", rule.structural, cl.name, layer)
}
return fn(cl, rule.target, in, out, layer, depth, walk)
}
out.PutID(rule.target)
return lt.transcodeBody(in, out, cl, rule.keep, layer, depth, walk)
}
if lt.newTypes[id] {
fn := newTypeFallbacks[id]
if fn == nil {
fn = newTypeFallbacksByType[cl.result]
}
if fn == nil {
return fmt.Errorf("layerwire: %s (%#08x) absent at layer %d and no fallback", cl.name, id, layer)
}
return fn(cl, in, out, layer, depth, walk)
}
if !lt.dirty[id] {
// Unaffected subtree: byte-for-byte copy.
pre := in.Buf
if err := in.ConsumeID(id); err != nil {
return err
}
if err := walk.skipCtorBody(canonical, in, cl, depth); err != nil {
return err
}
out.Put(pre[:len(pre)-len(in.Buf)])
return nil
}
// Unchanged at this level but a descendant is dirty: keep CRC, recurse.
if err := in.ConsumeID(id); err != nil {
return err
}
out.PutID(id)
return lt.transcodeBody(in, out, cl, nil, layer, depth, walk)
}
// transcodeBody re-encodes a constructor body. keep==nil means retain every
// field (recursing into dirty descendants); otherwise only the named canonical
// fields are written, flag integers are remasked to the retained bits, and
// dropped fields are read-and-discarded.
func (lt *layerTables) transcodeBody(in, out *bin.Buffer, cl *ctorLayout, keep map[string]bool, layer, depth int, walk *walkState) error {
kept := func(name string) bool { return keep == nil || keep[name] }
var flags map[string]uint32
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, err := in.Uint32()
if err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
if flags == nil {
flags = make(map[string]uint32, 2)
}
flags[f.name] = v
if kept(f.name) {
out.PutUint32(v & lt.keptMask(cl, f.name, kept))
}
continue
}
present := !f.conditional() || flags[f.flagName]&(1<<uint(f.flagBit)) != 0
if !present {
continue
}
if kept(f.name) {
if err := lt.transcodeValue(in, out, f, cl, layer, depth, walk); err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
} else if err := walk.skipValue(canonical, in, f, cl, depth); err != nil {
return fmt.Errorf("%s.%s (drop): %w", cl.name, f.name, err)
}
}
return nil
}
// keptMask is the OR of bits for retained conditional fields gated by flagName,
// clearing bits whose fields are dropped at the target layer.
func (lt *layerTables) keptMask(cl *ctorLayout, flagName string, kept func(string) bool) uint32 {
var mask uint32
for i := range cl.fields {
g := &cl.fields[i]
if g.conditional() && g.flagName == flagName && kept(g.name) {
mask |= 1 << uint(g.flagBit)
}
}
return mask
}
// transcodeValue writes one present field value, recursing only into dirty
// subtrees and byte-copying everything else.
func (lt *layerTables) transcodeValue(in, out *bin.Buffer, f *fieldLayout, owner *ctorLayout, layer, depth int, walk *walkState) error {
if !lt.fieldDirty(f) {
pre := in.Buf
if err := walk.skipValue(canonical, in, f, owner, depth); err != nil {
return err
}
out.Put(pre[:len(pre)-len(in.Buf)])
return nil
}
switch f.kind {
case kindVector, kindVectorBare:
vectorDepth := depth + 1
if vectorDepth <= 0 || vectorDepth > walk.limits.maxDepth {
return limitf("vector nesting depth %d exceeds limit %d", vectorDepth, walk.limits.maxDepth)
}
if f.kind == kindVector {
id, err := in.Uint32()
if err != nil {
return err
}
if id != vectorTypeID {
return fmt.Errorf("expected vector id, got %#08x", id)
}
out.PutUint32(vectorTypeID)
}
n, err := in.Int()
if err != nil {
return err
}
if n < 0 {
return malformedf("negative vector length %d", n)
}
if max := walk.vectorLimit(owner, f); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(f), n, max)
}
if err := walk.addUnits(n, "vector "+ownerName(owner)+"."+fieldName(f)); err != nil {
return err
}
out.PutInt(n)
for i := 0; i < n; i++ {
if err := lt.transcodeValue(in, out, f.elem, nil, layer, vectorDepth, walk); err != nil {
return err
}
}
return nil
case kindObject:
return lt.transcodeObject(in, out, layer, depth+1, walk)
case kindBareObject:
bareDepth := depth + 1
if err := walk.enter(bareDepth, "bare constructor"); err != nil {
return err
}
cl, ok := canonical.bareByT[f.typeName]
if !ok {
return fmt.Errorf("unknown bare type %q", f.typeName)
}
// Bare objects have no CRC and (within 220..227) no changed bare ctor;
// recurse all-kept to reach any dirty descendants.
return lt.transcodeBody(in, out, cl, nil, layer, bareDepth, walk)
default:
// Primitive marked dirty should be impossible.
return fmt.Errorf("unexpected dirty primitive kind %d", f.kind)
}
}

View file

@ -1,388 +0,0 @@
// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.
// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.
package layerwire
// generatedTables maps a supported client layer to its canonical(227)->layer
// downgrade table. See docs/layer-compat-220-227-design.md.
var generatedTables = map[int]layerRaw{
220: {
rules: map[uint32]ruleRaw{
0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x08cbec07: {target: 0x3f7ee58b, keep: []string{"value", "emoticon"}}, // messageMediaDice
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest
0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone
0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0xb92f76cf, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton
0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline
0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy
0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl
0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback
0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique
0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView
0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737,
0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495,
0x445663a7, 0x44e56023, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea,
0x574b617f, 0x5806b4ec, 0x59080c20, 0x59e65335, 0x5b1ccb28, 0x67e731ad,
0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x78fbf3a8,
0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa,
0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac072444, 0xac6a83aa, 0xae152a69,
0xb07ed085, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b,
0xbaff072f, 0xbd8367b9, 0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e,
0xc39a2ade, 0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcef7e7a8,
0xcff63ea9, 0xd6e3b813, 0xda2ad647, 0xdacb836a, 0xdbbe6c6a, 0xdbce6389,
0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc, 0xf08d516b, 0xf13bbcd7,
0xf1d628ec, 0xf3a9244a, 0xfa2bc90a, 0xfb9c547a,
},
},
221: {
rules: map[uint32]ruleRaw{
0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest
0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone
0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton
0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline
0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy
0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl
0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback
0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique
0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView
0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737,
0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495,
0x445663a7, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea, 0x574b617f,
0x5806b4ec, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe,
0x71777116, 0x7781fe18, 0x78fbf3a8, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd,
0x8c0f91fb, 0x904ac7c7, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b,
0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b,
0xac072444, 0xac6a83aa, 0xae152a69, 0xb07ed085, 0xb22083a6, 0xb43df56c,
0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9, 0xbdac7e70,
0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xc6c1e5a7,
0xcd24cf44, 0xcdd4093d, 0xcef7e7a8, 0xcff63ea9, 0xd6e3b813, 0xdacb836a,
0xdbbe6c6a, 0xdbce6389, 0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc,
0xf08d516b, 0xf13bbcd7, 0xf1d628ec, 0xfa2bc90a,
},
},
222: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x32fabf1a, keep: []string{"flags", "request_write_access", "request_phone_number", "bot", "domain", "browser", "platform", "ip", "region"}}, // urlAuthResultRequest
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc,
0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495, 0x445663a7,
0x4880ed9a, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x5806b4ec, 0x59080c20,
0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18,
0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa,
0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6,
0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9,
0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d,
0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813, 0xdacb836a,
0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7, 0xf1d628ec,
0xfa2bc90a,
},
},
223: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "noforwards_my_enabled", "noforwards_peer_enabled", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0xf8f8eb1e, keep: []string{"flags", "request_write_access", "request_phone_number", "match_codes_first", "bot", "domain", "browser", "platform", "ip", "region", "match_codes", "user_id_hint"}}, // urlAuthResultRequest
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc,
0x3c29a3e2, 0x3c60b621, 0x3e81e078, 0x402b4495, 0x445663a7, 0x4880ed9a,
0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9,
0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x79eb8cb3, 0x7b9e1801,
0x83281dbd, 0x8c0f91fb, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b,
0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b,
0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d,
0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade,
0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813,
0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7,
0xf1d628ec, 0xfa2bc90a,
},
},
224: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0xb8425be9, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "open_answers", "revoting_disabled", "shuffle_answers", "hide_results_until_close", "creator", "question", "answers", "close_period", "close_date", "hash"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0773c080, 0x096b2aec, 0x0a617e7b,
0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x1fa01357, 0x1fe9a9bf, 0x24c26789,
0x2999beed, 0x2f51c337, 0x3c29a3e2, 0x445663a7, 0x4c2a5d62, 0x519524ea,
0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe,
0x7781fe18, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x933ca597,
0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9,
0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b,
0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e,
0xc39a2ade, 0xc556a45d, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813,
0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf1d628ec,
},
},
225: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4,
0x0efa0194, 0x140502d1, 0x24c26789, 0x2f51c337, 0x3c29a3e2, 0x445663a7,
0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9,
0x6c24f3dd, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x933ca597, 0x98a3a840,
0x9b00622b, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa,
0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b,
0xbaff072f, 0xbdac7e70, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xcd24cf44,
0xd6e3b813, 0xdacb836a, 0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc,
},
},
226: {
rules: map[uint32]ruleRaw{
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f51c337: {target: 0x774bbdf4, structural: "messages.chatInviteJoinResultWebView"}, // target field "url" not found in canonical (reorder/insert)
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4,
0x24c26789, 0x3c29a3e2, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20,
0x67e731ad, 0x682a41a9, 0x7b9e1801, 0x83281dbd, 0x9b00622b, 0x9d2eac97,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xb43df56c, 0xb532772b,
0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xc556a45d, 0xcd24cf44, 0xdacb836a,
0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc,
},
},
}
// inboundMethodUpgrades maps an old client method constructor id to the
// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)
// are listed: rewriting the 4-byte id yields a valid 227 request body.
// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):
//
// channels.editAdmin: field "rank": conditional-ness changed
// channels.toggleJoinRequest: 227 inserts flags integer "flags"
// contacts.search: 227 inserts flags integer "flags"
// messages.composeMessageWithAI: target field "change_tone" not found in canonical (reorder/insert)
// messages.getPollResults: 227-only field "poll_hash" is non-conditional
// messages.sendBotRequestedPeer: field "msg_id": conditional-ness changed
// messages.toggleNoForwards: 227 inserts flags integer "flags"
var inboundMethodUpgrades = map[uint32]uint32{
0x052b08db: 0xb8f106e3, // messages.setBotGuestChatResult
0x198fb446: 0x894cc99c, // messages.requestUrlAuth
0x24b524c5: 0x7f6a1e22, // channels.joinChannel
0x2d0a0571: 0x60ed4229, // account.toggleWebBrowserSettingsException
0x51e842e1: 0xb106e66c, // messages.editMessage
0x545cd15a: 0xfef48f62, // messages.sendMessage
0x54ae308e: 0xad0fa15c, // messages.saveDraft
0x63183030: 0xa5eec345, // messages.translateText
0x6c50051c: 0xde91436e, // messages.importChatInvite
0x737fc2ec: 0x8f9e6898, // stories.sendStory
0x83557dba: 0xa423bb51, // messages.editInlineBotMessage
0x9d4104e2: 0xabbbd346, // messages.summarizeText
0xb12c7125: 0x67a3f0de, // messages.acceptUrlAuth
0xb583ba46: 0x2c63a72b, // stories.editStory
}

View file

@ -1,249 +0,0 @@
package layerwire
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// loadLayerModel parses a vendored historical schema (_schema/layer-N.tl) into a
// schemaModel used as an independent oracle: downgraded bytes must parse cleanly
// against the actual target-layer schema.
func loadLayerModel(t *testing.T, layer int) *schemaModel {
t.Helper()
src, err := os.ReadFile(filepath.Join("_schema", fmt.Sprintf("layer-%d.tl", layer)))
if err != nil {
t.Fatalf("read layer %d schema: %v", layer, err)
}
m, err := parseSchemaModel(string(src))
if err != nil {
t.Fatalf("parse layer %d schema: %v", layer, err)
}
return m
}
// TestTranscodeIdentity verifies that targeting the canonical layer (or above)
// is a pure passthrough — the transcoder must never mutate 227 bytes.
func TestTranscodeIdentity(t *testing.T) {
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
out, err := Transcode(raw, CanonicalLayer)
if err != nil {
t.Fatalf("%T: identity transcode: %v", o, err)
}
if !bytes.Equal(out, raw) {
t.Errorf("%T: identity transcode changed bytes", o)
}
}
}
// TestTranscodeDowngradeValid downgrades the corpus to every supported layer and
// asserts the result parses cleanly (full byte consumption) against that layer's
// own schema. This is the core correctness oracle for the transcoder.
func TestTranscodeDowngradeValid(t *testing.T) {
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
model := loadLayerModel(t, layer)
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
out, err := Transcode(raw, layer)
if err != nil {
t.Errorf("layer %d %T: transcode: %v", layer, o, err)
continue
}
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil {
t.Errorf("layer %d %T: result invalid at target: %v", layer, o, err)
continue
}
if b.Len() != 0 {
t.Errorf("layer %d %T: %d trailing bytes in downgraded output", layer, o, b.Len())
}
}
}
}
// TestTranscodeMessageGolden checks that a message downgraded to 220 carries the
// 220 constructor id and is strictly shorter (dropped trailing fields).
func TestTranscodeMessageGolden(t *testing.T) {
const message220CRC = 0xb92f76cf
raw := mustEncode(t, canonicalCorpus()[1]) // the rich message
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("transcode message->220: %v", err)
}
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
id, err := b.PeekID()
if err != nil {
t.Fatalf("peek id: %v", err)
}
if id != message220CRC {
t.Fatalf("message@220 id = %#08x, want %#08x", id, message220CRC)
}
if len(out) >= len(raw) {
t.Errorf("downgraded message not shorter: %d >= %d", len(out), len(raw))
}
}
func TestTranscodeFormattedDateEntityLayerBoundary(t *testing.T) {
const formattedDateEntityCRC = 0x904ac7c7
entityCRC := func(crc uint32) []byte {
return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)}
}
msg := &tg.Message{
ID: 7,
PeerID: &tg.PeerUser{UserID: 2},
Date: 100,
Message: "Meet soon",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityFormattedDate{Offset: 5, Length: 4, Date: 1773436800, ShortDate: true, ShortTime: true},
},
}
raw := mustEncode(t, msg)
out222, err := Transcode(raw, 222)
if err != nil {
t.Fatalf("transcode message->222: %v", err)
}
if bytes.Contains(out222, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 222 output leaked formatted-date entity")
}
if !bytes.Contains(out222, entityCRC(messageEntityUnknownID)) {
t.Fatalf("layer 222 output missing messageEntityUnknown fallback")
}
m222 := loadLayerModel(t, 222)
b222 := &bin.Buffer{Buf: append([]byte(nil), out222...)}
if err := m222.skipObject(b222); err != nil || b222.Len() != 0 {
t.Fatalf("layer 222 formatted-date fallback does not parse cleanly (err=%v left=%d)", err, b222.Len())
}
out223, err := Transcode(raw, 223)
if err != nil {
t.Fatalf("transcode message->223: %v", err)
}
if !bytes.Contains(out223, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 223 output did not preserve formatted-date entity")
}
m223 := loadLayerModel(t, 223)
b223 := &bin.Buffer{Buf: append([]byte(nil), out223...)}
if err := m223.skipObject(b223); err != nil || b223.Len() != 0 {
t.Fatalf("layer 223 formatted-date output does not parse cleanly (err=%v left=%d)", err, b223.Len())
}
}
// TestTranscodePassthroughNonAPI verifies that a top-level constructor absent
// from the tg schema (an MTProto control object such as rpc_error) passes
// through untouched at any layer.
func TestTranscodePassthroughNonAPI(t *testing.T) {
var b bin.Buffer
b.PutID(0xc4b9f9bb) // rpc_error#c4b9f9bb (mt.*), not a tg API constructor
b.PutInt(420)
b.PutString("FLOOD_WAIT")
raw := b.Copy()
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("passthrough transcode: %v", err)
}
if !bytes.Equal(out, raw) {
t.Errorf("non-API object was modified by transcode")
}
}
// TestTranscodeChangedTypeNestedInUnchangedContainer is the case raised in
// review: an outer constructor whose CRC is IDENTICAL across 227 and the target
// layer (so a naive "same CRC ⇒ copy verbatim" would be wrong) but which nests a
// CHANGED type (message). The dirty closure must mark the outer container dirty
// purely because it can transitively reach a changed type, so the transcoder
// keeps the outer CRC yet recurses and rewrites the inner message to the target.
func TestTranscodeChangedTypeNestedInUnchangedContainer(t *testing.T) {
const (
message227CRC = 0x7600b9d3
message220CRC = 0xb92f76cf
)
// updates#... nests Vector<Update> → updateNewMessage → message:Message.
updates := &tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdateNewMessage{
Message: &tg.Message{ID: 7, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "nested"},
Pts: 1, PtsCount: 1,
},
},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
Chats: []tg.ChatClass{},
Date: 100, Seq: 1,
}
// Premise of the question: the OUTER container's CRC is unchanged at 220.
m220 := loadLayerModel(t, 220)
if canonical.byName["updates"].crc != m220.byName["updates"].crc {
t.Skip("updates CRC differs 220<->227; premise no longer holds")
}
raw := mustEncode(t, updates)
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("transcode updates->220: %v", err)
}
// Outer CRC preserved (it really is unchanged).
if id, _ := (&bin.Buffer{Buf: out}).PeekID(); id != canonical.byName["updates"].crc {
t.Fatalf("outer updates id changed to %#08x", id)
}
// Inner message rewritten to the 220 constructor; the 227 one must be gone.
le := func(crc uint32) []byte { return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)} }
if bytes.Contains(out, le(message227CRC)) {
t.Errorf("downgraded output still contains the 227 message constructor")
}
if !bytes.Contains(out, le(message220CRC)) {
t.Errorf("downgraded output missing the 220 message constructor")
}
// Rigorous: the whole thing must parse cleanly against the real 220 schema —
// impossible if a 227-only nested constructor leaked through.
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := m220.skipObject(b); err != nil || b.Len() != 0 {
t.Fatalf("downgraded updates invalid at 220 (err=%v left=%d)", err, b.Len())
}
}
// TestTranscodePollResults exercises the pollAnswerVoters structural transform.
func TestTranscodePollResults(t *testing.T) {
raw := mustEncode(t, canonicalCorpus()[10]) // PollResults
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
out, err := Transcode(raw, layer)
if err != nil {
t.Fatalf("layer %d: pollResults transcode: %v", layer, err)
}
model := loadLayerModel(t, layer)
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil || b.Len() != 0 {
t.Errorf("layer %d: pollResults invalid (err=%v left=%d)", layer, err, b.Len())
}
}
}
// TestTranscodePollAnswerVotersAbsentFlag exercises the structural transform's
// flag-bit-2-unset path: a pollAnswerVoters whose voters field is absent in 227
// (flags.2 clear) must still emit voters:0 (unconditional int) at older layers.
func TestTranscodePollAnswerVotersAbsentFlag(t *testing.T) {
// voters absent (flag bit 2 unset): Voters=0 ⇒ gotd SetFlags leaves flags.2 clear.
pr := &tg.PollResults{
Results: []tg.PollAnswerVoters{{Option: []byte{0}, Chosen: true}},
TotalVoters: 0,
}
raw := mustEncode(t, pr)
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
out, err := Transcode(raw, layer)
if err != nil {
t.Fatalf("layer %d: transcode: %v", layer, err)
}
model := loadLayerModel(t, layer)
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil || b.Len() != 0 {
t.Errorf("layer %d: voters-absent pollResults invalid (err=%v left=%d)", layer, err, b.Len())
}
}
}

View file

@ -1,433 +0,0 @@
package layerwire
import (
"errors"
"fmt"
"io"
"math"
"github.com/gotd/td/bin"
)
// ErrMalformed identifies invalid or truncated TL wire data. Callers may use
// errors.Is to distinguish it from an otherwise well-formed request which was
// rejected by a walker resource limit.
var ErrMalformed = errors.New("layerwire: malformed TL")
// ErrResourceLimit identifies structurally valid-looking TL input which would
// exceed a walker resource budget.
var ErrResourceLimit = errors.New("layerwire: resource limit")
const (
defaultMaxVectorElements = 4096
defaultMaxWalkDepth = 32
defaultMaxWalkUnits = 131072 // constructors + declared vector elements
defaultMaxFieldBytes = 16 << 20
defaultMaxTotalBytes = 32 << 20
)
// A very small number of API methods have a documented limit above the
// package-wide default. Keeping overrides keyed by constructor and field makes
// every exception explicit and prevents a large vector in an unrelated method
// from inheriting the larger allowance.
type vectorLimitKey struct {
owner string
field string
}
var vectorElementLimitOverrides = map[vectorLimitKey]int{
{owner: "contacts.editCloseFriends", field: "id"}: 5000,
{owner: "contacts.setBlocked", field: "id"}: 5000,
}
type walkLimits struct {
maxVectorElements int
maxDepth int
maxUnits uint64
maxFieldBytes uint64
maxTotalBytes uint64
}
var defaultWalkLimits = walkLimits{
maxVectorElements: defaultMaxVectorElements,
maxDepth: defaultMaxWalkDepth,
maxUnits: defaultMaxWalkUnits,
maxFieldBytes: defaultMaxFieldBytes,
maxTotalBytes: defaultMaxTotalBytes,
}
// walkState is deliberately request-scoped. Every branch of one transform
// shares it, so splitting a large value across nested constructors or vectors
// cannot reset the aggregate budgets.
type walkState struct {
limits walkLimits
units uint64
bytes uint64
}
func newWalkState() *walkState {
return &walkState{limits: defaultWalkLimits}
}
func malformedf(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrMalformed, fmt.Sprintf(format, args...))
}
func limitf(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrResourceLimit, fmt.Sprintf(format, args...))
}
// classifyWalkError makes all public walker/transform failures classifiable,
// including errors returned by the low-level gotd bin decoder.
func classifyWalkError(err error) error {
if err == nil || errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
return err
}
return fmt.Errorf("%w: %v", ErrMalformed, err)
}
func (s *walkState) enter(depth int, what string) error {
if depth <= 0 || depth > s.limits.maxDepth {
return limitf("%s nesting depth %d exceeds limit %d", what, depth, s.limits.maxDepth)
}
return s.addUnits(1, what)
}
func (s *walkState) addUnits(n int, what string) error {
if n < 0 {
return malformedf("negative %s count %d", what, n)
}
u := uint64(n)
// Subtraction form avoids overflow even if limits are changed later.
if s.units > s.limits.maxUnits || u > s.limits.maxUnits-s.units {
return limitf("constructor/vector element budget exceeds %d at %s", s.limits.maxUnits, what)
}
s.units += u
return nil
}
func (s *walkState) addBytes(n uint64, what string) error {
if n > s.limits.maxFieldBytes {
return limitf("%s payload length %d exceeds per-field limit %d", what, n, s.limits.maxFieldBytes)
}
if s.bytes > s.limits.maxTotalBytes || n > s.limits.maxTotalBytes-s.bytes {
return limitf("string/bytes payload budget exceeds %d at %s", s.limits.maxTotalBytes, what)
}
s.bytes += n
return nil
}
func (s *walkState) vectorLimit(owner *ctorLayout, f *fieldLayout) int {
if owner != nil && f != nil {
if n := vectorElementLimitOverrides[vectorLimitKey{owner: owner.name, field: f.name}]; n > 0 {
return n
}
}
return s.limits.maxVectorElements
}
const maxConstructorFlagWords = 8
type constructorFlagWord struct {
name string
value uint32
}
// ValidateCanonicalRequest performs a complete, allocation-free structural
// preflight of one canonical Layer 227 method request. It is intended for the
// router seam immediately before typed dispatch. A successful result means the
// walker consumed exactly one known function constructor and all of its body.
func ValidateCanonicalRequest(body []byte) error {
b := &bin.Buffer{Buf: body}
id, err := b.PeekID()
if err != nil {
return classifyWalkError(err)
}
cl := canonical.byCRC[id]
if cl == nil {
return malformedf("unknown canonical request constructor %#08x", id)
}
if !cl.isFunc {
return malformedf("constructor %s (%#08x) is not a method", cl.name, id)
}
return validateRequestLayout(canonical, cl, body)
}
func validateRequestLayout(m *schemaModel, cl *ctorLayout, body []byte) error {
b := &bin.Buffer{Buf: body}
s := newWalkState()
if err := s.skipObject(m, b, 1); err != nil {
return classifyWalkError(err)
}
if b.Len() != 0 {
return malformedf("%d trailing bytes after canonical request %s", b.Len(), cl.name)
}
return nil
}
// skipObject advances b past one boxed object (CRC + body), resolving the
// constructor from m. This compatibility wrapper creates a fresh budget; all
// production transforms call the stateful variant directly.
func (m *schemaModel) skipObject(b *bin.Buffer) error {
return classifyWalkError(newWalkState().skipObject(m, b, 1))
}
func (s *walkState) skipObject(m *schemaModel, b *bin.Buffer, depth int) error {
if err := s.enter(depth, "constructor"); err != nil {
return err
}
id, err := b.PeekID()
if err != nil {
return err
}
cl, ok := m.byCRC[id]
if !ok {
return malformedf("unknown constructor %#08x", id)
}
if err := b.ConsumeID(id); err != nil {
return err
}
return s.skipCtorBody(m, b, cl, depth)
}
// skipCtorBody advances b past a constructor body (no leading CRC), evaluating
// flag integers so conditional fields are read iff present. The constructor's
// unit and depth have already been charged by the caller.
func (s *walkState) skipCtorBody(m *schemaModel, b *bin.Buffer, cl *ctorLayout, depth int) error {
// Layer 227 constructors currently use at most flags + flags2. Keep generous fixed stack
// storage so the allocation-free preflight remains allocation-free on the hottest flagged
// methods; the explicit bound also prevents a future malformed/generated layout from turning
// every request into an attacker-amplified map allocation.
var flags [maxConstructorFlagWords]constructorFlagWord
flagCount := 0
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, err := b.Uint32()
if err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
if flagCount >= len(flags) {
return limitf("constructor %s has more than %d flags words", cl.name, len(flags))
}
flags[flagCount] = constructorFlagWord{name: f.name, value: v}
flagCount++
continue
}
if f.conditional() {
var (
flagValue uint32
found bool
)
for j := 0; j < flagCount; j++ {
if flags[j].name == f.flagName {
flagValue = flags[j].value
found = true
break
}
}
if !found {
return malformedf("constructor %s conditional field %s references missing flags word %s", cl.name, f.name, f.flagName)
}
if flagValue&(1<<uint(f.flagBit)) == 0 {
continue
}
}
if err := s.skipValue(m, b, f, cl, depth); err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
}
return nil
}
// skipValue advances b past one already-known-present field value.
func (s *walkState) skipValue(m *schemaModel, b *bin.Buffer, f *fieldLayout, owner *ctorLayout, depth int) error {
switch f.kind {
case kindInt:
return skipFixed(b, 4)
case kindLong, kindDouble:
return skipFixed(b, 8)
case kindInt128:
return skipFixed(b, 16)
case kindInt256:
return skipFixed(b, 32)
case kindBytes:
return s.skipTLBytes(b, "bytes")
case kindString:
return s.skipTLBytes(b, "string")
case kindBool:
if err := s.addUnits(1, "Bool constructor"); err != nil {
return err
}
id, err := b.Uint32()
if err != nil {
return err
}
if id != bin.TypeTrue && id != bin.TypeFalse {
return malformedf("invalid Bool constructor %#08x", id)
}
return nil
case kindTrue:
return nil
case kindVector, kindVectorBare:
vectorDepth := depth + 1
if vectorDepth <= 0 || vectorDepth > s.limits.maxDepth {
return limitf("vector nesting depth %d exceeds limit %d", vectorDepth, s.limits.maxDepth)
}
if f.kind == kindVector {
id, err := b.Uint32()
if err != nil {
return err
}
if id != vectorTypeID {
return malformedf("expected vector id, got %#08x", id)
}
}
n, err := b.Int()
if err != nil {
return err
}
if n < 0 {
return malformedf("negative vector length %d", n)
}
if max := s.vectorLimit(owner, f); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(f), n, max)
}
if err := s.addUnits(n, "vector "+ownerName(owner)+"."+fieldName(f)); err != nil {
return err
}
if width, ok := fixedWireWidth(f.elem); ok {
total, ok := checkedMulInt(n, width)
if !ok {
return malformedf("vector byte length overflow: %d * %d", n, width)
}
return skipFixed(b, total)
}
for i := 0; i < n; i++ {
if err := s.skipValue(m, b, f.elem, nil, vectorDepth); err != nil {
return fmt.Errorf("vector element %d: %w", i, err)
}
}
return nil
case kindObject:
return s.skipObject(m, b, depth+1)
case kindBareObject:
bareDepth := depth + 1
if err := s.enter(bareDepth, "bare constructor"); err != nil {
return err
}
cl, ok := m.bareByT[f.typeName]
if !ok {
return malformedf("unknown bare type %q", f.typeName)
}
return s.skipCtorBody(m, b, cl, bareDepth)
default:
return malformedf("bad wire kind %d", f.kind)
}
}
// skipTLBytes parses TL's 1/4-byte length prefix directly and advances the
// input slice. Unlike bin.Buffer.Bytes it never copies payload data.
func (s *walkState) skipTLBytes(b *bin.Buffer, what string) error {
if len(b.Buf) == 0 {
return io.ErrUnexpectedEOF
}
var header, payload uint64
switch b.Buf[0] {
case 254:
if len(b.Buf) < 4 {
return io.ErrUnexpectedEOF
}
header = 4
payload = uint64(b.Buf[1]) | uint64(b.Buf[2])<<8 | uint64(b.Buf[3])<<16
case 255:
return malformedf("invalid %s length prefix 255", what)
default:
header = 1
payload = uint64(b.Buf[0])
}
if err := s.addBytes(payload, what); err != nil {
return err
}
encoded, ok := checkedAddUint64(header, payload)
if !ok {
return malformedf("%s encoded length overflow", what)
}
withPadding, ok := checkedAddUint64(encoded, 3)
if !ok {
return malformedf("%s padded length overflow", what)
}
padded := withPadding &^ uint64(3)
if padded > uint64(math.MaxInt) {
return malformedf("%s padded length %d overflows int", what, padded)
}
if uint64(len(b.Buf)) < padded {
return io.ErrUnexpectedEOF
}
b.Buf = b.Buf[int(padded):]
return nil
}
func skipFixed(b *bin.Buffer, n int) error {
if n < 0 {
return malformedf("negative fixed-width skip %d", n)
}
if len(b.Buf) < n {
return io.ErrUnexpectedEOF
}
b.Buf = b.Buf[n:]
return nil
}
func fixedWireWidth(f *fieldLayout) (int, bool) {
if f == nil {
return 0, false
}
switch f.kind {
case kindInt:
return 4, true
case kindLong, kindDouble:
return 8, true
case kindInt128:
return 16, true
case kindInt256:
return 32, true
case kindTrue:
return 0, true
default:
// Bool deliberately stays on the element loop so constructor ids are
// validated and charged to the aggregate constructor budget.
return 0, false
}
}
func checkedMulInt(a, b int) (int, bool) {
if a < 0 || b < 0 {
return 0, false
}
if a != 0 && b > math.MaxInt/a {
return 0, false
}
return a * b, true
}
func checkedAddUint64(a, b uint64) (uint64, bool) {
if b > math.MaxUint64-a {
return 0, false
}
return a + b, true
}
func ownerName(cl *ctorLayout) string {
if cl == nil || cl.name == "" {
return "<nested>"
}
return cl.name
}
func fieldName(f *fieldLayout) string {
if f == nil || f.name == "" {
return "<element>"
}
return f.name
}

View file

@ -1,263 +0,0 @@
package layerwire
import (
"errors"
"math"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func TestValidateCanonicalRequestFlaggedHotPathAllocatesNothing(t *testing.T) {
var body bin.Buffer
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "hello",
RandomID: 7,
}
if err := req.Encode(&body); err != nil {
t.Fatalf("encode request: %v", err)
}
if err := ValidateCanonicalRequest(body.Buf); err != nil {
t.Fatalf("validate request: %v", err)
}
if allocs := testing.AllocsPerRun(1000, func() {
if err := ValidateCanonicalRequest(body.Buf); err != nil {
panic(err)
}
}); allocs != 0 {
t.Fatalf("canonical request preflight allocations = %.2f, want 0", allocs)
}
}
func TestValidateCanonicalRequestVectorLimits(t *testing.T) {
editCloseFriends := canonical.byName["contacts.editCloseFriends"]
if editCloseFriends == nil {
t.Fatal("contacts.editCloseFriends missing from canonical schema")
}
t.Run("explicit_5000_override", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(5000)
for i := 0; i < 5000; i++ {
body.PutLong(int64(i))
}
if err := ValidateCanonicalRequest(body.Buf); err != nil {
t.Fatalf("validate legal 5000-element close-friends request: %v", err)
}
})
t.Run("override_stops_at_5000", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(5001)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("default_4096", func(t *testing.T) {
getMessages := canonical.byName["messages.getMessages"]
var body bin.Buffer
body.PutID(getMessages.crc)
body.PutVectorHeader(defaultMaxVectorElements + 1)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("max_int32_count_rejected_before_iteration", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
}
func TestValidateCanonicalRequestDepthLimit(t *testing.T) {
invoke := canonical.byName["invokeWithoutUpdates"]
leaf := canonical.byName["help.getConfig"]
if invoke == nil || leaf == nil {
t.Fatal("generic wrapper methods missing from canonical schema")
}
request := func(wrappers int) []byte {
var body bin.Buffer
for i := 0; i < wrappers; i++ {
body.PutID(invoke.crc)
}
body.PutID(leaf.crc)
return body.Buf
}
if err := ValidateCanonicalRequest(request(defaultMaxWalkDepth - 1)); err != nil {
t.Fatalf("depth exactly %d rejected: %v", defaultMaxWalkDepth, err)
}
err := ValidateCanonicalRequest(request(defaultMaxWalkDepth))
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("depth %d error = %v, want ErrResourceLimit", defaultMaxWalkDepth+1, err)
}
}
func TestTLBytesSkipIsZeroCopyAndBounded(t *testing.T) {
var encoded bin.Buffer
encoded.PutBytes([]byte("payload"))
fieldLen := len(encoded.Buf)
raw := append(encoded.Copy(), 0xaa, 0xbb, 0xcc, 0xdd)
b := &bin.Buffer{Buf: raw}
walk := newWalkState()
if err := walk.skipTLBytes(b, "bytes"); err != nil {
t.Fatalf("skip bytes: %v", err)
}
if len(b.Buf) != 4 || &b.Buf[0] != &raw[fieldLen] {
t.Fatalf("walker did not retain the original backing buffer")
}
t.Run("per_field_budget", func(t *testing.T) {
limited := newWalkState()
limited.limits.maxFieldBytes = 3
probe := &bin.Buffer{Buf: encoded.Copy()}
err := limited.skipTLBytes(probe, "bytes")
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("aggregate_budget", func(t *testing.T) {
limited := newWalkState()
limited.limits.maxTotalBytes = 10
first := &bin.Buffer{Buf: encoded.Copy()}
if err := limited.skipTLBytes(first, "bytes"); err != nil {
t.Fatalf("first field: %v", err)
}
second := &bin.Buffer{Buf: encoded.Copy()}
err := limited.skipTLBytes(second, "bytes")
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("second error = %v, want ErrResourceLimit", err)
}
})
t.Run("truncated_payload_is_malformed", func(t *testing.T) {
importAuth := canonical.byName["auth.importAuthorization"]
var body bin.Buffer
body.PutID(importAuth.crc)
body.PutLong(1)
body.Put([]byte{5, 'a', 'b'}) // declares five bytes, lacks payload/padding
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want only ErrMalformed", err)
}
})
}
func TestInboundTransformsShareWalkerBudgets(t *testing.T) {
t.Run("canonical_alias", func(t *testing.T) {
var body bin.Buffer
body.PutID(0x41d41ade) // DrKLO messages.forwardMessages alias
body.PutUint32(0)
body.PutID(canonical.byName["inputPeerEmpty"].crc)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, ok, err := UpgradeInbound(0x41d41ade, &body)
if !ok || !errors.Is(err, ErrResourceLimit) {
t.Fatalf("ok=%v error=%v, want matched ErrResourceLimit", ok, err)
}
})
t.Run("drift_body_transform", func(t *testing.T) {
var body bin.Buffer
body.PutID(0x2e1ee318) // DrKLO langpack.getStrings body transform
body.PutString("en")
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, ok, err := UpgradeInbound(0x2e1ee318, &body)
if !ok || !errors.Is(err, ErrResourceLimit) {
t.Fatalf("ok=%v error=%v, want matched ErrResourceLimit", ok, err)
}
})
t.Run("outbound_structural_transform", func(t *testing.T) {
poll := canonical.byName["pollAnswerVoters"]
var body bin.Buffer
body.PutID(poll.crc)
body.PutUint32(1 << 2)
body.PutBytes(nil)
body.PutInt(1)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, err := Transcode(body.Buf, CanonicalLayer-1)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
}
func TestWalkerArithmeticAndMalformedClassification(t *testing.T) {
if defaultMaxWalkUnits != 131072 {
t.Fatalf("default constructor/vector budget = %d, want 131072", defaultMaxWalkUnits)
}
if _, ok := checkedMulInt(math.MaxInt, 2); ok {
t.Fatal("checkedMulInt accepted overflow")
}
if _, ok := checkedAddUint64(math.MaxUint64, 1); ok {
t.Fatal("checkedAddUint64 accepted overflow")
}
t.Run("aggregate_constructor_and_vector_units", func(t *testing.T) {
editCloseFriends := canonical.byName["contacts.editCloseFriends"]
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(4)
for i := 0; i < 4; i++ {
body.PutLong(int64(i))
}
walk := newWalkState()
walk.limits.maxUnits = 4 // top constructor + four elements needs five
probe := &bin.Buffer{Buf: body.Buf}
err := walk.skipObject(canonical, probe, 1)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
tests := []struct {
name string
body []byte
}{
{name: "empty"},
{name: "unknown_constructor", body: []byte{1, 2, 3, 4}},
{name: "trailing_bytes", body: append(methodIDBytes(canonical.byName["help.getConfig"].crc), 0, 0, 0, 0)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCanonicalRequest(tt.body)
if !errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want only ErrMalformed", err)
}
})
}
}
func methodIDBytes(id uint32) []byte {
var b bin.Buffer
b.PutID(id)
return b.Buf
}
func FuzzValidateCanonicalRequest(f *testing.F) {
f.Add(methodIDBytes(canonical.byName["help.getConfig"].crc))
f.Add([]byte{})
f.Add([]byte{1, 2, 3, 4})
f.Fuzz(func(t *testing.T, body []byte) {
err := ValidateCanonicalRequest(body)
if err != nil && !errors.Is(err, ErrMalformed) && !errors.Is(err, ErrResourceLimit) {
t.Fatalf("unclassified walker error: %v", err)
}
})
}

View file

@ -1,35 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
)
func mustEncode(t *testing.T, o bin.Encoder) []byte {
t.Helper()
var b bin.Buffer
if err := o.Encode(&b); err != nil {
t.Fatalf("encode %T: %v", o, err)
}
return b.Copy()
}
// TestWalkConsumesCanonicalObjects encodes a diverse corpus of canonical (gotd,
// Layer 227) objects and asserts the generic walker consumes every byte. Full
// consumption proves the layout handles each field's wire kind (flags,
// multi-flags, conditionals, vectors, nested boxed/bare objects) exactly as gotd
// encoded them.
func TestWalkConsumesCanonicalObjects(t *testing.T) {
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
b := &bin.Buffer{Buf: append([]byte(nil), raw...)}
if err := canonical.skipObject(b); err != nil {
t.Errorf("%T: walk error: %v", o, err)
continue
}
if b.Len() != 0 {
t.Errorf("%T: %d/%d bytes left after walk", o, b.Len(), len(raw))
}
}
}

View file

@ -5,7 +5,7 @@ import (
"telesrv/internal/seed/appearance"
"github.com/gotd/td/tg"
"github.com/iamxvbaba/td/tg"
)
const appearanceSeedDCID = 2
@ -18,16 +18,16 @@ var peerColorOptionsCache = struct {
profile []tg.HelpPeerColorOption
}{}
func seedWallPapers() []tg.WallPaperClass {
func DefaultWallPapers() []tg.WallPaperClass {
catalog := appearance.Default()
out := make([]tg.WallPaperClass, 0, len(catalog.Wallpapers))
for _, wallpaper := range catalog.Wallpapers {
out = append(out, seedWallPaper(wallpaper))
out = append(out, DefaultWallPaper(wallpaper))
}
return out
}
// LookupWallPaper resolves a cloud wallpaper from the default seed catalog.
// LookupWallPaper resolves a cloud wallpaper from the Default seed catalog.
func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
if in, ok := input.(*tg.InputWallPaperNoFile); ok {
return &tg.WallPaperNoFile{ID: in.ID}, true
@ -35,13 +35,13 @@ func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
catalog := appearance.Default()
for _, wallpaper := range catalog.Wallpapers {
if inputWallPaperMatches(input, wallpaper) {
return seedWallPaper(wallpaper), true
return DefaultWallPaper(wallpaper), true
}
}
return nil, false
}
// LookupWallPapers resolves multiple wallpapers from the default seed catalog.
// LookupWallPapers resolves multiple wallpapers from the Default seed catalog.
func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) {
out := make([]tg.WallPaperClass, 0, len(inputs))
for _, input := range inputs {
@ -65,28 +65,28 @@ func inputWallPaperMatches(input tg.InputWallPaperClass, wallpaper appearance.Wa
}
}
func seedWallPaper(in appearance.Wallpaper) tg.WallPaperClass {
func DefaultWallPaper(in appearance.Wallpaper) tg.WallPaperClass {
if in.Type == 1 || in.Document.ID == 0 {
out := &tg.WallPaperNoFile{ID: in.ID}
out.SetDefault(in.Default)
out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings))
out.SetSettings(DefaultWallPaperSettings(in.Settings))
return out
}
out := &tg.WallPaper{
ID: in.ID,
AccessHash: in.AccessHash,
Slug: in.Slug,
Document: seedDocument(in.Document),
Document: DefaultDocument(in.Document),
}
out.SetDefault(in.Default)
out.SetPattern(in.Pattern)
out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings))
out.SetSettings(DefaultWallPaperSettings(in.Settings))
return out
}
func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings {
func DefaultWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings {
var out tg.WallPaperSettings
out.SetBlur(in.Blur)
out.SetMotion(in.Motion)
@ -111,7 +111,7 @@ func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings
return out
}
func seedDocument(in appearance.Document) tg.DocumentClass {
func DefaultDocument(in appearance.Document) tg.DocumentClass {
if in.ID == 0 {
return &tg.DocumentEmpty{}
}
@ -121,14 +121,14 @@ func seedDocument(in appearance.Document) tg.DocumentClass {
Date: in.Date,
MimeType: in.MimeType,
Size: in.Size,
Thumbs: seedPhotoSizes(in.Thumbs),
Thumbs: DefaultPhotoSizes(in.Thumbs),
DCID: appearanceSeedDCID,
Attributes: seedDocumentAttributes(in.Attributes),
Attributes: DefaultDocumentAttributes(in.Attributes),
FileReference: nil,
}
}
func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
func DefaultPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
out := make([]tg.PhotoSizeClass, 0, len(in))
for _, size := range in {
switch size.Kind {
@ -144,7 +144,7 @@ func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
return out
}
func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass {
func DefaultDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(in))
for _, attr := range in {
switch attr.Kind {
@ -159,20 +159,20 @@ func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttr
return out
}
func seedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
func DefaultPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
if profile {
peerColorOptionsCache.profileOnce.Do(func() {
peerColorOptionsCache.profile = buildSeedPeerColorOptions(true)
peerColorOptionsCache.profile = buildDefaultPeerColorOptions(true)
})
return clonePeerColorOptions(peerColorOptionsCache.profile)
}
peerColorOptionsCache.regularOnce.Do(func() {
peerColorOptionsCache.regular = buildSeedPeerColorOptions(false)
peerColorOptionsCache.regular = buildDefaultPeerColorOptions(false)
})
return clonePeerColorOptions(peerColorOptionsCache.regular)
}
func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
func buildDefaultPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
catalog := appearance.Default()
source := catalog.PeerColors
if profile {
@ -193,10 +193,10 @@ func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
if groupMin > 0 {
option.SetGroupMinLevel(groupMin)
}
if colors := seedPeerColorSet(color.Colors); colors != nil {
if colors := DefaultPeerColorSet(color.Colors); colors != nil {
option.SetColors(colors)
}
if colors := seedPeerColorSet(color.DarkColors); colors != nil {
if colors := DefaultPeerColorSet(color.DarkColors); colors != nil {
option.SetDarkColors(colors)
}
out = append(out, option)
@ -246,7 +246,7 @@ func boundedPeerColorMinLevel(level int) int {
return level
}
func seedPeerColorID(id int, profile bool) (bool, bool) {
func DefaultPeerColorID(id int, profile bool) (bool, bool) {
catalog := appearance.Default()
source := catalog.PeerColors
if profile {
@ -263,7 +263,7 @@ func seedPeerColorID(id int, profile bool) (bool, bool) {
return false, true
}
func seedPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass {
func DefaultPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass {
if in == nil {
return nil
}

View file

@ -3,7 +3,7 @@ package tdesktop
import (
"time"
"github.com/gotd/td/tg"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/links"
)
@ -14,7 +14,7 @@ import (
// (记录于 docs/compatibility-matrix.md
func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config {
meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/"
return &tg.Config{
config := &tg.Config{
Date: int(now.Unix()),
Expires: int(now.Add(time.Hour).Unix()),
TestMode: false,
@ -55,6 +55,8 @@ func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL strin
MessageLengthMax: 4096,
WebfileDCID: dc,
}
config.SetReactionsDefault(&tg.ReactionEmoji{Emoticon: DefaultReactionEmoticon})
return config
}
// NearestDC 构造 help.getNearestDc 返回值。

View file

@ -0,0 +1,20 @@
package tdesktop
import (
"testing"
"time"
"github.com/iamxvbaba/td/tg"
)
func TestBuildConfigIncludesDefaultReaction(t *testing.T) {
config := BuildConfig(2, "127.0.0.1", 2398, time.Unix(1, 0), "https://telesrv.net")
reaction, ok := config.GetReactionsDefault()
if !ok {
t.Fatal("reactions_default is absent")
}
emoji, ok := reaction.(*tg.ReactionEmoji)
if !ok || emoji.Emoticon != DefaultReactionEmoticon {
t.Fatalf("reactions_default = %#v, want %q emoji", reaction, DefaultReactionEmoticon)
}
}

View file

@ -1,7 +1,7 @@
package tdesktop
import (
"github.com/gotd/td/tg"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/catalog"
)

View file

@ -3,7 +3,7 @@ package tdesktop
import (
"time"
"github.com/gotd/td/tg"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/appearance"
"telesrv/internal/seed/catalog"
@ -147,7 +147,7 @@ func catalogThemeSettings(s appearance.ThemeSettings, base tg.BaseThemeClass) tg
ts.SetMessageColors(append([]int(nil), s.MessageColors...))
}
if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 {
ts.SetWallpaper(seedWallPaper(s.Wallpaper))
ts.SetWallpaper(DefaultWallPaper(s.Wallpaper))
}
return ts
}
@ -200,7 +200,7 @@ func WallPapers(hash int64) tg.AccountWallPapersClass {
if hash == wallPapersHash {
return &tg.AccountWallPapersNotModified{}
}
wallpapers := seedWallPapers()
wallpapers := DefaultWallPapers()
return &tg.AccountWallPapers{
Hash: wallPapersHash,
Wallpapers: wallpapers,
@ -256,13 +256,17 @@ func DefaultGroupPhotoEmojis() tg.EmojiListClass {
const availableReactionsHash = 20260602
const emptyStickerSetHash = 20260602
// DefaultReactionEmoticon is the account fallback used by config and the
// built-in available-reactions catalog.
const DefaultReactionEmoticon = "\U0001f44d"
type defaultReaction struct {
emoticon string
title string
}
var defaultAvailableReactions = []defaultReaction{
{emoticon: "\U0001f44d", title: "Thumbs Up"},
{emoticon: DefaultReactionEmoticon, title: "Thumbs Up"},
{emoticon: "\u2764\ufe0f", title: "Red Heart"},
{emoticon: "\U0001f602", title: "Face With Tears of Joy"},
{emoticon: "\U0001f62e", title: "Face With Open Mouth"},
@ -425,7 +429,7 @@ var defaultPeerColors = []defaultPeerColor{
// IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette.
func IsPeerColorID(id int) bool {
if found, seeded := seedPeerColorID(id, false); seeded {
if found, seeded := DefaultPeerColorID(id, false); seeded {
return found
}
for _, color := range defaultPeerColors {
@ -438,7 +442,7 @@ func IsPeerColorID(id int) bool {
// IsPeerProfileColorID reports whether id is in the profile background palette.
func IsPeerProfileColorID(id int) bool {
if found, seeded := seedPeerColorID(id, true); seeded {
if found, seeded := DefaultPeerColorID(id, true); seeded {
return found
}
return IsPeerColorID(id)
@ -448,7 +452,7 @@ func PeerColors(hash int) tg.HelpPeerColorsClass {
if hash == peerColorsHash {
return &tg.HelpPeerColorsNotModified{}
}
colors := seedPeerColorOptions(false)
colors := DefaultPeerColorOptions(false)
if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors {
@ -468,7 +472,7 @@ func PeerProfileColors(hash int) tg.HelpPeerColorsClass {
if hash == peerProfileColorsHash {
return &tg.HelpPeerColorsNotModified{}
}
colors := seedPeerColorOptions(true)
colors := DefaultPeerColorOptions(true)
if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors {

View file

@ -4,7 +4,7 @@ import (
"math"
"testing"
"github.com/gotd/td/tg"
"github.com/iamxvbaba/td/tg"
)
func TestNotifySettingsDefaultIsAudible(t *testing.T) {
@ -233,7 +233,7 @@ func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) {
}
}
func TestWallPapersUsesOrangeFileCatalog(t *testing.T) {
func TestWallPapersUsesDefaultFileCatalog(t *testing.T) {
got, ok := WallPapers(0).(*tg.AccountWallPapers)
if !ok {
t.Fatalf("WallPapers(0) = %T, want modified list", got)
@ -249,14 +249,14 @@ func TestWallPapersUsesOrangeFileCatalog(t *testing.T) {
t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0])
}
if wallpaper.ID == 0 || wallpaper.AccessHash == 0 || wallpaper.Slug == "" {
t.Fatalf("wallpaper identity = id %d hash %d slug %q, want seed ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug)
t.Fatalf("wallpaper identity = id %d hash %d slug %q, want Default ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug)
}
doc, ok := wallpaper.Document.(*tg.Document)
if !ok {
t.Fatalf("wallpaper document = %T, want *tg.Document", wallpaper.Document)
}
if doc.ID == 0 || doc.AccessHash == 0 || doc.Size == 0 || doc.MimeType == "" || doc.DCID != appearanceSeedDCID {
t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable seed document",
t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable Default document",
doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID)
}
if len(doc.Thumbs) == 0 {
@ -392,7 +392,7 @@ func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
}
if len(got.Colors) != 21 {
t.Fatalf("PeerColors(0).Colors length = %d, want seed palette count 21", len(got.Colors))
t.Fatalf("PeerColors(0).Colors length = %d, want Default palette count 21", len(got.Colors))
}
withExplicitColors := 0
for i, option := range got.Colors {
@ -412,7 +412,7 @@ func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
withExplicitColors++
}
if withExplicitColors == 0 {
t.Fatal("PeerColors() has no explicit seed color sets")
t.Fatal("PeerColors() has no explicit Default color sets")
}
if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash))
@ -428,7 +428,7 @@ func TestPeerProfileColorsAreNonEmptyHashableProfileSets(t *testing.T) {
t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
}
if len(got.Colors) != 16 {
t.Fatalf("PeerProfileColors(0).Colors length = %d, want seed profile palette count 16", len(got.Colors))
t.Fatalf("PeerProfileColors(0).Colors length = %d, want Default profile palette count 16", len(got.Colors))
}
for i, option := range got.Colors {
if !IsPeerProfileColorID(option.ColorID) {

View file

@ -4,6 +4,7 @@ package config
import (
"bufio"
"fmt"
"net/url"
"os"
"strconv"
"strings"
@ -36,13 +37,23 @@ type Config struct {
// MTProtoMaxConcurrentHandshakes 限制昂贵 RSA/DH exchange 并发;负数关闭。
MTProtoMaxConcurrentHandshakes int
// MTProto RPC 使用 Server 共享公平调度器per-connection 与 global 预算共同限制
// goroutine、排队任务和 request body 内存。
// goroutine、排队任务和 request memory charge。legacy charge 等于 copied body
// exact charge 是 typed decode 前的保守 materialization 上界,不等同 wire bytes。
MTProtoRPCMaxInflight int
MTProtoRPCQueueSize int
MTProtoRPCTimeout time.Duration
MTProtoRPCGlobalWorkers int
MTProtoRPCGlobalMaxTasks int
MTProtoRPCGlobalMaxBytes int64
// Pending ownership and completed rpc_result replay state share a three-level
// global/raw-auth/session budget over the full MTProto duplicate horizon.
MTProtoRPCResultCacheMaxEntries int
MTProtoRPCResultCacheMaxBytes int64
MTProtoRPCResultCacheAuthMaxEntries int
MTProtoRPCResultCacheAuthMaxBytes int64
MTProtoRPCResultCacheSessionMaxEntries int
MTProtoRPCResultCacheSessionMaxBytes int64
MTProtoRPCResultPendingPerAuth int
// MTProtoInboundFrameGlobalMaxBytes 是 transport wire + 最大解密 plaintext 的
// 进程级在途预算frame 长度读出后、payload 分配前预留。
MTProtoInboundFrameGlobalMaxBytes int64
@ -104,6 +115,9 @@ type Config struct {
DevAuthCode string
// AuthCodeTTL 是登录/注册/邮箱验证 code 的有效期。
AuthCodeTTL time.Duration
// PhoneCodeLength 是使用外部 provider 时生成的短信验证码长度。development
// provider 继续使用 DevAuthCode 原样,不受此字段影响。
PhoneCodeLength int
// AuthCodeMaxAttempts 是同一 phone_code_hash / email verification code 的最大错误次数。
// 达到上限后验证码立即失效,用户必须重发。
AuthCodeMaxAttempts int
@ -121,8 +135,9 @@ type Config struct {
LoginEmailCodeLength int
// EmailSignupEnable 启用「邮箱作为账号身份」模式:客户端用邮箱注册/登录,服务端把邮箱
// 编码进一个 888 前缀的合成号码复用现有 phone 全流程sendCode/signUp/signIn/changePhone
// 不变),验证码通过 SMTP 发到解码出的邮箱而非发短信。要求 SMTP 配置可用(与
// LoginEmailEnable 共用同一组 TELESRV_SMTP_* 变量)。
// 不变验证码通过登录邮箱同一投递通道PhoneCodeDeliveryProvider/smtp 或 webhook
// 发到解码出的邮箱而非发短信。要求该通道配置可用(与 LoginEmailEnable 共用同一组
// TELESRV_SMTP_* / TELESRV_OTP_WEBHOOK_* 变量)。
EmailSignupEnable bool
// EmailSignupPhonePrefixes 是账号实际可见的 users.phone 短号码
// domain.NewEmailSignupDisplayPhone随机选用的号段前缀列表逗号分隔
@ -132,7 +147,18 @@ type Config struct {
// help.getAppConfig 的 email_signup_phone_prefixes 下发给客户端,管理员
// 改动此列表不需要客户端升级。
EmailSignupPhonePrefixes []string
// SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。
// PhoneCodeDeliveryProvider 选择普通登录/注册与改号验证码的投递方式:
// development 保留固定码与 777000 app-codewebhook 使用随机 SMS code。
PhoneCodeDeliveryProvider string
// EmailCodeDeliveryProvider 选择登录邮箱与邮箱 setup/change 的投递方式
// login email 与 email-signup 共用同一个开关,见 EmailSignupEnable
EmailCodeDeliveryProvider string
// OTPWebhook* 定义固定 v1 webhook 协议的端点、HMAC secret 与请求超时。
OTPWebhookURL string
OTPWebhookSecret string
OTPWebhookTimeout time.Duration
// SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 或
// EmailSignupEnable=true 且 provider=smtp 时使用。
SMTPHost string
SMTPPort int
SMTPUsername string
@ -415,18 +441,29 @@ func Load() (Config, error) {
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
MTProtoMaxConnections: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS", 200000),
MTProtoMaxConnectionsPerIP: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP", 4096),
MTProtoMaxConcurrentHandshakes: envIntOr("TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES", 256),
MTProtoRPCMaxInflight: envIntOr("TELESRV_MTPROTO_RPC_MAX_INFLIGHT", 32),
MTProtoRPCQueueSize: envIntOr("TELESRV_MTPROTO_RPC_QUEUE_SIZE", 64),
MTProtoRPCTimeout: envDurationOr("TELESRV_MTPROTO_RPC_TIMEOUT", 30*time.Second),
MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256),
MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192),
MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20),
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
MTProtoMaxConnections: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS", 200000),
MTProtoMaxConnectionsPerIP: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP", 4096),
MTProtoMaxConcurrentHandshakes: envIntOr("TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES", 256),
MTProtoRPCMaxInflight: envIntOr("TELESRV_MTPROTO_RPC_MAX_INFLIGHT", 32),
MTProtoRPCQueueSize: envIntOr("TELESRV_MTPROTO_RPC_QUEUE_SIZE", 64),
MTProtoRPCTimeout: envDurationOr("TELESRV_MTPROTO_RPC_TIMEOUT", 30*time.Second),
MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256),
MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192),
MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20),
MTProtoRPCResultCacheMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", 1<<18),
MTProtoRPCResultCacheMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES", 64<<20),
MTProtoRPCResultCacheAuthMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES", 1<<15),
MTProtoRPCResultCacheAuthMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", 32<<20),
MTProtoRPCResultCacheSessionMaxEntries: envIntOr(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES", 1<<14,
),
MTProtoRPCResultCacheSessionMaxBytes: envInt64Or(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", 16<<20,
),
MTProtoRPCResultPendingPerAuth: envIntOr("TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", 1<<11),
MTProtoInboundFrameGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", 512<<20),
MTProtoOutboundQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", 128),
MTProtoOutboundControlQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", 32),
@ -460,6 +497,7 @@ func Load() (Config, error) {
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute),
PhoneCodeLength: envIntOr("TELESRV_PHONE_CODE_LENGTH", 5),
AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5),
AuthCodePhoneRateLimit: envIntOr("TELESRV_AUTH_CODE_PHONE_RATE_LIMIT", 5),
AuthCodeAuthKeyRateLimit: envIntOr("TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT", 20),
@ -469,6 +507,11 @@ func Load() (Config, error) {
EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false),
EmailSignupPhonePrefixes: envListOr("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", []string{"888"}),
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
PhoneCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "development"))),
EmailCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "smtp"))),
OTPWebhookURL: envOr("TELESRV_OTP_WEBHOOK_URL", ""),
OTPWebhookSecret: envOr("TELESRV_OTP_WEBHOOK_SECRET", ""),
OTPWebhookTimeout: envDurationOr("TELESRV_OTP_WEBHOOK_TIMEOUT", 5*time.Second),
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
@ -576,9 +619,45 @@ func Load() (Config, error) {
if err := validateLoginEmailConfig(cfg); err != nil {
return Config{}, err
}
if err := validateRPCResultCacheConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error {
if cfg.MTProtoRPCResultCacheMaxEntries <= 0 || cfg.MTProtoRPCResultCacheAuthMaxEntries <= 0 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries <= 0 {
return fmt.Errorf("MTProto rpc_result entry limits must be positive")
}
if cfg.MTProtoRPCResultCacheMaxEntries < cfg.MTProtoRPCResultCacheAuthMaxEntries ||
cfg.MTProtoRPCResultCacheAuthMaxEntries < cfg.MTProtoRPCResultCacheSessionMaxEntries {
return fmt.Errorf("MTProto rpc_result entry hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxEntries)
}
if cfg.MTProtoRPCResultCacheMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheSessionMaxBytes < mtProtoRPCResultMinBytes {
return fmt.Errorf("MTProto rpc_result byte limits must each be at least %d: %d/%d/%d",
mtProtoRPCResultMinBytes, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
}
if cfg.MTProtoRPCResultCacheMaxBytes < cfg.MTProtoRPCResultCacheAuthMaxBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < cfg.MTProtoRPCResultCacheSessionMaxBytes {
return fmt.Errorf("MTProto rpc_result byte hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxBytes, cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
}
if cfg.MTProtoRPCGlobalMaxTasks <= 0 || cfg.MTProtoRPCResultPendingPerAuth <= 0 ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCGlobalMaxTasks ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCResultCacheAuthMaxEntries {
return fmt.Errorf("MTProto rpc_result pending-per-auth %d must be positive and <= global pending %d and auth entries %d",
cfg.MTProtoRPCResultPendingPerAuth, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCResultCacheAuthMaxEntries)
}
return nil
}
func validateLoginEmailConfig(cfg Config) error {
if cfg.LoginEmailRequireSetup && !cfg.LoginEmailEnable {
return fmt.Errorf("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP requires TELESRV_LOGIN_EMAIL_ENABLE=true")
@ -589,6 +668,9 @@ func validateLoginEmailConfig(cfg Config) error {
if cfg.AuthCodeMaxAttempts <= 0 {
return fmt.Errorf("TELESRV_AUTH_CODE_MAX_ATTEMPTS must be positive")
}
if cfg.PhoneCodeLength < 4 || cfg.PhoneCodeLength > 10 {
return fmt.Errorf("TELESRV_PHONE_CODE_LENGTH must be between 4 and 10")
}
if cfg.LoginEmailCodeLength < 4 || cfg.LoginEmailCodeLength > 10 {
return fmt.Errorf("TELESRV_LOGIN_EMAIL_CODE_LENGTH must be between 4 and 10")
}
@ -607,7 +689,33 @@ func validateLoginEmailConfig(cfg Config) error {
}
}
}
if !cfg.LoginEmailEnable && !cfg.EmailSignupEnable {
switch cfg.PhoneCodeDeliveryProvider {
case "development", "webhook":
default:
return fmt.Errorf("TELESRV_PHONE_CODE_DELIVERY_PROVIDER must be development or webhook")
}
switch cfg.EmailCodeDeliveryProvider {
case "smtp", "webhook":
default:
return fmt.Errorf("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER must be smtp or webhook")
}
// EmailSignupEnable shares the same delivery channel as LoginEmailEnable
// (see Config.EmailSignupEnable doc), so it must gate the webhook/SMTP
// requirement checks identically, or an email-signup-only deployment
// (LoginEmailEnable=false) would skip webhook URL validation and SMTP
// requiredness below even though it needs one of them configured.
webhookEnabled := cfg.PhoneCodeDeliveryProvider == "webhook" ||
((cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook")
if webhookEnabled {
if cfg.OTPWebhookTimeout <= 0 {
return fmt.Errorf("TELESRV_OTP_WEBHOOK_TIMEOUT must be positive")
}
u, err := url.Parse(strings.TrimSpace(cfg.OTPWebhookURL))
if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("TELESRV_OTP_WEBHOOK_URL must be an absolute http(s) URL without userinfo")
}
}
if (!cfg.LoginEmailEnable && !cfg.EmailSignupEnable) || cfg.EmailCodeDeliveryProvider == "webhook" {
return nil
}
if strings.TrimSpace(cfg.SMTPHost) == "" {

View file

@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AdvertiseIP != "203.0.113.10" {
if cfg.AdvertiseIP != "192.0.2.10" {
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
}
}
@ -57,6 +57,13 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", "33")
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", "444")
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", "555555")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", "555")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES", "70000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES", "444")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", "40000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES", "333")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", "20000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", "222")
t.Setenv("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", "777777")
t.Setenv("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", "88")
t.Setenv("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", "22")
@ -77,6 +84,16 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
cfg.MTProtoRPCGlobalWorkers != 33 || cfg.MTProtoRPCGlobalMaxTasks != 444 || cfg.MTProtoRPCGlobalMaxBytes != 555555 {
t.Fatalf("rpc budget config = %d/%d/%v/%d/%d/%d", cfg.MTProtoRPCMaxInflight, cfg.MTProtoRPCQueueSize, cfg.MTProtoRPCTimeout, cfg.MTProtoRPCGlobalWorkers, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCGlobalMaxBytes)
}
if cfg.MTProtoRPCResultCacheMaxEntries != 555 || cfg.MTProtoRPCResultCacheMaxBytes != 70000000 ||
cfg.MTProtoRPCResultCacheAuthMaxEntries != 444 || cfg.MTProtoRPCResultCacheAuthMaxBytes != 40000000 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries != 333 || cfg.MTProtoRPCResultCacheSessionMaxBytes != 20000000 ||
cfg.MTProtoRPCResultPendingPerAuth != 222 {
t.Fatalf("rpc result cache config = global:%d/%d auth:%d/%d session:%d/%d pending/auth:%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxBytes,
cfg.MTProtoRPCResultCacheSessionMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxBytes,
cfg.MTProtoRPCResultPendingPerAuth)
}
if cfg.MTProtoInboundFrameGlobalMaxBytes != 777777 {
t.Fatalf("inbound frame budget config = %d", cfg.MTProtoInboundFrameGlobalMaxBytes)
}
@ -88,6 +105,46 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
}
}
func TestLoadRPCResultFairBudgetDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MTProtoRPCResultCacheMaxEntries != 1<<18 || cfg.MTProtoRPCResultCacheMaxBytes != 64<<20 ||
cfg.MTProtoRPCResultCacheAuthMaxEntries != 1<<15 || cfg.MTProtoRPCResultCacheAuthMaxBytes != 32<<20 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries != 1<<14 || cfg.MTProtoRPCResultCacheSessionMaxBytes != 16<<20 ||
cfg.MTProtoRPCResultPendingPerAuth != 1<<11 {
t.Fatalf("rpc_result fair defaults = global:%d/%d auth:%d/%d session:%d/%d pending/auth:%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxBytes,
cfg.MTProtoRPCResultCacheSessionMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxBytes,
cfg.MTProtoRPCResultPendingPerAuth)
}
}
func TestLoadRejectsInvalidRPCResultFairBudgets(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{name: "entry hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", value: "1024"},
{name: "byte below outbound body", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", value: "16700000"},
{name: "byte hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", value: "70000000"},
{name: "pending hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", value: "9000"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadOutboxPoisonPolicy(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_OUTBOX_POISON_RETENTION", "2m")
@ -142,6 +199,7 @@ func TestLoadLoginEmailDefaultsDisabled(t *testing.T) {
t.Fatal("LoginEmailRequireSetup = true, want false")
}
if cfg.AuthCodeTTL != 5*time.Minute || cfg.AuthCodeMaxAttempts != 5 || cfg.LoginEmailCodeLength != 6 ||
cfg.PhoneCodeLength != 5 || cfg.PhoneCodeDeliveryProvider != "development" || cfg.EmailCodeDeliveryProvider != "smtp" ||
cfg.AuthCodePhoneRateLimit != 5 || cfg.AuthCodeAuthKeyRateLimit != 20 || cfg.AuthCodeRateWindow != 10*time.Minute {
t.Fatalf("auth/login email defaults = ttl=%v attempts=%d length=%d phone_limit=%d key_limit=%d window=%v",
cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength,
@ -194,6 +252,84 @@ func TestLoadLoginEmailRequiresSMTPWhenEnabled(t *testing.T) {
}
}
func TestLoadLoginEmailWebhookDoesNotRequireSMTP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_LOGIN_EMAIL_ENABLE", "true")
t.Setenv("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "webhook")
t.Setenv("TELESRV_OTP_WEBHOOK_URL", "https://otp.example.test/v1/deliveries")
t.Setenv("TELESRV_OTP_WEBHOOK_SECRET", "test-secret")
t.Setenv("TELESRV_OTP_WEBHOOK_TIMEOUT", "3s")
t.Setenv("TELESRV_SMTP_HOST", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.EmailCodeDeliveryProvider != "webhook" || cfg.OTPWebhookURL != "https://otp.example.test/v1/deliveries" ||
cfg.OTPWebhookSecret != "test-secret" || cfg.OTPWebhookTimeout != 3*time.Second {
t.Fatalf("webhook config = %#v", cfg)
}
}
// TestLoadEmailSignupAloneRequiresSMTPWhenEnabled asserts that
// TELESRV_EMAIL_SIGNUP_ENABLE=true still requires a configured delivery
// channel even when TELESRV_LOGIN_EMAIL_ENABLE is off. Email-signup accounts
// share the same loginEmailSender/SMTP-or-webhook config as login email (see
// Config.EmailSignupEnable doc); the two features must gate identically, or
// an email-signup-only deployment would silently skip this validation and
// only find out at runtime that sendChangePhoneCodeByEmail/SendCode have a
// nil sender.
func TestLoadEmailSignupAloneRequiresSMTPWhenEnabled(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_EMAIL_SIGNUP_ENABLE", "true")
t.Setenv("TELESRV_SMTP_HOST", "")
if _, err := Load(); err == nil {
t.Fatal("Load succeeded with email signup enabled but no SMTP host and no webhook provider")
}
}
// TestLoadEmailSignupAloneWebhookDoesNotRequireSMTP mirrors
// TestLoadLoginEmailWebhookDoesNotRequireSMTP for the email-signup-only case.
func TestLoadEmailSignupAloneWebhookDoesNotRequireSMTP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_EMAIL_SIGNUP_ENABLE", "true")
t.Setenv("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "webhook")
t.Setenv("TELESRV_OTP_WEBHOOK_URL", "https://otp.example.test/v1/deliveries")
t.Setenv("TELESRV_OTP_WEBHOOK_SECRET", "test-secret")
t.Setenv("TELESRV_OTP_WEBHOOK_TIMEOUT", "3s")
t.Setenv("TELESRV_SMTP_HOST", "")
if _, err := Load(); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestLoadPhoneWebhookRequiresValidURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "webhook")
t.Setenv("TELESRV_OTP_WEBHOOK_URL", "relative/path")
if _, err := Load(); err == nil {
t.Fatal("Load succeeded with relative OTP webhook URL")
}
}
func TestLoadPhoneWebhookConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "webhook")
t.Setenv("TELESRV_PHONE_CODE_LENGTH", "7")
t.Setenv("TELESRV_OTP_WEBHOOK_URL", "http://127.0.0.1:8080/otp")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.PhoneCodeDeliveryProvider != "webhook" || cfg.PhoneCodeLength != 7 {
t.Fatalf("phone webhook config = %#v", cfg)
}
}
func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADMIN_UI_ADDR", "")

View file

@ -26,9 +26,15 @@ type AdminCommand struct {
CompletedAt *time.Time
}
type AccountSendRestriction struct {
// AccountFreeze is the durable account-level read-only state advertised to
// Telegram clients through help.getAppConfig. Until is the appeal/deletion
// deadline; reaching it does not silently unfreeze the account.
type AccountFreeze struct {
UserID int64
Frozen bool
Since time.Time
Until time.Time
AppealURL string
Reason string
Actor string
CommandID string

View file

@ -5,9 +5,12 @@ import "time"
// Authorization 是一条设备授权auth_key 与 user 的绑定 + initConnection 设备信息。
// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。
type Authorization struct {
AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64
UserID int64
Hash int64
AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64
UserID int64
Hash int64
// Layer is the last supported protocol profile explicitly observed for this
// auth key. It is a durable default for a new session, never an instruction
// to rewrite an already-active session's own profile.
Layer int
DeviceModel string
Platform string
@ -23,13 +26,18 @@ type Authorization struct {
}
// AuthKeyClientInfo 是未登录 auth_key 也需要保留的客户端协商元数据。
// 登录后的设备授权仍由 Authorization 表达;这里仅用于服务端重启后恢复
// pre-auth / setup 流程的 client type 与 layer。
// 登录后的设备授权仍由 Authorization 表达。Layer 保存最后一次受支持的显式
// wire profile供服务端重启后为同一 auth key 的新 session 初始化默认值;活跃
// session 仍以自己的显式 invokeWithLayer 纠正值为准。
type AuthKeyClientInfo struct {
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
Layer int
// LayerObservationID is a read-only ordering token owned by the protocol
// store. Generic client metadata updates must never manufacture or advance
// it; only ordered invokeWithLayer evidence may do so.
LayerObservationID int64
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
}

View file

@ -1,4 +1,4 @@
// Package domain 存放业务实体与值对象User、Peer、Dialog、Message、MessageID 等)。
//
// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型TL 类型只允许出现在 RPC/MTProto 边界。
// 铁律:本包禁止依赖 iamxvbaba/td/tg 等协议层类型TL 类型只允许出现在 RPC/MTProto 边界。
package domain

View file

@ -9,6 +9,55 @@ type LangPack struct {
Strings []LangPackString
}
// LangPackSeed 是一次启动扫描得到的完整语言包文件清单。
type LangPackSeed struct {
Catalog string
Scopes []string
Packs []LangPackSeedEntry
}
// LangPackSeedEntry 记录一个语言包文件、源文件 hash 与规范化内容 hash。
type LangPackSeedEntry struct {
Pack LangPack
SourceHash string
ContentHash string
StringsCount int
ContentLoaded bool
}
// LangPackSeedCatalog 是上次成功对账后可用于跳过未变文件解析的清单快照。
type LangPackSeedCatalog struct {
Catalog string `json:"catalog"`
Scopes []string `json:"scopes"`
Packs []LangPackSeedCatalogEntry `json:"packs"`
}
// LangPackSeedCatalogEntry 只保存判断源文件是否变化所需的有界元数据。
type LangPackSeedCatalogEntry struct {
LangPack string `json:"lang_pack"`
LangCode string `json:"lang_code"`
Version int `json:"version"`
SourceHash string `json:"source_hash"`
ContentHash string `json:"content_hash"`
StringsCount int `json:"strings_count"`
}
// LangPackLanguage 是 langpack.getLanguages/getLanguage 返回的语言元数据。
type LangPackLanguage struct {
LangPack string
LangCode string
Name string
NativeName string
BaseLangCode string
PluralCode string
Official bool
Rtl bool
Beta bool
StringsCount int
TranslatedCount int
TranslationsURL string
}
// LangPackString 是语言包中的一个普通或复数形式字符串。
type LangPackString struct {
Key string

View file

@ -561,6 +561,10 @@ const (
// MessageServiceActionStarGift 映射 messageActionStarGift收到一份 Star 礼物。
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
// immutable collectible snapshot is carried by the service message so an
// exact replay/difference never depends on mutable catalog state.
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -617,24 +621,42 @@ type MessageServiceAction struct {
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
}
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。
type MessageStarGiftAction struct {
GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"`
GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"`
CanUpgrade bool `json:"can_upgrade,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
}
// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade
// service message. Commercial transfer/resale/export fields are intentionally
// absent from the collectibles mainline.
type MessageStarGiftUniqueAction struct {
Gift UniqueStarGift `json:"gift"`
FromUserID int64 `json:"from_user_id,omitempty"`
Peer Peer `json:"peer"`
SavedID int64 `json:"saved_id,omitempty"`
Upgrade bool `json:"upgrade,omitempty"`
Saved bool `json:"saved,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
}
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。

View file

@ -246,19 +246,23 @@ type MessageFilter struct {
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
type SendPrivateTextRequest struct {
SenderUserID int64
RecipientUserID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
// OriginUserID identifies the authenticated initiator when a server-generated
// service message is authored by another user. Zero preserves the ordinary
// send path where the sender is the initiator.
OriginUserID int64
RecipientBlocked bool
// IdempotencyFingerprint 是调用边界对原始、不可变发送请求计算的 SHA-256。
// RPC 层应优先填入原始 TL 请求指纹,避免链接预览、骰子结果、上传媒体
@ -285,6 +289,13 @@ type SendPrivateTextRequest struct {
RichMessage *MessageRichMessage
}
// HasContent reports whether the command contains a client-visible message payload.
// TDesktop rich-message posting intentionally leaves Message empty, so every send
// boundary must treat text, media, and rich_message as equivalent content sources.
func (r SendPrivateTextRequest) HasContent() bool {
return r.Message != "" || !r.Media.IsZero() || !r.RichMessage.IsZero()
}
// PrivateSendReplayRequest identifies one already-committed private send without carrying any
// mutable or resolver-derived message fields. The fingerprint is computed at the original
// request boundary and must be a complete SHA-256 value.

View file

@ -3,36 +3,255 @@ package domain
import (
"encoding/base64"
"errors"
"regexp"
"strconv"
"strings"
"time"
)
// Star giftpayments.sendStarsForm + inputInvoiceStarGift领域模型。目录是从已 seed 的
// animated_emoji 合成的静态集合StarGiftpeer 收到的礼物实例落 peer_star_giftsSavedStarGift
// Star giftpayments.sendStarsForm + inputInvoiceStarGift领域模型。目录和不可变版本
// 持久化在 star_gift_catalog(_revisions)peer 收到的礼物实例落 peer_star_gifts
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
// StarGift 是一个可购买礼物目录项(合成、非用户持有)
// StarGift 是一个可购买礼物目录项。RevisionID 标识不可变的标题/价格/动画快照
type StarGift struct {
ID int64
Stars int64 // 购买价Stars
ConvertStars int64 // 收礼人可转换回的 Starsv1 = Stars全额
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
ID int64
RevisionID int64
Stars int64 // 购买价Stars
ConvertStars int64 // 收礼人可转换回的 Stars
UpgradeStars int64 // 升级为唯一礼物所需 Stars0 表示当前不可升级
UpgradeTotal int // 当前已发布属性池允许发行的唯一礼物总量
UpgradeIssued int // 当前已发行数量
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
}
// SavedStarGift 是一条已收到的礼物实例peer_star_gifts 一行)。
type SavedStarGift struct {
ID int64
Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID
RevisionID int64 // → star_gift_catalog_revisions.id历史查询必须按此版本投影
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除
ConvertStars int64 // 转换可退回的 Stars
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
Message string // 附言(可选)
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id
PinnedOrder int // >0 表示资料页置顶顺序
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
Unique *UniqueStarGift
}
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
type StarGiftCollectibleAttributeKind string
const (
StarGiftCollectibleModel StarGiftCollectibleAttributeKind = "model"
StarGiftCollectiblePattern StarGiftCollectibleAttributeKind = "pattern"
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
)
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
type StarGiftCollectibleAttribute struct {
ID int64
CollectibleRevisionID int64
Kind StarGiftCollectibleAttributeKind
Name string
Document *Document
BackdropID int
CenterColor int
EdgeColor int
PatternColor int
TextColor int
RarityPermille int
SortOrder int
Animation *StarGiftAnimation
Blob *FileBlob
}
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
type StarGiftCollectibleRevision struct {
ID int64
Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除
ConvertStars int64 // 转换可退回的 Stars
Message string // 附言(可选)
GiftID int64
Revision int
UpgradeStars int64
SupplyTotal int
Issued int
SlugPrefix string
Published bool
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
CreatedBy string
CreatedAt time.Time
PublishedAt time.Time
}
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
type StarGiftCollectibleWrite struct {
GiftID int64
UpgradeStars int64
SupplyTotal int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
Actor string
CommandID string
}
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
type UniqueStarGift struct {
ID int64
GiftID int64
CollectibleRevisionID int64
SourceSavedGiftID int64
Title string
Slug string
Num int
Owner Peer
Model StarGiftCollectibleAttribute
Pattern StarGiftCollectibleAttribute
Backdrop StarGiftCollectibleAttribute
AvailabilityIssued int
AvailabilityTotal int
KeepOriginalDetails bool
OriginalFromUserID int64
OriginalOwner Peer
OriginalDate int
OriginalMessage string
OriginalNameHidden bool
CreatedAt time.Time
}
// StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
type StarGiftUpgradePreview struct {
GiftID int64
Revision int
UpgradeStars int64
SupplyTotal int
Issued int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
}
// StarGiftCollectibleAvailability is the lightweight current-pool projection used
// when rendering historical saved gifts. The saved gift keeps its immutable catalog
// revision for appearance and prices, while upgrade availability follows the pool
// currently published for the logical gift ID.
type StarGiftCollectibleAvailability struct {
UpgradeStars int64
SupplyTotal int
Issued int
}
// StarGiftUpgradeRequest is one idempotent user-owned upgrade command. Paid
// invoice upgrades set ChargeStars; the direct payments.upgradeStarGift path
// sets RequirePrepaid and charges zero at upgrade time.
type StarGiftUpgradeRequest struct {
UserID int64
Ref SavedStarGiftRef
KeepOriginalDetails bool
ChargeStars int64
RequirePrepaid bool
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftUpgradeResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
// StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
type StarGiftCollection struct {
Owner Peer
CollectionID int
Title string
GiftIDs []int64 // peer_star_gifts.id按集合内顺序
Hash int64
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
// StarGiftCollectionPatch 描述 updateStarGiftCollection 的局部更新。
type StarGiftCollectionPatch struct {
Title *string
DeleteIDs []int64
AddIDs []int64
Order []int64
}
// StarGiftAnimationFormat 是后台导入源格式。服务端最终总是存储规范化 TGS。
type StarGiftAnimationFormat string
const (
StarGiftAnimationTGS StarGiftAnimationFormat = "tgs"
StarGiftAnimationLottie StarGiftAnimationFormat = "lottie"
)
// StarGiftAnimation 是已规范化并验证的动画。JSON 用于后台播放TGS 用于客户端。
type StarGiftAnimation struct {
SourceName string
SourceFormat StarGiftAnimationFormat
JSON []byte
TGS []byte
SHA256 []byte
Width int
Height int
FrameRate float64
InPoint float64
OutPoint float64
}
// StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。
type StarGiftCatalogWrite struct {
GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision
Title string
Stars int64
ConvertStars int64
Enabled bool
SortOrder int
Document Document
Blob FileBlob
Animation StarGiftAnimation
Actor string
CommandID string
}
// StarGiftCatalogEntry 是管理后台目录视图。
type StarGiftCatalogEntry struct {
Gift StarGift
Enabled bool
SortOrder int
Revision int
SourceName string
SourceFormat StarGiftAnimationFormat
AnimationSHA []byte
AnimationSize int64
Width int
Height int
FrameRate float64
ReceivedCount int64
CreatedBy string
UpdatedAt time.Time
}
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
@ -62,6 +281,24 @@ type SavedStarGiftPage struct {
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
}
// SavedStarGiftFilter describes the client-visible filters supported by
// payments.getSavedStarGifts. CollectionID is the collection membership filter;
// zero means all collections. The current catalog is used only to decide whether
// a regular gift remains upgradable, while its rendered gift snapshot still comes
// from RevisionID.
type SavedStarGiftFilter struct {
Owner Peer
ExcludeUnsaved bool
ExcludeSaved bool
ExcludeUnlimited bool
ExcludeUnique bool
ExcludeUpgradable bool
ExcludeUnupgradable bool
CollectionID int
Offset string
Limit int
}
// Star gift 边界常量。
const (
// MaxSavedStarGiftsLimit 是 getSavedStarGifts 单页上限。
@ -70,6 +307,20 @@ const (
MaxStarGiftMessageRunes = 255
// MaxStarGiftsOffsetBytes 是 keyset 游标字符串长度上限。
MaxStarGiftsOffsetBytes = 64
// MaxStarGiftTGSBytes 限制后台导入的压缩动画,避免管理面上传成为容量旁路。
MaxStarGiftTGSBytes int64 = 512 << 10
// MaxStarGiftLottieBytes 限制解压后的 Lottie JSON。
MaxStarGiftLottieBytes int64 = 4 << 20
// MaxStarGiftAnimationFrameRate / Seconds 限制管理后台播放器和客户端动画时间轴。
MaxStarGiftAnimationFrameRate = 120
MaxStarGiftAnimationSeconds = 30
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
MaxStarGiftCatalogSize = 500
MaxStarGiftTitleRunes = 128
MaxStarGiftCollectibleAttributesPerKind = 256
MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000
)
// Star gift 哨兵错误rpc 层 errors.Is 映射为 tgerr
@ -79,20 +330,138 @@ var (
// ErrStarGiftNotFound 表示找不到该已收到礼物实例。
ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
// ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars不可重复转换
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
ErrStarGiftFileInvalid = errors.New("stargift: invalid animation file")
ErrStarGiftCatalogFull = errors.New("stargift: catalog full")
ErrStarGiftCollectibleUnavailable = errors.New("stargift: collectible upgrade unavailable")
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
)
// StarGiftCatalogHash 由目录的 (gift_id, stars) 折叠出稳定 hash供 getStarGifts NotModified。
var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
// ValidateStarGiftCollectibleDraft validates the operator-authored definition before animation
// blobs/documents are allocated. This is the validation boundary used by admin dry-runs.
func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
write.SlugPrefix = strings.TrimSpace(strings.ToLower(write.SlugPrefix))
if write.GiftID <= 0 || write.UpgradeStars <= 0 || write.SupplyTotal <= 0 ||
!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
return ErrStarGiftCollectibleInvalid
}
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
return err
}
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, false); err != nil {
return err
}
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false)
}
// ValidateStarGiftCollectibleWrite validates a complete publish command. Published pools are
// immutable, so partial definitions are rejected before any document/blob rows are written.
func ValidateStarGiftCollectibleWrite(write StarGiftCollectibleWrite) error {
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
return err
}
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, true); err != nil {
return err
}
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, true); err != nil {
return err
}
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true)
}
func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind StarGiftCollectibleAttributeKind, requireStoredAsset bool) error {
if len(attributes) == 0 || len(attributes) > MaxStarGiftCollectibleAttributesPerKind {
return ErrStarGiftCollectibleInvalid
}
seen := make(map[string]struct{}, len(attributes))
total := 0
for _, attribute := range attributes {
name := strings.TrimSpace(attribute.Name)
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes ||
attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 {
return ErrStarGiftCollectibleInvalid
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return ErrStarGiftCollectibleInvalid
}
seen[key] = struct{}{}
total += attribute.RarityPermille
switch kind {
case StarGiftCollectibleModel, StarGiftCollectiblePattern:
if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
len(attribute.Animation.TGS) == 0 || len(attribute.Animation.SHA256) != 32 {
return ErrStarGiftCollectibleInvalid
}
if requireStoredAsset && (attribute.Document == nil || !attribute.Document.IsSticker() ||
attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) {
return ErrStarGiftCollectibleInvalid
}
case StarGiftCollectibleBackdrop:
if attribute.BackdropID <= 0 || attribute.Document != nil ||
attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
attribute.TextColor < 0 || attribute.TextColor > 0xffffff {
return ErrStarGiftCollectibleInvalid
}
default:
return ErrStarGiftCollectibleInvalid
}
}
if total != 1000 {
return ErrStarGiftCollectibleInvalid
}
return nil
}
// StarGiftCatalogHash 由客户端可见目录字段折叠出稳定 hash供 getStarGifts NotModified。
func StarGiftCatalogHash(catalog []StarGift) int {
var h uint64
for _, g := range catalog {
h ^= uint64(g.ID)
h = h*0x4f25 + uint64(g.ID)
h = h*0x4f25 + uint64(g.RevisionID)
h = h*0x4f25 + uint64(g.Stars)
h = h*0x4f25 + uint64(g.ConvertStars)
h = h*0x4f25 + uint64(g.UpgradeStars)
h = h*0x4f25 + uint64(g.UpgradeTotal)
h = h*0x4f25 + uint64(g.UpgradeIssued)
h = h*0x4f25 + uint64(g.Sticker.ID)
for _, r := range g.Title {
h = h*131 + uint64(r)
}
}
return int(h & 0x7fffffff)
}
// StarGiftCollectionsHash 按服务端返回顺序折叠每个集合自己的稳定 hash。
func StarGiftCollectionsHash(collections []StarGiftCollection) int64 {
var h uint64
for _, collection := range collections {
h = h*0x4f25 + uint64(collection.Hash)
}
return int64(h & 0x7fffffffffffffff)
}
// StarGiftCollectionHash returns the per-collection hash exposed by starGiftCollection.hash.
func StarGiftCollectionHash(title string, giftIDs []int64) int64 {
h := uint64(0x534743)
for _, r := range title {
h = h*131 + uint64(r)
}
for _, id := range giftIDs {
h = h*0x4f25 + uint64(id)
}
return int64(h & 0x7fffffffffffffff)
}
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id
func EncodeStarGiftCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))

View file

@ -20,21 +20,22 @@ type StarsBalance struct {
type StarsTransactionReason string
const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
type StarsTransaction struct {
ID int64 // 单调递增账本 idkeyset 游标)
UserID int64 // 账本归属
Peer Peer // 对手方grant/topup 等无对手时为零 Peer
Amount int64 // 带符号金额
Date int // Unix 秒
ID int64 // 单调递增账本 idkeyset 游标)
UserID int64 // 账本归属
Peer Peer // 对手方grant/topup 等无对手时为零 Peer
Amount int64 // 带符号金额
Date int // Unix 秒
Reason StarsTransactionReason
Title string // 可选,投影到 tg.StarsTransaction.Title
Description string // 可选,投影到 tg.StarsTransaction.Description

View file

@ -8,3 +8,14 @@ type UpdateState struct {
Date int
Seq int
}
// UpdateStateCommitMode describes what a physically delivered update-state
// response proves. Every delivered baseline advances the device-local
// confirmed cursor; only an explicitly audited getState baseline also proves
// that retention may advance the client-observed cursor to the same point.
type UpdateStateCommitMode uint8
const (
UpdateStateCommitDeliveredOnly UpdateStateCommitMode = iota + 1
UpdateStateCommitDeliveredAndObservedBaseline
)

View file

@ -3,14 +3,15 @@ package domain
import "errors"
var (
ErrUsernameInvalid = errors.New("username invalid")
ErrUsernameOccupied = errors.New("username occupied")
ErrUsernameNotOccupied = errors.New("username not occupied")
ErrPhoneNotOccupied = errors.New("phone not occupied")
ErrFirstNameInvalid = errors.New("first name invalid")
ErrAboutTooLong = errors.New("about too long")
ErrUserNotFound = errors.New("user not found")
ErrUserSendRestricted = errors.New("user send restricted")
ErrUsernameInvalid = errors.New("username invalid")
ErrUsernameOccupied = errors.New("username occupied")
ErrUsernameNotOccupied = errors.New("username not occupied")
ErrPhoneNotOccupied = errors.New("phone not occupied")
ErrFirstNameInvalid = errors.New("first name invalid")
ErrAboutTooLong = errors.New("about too long")
ErrUserNotFound = errors.New("user not found")
ErrUserFrozen = errors.New("user account frozen")
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
// ErrPremiumRequired 表示该操作仅限有效会员PREMIUM_ACCOUNT_REQUIRED
ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。

View file

@ -9,8 +9,8 @@ import (
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto/codec"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/proto/codec"
"telesrv/internal/store"
"telesrv/internal/store/memory"

View file

@ -0,0 +1,447 @@
package mtprotoedge
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
"go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/crypto"
"github.com/iamxvbaba/td/exchange"
"github.com/iamxvbaba/td/mt"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/proto/codec"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/transport"
)
func TestAuthKeyProtocolUnavailable(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tests := []struct {
name string
expiresAt int
want bool
}{
{name: "legacy unknown", expiresAt: -1, want: true},
{name: "permanent", expiresAt: 0, want: false},
{name: "expired temporary", expiresAt: int(now.Unix()), want: true},
{name: "live temporary", expiresAt: int(now.Add(time.Second).Unix()), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := authKeyProtocolUnavailable(tt.expiresAt, now); got != tt.want {
t.Fatalf("authKeyProtocolUnavailable(%d) = %v, want %v", tt.expiresAt, got, tt.want)
}
})
}
}
// expiryTestClock keeps server protocol time deterministic while retaining real
// timers for transport/RPC deadlines. Expiry admission reads Now before any
// envelope validation, so advancing it exercises the cached active-connection
// boundary without making the test sleep until a wall-clock second rolls over.
type expiryTestClock struct {
mu sync.RWMutex
now time.Time
}
func newExpiryTestClock(now time.Time) *expiryTestClock {
return &expiryTestClock{now: now}
}
func (c *expiryTestClock) Now() time.Time {
c.mu.RLock()
defer c.mu.RUnlock()
return c.now
}
func (c *expiryTestClock) Advance(d time.Duration) {
c.mu.Lock()
c.now = c.now.Add(d)
c.mu.Unlock()
}
func (*expiryTestClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
func (*expiryTestClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
type signalingGuardedLeaseWriter struct {
lease *physicalTransportLease
entered chan struct{}
once sync.Once
}
func (w *signalingGuardedLeaseWriter) Send(ctx context.Context, b *bin.Buffer) error {
return w.lease.Send(ctx, b)
}
func (w *signalingGuardedLeaseWriter) SendDeadlineWithScratchGuarded(deadline time.Time, b *bin.Buffer, scratch *[]byte, guard func() error) error {
w.once.Do(func() { close(w.entered) })
return w.lease.SendDeadlineWithScratchGuarded(deadline, b, scratch, guard)
}
func dialTemporaryHandshakeForExpiryTest(
t *testing.T,
addr string,
dc, expiresIn int,
pub exchange.PublicKey,
) (transport.Conn, exchange.ClientExchangeResult, crypto.Cipher) {
t.Helper()
conn := dialTransportOnly(t, addr)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
auth, err := exchange.NewExchanger(conn, dc).
WithTempMode(expiresIn).
WithRand(rand.Reader).
WithLogger(logzap.New(zaptest.NewLogger(t).Named("temp-client"))).
Client([]exchange.PublicKey{pub}).
Run(ctx)
if err != nil {
t.Fatalf("temporary client exchange: %v", err)
}
return conn, auth, crypto.NewClientCipher(rand.Reader)
}
func TestActiveTemporaryAuthKeyExpiresBeforeNextRPCDispatch(t *testing.T) {
const (
dc = 2
expiresIn = 60 * 60
)
now := time.Now()
testClock := newExpiryTestClock(now)
handler := &admissionCountingRPC{}
addr, pub, srv := startTestServer(t, Options{
DC: dc,
Clock: testClock,
legacyRPC: handler,
})
conn, auth, cipher := dialTemporaryHandshakeForExpiryTest(t, addr, dc, expiresIn, pub)
stored, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID)
if err != nil || !found {
t.Fatalf("temporary auth key after exchange: found=%v err=%v", found, err)
}
wantExpiresAt := int(now.Unix()) + expiresIn
if stored.ExpiresAt != wantExpiresAt {
t.Fatalf("temporary auth key expires_at = %d, want %d", stored.ExpiresAt, wantExpiresAt)
}
ids := proto.NewMessageIDGen(time.Now)
firstID := ids.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, firstID, &tg.HelpGetConfigRequest{})
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
proto.ResultTypeID: 1,
mt.MsgsAckTypeID: 1,
})
waitForAtomicCalls(t, &handler.calls, 1)
key := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: auth.SessionID}
srv.conns.mu.RLock()
active := srv.conns.bySession[key]
srv.conns.mu.RUnlock()
if active == nil || !active.isActive() {
t.Fatalf("temporary session was not active before expiry: %p", active)
}
// Cross the exact protocol boundary: expires_at <= now is invalid. The next
// frame must be rejected before decrypt/preflight/Dispatch, even though this
// connection already cached the key and completed session activation.
testClock.Advance(time.Duration(expiresIn+1) * time.Second)
sendEncrypted(t, conn, cipher, auth, ids.New(proto.MessageFromClient), &tg.HelpGetConfigRequest{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var response bin.Buffer
err = conn.Recv(ctx, &response)
var protocolErr *codec.ProtocolErr
if !errors.As(err, &protocolErr) || protocolErr.Code != codec.CodeAuthKeyNotFound {
t.Fatalf("expired active temp key recv = %T %v, want protocol -404", err, err)
}
waitForManagedSessionAbsent(t, srv.conns, key)
if got := handler.calls.Load(); got != 1 {
t.Fatalf("expired active temp key executed %d RPCs, want only the pre-expiry request", got)
}
}
func TestExpiredTemporaryAuthKeyRejectsServerPushWithoutWireWrite(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
clock := newExpiryTestClock(now)
tr := &failAfterTransport{}
c := newOutboundTestConn(t, tr, nil)
c.now = clock.Now
c.authKeyExpiresAt = int(now.Unix())
err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer,
exactTestUpdatesTooLong(t, c), 0)
if !errors.Is(err, ErrConnClosed) {
t.Fatalf("push on expired temp key = %v, want ErrConnClosed", err)
}
if got := tr.sends.Load(); got != 0 {
t.Fatalf("wire sends after expiry = %d, want zero", got)
}
if !c.isRetired() || tr.closes.Load() != 1 {
t.Fatalf("expired connection retired=%v transport_closes=%d, want true/1", c.isRetired(), tr.closes.Load())
}
}
func TestQueuedPushCannotCrossTemporaryAuthKeyExpiry(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
clock := newExpiryTestClock(now)
tr := newGatedRecordingTransport()
c := newOutboundTestConn(t, tr, nil)
c.now = clock.Now
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
encoded := exactTestUpdatesTooLong(t, c)
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
t.Fatalf("enqueue first push: %v", err)
}
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("first push did not enter blocked writer")
}
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
t.Fatalf("enqueue second push: %v", err)
}
clock.Advance(time.Minute)
tr.once.Do(func() { close(tr.release) })
select {
case <-c.outboundDone:
case <-time.After(time.Second):
t.Fatal("expired outbound actor did not stop")
}
if got := len(tr.snapshot()); got != 1 {
t.Fatalf("wire frames across expiry = %d, want only already-writing frame", got)
}
}
func TestTemporaryAuthKeyExpiryWhileWaitingForPhysicalWriterSkipsRawSend(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
testClock := newExpiryTestClock(now)
raw := newGatedRecordingTransport()
_, lease := newPhysicalTransportOwner(raw)
c := newOutboundTestConn(t, lease, nil)
c.transportLease = lease
c.now = testClock.Now
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
c.writer = signaling
// Simulate a quick ACK/protocol write that already owns the physical writer.
quickDone := make(chan error, 1)
go func() {
quickDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{1, 2, 3, 4}})
}()
select {
case <-raw.started:
case <-time.After(time.Second):
t.Fatal("direct protocol write did not acquire physical writer")
}
encoded := exactTestUpdatesTooLong(t, c)
actorDone := make(chan error, 1)
go func() {
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
}()
select {
case <-signaling.entered:
// writeFrame passed its outer expiry check and entered the guarded lease;
// the direct write still owns writeMu, so raw.Send cannot have started.
case <-time.After(time.Second):
t.Fatal("outbound actor did not wait for physical writer ownership")
}
testClock.Advance(time.Minute)
raw.once.Do(func() { close(raw.release) })
if err := <-quickDone; err != nil {
t.Fatalf("direct protocol write: %v", err)
}
if err := <-actorDone; !errors.Is(err, ErrConnClosed) {
t.Fatalf("actor write after expiry = %v, want ErrConnClosed", err)
}
if frames := raw.snapshot(); len(frames) != 1 {
t.Fatalf("raw wire frames = %d, want only the pre-expiry direct frame", len(frames))
}
if !c.isRetired() {
t.Fatal("connection was not fenced after guarded expiry rejection")
}
}
func TestRetiredActorWaitingForPhysicalWriterDoesNotDefeatLeaseTransfer(t *testing.T) {
raw := newGatedRecordingTransport()
_, lease := newPhysicalTransportOwner(raw)
c := newOutboundTestConn(t, lease, nil)
c.transportLease = lease
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
c.writer = signaling
directDone := make(chan error, 1)
go func() {
directDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{5, 6, 7, 8}})
}()
select {
case <-raw.started:
case <-time.After(time.Second):
t.Fatal("direct protocol write did not acquire physical writer")
}
actorDone := make(chan error, 1)
encoded := exactTestUpdatesTooLong(t, c)
go func() {
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
}()
select {
case <-signaling.entered:
case <-time.After(time.Second):
t.Fatal("outbound actor did not reach guarded physical writer")
}
c.beginTerminalShutdown()
raw.once.Do(func() { close(raw.release) })
if err := <-directDone; err != nil {
t.Fatalf("direct protocol write: %v", err)
}
select {
case <-c.outboundDone:
case <-time.After(time.Second):
t.Fatal("retired outbound actor did not drain")
}
select {
case err := <-actorDone:
if !errors.Is(err, ErrConnClosed) {
t.Fatalf("retired actor write = %v, want ErrConnClosed", err)
}
default:
}
if frames := raw.snapshot(); len(frames) != 1 {
t.Fatalf("retired actor reached raw writer: frames=%d, want one direct frame", len(frames))
}
if !lease.IsCurrentOpen() {
t.Fatal("retired actor closed physical lease")
}
if next, ok := lease.Transfer(); !ok || next == nil {
t.Fatal("retired actor defeated physical lease transfer")
}
}
func TestTerminalAuthKeyNotFoundSurvivesActorWaitingForPhysicalWriter(t *testing.T) {
raw := newGatedRecordingTransport()
_, lease := newPhysicalTransportOwner(raw)
c := newOutboundTestConn(t, lease, nil)
c.transportLease = lease
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
c.writer = signaling
directDone := make(chan error, 1)
go func() {
directDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{9, 10, 11, 12}})
}()
select {
case <-raw.started:
case <-time.After(time.Second):
t.Fatal("direct protocol write did not acquire physical writer")
}
encoded := exactTestUpdatesTooLong(t, c)
go func() {
_ = c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
}()
select {
case <-signaling.entered:
case <-time.After(time.Second):
t.Fatal("outbound actor did not reach guarded physical writer")
}
srv := New(Options{WriteTimeout: time.Second})
terminalDone := make(chan error, 1)
go func() {
terminalDone <- srv.sendTerminalProtoError(context.Background(), c, codec.CodeAuthKeyNotFound)
}()
select {
case err := <-terminalDone:
t.Fatalf("terminal error bypassed waiting actor: %v", err)
case <-time.After(50 * time.Millisecond):
}
raw.once.Do(func() { close(raw.release) })
if err := <-directDone; err != nil {
t.Fatalf("direct protocol write: %v", err)
}
select {
case err := <-terminalDone:
if err != nil {
t.Fatalf("send terminal -404: %v", err)
}
case <-time.After(time.Second):
t.Fatal("terminal -404 did not follow waiting actor drain")
}
frames := raw.snapshot()
if len(frames) != 2 {
t.Fatalf("wire frames = %d, want direct frame then -404", len(frames))
}
last := frames[len(frames)-1]
if len(last) != 4 || int32(binary.LittleEndian.Uint32(last)) != -codec.CodeAuthKeyNotFound {
t.Fatalf("last wire frame = %x, want bare -404", last)
}
}
func TestTerminalAuthKeyNotFoundWaitsForOutboundAndIsLastFrame(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
clock := newExpiryTestClock(now)
tr := newGatedRecordingTransport()
_, lease := newPhysicalTransportOwner(tr)
c := newOutboundTestConn(t, lease, nil)
c.transportLease = lease
c.now = clock.Now
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
encoded := exactTestUpdatesTooLong(t, c)
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
t.Fatalf("enqueue blocked push: %v", err)
}
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("push did not enter blocked writer")
}
clock.Advance(time.Minute)
srv := New(Options{WriteTimeout: time.Second})
terminalDone := make(chan error, 1)
go func() {
terminalDone <- srv.sendTerminalProtoError(context.Background(), c, codec.CodeAuthKeyNotFound)
}()
select {
case err := <-terminalDone:
t.Fatalf("terminal error bypassed active outbound writer: %v", err)
case <-time.After(50 * time.Millisecond):
}
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); !errors.Is(err, ErrConnClosed) {
t.Fatalf("push admitted behind terminal fence: %v", err)
}
tr.once.Do(func() { close(tr.release) })
select {
case err := <-terminalDone:
if err != nil {
t.Fatalf("send terminal -404: %v", err)
}
case <-time.After(time.Second):
t.Fatal("terminal -404 did not follow drained writer")
}
frames := tr.snapshot()
if len(frames) != 2 {
t.Fatalf("wire frames = %d, want encrypted frame then -404", len(frames))
}
last := frames[len(frames)-1]
if len(last) != 4 || int32(binary.LittleEndian.Uint32(last)) != -codec.CodeAuthKeyNotFound {
t.Fatalf("last wire frame = %x, want bare -404", last)
}
}

View file

@ -4,8 +4,8 @@ import (
"testing"
"time"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/iamxvbaba/td/mt"
"github.com/iamxvbaba/td/proto"
)
func TestEncryptedConnectionSwitchesAuthKeyEvenWhenSessionIDIsReused(t *testing.T) {

View file

@ -12,13 +12,13 @@ import (
"go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap"
"github.com/gotd/td/clock"
"github.com/gotd/td/exchange"
"github.com/gotd/td/session"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/exchange"
"github.com/iamxvbaba/td/session"
"github.com/iamxvbaba/td/telegram"
"github.com/iamxvbaba/td/telegram/dcs"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/transport"
"telesrv/internal/app/account"
"telesrv/internal/app/auth"
@ -69,7 +69,7 @@ func newBotCallbackEnv(t *testing.T, ctx context.Context) *botCallbackEnv {
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
deps := rpc.Deps{
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
memory.NewTempAuthKeyBindingStore(), "12345", auth.WithBotLogin(botStore)),
memory.NewTempAuthKeyBindingStore(authKeyStore), "12345", auth.WithBotLogin(botStore)),
Account: account.NewService(memory.NewPasswordStore()),
Help: help.NewService(helpStore, helpStore),
Users: users.NewService(userStore),
@ -84,8 +84,19 @@ func newBotCallbackEnv(t *testing.T, ctx context.Context) *botCallbackEnv {
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
botsService.SetRouterHooks(router)
botsService.SetTextDraftPusher(router)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions})
go func() { _ = srv.Serve(ctx, ln) }()
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ctx, ln) }()
t.Cleanup(func() {
select {
case err := <-serveErr:
if err != nil {
t.Errorf("serve: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop after callback test context cancellation")
}
})
newCli := func(storage *session.StorageMemory, handler telegram.UpdateHandler) *telegram.Client {
if handler == nil {

Some files were not shown because too many files have changed in this diff Show more