diff --git a/.env.example b/.env.example index 18585538..e78d074a 100644 --- a/.env.example +++ b/.env.example @@ -167,6 +167,9 @@ TELESRV_STARGIFT_CRAFT_DELAY=0s TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250 TELESRV_BLOB_DIR=data/blobs TELESRV_STICKER_SEED_DIR=data/sticker-seed +# Optional Premium feature-preview media export. Missing directory keeps the +# no-video fallback; an existing but incomplete/invalid directory fails startup. +TELESRV_PREMIUM_PROMO_SEED_DIR=data/premium-promo # Optional public-link Web listener for /, profile avatars, # sticker/custom emoji sets, and shared folders. This is a host:port bind diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index c1c8b82d..1c0d9beb 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -494,6 +494,15 @@ func run(logger *zap.Logger) error { zap.Int("blobs", stats.Blobs), ) } + if stats, err := filesService.SeedPremiumPromo(ctx, cfg.PremiumPromoSeedDir); err != nil { + return fmt.Errorf("seed premium promo: %w", err) + } else if !stats.Skipped { + logger.Info("Premium promo 视频种子导入完成", + zap.String("dir", cfg.PremiumPromoSeedDir), + zap.Int("videos", stats.Videos), + zap.Int("blobs", stats.Blobs), + ) + } if stats, err := filesService.SeedAppearance(ctx); err != nil { return fmt.Errorf("seed appearance: %w", err) } else if !stats.Skipped { @@ -901,6 +910,7 @@ func run(logger *zap.Logger) error { Channels: channelsService, Communities: communitiesService, Files: filesService, + PremiumPromo: filesService, Bots: botsService, Polls: pollsapp.NewService(pollStore), Stories: storiesService, diff --git a/docs/configuration.en.md b/docs/configuration.en.md index a6dc2af0..b97a8da5 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -389,6 +389,7 @@ key rings independently on different instances. | `TELESRV_BLOB_DIR` | path / `data/blobs` | Local development blob-backend root for media bytes. | | `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. | | `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. | +| `TELESRV_PREMIUM_PROMO_SEED_DIR` | path / `data/premium-promo` | Exported `help.getPremiumPromo` manifest, MP4 videos, and JPEG thumbnails. A missing directory keeps the no-video fallback; an existing invalid/incomplete directory fails startup. | The language-pack file manifest is authoritative. To add a language, place `data/langpack//__v.strings` and restart `telesrv`. The `pack` must match its first-level directory and may use the letters, digits, `-`, and `_` already used by Telegram (for example, `android_x`); `lang` is canonicalized to lowercase with hyphens (`pt_BR` becomes `pt-br`). Only the highest file version for each language is loaded. Effective content changes require a version bump; same-version effective mutations and version rollbacks stop startup. Removing a language file or an entire pack subdirectory atomically removes its database catalog and strings on the next restart. Startup streams a source-file SHA-256 first: unchanged files reuse the last atomic manifest without parsing strings or writing the database, while only new or changed files are parsed and replaced through PostgreSQL `COPY`. diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 34edc6ea..13005591 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -373,6 +373,7 @@ active key。不要手工编辑 manifest 或 PEM,不要在各实例上分别 | `TELESRV_BLOB_DIR` | path / `data/blobs` | 本地开发 blob backend 的媒体字节根目录。 | | `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 | | `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 | +| `TELESRV_PREMIUM_PROMO_SEED_DIR` | path / `data/premium-promo` | `help.getPremiumPromo` 导出的 manifest、MP4 视频与 JPEG 缩略图目录。目录缺失时保留无视频兼容响应;目录存在但内容非法或不完整时启动失败。 | 语言包 seed 以文件 manifest 为事实源。新增语言时放入 `data/langpack//__v.strings` 并重启 `telesrv`;`pack` 必须与所在一级目录一致,允许 Telegram 已使用的字母、数字、`-` 与 `_`(例如 `android_x`),`lang` 会统一为小写、连字符形式(例如 `pt_BR` 归一为 `pt-br`)。同一语言存在多个文件时只读取最高版本。修改已有语言的有效内容必须提高版本;同版本有效内容变化或版本倒退会阻止启动。删除语言文件或整个 pack 子目录后,下次重启会原子移除对应数据库目录和字符串。启动先流式计算源文件 SHA-256;未变化文件复用上次原子 manifest,不解析字符串也不写库,只有新增或变化文件才解析并通过 PostgreSQL `COPY` 整包替换。 diff --git a/internal/app/files/premium_promo_seed.go b/internal/app/files/premium_promo_seed.go new file mode 100644 index 00000000..fb67705d --- /dev/null +++ b/internal/app/files/premium_promo_seed.go @@ -0,0 +1,575 @@ +package files + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "image/jpeg" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "time" + + "telesrv/internal/domain" + + "go.uber.org/zap" +) + +const ( + premiumPromoSeedStateKey = "files.premium_promo" + premiumPromoSeedStateVersion = "premium-promo-v1" + + premiumPromoManifestName = "premium_promo.json" + premiumPromoMaxVideos = 128 + premiumPromoMaxVideoSize = int64(64 << 20) + premiumPromoMaxThumbSize = int64(4 << 20) + premiumPromoMaxTotalSize = int64(512 << 20) +) + +// PremiumPromoSeedStats reports the startup import outcome. Videos is the +// number of usable catalog entries; Blobs counts main/thumbnail blobs written +// during this run. +type PremiumPromoSeedStats struct { + Videos int + Blobs int + Skipped bool +} + +type premiumPromoSeedJSON struct { + APICall string `json:"api_call"` + StatusText string `json:"status_text"` + VideoSections []string `json:"video_sections"` + Videos []seedDocumentJSON `json:"videos"` + PeriodOptions []json.RawMessage `json:"period_options"` +} + +type premiumPromoSeedVideo struct { + section string + document domain.Document + mainPath string + thumbPath string + thumbType string +} + +// SeedPremiumPromo imports the exported promo videos into the ordinary +// document/file_blob storage. A missing root is an optional-resource fallback; +// once the directory exists, malformed or incomplete data is a startup error. +func (s *Service) SeedPremiumPromo(ctx context.Context, root string) (PremiumPromoSeedStats, error) { + var stats PremiumPromoSeedStats + if root == "" { + s.clearPremiumPromo() + stats.Skipped = true + s.warnPremiumPromoMissing(root, errors.New("seed dir is empty")) + return stats, nil + } + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + s.clearPremiumPromo() + stats.Skipped = true + s.warnPremiumPromoMissing(root, err) + return stats, nil + } + return stats, fmt.Errorf("stat premium promo seed dir %q: %w", root, err) + } + if !info.IsDir() { + return stats, fmt.Errorf("premium promo seed path %q is not a directory", root) + } + + manifestPath := filepath.Join(root, premiumPromoManifestName) + raw, err := os.ReadFile(manifestPath) + if err != nil { + return stats, fmt.Errorf("read premium promo manifest %q: %w", manifestPath, err) + } + videos, err := parsePremiumPromoSeed(root, raw) + if err != nil { + return stats, fmt.Errorf("validate premium promo seed: %w", err) + } + for i := range videos { + videos[i].document.DCID = s.dc + } + stats.Videos = len(videos) + + stateHash, err := premiumPromoSeedHash(raw, videos, s.dc) + if err != nil { + return stats, fmt.Errorf("hash premium promo seed: %w", err) + } + stateMatches, err := s.seedStateMatches(ctx, premiumPromoSeedStateKey, stateHash) + if err != nil { + return stats, fmt.Errorf("read premium promo seed state: %w", err) + } + if stateMatches { + if catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos); err != nil { + return stats, fmt.Errorf("verify premium promo catalog: %w", err) + } else if ready { + s.setPremiumPromoCatalog(catalog) + stats.Skipped = true + return stats, nil + } + } + + for _, video := range videos { + existing, found, err := s.media.GetDocument(ctx, video.document.ID) + if err != nil { + return stats, fmt.Errorf("read premium promo document %d: %w", video.document.ID, err) + } + if found && existing.AccessHash != video.document.AccessHash { + return stats, fmt.Errorf( + "premium promo document %d collides with access_hash %d (seed has %d)", + video.document.ID, + existing.AccessHash, + video.document.AccessHash, + ) + } + + forceBlobWrite := !stateMatches + if wrote, err := s.putPremiumPromoBlob( + ctx, + fmt.Sprintf("doc:%d", video.document.ID), + video.mainPath, + video.document.MimeType, + video.document.Size, + forceBlobWrite, + ); err != nil { + return stats, fmt.Errorf("import premium promo video %d: %w", video.document.ID, err) + } else if wrote { + stats.Blobs++ + } + thumb := video.document.Thumbs[0] + if wrote, err := s.putPremiumPromoBlob( + ctx, + fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType), + video.thumbPath, + "image/jpeg", + int64(thumb.Size), + forceBlobWrite, + ); err != nil { + return stats, fmt.Errorf("import premium promo thumbnail %d: %w", video.document.ID, err) + } else if wrote { + stats.Blobs++ + } + if err := s.media.PutDocument(ctx, video.document); err != nil { + return stats, fmt.Errorf("store premium promo document %d: %w", video.document.ID, err) + } + } + + catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos) + if err != nil { + return stats, fmt.Errorf("verify imported premium promo catalog: %w", err) + } + if !ready { + return stats, errors.New("premium promo catalog is incomplete after import") + } + if err := s.putSeedState(ctx, premiumPromoSeedStateKey, stateHash); err != nil { + return stats, fmt.Errorf("record premium promo seed state: %w", err) + } + s.setPremiumPromoCatalog(catalog) + return stats, nil +} + +// PremiumPromo returns a deep copy so callers cannot mutate the startup +// catalog or another request's response. +func (s *Service) PremiumPromo(_ context.Context) (domain.PremiumPromoCatalog, bool, error) { + s.premiumPromoMu.RLock() + defer s.premiumPromoMu.RUnlock() + if !s.premiumPromoReady { + return domain.PremiumPromoCatalog{}, false, nil + } + return domain.PremiumPromoCatalog{ + VideoSections: append([]string(nil), s.premiumPromo.VideoSections...), + Videos: copyDocuments(s.premiumPromo.Videos), + }, true, nil +} + +func (s *Service) setPremiumPromoCatalog(catalog domain.PremiumPromoCatalog) { + s.premiumPromoMu.Lock() + defer s.premiumPromoMu.Unlock() + s.premiumPromo = domain.PremiumPromoCatalog{ + VideoSections: append([]string(nil), catalog.VideoSections...), + Videos: copyDocuments(catalog.Videos), + } + s.premiumPromoReady = true +} + +func (s *Service) clearPremiumPromo() { + s.premiumPromoMu.Lock() + defer s.premiumPromoMu.Unlock() + s.premiumPromo = domain.PremiumPromoCatalog{} + s.premiumPromoReady = false +} + +func (s *Service) warnPremiumPromoMissing(root string, err error) { + if s.log == nil { + return + } + s.log.Warn( + "Premium promo seed 目录不存在,help.getPremiumPromo 将返回无视频兼容响应", + zap.String("dir", root), + zap.Error(err), + ) +} + +func parsePremiumPromoSeed(root string, raw []byte) ([]premiumPromoSeedVideo, error) { + var parsed premiumPromoSeedJSON + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("parse %s: %w", premiumPromoManifestName, err) + } + if parsed.APICall != "help.getPremiumPromo" { + return nil, fmt.Errorf("api_call = %q, want help.getPremiumPromo", parsed.APICall) + } + if len(parsed.VideoSections) == 0 || len(parsed.VideoSections) > premiumPromoMaxVideos { + return nil, fmt.Errorf("video_sections count %d is outside 1..%d", len(parsed.VideoSections), premiumPromoMaxVideos) + } + if len(parsed.VideoSections) != len(parsed.Videos) { + return nil, fmt.Errorf("video_sections count %d does not match videos count %d", len(parsed.VideoSections), len(parsed.Videos)) + } + + seenSections := make(map[string]struct{}, len(parsed.VideoSections)) + seenDocuments := make(map[int64]struct{}, len(parsed.Videos)) + out := make([]premiumPromoSeedVideo, 0, len(parsed.Videos)) + var totalSize int64 + for i, dj := range parsed.Videos { + section := parsed.VideoSections[i] + if !validPremiumPromoSection(section) { + return nil, fmt.Errorf("video_sections[%d] %q is invalid", i, section) + } + if _, exists := seenSections[section]; exists { + return nil, fmt.Errorf("duplicate video section %q", section) + } + seenSections[section] = struct{}{} + + if dj.ID <= 0 { + return nil, fmt.Errorf("videos[%d].id must be positive", i) + } + if _, exists := seenDocuments[dj.ID]; exists { + return nil, fmt.Errorf("duplicate video document id %d", dj.ID) + } + seenDocuments[dj.ID] = struct{}{} + if dj.AccessHash == 0 { + return nil, fmt.Errorf("videos[%d].access_hash must be non-zero", i) + } + fileReference, err := hex.DecodeString(dj.FileReference) + if err != nil || len(fileReference) == 0 { + return nil, fmt.Errorf("videos[%d].file_reference is not non-empty hex", i) + } + date, err := time.Parse(time.RFC3339, dj.Date) + if err != nil || date.Unix() < 0 || date.Unix() > 1<<31-1 { + return nil, fmt.Errorf("videos[%d].date %q is outside TL int date range", i, dj.Date) + } + if dj.MimeType != "video/mp4" { + return nil, fmt.Errorf("videos[%d].mime_type = %q, want video/mp4", i, dj.MimeType) + } + if dj.Size <= 0 || dj.Size > premiumPromoMaxVideoSize { + return nil, fmt.Errorf("videos[%d].size %d is outside 1..%d", i, dj.Size, premiumPromoMaxVideoSize) + } + if err := validatePremiumPromoAttributes(i, dj.Attributes); err != nil { + return nil, err + } + + mainPath := filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", dj.ID)) + mainInfo, err := regularFileInfo(mainPath) + if err != nil { + return nil, fmt.Errorf("videos[%d] main file: %w", i, err) + } + if mainInfo.Size() != dj.Size { + return nil, fmt.Errorf("videos[%d] main file size %d does not match manifest %d", i, mainInfo.Size(), dj.Size) + } + if err := validateMP4Header(mainPath); err != nil { + return nil, fmt.Errorf("videos[%d] main file: %w", i, err) + } + + thumbPath := filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", dj.ID)) + thumbInfo, err := regularFileInfo(thumbPath) + if err != nil { + return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err) + } + if thumbInfo.Size() <= 0 || thumbInfo.Size() > premiumPromoMaxThumbSize { + return nil, fmt.Errorf("videos[%d] thumbnail size %d is outside 1..%d", i, thumbInfo.Size(), premiumPromoMaxThumbSize) + } + w, h, err := jpegDimensions(thumbPath) + if err != nil { + return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err) + } + thumbType := premiumPromoThumbType(w, h) + attributes := seedDocumentAttributes(dj.Attributes) + document := domain.Document{ + ID: dj.ID, + AccessHash: dj.AccessHash, + FileReference: fileReference, + Date: int(date.Unix()), + MimeType: dj.MimeType, + Size: dj.Size, + DCID: 0, // overwritten with the canonical server DC by the caller + Attributes: attributes, + Thumbs: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, + Type: thumbType, + W: w, + H: h, + Size: int(thumbInfo.Size()), + }}, + } + out = append(out, premiumPromoSeedVideo{ + section: section, + document: document, + mainPath: mainPath, + thumbPath: thumbPath, + thumbType: thumbType, + }) + totalSize += mainInfo.Size() + thumbInfo.Size() + if totalSize > premiumPromoMaxTotalSize { + return nil, fmt.Errorf("premium promo source bytes %d exceed limit %d", totalSize, premiumPromoMaxTotalSize) + } + } + return out, nil +} + +func validatePremiumPromoAttributes(index int, attrs []seedAttrJSON) error { + var filename, video, animated int + for j, attr := range attrs { + switch attr.Type { + case "DocumentAttributeFilename": + filename++ + if strings.TrimSpace(attr.FileName) == "" { + return fmt.Errorf("videos[%d].attributes[%d] has empty file_name", index, j) + } + case "DocumentAttributeVideo": + video++ + if attr.W <= 0 || attr.W > 16384 || attr.H <= 0 || attr.H > 16384 { + return fmt.Errorf("videos[%d].attributes[%d] has invalid video dimensions %dx%d", index, j, attr.W, attr.H) + } + if attr.Duration <= 0 || attr.Duration > 3600 { + return fmt.Errorf("videos[%d].attributes[%d] has invalid duration %v", index, j, attr.Duration) + } + case "DocumentAttributeAnimated": + animated++ + default: + return fmt.Errorf("videos[%d].attributes[%d] has unsupported type %q", index, j, attr.Type) + } + } + if filename != 1 || video != 1 || animated > 1 { + return fmt.Errorf("videos[%d] must contain exactly one filename/video and at most one animated attribute", index) + } + return nil +} + +func validPremiumPromoSection(section string) bool { + if section == "" || len(section) > 64 { + return false + } + for _, r := range section { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + return true +} + +func regularFileInfo(path string) (os.FileInfo, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%q is not a regular file", path) + } + return info, nil +} + +func validateMP4Header(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + header := make([]byte, 12) + if _, err := io.ReadFull(f, header); err != nil { + return fmt.Errorf("read MP4 header: %w", err) + } + if string(header[4:8]) != "ftyp" { + return errors.New("missing ISO BMFF ftyp header") + } + return nil +} + +func jpegDimensions(path string) (int, int, error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer f.Close() + cfg, err := jpeg.DecodeConfig(f) + if err != nil { + return 0, 0, fmt.Errorf("decode JPEG config: %w", err) + } + if cfg.Width <= 0 || cfg.Width > 16384 || cfg.Height <= 0 || cfg.Height > 16384 { + return 0, 0, fmt.Errorf("invalid JPEG dimensions %dx%d", cfg.Width, cfg.Height) + } + return cfg.Width, cfg.Height, nil +} + +func premiumPromoThumbType(w, h int) string { + maxDimension := w + if h > maxDimension { + maxDimension = h + } + switch { + case maxDimension <= 100: + return "s" + case maxDimension <= 320: + return "m" + case maxDimension <= 800: + return "x" + case maxDimension <= 1280: + return "y" + default: + return "w" + } +} + +func premiumPromoSeedHash(raw []byte, videos []premiumPromoSeedVideo, dc int) (string, error) { + return seedStateHash(func(h hash.Hash) error { + writeSeedStateHeader(h, premiumPromoSeedStateVersion, dc) + if _, err := h.Write(raw); err != nil { + return err + } + paths := make([]string, 0, len(videos)*2) + for _, video := range videos { + paths = append(paths, video.mainPath, video.thumbPath) + } + sort.Strings(paths) + for _, path := range paths { + info, err := regularFileInfo(path) + if err != nil { + return err + } + rel := filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path)) + _, _ = fmt.Fprintf(h, "\nfile=%s\x00size=%d\x00mtime=%d", filepath.ToSlash(rel), info.Size(), info.ModTime().UnixNano()) + } + return nil + }) +} + +func (s *Service) loadPremiumPromoCatalog(ctx context.Context, videos []premiumPromoSeedVideo) (domain.PremiumPromoCatalog, bool, error) { + ids := make([]int64, 0, len(videos)) + locationKeys := make([]string, 0, len(videos)*2) + for i := range videos { + videos[i].document.DCID = s.dc + ids = append(ids, videos[i].document.ID) + locationKeys = append( + locationKeys, + fmt.Sprintf("doc:%d", videos[i].document.ID), + fmt.Sprintf("doc:%d:%s", videos[i].document.ID, videos[i].thumbType), + ) + } + stored, err := s.media.GetDocuments(ctx, ids) + if err != nil { + return domain.PremiumPromoCatalog{}, false, err + } + if len(stored) != len(videos) { + return domain.PremiumPromoCatalog{}, false, nil + } + byID := make(map[int64]domain.Document, len(stored)) + for _, doc := range stored { + byID[doc.ID] = doc + } + blobs, err := s.media.GetFileBlobs(ctx, locationKeys) + if err != nil { + return domain.PremiumPromoCatalog{}, false, err + } + + catalog := domain.PremiumPromoCatalog{ + VideoSections: make([]string, 0, len(videos)), + Videos: make([]domain.Document, 0, len(videos)), + } + for _, video := range videos { + storedDoc, ok := byID[video.document.ID] + if !ok || !premiumPromoDocumentEqual(storedDoc, video.document) { + return domain.PremiumPromoCatalog{}, false, nil + } + mainKey := fmt.Sprintf("doc:%d", video.document.ID) + thumbKey := fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType) + if !s.premiumPromoBlobReady(ctx, blobs[mainKey], mainKey, video.document.Size, video.document.MimeType) { + return domain.PremiumPromoCatalog{}, false, nil + } + if !s.premiumPromoBlobReady(ctx, blobs[thumbKey], thumbKey, int64(video.document.Thumbs[0].Size), "image/jpeg") { + return domain.PremiumPromoCatalog{}, false, nil + } + catalog.VideoSections = append(catalog.VideoSections, video.section) + catalog.Videos = append(catalog.Videos, storedDoc) + } + return catalog, true, nil +} + +func premiumPromoDocumentEqual(got, want domain.Document) bool { + return got.ID == want.ID && + got.AccessHash == want.AccessHash && + bytes.Equal(got.FileReference, want.FileReference) && + got.Date == want.Date && + got.MimeType == want.MimeType && + got.Size == want.Size && + got.DCID == want.DCID && + reflect.DeepEqual(got.Attributes, want.Attributes) && + reflect.DeepEqual(got.Thumbs, want.Thumbs) +} + +func (s *Service) premiumPromoBlobReady(ctx context.Context, blob domain.FileBlob, locationKey string, size int64, mimeType string) bool { + if blob.LocationKey != locationKey || + blob.Backend != domain.MediaBackend(s.blobs.Name()) || + blob.ObjectKey == "" || + blob.Size != size || + blob.MimeType != mimeType { + return false + } + _, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, 1) + return err == nil && total == size +} + +func (s *Service) putPremiumPromoBlob( + ctx context.Context, + locationKey string, + path string, + mimeType string, + wantSize int64, + force bool, +) (bool, error) { + if !force { + if blob, found, err := s.media.GetFileBlob(ctx, locationKey); err != nil { + return false, err + } else if found && s.premiumPromoBlobReady(ctx, blob, locationKey, wantSize, mimeType) { + return false, nil + } + } + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + objectKey, size, sum, err := s.blobs.PutReader(ctx, f) + if err != nil { + return false, err + } + if size != wantSize { + return false, fmt.Errorf("streamed size %d does not match validated size %d", size, wantSize) + } + blob := domain.FileBlob{ + LocationKey: locationKey, + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: size, + SHA256: append([]byte(nil), sum...), + MimeType: mimeType, + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return false, err + } + s.blobCache.put(locationKey, blob) + return true, nil +} diff --git a/internal/app/files/premium_promo_seed_test.go b/internal/app/files/premium_promo_seed_test.go new file mode 100644 index 00000000..8a60d6da --- /dev/null +++ b/internal/app/files/premium_promo_seed_test.go @@ -0,0 +1,292 @@ +package files + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "os" + "path/filepath" + "sync" + "testing" + + "telesrv/internal/domain" +) + +func TestSeedPremiumPromoImportsDownloadsSkipsAndRepairs(t *testing.T) { + ctx := context.Background() + root, videoBytes, thumbBytes := writePremiumPromoFixture(t) + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 7, WithVideoThumbnailer(nil), WithGIFTranscoder(nil)) + + first, err := svc.SeedPremiumPromo(ctx, root) + if err != nil { + t.Fatalf("SeedPremiumPromo first: %v", err) + } + if first.Skipped || first.Videos != 1 || first.Blobs != 2 { + t.Fatalf("first stats = %+v, want one video and two blobs", first) + } + catalog, found, err := svc.PremiumPromo(ctx) + if err != nil || !found { + t.Fatalf("PremiumPromo found=%v err=%v", found, err) + } + if len(catalog.VideoSections) != 1 || catalog.VideoSections[0] != "no_ads" || len(catalog.Videos) != 1 { + t.Fatalf("catalog = %+v", catalog) + } + doc := catalog.Videos[0] + if doc.DCID != 7 || doc.MimeType != "video/mp4" || len(doc.Thumbs) != 1 || doc.Thumbs[0].Type != "m" { + t.Fatalf("document = %+v", doc) + } + + main, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("doc:%d", doc.ID), + Limit: len(videoBytes) + 1, + }) + if err != nil || !ok { + t.Fatalf("download main ok=%v err=%v", ok, err) + } + if !bytes.Equal(main.Bytes, videoBytes) { + t.Fatalf("downloaded main = %x, want %x", main.Bytes, videoBytes) + } + thumb, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, doc.Thumbs[0].Type), + Limit: len(thumbBytes) + 1, + }) + if err != nil || !ok { + t.Fatalf("download thumb ok=%v err=%v", ok, err) + } + if !bytes.Equal(thumb.Bytes, thumbBytes) { + t.Fatalf("downloaded thumb differs: got %d bytes, want %d", len(thumb.Bytes), len(thumbBytes)) + } + + // Returned values are request-owned: mutating one response must not corrupt + // the immutable catalog seen by later/concurrent requests. + catalog.VideoSections[0] = "mutated" + catalog.Videos[0].FileReference[0] ^= 0xff + catalog.Videos[0].Thumbs[0].Type = "z" + again, found, err := svc.PremiumPromo(ctx) + if err != nil || !found { + t.Fatalf("PremiumPromo again found=%v err=%v", found, err) + } + if again.VideoSections[0] != "no_ads" || again.Videos[0].Thumbs[0].Type != "m" || again.Videos[0].FileReference[0] != 0 { + t.Fatalf("catalog was mutated through returned value: %+v", again) + } + + var readers sync.WaitGroup + readerErrors := make(chan error, 32) + for i := 0; i < 32; i++ { + readers.Add(1) + go func() { + defer readers.Done() + got, found, err := svc.PremiumPromo(ctx) + if err != nil || !found || len(got.Videos) != 1 { + readerErrors <- fmt.Errorf("found=%v videos=%d err=%v", found, len(got.Videos), err) + return + } + got.VideoSections[0] = "request-owned" + got.Videos[0].FileReference[0] = 0x7f + }() + } + readers.Wait() + close(readerErrors) + for err := range readerErrors { + t.Error(err) + } + + second, err := svc.SeedPremiumPromo(ctx, root) + if err != nil { + t.Fatalf("SeedPremiumPromo unchanged: %v", err) + } + if !second.Skipped || second.Videos != 1 || second.Blobs != 0 { + t.Fatalf("unchanged stats = %+v, want skipped catalog", second) + } + + mainKey := fmt.Sprintf("doc:%d", doc.ID) + media.mu.Lock() + delete(media.blobs, mainKey) + media.mu.Unlock() + repaired, err := svc.SeedPremiumPromo(ctx, root) + if err != nil { + t.Fatalf("SeedPremiumPromo repair: %v", err) + } + if repaired.Skipped || repaired.Videos != 1 || repaired.Blobs != 1 { + t.Fatalf("repair stats = %+v, want one repaired blob", repaired) + } + if _, ok, err := media.GetFileBlob(ctx, mainKey); err != nil || !ok { + t.Fatalf("repaired main blob ok=%v err=%v", ok, err) + } +} + +func TestSeedPremiumPromoMissingAndInvalidSources(t *testing.T) { + ctx := context.Background() + newService := func(t *testing.T) *Service { + t.Helper() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + return NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil)) + } + + t.Run("missing directory falls back", func(t *testing.T) { + svc := newService(t) + stats, err := svc.SeedPremiumPromo(ctx, filepath.Join(t.TempDir(), "missing")) + if err != nil || !stats.Skipped { + t.Fatalf("stats=%+v err=%v, want optional-resource fallback", stats, err) + } + if _, found, err := svc.PremiumPromo(ctx); err != nil || found { + t.Fatalf("PremiumPromo found=%v err=%v, want unavailable", found, err) + } + }) + + t.Run("existing directory without manifest fails", func(t *testing.T) { + svc := newService(t) + if _, err := svc.SeedPremiumPromo(ctx, t.TempDir()); err == nil { + t.Fatal("existing incomplete seed directory was accepted") + } + }) + + t.Run("positional vectors must match", func(t *testing.T) { + root, _, _ := writePremiumPromoFixture(t) + rewritePremiumPromoManifest(t, root, func(m map[string]any) { + m["video_sections"] = []string{"no_ads", "extra"} + }) + if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil { + t.Fatal("mismatched video_sections/videos was accepted") + } + }) + + t.Run("manifest size must match file", func(t *testing.T) { + root, _, _ := writePremiumPromoFixture(t) + rewritePremiumPromoManifest(t, root, func(m map[string]any) { + videos := m["videos"].([]any) + videos[0].(map[string]any)["size"] = float64(999) + }) + if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil { + t.Fatal("wrong video size was accepted") + } + }) + + t.Run("missing thumbnail fails", func(t *testing.T) { + root, _, _ := writePremiumPromoFixture(t) + thumbPath := filepath.Join(root, "thumbs", "1000000000000001.jpg") + if err := os.Remove(thumbPath); err != nil { + t.Fatal(err) + } + if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil { + t.Fatal("missing thumbnail was accepted") + } + }) +} + +func TestSeedPremiumPromoFromRealExport(t *testing.T) { + root := os.Getenv("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR") + if root == "" { + t.Skip("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR not set") + } + if _, err := os.Stat(root); err != nil { + t.Skipf("seed dir %s not present: %v", root, err) + } + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil)) + stats, err := svc.SeedPremiumPromo(context.Background(), root) + if err != nil { + t.Fatalf("SeedPremiumPromo: %v", err) + } + catalog, found, err := svc.PremiumPromo(context.Background()) + if err != nil || !found { + t.Fatalf("PremiumPromo found=%v err=%v", found, err) + } + if stats.Videos != 31 || len(catalog.VideoSections) != 31 || len(catalog.Videos) != 31 { + t.Fatalf("stats=%+v sections=%d videos=%d, want 31", stats, len(catalog.VideoSections), len(catalog.Videos)) + } + t.Logf("real premium promo seed: videos=%d blobs=%d", stats.Videos, stats.Blobs) +} + +func writePremiumPromoFixture(t *testing.T) (string, []byte, []byte) { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "thumbs"), 0o755); err != nil { + t.Fatal(err) + } + const documentID int64 = 1000000000000001 + videoBytes := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0, 0, 0, 0} + if err := os.WriteFile(filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", documentID)), videoBytes, 0o644); err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, 160, 240)) + for y := 0; y < 240; y++ { + for x := 0; x < 160; x++ { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x88, A: 0xff}) + } + } + var thumb bytes.Buffer + if err := jpeg.Encode(&thumb, img, &jpeg.Options{Quality: 80}); err != nil { + t.Fatal(err) + } + thumbBytes := thumb.Bytes() + if err := os.WriteFile(filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", documentID)), thumbBytes, 0o644); err != nil { + t.Fatal(err) + } + manifest := premiumPromoSeedJSON{ + APICall: "help.getPremiumPromo", + StatusText: "ignored source status", + VideoSections: []string{"no_ads"}, + Videos: []seedDocumentJSON{{ + ID: documentID, + AccessHash: -7, + FileReference: "00112233445566778899aabbccddeeff", + Date: "2026-01-02T03:04:05Z", + MimeType: "video/mp4", + Size: int64(len(videoBytes)), + DCID: 4, + Attributes: []seedAttrJSON{ + {Type: "DocumentAttributeFilename", FileName: "promo.mp4"}, + {Type: "DocumentAttributeVideo", W: 720, H: 1070, Duration: 5, SupportsStreaming: true}, + {Type: "DocumentAttributeAnimated"}, + }, + }}, + } + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, premiumPromoManifestName), raw, 0o644); err != nil { + t.Fatal(err) + } + return root, append([]byte(nil), videoBytes...), append([]byte(nil), thumbBytes...) +} + +func rewritePremiumPromoManifest(t *testing.T, root string, mutate func(map[string]any)) { + t.Helper() + path := filepath.Join(root, premiumPromoManifestName) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + mutate(manifest) + raw, err = json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/files/service.go b/internal/app/files/service.go index 789d86fd..3133b8a1 100644 --- a/internal/app/files/service.go +++ b/internal/app/files/service.go @@ -7,6 +7,7 @@ import ( "fmt" "hash" "io" + "sync" "time" "telesrv/internal/domain" @@ -71,6 +72,13 @@ type Service struct { // effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。 effects []domain.AvailableEffect effectsHash int + + // premiumPromo is populated during startup seed and then read by RPC + // handlers. Keep a lock so the ownership boundary remains race-safe even + // when exercised concurrently in tests. + premiumPromoMu sync.RWMutex + premiumPromo domain.PremiumPromoCatalog + premiumPromoReady bool } // Option 配置 files 服务的可选能力。 diff --git a/internal/config/config.go b/internal/config/config.go index 0035f3c4..5b62b9d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -211,6 +211,9 @@ type Config struct { StickerSeedDir string // StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。 StickerSeedMaxSets int + // PremiumPromoSeedDir 是 help.getPremiumPromo 视频与缩略图导出目录。 + // 目录缺失时保留无视频兼容响应;目录存在但内容非法时启动失败。 + PremiumPromoSeedDir string // BusinessAIProvider 控制服务端 Business automation 回复生成器。 // 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路; // "template" 使用 quick reply 模板。 @@ -566,6 +569,7 @@ func Load() (Config, error) { BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300), + PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"), MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""), MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"), ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 64a345d1..bbbd093d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,6 +37,22 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { if cfg.CallRegistryMaxEntries != 10_000 { t.Fatalf("CallRegistryMaxEntries = %d, want 10000", cfg.CallRegistryMaxEntries) } + if cfg.PremiumPromoSeedDir != "data/premium-promo" { + t.Fatalf("PremiumPromoSeedDir = %q, want data/premium-promo", cfg.PremiumPromoSeedDir) + } +} + +func TestLoadPremiumPromoSeedDirOverride(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_PREMIUM_PROMO_SEED_DIR", `D:\seed\premium-promo`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.PremiumPromoSeedDir != `D:\seed\premium-promo` { + t.Fatalf("PremiumPromoSeedDir = %q", cfg.PremiumPromoSeedDir) + } } func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { diff --git a/internal/domain/premium.go b/internal/domain/premium.go new file mode 100644 index 00000000..ff72aec7 --- /dev/null +++ b/internal/domain/premium.go @@ -0,0 +1,10 @@ +package domain + +// PremiumPromoCatalog is the immutable, domain-only media catalog returned by +// help.getPremiumPromo. VideoSections[i] describes Videos[i]; callers must +// preserve the one-to-one ordering because official clients use positional +// lookup. +type PremiumPromoCatalog struct { + VideoSections []string + Videos []Document +} diff --git a/internal/rpc/auth_gate.go b/internal/rpc/auth_gate.go index 682a668b..e2439861 100644 --- a/internal/rpc/auth_gate.go +++ b/internal/rpc/auth_gate.go @@ -55,7 +55,6 @@ func rpcAllowedWithoutAuthorization(id uint32) bool { tg.HelpGetPeerProfileColorsRequestTypeID, tg.HelpGetPromoDataRequestTypeID, tg.HelpGetTermsOfServiceUpdateRequestTypeID, - tg.HelpGetPremiumPromoRequestTypeID, tg.LangpackGetLanguagesRequestTypeID, tg.LangpackGetLanguageRequestTypeID, tg.LangpackGetLangPackRequestTypeID, diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 431799ee..d44b47db 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -924,6 +924,13 @@ type ModerationService interface { ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) } +// PremiumPromoService exposes the immutable promo media catalog through a +// domain-only boundary. File bytes remain served by upload.getFile through the +// ordinary Files service. +type PremiumPromoService interface { + PremiumPromo(ctx context.Context) (domain.PremiumPromoCatalog, bool, error) +} + // Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 type Deps struct { Auth AuthService @@ -956,6 +963,7 @@ type Deps struct { Channels ChannelsService Communities CommunitiesService Files FilesService + PremiumPromo PremiumPromoService Bots BotsService Polls PollsService Phone PhoneService diff --git a/internal/rpc/help.go b/internal/rpc/help.go index 011d4b43..c4021efc 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -176,11 +176,10 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis return androidcompat.DismissSuggestion(req.Suggestion), nil } -// onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer -// 的会员有效期生成;videos/period_options 留空——购买入口已被 appConfig -// premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段(TDesktop -// 空 period_options 仅隐藏价格按钮,DrKLO 回退到无价文案,均不报错)。 -// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。 +// onHelpGetPremiumPromo returns the viewer-specific Premium status plus the +// immutable, startup-seeded video catalog. Period options intentionally remain +// empty: telesrv has no subscription purchase backend and must not advertise +// dead payment URLs. All six TL fields are mandatory. func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { promo := &tg.HelpPremiumPromo{ StatusText: branding.PremiumName + " is not active on this account.", @@ -190,17 +189,42 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm PeriodOptions: []tg.PremiumSubscriptionOption{}, Users: []tg.UserClass{}, } - userID, _, err := r.currentUserID(ctx) - if err != nil || r.deps.Users == nil { - return promo, nil + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !authorized || userID == 0 { + return nil, authKeyUnregisteredErr() + } + if r.deps.Users == nil { + return nil, notImplementedErr() } u, err := r.deps.Users.Self(ctx, userID) if err != nil { - return promo, nil + return nil, internalErr() + } + if u.ID != userID { + return nil, internalErr() + } + if u.Bot { + return nil, botMethodInvalidErr() } if u.PremiumActiveAt(r.clock.Now().Unix()) { until := time.Unix(int64(u.PremiumUntil), 0) promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "." } + if r.deps.PremiumPromo != nil { + catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx) + if err != nil { + return nil, internalErr() + } + if found { + if len(catalog.VideoSections) != len(catalog.Videos) { + return nil, internalErr() + } + promo.VideoSections = append([]string(nil), catalog.VideoSections...) + promo.Videos = tgDocuments(catalog.Videos) + } + } return promo, nil } diff --git a/internal/rpc/help_premium_promo_test.go b/internal/rpc/help_premium_promo_test.go new file mode 100644 index 00000000..88e3ce79 --- /dev/null +++ b/internal/rpc/help_premium_promo_test.go @@ -0,0 +1,176 @@ +package rpc + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "github.com/iamxvbaba/td/tlprofile" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +type staticPremiumPromoService struct { + catalog domain.PremiumPromoCatalog + found bool + err error +} + +func (s staticPremiumPromoService) PremiumPromo(context.Context) (domain.PremiumPromoCatalog, bool, error) { + out := domain.PremiumPromoCatalog{ + VideoSections: append([]string(nil), s.catalog.VideoSections...), + Videos: append([]domain.Document(nil), s.catalog.Videos...), + } + for i := range out.Videos { + out.Videos[i].FileReference = append([]byte(nil), out.Videos[i].FileReference...) + out.Videos[i].Attributes = append([]domain.DocumentAttribute(nil), out.Videos[i].Attributes...) + out.Videos[i].Thumbs = append([]domain.PhotoSize(nil), out.Videos[i].Thumbs...) + } + return out, s.found, s.err +} + +func TestHelpGetPremiumPromoReturnsSeededCatalogAcrossExactProfiles(t *testing.T) { + const userID int64 = 1000000001 + now := time.Date(2026, 7, 26, 8, 0, 0, 0, time.UTC) + user := domain.User{ + ID: userID, + AccessHash: 17, + FirstName: "Alice", + PremiumUntil: int(now.Add(48 * time.Hour).Unix()), + } + catalog := premiumPromoRPCTestCatalog() + r := New(Config{}, Deps{ + Users: staticUsersService{user: user}, + PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + ctx := WithUserID(context.Background(), userID) + + for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ { + t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) { + result, method := dispatchExactLayerRPCTest(t, r, ctx, profile, &tg.HelpGetPremiumPromoRequest{}) + if method != "help.getPremiumPromo" { + t.Fatalf("method = %q", method) + } + promo, ok := dispatchCanonicalValue(result).(*tg.HelpPremiumPromo) + if !ok { + t.Fatalf("response = %T, want *tg.HelpPremiumPromo", dispatchCanonicalValue(result)) + } + if len(promo.VideoSections) != 1 || promo.VideoSections[0] != "no_ads" || len(promo.Videos) != 1 { + t.Fatalf("promo vectors = sections:%v videos:%d", promo.VideoSections, len(promo.Videos)) + } + doc, ok := promo.Videos[0].(*tg.Document) + if !ok { + t.Fatalf("video = %T, want *tg.Document", promo.Videos[0]) + } + if doc.ID != catalog.Videos[0].ID || doc.DCID != 2 || len(doc.Thumbs) != 1 { + t.Fatalf("document = %+v", doc) + } + thumb, ok := doc.Thumbs[0].(*tg.PhotoSize) + if !ok || thumb.Type != "m" || thumb.Size != 1234 { + t.Fatalf("thumb = %#v", doc.Thumbs[0]) + } + if len(promo.PeriodOptions) != 0 { + t.Fatalf("period options = %+v, want no dead purchase entry", promo.PeriodOptions) + } + if !strings.Contains(promo.StatusText, "2026-07-28") { + t.Fatalf("status text = %q, want viewer expiry", promo.StatusText) + } + }) + } +} + +func TestHelpGetPremiumPromoAuthorizationBotAndFallback(t *testing.T) { + const userID int64 = 1000000001 + user := domain.User{ID: userID, AccessHash: 17, FirstName: "Alice"} + r := New(Config{}, Deps{ + Users: staticUsersService{user: user}, + PremiumPromo: staticPremiumPromoService{}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)}) + + if rpcAllowedWithoutAuthorization(tg.HelpGetPremiumPromoRequestTypeID) { + t.Fatal("help.getPremiumPromo must require a fully authorized user") + } + if _, err := r.onHelpGetPremiumPromo(context.Background()); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("unauthorized error = %v, want AUTH_KEY_UNREGISTERED", err) + } + + promo, err := r.onHelpGetPremiumPromo(WithUserID(context.Background(), userID)) + if err != nil { + t.Fatalf("fallback response: %v", err) + } + if len(promo.VideoSections) != 0 || len(promo.Videos) != 0 || len(promo.PeriodOptions) != 0 { + t.Fatalf("fallback vectors = %+v", promo) + } + + botRouter := New(Config{}, Deps{ + Users: staticUsersService{user: domain.User{ + ID: userID, + AccessHash: 19, + FirstName: "PromoBot", + Bot: true, + }}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)}) + if _, err := botRouter.onHelpGetPremiumPromo(WithUserID(context.Background(), userID)); !tgerr.Is(err, "BOT_METHOD_INVALID") { + t.Fatalf("bot error = %v, want BOT_METHOD_INVALID", err) + } +} + +func TestHelpGetPremiumPromoResponsesDoNotShareMutableDocuments(t *testing.T) { + const userID int64 = 1000000001 + catalog := premiumPromoRPCTestCatalog() + r := New(Config{}, Deps{ + Users: staticUsersService{user: domain.User{ID: userID}}, + PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)}) + ctx := WithUserID(context.Background(), userID) + + first, err := r.onHelpGetPremiumPromo(ctx) + if err != nil { + t.Fatal(err) + } + first.VideoSections[0] = "mutated" + firstDoc := first.Videos[0].(*tg.Document) + firstDoc.DCID = 99 + firstDoc.FileReference[0] ^= 0xff + + second, err := r.onHelpGetPremiumPromo(ctx) + if err != nil { + t.Fatal(err) + } + secondDoc := second.Videos[0].(*tg.Document) + if second.VideoSections[0] != "no_ads" || secondDoc.DCID != 2 || secondDoc.FileReference[0] != 0 { + t.Fatalf("second response inherited mutation: sections=%v doc=%+v", second.VideoSections, secondDoc) + } +} + +func premiumPromoRPCTestCatalog() domain.PremiumPromoCatalog { + return domain.PremiumPromoCatalog{ + VideoSections: []string{"no_ads"}, + Videos: []domain.Document{{ + ID: 5814500255441357739, + AccessHash: 5876417653416908580, + FileReference: []byte{0, 1, 2, 3}, + Date: 1_654_006_663, + MimeType: "video/mp4", + Size: 2_650_178, + DCID: 2, + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrFilename, FileName: "promo.mp4"}, + {Kind: domain.DocAttrVideo, W: 720, H: 1070, Duration: 5, SupportsStreaming: true}, + {Kind: domain.DocAttrAnimated}, + }, + Thumbs: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, + Type: "m", + W: 160, + H: 240, + Size: 1234, + }}, + }}, + } +}