Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -0,0 +1,83 @@
package admin
import (
"context"
"os"
"testing"
stargiftapp "telesrv/internal/app/stargifts"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
// This opt-in regression uses the exact official snapshot reported by the
// Party Sparkler import issue. It crosses official catalog verification,
// admin mapping, animation materialization and the complete store validator.
func TestConfiguredOfficialPartySparklerImport(t *testing.T) {
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
if root == "" {
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
}
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Gifts: giftService,
OfficialGifts: officialgifts.New(root),
Now: fixedNow,
})
result, err := svc.ImportOfficialStarGift(ctx, ImportOfficialStarGiftRequest{
CommandMeta: CommandMeta{
CommandID: "exec-party-sparkler-snapshot-regression",
Actor: "test",
Reason: "verify official collectible import",
},
SourceGiftID: "6003643167683903930",
Enabled: true,
IncludeCollectible: true,
})
if err != nil {
t.Fatalf("import Party Sparkler: result=%+v err=%v", result, err)
}
if result.Details["models"] != 100 || result.Details["patterns"] != 136 || result.Details["backdrops"] != 60 {
t.Fatalf("Party Sparkler details = %+v", result.Details)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%+v err=%v, want one gift", catalog, err)
}
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
if err != nil || !ok || len(preview.Models) != 100 || len(preview.Patterns) != 136 || len(preview.Backdrops) != 60 {
t.Fatalf("preview counts=%d/%d/%d ok=%v err=%v", len(preview.Models), len(preview.Patterns), len(preview.Backdrops), ok, err)
}
for _, model := range preview.Models {
if model.Document == nil || !model.Document.IsSticker() || model.Document.IsCustomEmoji() {
t.Fatalf("model %q document=%+v, want ordinary sticker", model.Name, model.Document)
}
}
for _, pattern := range preview.Patterns {
if pattern.Document == nil || pattern.Document.IsSticker() || !pattern.Document.IsCustomEmoji() ||
!hasTextColorCustomEmoji(pattern.Document.Attributes) || !hasInlinePathThumb(pattern.Document.Thumbs) {
t.Fatalf("pattern %q document=%+v, want text-color custom emoji with inline path", pattern.Name, pattern.Document)
}
}
}
func hasTextColorCustomEmoji(attributes []domain.DocumentAttribute) bool {
for _, attribute := range attributes {
if attribute.Kind == domain.DocAttrCustomEmoji && attribute.TextColor {
return true
}
}
return false
}
func hasInlinePathThumb(thumbs []domain.PhotoSize) bool {
for _, thumb := range thumbs {
if thumb.Kind == domain.PhotoSizeKindPath && thumb.Type != "" && len(thumb.Bytes) > 0 {
return true
}
}
return false
}

View file

@ -4,15 +4,18 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
)
const (
@ -25,6 +28,7 @@ const (
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import"
ActionImportOfficialStarGift = "gifts.official.import"
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
@ -94,7 +98,9 @@ type MessagesService interface {
type GiftsService interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, 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)
@ -103,6 +109,11 @@ type GiftsService interface {
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
type OfficialGiftsSource interface {
List(ctx context.Context) ([]officialgifts.GiftSummary, error)
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -116,6 +127,7 @@ type Dependencies struct {
ChannelNotifier ChannelNotifier
Messages MessagesService
Gifts GiftsService
OfficialGifts OfficialGiftsSource
Now func() time.Time
}
@ -132,6 +144,7 @@ type Service struct {
channelNotifier ChannelNotifier
messages MessagesService
gifts GiftsService
officialGifts OfficialGiftsSource
now func() time.Time
}
@ -177,6 +190,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Gifts != nil {
s.gifts = deps.Gifts
}
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Now != nil {
s.now = deps.Now
}
@ -219,6 +235,23 @@ type ImportStarGiftRequest struct {
Data []byte `json:"-"`
}
type ImportOfficialStarGiftRequest struct {
CommandMeta
SourceGiftID string `json:"source_gift_id"`
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"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
SupplyTotal int `json:"supply_total,omitempty"`
SlugPrefix string `json:"slug_prefix,omitempty"`
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
AssetSHA256 []string `json:"asset_sha256,omitempty"`
}
type SetStarGiftEnabledRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
@ -796,8 +829,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
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,
"gift_id": strconv.FormatInt(req.GiftID, 10), "title": strings.TrimSpace(req.Title),
"stars": strconv.FormatInt(req.Stars, 10), "convert_stars": strconv.FormatInt(req.ConvertStars, 10),
"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),
@ -813,13 +847,232 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
if err != nil {
return CommandResult{Details: details}, err
}
details["gift_id"] = entry.Gift.ID
details["revision_id"] = entry.Gift.RevisionID
details["gift_id"] = strconv.FormatInt(entry.Gift.ID, 10)
details["revision_id"] = strconv.FormatInt(entry.Gift.RevisionID, 10)
details["revision"] = entry.Revision
return CommandResult{Message: "star gift imported", Details: details}, nil
})
}
func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) {
if s == nil || s.officialGifts == nil {
return nil, officialgifts.ErrUnavailable
}
return s.officialGifts.List(ctx)
}
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
if s == nil || s.officialGifts == nil || s.gifts == nil {
return nil, false, officialgifts.ErrUnavailable
}
id, err := strconv.ParseInt(strings.TrimSpace(sourceGiftID), 10, 64)
if err != nil || id <= 0 {
return nil, false, officialgifts.ErrNotFound
}
bundle, err := s.officialGifts.Bundle(ctx, id, false)
if errors.Is(err, officialgifts.ErrNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
animation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
if err != nil {
return nil, false, err
}
return animation.JSON, true, nil
}
func (s *Service) ImportOfficialStarGift(ctx context.Context, req ImportOfficialStarGiftRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || s.officialGifts == nil {
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
}
sourceID, err := strconv.ParseInt(strings.TrimSpace(req.SourceGiftID), 10, 64)
if err != nil || sourceID <= 0 || req.GiftID < 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, domain.ErrStarGiftInvalid
}
bundle, err := s.officialGifts.Bundle(ctx, sourceID, req.IncludeCollectible)
if err != nil {
return CommandResult{}, err
}
if req.Title = strings.TrimSpace(req.Title); req.Title == "" {
req.Title = strings.TrimSpace(bundle.Gift.Title)
if req.Title == "" {
req.Title = "Official gift " + req.SourceGiftID
}
}
if req.Stars <= 0 {
req.Stars = bundle.Gift.Stars
}
if req.ConvertStars < 0 || req.ConvertStars > req.Stars || len([]rune(req.Title)) > domain.MaxStarGiftTitleRunes {
return CommandResult{}, domain.ErrStarGiftInvalid
}
if req.UpgradeStars <= 0 {
req.UpgradeStars = bundle.Gift.UpgradeStars
}
if req.SupplyTotal <= 0 {
req.SupplyTotal = bundle.Gift.AvailabilityTotal
}
if req.SlugPrefix = strings.ToLower(strings.TrimSpace(req.SlugPrefix)); req.SlugPrefix == "" {
req.SlugPrefix = "official-" + req.SourceGiftID
}
baseAnimation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official gift animation: %w", err)
}
assetHashes := []string{bundle.BaseDocument.SHA256}
rarityCounts := map[string]int{}
var background *domain.StarGiftBackground
if bundle.Gift.Background != nil {
background = &domain.StarGiftBackground{
CenterColor: bundle.Gift.Background.CenterColor,
EdgeColor: bundle.Gift.Background.EdgeColor,
TextColor: bundle.Gift.Background.TextColor,
}
}
var collectible *domain.StarGiftCollectibleWrite
if req.IncludeCollectible {
if bundle.Collectible == nil {
return CommandResult{}, domain.ErrStarGiftCollectibleInvalid
}
models := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Models))
for index, value := range bundle.Collectible.Models {
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official model %q: %w", value.Name, err)
}
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
models = append(models, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleModel,
Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille,
Crafted: value.Crafted, OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
assetHashes = append(assetHashes, value.Document.SHA256)
rarityCounts[string(rarityKind)]++
}
patterns := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Patterns))
for index, value := range bundle.Collectible.Patterns {
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
if err != nil {
return CommandResult{}, fmt.Errorf("prepare official pattern %q: %w", value.Name, err)
}
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
patterns = append(patterns, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectiblePattern,
Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille,
OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
assetHashes = append(assetHashes, value.Document.SHA256)
rarityCounts[string(rarityKind)]++
}
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Backdrops))
for index, value := range bundle.Collectible.Backdrops {
rarityKind, permille, err := officialRarity(value.Rarity)
if err != nil {
return CommandResult{}, err
}
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop,
Name: strings.TrimSpace(value.Name), BackdropID: value.BackdropID, CenterColor: value.CenterColor,
EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor,
RarityKind: rarityKind, RarityPermille: permille, SortOrder: index})
rarityCounts[string(rarityKind)]++
}
collectible = &domain.StarGiftCollectibleWrite{GiftID: req.GiftID, UpgradeStars: req.UpgradeStars,
SupplyTotal: req.SupplyTotal, SlugPrefix: req.SlugPrefix, Models: models, Patterns: patterns, Backdrops: backdrops,
Actor: req.Actor, CommandID: req.CommandID, OfficialGiftID: sourceID,
SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...)}
validation := *collectible
if validation.GiftID == 0 {
validation.GiftID = 1
}
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
return CommandResult{}, err
}
}
req.ManifestSHA256 = hex.EncodeToString(bundle.ManifestSHA256)
sort.Strings(assetHashes)
req.AssetSHA256 = assetHashes
write := domain.StarGiftCatalogBundleWrite{Catalog: domain.StarGiftCatalogWrite{
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: baseAnimation, Actor: req.Actor, CommandID: req.CommandID,
OfficialGiftID: sourceID, SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...),
OfficialSourceJSON: append([]byte(nil), bundle.SourceJSON...),
// The snapshot describes Telegram's global market, not this deployment's
// inventory. Keep the complete source JSON as provenance, while publishing
// regular official imports as a fresh, locally purchasable catalog entry.
// Local resale counters and sale dates are derived by lifecycle writes.
Limited: false, SoldOut: false, Birthday: bundle.Gift.Birthday,
RequirePremium: bundle.Gift.RequirePremium, LimitedPerUser: bundle.Gift.LimitedPerUser,
PeerColorAvailable: bundle.Gift.PeerColorAvailable, Auction: bundle.Gift.Auction,
AvailabilityRemains: 0, AvailabilityTotal: 0,
AvailabilityResale: 0, FirstSaleDate: 0,
LastSaleDate: 0, ResellMinStars: 0,
PerUserTotal: bundle.Gift.PerUserTotal, LockedUntilDate: bundle.Gift.LockedUntilDate,
AuctionSlug: bundle.Gift.AuctionSlug, GiftsPerRound: bundle.Gift.GiftsPerRound,
AuctionStartDate: bundle.Gift.AuctionStartDate, UpgradeVariants: bundle.Gift.UpgradeVariants,
Background: background,
}, Collectible: collectible}
return s.runCommand(ctx, req.CommandMeta, ActionImportOfficialStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"source_gift_id": req.SourceGiftID, "gift_id": strconv.FormatInt(req.GiftID, 10),
"manifest_sha256": req.ManifestSHA256, "title": req.Title, "stars": strconv.FormatInt(req.Stars, 10),
"convert_stars": strconv.FormatInt(req.ConvertStars, 10), "include_collectible": req.IncludeCollectible,
"verified_asset_count": len(assetHashes), "rarity_counts": rarityCounts,
"official_limited": bundle.Gift.Limited, "official_sold_out": bundle.Gift.SoldOut,
"official_auction": bundle.Gift.Auction, "official_birthday": bundle.Gift.Birthday,
"official_require_premium": bundle.Gift.RequirePremium,
"official_availability_remains": bundle.Gift.AvailabilityRemains,
"official_availability_total": bundle.Gift.AvailabilityTotal,
"official_availability_resale": bundle.Gift.AvailabilityResale,
}
if bundle.Collectible != nil {
details["models"] = len(bundle.Collectible.Models)
details["patterns"] = len(bundle.Collectible.Patterns)
details["backdrops"] = len(bundle.Collectible.Backdrops)
crafted := 0
for _, model := range bundle.Collectible.Models {
if model.Crafted {
crafted++
}
}
details["crafted_models"] = crafted
}
if req.DryRun {
return CommandResult{Message: "official star gift bundle validated", Details: details}, nil
}
result, err := s.gifts.CreateCatalogBundle(ctx, write)
if err != nil {
return CommandResult{Details: details}, err
}
details["gift_id"] = strconv.FormatInt(result.Catalog.Gift.ID, 10)
details["catalog_revision_id"] = strconv.FormatInt(result.Catalog.Gift.RevisionID, 10)
if result.Collectible != nil {
details["collectible_revision_id"] = strconv.FormatInt(result.Collectible.ID, 10)
details["collectible_revision"] = result.Collectible.Revision
}
return CommandResult{Message: "official star gift bundle imported", Details: details}, nil
})
}
func officialRarity(value officialgifts.Rarity) (domain.StarGiftAttributeRarityKind, int, error) {
kind := domain.StarGiftAttributeRarityKind(strings.ToLower(strings.TrimSpace(value.Kind)))
if !kind.Valid() {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
if kind == domain.StarGiftRarityPermille {
if value.Permille == nil || *value.Permille <= 0 || *value.Permille > 1000 {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
return kind, *value.Permille, nil
}
if value.Permille != nil {
return "", 0, domain.ErrStarGiftCollectibleInvalid
}
return kind, 0, 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")
@ -833,8 +1086,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
}
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,
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille,
RarityPermille: uploads[i].RarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation,
}
}
return attributes, nil
@ -852,7 +1106,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
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,
TextColor: backdrop.TextColor, RarityKind: domain.StarGiftRarityPermille,
RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder,
}
}
write := domain.StarGiftCollectibleWrite{
@ -873,8 +1128,9 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
}
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),
"gift_id": strconv.FormatInt(req.GiftID, 10), "upgrade_stars": strconv.FormatInt(req.UpgradeStars, 10),
"supply_total": req.SupplyTotal,
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models),
"patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops),
}
if req.DryRun {
@ -884,7 +1140,7 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
if err != nil {
return CommandResult{Details: details}, err
}
details["revision_id"] = revision.ID
details["revision_id"] = strconv.FormatInt(revision.ID, 10)
details["revision"] = revision.Revision
details["published"] = revision.Published
return CommandResult{Message: "star gift collectible pool published", Details: details}, nil
@ -907,7 +1163,7 @@ func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabled
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}
details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "enabled": req.Enabled}
if req.DryRun {
return CommandResult{Message: "star gift state change validated", Details: details}, nil
}
@ -922,7 +1178,7 @@ func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortO
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}
details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "sort_order": req.SortOrder}
if req.DryRun {
return CommandResult{Message: "star gift order change validated", Details: details}, nil
}

View file

@ -1,15 +1,20 @@
package admin
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"reflect"
"strings"
"testing"
"time"
stargiftapp "telesrv/internal/app/stargifts"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
@ -710,7 +715,7 @@ func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
}
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) {
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
@ -747,12 +752,161 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
}
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 {
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
type fakeGiftsService struct{ createCalls int }
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
permille := 922
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x42, 32),
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
Gift: officialgifts.Gift{
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
},
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
},
Patterns: []officialgifts.Pattern{{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}}},
Backdrops: []officialgifts.Backdrop{{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}}},
},
}}
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
result, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
models := gifts.lastBundle.Collectible.Models
if len(models) != 2 || !models[1].Crafted || models[1].RarityKind != domain.StarGiftRarityLegendary || models[1].RarityPermille != 0 ||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
}
catalog := gifts.lastBundle.Catalog
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
}
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
t.Fatalf("official provenance was not preserved: %+v", catalog)
}
}
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
document := func(id int64, name string) officialgifts.Document {
raw := []byte(lottie)
sum := sha256.Sum256(raw)
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
}
permille := 1000
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x24, sha256.Size),
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
Gift: officialgifts.Gift{
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
},
BaseDocument: document(1, "gift.json"),
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{{
Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
Document: document(2, "model.json"),
}},
Patterns: []officialgifts.Pattern{{
Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
Document: document(3, "pattern.json"),
}},
Backdrops: []officialgifts.Backdrop{{
Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille},
}},
},
}}
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
})
req := ImportOfficialStarGiftRequest{
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
}
result, err := svc.ImportOfficialStarGift(ctx, req)
if err != nil {
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
}
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
if err != nil || !ok || len(preview.Models) != 1 || len(preview.Patterns) != 1 {
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
}
model := preview.Models[0].Document
pattern := preview.Patterns[0].Document
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
}
}
type adminGiftBlob struct{ data map[string][]byte }
func (b *adminGiftBlob) Name() string { return "localfs" }
func (b *adminGiftBlob) 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 *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func bytesOf(value byte, count int) []byte {
out := make([]byte, count)
for i := range out {
out[i] = value
}
return out
}
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
}
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
if giftID != f.bundle.Gift.ID {
return officialgifts.Bundle{}, officialgifts.ErrNotFound
}
out := f.bundle
if !include {
out.Collectible = nil
}
return out, nil
}
type fakeGiftsService struct {
createCalls int
lastBundle domain.StarGiftCatalogBundleWrite
}
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data)
@ -761,10 +915,24 @@ func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.St
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil
}
func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
return f.PrepareAnimation(name, data)
}
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 (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
f.createCalls++
f.lastBundle = write
entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1}
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
if write.Collectible != nil {
revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true}
result.Collectible = &revision
}
return result, nil
}
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -15,6 +16,7 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
)
type Config struct {
@ -32,6 +34,9 @@ type Service interface {
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)
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error)
OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, 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)
@ -95,6 +100,9 @@ func (s *Server) routes() http.Handler {
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("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts))
mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation))
mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift))
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))
@ -221,6 +229,60 @@ func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
items, err := s.svc.OfficialStarGifts(r.Context())
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, officialgifts.ErrUnavailable) {
status = http.StatusServiceUnavailable
}
writeError(w, status, err.Error())
return
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
result = append(result, officialStarGiftListItem(item))
}
writeJSON(w, http.StatusOK, map[string]any{"gifts": result})
}
func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any {
return map[string]any{
"source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title,
"stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10),
"upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10),
"availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut,
"model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount,
"crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(),
"document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated,
}
}
func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "official 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) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) {
var req admin.ImportOfficialStarGiftRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.ImportOfficialStarGift(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)
@ -338,7 +400,7 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque
return
}
if !found {
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID})
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)})
return
}
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
@ -347,8 +409,10 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque
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,
"id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind,
"rarity_permille": value.RarityPermille, "crafted": value.Crafted,
"official_document_id": strconv.FormatInt(value.OfficialDocumentID, 10),
"sort_order": value.SortOrder, "kind": value.Kind,
}
if value.Animation != nil {
result["source_name"] = value.Animation.SourceName
@ -371,8 +435,9 @@ func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[strin
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,
"found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision,
"upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10),
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
"slug_prefix": preview.SlugPrefix,
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
"backdrops": mapAttributes(preview.Backdrops),

View file

@ -11,6 +11,7 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
)
func TestAdminAPIRequiresBearerToken(t *testing.T) {
@ -151,6 +152,49 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
}
}
func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
got := collectiblePreviewResponse(domain.StarGiftUpgradePreview{
GiftID: maxInt64,
UpgradeStars: maxInt64,
Models: []domain.StarGiftCollectibleAttribute{{
ID: maxInt64,
Kind: domain.StarGiftCollectibleModel,
Name: "Exact",
RarityKind: domain.StarGiftRarityPermille,
RarityPermille: 1000,
OfficialDocumentID: maxInt64,
}},
})
if got["gift_id"] != "9223372036854775807" || got["upgrade_stars"] != "9223372036854775807" {
t.Fatalf("preview ids = %#v", got)
}
models, ok := got["models"].([]map[string]any)
if !ok || len(models) != 1 {
t.Fatalf("preview models = %#v", got["models"])
}
if models[0]["id"] != "9223372036854775807" || models[0]["official_document_id"] != "9223372036854775807" {
t.Fatalf("preview model ids = %#v", models[0])
}
}
func TestOfficialStarGiftListItemExposesExplicitCapabilities(t *testing.T) {
item := officialStarGiftListItem(officialgifts.GiftSummary{
ID: 9223372036854775807, Title: "Fresh Socks", Stars: 25, ConvertStars: 10, UpgradeStars: 50,
ModelCount: 10, PatternCount: 20, BackdropCount: 30, CraftedModelCount: 2,
})
if item["source_gift_id"] != "9223372036854775807" || item["title"] != "Fresh Socks" ||
item["can_upgrade"] != true || item["can_craft"] != true {
t.Fatalf("official gift item = %#v", item)
}
item = officialStarGiftListItem(officialgifts.GiftSummary{
ID: 1, UpgradeStars: 0, ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1,
})
if item["can_upgrade"] != false || item["can_craft"] != false {
t.Fatalf("unavailable official gift capabilities = %#v", item)
}
}
type fakeService struct{}
type captureFreezeService struct {
@ -219,6 +263,18 @@ func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftReq
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
}
func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -0,0 +1,361 @@
package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
const accountDeletionDelay = 7 * 24 * time.Hour
// DeleteAccount implements the official 2FA deletion decision. A supplied and
// valid SRP proof always deletes immediately. Without a proof, an account whose
// password is older than seven days and which was active during the last seven
// days gets a cancellable seven-day window; all other cases delete immediately.
func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) {
if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
if now.IsZero() {
now = time.Now().UTC()
}
reason = strings.TrimSpace(reason)
if len(reason) > 1024 {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
if !found {
return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound
}
if snapshot.User.Deleted {
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil
}
if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
if password != nil && !password.Empty {
if !snapshot.HasPassword {
return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid
}
if err := s.CheckPassword(ctx, userID, *password); err != nil {
return domain.AccountDeleteOutcome{}, err
}
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
if !snapshot.HasPassword {
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
lastActive := snapshot.User.CreatedAt
if snapshot.User.LastSeenAt > 0 {
seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC()
if seen.After(lastActive) {
lastActive = seen
}
}
passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay))
recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay))
if !passwordOldEnough || !recentlyActive {
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
if snapshot.Pending != nil {
return delayedDeleteOutcome(*snapshot.Pending, now), nil
}
rawToken, digest, err := newAccountDeletionToken()
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
executeAt := now.Add(accountDeletionDelay)
message := fmt.Sprintf(
"A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
)
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: userID,
RequesterAuthKeyID: authKeyID,
Reason: reason,
ConfirmHashDigest: digest,
ServiceMessage: message,
RequestedAt: now,
ExecuteAt: executeAt,
})
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
return delayedDeleteOutcome(pending, now), nil
}
func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) {
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now)
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
if s.userCache != nil {
_ = s.userCache.Delete(ctx, []int64{userID})
}
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil
}
func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome {
wait := int(time.Until(pending.ExecuteAt).Seconds())
if !now.IsZero() {
wait = int(pending.ExecuteAt.Sub(now).Seconds())
}
if wait < 0 {
wait = 0
}
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt}
}
func deletionSourceForReason(reason string) domain.AccountDeletionSource {
switch strings.ToLower(strings.TrimSpace(reason)) {
case "forgot password":
return domain.AccountDeletionForgotPassword
case "decline tos update":
return domain.AccountDeletionTOSDecline
default:
return domain.AccountDeletionManual
}
}
func newAccountDeletionToken() (string, [32]byte, error) {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err)
}
token := hex.EncodeToString(raw[:])
return token, sha256.Sum256([]byte(token)), nil
}
func accountDeletionDigest(raw string) ([32]byte, error) {
raw = strings.TrimSpace(raw)
decoded, err := hex.DecodeString(raw)
if err != nil || len(decoded) != 32 {
return [32]byte{}, domain.ErrAccountDeletionHashInvalid
}
return sha256.Sum256([]byte(raw)), nil
}
// SendConfirmPhoneCode validates the secret confirmphone link and issues an
// auth-key-scoped SMS code to the account's current phone.
func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) {
digest, err := accountDeletionDigest(rawHash)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil {
return "", domain.AuthCodeDelivery{}, err
} else if !found {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
if !found || u.Deleted || u.Phone == "" {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
}
func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) {
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
}
if strings.TrimSpace(code) == "" {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured")
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
Purpose: store.PhoneCodePurposeConfirmPhone,
UserID: userID,
AuthKeyID: authKeyID,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
AccountDeletionHash: hex.EncodeToString(digest[:]),
}
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeConfirmPhone,
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, cleanupErr)
}
return "", domain.AuthCodeDelivery{}, err
}
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil
}
// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and
// revokes the auth key that initiated the deletion attempt.
func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) {
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
return nil, domain.ErrPhoneCodeEmpty
}
if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil {
return nil, domain.ErrPhoneCodeInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return nil, err
}
if !found || u.Deleted || u.Phone == "" {
return nil, domain.ErrPhoneCodeInvalid
}
scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone}
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
if err != nil {
return nil, err
}
switch verified.Status {
case store.LoginCodeVerifyMissing:
return nil, domain.ErrPhoneCodeExpired
case store.LoginCodeVerifyInvalid:
return nil, domain.ErrPhoneCodeInvalid
case store.LoginCodeVerifyAccepted:
default:
return nil, domain.ErrPhoneCodeInvalid
}
digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash)
if err != nil || len(digestBytes) != 32 {
return nil, domain.ErrPhoneCodeInvalid
}
var digest [32]byte
copy(digest[:], digestBytes)
if now.IsZero() {
now = time.Now().UTC()
}
return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now)
}
// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is
// an active confirm-phone code for this authorized user/auth key.
func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) {
if s == nil || s.codes == nil || s.users == nil || userID == 0 {
return "", domain.AuthCodeDelivery{}, false, nil
}
rec, found, err := s.codes.Get(ctx, oldHash)
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
return "", domain.AuthCodeDelivery{}, false, err
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) {
if err == nil {
err = domain.ErrPhoneCodeInvalid
}
return "", domain.AuthCodeDelivery{}, true, err
}
consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope())
if err != nil || !found {
if err == nil {
err = domain.ErrPhoneCodeExpired
}
return "", domain.AuthCodeDelivery{}, true, err
}
digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash)
if err != nil || len(digestBytes) != 32 {
return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid
}
var digest [32]byte
copy(digest[:], digestBytes)
hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
return hash, delivery, true, err
}
func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) {
if s == nil || s.codes == nil || userID == 0 {
return false, nil
}
rec, found, err := s.codes.Get(ctx, hash)
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
return false, err
}
if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) {
return true, domain.ErrPhoneCodeInvalid
}
_, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope())
return true, err
}
func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) {
if s == nil || s.lifecycle == nil || limit <= 0 {
return nil, nil
}
candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit)
if err != nil {
return nil, err
}
out := make([]domain.AccountDeletionResult, 0, len(candidates))
for _, candidate := range candidates {
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now)
if err != nil {
return out, err
}
if s.userCache != nil {
_ = s.userCache.Delete(ctx, []int64{candidate.UserID})
}
out = append(out, result)
}
return out, nil
}
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.lifecycle == nil {
return nil, nil
}
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
}
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
}

View file

@ -0,0 +1,149 @@
package account
import (
"context"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
now := time.Unix(1_800_000_000, 0).UTC()
authKey := [8]byte{1}
tests := []struct {
name string
hasPassword bool
passwordUpdated time.Time
createdAt time.Time
lastSeen int
wantKind domain.AccountDeleteKind
}{
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
}}
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
if err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
if outcome.Kind != test.wantKind {
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
}
if test.wantKind == domain.AccountDeleteDelayed {
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
}
} else if lifecycle.executedSource == "" {
t.Fatal("immediate path did not execute the tombstone boundary")
}
})
}
}
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_800_000_000, 0).UTC()
users := memory.NewUserStore()
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
if err != nil {
t.Fatal(err)
}
requester := [8]byte{9}
confirming := [8]byte{8}
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
WithAccountLifecycle(lifecycle),
)
rawToken, digest, err := newAccountDeletionToken()
if err != nil {
t.Fatal(err)
}
lifecycle.pending = &domain.AccountDeletionRequest{
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
}
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
if err != nil || hash == "" || delivery.Length != 5 {
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
}
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
if err != nil {
t.Fatalf("ConfirmPhone: %v", err)
}
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
}
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("replay error = %v, want expired", err)
}
}
type fakeAccountLifecycleStore struct {
snapshot domain.AccountDeletionSnapshot
pending *domain.AccountDeletionRequest
scheduled *domain.ScheduleAccountDeletion
executedSource domain.AccountDeletionSource
}
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
f.snapshot.Pending = f.pending
return f.snapshot, true, nil
}
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
f.scheduled = &req
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
f.pending = &pending
return pending, true, nil
}
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
return domain.AccountDeletionRequest{}, false, nil
}
return *f.pending, true, nil
}
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
f.executedSource = source
u := f.snapshot.User
u.Deleted = true
u.DeletedAt = now.Unix()
u.DeletionSource = source
u.DeletionReason = reason
u = u.DeletedTombstone()
return domain.AccountDeletionResult{User: u, Changed: true}, nil
}
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
return nil, domain.ErrAccountDeletionHashInvalid
}
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
f.pending = nil
return revoked, nil
}
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
return nil
}

View file

@ -43,6 +43,7 @@ type Service struct {
userCache store.UserCache
authorizations store.AuthorizationStore
phoneChanges store.PhoneChangeStore
lifecycle store.AccountLifecycleStore
publicBaseURL string
codes store.CodeStore
phoneChangeCode string
@ -189,6 +190,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption
}
}
// WithAccountLifecycle installs the single durable account deletion boundary.
// It shares the already configured phone-code delivery and user cache.
func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption {
return func(s *Service) {
s.lifecycle = lifecycle
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{

View file

@ -17,6 +17,7 @@ import (
"github.com/iamxvbaba/td/bin"
mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
@ -1547,9 +1548,9 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
return found && settings.HasPassword, nil
}
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your Telegram account. We never ask it for anything else.
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.`

View file

@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -42,7 +43,7 @@ const (
botFatherDraftBotUsername = "bot_username"
)
const botFatherHelpText = `I can help you create and manage Telegram bots.
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
You can control me by sending these commands:

View file

@ -27,7 +27,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
before, _, _ := users.ByID(ctx, bot.ID)
v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{
{Command: "/Start", Description: "begin"},
{Command: "/Start", Description: "begin", Ephemeral: true},
{Command: "help", Description: "show help"},
})
if err != nil {
@ -40,7 +40,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
if err != nil {
t.Fatalf("get commands: %v", err)
}
if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" {
if len(got) != 2 || got[0].Command != "start" || !got[0].Ephemeral || got[1].Command != "help" || got[1].Ephemeral {
t.Fatalf("commands = %+v, want normalized [start,help]", got)
}

View file

@ -497,7 +497,7 @@ func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
return 0, domain.ErrBotCommandInvalid
}
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc})
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc, Ephemeral: c.Ephemeral})
}
// 同值短路bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
// bot_info_version bump驱动全体客户端多打一轮 getFullUser与多余推送。
@ -528,7 +528,7 @@ func botCommandsEqual(a, b []domain.BotCommand) bool {
return false
}
for i := range a {
if a[i].Command != b[i].Command || a[i].Description != b[i].Description {
if a[i].Command != b[i].Command || a[i].Description != b[i].Description || a[i].Ephemeral != b[i].Ephemeral {
return false
}
}

View file

@ -508,6 +508,15 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) (
return s.channels.ListAdminedPublicChannels(ctx, userID)
}
// ListCommunityLinkableChannels returns owned/administered channels that are not
// already linked to another Community. Private megagroups are valid candidates.
func (s *Service) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, nil
}
return s.channels.ListCommunityLinkableChannels(ctx, userID)
}
// ListStoryPostableChannels returns channels where user can publish stories.
func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -1276,6 +1285,9 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send
if req.UserID != userID {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.SendChannelMessageResult{}, err
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.ChannelSendFingerprint(req)
if err != nil {
@ -1343,6 +1355,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.EditChannelMessageResult{}, err
}
}
return s.channels.EditChannelMessage(ctx, req)
}
@ -1359,6 +1376,11 @@ func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req dom
if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.EditChannelMessageResult{}, err
}
}
req.ViaBotEditBotID = botID
return s.channels.EditChannelMessage(ctx, req)
}

View file

@ -0,0 +1,185 @@
package communities
import (
"context"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service owns Community business validation. The store is the aggregate
// transaction boundary because link changes span community/link and peer rows.
type Service struct {
communities store.CommunityStore
}
func NewService(communities store.CommunityStore) *Service {
return &Service{communities: communities}
}
func validPeer(peer domain.Peer) bool {
return peer.ID > 0 && (peer.Type == domain.PeerTypeChannel || peer.Type == domain.PeerTypeUser)
}
func validVisibility(v domain.CommunityPeerVisibility) bool {
return v == domain.CommunityPeerVisible || v == domain.CommunityPeerHidden
}
func (s *Service) Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error) {
if s == nil || s.communities == nil || userID == 0 || !validPeer(req.InitialPeer) || !validVisibility(req.Visibility) {
return domain.CommunityView{}, domain.ErrCommunityInvalid
}
req.CreatorUserID = userID
req.Title = strings.TrimSpace(req.Title)
req.About = strings.TrimSpace(req.About)
if req.Title == "" || utf8.RuneCountInString(req.Title) > domain.MaxCommunityTitleRunes {
return domain.CommunityView{}, domain.ErrChannelTitleInvalid
}
if utf8.RuneCountInString(req.About) > domain.MaxCommunityAboutRunes {
return domain.CommunityView{}, domain.ErrAboutTooLong
}
return s.communities.CreateCommunity(ctx, req)
}
func (s *Service) Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 {
return domain.CommunityView{}, domain.ErrCommunityInvalid
}
return s.communities.GetCommunity(ctx, userID, communityID)
}
func (s *Service) GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) {
if s == nil || s.communities == nil || userID == 0 {
return nil, domain.ErrCommunityInvalid
}
return s.communities.GetCommunities(ctx, userID, ids)
}
func (s *Service) ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error) {
if s == nil || s.communities == nil || userID == 0 {
return nil, domain.ErrCommunityInvalid
}
return s.communities.ListJoinedCommunities(ctx, userID)
}
func (s *Service) TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) {
if s == nil || s.communities == nil || userID == 0 || req.CommunityID == 0 || !validPeer(req.Peer) {
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid
}
if !req.Deleted && !validVisibility(req.Visibility) {
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid
}
req.ActorUserID = userID
return s.communities.ToggleCommunityPeerLink(ctx, req)
}
func (s *Service) SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 {
return domain.CommunityView{}, false, domain.ErrCommunityInvalid
}
return s.communities.SetCommunityCollapsed(ctx, userID, communityID, collapsed)
}
func (s *Service) ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 {
return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityInvalid
}
if limit <= 0 || limit > domain.MaxCommunityLinkRequests {
limit = domain.MaxCommunityLinkRequests
}
return s.communities.ListCommunityPeerLinkRequests(ctx, userID, communityID, offset, limit)
}
func (s *Service) DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 || !validPeer(peer) {
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid
}
return s.communities.DecideCommunityPeerLinkRequest(ctx, userID, communityID, peer, reject, date)
}
func (s *Service) DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 {
return nil, domain.ErrCommunityInvalid
}
return s.communities.DecideAllCommunityPeerLinkRequests(ctx, userID, communityID, reject, date)
}
func (s *Service) ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 {
return domain.CommunityParticipantBanResult{}, domain.ErrCommunityInvalid
}
return s.communities.ToggleCommunityParticipantBanned(ctx, userID, communityID, participantUserID, unban, date)
}
func (s *Service) ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 {
return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityInvalid
}
return s.communities.GetCommunityParticipantJoinedChats(ctx, userID, communityID, participantUserID)
}
func (s *Service) Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) {
if s == nil || s.communities == nil || userID == 0 || communityID == 0 {
return domain.CommunityParticipantList{}, domain.ErrCommunityInvalid
}
if offset < 0 {
offset = 0
}
if offset > domain.MaxChannelParticipantsOffset {
offset = domain.MaxChannelParticipantsOffset
}
if limit <= 0 || limit > domain.MaxCommunityParticipants {
limit = domain.MaxCommunityParticipants
}
return s.communities.ListCommunityParticipants(ctx, userID, communityID, filter, offset, limit)
}
func (s *Service) EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error) {
title = strings.TrimSpace(title)
if title == "" || utf8.RuneCountInString(title) > domain.MaxCommunityTitleRunes {
return domain.CommunityView{}, false, domain.ErrChannelTitleInvalid
}
return s.communities.EditCommunityTitle(ctx, userID, communityID, title)
}
func (s *Service) EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error) {
about = strings.TrimSpace(about)
if utf8.RuneCountInString(about) > domain.MaxCommunityAboutRunes {
return domain.CommunityView{}, false, domain.ErrAboutTooLong
}
return s.communities.EditCommunityAbout(ctx, userID, communityID, about)
}
func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) {
if req.CommunityID == 0 || req.UserID == 0 || userID == 0 {
return domain.CommunityView{}, false, domain.ErrCommunityInvalid
}
req.ActorUserID = userID
return s.communities.EditCommunityAdmin(ctx, req)
}
func (s *Service) EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) {
return s.communities.EditCommunityDefaultBannedRights(ctx, userID, communityID, rights)
}
func (s *Service) SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) {
return s.communities.SetCommunityPhoto(ctx, userID, communityID, photo, date)
}
func (s *Service) Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) {
return s.communities.DeleteCommunity(ctx, userID, communityID, date)
}
func (s *Service) SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) {
return s.communities.SetCommunityPinned(ctx, userID, communityID, pinned)
}
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) {
return s.communities.ReorderCommunityPinned(ctx, userID, order, force)
}
func (s *Service) SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error) {
return s.communities.CommunitySearchScope(ctx, userID, communityID)
}

View file

@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
if in == nil {
return nil
}
out := &domain.MessageReplyMarkup{}
out := &domain.MessageReplyMarkup{
Type: in.Type,
Resize: in.Resize,
SingleUse: in.SingleUse,
Selective: in.Selective,
Persistent: in.Persistent,
Placeholder: in.Placeholder,
}
if len(in.Inline) > 0 {
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
for i, row := range in.Inline {
@ -472,6 +479,12 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
}
}
}
if len(in.Keyboard) > 0 {
out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard))
for i, row := range in.Keyboard {
out.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
return out
}

View file

@ -0,0 +1,617 @@
package ephemeral
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type ChannelAccess interface {
ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
}
type UserDirectory interface {
ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error)
}
type BotCommands interface {
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
}
type Option func(*Service)
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
func WithIDGenerator(next func() (int, error)) Option {
return func(s *Service) {
if next != nil {
s.nextID = next
}
}
}
type Service struct {
messages store.EphemeralMessageStore
channels ChannelAccess
users UserDirectory
bots BotCommands
now func() time.Time
nextID func() (int, error)
}
func NewService(messages store.EphemeralMessageStore, channels ChannelAccess, users UserDirectory, bots BotCommands, options ...Option) *Service {
s := &Service{
messages: messages,
channels: channels,
users: users,
bots: bots,
now: time.Now,
nextID: randomEphemeralID,
}
for _, option := range options {
if option != nil {
option(s)
}
}
return s
}
func (s *Service) SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) {
if s == nil || s.messages == nil || s.channels == nil || s.users == nil || s.bots == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.SenderUserID <= 0 || request.ReceiverBotID <= 0 || request.SenderUserID == request.ReceiverBotID ||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 || request.RandomID == 0 ||
request.OriginDevice.UserID != request.SenderUserID || request.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
request.OriginDevice.SessionID == 0 || !validContent(request.Content) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
view, err := s.requireActiveGroupPair(ctx, request.SenderUserID, request.ReceiverBotID, request.Peer.ID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
receiver, found, err := s.users.ByID(ctx, request.SenderUserID, request.ReceiverBotID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || !receiver.Bot || receiver.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
}
var replyTarget *domain.EphemeralMessage
if request.ReplyToEphemeralID != 0 {
target, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, s.now())
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || target.Deleted || target.SenderUserID != request.ReceiverBotID || target.ReceiverUserID != request.SenderUserID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
if target.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && target.OriginDevice.BusinessAuthKeyID != request.OriginDevice.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
if request.TopMessageID != 0 && request.TopMessageID != target.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = target.TopMessageID
replyTarget = &target
} else {
allowed, err := s.isEphemeralCommand(ctx, receiver, request.Content.Message)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !allowed {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralCommandInvalid
}
}
if err := s.validateForumTopic(ctx, request.SenderUserID, view, request.TopMessageID); err != nil {
return domain.EphemeralMessage{}, false, err
}
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
Peer: request.Peer,
SenderUserID: request.SenderUserID,
ReceiverUserID: request.ReceiverBotID,
RandomID: request.RandomID,
TopMessageID: request.TopMessageID,
ReplyToEphemeralID: request.ReplyToEphemeralID,
Content: request.Content,
OriginDevice: request.OriginDevice,
PayloadHash: clientPayloadHash(request),
})
if err == nil && replyTarget != nil {
message.BotAPIReply = replyTarget
}
return message, fresh, err
}
func (s *Service) SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) {
return s.sendFromBot(ctx, request, func(context.Context) (domain.EphemeralContent, error) {
return request.Content, nil
})
}
// SendFromBotLazy authorizes the bot, receiver, chat and eligible action before
// materializing content. The RPC edge uses it for URL/upload media so an
// unauthorized target cannot consume file storage, network or decoder work.
func (s *Service) SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
if build == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
return s.sendFromBot(ctx, request, build)
}
func (s *Service) sendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
if s == nil || s.messages == nil || s.channels == nil || s.users == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.BotUserID <= 0 || request.ReceiverUserID <= 0 || request.BotUserID == request.ReceiverUserID ||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
view, err := s.requireActiveGroupPair(ctx, request.BotUserID, request.ReceiverUserID, request.Peer.ID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
bot, found, err := s.users.ByID(ctx, request.BotUserID, request.BotUserID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || !bot.Bot || bot.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralSenderInvalid
}
receiver, found, err := s.users.ByID(ctx, request.BotUserID, request.ReceiverUserID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || receiver.Bot || receiver.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
}
now := s.now()
var targetDevice domain.EphemeralDevice
var replyTarget *domain.EphemeralMessage
if request.ActionMessageID != 0 && request.CallbackQueryID != 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.CallbackQueryID != 0 {
action, found, err := s.messages.GetEphemeralCallbackAction(ctx, request.BotUserID, request.CallbackQueryID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || action.UserID != request.ReceiverUserID || action.Peer != request.Peer || !now.Before(action.ExpiresAt) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
targetDevice = action.Device
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = action.TopMessageID
} else if request.ActionMessageID != 0 {
action, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ActionMessageID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || action.Deleted || action.SenderUserID != request.ReceiverUserID || action.ReceiverUserID != request.BotUserID ||
now.Sub(action.CreatedAt) < 0 || now.Sub(action.CreatedAt) > domain.EphemeralReplyWindow {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
targetDevice = action.OriginDevice
replyTarget = &action
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = action.TopMessageID
if request.ReplyToEphemeralID == 0 {
request.ReplyToEphemeralID = action.ID
}
} else {
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
}
if request.ReplyToEphemeralID != 0 {
var reply domain.EphemeralMessage
found := false
if replyTarget != nil && replyTarget.ID == request.ReplyToEphemeralID {
reply, found = *replyTarget, true
} else {
var err error
reply, found, err = s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
}
if !found || reply.Deleted || !sameEphemeralParticipants(reply, request.BotUserID, request.ReceiverUserID) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
if targetDevice.BusinessAuthKeyID != ([8]byte{}) && reply.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
targetDevice.BusinessAuthKeyID != reply.OriginDevice.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
if request.TopMessageID != 0 && request.TopMessageID != reply.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = reply.TopMessageID
replyTarget = &reply
}
if err := s.validateForumTopic(ctx, request.BotUserID, view, request.TopMessageID); err != nil {
return domain.EphemeralMessage{}, false, err
}
content, err := build(ctx)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !validContent(content) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
request.Content = content
if request.RandomID == 0 {
request.RandomID, err = randomEphemeralRandomID()
if err != nil {
return domain.EphemeralMessage{}, false, err
}
}
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
Peer: request.Peer,
SenderUserID: request.BotUserID,
ReceiverUserID: request.ReceiverUserID,
RandomID: request.RandomID,
TopMessageID: request.TopMessageID,
ReplyToEphemeralID: request.ReplyToEphemeralID,
Content: request.Content,
OriginDevice: targetDevice,
PayloadHash: botPayloadHash(request),
})
if err == nil && replyTarget != nil {
message.BotAPIReply = replyTarget
}
return message, fresh, err
}
func (s *Service) EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.SenderUserID != botUserID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
}
func (s *Service) EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) {
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, func(context.Context) (domain.EditEphemeralFields, error) {
return fields, nil
})
}
// EditFieldsFromBotLazy performs the identity/ownership lookup before building
// replacement media. This keeps invalid edit requests off the remote-fetch and
// blob-materialization paths while preserving a single CAS write on success.
func (s *Service) EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
if build == nil {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, build)
}
func (s *Service) editFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.SenderUserID != botUserID || message.ReceiverUserID != receiverUserID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
fields, err := build(ctx)
if err != nil {
return domain.EphemeralMessage{}, err
}
switch mode {
case domain.EphemeralEditText:
if message.Content.Media != nil || !message.Content.RichMessage.IsZero() || !fields.SetMessage {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditCaption:
if message.Content.Media == nil || !fields.SetMessage {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditMedia:
if message.Content.Media == nil || !fields.SetMedia {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditReplyMarkup:
if !fields.SetReplyMarkup || fields.SetMessage || fields.SetMedia {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
default:
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
content := message.Content
if fields.SetMessage {
content.Message = fields.Message
content.Entities = append([]domain.MessageEntity(nil), fields.Entities...)
}
if fields.SetMedia {
content.Media = fields.Media
}
if fields.SetReplyMarkup {
content.ReplyMarkup = fields.ReplyMarkup
}
if !validContent(content) {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
}
func (s *Service) Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
return s.delete(ctx, actorUserID, receiverUserID, nil, peer, id)
}
func (s *Service) DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
if device.UserID != actorUserID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
return s.delete(ctx, actorUserID, receiverUserID, &device, peer, id)
}
func (s *Service) delete(ctx context.Context, actorUserID, receiverUserID int64, device *domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
}
if message.ReceiverUserID != receiverUserID || (actorUserID != message.SenderUserID && actorUserID != message.ReceiverUserID) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
if device != nil && message.OriginDevice.UserID == actorUserID && message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
return s.messages.DeleteEphemeralMessage(ctx, peer, id, message.Version, now)
}
func (s *Service) Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) {
if len(data) > domain.MaxEphemeralCallbackDataBytes || userID <= 0 || device.UserID != userID ||
device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralCallback{}, err
}
if !found || message.Deleted || message.ReceiverUserID != userID {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
if !ephemeralMarkupContainsCallback(message.Content.ReplyMarkup, data) {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralCallback{}, domain.ErrEphemeralDeviceMismatch
}
return domain.EphemeralCallback{
Message: message,
BotUserID: message.SenderUserID,
UserID: userID,
Peer: peer,
Data: append([]byte(nil), data...),
Device: device,
OccurredAt: now,
}, nil
}
func (s *Service) PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) {
if s == nil || s.messages == nil {
return false, domain.ErrEphemeralInvalid
}
return s.messages.PutEphemeralCallbackAction(ctx, action)
}
func (s *Service) ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
if userID <= 0 || device.UserID != userID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, s.now())
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found || message.Deleted || message.ReceiverUserID != userID {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralMessage{}, domain.ErrEphemeralDeviceMismatch
}
return message, nil
}
func ephemeralMarkupContainsCallback(markup *domain.MessageReplyMarkup, data []byte) bool {
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return false
}
for _, row := range markup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
return true
}
}
}
return false
}
func sameEphemeralParticipants(message domain.EphemeralMessage, first, second int64) bool {
return (message.SenderUserID == first && message.ReceiverUserID == second) ||
(message.SenderUserID == second && message.ReceiverUserID == first)
}
func (s *Service) create(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
now := s.now()
message.Date = int(now.Unix())
message.CreatedAt = now
message.ExpiresAt = now.Add(domain.EphemeralMessageRetention)
message.Version = 1
for attempt := 0; attempt < domain.MaxEphemeralCreateAttempts; attempt++ {
id, err := s.nextID()
if err != nil {
return domain.EphemeralMessage{}, false, err
}
message.ID = id
created, fresh, err := s.messages.CreateEphemeralMessage(ctx, message)
if !errors.Is(err, domain.ErrEphemeralIDCollision) {
return created, fresh, err
}
}
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
}
func (s *Service) requireActiveGroupPair(ctx context.Context, viewerUserID, otherUserID, channelID int64) (domain.ChannelView, error) {
view, err := s.channels.ResolveChannel(ctx, viewerUserID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
if view.Channel.Deleted || view.Channel.Broadcast || view.Channel.Monoforum || view.Self.Status != domain.ChannelMemberActive {
return domain.ChannelView{}, domain.ErrEphemeralPeerInvalid
}
other, err := s.channels.GetParticipant(ctx, viewerUserID, channelID, otherUserID)
if err != nil {
return domain.ChannelView{}, err
}
if other.Status != domain.ChannelMemberActive {
return domain.ChannelView{}, domain.ErrEphemeralReceiverInvalid
}
return view, nil
}
func (s *Service) validateForumTopic(ctx context.Context, userID int64, view domain.ChannelView, topMessageID int) error {
if topMessageID == 0 {
return nil
}
if !view.Channel.Forum || topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
return domain.ErrEphemeralPeerInvalid
}
topics, err := s.channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, []int{topMessageID})
if err != nil {
return err
}
if len(topics.Topics) != 1 || topics.Topics[0].TopicID != topMessageID || topics.Topics[0].Hidden {
return domain.ErrEphemeralPeerInvalid
}
if topics.Topics[0].Closed && view.Self.Role != domain.ChannelRoleAdmin && view.Self.Role != domain.ChannelRoleCreator {
return domain.ErrEphemeralForbidden
}
return nil
}
func (s *Service) isEphemeralCommand(ctx context.Context, bot domain.User, message string) (bool, error) {
command, username, ok := parseCommand(message)
if !ok || (username != "" && !strings.EqualFold(username, bot.Username)) {
return false, nil
}
commands, err := s.bots.GetBotCommands(ctx, bot.ID)
if err != nil {
return false, err
}
for _, candidate := range commands {
if candidate.Ephemeral && strings.EqualFold(candidate.Command, command) {
return true, nil
}
}
return false, nil
}
func parseCommand(message string) (command, username string, ok bool) {
fields := strings.Fields(strings.TrimSpace(message))
if len(fields) == 0 || len(fields[0]) < 2 || fields[0][0] != '/' {
return "", "", false
}
parts := strings.SplitN(fields[0][1:], "@", 2)
command = strings.ToLower(parts[0])
if command == "" {
return "", "", false
}
if len(parts) == 2 {
username = strings.TrimPrefix(strings.ToLower(parts[1]), "@")
if username == "" {
return "", "", false
}
}
return command, username, true
}
func validContent(content domain.EphemeralContent) bool {
return domain.ValidateEphemeralContent(content) == nil
}
func clientPayloadHash(request domain.SendClientEphemeralRequest) [32]byte {
return payloadHash(struct {
SenderUserID, ReceiverBotID int64
Peer domain.Peer
QueryID, RandomID int64
TopMessageID, ReplyID int
Content domain.EphemeralContent
Device domain.EphemeralDevice
}{request.SenderUserID, request.ReceiverBotID, request.Peer, request.QueryID, request.RandomID,
request.TopMessageID, request.ReplyToEphemeralID, request.Content, request.OriginDevice})
}
func botPayloadHash(request domain.SendBotEphemeralRequest) [32]byte {
return payloadHash(request)
}
func payloadHash(value any) [32]byte {
raw, err := json.Marshal(value)
if err != nil {
return sha256.Sum256([]byte("invalid-ephemeral-payload"))
}
return sha256.Sum256(raw)
}
func randomEphemeralID() (int, error) {
var raw [4]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
}
value := binary.LittleEndian.Uint32(raw[:]) & 0x7fffffff
if value == 0 {
value = 1
}
return int(value), nil
}
func randomEphemeralRandomID() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
}
value := int64(binary.LittleEndian.Uint64(raw[:]))
if value == 0 {
value = 1
}
return value, nil
}

View file

@ -0,0 +1,385 @@
package ephemeral
import (
"context"
"crypto/sha256"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const (
testHumanID int64 = 1001
testBotID int64 = 2001
testChannel int64 = 3001
testSession int64 = 4001
)
var testDeviceKey = [8]byte{1, 2, 3, 4}
type testChannels struct {
roles map[int64]domain.ChannelMemberRole
status map[int64]domain.ChannelMemberStatus
channel domain.Channel
}
func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
if channelID != c.channel.ID {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{
ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID],
}}, nil
}
func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) {
if channelID != c.channel.ID {
return domain.ChannelMember{}, domain.ErrChannelInvalid
}
return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil
}
func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
if channelID != c.channel.ID {
return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
}
out := domain.ChannelForumTopicList{Channel: c.channel}
for _, id := range ids {
if id > 0 {
out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id})
}
}
return out, nil
}
type testUsers map[int64]domain.User
func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) {
user, found := u[userID]
return user, found, nil
}
type testBots map[int64][]domain.BotCommand
func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) {
return append([]domain.BotCommand(nil), b[botUserID]...), nil
}
type serviceFixture struct {
service *Service
store *memory.EphemeralMessageStore
now time.Time
nextID int
channels *testChannels
}
func newServiceFixture() *serviceFixture {
f := &serviceFixture{
store: memory.NewEphemeralMessageStore(),
now: time.Unix(1_900_000_000, 0),
nextID: 10,
channels: &testChannels{
roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember},
status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive},
channel: domain.Channel{ID: testChannel, Megagroup: true},
},
}
f.service = NewService(f.store, f.channels, testUsers{
testHumanID: {ID: testHumanID, Username: "alice"},
testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1},
}, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}},
WithClock(func() time.Time { return f.now }),
WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil }))
return f
}
func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest {
return domain.SendClientEphemeralRequest{
SenderUserID: testHumanID, ReceiverBotID: testBotID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"},
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession},
}
}
func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) {
f := newServiceFixture()
message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest())
if err != nil || !fresh {
t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err)
}
if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey {
t.Fatalf("message = %+v", message)
}
request := f.clientRequest()
request.RandomID++
request.Content.Message = "/public"
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) {
t.Fatalf("ordinary command err=%v", err)
}
}
func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) {
f := newServiceFixture()
request := f.clientRequest()
message, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || !fresh {
t.Fatalf("create fresh=%v err=%v", fresh, err)
}
device := request.OriginDevice
if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed {
t.Fatalf("delete changed=%v err=%v", changed, err)
}
replayed, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 {
t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err)
}
}
func TestClientReplyMustMatchTargetDevice(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
request := f.clientRequest()
request.Content.Message = "reply"
request.ReplyToEphemeralID = incoming.ID
reply, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID {
t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err)
}
request.RandomID++
request.OriginDevice.BusinessAuthKeyID = [8]byte{9}
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device reply err=%v", err)
}
}
func TestBotReplyWindowAndAdminBroadcast(t *testing.T) {
f := newServiceFixture()
action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest())
if err != nil {
t.Fatal(err)
}
f.now = f.now.Add(14 * time.Second)
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID,
Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID,
})
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID ||
reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID {
t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err)
}
f.now = f.now.Add(2 * time.Second)
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID,
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
t.Fatalf("late bot reply err=%v", err)
}
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
RandomID: 94, Content: domain.EphemeralContent{Message: "admin"},
})
if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) {
t.Fatalf("admin broadcast = %+v err=%v", broadcast, err)
}
}
func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" {
t.Fatalf("callback = %+v err=%v", callback, err)
}
device.BusinessAuthKeyID = [8]byte{7}
if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device callback err=%v", err)
}
if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device delete err=%v", err)
}
device.BusinessAuthKeyID = testDeviceKey
deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID)
if err != nil || !changed || !deleted.Deleted {
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
}
}
func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
if err != nil {
t.Fatal(err)
}
const queryID = int64(777)
created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{
QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer,
MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now,
ExpiresAt: f.now.Add(domain.EphemeralReplyWindow),
})
if err != nil || !created {
t.Fatalf("put callback action created=%v err=%v", created, err)
}
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"},
})
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey {
t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err)
}
f.now = f.now.Add(domain.EphemeralReplyWindow)
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"},
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
t.Fatalf("expired callback action err=%v", err)
}
}
func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) {
f := newServiceFixture()
f.channels.channel.Forum = true
incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42)
request := f.clientRequest()
request.Content.Message = "topic reply"
request.ReplyToEphemeralID = incoming.ID
reply, _, err := f.service.SendFromClient(context.Background(), request)
if err != nil || reply.TopMessageID != 42 {
t.Fatalf("topic reply=%+v err=%v", reply, err)
}
f = newServiceFixture()
request = f.clientRequest()
request.TopMessageID = 42
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) {
t.Fatalf("non-forum topic err=%v", err)
}
}
func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) {
f := newServiceFixture()
request := f.clientRequest()
request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private "))
if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil {
t.Fatalf("4096 Unicode characters rejected: %v", err)
}
request.RandomID++
request.Content.Message += "界"
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("overlong Unicode text err=%v", err)
}
}
func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) {
f := newServiceFixture()
textMessage := f.putIncoming(t, testDeviceKey, f.now)
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil {
t.Fatalf("text edit: %v", err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("caption edit on text err=%v", err)
}
mediaMessage := f.putIncoming(t, testDeviceKey, f.now)
mediaContent := domain.EphemeralContent{
Message: "caption",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}},
}
mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now)
if err != nil {
t.Fatal(err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil {
t.Fatalf("media caption edit: %v", err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("text edit on media err=%v", err)
}
}
func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) {
f := newServiceFixture()
builds := 0
buildText := func(context.Context) (domain.EphemeralContent, error) {
builds++
return domain.EphemeralContent{Message: "authorized"}, nil
}
request := domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID + 99,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
}
if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil {
t.Fatal("unknown receiver was accepted")
}
if builds != 0 {
t.Fatalf("unauthorized send materialized content %d times", builds)
}
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
request.ReceiverUserID = testHumanID
if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh {
t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err)
}
if builds != 1 {
t.Fatalf("authorized send materialized content %d times", builds)
}
incoming := f.putIncoming(t, testDeviceKey, f.now)
editBuilds := 0
buildEdit := func(context.Context) (domain.EditEphemeralFields, error) {
editBuilds++
return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil
}
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID,
domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) {
t.Fatalf("unauthorized lazy edit err=%v", err)
}
if editBuilds != 0 {
t.Fatalf("unauthorized edit materialized content %d times", editBuilds)
}
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID,
domain.EphemeralEditText, buildEdit); err != nil {
t.Fatalf("authorized lazy edit: %v", err)
}
if editBuilds != 1 {
t.Fatalf("authorized edit materialized content %d times", editBuilds)
}
}
func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage {
return f.putIncomingInTopic(t, deviceKey, createdAt, 0)
}
func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage {
t.Helper()
f.nextID++
message := domain.EphemeralMessage{
ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID),
TopMessageID: topMessageID,
Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupInline,
Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}},
}},
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession},
PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1,
CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention),
}
stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message)
if err != nil {
t.Fatal(err)
}
return stored
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"hash"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/seed/appearance"
)
@ -253,7 +254,7 @@ func appearanceDocumentAttributes(in []appearance.DocumentAttribute) []domain.Do
if attr.FileName != "" {
out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrFilename,
FileName: attr.FileName,
FileName: branding.UserVisibleText(attr.FileName, ""),
})
}
}

View file

@ -98,6 +98,41 @@ func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, e
return s.media.GetPhoto(ctx, id)
}
type photoBatchStore interface {
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
}
// GetPhotos loads immutable photo metadata in caller order without requiring
// one storage round-trip per requested-peer response. PostgreSQL implements the
// optional batch primitive; lightweight stores retain a bounded fallback.
func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if s == nil || s.media == nil || len(ids) == 0 {
return nil, nil
}
if batch, ok := s.media.(photoBatchStore); ok {
return batch.GetPhotos(ctx, ids)
}
seen := make(map[int64]struct{}, len(ids))
out := make([]domain.Photo, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
photo, found, err := s.media.GetPhoto(ctx, id)
if err != nil {
return nil, err
}
if found {
out = append(out, photo)
}
}
return out, nil
}
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
return s.media.GetDocument(ctx, id)

View file

@ -7,6 +7,7 @@ import (
"golang.org/x/sync/singleflight"
"golang.org/x/text/unicode/bidi"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -18,16 +19,34 @@ type Service struct {
languageCache *languageListCache
packLoads singleflight.Group
languageLoads singleflight.Group
publicBaseURL string
}
// Option configures user-visible language-pack projection.
type Option func(*Service)
// WithPublicBaseURL replaces official public hosts embedded in upstream
// language-pack values with this deployment's public link root.
func WithPublicBaseURL(value string) Option {
return func(s *Service) {
if strings.TrimSpace(value) != "" {
s.publicBaseURL = value
}
}
}
// NewService 创建 langpack 服务。
func NewService(packs store.LangPackStore) *Service {
return newServiceWithCacheLimits(
func NewService(packs store.LangPackStore, opts ...Option) *Service {
s := newServiceWithCacheLimits(
packs,
defaultLangPackCacheMaxBytes,
defaultLangPackCacheMaxEntries,
defaultLanguageListCacheMaxEntries,
)
for _, opt := range opts {
opt(s)
}
return s
}
func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service {
@ -35,6 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
packs: packs,
packCache: newLangPackCache(maxBytes, maxEntries),
languageCache: newLanguageListCache(languageEntries),
publicBaseURL: branding.DefaultPublicURL,
}
}
@ -135,7 +155,11 @@ func shouldOverlayWebA(langPack string) bool {
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)
pack, err := s.packs.GetPack(ctx, langPack, langCode, 0)
if err != nil {
return domain.LangPack{}, err
}
return s.brandPack(pack), nil
})
}
@ -218,6 +242,7 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
}
for i := range languages {
languages[i] = completeLanguageMetadata(langPack, languages[i])
languages[i] = s.brandLanguage(languages[i])
}
return cachedLanguagesLoadResult{
languages: languages,
@ -237,6 +262,27 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
}
}
func (s *Service) brandPack(pack domain.LangPack) domain.LangPack {
for i := range pack.Strings {
item := &pack.Strings[i]
item.Value = branding.UserVisibleText(item.Value, s.publicBaseURL)
item.ZeroValue = branding.UserVisibleText(item.ZeroValue, s.publicBaseURL)
item.OneValue = branding.UserVisibleText(item.OneValue, s.publicBaseURL)
item.TwoValue = branding.UserVisibleText(item.TwoValue, s.publicBaseURL)
item.FewValue = branding.UserVisibleText(item.FewValue, s.publicBaseURL)
item.ManyValue = branding.UserVisibleText(item.ManyValue, s.publicBaseURL)
item.OtherValue = branding.UserVisibleText(item.OtherValue, s.publicBaseURL)
}
return pack
}
func (s *Service) brandLanguage(lang domain.LangPackLanguage) domain.LangPackLanguage {
lang.Name = branding.UserVisibleText(lang.Name, s.publicBaseURL)
lang.NativeName = branding.UserVisibleText(lang.NativeName, s.publicBaseURL)
lang.TranslationsURL = branding.UserVisibleText(lang.TranslationsURL, s.publicBaseURL)
return lang
}
func (s *Service) flushCaches() {
if s == nil {
return

View file

@ -74,6 +74,66 @@ func TestServiceNormalizesWebARawLangCode(t *testing.T) {
}
}
func TestServiceRebrandsEveryLanguagePackProjection(t *testing.T) {
ctx := context.Background()
base := memory.NewLangPackStore()
seed := domain.LangPack{
LangPack: "weba",
LangCode: "en",
Version: 7,
Strings: []domain.LangPackString{
{Key: "AppName", Value: "Telegram", Pluralized: true, ZeroValue: "No Telegram accounts", OneValue: "One Telegram account", TwoValue: "Two Telegram accounts", FewValue: "Few Telegram accounts", ManyValue: "Many Telegram accounts", OtherValue: "Other Telegram accounts"},
{Key: "TranslationLink", Value: "https://translations.telegram.org/en"},
{Key: "RuntimeIdentifier", Value: "org.telegram.messenger"},
},
}
if err := base.UpsertPack(ctx, seed); err != nil {
t.Fatalf("seed langpack: %v", err)
}
storeWithMetadata := &metadataLangPackStore{
LangPackStore: base,
languages: []domain.LangPackLanguage{{
LangPack: "weba",
LangCode: "en",
Name: "Telegram English",
NativeName: "Telegram English",
TranslationsURL: "https://translations.telegram.org/en",
}},
}
svc := NewService(storeWithMetadata, WithPublicBaseURL("https://chat.example/root/"))
for name, load := range map[string]func() (domain.LangPack, error){
"full": func() (domain.LangPack, error) { return svc.GetLangPack(ctx, "weba", "en") },
"difference": func() (domain.LangPack, error) { return svc.GetDifference(ctx, "weba", "en", 1) },
"keys": func() (domain.LangPack, error) {
return svc.GetStrings(ctx, "weba", "en", []string{"AppName", "TranslationLink", "RuntimeIdentifier"})
},
} {
pack, err := load()
if err != nil {
t.Fatalf("%s projection: %v", name, err)
}
appName := findLangPackString(pack.Strings, "AppName")
if appName == nil || appName.Value != "Telesrv" || appName.ZeroValue != "No Telesrv accounts" || appName.OneValue != "One Telesrv account" || appName.TwoValue != "Two Telesrv accounts" || appName.FewValue != "Few Telesrv accounts" || appName.ManyValue != "Many Telesrv accounts" || appName.OtherValue != "Other Telesrv accounts" {
t.Fatalf("%s AppName = %+v, want all value forms rebranded", name, appName)
}
if got := stringValue(pack.Strings, "TranslationLink"); got != "https://chat.example/root/en" {
t.Fatalf("%s TranslationLink = %q", name, got)
}
if got := stringValue(pack.Strings, "RuntimeIdentifier"); got != "org.telegram.messenger" {
t.Fatalf("%s RuntimeIdentifier = %q, want protocol identifier unchanged", name, got)
}
}
languages, err := svc.ListLanguages(ctx, "weba")
if err != nil {
t.Fatalf("list languages: %v", err)
}
if len(languages) != 1 || languages[0].Name != "Telesrv English" || languages[0].NativeName != "Telesrv English" || languages[0].TranslationsURL != "https://chat.example/root/en" {
t.Fatalf("languages = %+v, want branded metadata", languages)
}
}
func TestListLanguagesUsesSeededPacks(t *testing.T) {
ctx := context.Background()
packs := memory.NewLangPackStore()
@ -286,6 +346,15 @@ type countingLangPackStore struct {
listLanguages int
}
type metadataLangPackStore struct {
store.LangPackStore
languages []domain.LangPackLanguage
}
func (s *metadataLangPackStore) ListLanguages(context.Context, string) ([]domain.LangPackLanguage, error) {
return append([]domain.LangPackLanguage(nil), s.languages...), nil
}
func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
s.mu.Lock()
s.getPack++
@ -334,3 +403,12 @@ func stringValue(strings []domain.LangPackString, key string) string {
}
return ""
}
func findLangPackString(strings []domain.LangPackString, key string) *domain.LangPackString {
for i := range strings {
if strings[i].Key == key {
return &strings[i]
}
}
return nil
}

View file

@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
}
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.SendPrivateTextResult{}, err
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req)
if err != nil {
@ -263,6 +266,32 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom
return s.projectMessageUsers(ctx, userID, list)
}
type messageByUIDStore interface {
GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
}
// GetMessageByUID translates a shared private message id into one owner's exact box row.
// It is intentionally an optional capability so lightweight MessageStore test doubles that
// never exercise callback translation do not need a meaningless implementation.
func (s *Service) GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
if s == nil || userID == 0 || uid == 0 {
return domain.Message{}, false, nil
}
provider, ok := s.messages.(messageByUIDStore)
if !ok {
return domain.Message{}, false, nil
}
msg, found, err := provider.GetByUID(ctx, userID, uid)
if err != nil || !found {
return domain.Message{}, found, err
}
list, err := s.projectMessageUsers(ctx, userID, domain.MessageList{Messages: []domain.Message{msg}})
if err != nil || len(list.Messages) != 1 {
return domain.Message{}, false, err
}
return list.Messages[0], true, nil
}
// GetHistory 返回当前账号某个 peer 的历史消息。
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
return s.list(ctx, userID, filter)
@ -406,6 +435,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if req.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.EditMessageResult{OwnerUserID: userID}, err
}
}
return s.messages.EditMessage(ctx, req)
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"time"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/webauthn"
@ -40,7 +41,7 @@ type Option func(*Service)
func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } }
// WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin
//(服务端通常不预知 Android apk-key-hash origin)。
// (服务端通常不预知 Android apk-key-hash origin)。
func WithAllowedOrigins(origins []string) Option {
return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) }
}
@ -70,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
creds: creds,
challenges: challenges,
rpID: rpID,
rpName: "Telegram",
rpName: branding.ProductName,
dcID: dcID,
challengeTTL: defaultChallengeTTL,
now: time.Now,

View file

@ -21,7 +21,18 @@ func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGif
return prepareAnimation(fileName, data)
}
// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official
// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary
// operator uploads continue through PrepareAnimation and reject expressions.
func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimationWithPolicy(fileName, data, true)
}
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimationWithPolicy(fileName, data, false)
}
func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) {
fileName = strings.TrimSpace(filepath.Base(fileName))
ext := strings.ToLower(filepath.Ext(fileName))
format := domain.StarGiftAnimationLottie
@ -46,7 +57,7 @@ func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, e
rawJSON = data
}
normalized, meta, err := normalizeAndValidateLottie(rawJSON)
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
if err != nil {
return domain.StarGiftAnimation{}, err
}
@ -80,7 +91,7 @@ type lottieMetadata struct {
Assets []json.RawMessage `json:"assets"`
}
func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]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
@ -94,7 +105,7 @@ func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
if _, ok := root.(map[string]any); !ok {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if containsLottieExpression(root) {
if !allowExpressions && containsLottieExpression(root) {
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
}
var meta lottieMetadata

View file

@ -68,7 +68,7 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
t.Fatal(err)
}
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation,
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation,
})
if err != nil {
t.Fatalf("create first: %v", err)
@ -84,10 +84,86 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
t.Fatalf("current=%+v found=%v", current, found)
}
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "First" {
if !found || historical.Stars != 50 || historical.Title != "Telesrv Pin" {
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)
}
}
func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(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)
}
hash := make([]byte, sha256.Size)
_, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation,
OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
OfficialGiftID: 11, SourceManifestSHA256: hash,
},
})
if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err)
}
}
func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareOfficialAnimation("official.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
manifestSHA := make([]byte, sha256.Size)
result, err := svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, Animation: animation,
Actor: "test", CommandID: "official-catalog", OfficialGiftID: 10,
SourceManifestSHA256: manifestSHA, OfficialSourceJSON: []byte(`{"id":10}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille,
RarityPermille: 1000, Animation: &animation,
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille,
RarityPermille: 1000, Animation: &animation,
}},
Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", RarityKind: domain.StarGiftRarityPermille,
RarityPermille: 1000,
}},
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
SourceManifestSHA256: manifestSHA,
},
})
if err != nil {
t.Fatalf("create official collectible bundle: %v", err)
}
if result.Collectible == nil || len(result.Collectible.Models) != 1 || len(result.Collectible.Patterns) != 1 {
t.Fatalf("collectible result = %+v", result.Collectible)
}
model := result.Collectible.Models[0].Document
pattern := result.Collectible.Patterns[0].Document
if model == nil || !model.IsSticker() || model.IsCustomEmoji() {
t.Fatalf("model document = %+v, want ordinary sticker", model)
}
if pattern == nil || pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
t.Fatalf("pattern document = %+v, want text-color custom emoji with inline path", pattern)
}
if !pattern.Attributes[1].TextColor {
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
}
}

View file

@ -0,0 +1,34 @@
package stargifts
import (
"bytes"
"testing"
"telesrv/internal/domain"
)
func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) {
pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern)
if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor {
t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern)
}
model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel)
if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor {
t.Fatalf("model attributes = %+v, want ordinary sticker", model)
}
}
func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) {
pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern)
if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath ||
pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern)
}
pattern[0].Bytes[0] ^= 0xff
if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
t.Fatal("collectibleDocumentThumbs returned shared mutable bytes")
}
if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 {
t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model)
}
}

View file

@ -0,0 +1,50 @@
package stargifts
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
)
const localWithdrawalTTL = 15 * time.Minute
// LocalWithdrawalProvider implements the TON/export UX entirely inside
// telesrv. It mints an unguessable, short-lived bearer URL; no external
// blockchain, Fragment endpoint, wallet or network RPC is contacted.
type LocalWithdrawalProvider struct {
publicBaseURL string
}
func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) {
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
parsed, err := url.Parse(publicBaseURL)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, fmt.Errorf("invalid local star gift withdrawal base URL")
}
return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil
}
func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" }
func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) {
if p == nil || p.publicBaseURL == "" {
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
return StarGiftWithdrawalProviderResult{
RequestID: token,
URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token),
ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()),
}, nil
}
var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil)

View file

@ -0,0 +1,34 @@
package stargifts
import (
"context"
"strings"
"testing"
"time"
)
func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) {
for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} {
if _, err := NewLocalWithdrawalProvider(invalid); err == nil {
t.Fatalf("invalid withdrawal base URL %q accepted", invalid)
}
}
provider, err := NewLocalWithdrawalProvider("https://example.test/base/")
if err != nil {
t.Fatal(err)
}
before := time.Now()
result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{})
if err != nil {
t.Fatal(err)
}
if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 ||
result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID ||
strings.ContainsAny(result.RequestID, "+/=") {
t.Fatalf("local withdrawal result = %+v", result)
}
expires := time.Unix(int64(result.ExpiresAt), 0)
if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) {
t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires)
}
}

View file

@ -0,0 +1,56 @@
package stargifts_test
import (
"context"
"os"
"testing"
"telesrv/internal/app/stargifts"
"telesrv/internal/officialgifts"
)
// This opt-in test is run by the official import audit. It validates every distinct base,
// model and pattern document with the trusted official animation policy, including the
// small set of Telegram-authored expression animations.
func TestConfiguredOfficialSnapshotAnimations(t *testing.T) {
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
if root == "" {
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
}
catalog := officialgifts.New(root)
items, err := catalog.List(context.Background())
if err != nil {
t.Fatal(err)
}
service := &stargifts.Service{}
seen := map[int64]struct{}{}
validate := func(document officialgifts.Document) {
t.Helper()
if _, ok := seen[document.ID]; ok {
return
}
seen[document.ID] = struct{}{}
if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil {
t.Fatalf("document %d (%s): %v", document.ID, document.Path, err)
}
}
for _, item := range items {
bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0)
if err != nil {
t.Fatalf("gift %d: %v", item.ID, err)
}
validate(bundle.BaseDocument)
if bundle.Collectible == nil {
continue
}
for _, model := range bundle.Collectible.Models {
validate(model.Document)
}
for _, pattern := range bundle.Collectible.Patterns {
validate(pattern.Document)
}
}
if len(seen) != 8333 {
t.Fatalf("validated %d documents, want 8333", len(seen))
}
}

View file

@ -2,14 +2,18 @@
package stargifts
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -22,26 +26,65 @@ type BlobBackend interface {
}
type Service struct {
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
lifecycle store.StarGiftLifecycleStore
withdrawal StarGiftWithdrawalProvider
blobs BlobBackend
dc int
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
formMu sync.Mutex
forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm
}
type starGiftPurchaseFormKey struct {
buyerUserID int64
formID int64
}
// AtomicPurchaseConfigured reports whether the production aggregate
// coordinator is installed. It lets the RPC package keep its isolated memory
// test adapter without silently downgrading PostgreSQL deployments.
func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil }
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option {
return func(service *Service) { service.lifecycle = lifecycle }
}
type StarGiftWithdrawalProvider interface {
Name() string
CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error)
}
type StarGiftWithdrawalProviderRequest struct {
UserID int64
Gift domain.UniqueStarGift
}
type StarGiftWithdrawalProviderResult struct {
RequestID string
URL string
ExpiresAt int
}
func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option {
return func(service *Service) { service.withdrawal = provider }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc}
service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)}
for _, opt := range opts {
opt(service)
}
@ -131,27 +174,39 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
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)
write.Title = branding.UserVisibleText(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
}
if err := s.materializeCatalogWrite(ctx, &write); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error {
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
return fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
return fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
@ -175,12 +230,70 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
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
return nil
}
// CreateCatalogBundle materializes every verified asset before publishing both active
// revision pointers in one store transaction. Blob writes are content-addressed and may be
// safely orphaned for later GC if the database transaction fails.
func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured")
}
s.InvalidateStarGiftCatalog()
return entry, nil
write.Catalog.Title = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "")
write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "")
if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars ||
write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 ||
len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
var officialSource map[string]any
if write.Catalog.OfficialGiftID < 0 {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 ||
json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Collectible != nil {
write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix))
brandCollectibleAttributes(write.Collectible.Models)
brandCollectibleAttributes(write.Collectible.Patterns)
brandCollectibleAttributes(write.Collectible.Backdrops)
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
}
validation := *write.Collectible
if validation.GiftID == 0 {
validation.GiftID = write.Catalog.GiftID
if validation.GiftID == 0 {
validation.GiftID = 1
}
}
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if write.Collectible != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
result, err := s.store.CreateCatalogBundle(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
@ -207,6 +320,9 @@ func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.S
if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
}
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
@ -222,58 +338,109 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
}
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
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 {
if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := materialize(write.Patterns); err != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) {
for i := range attributes {
attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "")
}
}
func (s *Service) materializeCollectibleAttributes(ctx context.Context, 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: collectibleDocumentAttributes(attributes[i].Kind),
Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
}
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
}
// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder.
// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an
// animated sticker only when document.thumbs is non-empty. The placeholder is
// not used as the rendered collectible pattern: after classification Android
// downloads and decodes the document's full TGS first frame. Keeping the
// placeholder inline avoids introducing a second downloadable blob and matches
// the shape used by official animated-sticker documents.
var collectiblePatternPathThumb = []byte{
0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e,
0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c,
0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3,
0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c,
0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90,
0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86,
0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79,
0x87, 0x03, 0x47, 0x06, 0x87, 0x03,
}
func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize {
if kind != domain.StarGiftCollectiblePattern {
return nil
}
return []domain.PhotoSize{{
Kind: domain.PhotoSizeKindPath,
Type: "j",
Bytes: append([]byte(nil), collectiblePatternPathThumb...),
}}
}
func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute {
renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"}
if kind == domain.StarGiftCollectiblePattern {
// DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the
// pattern is a text-color custom emoji. Without this the gradient is
// visible but the collectible pattern is rendered with its raw fill.
renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
}
return []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
renderAttribute,
{Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"},
}
}
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
@ -324,6 +491,14 @@ func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[i
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
}
func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || owner.ID <= 0 ||
(owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 {
return []domain.UniqueStarGift{}, nil
}
return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit))
}
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")
@ -335,6 +510,338 @@ func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest
return result, err
}
func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
}
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
result, err := s.lifecycle.PurchaseStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the
// intent so server restarts cannot turn a valid checkout into an unbound
// payment. The bounded in-memory branch exists only for isolated RPC tests.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if !validPurchaseForm(form) {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
if s != nil && s.lifecycle != nil {
return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form)
}
if s == nil {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
s.formMu.Lock()
defer s.formMu.Unlock()
for key, existing := range s.forms {
if existing.ExpiresAt < form.IssuedAt {
delete(s.forms, key)
}
}
for attempt := 0; attempt < 8; attempt++ {
formID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftPurchaseForm{}, err
}
key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID}
if _, exists := s.forms[key]; exists {
continue
}
form.FormID = formID
s.forms[key] = form
return form, nil
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
// ValidatePurchaseForm is a read-only preflight used for precise RPC errors.
// The PostgreSQL purchase transaction repeats this validation while holding a
// row lock; callers must not treat this preflight as the atomicity boundary.
func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s != nil && s.lifecycle != nil {
return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req)
}
if s == nil || req.FormID == 0 {
return domain.ErrStarGiftFormExpired
}
s.formMu.Lock()
defer s.formMu.Unlock()
form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}]
if !ok || form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
return validatePurchaseFormIntent(form, req)
}
func validPurchaseForm(form domain.StarGiftPurchaseForm) bool {
return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 &&
(form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) &&
form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 &&
form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128
}
func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error {
if form.BuyerUserID != req.BuyerUserID || form.To != req.To || form.GiftID != req.GiftID ||
form.IncludeUpgrade != req.IncludeUpgrade || form.HideName != req.HideName || form.Message != req.Message {
return domain.ErrStarGiftFormPurposeInvalid
}
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
return domain.ErrStarGiftFormAmountMismatch
}
return nil
}
func (s *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.ListResaleStarGifts(ctx, filter)
}
func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID)
}
func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) {
if s == nil || s.lifecycle == nil {
return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.SetStarGiftListing(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable
}
return s.lifecycle.TransferStarGift(ctx, req)
}
func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.SendStarGiftOffer(ctx, req)
}
func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.ResolveStarGiftOffer(ctx, req)
}
func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit)
}
func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.CraftStarGift(ctx, req)
}
func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now)
}
func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now)
}
func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID)
}
func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.BidStarGiftAuction(ctx, req)
}
func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash)
}
func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepayStarGiftUpgrade(ctx, req)
}
func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.DropStarGiftOriginalDetails(ctx, req)
}
func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error {
if s == nil || s.lifecycle == nil {
return domain.ErrStarGiftUnavailable
}
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
}
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
saved, found, err := s.store.GetByRef(ctx, req.Ref)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found || unique.Burned || unique.Owner != saved.Owner {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique})
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt)
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return recorded, nil
}
func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, false, nil
}
return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID)
}
func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date)
}
func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.TonBalance(ctx, userID)
}
func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.TonTransactions(ctx, userID, offset, limit)
}
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
}
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarsTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit)
}
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelTonBalance(ctx, channelID)
}
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit)
}
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit)
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
@ -360,6 +867,17 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
}
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
return 0, err
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
}
gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
return s.store.Create(ctx, gift)
}
@ -396,10 +914,20 @@ func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef,
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert keeps the in-memory/catalog store primitive available to isolated
// tests and non-production adapters. RPC production paths must use
// ConvertAggregate so balance credit and terminal state cannot split.
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable
}
return s.lifecycle.ConvertStarGift(ctx, req)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {

View file

@ -593,6 +593,20 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
}, true, excludeSessionID)
}
// RecordUserEmojiStatus durably synchronizes an absolute emoji-status snapshot
// to the account's other sessions and offline difference stream.
func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
if !status.Valid() {
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid
}
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventUserEmojiStatus,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
EmojiStatus: status,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
// 状态,重放时按 peer 重载当前值。updateDraftMessage 无 pts 字段,走 LacksWirePts
// aux 簿记topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。

View file

@ -219,6 +219,35 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
}
}
func TestRecordCollectibleEmojiStatusFeedsDifference(t *testing.T) {
ctx := context.Background()
authKeyID := [8]byte{3, 1}
events := memory.NewUpdateEventStore()
svc := NewService(memory.NewUpdateStateStore(), events)
ownerUserID := int64(1000000001)
status := domain.UserEmojiStatus{
DocumentID: 71,
Collectible: domain.EmojiStatusCollectible{
CollectibleID: 91, DocumentID: 71, Title: "Gift", Slug: "Gift-1",
PatternDocumentID: 72, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4,
},
}
event, state, err := svc.RecordUserEmojiStatus(ctx, authKeyID, ownerUserID, status, authKeyID, 42)
if err != nil {
t.Fatalf("RecordUserEmojiStatus: %v", err)
}
if event.Type != domain.UpdateEventUserEmojiStatus || event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() {
t.Fatalf("event/state = %+v / %+v", event, state)
}
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if len(diff.Events) != 1 || diff.Events[0].EmojiStatus != status || diff.Events[0].Peer.ID != ownerUserID {
t.Fatalf("difference = %+v, want exact collectible snapshot", diff)
}
}
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
ctx := context.Background()
authKeyID := [8]byte{4}

View file

@ -0,0 +1,19 @@
package userprojection
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) {
in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true}
got, err := New().One(context.Background(), 7, in)
if err != nil {
t.Fatal(err)
}
if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact {
t.Fatalf("deleted projection leaked PII: %+v", got)
}
}

View file

@ -83,6 +83,7 @@ func New(opts ...Option) *Projector {
// ForViewer applies both current profile photos and owner-specific contact view.
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
users = sanitizeDeletedUsers(users)
if p == nil {
return users, nil
}
@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
// 调用方传入的 users 不被修改(内部复制)。
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
users = sanitizeDeletedUsers(users)
out := make(map[int64][]domain.User, len(viewerUserIDs))
if p == nil || len(users) == 0 {
for _, v := range viewerUserIDs {
@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
if u.ID == 0 {
continue
}
if u.Deleted {
projected[i] = u.DeletedTombstone()
continue
}
if pj, ok := cache[u.ID]; ok {
projected[i] = pj
continue
@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 {
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
// The lookup is best-effort: a storage error keeps the original user list.
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
users = sanitizeDeletedUsers(users)
if photos == nil || len(users) == 0 {
return users
}
ids := make([]int64, 0, len(users))
seen := make(map[int64]struct{}, len(users))
for _, u := range users {
if u.ID == 0 {
if u.ID == 0 || u.Deleted {
continue
}
if _, ok := seen[u.ID]; ok {
@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
// In particular, phone is visible for self and contacts; non-contacts should not
// receive a phone field because TDesktop will prefer it over the public name.
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
users = sanitizeDeletedUsers(users)
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
return users, nil
}
@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
cache := make(map[int64]domain.User, len(users))
for i := range out {
u := out[i]
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
continue
}
if projected, ok := cache[u.ID]; ok {
@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
}
out := make([]domain.User, len(users))
copy(out, users)
out = sanitizeDeletedUsers(out)
ids := uniqueUserIDs(out)
var (
profileRefs = map[int64]domain.ProfilePhotoRef{}
@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
if u.ID == 0 {
continue
}
if u.Deleted {
out[i] = u.DeletedTombstone()
continue
}
if projected, ok := cache[u.ID]; ok {
out[i] = projected
continue
@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
ids := make([]int64, 0, len(users))
seen := make(map[int64]struct{}, len(users))
for _, u := range users {
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
continue
}
if _, ok := seen[u.ID]; ok {
@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
}
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
if user.Deleted {
return user.DeletedTombstone(), nil
}
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
if err != nil {
return domain.User{}, err
@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 {
seen := make(map[int64]struct{}, len(users))
ids := make([]int64, 0, len(users))
for _, user := range users {
if user.ID == 0 {
if user.ID == 0 || user.Deleted {
continue
}
if _, ok := seen[user.ID]; ok {
@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 {
}
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
if user.Deleted {
return user.DeletedTombstone()
}
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
return user
}
@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m
}
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
if user.Deleted {
return user.DeletedTombstone()
}
if !found {
user.Phone = ""
user.Contact = false
@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
}
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
if user.Deleted {
return user.DeletedTombstone(), nil
}
if privacy == nil {
return user, nil
}
@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) {
user.PhotoPersonal = false
user.PhotoHasVideo = false
}
func sanitizeDeletedUsers(users []domain.User) []domain.User {
var out []domain.User
for i, user := range users {
if !user.Deleted {
continue
}
if out == nil {
out = make([]domain.User, len(users))
copy(out, users)
}
out[i] = user.DeletedTombstone()
}
if out != nil {
return out
}
return users
}

View file

@ -107,7 +107,7 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) {
svc := NewService(store)
// 非会员设置被拒PREMIUM_ACCOUNT_REQUIRED
if _, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0); !errors.Is(err, domain.ErrPremiumRequired) {
if _, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}); !errors.Is(err, domain.ErrPremiumRequired) {
t.Fatalf("non-premium set err = %v, want ErrPremiumRequired", err)
}
@ -115,14 +115,14 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) {
if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant: %v", err)
}
set, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0)
set, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42})
if err != nil || set.EmojiStatusDocumentID != 42 {
t.Fatalf("premium set = %+v err %v, want document 42", set, err)
}
if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil {
t.Fatalf("downgrade: %v", err)
}
cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, 0, 0)
cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{})
if err != nil || cleared.EmojiStatusDocumentID != 0 {
t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err)
}

View file

@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int)
return users, nil
}
// UpdateEmojiStatus 更新当前用户 emoji statuspremium 专属;documentID=0 清除)。
// UpdateEmojiStatus 更新当前用户 emoji statuspremium 专属;零值清除)。
// 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。
func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
if err != nil {
return domain.User{}, err
}
if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) {
return domain.User{}, domain.ErrPremiumRequired
}
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until)
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
if err != nil {
return domain.User{}, err
}
@ -393,6 +390,52 @@ func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentI
return s.projectOne(ctx, self.ID, u)
}
// UpdateEmojiStatusWithEvent uses the store's aggregate transaction when it
// is available. The bool reports whether the returned event was durably
// appended with dispatch; lightweight memory/test wiring falls back to the
// ordinary state write and lets the RPC's Updates service append the event.
func (s *Service) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) {
self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
if err != nil {
return domain.User{}, domain.UpdateEvent{}, false, err
}
writer, ok := s.users.(store.UserEmojiStatusEventStore)
if !ok {
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
if err == nil {
s.refreshCachedUsers(ctx, u)
}
return u, domain.UpdateEvent{}, false, err
}
event := domain.UpdateEvent{
Type: domain.UpdateEventUserEmojiStatus,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: self.ID},
EmojiStatus: status,
Date: date,
PtsCount: 1,
}
u, event, err := writer.UpdateEmojiStatusWithEvent(ctx, self.ID, status, event, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.User{}, domain.UpdateEvent{}, false, err
}
s.refreshCachedUsers(ctx, u)
return u, event, true, nil
}
func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !status.Valid() {
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
}
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
return domain.User{}, domain.ErrPremiumRequired
}
return self, nil
}
// UpdateBirthday 设置/清除用户生日account.updateBirthday。零值 Birthday 表示清除。
func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
@ -513,7 +556,7 @@ func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]do
if s.cache != nil {
if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 {
for id, u := range cached {
if u.ID != 0 {
if u.ID != 0 && u.EmojiStatusCollectible.Empty() {
loaded[id] = u
}
}
@ -561,7 +604,18 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
if s.cache == nil || len(users) == 0 {
return
}
_ = s.cache.PutMany(ctx, users)
cacheable := make([]domain.User, 0, len(users))
for _, user := range users {
// Collectible ownership may change inside the star-gift aggregate. Keep
// these uncommon users on the authoritative store path so the database
// lifecycle trigger can never be masked by a stale base-user cache entry.
if user.ID != 0 && user.EmojiStatusCollectible.Empty() {
cacheable = append(cacheable, user)
}
}
if len(cacheable) > 0 {
_ = s.cache.PutMany(ctx, cacheable)
}
}
func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {

View file

@ -0,0 +1,104 @@
package botapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"telesrv/internal/domain"
)
const maxBotAPICommands = 100
func validateDefaultBotCommandScope(values map[string]string) error {
if strings.TrimSpace(values["language_code"]) != "" {
return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED")
}
raw := strings.TrimSpace(values["scope"])
if raw == "" {
return nil
}
var scope struct {
Type string `json:"type"`
}
if json.Unmarshal([]byte(raw), &scope) != nil || scope.Type != "default" {
return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED")
}
return nil
}
func (h *handler) setMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil || h.bots == nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if err := validateDefaultBotCommandScope(values); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
var input []struct {
Command string `json:"command"`
Description string `json:"description"`
IsEphemeral bool `json:"is_ephemeral"`
}
if json.Unmarshal([]byte(values["commands"]), &input) != nil || len(input) > maxBotAPICommands {
writeAPIError(w, http.StatusBadRequest, "BOT_COMMAND_INVALID")
return
}
commands := make([]domain.BotCommand, 0, len(input))
for _, command := range input {
commands = append(commands, domain.BotCommand{
Command: command.Command, Description: command.Description, Ephemeral: command.IsEphemeral,
})
}
if _, err := h.bots.SetBotCommands(r.Context(), botID, commands); err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, true)
}
func (h *handler) deleteMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil || h.bots == nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if err := validateDefaultBotCommandScope(values); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := h.bots.SetBotCommands(r.Context(), botID, nil); err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, true)
}
func (h *handler) getMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil || h.bots == nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if err := validateDefaultBotCommandScope(values); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
commands, err := h.bots.GetBotCommands(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
out := make([]map[string]any, 0, len(commands))
for _, command := range commands {
item := map[string]any{"command": command.Command, "description": command.Description}
if command.Ephemeral {
item["is_ephemeral"] = true
}
out = append(out, item)
}
writeAPIOK(w, out)
}

View file

@ -0,0 +1,351 @@
package botapi
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"telesrv/internal/domain"
)
type ephemeralSendTarget struct {
receiverUserID int64
callbackQueryID int64
replyToEphemeralID int
topMessageID int
}
func parseEphemeralSendTarget(values map[string]string) (ephemeralSendTarget, bool, error) {
var result ephemeralSendTarget
receiverRaw := strings.TrimSpace(values["receiver_user_id"])
callbackRaw := strings.TrimSpace(values["callback_query_id"])
var reply struct {
MessageID int `json:"message_id"`
EphemeralMessageID int `json:"ephemeral_message_id"`
}
if raw := strings.TrimSpace(values["reply_parameters"]); raw != "" {
if json.Unmarshal([]byte(raw), &reply) != nil || reply.MessageID < 0 || reply.EphemeralMessageID < 0 ||
(reply.MessageID != 0 && reply.EphemeralMessageID != 0) {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
}
if receiverRaw == "" {
if callbackRaw != "" || reply.EphemeralMessageID != 0 {
return result, false, errors.New("USER_ID_INVALID")
}
return result, false, nil
}
receiver, err := strconv.ParseInt(receiverRaw, 10, 64)
if err != nil || receiver <= 0 {
return result, false, errors.New("USER_ID_INVALID")
}
result.receiverUserID = receiver
result.replyToEphemeralID = reply.EphemeralMessageID
if reply.MessageID != 0 {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
if callbackRaw != "" {
result.callbackQueryID, err = strconv.ParseInt(callbackRaw, 10, 64)
if err != nil || result.callbackQueryID == 0 {
return result, false, errors.New("QUERY_ID_INVALID")
}
}
if result.callbackQueryID != 0 && result.replyToEphemeralID != 0 {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
if raw := strings.TrimSpace(values["message_thread_id"]); raw != "" {
result.topMessageID, err = strconv.Atoi(raw)
if err != nil || result.topMessageID <= 0 || result.topMessageID > domain.MaxMessageBoxID {
return result, false, errors.New("MESSAGE_THREAD_ID_INVALID")
}
}
return result, true, nil
}
func botAPIFileInput(raw string, files map[string]uploadedFile, field string, values map[string]string) (domain.BotAPIFileInput, bool) {
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(raw, files, field)
if !ok {
return domain.BotAPIFileInput{}, false
}
return domain.BotAPIFileInput{
LocationKey: locationKey, RemoteURL: remoteURL, FileName: fileName, MimeType: mimeType, Bytes: fileBytes,
Width: apiInt(values["width"], 0), Height: apiInt(values["height"], 0), Duration: apiInt(values["duration"], 0),
Title: values["title"], Performer: values["performer"], Emoji: values["emoji"],
}, true
}
func (h *handler) writeEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, message domain.EphemeralMessage) {
users := make([]domain.User, 0, 1)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
projected, ok := apiEphemeralMessage(message, users, nil)
if !ok {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, projected)
}
func (h *handler) sendEphemeralContact(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
target, ephemeral, err := parseEphemeralSendTarget(values)
if err != nil || !ephemeral {
if err == nil {
err = errors.New("EPHEMERAL_TARGET_REQUIRED")
}
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
if !ok || strings.TrimSpace(values["phone_number"]) == "" || strings.TrimSpace(values["first_name"]) == "" || len(values["vcard"]) > 2048 {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
markup, _, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID,
CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID,
Kind: "contact", ReplyMarkup: markup, DirectMedia: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
PhoneNumber: values["phone_number"], FirstName: values["first_name"], LastName: values["last_name"], Vcard: values["vcard"],
}},
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
}
func (h *handler) sendEphemeralLocation(w http.ResponseWriter, r *http.Request, botID int64, venue bool) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
target, ephemeral, err := parseEphemeralSendTarget(values)
if err != nil || !ephemeral {
if err == nil {
err = errors.New("EPHEMERAL_TARGET_REQUIRED")
}
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
latitude, latErr := strconv.ParseFloat(strings.TrimSpace(values["latitude"]), 64)
longitude, longErr := strconv.ParseFloat(strings.TrimSpace(values["longitude"]), 64)
accuracy, accuracyErr := strconv.ParseFloat(defaultString(values["horizontal_accuracy"], "0"), 64)
if !ok || latErr != nil || longErr != nil || accuracyErr != nil || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || accuracy < 0 || accuracy > 1500 || apiInt(values["live_period"], 0) != 0 {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
markup, _, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
geo := domain.MessageGeoPoint{Lat: latitude, Long: longitude, AccuracyRadius: int(accuracy)}
media := &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &geo}
if venue {
if strings.TrimSpace(values["title"]) == "" || strings.TrimSpace(values["address"]) == "" {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
provider, venueID, venueType := "", "", ""
if values["foursquare_id"] != "" || values["foursquare_type"] != "" {
provider, venueID, venueType = "foursquare", values["foursquare_id"], values["foursquare_type"]
} else if values["google_place_id"] != "" || values["google_place_type"] != "" {
provider, venueID, venueType = "gplaces", values["google_place_id"], values["google_place_type"]
}
media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
Geo: geo, Title: values["title"], Address: values["address"], Provider: provider, VenueID: venueID, VenueType: venueType,
}}
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID,
CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID,
Kind: "location", ReplyMarkup: markup, DirectMedia: media,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
}
func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, mode string) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64)
messageID := apiInt(values["ephemeral_message_id"], 0)
if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID")
return
}
input := domain.BotAPIEphemeralEditInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: receiverID, MessageID: messageID,
Mode: domain.EphemeralEditMode(mode),
}
markup, markupSet, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
switch mode {
case "text":
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities
case "caption":
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
case "reply_markup":
input.Fields.SetReplyMarkup = true
case "media":
if err := parseEphemeralEditMedia(values["media"], &input); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
default:
writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
result, err := gateway.BotAPIEditEphemeral(r.Context(), input)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, result)
}
func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) error {
var media struct {
Type string `json:"type"`
Media string `json:"media"`
Photo string `json:"photo"`
Caption string `json:"caption"`
ParseMode string `json:"parse_mode"`
CaptionEntities json.RawMessage `json:"caption_entities"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
Title string `json:"title"`
Performer string `json:"performer"`
}
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" {
return errors.New("MEDIA_INVALID")
}
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true}
if !allowed[media.Type] {
return errors.New("MEDIA_INVALID")
}
primaryRaw := media.Media
if media.Type == "live_photo" {
primaryRaw = media.Photo
}
primary, ok := botAPIFileInput(primaryRaw, nil, "", map[string]string{
"width": strconv.Itoa(media.Width), "height": strconv.Itoa(media.Height), "duration": strconv.Itoa(media.Duration),
"title": media.Title, "performer": media.Performer,
})
if !ok || len(primary.Bytes) != 0 {
return errors.New("FILE_ID_INVALID")
}
input.MediaKind, input.File = media.Type, primary
if media.Type == "live_photo" {
secondary, ok := botAPIFileInput(media.Media, nil, "", map[string]string{"duration": strconv.Itoa(media.Duration)})
if !ok || len(secondary.Bytes) != 0 {
return errors.New("FILE_ID_INVALID")
}
input.SecondaryFile = secondary
}
caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false)
if err != nil {
return err
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
return nil
}
func (h *handler) deleteEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64)
messageID := apiInt(values["ephemeral_message_id"], 0)
if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
result, err := gateway.BotAPIDeleteEphemeral(r.Context(), botID, chatID, receiverID, messageID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, result)
}
func optionalInlineReplyMarkup(values map[string]string) (*domain.MessageReplyMarkup, bool, error) {
raw, exists := values["reply_markup"]
if !exists || strings.TrimSpace(raw) == "" {
return nil, exists, nil
}
markup, err := inlineReplyMarkupFromAPI(json.RawMessage(raw))
return markup, true, err
}
func parsePositiveOrNegativeID(raw string) (int64, bool) {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return id, err == nil && id != 0
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,255 @@
package botapi
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"unicode/utf8"
"telesrv/internal/domain"
)
func TestParseBotAPIHTMLNestedUTF16LinksAndDate(t *testing.T) {
plain, entities, err := parseBotAPIHTML(`<b>A <i>😀</i></b> <a href="tg://user?id=42">Alice</a> <tg-time unix="1700000000" format="wdT">now</tg-time>`)
if err != nil {
t.Fatal(err)
}
if plain != "A 😀 Alice now" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
{Type: domain.MessageEntityItalic, Offset: 2, Length: 2},
{Type: domain.MessageEntityMentionName, Offset: 5, Length: 5, UserID: 42},
{Type: domain.MessageEntityFormattedDate, Offset: 11, Length: 3, Date: 1700000000, DayOfWeek: true, ShortDate: true, LongTime: true},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPIHTMLPreAndEscapes(t *testing.T) {
plain, entities, err := parseBotAPIHTML(`<pre><code class="language-go">if a &lt; b &amp;&amp; b &gt; c</code></pre>`)
if err != nil {
t.Fatal(err)
}
if plain != "if a < b && b > c" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{{Type: domain.MessageEntityPre, Offset: 0, Length: 17, Language: "go"}}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPILegacyMarkdown(t *testing.T) {
plain, entities, err := parseBotAPIMarkdown(`*bold* _😀_ [site](https://example.com) \*raw\*`)
if err != nil {
t.Fatal(err)
}
if plain != "bold 😀 site *raw*" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com"},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPIMarkdownV2NestedLinksAndExpandableQuote(t *testing.T) {
plain, entities, err := parseBotAPIMarkdownV2(`*bold _😀_* [site](https://example.com/a\)b) ||secret||`)
if err != nil {
t.Fatal(err)
}
if plain != "bold 😀 site secret" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 7},
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com/a)b"},
{Type: domain.MessageEntitySpoiler, Offset: 13, Length: 6},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
plain, entities, err = parseBotAPIMarkdownV2(">visible\n>hidden||")
if err != nil {
t.Fatal(err)
}
if plain != "visible\nhidden" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBlockquote, Offset: 0, Length: 14, Collapsed: true}}) {
t.Fatalf("expandable quote plain=%q entities=%#v", plain, entities)
}
}
func TestParseBotAPIMarkdownV2FormattedDate(t *testing.T) {
plain, entities, err := parseBotAPIMarkdownV2(`![when](tg://time?unix=1700000000&format=wdT)`)
if err != nil {
t.Fatal(err)
}
want := []domain.MessageEntity{{
Type: domain.MessageEntityFormattedDate, Offset: 0, Length: 4, Date: 1700000000,
DayOfWeek: true, ShortDate: true, LongTime: true,
}}
if plain != "when" || !reflect.DeepEqual(entities, want) {
t.Fatalf("plain=%q entities=%#v", plain, entities)
}
}
func TestBotAPIFormattedTextPrecedenceAndEntityBounds(t *testing.T) {
plain, entities, err := botAPIFormattedTextRaw(`<b>ok</b>`, " HTML ", `{not json`, domain.MaxMessageTextLength, true)
if err != nil {
t.Fatal(err)
}
if plain != "ok" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 2}}) {
t.Fatalf("plain=%q entities=%#v", plain, entities)
}
for name, raw := range map[string]string{
"unterminated HTML": `<b>broken`,
"reserved MarkdownV2": `plain-text`,
} {
t.Run(name, func(t *testing.T) {
mode := "HTML"
if strings.Contains(name, "MarkdownV2") {
mode = "MarkdownV2"
}
if _, _, err := botAPIFormattedTextRaw(raw, mode, "", domain.MaxMessageTextLength, true); err == nil || !strings.Contains(err.Error(), "Can't parse entities") {
t.Fatalf("error = %v", err)
}
})
}
_, _, err = botAPIFormattedText("😀x", "", []apiMessageEntity{{Type: "bold", Offset: 1, Length: 1}}, domain.MaxMessageTextLength, true)
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
t.Fatalf("surrogate-split error = %v", err)
}
_, _, err = botAPIFormattedText("abcdef", "", []apiMessageEntity{
{Type: "bold", Offset: 0, Length: 4},
{Type: "italic", Offset: 2, Length: 4},
}, domain.MaxMessageTextLength, true)
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
t.Fatalf("crossing error = %v", err)
}
}
func TestBotAPIExplicitExtendedEntitiesRoundTrip(t *testing.T) {
input := []apiMessageEntity{
{Type: "expandable_blockquote", Offset: 0, Length: 4},
{Type: "date_time", Offset: 5, Length: 4, UnixTime: 1700000000, DateTimeFormat: "wdT"},
{Type: "bank_card_number", Offset: 10, Length: 4},
}
_, entities, err := botAPIFormattedText("text when 1234", "", input, domain.MaxMessageTextLength, true)
if err != nil {
t.Fatal(err)
}
projected := apiMessageEntities(entities, nil)
if projected[0]["type"] != "expandable_blockquote" || projected[1]["type"] != "date_time" || projected[1]["unix_time"] != 1700000000 || projected[1]["date_time_format"] != "wdT" || projected[2]["type"] != "bank_card_number" {
t.Fatalf("projected = %#v", projected)
}
}
func TestBotAPIInlineAndNestedMediaUseFormattedTextParser(t *testing.T) {
payload := apiInlineResult{InputMessageContent: json.RawMessage(`{
"message_text":"<b>inline</b>",
"parse_mode":"HTML",
"entities":[{"type":"bold","offset":999,"length":1}]
}`)}
message, entities, _, err := inputTextMessageContentFromAPI(payload)
if err != nil {
t.Fatal(err)
}
if message != "inline" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 6}}) {
t.Fatalf("inline message=%q entities=%#v", message, entities)
}
fileID := encodeBotAPIFileID("photo:7002:m")
raw, _ := json.Marshal(map[string]any{
"type": "photo", "media": fileID, "caption": "_media_", "parse_mode": "MarkdownV2",
})
var input domain.BotAPIEphemeralEditInput
if err := parseEphemeralEditMedia(string(raw), &input); err != nil {
t.Fatal(err)
}
if input.Fields.Message != "media" || !reflect.DeepEqual(input.Fields.Entities, []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}) {
t.Fatalf("media fields=%#v", input.Fields)
}
}
func TestBotAPIFormattedTextIsUsedByAllMessageEntryPoints(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
sendMessage: domain.Message{ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "hello"},
sendMediaMessage: domain.Message{ID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "caption"},
editMessage: domain.Message{ID: 3, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "edited"},
ephemeralMessage: domain.EphemeralMessage{ID: 4, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, Content: domain.EphemeralContent{Message: "ephemeral"}},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>hello</b>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || gateway.sendText != "hello" || len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("sendMessage status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendText, gateway.sendEntities)
}
fileID := encodeBotAPIFileID("doc:7001")
body, _ := json.Marshal(map[string]any{"chat_id": 2001, "document": fileID, "caption": "*caption*", "parse_mode": "MarkdownV2"})
rec = performBotAPIRequest(t, h, bots.profile, "sendDocument", string(body))
if rec.Code != http.StatusOK || gateway.sendMediaCaption != "caption" || len(gateway.sendMediaEntities) != 1 || gateway.sendMediaEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("sendDocument status=%d body=%s caption=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendMediaCaption, gateway.sendMediaEntities)
}
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":3,"text":"_edited_","parse_mode":"Markdown"}`)
if rec.Code != http.StatusOK || gateway.editText != "edited" || len(gateway.editEntities) != 1 || gateway.editEntities[0].Type != domain.MessageEntityItalic {
t.Fatalf("edit status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.editText, gateway.editEntities)
}
rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"text":"<u>ephemeral</u>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || len(gateway.ephemeralSends) == 0 {
t.Fatalf("ephemeral status=%d body=%s", rec.Code, rec.Body.String())
}
lastSend := gateway.ephemeralSends[len(gateway.ephemeralSends)-1]
if lastSend.Text != "ephemeral" || len(lastSend.Entities) != 1 || lastSend.Entities[0].Type != domain.MessageEntityUnderline {
t.Fatalf("ephemeral status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastSend)
}
rec = performBotAPIRequest(t, h, bots.profile, "editEphemeralMessageCaption", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":4,"caption":"<s>caption</s>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || len(gateway.ephemeralEdits) == 0 {
t.Fatalf("ephemeral edit status=%d body=%s", rec.Code, rec.Body.String())
}
lastEdit := gateway.ephemeralEdits[len(gateway.ephemeralEdits)-1]
if lastEdit.Fields.Message != "caption" || len(lastEdit.Fields.Entities) != 1 || lastEdit.Fields.Entities[0].Type != domain.MessageEntityStrike {
t.Fatalf("ephemeral edit status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastEdit)
}
}
func FuzzBotAPIFormattedTextParsersNeverPanic(f *testing.F) {
for _, seed := range []string{"", "plain", "<b>x</b>", "<", "&broken", "*x*", "_", ">quote\n>hidden||", "![x](tg://emoji?id=1)", "😀"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
if len(input) > 4096 || !utf8.ValidString(input) {
return
}
_, _, _ = parseBotAPIHTML(input)
_, _, _ = parseBotAPIMarkdown(input)
_, _, _ = parseBotAPIMarkdownV2(input)
})
}
func TestBotAPIHTMLParseFailureIsAtomic(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>broken","parse_mode":"HTML"}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Can't parse entities") || gateway.sendCalled {
t.Fatalf("status=%d body=%s gatewayCalled=%v", rec.Code, rec.Body.String(), gateway.sendCalled)
}
}

View file

@ -6,7 +6,6 @@ import (
"net/url"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -36,7 +35,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) {
if err != nil {
return domain.BotInlineResult{}, err
}
markup, err := replyMarkupFromAPI(payload.ReplyMarkup)
markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup)
if err != nil {
return domain.BotInlineResult{}, err
}
@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M
} else if payload.MessageText != "" {
content.MessageText = payload.MessageText
}
if content.ParseMode != "" {
return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED")
}
message := content.MessageText
if message == "" {
return "", nil, false, errors.New("MESSAGE_EMPTY")
}
if utf8.RuneCountInString(message) > domain.MaxMessageTextLength {
return "", nil, false, errors.New("MESSAGE_TOO_LONG")
}
entities, err := messageEntitiesFromAPI(content.Entities)
message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true)
if err != nil {
return "", nil, false, err
}
@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item := domain.MessageEntity{
Type: mapped,
Offset: entity.Offset,
Length: entity.Length,
URL: entity.URL,
Language: entity.Language,
Type: mapped,
Offset: entity.Offset,
Length: entity.Length,
}
if entity.User != nil {
item.UserID = entity.User.ID
}
if entity.CustomEmojiID != "" {
switch mapped {
case domain.MessageEntityTextURL:
resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length)
if !ok {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item = resolved
case domain.MessageEntityMentionName:
if entity.User != nil {
item.UserID = entity.User.ID
}
if item.UserID <= 0 {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
case domain.MessageEntityPre:
item.Language = entity.Language
case domain.MessageEntityBlockquote:
item.Collapsed = entity.Type == "expandable_blockquote"
case domain.MessageEntityCustomEmoji:
id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
if err != nil || id <= 0 {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item.DocumentID = id
case domain.MessageEntityFormattedDate:
formatted, err := botAPIFormattedDate(entity.UnixTime, entity.DateTimeFormat)
if err != nil {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
formatted.Offset, formatted.Length = entity.Offset, entity.Length
item = formatted
}
out = append(out, item)
}
@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntitySpoiler, true
case "blockquote":
return domain.MessageEntityBlockquote, true
case "expandable_blockquote":
return domain.MessageEntityBlockquote, true
case "custom_emoji":
return domain.MessageEntityCustomEmoji, true
case "mention":
@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntityEmail, true
case "phone_number":
return domain.MessageEntityPhone, true
case "bank_card_number":
return domain.MessageEntityBankCard, true
case "date_time":
return domain.MessageEntityFormattedDate, true
default:
return "", false
}
@ -165,6 +180,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var shape map[string]json.RawMessage
if err := json.Unmarshal(raw, &shape); err != nil {
return nil, errors.New("BUTTON_INVALID")
}
constructors := 0
for _, key := range []string{"inline_keyboard", "keyboard", "remove_keyboard", "force_reply"} {
if _, ok := shape[key]; ok {
constructors++
}
}
if constructors != 1 {
return nil, errors.New("BUTTON_INVALID")
}
if _, ok := shape["inline_keyboard"]; ok {
return inlineKeyboardMarkupFromAPI(raw)
}
if _, ok := shape["keyboard"]; ok {
return replyKeyboardMarkupFromAPI(raw)
}
if _, ok := shape["remove_keyboard"]; ok {
var payload apiReplyKeyboardRemove
if err := json.Unmarshal(raw, &payload); err != nil || !payload.RemoveKeyboard {
return nil, errors.New("BUTTON_INVALID")
}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: payload.Selective}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
var payload apiForceReply
if err := json.Unmarshal(raw, &payload); err != nil || !payload.ForceReply {
return nil, errors.New("BUTTON_INVALID")
}
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupForceReply,
SingleUse: true,
Selective: payload.Selective,
Placeholder: payload.InputFieldPlaceholder,
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
func inlineReplyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
markup, err := replyMarkupFromAPI(raw)
if err != nil || markup == nil {
return markup, err
}
if markup.Kind() != domain.MessageReplyMarkupInline {
return nil, errors.New("BUTTON_INVALID")
}
return markup, nil
}
func inlineKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
var payload apiInlineKeyboardMarkup
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, errors.New("BUTTON_INVALID")
@ -172,7 +245,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(payload.InlineKeyboard) == 0 {
return nil, nil
}
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
for _, row := range payload.InlineKeyboard {
domainRow := make([]domain.MarkupButton, 0, len(row))
for _, button := range row {
@ -193,19 +266,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
return out, nil
}
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
if button.URL != "" {
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil
func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
var payload apiReplyKeyboardMarkup
if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 {
return nil, errors.New("BUTTON_INVALID")
}
if button.CallbackData != nil {
if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen {
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: make([][]domain.MarkupButton, 0, len(payload.Keyboard)),
Resize: payload.ResizeKeyboard,
SingleUse: payload.OneTimeKeyboard,
Selective: payload.Selective,
Persistent: payload.IsPersistent,
Placeholder: payload.InputFieldPlaceholder,
}
for _, row := range payload.Keyboard {
domainRow := make([]domain.MarkupButton, 0, len(row))
for _, button := range row {
if button.Text == "" {
return nil, errors.New("BUTTON_INVALID")
}
if button.Unsupported {
return nil, errors.New("BUTTON_TYPE_INVALID")
}
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
if err != nil {
return nil, err
}
item := domain.MarkupButton{Type: domain.MarkupButtonText, Text: button.Text, Style: style, IconCustomEmojiID: icon}
switch button.Kind {
case "request_contact":
item.Type = domain.MarkupButtonRequestPhone
case "request_location":
item.Type = domain.MarkupButtonRequestLocation
case "request_poll":
item.Type, item.PollType = domain.MarkupButtonRequestPoll, button.PollType
case "request_users":
item.Type, item.ButtonID, item.RequestPeerType = domain.MarkupButtonRequestPeer, button.RequestID, "user"
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = button.MaxQuantity, button.RequestName, button.RequestUsername, button.RequestPhoto
item.RequestPeerFilter = button.RequestPeerFilter
case "request_chat":
item.Type, item.ButtonID = domain.MarkupButtonRequestPeer, button.RequestID
if button.ChatIsChannel {
item.RequestPeerType = "broadcast"
} else {
item.RequestPeerType = "chat"
}
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = 1, button.RequestTitle, button.RequestUsername, button.RequestPhoto
item.RequestPeerFilter = button.RequestPeerFilter
case "web_app":
item.Type, item.URL = domain.MarkupButtonSimpleWebView, button.WebAppURL
}
domainRow = append(domainRow, item)
}
out.Keyboard = append(out.Keyboard, domainRow)
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
if button.Unsupported {
return domain.MarkupButton{}, errors.New("BUTTON_TYPE_INVALID")
}
constructors := 0
if button.URLSet {
constructors++
}
if button.CallbackDataSet {
constructors++
}
if button.WebAppSet {
constructors++
}
if button.SwitchInlineSet {
constructors++
}
if button.CopyTextSet {
constructors++
}
if constructors != 1 {
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
}
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
if err != nil {
return domain.MarkupButton{}, err
}
if button.URLSet {
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
}
if button.CallbackDataSet {
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
}
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(button.CallbackData), Style: style, IconCustomEmojiID: icon}, nil
}
if button.WebAppSet {
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: button.Text, URL: button.WebAppURL, Style: style, IconCustomEmojiID: icon}, nil
}
if button.SwitchInlineSet {
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: button.Text, Query: button.SwitchInlineQuery, SamePeer: button.SwitchInlineSamePeer, PeerTypes: append([]string(nil), button.SwitchInlinePeerTypes...), Style: style, IconCustomEmojiID: icon}, nil
}
if button.CopyTextSet {
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: button.Text, CopyText: button.CopyText, Style: style, IconCustomEmojiID: icon}, nil
}
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
}
func markupButtonDecorationFromAPI(rawStyle, rawIcon string, iconSet bool) (domain.MarkupButtonStyle, int64, error) {
style := domain.MarkupButtonStyle(strings.TrimSpace(rawStyle))
switch style {
case "", domain.MarkupButtonStylePrimary, domain.MarkupButtonStyleDanger, domain.MarkupButtonStyleSuccess:
default:
return "", 0, errors.New("BUTTON_INVALID")
}
if !iconSet {
return style, 0, nil
}
icon, err := strconv.ParseInt(strings.TrimSpace(rawIcon), 10, 64)
if err != nil || icon <= 0 {
return "", 0, errors.New("BUTTON_INVALID")
}
return style, icon, nil
}
func replyMarkupErrFromDomain(err error) error {
switch {
case errors.Is(err, domain.ErrButtonURLInvalid):
@ -279,16 +465,352 @@ type apiMessageEntity struct {
User *struct {
ID int64 `json:"id"`
} `json:"user"`
Language string `json:"language"`
CustomEmojiID string `json:"custom_emoji_id"`
Language string `json:"language"`
CustomEmojiID string `json:"custom_emoji_id"`
UnixTime int `json:"unix_time"`
DateTimeFormat string `json:"date_time_format"`
}
type apiInlineKeyboardMarkup struct {
InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"`
}
type apiInlineKeyboardButton struct {
Text string `json:"text"`
URL string `json:"url"`
CallbackData *string `json:"callback_data"`
type apiReplyKeyboardMarkup struct {
Keyboard [][]apiKeyboardButton `json:"keyboard"`
IsPersistent bool `json:"is_persistent"`
ResizeKeyboard bool `json:"resize_keyboard"`
OneTimeKeyboard bool `json:"one_time_keyboard"`
InputFieldPlaceholder string `json:"input_field_placeholder"`
Selective bool `json:"selective"`
}
type apiKeyboardButton struct {
Text string
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
Kind string
PollType string
RequestID int
MaxQuantity int
RequestName bool
RequestUsername bool
RequestPhoto bool
RequestTitle bool
ChatIsChannel bool
WebAppURL string
RequestPeerFilter *domain.BotRequestPeerFilter
}
type apiChatAdministratorRights struct {
IsAnonymous bool `json:"is_anonymous"`
CanManageChat bool `json:"can_manage_chat"`
CanDeleteMessages bool `json:"can_delete_messages"`
CanManageVideoChats bool `json:"can_manage_video_chats"`
CanRestrictMembers bool `json:"can_restrict_members"`
CanPromoteMembers bool `json:"can_promote_members"`
CanChangeInfo bool `json:"can_change_info"`
CanInviteUsers bool `json:"can_invite_users"`
CanPostStories bool `json:"can_post_stories"`
CanEditStories bool `json:"can_edit_stories"`
CanDeleteStories bool `json:"can_delete_stories"`
CanPostMessages bool `json:"can_post_messages"`
CanEditMessages bool `json:"can_edit_messages"`
CanPinMessages bool `json:"can_pin_messages"`
CanManageTopics bool `json:"can_manage_topics"`
CanManageDirectMessages bool `json:"can_manage_direct_messages"`
}
func domainRequestAdminRights(in *apiChatAdministratorRights) *domain.BotRequestAdminRights {
if in == nil {
return nil
}
return &domain.BotRequestAdminRights{
Anonymous: in.IsAnonymous, ManageChat: in.CanManageChat, DeleteMessages: in.CanDeleteMessages,
ManageVideoChats: in.CanManageVideoChats, RestrictMembers: in.CanRestrictMembers,
PromoteMembers: in.CanPromoteMembers, ChangeInfo: in.CanChangeInfo, InviteUsers: in.CanInviteUsers,
PostStories: in.CanPostStories, EditStories: in.CanEditStories, DeleteStories: in.CanDeleteStories,
PostMessages: in.CanPostMessages, EditMessages: in.CanEditMessages, PinMessages: in.CanPinMessages,
ManageTopics: in.CanManageTopics, ManageDirectMessages: in.CanManageDirectMessages,
}
}
func (b *apiKeyboardButton) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if strings.HasPrefix(trimmed, "\"") {
b.Kind = "text"
return json.Unmarshal([]byte(trimmed), &b.Text)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
text, ok := fields["text"]
if !ok || json.Unmarshal(text, &b.Text) != nil {
return errors.New("invalid keyboard button text")
}
if raw, ok := fields["style"]; ok {
if err := json.Unmarshal(raw, &b.Style); err != nil {
return err
}
}
if raw, ok := fields["icon_custom_emoji_id"]; ok {
b.IconCustomEmojiIDSet = true
if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil {
return err
}
}
actions := 0
if raw, ok := fields["request_contact"]; ok {
var enabled bool
if json.Unmarshal(raw, &enabled) != nil || !enabled {
b.Unsupported = true
} else {
b.Kind = "request_contact"
actions++
}
}
if raw, ok := fields["request_location"]; ok {
var enabled bool
if json.Unmarshal(raw, &enabled) != nil || !enabled {
b.Unsupported = true
} else {
b.Kind = "request_location"
actions++
}
}
if raw, ok := fields["request_poll"]; ok {
var poll struct {
Type string `json:"type"`
}
if json.Unmarshal(raw, &poll) != nil {
b.Unsupported = true
} else {
b.Kind, b.PollType = "request_poll", poll.Type
actions++
}
}
if raw, ok := fields["request_users"]; ok {
var request struct {
RequestID int `json:"request_id"`
UserIsBot *bool `json:"user_is_bot"`
UserIsPremium *bool `json:"user_is_premium"`
MaxQuantity int `json:"max_quantity"`
RequestName bool `json:"request_name"`
RequestUsername bool `json:"request_username"`
RequestPhoto bool `json:"request_photo"`
}
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 {
b.Unsupported = true
} else {
b.Kind, b.RequestID, b.MaxQuantity, b.RequestName, b.RequestUsername, b.RequestPhoto = "request_users", request.RequestID, request.MaxQuantity, request.RequestName, request.RequestUsername, request.RequestPhoto
b.RequestPeerFilter = &domain.BotRequestPeerFilter{}
if request.UserIsBot != nil {
b.RequestPeerFilter.UserIsBotSet, b.RequestPeerFilter.UserIsBot = true, *request.UserIsBot
}
if request.UserIsPremium != nil {
b.RequestPeerFilter.UserIsPremiumSet, b.RequestPeerFilter.UserIsPremium = true, *request.UserIsPremium
}
if b.MaxQuantity == 0 {
b.MaxQuantity = 1
}
actions++
}
}
if raw, ok := fields["request_chat"]; ok {
var request struct {
RequestID int `json:"request_id"`
ChatIsChannel bool `json:"chat_is_channel"`
ChatIsForum *bool `json:"chat_is_forum"`
ChatHasUsername *bool `json:"chat_has_username"`
ChatIsCreated bool `json:"chat_is_created"`
UserAdministratorRights *apiChatAdministratorRights `json:"user_administrator_rights"`
BotAdministratorRights *apiChatAdministratorRights `json:"bot_administrator_rights"`
BotIsMember bool `json:"bot_is_member"`
RequestTitle bool `json:"request_title"`
RequestUsername bool `json:"request_username"`
RequestPhoto bool `json:"request_photo"`
}
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 || (request.ChatIsChannel && (request.ChatIsForum != nil || request.BotIsMember)) {
b.Unsupported = true
} else {
b.Kind, b.RequestID, b.ChatIsChannel, b.RequestTitle, b.RequestUsername, b.RequestPhoto = "request_chat", request.RequestID, request.ChatIsChannel, request.RequestTitle, request.RequestUsername, request.RequestPhoto
b.RequestPeerFilter = &domain.BotRequestPeerFilter{
ChatIsCreated: request.ChatIsCreated, BotIsMember: request.BotIsMember,
UserAdminRights: domainRequestAdminRights(request.UserAdministratorRights),
BotAdminRights: domainRequestAdminRights(request.BotAdministratorRights),
}
if request.ChatIsForum != nil {
b.RequestPeerFilter.ChatIsForumSet, b.RequestPeerFilter.ChatIsForum = true, *request.ChatIsForum
}
if request.ChatHasUsername != nil {
b.RequestPeerFilter.ChatHasUsernameSet, b.RequestPeerFilter.ChatHasUsername = true, *request.ChatHasUsername
}
actions++
}
}
if raw, ok := fields["web_app"]; ok {
var app struct {
URL string `json:"url"`
}
if json.Unmarshal(raw, &app) != nil {
b.Unsupported = true
} else {
b.Kind, b.WebAppURL = "web_app", app.URL
actions++
}
}
if actions == 0 {
b.Kind = "text"
}
if actions > 1 {
b.Unsupported = true
}
for key := range fields {
switch key {
case "text", "style", "icon_custom_emoji_id", "request_contact", "request_location", "request_poll", "request_users", "request_chat", "web_app":
default:
b.Unsupported = true
}
}
return nil
}
type apiReplyKeyboardRemove struct {
RemoveKeyboard bool `json:"remove_keyboard"`
Selective bool `json:"selective"`
}
type apiForceReply struct {
ForceReply bool `json:"force_reply"`
InputFieldPlaceholder string `json:"input_field_placeholder"`
Selective bool `json:"selective"`
}
type apiInlineKeyboardButton struct {
Text string
URL string
URLSet bool
CallbackData string
CallbackDataSet bool
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
WebAppURL string
WebAppSet bool
SwitchInlineQuery string
SwitchInlineSet bool
SwitchInlineSamePeer bool
SwitchInlinePeerTypes []string
CopyText string
CopyTextSet bool
}
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
text, ok := fields["text"]
if !ok || json.Unmarshal(text, &b.Text) != nil {
return errors.New("invalid inline keyboard button text")
}
if raw, ok := fields["url"]; ok {
b.URLSet = true
if err := json.Unmarshal(raw, &b.URL); err != nil {
return err
}
}
if raw, ok := fields["callback_data"]; ok {
b.CallbackDataSet = true
if err := json.Unmarshal(raw, &b.CallbackData); err != nil {
return err
}
}
if raw, ok := fields["web_app"]; ok {
b.WebAppSet = true
var app struct {
URL string `json:"url"`
}
if json.Unmarshal(raw, &app) != nil {
return errors.New("invalid web app")
}
b.WebAppURL = app.URL
}
switchActions := 0
if raw, ok := fields["switch_inline_query"]; ok {
switchActions++
b.SwitchInlineSet = true
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
return errors.New("invalid switch inline query")
}
}
if raw, ok := fields["switch_inline_query_current_chat"]; ok {
switchActions++
b.SwitchInlineSet, b.SwitchInlineSamePeer = true, true
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
return errors.New("invalid switch inline query")
}
}
if raw, ok := fields["switch_inline_query_chosen_chat"]; ok {
switchActions++
b.SwitchInlineSet = true
var chosen struct {
Query string `json:"query"`
AllowUserChats bool `json:"allow_user_chats"`
AllowBotChats bool `json:"allow_bot_chats"`
AllowGroupChats bool `json:"allow_group_chats"`
AllowChannelChats bool `json:"allow_channel_chats"`
}
if json.Unmarshal(raw, &chosen) != nil {
return errors.New("invalid switch inline query")
}
b.SwitchInlineQuery = chosen.Query
if chosen.AllowUserChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypePM)
}
if chosen.AllowBotChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBotPM)
}
if chosen.AllowGroupChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup)
}
if chosen.AllowChannelChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBroadcast)
}
}
if switchActions > 1 {
b.Unsupported = true
}
if raw, ok := fields["copy_text"]; ok {
b.CopyTextSet = true
var copy struct {
Text string `json:"text"`
}
if json.Unmarshal(raw, &copy) != nil {
return errors.New("invalid copy text")
}
b.CopyText = copy.Text
}
if raw, ok := fields["style"]; ok {
if err := json.Unmarshal(raw, &b.Style); err != nil {
return err
}
}
if raw, ok := fields["icon_custom_emoji_id"]; ok {
b.IconCustomEmojiIDSet = true
if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil {
return err
}
}
for key := range fields {
switch key {
case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
default:
b.Unsupported = true
}
}
return nil
}

View file

@ -2,12 +2,14 @@ package botapi
import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"strconv"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func apiInt(raw string, fallback int) int {
@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) {
return messageEntitiesFromAPI(payload)
}
func allowedUpdates(raw string) map[string]struct{} {
func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
out := make(map[string]struct{}, len(items))
if len(items) > 100 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items))
out := make([]domain.BotAPIUpdateKind, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
out[item] = struct{}{}
if item == "" || len(item) > 64 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
kind := domain.BotAPIUpdateKind(item)
if _, ok := seen[kind]; !ok {
seen[kind] = struct{}{}
out = append(out, kind)
}
}
return out
return out, nil
}
func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any {
func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 100
}
out := make([]map[string]any, 0, min(len(events), limit))
for _, event := range events {
item, kind, ok := apiUpdate(event)
if !ok || !updateAllowed(kind, allowed) {
item, _, ok := apiUpdate(event)
if !ok {
continue
}
out = append(out, item)
@ -73,45 +84,176 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit
return out
}
func updateAllowed(kind string, allowed map[string]struct{}) bool {
if len(allowed) == 0 {
return true
}
_, ok := allowed[kind]
return ok
}
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
if event.Pts <= 0 {
updateID := event.BotAPIUpdateID
if updateID <= 0 {
updateID = int64(event.Pts)
}
if updateID <= 0 {
return nil, "", false
}
switch event.Type {
case domain.UpdateEventNewMessage:
if event.EphemeralMessage != nil {
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
if !ok {
return nil, "", false
}
return map[string]any{"update_id": updateID, "message": message}, "message", true
}
if !apiMessageProjectable(event.Message) {
return nil, "", false
}
return map[string]any{
"update_id": event.Pts,
"message": apiMessage(event.Message, event.Users),
"update_id": updateID,
"message": apiMessage(event.Message, event.Users, event.Channels),
}, "message", true
case domain.UpdateEventEditMessage:
if event.EphemeralMessage != nil {
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
if !ok {
return nil, "", false
}
return map[string]any{"update_id": updateID, "edited_message": message}, "edited_message", true
}
if !apiMessageProjectable(event.Message) {
return nil, "", false
}
return map[string]any{
"update_id": event.Pts,
"edited_message": apiMessage(event.Message, event.Users),
"update_id": updateID,
"edited_message": apiMessage(event.Message, event.Users, event.Channels),
}, "edited_message", true
case domain.UpdateEventBotCallbackQuery:
callback := event.BotCallbackQuery
if callback == nil || callback.ID == 0 || callback.UserID == 0 {
return nil, "", false
}
var from domain.User
for _, user := range event.Users {
if user.ID == callback.UserID {
from = user
break
}
}
if from.ID == 0 {
from = domain.User{ID: callback.UserID}
}
query := map[string]any{
"id": strconv.FormatInt(callback.ID, 10),
"from": apiUser(from),
"chat_instance": strconv.FormatInt(callback.ChatInstance, 10),
"data": string(callback.Data),
}
if callback.InlineMessage != nil {
inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage)
if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) {
return nil, "", false
}
query["inline_message_id"] = inlineMessageID
} else if event.EphemeralMessage != nil {
if callback.MessageID <= 0 || event.EphemeralMessage.ID != callback.MessageID || event.EphemeralMessage.Peer != callback.Peer {
return nil, "", false
}
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
if !ok {
return nil, "", false
}
query["message"] = message
} else {
if callback.MessageID <= 0 || event.Message.ID != callback.MessageID {
return nil, "", false
}
query["message"] = apiMessage(event.Message, event.Users, event.Channels)
}
return map[string]any{
"update_id": updateID,
"callback_query": query,
}, "callback_query", true
default:
return nil, "", false
}
}
func apiEphemeralMessage(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel) (map[string]any, bool) {
return apiEphemeralMessageDepth(message, users, channels, 0)
}
func apiEphemeralMessageDepth(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel, depth int) (map[string]any, bool) {
if message.ID <= 0 || message.Peer.Type != domain.PeerTypeChannel || message.Peer.ID <= 0 ||
message.SenderUserID <= 0 || message.ReceiverUserID <= 0 || message.Date <= 0 || message.Deleted {
return nil, false
}
if message.Content.Message == "" && (message.Content.Media == nil || message.Content.Media.IsZero()) {
return nil, false
}
projected := apiMessage(domain.Message{
ID: 0, Peer: message.Peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: message.SenderUserID},
Date: message.Date, EditDate: message.EditDate, Body: message.Content.Message,
Entities: message.Content.Entities, Media: message.Content.Media, ReplyMarkup: message.Content.ReplyMarkup,
}, users, channels)
projected["message_id"] = 0
projected["ephemeral_message_id"] = message.ID
receiver := domain.User{ID: message.ReceiverUserID}
for _, user := range users {
if user.ID == message.ReceiverUserID {
receiver = user
break
}
}
projected["receiver_user"] = apiUser(receiver)
if message.ReplyToEphemeralID > 0 {
if depth != 0 || message.BotAPIReply == nil || message.BotAPIReply.ID != message.ReplyToEphemeralID {
return nil, false
}
reply, ok := apiEphemeralMessageDepth(*message.BotAPIReply, users, channels, depth+1)
if !ok {
return nil, false
}
projected["reply_to_message"] = reply
}
return projected, true
}
const botAPIInlineMessageIDVersion byte = 1
// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an
// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the
// version byte lets us reject rather than reinterpret future shapes.
func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) {
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return "", false
}
buf := make([]byte, 1+4+8+4+8)
buf[0] = botAPIInlineMessageIDVersion
binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID))
binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID))
binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID))
binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash))
return base64.RawURLEncoding.EncodeToString(buf), true
}
func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) {
buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
id := domain.BotInlineMessageID{
DCID: int(binary.LittleEndian.Uint32(buf[1:5])),
OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])),
ID: int(binary.LittleEndian.Uint32(buf[13:17])),
AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])),
}
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
return id, nil
}
func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 {
return false
}
return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
}
func apiUser(u domain.User) map[string]any {
@ -133,11 +275,17 @@ func apiUser(u domain.User) map[string]any {
return out
}
func apiMessage(msg domain.Message, users []domain.User) map[string]any {
func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domain.Channel) map[string]any {
userByID := map[int64]domain.User{}
for _, u := range users {
userByID[u.ID] = u
}
channelByID := map[int64]domain.Channel{}
if len(channelLists) > 0 {
for _, channel := range channelLists[0] {
channelByID[channel.ID] = channel
}
}
out := map[string]any{
"message_id": msg.ID,
"date": msg.Date,
@ -153,17 +301,21 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any {
}
out["from"] = apiUser(from)
}
media := apiMessageMedia(msg.Media)
media := apiMessageMedia(msg.Media, userByID, channelByID)
if msg.Body != "" {
if len(media) > 0 {
if apiMediaUsesCaption(media) {
out["caption"] = msg.Body
} else if poll, ok := media["poll"].(map[string]any); ok {
poll["description"] = msg.Body
} else {
out["text"] = msg.Body
}
}
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if len(media) > 0 {
if apiMediaUsesCaption(media) {
out["caption_entities"] = entities
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
poll["description_entities"] = entities
} else {
out["entities"] = entities
}
@ -189,6 +341,15 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any {
return out
}
func apiMediaUsesCaption(media map[string]any) bool {
for _, key := range []string{"photo", "live_photo", "animation", "audio", "document", "video", "voice"} {
if _, ok := media[key]; ok {
return true
}
}
return false
}
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
switch peer.Type {
case domain.PeerTypeUser:
@ -241,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
"offset": entity.Offset,
"length": entity.Length,
}
if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed {
item["type"] = "expandable_blockquote"
}
if entity.URL != "" {
item["url"] = entity.URL
}
@ -257,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
if entity.DocumentID != 0 {
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10)
}
if entity.Type == domain.MessageEntityFormattedDate {
item["unix_time"] = entity.Date
item["date_time_format"] = botAPIFormattedDateFormat(entity)
}
out = append(out, item)
}
return out
@ -300,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) {
return "email", true
case domain.MessageEntityPhone:
return "phone_number", true
case domain.MessageEntityBankCard:
return "bank_card_number", true
case domain.MessageEntityFormattedDate:
return "date_time", true
default:
return "", false
}
@ -309,6 +481,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
if markup.IsZero() {
return nil
}
// Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup,
// ReplyKeyboardRemove and ForceReply are send parameters, not message response fields.
if markup.Kind() != domain.MessageReplyMarkupInline {
return nil
}
rows := make([][]map[string]any, 0, len(markup.Inline))
for _, row := range markup.Inline {
if len(row) == 0 {
@ -317,11 +494,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
apiRow := make([]map[string]any, 0, len(row))
for _, button := range row {
item := map[string]any{"text": button.Text}
if button.Style != "" {
item["style"] = string(button.Style)
}
if button.IconCustomEmojiID > 0 {
item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10)
}
switch button.Type {
case domain.MarkupButtonURL:
item["url"] = button.URL
case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data)
case domain.MarkupButtonWebView:
item["web_app"] = map[string]any{"url": button.URL}
case domain.MarkupButtonSwitchInline:
switch {
case button.SamePeer:
item["switch_inline_query_current_chat"] = button.Query
case len(button.PeerTypes) > 0:
chosen := map[string]any{"query": button.Query}
for _, peerType := range button.PeerTypes {
switch peerType {
case store.InlineQueryPeerTypePM:
chosen["allow_user_chats"] = true
case store.InlineQueryPeerTypeBotPM:
chosen["allow_bot_chats"] = true
case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup:
chosen["allow_group_chats"] = true
case store.InlineQueryPeerTypeBroadcast:
chosen["allow_channel_chats"] = true
}
}
item["switch_inline_query_chosen_chat"] = chosen
default:
item["switch_inline_query"] = button.Query
}
case domain.MarkupButtonCopy:
item["copy_text"] = map[string]any{"text": button.CopyText}
default:
continue
}
@ -337,7 +546,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
return map[string]any{"inline_keyboard": rows}
}
func apiMessageMedia(media *domain.MessageMedia) map[string]any {
func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, channels map[int64]domain.Channel) map[string]any {
if media.IsZero() {
return nil
}
@ -350,17 +559,342 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any {
if len(photos) == 0 {
return nil
}
if media.LivePhotoVideo != nil {
live := apiDocument(*media.LivePhotoVideo)
live["photo"] = photos
for _, attribute := range media.LivePhotoVideo.Attributes {
if attribute.Kind == domain.DocAttrVideo {
live["width"], live["height"], live["duration"] = attribute.W, attribute.H, int(attribute.Duration)
break
}
}
return map[string]any{"live_photo": live}
}
return map[string]any{"photo": photos}
case domain.MessageMediaKindDocument:
if media.Document == nil {
return nil
}
return map[string]any{"document": apiDocument(*media.Document)}
return apiDocumentMedia(*media.Document)
case domain.MessageMediaKindContact:
if media.Contact == nil {
return nil
}
contact := map[string]any{
"phone_number": media.Contact.PhoneNumber,
"first_name": media.Contact.FirstName,
}
if media.Contact.LastName != "" {
contact["last_name"] = media.Contact.LastName
}
if media.Contact.Vcard != "" {
contact["vcard"] = media.Contact.Vcard
}
if media.Contact.UserID != 0 {
contact["user_id"] = media.Contact.UserID
}
return map[string]any{"contact": contact}
case domain.MessageMediaKindGeo:
if media.Geo == nil {
return nil
}
return map[string]any{"location": apiLocation(*media.Geo, nil)}
case domain.MessageMediaKindVenue:
if media.Venue == nil {
return nil
}
return map[string]any{"venue": apiVenue(*media.Venue)}
case domain.MessageMediaKindGeoLive:
if media.GeoLive == nil {
return nil
}
return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)}
case domain.MessageMediaKindPoll:
if media.Poll == nil {
return nil
}
return map[string]any{"poll": apiPoll(*media.Poll, users)}
case domain.MessageMediaKindService:
if media.ServiceAction == nil {
return nil
}
switch media.ServiceAction.Kind {
case domain.MessageServiceActionWebViewDataSent:
if media.ServiceAction.WebViewData == nil {
return nil
}
return map[string]any{"web_app_data": map[string]any{
"data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText,
}}
case domain.MessageServiceActionRequestedPeer:
return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels)
default:
return nil
}
default:
return nil
}
}
func apiDocumentMedia(document domain.Document) map[string]any {
base := apiDocument(document)
for _, attribute := range document.Attributes {
switch attribute.Kind {
case domain.DocAttrSticker:
sticker := cloneAPIMap(base)
sticker["type"], sticker["width"], sticker["height"] = "regular", attribute.W, attribute.H
sticker["is_animated"] = hasDocumentAttribute(document, domain.DocAttrAnimated)
sticker["is_video"] = hasDocumentAttribute(document, domain.DocAttrVideo)
if attribute.Alt != "" {
sticker["emoji"] = attribute.Alt
}
return map[string]any{"sticker": sticker}
case domain.DocAttrAudio:
audio := cloneAPIMap(base)
audio["duration"] = attribute.AudioDuration
if attribute.Voice {
return map[string]any{"voice": audio}
}
if attribute.Title != "" {
audio["title"] = attribute.Title
}
if attribute.Performer != "" {
audio["performer"] = attribute.Performer
}
return map[string]any{"audio": audio}
case domain.DocAttrVideo:
video := cloneAPIMap(base)
video["width"], video["height"], video["duration"] = attribute.W, attribute.H, int(attribute.Duration)
if attribute.RoundMessage {
video["length"] = attribute.W
delete(video, "width")
delete(video, "height")
return map[string]any{"video_note": video}
}
if hasDocumentAttribute(document, domain.DocAttrAnimated) {
return map[string]any{"animation": video, "document": base}
}
return map[string]any{"video": video}
}
}
return map[string]any{"document": base}
}
func hasDocumentAttribute(document domain.Document, kind domain.DocumentAttributeKind) bool {
for _, attribute := range document.Attributes {
if attribute.Kind == kind {
return true
}
}
return false
}
func cloneAPIMap(input map[string]any) map[string]any {
out := make(map[string]any, len(input)+4)
for key, value := range input {
out[key] = value
}
return out
}
func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any {
out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long}
if geo.AccuracyRadius > 0 {
out["horizontal_accuracy"] = float64(geo.AccuracyRadius)
}
if live != nil {
if live.Period > 0 {
out["live_period"] = live.Period
}
if live.Heading > 0 {
out["heading"] = live.Heading
}
if live.ProximityNotificationRadius > 0 {
out["proximity_alert_radius"] = live.ProximityNotificationRadius
}
}
return out
}
func apiVenue(venue domain.MessageVenue) map[string]any {
out := map[string]any{
"location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address,
}
switch strings.ToLower(venue.Provider) {
case "foursquare":
if venue.VenueID != "" {
out["foursquare_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["foursquare_type"] = venue.VenueType
}
case "gplaces", "google":
if venue.VenueID != "" {
out["google_place_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["google_place_type"] = venue.VenueType
}
}
return out
}
func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any {
resultByOption := make(map[string]domain.MessagePollAnswerVoters)
totalVoters := 0
if poll.Results != nil {
totalVoters = poll.Results.TotalVoters
for _, result := range poll.Results.Voters {
resultByOption[string(result.Option)] = result
}
}
options := make([]map[string]any, 0, len(poll.Answers))
correct := make([]int, 0, len(poll.Answers))
for index, answer := range poll.Answers {
persistentID := base64.RawURLEncoding.EncodeToString(answer.Option)
if persistentID == "" {
persistentID = strconv.Itoa(index)
}
result := resultByOption[string(answer.Option)]
option := map[string]any{
"persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters,
}
if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 {
option["text_entities"] = entities
}
if answer.Media != nil {
if projected := apiPollMedia(answer.Media); len(projected) > 0 {
option["media"] = projected
}
}
if result.Correct {
correct = append(correct, index)
}
options = append(options, option)
}
pollType := "regular"
if poll.Quiz {
pollType = "quiz"
}
out := map[string]any{
"id": strconv.FormatInt(poll.ID, 10), "question": poll.Question,
"options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed,
"is_anonymous": !poll.PublicVoters, "type": pollType,
"allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled,
}
if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 {
out["question_entities"] = entities
}
if len(correct) > 0 {
out["correct_option_ids"] = correct
}
if poll.Results != nil && poll.Results.Solution != "" {
out["explanation"] = poll.Results.Solution
if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 {
out["explanation_entities"] = entities
}
}
if poll.ClosePeriod > 0 {
out["open_period"] = poll.ClosePeriod
}
if poll.CloseDate > 0 {
out["close_date"] = poll.CloseDate
}
if poll.AttachedMedia != nil {
if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 {
out["media"] = projected
}
}
return out
}
func apiPollMedia(media *domain.MessageMedia) map[string]any {
if media.IsZero() {
return nil
}
switch media.Kind {
case domain.MessageMediaKindPhoto:
if media.Photo != nil {
if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 {
return map[string]any{"photo": sizes}
}
}
case domain.MessageMediaKindDocument:
if media.Document != nil {
return map[string]any{"document": apiDocument(*media.Document)}
}
case domain.MessageMediaKindGeo:
if media.Geo != nil {
return map[string]any{"location": apiLocation(*media.Geo, nil)}
}
case domain.MessageMediaKindVenue:
if media.Venue != nil {
return map[string]any{"venue": apiVenue(*media.Venue)}
}
}
return nil
}
func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any {
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 {
return nil
}
allUsers := true
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
for _, detail := range action.Details {
details[detail.Peer] = detail
}
for _, peer := range action.Peers {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
allUsers = allUsers && peer.Type == domain.PeerTypeUser
}
if allUsers {
shared := make([]map[string]any, 0, len(action.Peers))
for _, peer := range action.Peers {
item := map[string]any{"user_id": peer.ID}
detail := details[peer]
if action.NameRequested {
if detail.FirstName != "" {
item["first_name"] = detail.FirstName
}
if detail.LastName != "" {
item["last_name"] = detail.LastName
}
}
if action.UsernameRequested && detail.Username != "" {
item["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
item["photo"] = photo
}
}
shared = append(shared, item)
}
return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}}
}
if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel {
return nil
}
peer := action.Peers[0]
shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID}
detail := details[peer]
if action.NameRequested && detail.Title != "" {
shared["title"] = detail.Title
}
if action.UsernameRequested && detail.Username != "" {
shared["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
shared["photo"] = photo
}
}
return map[string]any{"chat_shared": shared}
}
func apiPhotoSizes(photo domain.Photo) []map[string]any {
return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":")
}

View file

@ -10,8 +10,10 @@ import (
"io"
"net"
"net/http"
neturl "net/url"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@ -21,13 +23,15 @@ import (
type BotsService interface {
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error)
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error)
GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error)
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error)
}
type UsersService interface {
UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
}
type WebAppService interface {
@ -41,16 +45,46 @@ type GatewayService interface {
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error)
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
}
type EphemeralGatewayService interface {
BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error)
BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error)
BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error)
}
type GatewayUpdateWaiter interface {
BotAPIUpdateWaitVersion(botID int64) uint64
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
}
type GatewayUpdateControl interface {
BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error
BotAPIDropPendingUpdates(ctx context.Context, botID int64) error
BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error)
}
type GatewayPollLease interface {
AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error
}
type GatewayWebhookControl interface {
BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error
BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error
BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error)
ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error)
AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error
RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error
RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error
ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error
}
func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, logger *zap.Logger) (*http.Server, error) {
if strings.TrimSpace(addr) == "" {
return nil, nil
@ -58,7 +92,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
if logger == nil {
logger = zap.NewNop()
}
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger}
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger, webhookClient: newWebhookHTTPClient()}
srv := &http.Server{
Addr: addr,
Handler: handler.routes(),
@ -76,6 +110,9 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
logger.Warn("Bot API 网关退出", zap.Error(err))
}
}()
if webhooks, ok := gateway.(GatewayWebhookControl); ok {
go runWebhookDispatcher(ctx, webhooks, gateway, handler.webhookClient, logger.Named("webhook"))
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -86,11 +123,37 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
}
type handler struct {
bots BotsService
users UsersService
webapps WebAppService
gateway GatewayService
logger *zap.Logger
bots BotsService
users UsersService
webapps WebAppService
gateway GatewayService
logger *zap.Logger
polls botAPIPollRegistry
webhookClient *http.Client
}
type botAPIPollRegistry struct {
mu sync.Mutex
active map[int64]struct{}
}
func (p *botAPIPollRegistry) acquire(botID int64) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.active == nil {
p.active = make(map[int64]struct{})
}
if _, exists := p.active[botID]; exists {
return false
}
p.active[botID] = struct{}{}
return true
}
func (p *botAPIPollRegistry) release(botID int64) {
p.mu.Lock()
delete(p.active, botID)
p.mu.Unlock()
}
const (
@ -131,28 +194,64 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
switch strings.ToLower(method) {
case "getme":
h.getMe(w, r, botID)
case "setmycommands":
h.setMyCommands(w, r, botID)
case "deletemycommands":
h.deleteMyCommands(w, r, botID)
case "getmycommands":
h.getMyCommands(w, r, botID)
case "getupdates":
h.getUpdates(w, r, botID)
case "sendmessage":
h.sendMessage(w, r, botID)
case "sendphoto":
h.sendMedia(w, r, botID, "photo")
case "sendanimation":
h.sendMedia(w, r, botID, "animation")
case "sendaudio":
h.sendMedia(w, r, botID, "audio")
case "senddocument":
h.sendMedia(w, r, botID, "document")
case "sendlivephoto":
h.sendMedia(w, r, botID, "live_photo")
case "sendsticker":
h.sendMedia(w, r, botID, "sticker")
case "sendvideo":
h.sendMedia(w, r, botID, "video")
case "sendvideonote":
h.sendMedia(w, r, botID, "video_note")
case "sendvoice":
h.sendMedia(w, r, botID, "voice")
case "sendcontact":
h.sendEphemeralContact(w, r, botID)
case "sendlocation":
h.sendEphemeralLocation(w, r, botID, false)
case "sendvenue":
h.sendEphemeralLocation(w, r, botID, true)
case "editmessagetext":
h.editMessageText(w, r, botID)
case "deletemessage":
h.deleteMessage(w, r, botID)
case "editephemeralmessagetext":
h.editEphemeralMessage(w, r, botID, "text")
case "editephemeralmessagemedia":
h.editEphemeralMessage(w, r, botID, "media")
case "editephemeralmessagecaption":
h.editEphemeralMessage(w, r, botID, "caption")
case "editephemeralmessagereplymarkup":
h.editEphemeralMessage(w, r, botID, "reply_markup")
case "deleteephemeralmessage":
h.deleteEphemeralMessage(w, r, botID)
case "answercallbackquery":
h.answerCallbackQuery(w, r, botID)
case "getfile":
h.getFile(w, r, botID)
case "deletewebhook":
writeAPIOK(w, true)
h.deleteWebhook(w, r, botID)
case "getwebhookinfo":
writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0})
h.getWebhookInfo(w, r, botID)
case "setwebhook":
h.setWebhook(w, r)
h.setWebhook(w, r, botID)
case "setchatmenubutton":
h.setChatMenuButton(w, r, botID)
case "getchatmenubutton":
@ -216,7 +315,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64)
var offset int64
if raw := strings.TrimSpace(values["offset"]); raw != "" {
offset, err = strconv.ParseInt(raw, 10, 64)
if err != nil || offset < -10000 {
writeAPIError(w, http.StatusBadRequest, "OFFSET_INVALID")
return
}
}
limit := apiInt(values["limit"], 100)
if limit <= 0 {
limit = 100
@ -231,7 +337,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
if timeoutSeconds > 50 {
timeoutSeconds = 50
}
allowed := allowedUpdates(values["allowed_updates"])
if !h.polls.acquire(botID) {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer h.polls.release(botID)
if leases, ok := h.gateway.(GatewayPollLease); ok {
owner := randomBotAPIOwner()
leaseTTL := time.Duration(timeoutSeconds)*time.Second + 30*time.Second
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, leaseTTL)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner); err != nil {
h.logger.Warn("release bot api poll lease", zap.Int64("bot_user_id", botID), zap.Error(err))
}
}()
}
if webhooks, ok := h.gateway.(GatewayWebhookControl); ok {
if _, configured, err := webhooks.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if configured {
writeAPIError(w, http.StatusConflict, "CONFLICT: can't use getUpdates method while webhook is active")
return
}
}
if raw, present := values["allowed_updates"]; present {
allowed, err := parseAllowedUpdates(raw)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
control, ok := h.gateway.(GatewayUpdateControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "ALLOWED_UPDATES_UNSUPPORTED")
return
}
if err := control.BotAPISetAllowedUpdates(r.Context(), botID, allowed); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
}
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
for {
version := botAPIUpdateWaitVersion(h.gateway, botID)
@ -240,7 +395,7 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
updates := apiUpdates(events, allowed, limit)
updates := apiUpdates(events, limit)
if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) {
writeAPIOK(w, updates)
return
@ -249,6 +404,84 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
}
}
func randomBotAPIOwner() string {
var raw [16]byte
if _, err := rand.Read(raw[:]); err == nil {
return fmt.Sprintf("%x", raw[:])
}
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
func (h *handler) deleteWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
control, ok := h.gateway.(GatewayWebhookControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
return
}
leaseOwner := randomBotAPIOwner()
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if found {
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, leaseOwner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
return
}
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, leaseOwner) }()
}
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
}
func (h *handler) getWebhookInfo(w http.ResponseWriter, r *http.Request, botID int64) {
pending := 0
if control, ok := h.gateway.(GatewayUpdateControl); ok {
var err error
pending, err = control.BotAPIPendingUpdateCount(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
}
result := map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": pending}
if control, ok := h.gateway.(GatewayWebhookControl); ok {
config, found, err := control.BotAPIWebhook(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if found {
result["url"] = config.URL
result["max_connections"] = config.MaxConnections
if config.AllowedUpdates != nil {
allowed := make([]string, 0, len(config.AllowedUpdates))
for _, kind := range config.AllowedUpdates {
allowed = append(allowed, string(kind))
}
result["allowed_updates"] = allowed
}
if config.LastErrorDate > 0 {
result["last_error_date"] = config.LastErrorDate
result["last_error_message"] = config.LastErrorMessage
}
}
}
writeAPIOK(w, result)
}
func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 {
waiter, ok := gateway.(GatewayUpdateWaiter)
if !ok {
@ -291,12 +524,7 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
text := values["text"]
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -309,6 +537,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
return
}
}
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if isEphemeral {
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
TopMessageID: ephemeral.topMessageID, Kind: "message", Text: text, Entities: entities, ReplyMarkup: markup,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
return
}
replyTo := apiInt(values["reply_to_message_id"], 0)
msg, err := h.gateway.BotAPISendMessage(r.Context(), botID, chatID, text, entities, markup, apiBool(values["disable_web_page_preview"]), apiBool(values["disable_notification"]), replyTo)
if err != nil {
@ -337,11 +592,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -354,12 +605,57 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
return
}
}
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind)
var file, secondary domain.BotAPIFileInput
var ok bool
if kind == "live_photo" {
file, ok = botAPIFileInput(values["photo"], files, "photo", values)
if ok {
secondary, ok = botAPIFileInput(values["live_photo"], files, "live_photo", values)
}
} else {
file, ok = botAPIFileInput(values[kind], files, kind, values)
}
if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
// The official Bot API does not accept HTTP URLs for the video part of a
// live photo or for video notes. Reject them before either the ordinary or
// ephemeral send path can fetch the remote resource.
if (kind == "live_photo" && secondary.RemoteURL != "") || (kind == "video_note" && file.RemoteURL != "") {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if isEphemeral {
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: caption, Entities: entities,
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
return
}
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, caption, entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
@ -381,21 +677,25 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
rawInlineID := strings.TrimSpace(values["inline_message_id"])
var chatID int64
messageID := 0
if rawInlineID == "" {
chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
messageID = apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
return
}
} else if strings.TrimSpace(values["chat_id"]) != "" || strings.TrimSpace(values["message_id"]) != "" {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
return
}
messageID := apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -403,13 +703,27 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
var markup *domain.MessageReplyMarkup
_, setReplyMarkup := values["reply_markup"]
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if rawInlineID != "" {
inlineID, err := decodeBotAPIInlineMessageID(rawInlineID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, ok)
return
}
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
@ -557,17 +871,136 @@ func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) {
}
}
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) {
values, err := requestValues(r)
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
values, files, err := requestValuesWithFiles(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if strings.TrimSpace(values["url"]) == "" {
control, ok := h.gateway.(GatewayWebhookControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
return
}
rawURL := strings.TrimSpace(values["url"])
if rawURL == "" {
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
return
}
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED")
if err := validateWebhookURL(rawURL); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(values["certificate"]) != "" || len(files) != 0 {
writeAPIError(w, http.StatusBadRequest, "CERTIFICATE_PINNING_UNSUPPORTED")
return
}
if strings.TrimSpace(values["ip_address"]) != "" {
writeAPIError(w, http.StatusBadRequest, "IP_ADDRESS_UNSUPPORTED")
return
}
secret := strings.TrimSpace(values["secret_token"])
if !validWebhookSecret(secret) {
writeAPIError(w, http.StatusBadRequest, "SECRET_TOKEN_INVALID")
return
}
maxConnections := apiInt(values["max_connections"], 40)
if maxConnections < 1 || maxConnections > 100 {
writeAPIError(w, http.StatusBadRequest, "MAX_CONNECTIONS_INVALID")
return
}
var allowed []domain.BotAPIUpdateKind
_, allowedUpdatesSet := values["allowed_updates"]
if raw, present := values["allowed_updates"]; present {
allowed, err = parseAllowedUpdates(raw)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if len(allowed) == 0 {
allowed = nil
}
}
if !h.polls.acquire(botID) {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer h.polls.release(botID)
if leases, ok := h.gateway.(GatewayPollLease); ok {
owner := randomBotAPIOwner()
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner)
}()
}
webhookOwner := randomBotAPIOwner()
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if found {
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, webhookOwner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
return
}
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, webhookOwner) }()
}
if err := control.BotAPISetWebhook(r.Context(), domain.BotAPIWebhook{
BotUserID: botID, URL: rawURL, SecretToken: secret,
MaxConnections: maxConnections, AllowedUpdates: allowed, AllowedUpdatesSet: allowedUpdatesSet,
}, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
}
func validateWebhookURL(raw string) error {
if len(raw) > 2048 {
return errors.New("WEBHOOK_URL_INVALID")
}
u, err := neturl.ParseRequestURI(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
}
return nil
}
func validWebhookSecret(secret string) bool {
if secret == "" {
return true
}
if len(secret) > 256 {
return false
}
for _, r := range secret {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {
@ -661,7 +1094,7 @@ func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, bot
}
until = n
}
if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, documentID, until); err != nil {
if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, domain.UserEmojiStatus{DocumentID: documentID, Until: until}); err != nil {
if errors.Is(err, domain.ErrPremiumRequired) {
writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED")
return
@ -911,7 +1344,6 @@ func apiErrorDescription(err error) string {
"BOT_INVALID",
"CHAT_ID_INVALID",
"ENTITY_INVALID",
"ENTITY_PARSE_UNSUPPORTED",
"ENTITIES_TOO_LONG",
"ENTITY_BOUNDS_INVALID",
"ENTITY_TYPE_UNSUPPORTED",
@ -922,6 +1354,9 @@ func apiErrorDescription(err error) string {
"QUERY_ID_INVALID",
"MESSAGE_ID_INVALID",
"MESSAGE_NOT_MODIFIED",
"BOT_COMMAND_INVALID",
"EPHEMERAL_MESSAGE_ID_INVALID",
"EPHEMERAL_ACTION_EXPIRED",
"CHAT_WRITE_FORBIDDEN",
"CHAT_ADMIN_REQUIRED",
"REPLY_MESSAGE_ID_INVALID",

View file

@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -159,6 +160,62 @@ func TestGetMeUsesGateway(t *testing.T) {
}
}
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", `{
"commands": [
{"command":"private","description":"Private reply","is_ephemeral":true},
{"command":"public","description":"Public reply"}
]
}`)
if rec.Code != http.StatusOK || len(bots.commands) != 2 || !bots.commands[0].Ephemeral || bots.commands[1].Ephemeral {
t.Fatalf("setMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands)
}
rec = performBotAPIRequest(t, h, bots.profile, "getMyCommands", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("getMyCommands status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
OK bool `json:"ok"`
Result []struct {
Command string `json:"command"`
Description string `json:"description"`
IsEphemeral bool `json:"is_ephemeral"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if !response.OK || len(response.Result) != 2 || !response.Result[0].IsEphemeral || response.Result[1].IsEphemeral {
t.Fatalf("getMyCommands response=%s", rec.Body.String())
}
rec = performBotAPIRequest(t, h, bots.profile, "deleteMyCommands", `{}`)
if rec.Code != http.StatusOK || len(bots.commands) != 0 {
t.Fatalf("deleteMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands)
}
}
func TestBotCommandsRejectUnsupportedScopeAndLanguage(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots}).routes()
for name, body := range map[string]string{
"scope": `{"scope":{"type":"all_group_chats"},"commands":[]}`,
"language": `{"language_code":"en","commands":[]}`,
} {
t.Run(name, func(t *testing.T) {
rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BOT_COMMAND_SCOPE_UNSUPPORTED") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
@ -261,6 +318,221 @@ func TestGetUpdatesSkipsOutgoingBotMessage(t *testing.T) {
}
}
func TestGetUpdatesProjectsEphemeralMessageWithoutPts(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
message := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 2001, ReceiverUserID: 1001, Date: 1_900_000_000,
Content: domain.EphemeralContent{Message: "/private"},
}
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
Type: domain.UpdateEventNewMessage, BotAPIUpdateID: 901, EphemeralMessage: &message,
Users: []domain.User{{ID: 2001, FirstName: "Alice"}, {ID: 1001, FirstName: "Bot", Bot: true}},
Channels: []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}},
}}}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
OK bool `json:"ok"`
Result []struct {
UpdateID int64 `json:"update_id"`
Message struct {
MessageID int `json:"message_id"`
EphemeralMessageID int `json:"ephemeral_message_id"`
Text string `json:"text"`
ReceiverUser struct {
ID int64 `json:"id"`
} `json:"receiver_user"`
} `json:"message"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if !response.OK || len(response.Result) != 1 || response.Result[0].UpdateID != 901 ||
response.Result[0].Message.MessageID != 0 || response.Result[0].Message.EphemeralMessageID != 77 ||
response.Result[0].Message.ReceiverUser.ID != 1001 || response.Result[0].Message.Text != "/private" {
t.Fatalf("response=%s", rec.Body.String())
}
}
func TestEphemeralReplyProjectionContainsValidOneLevelTarget(t *testing.T) {
target := domain.EphemeralMessage{
ID: 70, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
Content: domain.EphemeralContent{Message: "question"},
}
message := domain.EphemeralMessage{
ID: 71, Peer: target.Peer, SenderUserID: 2001, ReceiverUserID: 1001,
Date: 1_900_000_001, ReplyToEphemeralID: target.ID,
Content: domain.EphemeralContent{Message: "answer"}, BotAPIReply: &target,
}
projected, ok := apiEphemeralMessage(message, []domain.User{{ID: 1001, Bot: true}, {ID: 2001}}, []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}})
if !ok {
t.Fatal("reply was not projectable")
}
reply, ok := projected["reply_to_message"].(map[string]any)
if !ok || reply["message_id"] != 0 || reply["ephemeral_message_id"] != target.ID || reply["date"] != target.Date || reply["text"] != "question" {
t.Fatalf("reply_to_message=%#v", projected["reply_to_message"])
}
}
func TestEphemeralSendMethodsRouteAllOfficialMediaKinds(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
ephemeralMessage: domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
Content: domain.EphemeralContent{Message: "sent"},
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
chatID := int64(-1000000003001)
documentID := encodeBotAPIFileID("doc:7001")
photoID := encodeBotAPIFileID("photo:7002:m")
tests := []struct {
method string
kind string
body map[string]any
}{
{"sendMessage", "message", map[string]any{"text": "hello", "message_thread_id": 42}},
{"sendAnimation", "animation", map[string]any{"animation": documentID}},
{"sendAudio", "audio", map[string]any{"audio": documentID}},
{"sendDocument", "document", map[string]any{"document": documentID}},
{"sendLivePhoto", "live_photo", map[string]any{"photo": photoID, "live_photo": documentID}},
{"sendPhoto", "photo", map[string]any{"photo": photoID}},
{"sendSticker", "sticker", map[string]any{"sticker": documentID}},
{"sendVideo", "video", map[string]any{"video": documentID}},
{"sendVideoNote", "video_note", map[string]any{"video_note": documentID}},
{"sendVoice", "voice", map[string]any{"voice": documentID}},
{"sendContact", "contact", map[string]any{"phone_number": "+100", "first_name": "Alice"}},
{"sendLocation", "location", map[string]any{"latitude": 1.25, "longitude": 2.5}},
{"sendVenue", "location", map[string]any{"latitude": 1.25, "longitude": 2.5, "title": "Place", "address": "Street"}},
}
for _, test := range tests {
t.Run(test.method, func(t *testing.T) {
body := test.body
body["chat_id"] = chatID
body["receiver_user_id"] = int64(2001)
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
got := gateway.ephemeralSends[len(gateway.ephemeralSends)-1]
if got.Kind != test.kind || got.ChatID != chatID || got.ReceiverUserID != 2001 {
t.Fatalf("input=%+v", got)
}
var response struct {
OK bool `json:"ok"`
Result struct {
MessageID int `json:"message_id"`
EphemeralMessageID int `json:"ephemeral_message_id"`
ReceiverUser struct {
ID int64 `json:"id"`
} `json:"receiver_user"`
} `json:"result"`
}
if json.Unmarshal(rec.Body.Bytes(), &response) != nil || !response.OK || response.Result.MessageID != 0 ||
response.Result.EphemeralMessageID != 77 || response.Result.ReceiverUser.ID != 2001 {
t.Fatalf("response=%s", rec.Body.String())
}
})
}
if gateway.ephemeralSends[0].TopMessageID != 42 {
t.Fatalf("message_thread_id=%d", gateway.ephemeralSends[0].TopMessageID)
}
}
func TestEphemeralSendRejectsOfficiallyUnsupportedMediaURLs(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{}
h := (&handler{bots: bots, gateway: gateway}).routes()
photoID := encodeBotAPIFileID("photo:7002:m")
tests := []struct {
method string
body map[string]any
}{
{"sendVideoNote", map[string]any{"video_note": "https://example.com/note.mp4"}},
{"sendLivePhoto", map[string]any{"photo": photoID, "live_photo": "https://example.com/live.mp4"}},
}
for _, test := range tests {
t.Run(test.method, func(t *testing.T) {
test.body["chat_id"] = int64(-1000000003001)
test.body["receiver_user_id"] = int64(2001)
raw, _ := json.Marshal(test.body)
rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "FILE_ID_INVALID") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
if len(gateway.ephemeralSends) != 0 {
t.Fatalf("gateway was called: %+v", gateway.ephemeralSends)
}
}
func TestEphemeralCallbackReplyEditAndDeleteContracts(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
ephemeralMessage: domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
Content: domain.EphemeralContent{Message: "sent"},
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
chatID := int64(-1000000003001)
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"callback_query_id":"991","text":"answer"}`)
if rec.Code != http.StatusOK || len(gateway.ephemeralSends) != 1 || gateway.ephemeralSends[0].CallbackQueryID != 991 {
t.Fatalf("callback send status=%d body=%s inputs=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends)
}
rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"reply_parameters":{"ephemeral_message_id":66},"text":"reply"}`)
if rec.Code != http.StatusOK || gateway.ephemeralSends[1].ReplyToEphemeralID != 66 {
t.Fatalf("reply send status=%d body=%s input=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends[1])
}
photoID := encodeBotAPIFileID("photo:7002:m")
media, _ := json.Marshal(map[string]any{"type": "photo", "media": photoID, "caption": "new"})
edits := []struct {
method string
body map[string]any
}{
{"editEphemeralMessageText", map[string]any{"text": "edited"}},
{"editEphemeralMessageMedia", map[string]any{"media": json.RawMessage(media)}},
{"editEphemeralMessageCaption", map[string]any{"caption": "caption"}},
{"editEphemeralMessageReplyMarkup", map[string]any{"reply_markup": map[string]any{"inline_keyboard": []any{}}}},
}
for _, edit := range edits {
body := edit.body
body["chat_id"], body["receiver_user_id"], body["ephemeral_message_id"] = chatID, int64(2001), 77
raw, _ := json.Marshal(body)
rec = performBotAPIRequest(t, h, bots.profile, edit.method, string(raw))
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", edit.method, rec.Code, rec.Body.String())
}
}
if len(gateway.ephemeralEdits) != 4 || gateway.ephemeralEdits[0].Mode != domain.EphemeralEditText ||
gateway.ephemeralEdits[1].Mode != domain.EphemeralEditMedia || gateway.ephemeralEdits[1].MediaKind != "photo" ||
gateway.ephemeralEdits[2].Mode != domain.EphemeralEditCaption ||
gateway.ephemeralEdits[3].Mode != domain.EphemeralEditReplyMarkup || !gateway.ephemeralEdits[3].Fields.SetReplyMarkup {
t.Fatalf("edits=%+v", gateway.ephemeralEdits)
}
rec = performBotAPIRequest(t, h, bots.profile, "deleteEphemeralMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":77}`)
if rec.Code != http.StatusOK || !gateway.ephemeralDeleteCalled || gateway.ephemeralDeleteMessageID != 77 {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
@ -328,6 +600,312 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
}
}
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
markup := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}, {Type: domain.MarkupButtonText, Text: "Status"}}},
Resize: true,
SingleUse: true,
Persistent: true,
Placeholder: "Choose an action",
}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
sendMessage: domain.Message{
ID: 10, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000003, Body: "pick", Out: true, ReplyMarkup: markup,
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{
"chat_id":2001,
"text":"pick",
"reply_markup":{
"keyboard":[["Help",{"text":"Status"}]],
"resize_keyboard":true,
"one_time_keyboard":true,
"is_persistent":true,
"input_field_placeholder":"Choose an action"
}
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if gateway.sendMarkup == nil || gateway.sendMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
len(gateway.sendMarkup.Keyboard) != 1 || len(gateway.sendMarkup.Keyboard[0]) != 2 ||
gateway.sendMarkup.Keyboard[0][0].Text != "Help" || !gateway.sendMarkup.Resize ||
!gateway.sendMarkup.SingleUse || !gateway.sendMarkup.Persistent || gateway.sendMarkup.Placeholder != "Choose an action" {
t.Fatalf("gateway reply keyboard = %#v", gateway.sendMarkup)
}
var resp struct {
OK bool `json:"ok"`
Result struct {
ReplyMarkup json.RawMessage `json:"reply_markup"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Bot API Message.reply_markup only contains InlineKeyboardMarkup; reply keyboards are
// accepted send parameters but are deliberately absent from the returned Message object.
if !resp.OK || len(resp.Result.ReplyMarkup) != 0 {
t.Fatalf("reply keyboard response = %s", rec.Body.String())
}
}
func TestGetUpdatesProjectsCallbackQuery(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
callback := &domain.BotCallbackQuery{
ID: 123456, BotUserID: 1001, UserID: 2001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, MessageID: 9,
ChatInstance: 9988, Data: []byte("confirm"),
}
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 77, Date: 1700000004,
Peer: callback.Peer, BotCallbackQuery: callback,
Message: domain.Message{
ID: 9, OwnerUserID: 1001, Peer: callback.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Date: 1700000003,
Body: "tap", Out: true,
},
Users: []domain.User{{ID: 1001, FirstName: "Echo", Bot: true}, {ID: 2001, FirstName: "Alice"}},
}}}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"allowed_updates":["callback_query"]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Result []struct {
UpdateID int `json:"update_id"`
CallbackQuery struct {
ID string `json:"id"`
Data string `json:"data"`
ChatInstance string `json:"chat_instance"`
From struct {
ID int64 `json:"id"`
} `json:"from"`
Message struct {
MessageID int `json:"message_id"`
} `json:"message"`
} `json:"callback_query"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || len(resp.Result) != 1 || resp.Result[0].UpdateID != 77 ||
resp.Result[0].CallbackQuery.ID != "123456" || resp.Result[0].CallbackQuery.Data != "confirm" ||
resp.Result[0].CallbackQuery.ChatInstance != "9988" || resp.Result[0].CallbackQuery.From.ID != 2001 ||
resp.Result[0].CallbackQuery.Message.MessageID != 9 {
t.Fatalf("callback update response = %s", rec.Body.String())
}
}
func TestInlineCallbackProjectsOpaqueIDAndEditMessageTextUsesIt(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 998877}
callback := &domain.BotCallbackQuery{
ID: 123456, BotUserID: 1001, UserID: 2001,
ChatInstance: 9988, Data: []byte("inline"), InlineMessage: inline,
}
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 78, Date: 1700000004,
BotCallbackQuery: callback, Users: []domain.User{{ID: 2001, FirstName: "Alice"}},
}}}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("getUpdates status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
Result []struct {
CallbackQuery struct {
InlineMessageID string `json:"inline_message_id"`
Message json.RawMessage `json:"message"`
} `json:"callback_query"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || len(response.Result) != 1 {
t.Fatalf("callback response=%s err=%v", rec.Body.String(), err)
}
inlineToken := response.Result[0].CallbackQuery.InlineMessageID
decoded, err := decodeBotAPIInlineMessageID(inlineToken)
if err != nil || decoded != *inline || len(response.Result[0].CallbackQuery.Message) != 0 {
t.Fatalf("inline token=%q decoded=%#v message=%s err=%v", inlineToken, decoded, response.Result[0].CallbackQuery.Message, err)
}
edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"inline_message_id":"`+inlineToken+`","text":"updated"}`)
if edit.Code != http.StatusOK || !gateway.editInlineCalled || gateway.editInlineID != *inline {
t.Fatalf("edit status=%d body=%s called=%v id=%#v", edit.Code, edit.Body.String(), gateway.editInlineCalled, gateway.editInlineID)
}
}
func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
tests := []struct {
name string
raw string
kind domain.MessageReplyMarkupType
err string
}{
{name: "remove", raw: `{"remove_keyboard":true,"selective":true}`, kind: domain.MessageReplyMarkupHide},
{name: "force", raw: `{"force_reply":true,"input_field_placeholder":"Answer"}`, kind: domain.MessageReplyMarkupForceReply},
{name: "contact", raw: `{"keyboard":[[{"text":"Phone","request_contact":true}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "filtered users", raw: `{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2,"request_name":true}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "filtered chat", raw: `{"keyboard":[[{"text":"Forum","request_chat":{"request_id":8,"chat_is_channel":false,"chat_is_forum":true,"chat_has_username":false,"chat_is_created":true,"bot_is_member":true,"user_administrator_rights":{"can_manage_chat":true,"can_delete_messages":true},"bot_administrator_rights":{"can_manage_chat":true}}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "unsupported legacy user request", raw: `{"keyboard":[[{"text":"User","request_user":{"request_id":1}}]]}`, err: "BUTTON_TYPE_INVALID"},
{name: "multiple constructors", raw: `{"keyboard":[["A"]],"inline_keyboard":[[{"text":"B","callback_data":"b"}]]}`, err: "BUTTON_INVALID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
markup, err := replyMarkupFromAPI(json.RawMessage(tt.raw))
if tt.err != "" {
if err == nil || err.Error() != tt.err {
t.Fatalf("error = %v, want %s", err, tt.err)
}
return
}
if err != nil || markup == nil || markup.Kind() != tt.kind {
t.Fatalf("markup = %#v err=%v, want kind %s", markup, err, tt.kind)
}
})
}
if _, err := inlineReplyMarkupFromAPI(json.RawMessage(`{"keyboard":[["A"]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("inline-only parser error = %v, want BUTTON_INVALID", err)
}
if _, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Bad","url":"https://example.com","callback_data":"x"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("multi-constructor inline button error = %v, want BUTTON_INVALID", err)
}
filtered, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2}}]]}`))
if err != nil || filtered == nil {
t.Fatalf("filtered users markup=%#v err=%v", filtered, err)
}
filter := filtered.Keyboard[0][0].RequestPeerFilter
if filter == nil || !filter.UserIsBotSet || filter.UserIsBot || !filter.UserIsPremiumSet || !filter.UserIsPremium {
t.Fatalf("filtered users = %#v", filter)
}
webApp, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"App","web_app":{"url":"https://example.com"}}]]}`))
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
}
}
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {
reply, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Run","style":"primary","icon_custom_emoji_id":"123"}]]}`))
if err != nil {
t.Fatalf("reply markup: %v", err)
}
button := reply.Keyboard[0][0]
if button.Style != domain.MarkupButtonStylePrimary || button.IconCustomEmojiID != 123 {
t.Fatalf("reply button = %#v", button)
}
inline, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Delete","callback_data":"delete","style":"danger","icon_custom_emoji_id":"456"}]]}`))
if err != nil {
t.Fatalf("inline markup: %v", err)
}
button = inline.Inline[0][0]
if button.Style != domain.MarkupButtonStyleDanger || button.IconCustomEmojiID != 456 {
t.Fatalf("inline button = %#v", button)
}
projected := apiReplyMarkup(inline)
rows := projected["inline_keyboard"].([][]map[string]any)
if rows[0][0]["style"] != "danger" || rows[0][0]["icon_custom_emoji_id"] != "456" {
t.Fatalf("projected inline button = %#v", rows[0][0])
}
if _, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Bad","style":"rainbow"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("invalid style error = %v", err)
}
}
func TestDeleteWebhookDropsPendingAndWebhookInfoReportsCount(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{pendingCount: 7}
h := (&handler{bots: bots, gateway: gateway}).routes()
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"pending_update_count":7`) {
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
}
drop := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{"drop_pending_updates":true}`)
if drop.Code != http.StatusOK || !gateway.dropPending {
t.Fatalf("deleteWebhook status=%d body=%s drop=%v", drop.Code, drop.Body.String(), gateway.dropPending)
}
}
func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{pendingCount: 3}
h := (&handler{bots: bots, gateway: gateway}).routes()
set := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{
"url":"https://bot.example.test/hook",
"secret_token":"safe_secret-1",
"max_connections":12,
"allowed_updates":["message","callback_query"],
"drop_pending_updates":true
}`)
if set.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != "https://bot.example.test/hook" ||
gateway.webhook.SecretToken != "safe_secret-1" || gateway.webhook.MaxConnections != 12 ||
len(gateway.webhook.AllowedUpdates) != 2 || !gateway.webhook.AllowedUpdatesSet || !gateway.webhookDrop {
t.Fatalf("setWebhook status=%d body=%s config=%#v", set.Code, set.Body.String(), gateway.webhook)
}
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"url":"https://bot.example.test/hook"`) ||
!strings.Contains(info.Body.String(), `"max_connections":12`) || !strings.Contains(info.Body.String(), `"pending_update_count":3`) {
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
}
reconfigure := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{"url":"https://bot.example.test/new"}`)
if reconfigure.Code != http.StatusOK || gateway.webhook.AllowedUpdatesSet {
t.Fatalf("omitted allowed_updates status=%d body=%s config=%#v", reconfigure.Code, reconfigure.Body.String(), gateway.webhook)
}
poll := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if poll.Code != http.StatusConflict || !strings.Contains(poll.Body.String(), "webhook is active") {
t.Fatalf("getUpdates status=%d body=%s", poll.Code, poll.Body.String())
}
del := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{}`)
if del.Code != http.StatusOK || !gateway.webhookDeleted || gateway.webhookFound {
t.Fatalf("deleteWebhook status=%d body=%s deleted=%v", del.Code, del.Body.String(), gateway.webhookDeleted)
}
}
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
tests := []struct {
body string
want string
}{
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
}
for _, tt := range tests {
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", tt.body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tt.want) {
t.Fatalf("setWebhook body=%s status=%d response=%s want=%s", tt.body, rec.Code, rec.Body.String(), tt.want)
}
}
}
func TestBotAPIPollRegistryRejectsConcurrentPoller(t *testing.T) {
var polls botAPIPollRegistry
if !polls.acquire(1001) {
t.Fatal("first poller was rejected")
}
if polls.acquire(1001) {
t.Fatal("second poller for same bot was accepted")
}
if !polls.acquire(1002) {
t.Fatal("different bot poller was rejected")
}
polls.release(1001)
if !polls.acquire(1001) {
t.Fatal("poller remained locked after release")
}
}
func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
@ -554,6 +1132,108 @@ func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) {
}
}
func TestAPIMessageProjectsReplyKeyboardResponses(t *testing.T) {
base := domain.Message{
ID: 10, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000010,
}
t.Run("contact", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
PhoneNumber: "+12025550123", FirstName: "Alice", LastName: "Example", Vcard: "VCARD", UserID: 2001,
}}
contact := apiMessage(msg, nil)["contact"].(map[string]any)
if contact["phone_number"] != "+12025550123" || contact["user_id"] != int64(2001) {
t.Fatalf("contact=%#v", contact)
}
})
t.Run("locations", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{Lat: 1.5, Long: 2.5, AccuracyRadius: 7}}
location := apiMessage(msg, nil)["location"].(map[string]any)
if location["latitude"] != 1.5 || location["horizontal_accuracy"] != float64(7) {
t.Fatalf("location=%#v", location)
}
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{
Geo: domain.MessageGeoPoint{Lat: 3.5, Long: 4.5}, Period: 60, Heading: 90, ProximityNotificationRadius: 25,
}}
location = apiMessage(msg, nil)["location"].(map[string]any)
if location["live_period"] != 60 || location["heading"] != 90 || location["proximity_alert_radius"] != 25 {
t.Fatalf("live location=%#v", location)
}
})
t.Run("venue", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
Geo: domain.MessageGeoPoint{Lat: 1, Long: 2}, Title: "Cafe", Address: "Main St",
Provider: "foursquare", VenueID: "place-1", VenueType: "food/cafe",
}}
venue := apiMessage(msg, nil)["venue"].(map[string]any)
if venue["title"] != "Cafe" || venue["foursquare_id"] != "place-1" {
t.Fatalf("venue=%#v", venue)
}
})
t.Run("poll", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{
ID: 77, Question: "Pick", Quiz: true, RevotingDisabled: true,
Answers: []domain.MessagePollAnswer{{Text: "A", Option: []byte{1}}, {Text: "B", Option: []byte{2}}},
Results: &domain.MessagePollResults{TotalVoters: 3, Voters: []domain.MessagePollAnswerVoters{
{Option: []byte{1}, Voters: 1}, {Option: []byte{2}, Voters: 2, Correct: true},
}, Solution: "Because B"},
}}
poll := apiMessage(msg, nil)["poll"].(map[string]any)
options := poll["options"].([]map[string]any)
correct := poll["correct_option_ids"].([]int)
if poll["id"] != "77" || poll["type"] != "quiz" || poll["allows_revoting"] != false ||
len(options) != 2 || options[1]["voter_count"] != 2 || len(correct) != 1 || correct[0] != 1 {
t.Fatalf("poll=%#v", poll)
}
})
t.Run("web_app_data", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionWebViewDataSent,
WebViewData: &domain.MessageWebViewDataAction{ButtonText: "Open", Data: `{"ok":true}`},
}}
data := apiMessage(msg, nil)["web_app_data"].(map[string]any)
if data["button_text"] != "Open" || data["data"] != `{"ok":true}` {
t.Fatalf("web_app_data=%#v", data)
}
})
t.Run("shared_peers", func(t *testing.T) {
msg := base
sharedPhoto := domain.Photo{ID: 9001, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 42, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 3001}},
Details: []domain.MessageRequestedPeerDetails{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3001}, FirstName: "Shared", Username: "shared_user", Photo: &sharedPhoto,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}}
projected := apiMessage(msg, nil)
usersShared := projected["users_shared"].(map[string]any)
sharedUsers := usersShared["users"].([]map[string]any)
if usersShared["request_id"] != 42 || sharedUsers[0]["user_id"] != int64(3001) || sharedUsers[0]["username"] != "shared_user" || len(sharedUsers[0]["photo"].([]map[string]any)) != 1 {
t.Fatalf("users_shared=%#v", usersShared)
}
msg.Media.ServiceAction.RequestedPeer.Peers = []domain.Peer{{Type: domain.PeerTypeChannel, ID: 55}}
msg.Media.ServiceAction.RequestedPeer.Details = []domain.MessageRequestedPeerDetails{{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 55}, Title: "Shared Chat", Username: "shared_chat",
}}
projected = apiMessage(msg, nil)
chatShared := projected["chat_shared"].(map[string]any)
if chatShared["request_id"] != 42 || chatShared["chat_id"] != int64(-1000000000055) || chatShared["title"] != "Shared Chat" {
t.Fatalf("chat_shared=%#v", chatShared)
}
})
}
func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder {
t.Helper()
token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret)
@ -571,13 +1251,23 @@ type apiResponse struct {
}
type fakeBotAPIBots struct {
profile domain.BotProfile
profile domain.BotProfile
commands []domain.BotCommand
}
func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) {
return f.profile, true, nil
}
func (f *fakeBotAPIBots) SetBotCommands(_ context.Context, _ int64, commands []domain.BotCommand) (int, error) {
f.commands = append([]domain.BotCommand(nil), commands...)
return 1, nil
}
func (f *fakeBotAPIBots) GetBotCommands(context.Context, int64) ([]domain.BotCommand, error) {
return append([]domain.BotCommand(nil), f.commands...), nil
}
func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) {
return 0, nil
}
@ -629,31 +1319,51 @@ type fakeBotAPIGateway struct {
updateBotID int64
updateOffset int64
sendCalled bool
sendBotID int64
sendChatID int64
sendText string
sendEntities []domain.MessageEntity
sendMarkup *domain.MessageReplyMarkup
sendNoWebpage bool
sendSilent bool
sendReplyTo int
sendMessage domain.Message
sendMediaCalled bool
sendMediaKind string
sendMediaChatID int64
sendMediaFileName string
sendMediaBytes []byte
sendMediaCaption string
sendMediaMessage domain.Message
editCalled bool
editSetMarkup bool
editMessage domain.Message
deleteCalled bool
callbackCalled bool
callbackID string
fileLocationKey string
fileChunks map[string]domain.FileChunk
sendCalled bool
sendBotID int64
sendChatID int64
sendText string
sendEntities []domain.MessageEntity
sendMarkup *domain.MessageReplyMarkup
sendNoWebpage bool
sendSilent bool
sendReplyTo int
sendMessage domain.Message
sendMediaCalled bool
sendMediaKind string
sendMediaChatID int64
sendMediaFileName string
sendMediaBytes []byte
sendMediaCaption string
sendMediaEntities []domain.MessageEntity
sendMediaMessage domain.Message
editCalled bool
editText string
editEntities []domain.MessageEntity
editSetMarkup bool
editMessage domain.Message
editInlineCalled bool
editInlineID domain.BotInlineMessageID
editInlineText string
editInlineEntities []domain.MessageEntity
deleteCalled bool
callbackCalled bool
callbackID string
fileLocationKey string
fileChunks map[string]domain.FileChunk
allowedUpdates []domain.BotAPIUpdateKind
dropPending bool
pendingCount int
webhook domain.BotAPIWebhook
webhookFound bool
webhookDeleted bool
webhookDrop bool
webhookConfirmed int64
ephemeralMessage domain.EphemeralMessage
ephemeralSends []domain.BotAPIEphemeralSendInput
ephemeralEdits []domain.BotAPIEphemeralEditInput
ephemeralDeleteCalled bool
ephemeralDeleteMessageID int
}
func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) {
@ -666,6 +1376,65 @@ func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset
return append([]domain.UpdateEvent(nil), f.updates...), nil
}
func (f *fakeBotAPIGateway) BotAPISetAllowedUpdates(_ context.Context, _ int64, allowed []domain.BotAPIUpdateKind) error {
f.allowedUpdates = append([]domain.BotAPIUpdateKind(nil), allowed...)
return nil
}
func (f *fakeBotAPIGateway) BotAPIDropPendingUpdates(context.Context, int64) error {
f.dropPending = true
return nil
}
func (f *fakeBotAPIGateway) BotAPIPendingUpdateCount(context.Context, int64) (int, error) {
return f.pendingCount, nil
}
func (f *fakeBotAPIGateway) BotAPISetWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
f.webhook, f.webhookFound, f.webhookDrop = config, true, dropPending
return nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteWebhook(_ context.Context, _ int64, dropPending bool) error {
f.webhook, f.webhookFound, f.webhookDeleted, f.webhookDrop = domain.BotAPIWebhook{}, false, true, dropPending
if dropPending {
f.dropPending = true
}
return nil
}
func (f *fakeBotAPIGateway) BotAPIWebhook(context.Context, int64) (domain.BotAPIWebhook, bool, error) {
return f.webhook, f.webhookFound, nil
}
func (f *fakeBotAPIGateway) ListDueBotAPIWebhooks(context.Context, int) ([]domain.BotAPIWebhook, error) {
if !f.webhookFound {
return nil, nil
}
return []domain.BotAPIWebhook{f.webhook}, nil
}
func (f *fakeBotAPIGateway) AcquireBotAPIWebhookLease(context.Context, int64, string, time.Duration) (bool, error) {
return true, nil
}
func (f *fakeBotAPIGateway) ReleaseBotAPIWebhookLease(context.Context, int64, string) error {
return nil
}
func (f *fakeBotAPIGateway) RecordBotAPIWebhookFailure(context.Context, int64, string, time.Time, string) error {
return nil
}
func (f *fakeBotAPIGateway) RecordBotAPIWebhookSuccess(context.Context, int64, string, time.Time) error {
return nil
}
func (f *fakeBotAPIGateway) ConfirmBotAPIWebhookDelivery(_ context.Context, _ int64, updateID int64) error {
f.webhookConfirmed = updateID
return nil
}
func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) {
f.sendCalled = true
f.sendBotID = botID
@ -686,15 +1455,25 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int
f.sendMediaFileName = fileName
f.sendMediaBytes = append([]byte(nil), fileBytes...)
f.sendMediaCaption = caption
f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...)
return f.sendMediaMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
f.editCalled = true
f.editText = text
f.editEntities = append([]domain.MessageEntity(nil), entities...)
f.editSetMarkup = setReplyMarkup
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
f.editInlineCalled, f.editInlineID = true, inlineMessageID
f.editInlineText = text
f.editInlineEntities = append([]domain.MessageEntity(nil), entities...)
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
f.deleteCalled = true
return true, nil
@ -723,3 +1502,19 @@ func (f *fakeBotAPIGateway) BotAPIGetFile(_ context.Context, _ int64, locationKe
out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...)
return out, true, nil
}
func (f *fakeBotAPIGateway) BotAPISendEphemeral(_ context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) {
f.ephemeralSends = append(f.ephemeralSends, input)
return f.ephemeralMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditEphemeral(_ context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) {
f.ephemeralEdits = append(f.ephemeralEdits, input)
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteEphemeral(_ context.Context, _ int64, _ int64, _ int64, messageID int) (bool, error) {
f.ephemeralDeleteCalled = true
f.ephemeralDeleteMessageID = messageID
return true, nil
}

259
internal/botapi/webhook.go Normal file
View file

@ -0,0 +1,259 @@
package botapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const (
webhookScanInterval = 250 * time.Millisecond
webhookLeaseTTL = 30 * time.Second
webhookIdleDelay = time.Hour
webhookBotWorkers = 16
webhookHTTPWorkers = 64
webhookDueBatch = 64
)
type webhookDispatcher struct {
control GatewayWebhookControl
gateway GatewayService
client *http.Client
logger *zap.Logger
botSem chan struct{}
httpSem chan struct{}
}
func newWebhookHTTPClient() *http.Client {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
// A redirect could leak X-Telegram-Bot-Api-Secret-Token to another host.
return http.ErrUseLastResponse
},
}
}
func runWebhookDispatcher(ctx context.Context, control GatewayWebhookControl, gateway GatewayService, client *http.Client, logger *zap.Logger) {
if control == nil || gateway == nil {
return
}
if client == nil {
client = newWebhookHTTPClient()
}
if logger == nil {
logger = zap.NewNop()
}
d := &webhookDispatcher{
control: control, gateway: gateway, client: client, logger: logger,
botSem: make(chan struct{}, webhookBotWorkers), httpSem: make(chan struct{}, webhookHTTPWorkers),
}
d.scan(ctx)
ticker := time.NewTicker(webhookScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.scan(ctx)
}
}
}
func (d *webhookDispatcher) scan(ctx context.Context) {
configs, err := d.control.ListDueBotAPIWebhooks(ctx, webhookDueBatch)
if err != nil {
d.logger.Warn("list due bot api webhooks", zap.Error(err))
return
}
for _, config := range configs {
select {
case d.botSem <- struct{}{}:
go func(config domain.BotAPIWebhook) {
defer func() { <-d.botSem }()
d.deliver(ctx, config)
}(config)
default:
return
}
}
}
func (d *webhookDispatcher) deliver(parent context.Context, candidate domain.BotAPIWebhook) {
ctx, cancel := context.WithTimeout(parent, webhookLeaseTTL)
defer cancel()
owner := randomBotAPIOwner()
acquired, err := d.control.AcquireBotAPIWebhookLease(ctx, candidate.BotUserID, owner, webhookLeaseTTL)
if err != nil {
d.logger.Warn("acquire bot api webhook lease", zap.Int64("bot_user_id", candidate.BotUserID), zap.Error(err))
return
}
if !acquired {
return
}
released := false
defer func() {
if released {
return
}
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer releaseCancel()
_ = d.control.ReleaseBotAPIWebhookLease(releaseCtx, candidate.BotUserID, owner)
}()
// Re-read after taking the lease so a stale due-list row can never deliver to
// a URL that has since been deleted or replaced.
config, found, err := d.control.BotAPIWebhook(ctx, candidate.BotUserID)
if err != nil || !found {
return
}
events, err := d.gateway.BotAPIUpdates(ctx, config.BotUserID, 0)
if err != nil {
d.fail(ctx, config, owner, fmt.Errorf("load updates: %w", err))
released = true
return
}
if len(events) == 0 {
err = d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, time.Now().Add(webhookIdleDelay))
if err != nil {
d.logger.Warn("idle bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
}
released = err == nil
return
}
limit := config.MaxConnections
if limit <= 0 || limit > 100 {
limit = 40
}
if limit > len(events) {
limit = len(events)
}
type delivery struct {
index int
updateID int64
err error
}
results := make(chan delivery, limit)
for i := 0; i < limit; i++ {
item, _, ok := apiUpdate(events[i])
if !ok {
results <- delivery{index: i, updateID: int64(events[i].Pts), err: errors.New("update projection failed")}
continue
}
payload, err := json.Marshal(item)
if err != nil {
results <- delivery{index: i, updateID: int64(events[i].Pts), err: err}
continue
}
go func(index int, updateID int64, payload []byte) {
select {
case d.httpSem <- struct{}{}:
defer func() { <-d.httpSem }()
case <-ctx.Done():
results <- delivery{index: index, updateID: updateID, err: ctx.Err()}
return
}
results <- delivery{index: index, updateID: updateID, err: d.post(ctx, config, payload)}
}(i, int64(events[i].Pts), payload)
}
deliveries := make([]delivery, limit)
for i := 0; i < limit; i++ {
result := <-results
deliveries[result.index] = result
}
confirmedID := int64(0)
var firstErr error
for _, result := range deliveries {
if result.err != nil {
firstErr = result.err
break
}
confirmedID = result.updateID
}
if confirmedID > 0 {
if err := d.control.ConfirmBotAPIWebhookDelivery(ctx, config.BotUserID, confirmedID); err != nil {
firstErr = fmt.Errorf("confirm update %d: %w", confirmedID, err)
}
}
if firstErr != nil {
d.fail(ctx, config, owner, firstErr)
released = true
return
}
nextAttempt := time.Now()
if limit == len(events) && len(events) < 100 {
nextAttempt = nextAttempt.Add(webhookIdleDelay)
}
if err := d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, nextAttempt); err != nil {
d.logger.Warn("complete bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
return
}
released = true
}
func (d *webhookDispatcher) post(ctx context.Context, config domain.BotAPIWebhook, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if config.SecretToken != "" {
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", config.SecretToken)
}
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
}
return nil
}
func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhook, owner string, cause error) {
exponent := config.FailureCount
if exponent < 0 {
exponent = 0
}
if exponent > 8 {
exponent = 8
}
delay := time.Second * time.Duration(1<<exponent)
if delay > 5*time.Minute {
delay = 5 * time.Minute
}
// Small deterministic jitter prevents synchronized retries without a global RNG lock.
delay += time.Duration(config.BotUserID&255) * time.Millisecond
message := cause.Error()
if err := d.control.RecordBotAPIWebhookFailure(ctx, config.BotUserID, owner, time.Now().Add(delay), message); err != nil {
d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
return
}
d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
}

View file

@ -0,0 +1,127 @@
package botapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
type recordingWebhookGateway struct {
*fakeBotAPIGateway
mu sync.Mutex
failure string
failureNext time.Time
successNext time.Time
recordedOwner string
}
func (g *recordingWebhookGateway) RecordBotAPIWebhookFailure(_ context.Context, _ int64, owner string, next time.Time, message string) error {
g.mu.Lock()
g.recordedOwner, g.failure, g.failureNext = owner, message, next
g.mu.Unlock()
return nil
}
func (g *recordingWebhookGateway) RecordBotAPIWebhookSuccess(_ context.Context, _ int64, owner string, next time.Time) error {
g.mu.Lock()
g.recordedOwner, g.successNext = owner, next
g.mu.Unlock()
return nil
}
func webhookEvents(ids ...int) []domain.UpdateEvent {
out := make([]domain.UpdateEvent, 0, len(ids))
for _, id := range ids {
out = append(out, domain.UpdateEvent{
UserID: 1001, Type: domain.UpdateEventNewMessage, Pts: id, Date: 1700000000 + id,
Message: domain.Message{
ID: id, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000000 + id, Body: "message", Out: false,
},
Users: []domain.User{{ID: 2001, FirstName: "Alice"}},
})
}
return out
}
func TestWebhookDispatcherPostsInParallelWithSecretAndConfirmsContiguousBatch(t *testing.T) {
var mu sync.Mutex
received := make(map[int]bool)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token"); got != "secret_1" {
t.Errorf("secret header = %q", got)
}
var update struct {
UpdateID int `json:"update_id"`
}
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
t.Errorf("decode webhook: %v", err)
}
mu.Lock()
received[update.UpdateID] = true
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
base := &fakeBotAPIGateway{
updates: webhookEvents(11, 12, 13),
webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, SecretToken: "secret_1", MaxConnections: 3},
webhookFound: true,
}
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
d.deliver(context.Background(), base.webhook)
mu.Lock()
count := len(received)
mu.Unlock()
if count != 3 || base.webhookConfirmed != 13 {
t.Fatalf("received=%v confirmed=%d", received, base.webhookConfirmed)
}
gateway.mu.Lock()
successNext, failure := gateway.successNext, gateway.failure
gateway.mu.Unlock()
if !successNext.After(time.Now().Add(30*time.Minute)) || failure != "" {
t.Fatalf("success next=%v failure=%q", successNext, failure)
}
}
func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var update struct {
UpdateID int `json:"update_id"`
}
_ = json.NewDecoder(r.Body).Decode(&update)
if update.UpdateID == 22 {
http.Error(w, "retry", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
base := &fakeBotAPIGateway{
updates: webhookEvents(21, 22, 23),
webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, MaxConnections: 3},
webhookFound: true,
}
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
d.deliver(context.Background(), base.webhook)
gateway.mu.Lock()
failure, retryAt := gateway.failure, gateway.failureNext
gateway.mu.Unlock()
if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) {
t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt)
}
}

View file

@ -0,0 +1,95 @@
// Package branding owns the user-visible telesrv product identity.
//
// Protocol identifiers, client detection tokens and third-party compatibility
// headers do not belong here: callers must only pass text that is rendered to
// an end user.
package branding
import (
"net/url"
"regexp"
"strings"
)
const (
ProductName = "Telesrv"
ProductUsername = "telesrv"
DesktopAppName = "Telesrv Desktop"
AndroidAppName = "Telesrv Android"
IOSAppName = "Telesrv iOS"
MacOSAppName = "Telesrv macOS"
WebAAppName = "Telesrv Web A"
WebKAppName = "Telesrv Web K"
PremiumName = "Telesrv Premium"
StarsName = "Telesrv Stars"
DefaultPublicURL = "https://telesrv.net"
)
// ClientAppName returns the branded display name for a stored client platform.
// Stored detection tokens remain unchanged; this is only used at presentation
// boundaries such as account.getAuthorizations.
func ClientAppName(platform string) string {
switch strings.ToLower(strings.TrimSpace(platform)) {
case "android":
return AndroidAppName
case "ios":
return IOSAppName
case "macos":
return MacOSAppName
case "telegram-tt", "weba":
return WebAAppName
case "tweb", "webk":
return WebKAppName
case "tdesktop", "desktop", "windows":
return DesktopAppName
default:
return ProductName
}
}
// UserVisibleClientPlatform hides internal compatibility tokens from the
// authorization UI without changing their durable representation.
func UserVisibleClientPlatform(platform string) string {
if strings.EqualFold(strings.TrimSpace(platform), "telegram-tt") {
return "weba"
}
return UserVisibleText(platform, "")
}
var (
officialHTTPHostRE = regexp.MustCompile(`(?i)https?://(?:[a-z0-9-]+\.)*(?:telegram\.(?:org|me|com|dog)|t\.me)([^a-z0-9]|$)`)
officialBareHostRE = regexp.MustCompile(`(?i)(?:(?:[a-z0-9-]+\.)*telegram\.(?:org|me|com|dog)|\bt\.me)([^a-z0-9]|$)`)
officialBrandRE = regexp.MustCompile(`(?i)telegram|телеграм[\p{L}]*|تيليجرام|تلگرام|텔레그램|טלגרם`)
technicalIDRE = regexp.MustCompile(`^[A-Za-z0-9-]+(?:[._][A-Za-z0-9-]+)+$`)
)
// UserVisibleText replaces the official product brand and its public hosts in
// text returned to clients. Placeholder syntax, markup and string keys are
// deliberately untouched by callers; only values should pass through here.
func UserVisibleText(value, publicBaseURL string) string {
if value == "" {
return ""
}
baseURL, publicHost := publicDestination(publicBaseURL)
value = officialHTTPHostRE.ReplaceAllString(value, baseURL+"${1}")
value = officialBareHostRE.ReplaceAllString(value, publicHost+"${1}")
// Some platform packs carry dotted or underscored runtime identifiers as
// values. They are not copy and changing them can break client navigation.
if technicalIDRE.MatchString(value) {
return value
}
return officialBrandRE.ReplaceAllString(value, ProductName)
}
func publicDestination(raw string) (string, string) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
raw = DefaultPublicURL
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
raw = DefaultPublicURL
parsed, _ = url.Parse(raw)
}
return raw, parsed.Host
}

View file

@ -0,0 +1,64 @@
package branding
import "testing"
func TestUserVisibleTextRebrandsWordsAndOfficialHosts(t *testing.T) {
got := UserVisibleText(
"Telegram telegram TELEGRAM Telegram-like https://translations.telegram.org/en t.me/example desktop.telegram.org",
"https://chat.example/root/",
)
want := "Telesrv Telesrv Telesrv Telesrv-like https://chat.example/root/en chat.example/example chat.example"
if got != want {
t.Fatalf("UserVisibleText() = %q, want %q", got, want)
}
}
func TestUserVisibleTextPreservesTechnicalIdentifiers(t *testing.T) {
for _, value := range []string{
"org.telegram.messenger",
"telegram_antispam_user_id",
"telegram_aicomposetone",
} {
if got := UserVisibleText(value, ""); got != value {
t.Fatalf("UserVisibleText(%q) = %q, want unchanged", value, got)
}
}
}
func TestUserVisibleTextRebrandsBareOfficialHostsWithoutTouchingDottedIdentifiers(t *testing.T) {
for input, want := range map[string]string{
"telegram.org": "telesrv.net",
"desktop.telegram.org": "telesrv.net",
"t.me/example": "telesrv.net/example",
"org.telegram.messenger": "org.telegram.messenger",
} {
if got := UserVisibleText(input, ""); got != want {
t.Fatalf("UserVisibleText(%q) = %q, want %q", input, got, want)
}
}
}
func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
got := UserVisibleText("Телеграмом تيليجرام تلگرام 텔레그램 טלגרם", "")
if want := "Telesrv Telesrv Telesrv Telesrv Telesrv"; got != want {
t.Fatalf("UserVisibleText() = %q, want %q", got, want)
}
}
func TestClientPresentationNames(t *testing.T) {
for platform, want := range map[string]string{
"tdesktop": DesktopAppName,
"android": AndroidAppName,
"ios": IOSAppName,
"macos": MacOSAppName,
"telegram-tt": WebAAppName,
"tweb": WebKAppName,
} {
if got := ClientAppName(platform); got != want {
t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want)
}
}
if got := UserVisibleClientPlatform("telegram-tt"); got != "weba" {
t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got)
}
}

View file

@ -196,6 +196,11 @@ type Config struct {
WebPagePreviewRatePerMin int
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
LangPackSeedDir string
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
OfficialGiftsDir string
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
StarGiftTONStartingGrant int64
// BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。
BlobDir string
// StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob
@ -336,6 +341,21 @@ type Config struct {
PremiumSweepInterval time.Duration
// PremiumSweepBatch 是单次到期清理的最大行数。
PremiumSweepBatch int
// StarGiftSweepInterval drives offer expiry/refunds, auction rounds and their
// durable notification/delivery outboxes. It is entirely server-local.
StarGiftSweepInterval time.Duration
// StarGiftSweepBatch bounds rows/aggregates claimed by one sweep.
StarGiftSweepBatch int
StarGiftTransferStars int64
StarGiftDropOriginalDetailsStars int64
StarGiftOfferMinStars int
StarGiftStarsProceedsPermille int
StarGiftTONProceedsPermille int
StarGiftExportDelay time.Duration
StarGiftTransferDelay time.Duration
StarGiftResellDelay time.Duration
StarGiftCraftDelay time.Duration
StarGiftCraftChancePermille int
// GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
// 4s 一跳M1 起 SFU liveness reporter 同样刷新该水位)。
@ -529,6 +549,8 @@ func Load() (Config, error) {
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"),
StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000),
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
@ -593,12 +615,24 @@ func Load() (Config, error) {
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
StarGiftSweepInterval: envDurationOr("TELESRV_STARGIFT_SWEEP_INTERVAL", 15*time.Second),
StarGiftSweepBatch: envIntOr("TELESRV_STARGIFT_SWEEP_BATCH", 1000),
StarGiftTransferStars: int64(envIntOr("TELESRV_STARGIFT_TRANSFER_STARS", 25)),
StarGiftDropOriginalDetailsStars: int64(envIntOr("TELESRV_STARGIFT_DROP_DETAILS_STARS", 25)),
StarGiftOfferMinStars: envIntOr("TELESRV_STARGIFT_OFFER_MIN_STARS", 1),
StarGiftStarsProceedsPermille: envIntOr("TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE", 1000),
StarGiftTONProceedsPermille: envIntOr("TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE", 1000),
StarGiftExportDelay: envDurationOr("TELESRV_STARGIFT_EXPORT_DELAY", 0),
StarGiftTransferDelay: envDurationOr("TELESRV_STARGIFT_TRANSFER_DELAY", 0),
StarGiftResellDelay: envDurationOr("TELESRV_STARGIFT_RESELL_DELAY", 0),
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second),
@ -630,9 +664,40 @@ func Load() (Config, error) {
if err := validateRPCResultCacheConfig(cfg); err != nil {
return Config{}, err
}
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func validateStarGiftConfig(cfg Config) error {
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")
}
if cfg.StarGiftTONStartingGrant < 0 {
return fmt.Errorf("TELESRV_STARGIFT_TON_STARTING_GRANT must be non-negative")
}
if cfg.StarGiftTransferStars < 0 || cfg.StarGiftDropOriginalDetailsStars < 0 || cfg.StarGiftOfferMinStars < 0 {
return fmt.Errorf("TELESRV_STARGIFT_TRANSFER_STARS, TELESRV_STARGIFT_DROP_DETAILS_STARS and TELESRV_STARGIFT_OFFER_MIN_STARS must be non-negative")
}
if cfg.StarGiftExportDelay < 0 || cfg.StarGiftTransferDelay < 0 || cfg.StarGiftResellDelay < 0 || cfg.StarGiftCraftDelay < 0 {
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays must be non-negative")
}
const maxProtocolDelay = time.Duration(1<<31-1) * time.Second
if cfg.StarGiftExportDelay > maxProtocolDelay || cfg.StarGiftTransferDelay > maxProtocolDelay ||
cfg.StarGiftResellDelay > maxProtocolDelay || cfg.StarGiftCraftDelay > maxProtocolDelay {
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays exceed the protocol int32 date range")
}
if cfg.StarGiftCraftChancePermille < 0 || cfg.StarGiftCraftChancePermille > 1000 {
return fmt.Errorf("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE must be 0..1000")
}
if cfg.StarGiftStarsProceedsPermille < 0 || cfg.StarGiftStarsProceedsPermille > 1000 ||
cfg.StarGiftTONProceedsPermille < 0 || cfg.StarGiftTONProceedsPermille > 1000 {
return fmt.Errorf("TELESRV_STARGIFT_*_PROCEEDS_PERMILLE must be 0..1000")
}
return nil
}
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error {

View file

@ -553,6 +553,19 @@ func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) {
}
}
func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) {
cfg := Config{
StarGiftSweepInterval: time.Second,
StarGiftSweepBatch: 1,
StarGiftTONStartingGrant: -1,
StarGiftStarsProceedsPermille: 1000,
StarGiftTONProceedsPermille: 1000,
}
if err := validateStarGiftConfig(cfg); err == nil {
t.Fatal("negative internal TON starting grant was accepted")
}
}
func writeConfigFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {

View file

@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
}
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
const DefaultAccountTTLDays = 365
const (
DefaultAccountTTLDays = 365
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
MaxAccountTTLDays = 3650
)
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings {
// NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。
func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 {
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
return DefaultAccountTTLDays
}
return s.AccountTTLDays

View file

@ -0,0 +1,99 @@
package domain
import (
"errors"
"time"
)
var (
ErrAccountDeleted = errors.New("account deleted")
ErrAccountDeletionForbidden = errors.New("account deletion forbidden")
ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid")
ErrAccountDeletionNotPending = errors.New("account deletion not pending")
)
// AccountDeletionSource is the single audited reason attached to a user
// tombstone. Different entry points share one execution and cleanup path.
type AccountDeletionSource string
const (
AccountDeletionManual AccountDeletionSource = "manual"
AccountDeletionForgotPassword AccountDeletionSource = "forgot_password"
AccountDeletionTOSDecline AccountDeletionSource = "tos_decline"
AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry"
AccountDeletionAccountTTL AccountDeletionSource = "account_ttl"
AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry"
)
type AccountDeletionRequestState string
const (
AccountDeletionPending AccountDeletionRequestState = "pending"
AccountDeletionCancelled AccountDeletionRequestState = "cancelled"
AccountDeletionExecuted AccountDeletionRequestState = "executed"
)
// AccountDeletionRequest represents the seven-day 2FA confirmation window.
// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included
// in the durable service message and is never persisted as a credential.
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID [8]byte
State AccountDeletionRequestState
Reason string
ConfirmHashDigest [32]byte
RequestedAt time.Time
ExecuteAt time.Time
CompletedAt time.Time
}
type AccountDeletionSnapshot struct {
User User
HasPassword bool
PasswordUpdatedAt time.Time
Pending *AccountDeletionRequest
}
type ScheduleAccountDeletion struct {
UserID int64
RequesterAuthKeyID [8]byte
Reason string
ConfirmHashDigest [32]byte
ServiceMessage string
RequestedAt time.Time
ExecuteAt time.Time
}
type AccountDeletionResult struct {
User User
Changed bool
RevokedAuthorizations []Authorization
}
type AccountDeleteKind string
const (
AccountDeleteImmediate AccountDeleteKind = "immediate"
AccountDeleteDelayed AccountDeleteKind = "delayed"
)
type AccountDeleteOutcome struct {
Kind AccountDeleteKind
WaitSeconds int
ExecuteAt time.Time
Deletion AccountDeletionResult
}
type AccountDeletionCandidate struct {
UserID int64
Source AccountDeletionSource
DueAt time.Time
}
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Attempts int
}

View file

@ -92,6 +92,7 @@ const (
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
Ephemeral bool `json:"ephemeral,omitempty"`
}
// BotMenuButtonType 标识菜单按钮类型。
@ -204,15 +205,19 @@ type BotAttachMenuState struct {
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
type BotRequestedWebViewButton struct {
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
CreatedAt time.Time
ExpiresAt time.Time
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
PeerFilter *BotRequestPeerFilter
NameRequested bool
UsernameRequested bool
PhotoRequested bool
CreatedAt time.Time
ExpiresAt time.Time
}
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答

View file

@ -1,13 +1,142 @@
package domain
import "time"
// BotAPIUpdateKind is the Bot API delivery shape for a queued update.
type BotAPIUpdateKind string
const (
BotAPIUpdateMessage BotAPIUpdateKind = "message"
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message"
BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query"
)
// BotCallbackQuery is the protocol-neutral payload shared by MTProto
// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection.
type BotCallbackQuery struct {
ID int64
BotUserID int64
UserID int64
Peer Peer
MessageID int
ChatInstance int64
Data []byte
InlineMessage *BotInlineMessageID
}
// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot.
// Ordinary queued messages are reloaded from their durable message tables;
// ephemeral messages have no such table and therefore travel in this explicit
// envelope instead of overloading SourcePts or an ordinary message id. The
// public shape deliberately cannot represent random IDs, payload hashes,
// auth-key/session identifiers, or the originating device.
type BotAPIEphemeralPayload struct {
Message BotAPIEphemeralMessage
ReplyTo *BotAPIEphemeralMessage `json:",omitempty"`
}
type BotAPIEphemeralMessage struct {
ID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
Version uint64
ExpiresAt time.Time
}
func NewBotAPIEphemeralPayload(message EphemeralMessage) *BotAPIEphemeralPayload {
payload := &BotAPIEphemeralPayload{Message: publicBotAPIEphemeralMessage(message)}
if message.BotAPIReply != nil {
reply := publicBotAPIEphemeralMessage(*message.BotAPIReply)
payload.ReplyTo = &reply
}
return payload
}
func publicBotAPIEphemeralMessage(message EphemeralMessage) BotAPIEphemeralMessage {
return BotAPIEphemeralMessage{
ID: message.ID, Peer: message.Peer,
SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID,
Date: message.Date, EditDate: message.EditDate,
TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID,
Content: message.Content, Version: message.Version, ExpiresAt: message.ExpiresAt,
}
}
func (m BotAPIEphemeralMessage) EphemeralMessage() EphemeralMessage {
return EphemeralMessage{
ID: m.ID, Peer: m.Peer,
SenderUserID: m.SenderUserID, ReceiverUserID: m.ReceiverUserID,
Date: m.Date, EditDate: m.EditDate,
TopMessageID: m.TopMessageID, ReplyToEphemeralID: m.ReplyToEphemeralID,
Content: m.Content, Version: m.Version, ExpiresAt: m.ExpiresAt,
}
}
func (p BotAPIEphemeralPayload) EphemeralMessage() EphemeralMessage {
message := p.Message.EphemeralMessage()
if p.ReplyTo != nil {
reply := p.ReplyTo.EphemeralMessage()
message.BotAPIReply = &reply
}
return message
}
func (p BotAPIEphemeralPayload) Validate() error {
if err := p.Message.Validate(); err != nil {
return err
}
if p.Message.ReplyToEphemeralID == 0 {
if p.ReplyTo != nil {
return ErrEphemeralInvalid
}
return nil
}
if p.ReplyTo == nil || p.ReplyTo.Validate() != nil || p.ReplyTo.ID != p.Message.ReplyToEphemeralID ||
p.ReplyTo.Peer != p.Message.Peer || p.ReplyTo.Date > p.Message.Date ||
!sameEphemeralParticipantPair(p.Message.SenderUserID, p.Message.ReceiverUserID, p.ReplyTo.SenderUserID, p.ReplyTo.ReceiverUserID) {
return ErrEphemeralInvalid
}
return nil
}
func sameEphemeralParticipantPair(firstSender, firstReceiver, secondSender, secondReceiver int64) bool {
return (firstSender == secondSender && firstReceiver == secondReceiver) ||
(firstSender == secondReceiver && firstReceiver == secondSender)
}
func (m BotAPIEphemeralMessage) Expired(now time.Time) bool {
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
}
func (m BotAPIEphemeralMessage) Validate() error {
date := time.Unix(int64(m.Date), 0)
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID ||
m.Date <= 0 || m.Version == 0 || m.ExpiresAt.IsZero() || !m.ExpiresAt.After(date) ||
m.ExpiresAt.Sub(date) > EphemeralMessageRetention+time.Second ||
(m.EditDate != 0 && m.EditDate < m.Date) || m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID {
return ErrEphemeralInvalid
}
return ValidateEphemeralContent(m.Content)
}
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
// It can be projected both to MTProto and to Bot API's opaque
// inline_message_id without leaking tg types into the store boundary.
type BotInlineMessageID struct {
DCID int
OwnerID int64
ID int
AccessHash int64
}
// BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id
// and is global across all bots, matching Telegram Bot API's monotonic offset
// contract without reusing MTProto pts from user/channel logs.
@ -19,6 +148,8 @@ type BotAPIUpdate struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
}
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
@ -30,4 +161,6 @@ type EnqueueBotAPIUpdateRequest struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
}

View file

@ -0,0 +1,41 @@
package domain
import (
"bytes"
"encoding/json"
"testing"
"time"
)
func TestBotAPIEphemeralPayloadCannotSerializePrivateRoutingState(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
reply := EphemeralMessage{
ID: 16, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 3001, ReceiverUserID: 2001, Date: int(now.Unix()) - 1,
Content: EphemeralContent{Message: "prompt"}, Version: 1, ExpiresAt: now.Add(EphemeralMessageRetention),
}
payload := NewBotAPIEphemeralPayload(EphemeralMessage{
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()),
RandomID: 99, ReplyToEphemeralID: reply.ID, Content: EphemeralContent{Message: "private"},
OriginDevice: EphemeralDevice{UserID: 2001, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
PayloadHash: [32]byte{5, 6, 7}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), BotAPIReply: &reply,
})
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
for _, privateField := range [][]byte{
[]byte("RandomID"), []byte("OriginDevice"), []byte("BusinessAuthKeyID"),
[]byte("SessionID"), []byte("PayloadHash"), []byte("CreatedAt"),
} {
if bytes.Contains(raw, privateField) {
t.Fatalf("durable Bot API envelope leaked %s: %s", privateField, raw)
}
}
if payload.Validate() != nil || payload.Message.ID != 17 || payload.Message.Content.Message != "private" || payload.Message.ExpiresAt.IsZero() ||
payload.ReplyTo == nil || payload.ReplyTo.ID != reply.ID {
t.Fatalf("public payload=%+v", payload)
}
}

View file

@ -0,0 +1,21 @@
package domain
import "time"
// BotAPIWebhook is durable delivery configuration and observable retry state.
// The token secret is never stored here: authentication remains owned by BotProfile.
type BotAPIWebhook struct {
BotUserID int64
URL string
SecretToken string
MaxConnections int
AllowedUpdates []BotAPIUpdateKind
// AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty)
// setWebhook parameter from omission, which must preserve the previous
// getUpdates/setWebhook policy atomically at the store boundary.
AllowedUpdatesSet bool
FailureCount int
LastErrorDate int
LastErrorMessage string
NextAttemptAt time.Time
}

View file

@ -0,0 +1,20 @@
package domain
import (
"strings"
"testing"
)
func TestServiceIdentityAndLoginMessageUseTelesrvBrand(t *testing.T) {
serviceUser := OfficialSystemUser()
if serviceUser.FirstName != "Telesrv" || serviceUser.Username != "telesrv" {
t.Fatalf("service user = %+v, want Telesrv identity", serviceUser)
}
message, err := OfficialLoginCodeMessage(42, "12345", 1)
if err != nil {
t.Fatalf("build login message: %v", err)
}
if !strings.Contains(message.Body, "Telesrv") || strings.Contains(strings.ToLower(message.Body), "telegram") {
t.Fatalf("login message exposes wrong brand: %q", message.Body)
}
}

View file

@ -188,20 +188,23 @@ const (
// ChannelAdminRights is a domain-only representation of Telegram admin rights.
type ChannelAdminRights struct {
ChangeInfo bool
PostMessages bool
EditMessages bool
DeleteMessages bool
PostStories bool
EditStories bool
DeleteStories bool
BanUsers bool
InviteUsers bool
PinMessages bool
AddAdmins bool
ManageCall bool
Anonymous bool
ManageRanks bool
ChangeInfo bool
PostMessages bool
EditMessages bool
DeleteMessages bool
PostStories bool
EditStories bool
DeleteStories bool
BanUsers bool
InviteUsers bool
PinMessages bool
AddAdmins bool
ManageCall bool
ManageChat bool
ManageTopics bool
Anonymous bool
ManageRanks bool
ManageLinkedPeers bool
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
ManageDirectMessages bool
@ -210,19 +213,22 @@ type ChannelAdminRights struct {
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
func CreatorChannelAdminRights() ChannelAdminRights {
return ChannelAdminRights{
ChangeInfo: true,
PostMessages: true,
EditMessages: true,
DeleteMessages: true,
PostStories: true,
EditStories: true,
DeleteStories: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
AddAdmins: true,
ManageCall: true,
ManageRanks: true,
ChangeInfo: true,
PostMessages: true,
EditMessages: true,
DeleteMessages: true,
PostStories: true,
EditStories: true,
DeleteStories: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
AddAdmins: true,
ManageCall: true,
ManageChat: true,
ManageTopics: true,
ManageRanks: true,
ManageLinkedPeers: true,
}
}
@ -274,7 +280,10 @@ type ChannelBannedRights struct {
SendPlain bool
EditRank bool
SendReactions bool
UntilDate int
// ManageLinkedPeers is the Layer 228 default restriction used by Communities:
// true means only admins may add peers; false lets members submit requests.
ManageLinkedPeers bool
UntilDate int
}
// ChannelReactionPolicyType describes which reactions are allowed in a channel.
@ -425,6 +434,10 @@ type Channel struct {
// megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。
HasLink bool
LinkedChatID int64
// LinkedCommunityID is the unique Community containing this group/channel.
// A channel can belong to at most one Community and Communities themselves
// are stored in a separate aggregate, never in channels.
LinkedCommunityID int64
// Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。
// LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。
Monoforum bool
@ -550,13 +563,20 @@ const (
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUniquecollectible
// 升级、转赠等所有权变更只进入 Recent Actions不伪造频道历史/pts。
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
// CommunityID means linked; zero means unlinked.
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
)
// ChannelMessageAction describes a service action without depending on tg.*.
type ChannelMessageAction struct {
Type ChannelMessageActionType
CommunityID int64
Title string
IconColor int
IconEmojiID int64
@ -583,7 +603,8 @@ type ChannelMessageAction struct {
Incompleted []int
TodoItems []MessageTodoItem
// StarGift 仅 star_gift 服务消息使用。
StarGift *MessageStarGiftAction
StarGift *MessageStarGiftAction
StarGiftUnique *MessageStarGiftUniqueAction
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
Wallpaper *Wallpaper
// Photo 仅 chat_edit_photo 服务消息使用。
@ -599,17 +620,21 @@ type ChannelMessage struct {
From Peer
SendAs *Peer
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
SavedPeer Peer
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
SavedPeer Peer
// SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。
SuggestedPost *SuggestedPost
// PaidMessageStars 是本条频道私信实际扣除的 Stars管理员免费回复及普通频道消息为 0。
PaidMessageStars int64
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
// GroupedID 相册分组 idsendMultiMedia 同组共享非零值,非相册恒 0
GroupedID int64
ReplyMarkup *MessageReplyMarkup
@ -1455,7 +1480,15 @@ type SendMonoforumMessageRequest struct {
IdempotencyPreflighted bool
Message string
Entities []MessageEntity
Date int
Media *MessageMedia
ReplyTo *MessageReply
Silent bool
NoForwards bool
SuggestedPost *SuggestedPost
// AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。
AllowPaidStars int64
ClearDraft bool
Date int
}
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
@ -1575,6 +1608,8 @@ type SendChannelMessageResult struct {
Event ChannelUpdateEvent
Recipients []int64
Duplicate bool
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回RPC 只向发件人投影余额更新。
SenderStarsBalance *StarsBalance
// ReplayDeleteEvent is the existing durable channel delete event paired
// with a deleted exact-random_id replay. It must be returned only to the
// caller echo and must never be fanned out as a fresh event.
@ -2015,18 +2050,24 @@ type ChannelSearchPostsRequest struct {
// ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page
// over channel/supergroup messages visible to the current account.
type ChannelGlobalSearchRequest struct {
Query string
BroadcastsOnly bool
GroupsOnly bool
MusicOnly bool
HasFolderID bool
FolderID int
OffsetRate int
OffsetChannelID int64
OffsetID int
MinDate int
MaxDate int
Limit int
Query string
ChannelIDs []int64
RestrictChannelIDs bool
// AllowPublicPreview includes linked public channels that the account can
// preview without joining. It is enabled only by Layer 228 Community-scoped
// search; ordinary global search remains joined-dialog-only.
AllowPublicPreview bool
BroadcastsOnly bool
GroupsOnly bool
MusicOnly bool
HasFolderID bool
FolderID int
OffsetRate int
OffsetChannelID int64
OffsetID int
MinDate int
MaxDate int
Limit int
}
// ChannelRepliesFilter describes messages.getReplies query conditions.

View file

@ -6,37 +6,38 @@ import (
)
var (
ErrChannelInvalid = errors.New("channel invalid")
ErrChannelPrivate = errors.New("channel private")
ErrChannelTitleInvalid = errors.New("channel title invalid")
ErrChannelUserBanned = errors.New("user banned in channel")
ErrChannelWriteForbidden = errors.New("chat write forbidden")
ErrChannelAdminRequired = errors.New("chat admin required")
ErrChannelNotModified = errors.New("chat not modified")
ErrChannelForumMissing = errors.New("channel forum missing")
ErrLinkNotModified = errors.New("discussion link not modified")
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
ErrChatPublicRequired = errors.New("chat public required")
ErrChannelUserCreator = errors.New("channel user creator")
ErrChannelRightForbidden = errors.New("channel right forbidden")
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
ErrInviteHashEmpty = errors.New("invite hash empty")
ErrInviteHashInvalid = errors.New("invite hash invalid")
ErrInviteHashExpired = errors.New("invite hash expired")
ErrInvitePermanent = errors.New("chat invite permanent")
ErrInviteRevokedMissing = errors.New("invite revoked missing")
ErrInviteRequestSent = errors.New("invite request sent")
ErrHideRequesterMissing = errors.New("hide requester missing")
ErrUsersTooMuch = errors.New("users too much")
ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserKicked = errors.New("user kicked")
ErrUserNotParticipant = errors.New("user not participant")
ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
ErrChannelInvalid = errors.New("channel invalid")
ErrChannelPrivate = errors.New("channel private")
ErrChannelTitleInvalid = errors.New("channel title invalid")
ErrChannelUserBanned = errors.New("user banned in channel")
ErrChannelWriteForbidden = errors.New("chat write forbidden")
ErrChannelAdminRequired = errors.New("chat admin required")
ErrChannelNotModified = errors.New("chat not modified")
ErrChannelForumMissing = errors.New("channel forum missing")
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
ErrLinkNotModified = errors.New("discussion link not modified")
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
ErrChatPublicRequired = errors.New("chat public required")
ErrChannelUserCreator = errors.New("channel user creator")
ErrChannelRightForbidden = errors.New("channel right forbidden")
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
ErrInviteHashEmpty = errors.New("invite hash empty")
ErrInviteHashInvalid = errors.New("invite hash invalid")
ErrInviteHashExpired = errors.New("invite hash expired")
ErrInvitePermanent = errors.New("chat invite permanent")
ErrInviteRevokedMissing = errors.New("invite revoked missing")
ErrInviteRequestSent = errors.New("invite request sent")
ErrHideRequesterMissing = errors.New("hide requester missing")
ErrUsersTooMuch = errors.New("users too much")
ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserKicked = errors.New("user kicked")
ErrUserNotParticipant = errors.New("user not participant")
ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
)
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.

View file

@ -0,0 +1,214 @@
package domain
import "errors"
const (
MaxCommunityPeers = 100
MaxCommunityBotPeers = 100
MaxCommunityLinkRequests = 100
MaxCommunityTitleRunes = 128
MaxCommunityAboutRunes = 255
MaxCommunityParticipants = 200
)
var (
ErrCommunityInvalid = errors.New("community invalid")
ErrCommunityPrivate = errors.New("community private")
ErrCommunityAdminRequired = errors.New("community admin required")
ErrCommunityCreatorRequired = errors.New("community creator required")
ErrCommunityPeerInvalid = errors.New("community peer invalid")
ErrCommunityPeerLinked = errors.New("community peer already linked")
ErrCommunityPeersTooMuch = errors.New("community peers too much")
ErrCommunityRequestCreated = errors.New("community request created")
ErrCommunityRequestMissing = errors.New("community request missing")
ErrCommunityParticipantInvalid = errors.New("community participant invalid")
)
// Community is the Layer 228 aggregation container. It intentionally has no
// message/read/pts fields: linked dialogs remain the only message truth.
type Community struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Date int
Deleted bool
DefaultBannedRights ChannelBannedRights
PhotoID int64
PhotoDCID int
PhotoStripped []byte
}
type CommunityMemberRole string
const (
CommunityRoleCreator CommunityMemberRole = "creator"
CommunityRoleAdmin CommunityMemberRole = "admin"
CommunityRoleMember CommunityMemberRole = "member"
)
type CommunityMemberStatus string
const (
CommunityMemberActive CommunityMemberStatus = "active"
CommunityMemberKicked CommunityMemberStatus = "kicked"
)
type CommunityMember struct {
CommunityID int64
UserID int64
Role CommunityMemberRole
Status CommunityMemberStatus
AdminRights ChannelAdminRights
Rank string
Date int
}
func (m CommunityMember) Active() bool { return m.Status == CommunityMemberActive }
func (m CommunityMember) CanManageLinkedPeers() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.ManageLinkedPeers))
}
func (m CommunityMember) CanChangeInfo() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.ChangeInfo))
}
func (m CommunityMember) CanAddAdmins() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.AddAdmins))
}
func (m CommunityMember) CanBanUsers() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.BanUsers))
}
type CommunityPeerVisibility string
const (
CommunityPeerVisible CommunityPeerVisibility = "visible"
CommunityPeerHidden CommunityPeerVisibility = "hidden"
)
type CommunityPeerLink struct {
CommunityID int64
Peer Peer
Visibility CommunityPeerVisibility
CanViewHistory bool
CreatedBy int64
Date int
}
func (l CommunityPeerLink) Visible() bool { return l.Visibility == CommunityPeerVisible }
type CommunityPeerLinkRequest struct {
CommunityID int64
Peer Peer
RequestedBy int64
Visibility CommunityPeerVisibility
Date int
}
type CommunityUserState struct {
CommunityID int64
UserID int64
Collapsed bool
Pinned bool
PinnedOrder int
NotifySettings *PeerNotifySettings
}
type CommunityView struct {
Community Community
Self CommunityMember
State CommunityUserState
Links []CommunityPeerLink
Channels []Channel
Users []User
ServiceMessages []SendChannelMessageResult
AdminsCount int
KickedCount int
PendingRequests int
Forbidden bool
}
func (v CommunityView) Creator() bool {
return v.Self.Active() && v.Self.Role == CommunityRoleCreator
}
type CreateCommunityRequest struct {
CreatorUserID int64
Title string
About string
InitialPeer Peer
Visibility CommunityPeerVisibility
Date int
}
type CommunityTogglePeerLinkRequest struct {
ActorUserID int64
CommunityID int64
Peer Peer
Visibility CommunityPeerVisibility
Deleted bool
RequestOnly bool
Date int
}
type CommunityTogglePeerLinkResult struct {
Community Community
Peer Peer
RequestedBy int64
Link *CommunityPeerLink
ServiceMessage *SendChannelMessageResult
Removed bool
RequestCreated bool
}
type CommunityPeerLinkRequestPage struct {
TotalCount int
Requests []CommunityPeerLinkRequest
NextOffset string
Channels []Channel
Users []User
}
type CommunityParticipantJoinedChats struct {
CreatorChatIDs []int64
JoinedChatIDs []int64
Channels []Channel
Users []User
}
type CommunityParticipantList struct {
Community Community
Count int
Participants []CommunityMember
Users []User
Hash int64
}
type CommunityParticipantBanResult struct {
Changed bool
ChannelBans []EditChannelBannedResult
RemovedLinks []CommunityTogglePeerLinkResult
}
type CommunityEditAdminRequest struct {
ActorUserID int64
CommunityID int64
UserID int64
Rights ChannelAdminRights
Rank string
Date int
}
type CommunitySearchScope struct {
CommunityID int64
ChannelIDs []int64
BotUserIDs []int64
}

View file

@ -6,6 +6,9 @@ type PeerType string
const (
PeerTypeUser PeerType = "user"
PeerTypeChannel PeerType = "channel"
// PeerTypeCommunity identifies a Layer 228 Community container. Communities
// have dialog pin/notify state but never own messages, read boundaries or pts.
PeerTypeCommunity PeerType = "community"
// PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder
// archive folder 行本身被置顶/取消置顶ID 为 folder_id。
PeerTypeFolder PeerType = "folder"
@ -101,19 +104,42 @@ type DialogDraftWebPage struct {
Optional bool
}
type SuggestedPostPriceKind string
const (
SuggestedPostPriceStars SuggestedPostPriceKind = "stars"
SuggestedPostPriceTON SuggestedPostPriceKind = "ton"
)
// SuggestedPostPrice is either a decimal Stars amount or a nanotons amount.
type SuggestedPostPrice struct {
Kind SuggestedPostPriceKind
Amount int64
Nanos int
}
// SuggestedPost is the domain-only snapshot shared by a monoforum message and its cloud draft.
type SuggestedPost struct {
Accepted bool
Rejected bool
Price *SuggestedPostPrice
ScheduleDate int
}
// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types.
type DialogDraft struct {
Peer Peer
TopMessageID int
Date int
NoWebpage bool
InvertMedia bool
Message string
Entities []MessageEntity
ReplyTo *MessageReply
WebPage *DialogDraftWebPage
Effect int64
RichMessage *MessageRichMessage
Peer Peer
TopMessageID int
Date int
NoWebpage bool
InvertMedia bool
Message string
Entities []MessageEntity
ReplyTo *MessageReply
WebPage *DialogDraftWebPage
Effect int64
SuggestedPost *SuggestedPost
RichMessage *MessageRichMessage
}
// Empty reports whether this draft should clear the cloud draft slot.
@ -126,6 +152,7 @@ func (d DialogDraft) Empty() bool {
(d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil &&
d.Effect == 0 &&
d.SuggestedPost == nil &&
d.RichMessage.IsZero()
}
@ -151,6 +178,7 @@ type DialogList struct {
ChannelMessages []ChannelMessage
Users []User
Channels []Channel
Communities []CommunityView
State UpdateState
Hash int64
Count int

View file

@ -0,0 +1,362 @@
package domain
import (
"crypto/sha256"
"errors"
"time"
"unicode/utf8"
)
const (
// EphemeralMessageRetention matches TDesktop's in-memory upper bound. The
// server never replays these records; the retention only keeps callback,
// edit, delete and abuse-report lookups coherent across instances.
EphemeralMessageRetention = 48 * time.Hour
// EphemeralReplyWindow is the official Bot API eligible-action window.
EphemeralReplyWindow = 15 * time.Second
// MaxEphemeralCreateAttempts bounds random int32 ID collision retries.
MaxEphemeralCreateAttempts = 8
// MaxEphemeralCallbackDataBytes is the Bot API callback_data wire limit.
MaxEphemeralCallbackDataBytes = 64
// MaxEphemeralCaptionLength follows the Bot API media-caption contract.
MaxEphemeralCaptionLength = 1024
// Rich messages are accepted at the domain boundary only within a bounded
// wire-sized snapshot. The current official client does not send this flag,
// but malformed callers must not be able to retain unbounded block vectors.
MaxEphemeralRichBlocksBytes = 1 << 20
MaxEphemeralRichMediaRefs = 100
)
var (
ErrEphemeralInvalid = errors.New("ephemeral message invalid")
ErrEphemeralNotFound = errors.New("ephemeral message not found")
ErrEphemeralExpired = errors.New("ephemeral message expired")
ErrEphemeralDeleted = errors.New("ephemeral message deleted")
ErrEphemeralIDCollision = errors.New("ephemeral message id collision")
ErrEphemeralRandomIDConflict = errors.New("ephemeral random id conflict")
ErrEphemeralVersionConflict = errors.New("ephemeral message version conflict")
ErrEphemeralReplyExpired = errors.New("ephemeral reply expired")
ErrEphemeralQueryInvalid = errors.New("ephemeral query invalid")
ErrEphemeralPeerInvalid = errors.New("ephemeral peer invalid")
ErrEphemeralSenderInvalid = errors.New("ephemeral sender invalid")
ErrEphemeralReceiverInvalid = errors.New("ephemeral receiver invalid")
ErrEphemeralCommandInvalid = errors.New("ephemeral command invalid")
ErrEphemeralForbidden = errors.New("ephemeral action forbidden")
ErrEphemeralDeviceMismatch = errors.New("ephemeral device mismatch")
ErrEphemeralCallbackInvalid = errors.New("ephemeral callback invalid")
)
// EphemeralDevice identifies the exact client application that originated an
// eligible action. BusinessAuthKeyID is the durable device identity; SessionID
// is retained for binding checks and diagnostics, not used as a global key.
type EphemeralDevice struct {
UserID int64
BusinessAuthKeyID [8]byte
SessionID int64
}
// EphemeralContent is the mutable presentation payload. Identity, routing and
// reply ancestry live on EphemeralMessage and never change during edits.
type EphemeralContent struct {
Message string
Entities []MessageEntity
Media *MessageMedia
ReplyMarkup *MessageReplyMarkup
RichMessage *MessageRichMessage
}
// EphemeralMessage is a short-lived bot/member interaction. It deliberately
// has no ordinary message box ID, pts, qts, seq, unread or dialog fields.
type EphemeralMessage struct {
ID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
OriginDevice EphemeralDevice
PayloadHash [32]byte
Version uint64
Deleted bool
CreatedAt time.Time
ExpiresAt time.Time
// BotAPIReply is a one-level, runtime-only reply snapshot. It is attached
// after the authoritative message has been written, excluded from Redis and
// broker JSON, and used only to project a valid Bot API reply_to_message.
BotAPIReply *EphemeralMessage `json:"-"`
}
type SendClientEphemeralRequest struct {
SenderUserID int64
ReceiverBotID int64
Peer Peer
QueryID int64
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
OriginDevice EphemeralDevice
}
type SendBotEphemeralRequest struct {
BotUserID int64
ReceiverUserID int64
Peer Peer
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
// ActionMessageID authorizes the ordinary 15-second response path. When it
// is zero the bot must be an administrator and delivery targets every ready
// Layer 228 device of ReceiverUserID.
ActionMessageID int
// CallbackQueryID authorizes a response to a callback originating from a
// bot→user ephemeral message. The shared action record owns the target device.
CallbackQueryID int64
}
type EphemeralCallback struct {
Message EphemeralMessage
BotUserID int64
UserID int64
Peer Peer
Data []byte
Device EphemeralDevice
OccurredAt time.Time
}
type EphemeralCallbackAction struct {
QueryID int64
BotUserID int64
UserID int64
Peer Peer
MessageID int
TopMessageID int
Device EphemeralDevice
CreatedAt time.Time
ExpiresAt time.Time
}
// EphemeralReportEvidence is the durable, device-identity-free snapshot kept
// for abuse review after the transient Redis record expires. It intentionally
// excludes OriginDevice, random IDs and session/auth-key identifiers.
type EphemeralReportEvidence struct {
MessageID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
PayloadHash [32]byte
Version uint64
}
// EphemeralAbuseReport is written only for a final report option. CommentHash
// makes retries idempotent without indexing potentially large user text.
type EphemeralAbuseReport struct {
ReporterUserID int64
Option string
Comment string
CommentHash [32]byte
Evidence EphemeralReportEvidence
CreatedAt time.Time
}
func NewEphemeralAbuseReport(reporterUserID int64, option, comment string, message EphemeralMessage, createdAt time.Time) EphemeralAbuseReport {
return EphemeralAbuseReport{
ReporterUserID: reporterUserID,
Option: option,
Comment: comment,
CommentHash: sha256.Sum256([]byte(comment)),
Evidence: EphemeralReportEvidence{
MessageID: message.ID, Peer: message.Peer,
SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID,
Date: message.Date, EditDate: message.EditDate,
TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID,
Content: message.Content, PayloadHash: message.PayloadHash, Version: message.Version,
},
CreatedAt: createdAt,
}
}
func (r EphemeralAbuseReport) Validate() error {
if r.ReporterUserID <= 0 || r.Option == "" || len(r.Option) > 64 || utf8.RuneCountInString(r.Comment) > 4096 ||
r.Evidence.MessageID <= 0 || r.Evidence.MessageID > MaxMessageBoxID ||
r.Evidence.Peer.Type != PeerTypeChannel || r.Evidence.Peer.ID <= 0 ||
r.Evidence.SenderUserID <= 0 || r.Evidence.ReceiverUserID != r.ReporterUserID ||
r.Evidence.SenderUserID == r.Evidence.ReceiverUserID || r.CreatedAt.IsZero() ||
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
return ErrEphemeralInvalid
}
return nil
}
type EditEphemeralFields struct {
SetMessage bool
Message string
Entities []MessageEntity
SetMedia bool
Media *MessageMedia
SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup
}
type BotAPIFileInput struct {
LocationKey string
RemoteURL string
FileName string
MimeType string
Bytes []byte
Width int
Height int
Duration int
Title string
Performer string
Emoji string
}
type BotAPIEphemeralSendInput struct {
BotUserID int64
ChatID int64
ReceiverUserID int64
CallbackQueryID int64
ReplyToEphemeralID int
TopMessageID int
Kind string
Text string
Entities []MessageEntity
ReplyMarkup *MessageReplyMarkup
File BotAPIFileInput
SecondaryFile BotAPIFileInput
DirectMedia *MessageMedia
}
type BotAPIEphemeralEditInput struct {
BotUserID int64
ChatID int64
ReceiverUserID int64
MessageID int
Mode EphemeralEditMode
Fields EditEphemeralFields
MediaKind string
File BotAPIFileInput
SecondaryFile BotAPIFileInput
}
type EphemeralEditMode string
const (
EphemeralEditText EphemeralEditMode = "text"
EphemeralEditMedia EphemeralEditMode = "media"
EphemeralEditCaption EphemeralEditMode = "caption"
EphemeralEditReplyMarkup EphemeralEditMode = "reply_markup"
)
func (m EphemeralMessage) ValidateForCreate(now time.Time) error {
if err := m.ValidateStored(); err != nil || m.Version != 1 || m.Deleted || !m.ExpiresAt.After(now) {
return ErrEphemeralInvalid
}
return nil
}
func (m EphemeralMessage) ValidateStored() error {
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID ||
m.RandomID == 0 || m.Date <= 0 || m.Version == 0 || m.CreatedAt.IsZero() || m.ExpiresAt.IsZero() ||
!m.ExpiresAt.After(m.CreatedAt) || m.ExpiresAt.Sub(m.CreatedAt) > EphemeralMessageRetention ||
m.Date != int(m.CreatedAt.Unix()) || (m.EditDate != 0 && m.EditDate < m.Date) ||
m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID ||
m.PayloadHash == ([32]byte{}) {
return ErrEphemeralInvalid
}
zeroDevice := m.OriginDevice == (EphemeralDevice{})
if !zeroDevice && (m.OriginDevice.UserID <= 0 || m.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
m.OriginDevice.SessionID == 0 ||
(m.OriginDevice.UserID != m.SenderUserID && m.OriginDevice.UserID != m.ReceiverUserID)) {
return ErrEphemeralInvalid
}
if m.Deleted {
if m.Version < 2 || m.Content.Message != "" || len(m.Content.Entities) != 0 || m.Content.Media != nil ||
m.Content.ReplyMarkup != nil || !m.Content.RichMessage.IsZero() {
return ErrEphemeralInvalid
}
return nil
}
return ValidateEphemeralContent(m.Content)
}
func ValidateEphemeralContent(content EphemeralContent) error {
if !utf8.ValidString(content.Message) || utf8.RuneCountInString(content.Message) > MaxMessageTextLength ||
len(content.Entities) > MaxMessageEntityCount || !validEphemeralEntityBounds(content.Message, content.Entities) {
return ErrEphemeralInvalid
}
if err := ValidateReplyMarkup(content.ReplyMarkup); err != nil {
return ErrEphemeralInvalid
}
if content.ReplyMarkup != nil && !content.ReplyMarkup.IsZero() && content.ReplyMarkup.Kind() != MessageReplyMarkupInline {
return ErrEphemeralInvalid
}
if content.Media != nil && !validEphemeralMedia(content.Media) {
return ErrEphemeralInvalid
}
if rich := content.RichMessage; !rich.IsZero() {
if len(rich.Blocks) == 0 || len(rich.Blocks) > MaxEphemeralRichBlocksBytes ||
len(rich.Photos) > MaxEphemeralRichMediaRefs || len(rich.Documents) > MaxEphemeralRichMediaRefs {
return ErrEphemeralInvalid
}
}
if content.Message == "" && content.Media == nil && content.RichMessage.IsZero() {
return ErrEphemeralInvalid
}
return nil
}
func validEphemeralEntityBounds(message string, entities []MessageEntity) bool {
utf16Length := 0
for _, value := range message {
utf16Length++
if value > 0xffff {
utf16Length++
}
}
for _, entity := range entities {
if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length ||
entity.Length > utf16Length-entity.Offset {
return false
}
}
return true
}
func validEphemeralMedia(media *MessageMedia) bool {
if media == nil || media.IsZero() || media.ServiceAction != nil || media.Dice != nil || media.Poll != nil ||
media.GeoLive != nil || media.Todo != nil || media.Story != nil || media.WebPage != nil {
return false
}
switch media.Kind {
case MessageMediaKindPhoto:
return media.Photo != nil && media.Document == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindDocument:
return media.Document != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindContact:
return media.Contact != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindGeo:
return media.Geo != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Venue == nil
case MessageMediaKindVenue:
return media.Venue != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Geo == nil
default:
return false
}
}
func (m EphemeralMessage) Expired(now time.Time) bool {
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
}

View file

@ -0,0 +1,64 @@
package domain
import (
"errors"
"testing"
"time"
)
func TestValidateEphemeralContentBoundsAllRetainedVectors(t *testing.T) {
valid := EphemeralContent{
Message: "hi 👋",
Entities: []MessageEntity{{Type: MessageEntityBold, Offset: 0, Length: 2}},
ReplyMarkup: &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{
Type: MarkupButtonCallback, Text: "OK", Data: []byte("ok"),
}}}},
}
if err := ValidateEphemeralContent(valid); err != nil {
t.Fatalf("valid content: %v", err)
}
badBounds := valid
badBounds.Entities = []MessageEntity{{Type: MessageEntityBold, Offset: 5, Length: 2}}
if err := ValidateEphemeralContent(badBounds); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("entity bounds err=%v", err)
}
badKeyboard := valid
badKeyboard.ReplyMarkup = &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{
Type: MarkupButtonText, Text: "public keyboard",
}}}}
if err := ValidateEphemeralContent(badKeyboard); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("reply keyboard err=%v", err)
}
badRich := EphemeralContent{RichMessage: &MessageRichMessage{Blocks: make([]byte, MaxEphemeralRichBlocksBytes+1)}}
if err := ValidateEphemeralContent(badRich); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("rich bound err=%v", err)
}
badMedia := EphemeralContent{Media: &MessageMedia{Kind: MessageMediaKindPhoto}}
if err := ValidateEphemeralContent(badMedia); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("media shape err=%v", err)
}
}
func TestEphemeralStoredStateRejectsPartialDeviceAndInvalidTombstone(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
message := EphemeralMessage{
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), RandomID: 9,
Content: EphemeralContent{Message: "private"}, OriginDevice: EphemeralDevice{UserID: 3001},
PayloadHash: [32]byte{1}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention),
}
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("partial device err=%v", err)
}
message.OriginDevice = EphemeralDevice{}
message.Deleted = true
message.Content = EphemeralContent{}
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("version-one tombstone err=%v", err)
}
message.Version = 2
if err := message.ValidateStored(); err != nil {
t.Fatalf("valid tombstone err=%v", err)
}
}

View file

@ -4,11 +4,13 @@ import (
"fmt"
"math"
"strings"
"telesrv/internal/branding"
)
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your Telegram account. We never ask it for anything else.
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.`

View file

@ -564,7 +564,9 @@ const (
// 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"
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -608,78 +610,132 @@ type MessageWebViewDataAction struct {
type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"`
// Details is the immutable, permission-gated snapshot delivered to the bot.
// It is kept separate from Peers because the sender-side MTProto action only
// exposes peer identities, while the bot-side/Bot API view may additionally
// expose the requested name, username, and profile photo.
Details []MessageRequestedPeerDetails `json:"details,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
}
type MessageRequestedPeerDetails struct {
Peer Peer `json:"peer"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Photo *Photo `json:"photo,omitempty"`
}
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
type MessageServiceAction struct {
Kind MessageServiceActionKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Call *MessagePhoneCallAction `json:"call,omitempty"`
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
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"`
Kind MessageServiceActionKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Call *MessagePhoneCallAction `json:"call,omitempty"`
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
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"`
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,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"`
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"`
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"`
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
// outer messageActionStarGift and is only the amount already prepaid by the
// sender. TDesktop uses these two fields to choose the paid vs free flow.
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
GiftMsgID int `json:"gift_msg_id,omitempty"`
GiftNum int `json:"gift_num,omitempty"`
AuctionAcquired bool `json:"auction_acquired,omitempty"`
To Peer `json:"to,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"`
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"`
Transferred bool `json:"transferred,omitempty"`
Refunded bool `json:"refunded,omitempty"`
Assigned bool `json:"assigned,omitempty"`
FromOffer bool `json:"from_offer,omitempty"`
Craft bool `json:"craft,omitempty"`
CanExportAt int `json:"can_export_at,omitempty"`
TransferStars int64 `json:"transfer_stars,omitempty"`
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
CanTransferAt int `json:"can_transfer_at,omitempty"`
CanResellAt int `json:"can_resell_at,omitempty"`
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
CanCraftAt int `json:"can_craft_at,omitempty"`
}
type MessageStarGiftOfferAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
ExpiresAt int `json:"expires_at"`
Accepted bool `json:"accepted,omitempty"`
Declined bool `json:"declined,omitempty"`
}
type MessageStarGiftOfferDeclinedAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
Expired bool `json:"expired,omitempty"`
}
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
type MessageMedia struct {
Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Document *Document `json:"document,omitempty"`
Contact *MessageContact `json:"contact,omitempty"`
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
Geo *MessageGeoPoint `json:"geo,omitempty"`
Venue *MessageVenue `json:"venue,omitempty"`
Dice *MessageDice `json:"dice,omitempty"`
Poll *MessagePoll `json:"poll,omitempty"`
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
Todo *MessageTodo `json:"todo,omitempty"`
Story *MessageStory `json:"story,omitempty"`
WebPage *MessageWebPage `json:"web_page,omitempty"`
Spoiler bool `json:"spoiler,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
Nopremium bool `json:"nopremium,omitempty"`
Voice bool `json:"voice,omitempty"`
Round bool `json:"round,omitempty"`
Video bool `json:"video,omitempty"`
Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
LivePhotoVideo *Document `json:"live_photo_video,omitempty"`
Document *Document `json:"document,omitempty"`
Contact *MessageContact `json:"contact,omitempty"`
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
Geo *MessageGeoPoint `json:"geo,omitempty"`
Venue *MessageVenue `json:"venue,omitempty"`
Dice *MessageDice `json:"dice,omitempty"`
Poll *MessagePoll `json:"poll,omitempty"`
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
Todo *MessageTodo `json:"todo,omitempty"`
Story *MessageStory `json:"story,omitempty"`
WebPage *MessageWebPage `json:"web_page,omitempty"`
Spoiler bool `json:"spoiler,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
Nopremium bool `json:"nopremium,omitempty"`
Voice bool `json:"voice,omitempty"`
Round bool `json:"round,omitempty"`
Video bool `json:"video,omitempty"`
// InvertMedia 映射 message.invert_media媒体典型为链接预览渲染在文本上方。
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
InvertMedia bool `json:"invert_media,omitempty"`

View file

@ -151,7 +151,7 @@ type Message struct {
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
// 转发不携带特效(新消息恒 0。仅私聊群/频道不渲染。
Effect int64
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照P3。仅 bot 出站消息可
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
@ -242,6 +242,10 @@ type MessageFilter struct {
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
// messages.getSavedHistoryPeer 必须同时是 self。
SavedPeer Peer
// PeerIDs restricts a global private search to these user peers. Empty is a
// valid restricted set, so RestrictPeerIDs carries presence separately.
PeerIDs []int64
RestrictPeerIDs bool
}
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
@ -283,7 +287,7 @@ type SendPrivateTextRequest struct {
// BusinessAutomationKind is internal app-layer metadata used to suppress
// recursive greeting/away automation for server-generated replies.
BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照P3;普通用户发送恒 nil。
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage

View file

@ -18,6 +18,10 @@ const (
MaxCallbackDataLen = 64
// MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。
MaxMarkupButtonTextLen = 256
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
MaxReplyKeyboardButtonTextLen = 64
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
MaxReplyKeyboardPlaceholderLen = 64
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
MaxBotCallbackAnswerLen = 200
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64
@ -38,20 +42,81 @@ var (
ErrStartParamInvalid = errors.New("start param invalid")
)
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
// MarkupButtonType 标识消息键盘按钮类型。
type MarkupButtonType string
const (
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
MarkupButtonText MarkupButtonType = "text"
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
MarkupButtonRequestLocation MarkupButtonType = "request_location"
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
MarkupButtonRequestPeer MarkupButtonType = "request_peer"
MarkupButtonWebView MarkupButtonType = "webview"
MarkupButtonSimpleWebView MarkupButtonType = "simple_webview"
MarkupButtonSwitchInline MarkupButtonType = "switch_inline"
MarkupButtonCopy MarkupButtonType = "copy"
)
// MarkupButton 是一颗 inline keyboard 按钮P3 仅 callback/url
// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
// intentionally exposes semantic colors instead of arbitrary RGB values.
type MarkupButtonStyle string
const (
MarkupButtonStylePrimary MarkupButtonStyle = "primary"
MarkupButtonStyleDanger MarkupButtonStyle = "danger"
MarkupButtonStyleSuccess MarkupButtonStyle = "success"
)
// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without
// importing protocol types into persisted message state.
type BotRequestAdminRights struct {
Anonymous bool `json:"anonymous,omitempty"`
ManageChat bool `json:"manage_chat,omitempty"`
DeleteMessages bool `json:"delete_messages,omitempty"`
ManageVideoChats bool `json:"manage_video_chats,omitempty"`
RestrictMembers bool `json:"restrict_members,omitempty"`
PromoteMembers bool `json:"promote_members,omitempty"`
ChangeInfo bool `json:"change_info,omitempty"`
InviteUsers bool `json:"invite_users,omitempty"`
PostStories bool `json:"post_stories,omitempty"`
EditStories bool `json:"edit_stories,omitempty"`
DeleteStories bool `json:"delete_stories,omitempty"`
PostMessages bool `json:"post_messages,omitempty"`
EditMessages bool `json:"edit_messages,omitempty"`
PinMessages bool `json:"pin_messages,omitempty"`
ManageTopics bool `json:"manage_topics,omitempty"`
ManageDirectMessages bool `json:"manage_direct_messages,omitempty"`
}
type BotRequestPeerFilter struct {
UserIsBotSet bool `json:"user_is_bot_set,omitempty"`
UserIsBot bool `json:"user_is_bot,omitempty"`
UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"`
UserIsPremium bool `json:"user_is_premium,omitempty"`
ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"`
ChatHasUsername bool `json:"chat_has_username,omitempty"`
ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"`
ChatIsForum bool `json:"chat_is_forum,omitempty"`
ChatIsCreated bool `json:"chat_is_created,omitempty"`
BotIsMember bool `json:"bot_is_member,omitempty"`
UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"`
BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"`
}
// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮;
// inline keyboard 当前接受 callback/url。
type MarkupButton struct {
Type MarkupButtonType `json:"type"`
Text string `json:"text"`
// Style is one of primary/danger/success. Empty means the client default.
Style MarkupButtonStyle `json:"style,omitempty"`
// IconCustomEmojiID is the optional custom emoji rendered before Text.
IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"`
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位。json 自动 base64
// 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。
Data []byte `json:"data,omitempty"`
@ -60,11 +125,68 @@ type MarkupButton struct {
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"`
// PollType is empty, "regular", or "quiz" for request_poll.
PollType string `json:"poll_type,omitempty"`
// ButtonID and request-peer fields preserve Bot API request_id and the
// client-side chooser shape. RequestPeerType is user/chat/broadcast.
ButtonID int `json:"button_id,omitempty"`
RequestPeerType string `json:"request_peer_type,omitempty"`
MaxQuantity int `json:"max_quantity,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"`
Query string `json:"query,omitempty"`
SamePeer bool `json:"same_peer,omitempty"`
PeerTypes []string `json:"peer_types,omitempty"`
CopyText string `json:"copy_text,omitempty"`
}
// MessageReplyMarkup 是消息携带的 inline keyboard 快照P3 仅 ReplyInlineMarkup
// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。
type MessageReplyMarkupType string
const (
MessageReplyMarkupInline MessageReplyMarkupType = "inline"
MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard"
MessageReplyMarkupHide MessageReplyMarkupType = "hide"
MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
)
// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline
// 非空表示 0110 之前已经持久化的合法 inline keyboardKind 会将其解释为 inline。
type MessageReplyMarkup struct {
Inline [][]MarkupButton `json:"inline,omitempty"`
Type MessageReplyMarkupType `json:"type,omitempty"`
Inline [][]MarkupButton `json:"inline,omitempty"`
Keyboard [][]MarkupButton `json:"keyboard,omitempty"`
Resize bool `json:"resize,omitempty"`
SingleUse bool `json:"single_use,omitempty"`
Selective bool `json:"selective,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
}
// Kind 返回 markup constructor兼容已落库的无 Type inline 快照。
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
if m == nil {
return ""
}
if m.Type != "" {
return m.Type
}
if len(m.Inline) > 0 {
return MessageReplyMarkupInline
}
return ""
}
// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
switch m.Kind() {
case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return true
default:
return false
}
}
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
if m == nil {
return true
}
for _, row := range m.Inline {
if len(row) > 0 {
return false
switch m.Kind() {
case MessageReplyMarkupInline:
for _, row := range m.Inline {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupKeyboard:
for _, row := range m.Keyboard {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return false
default:
return true
}
return true
}
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮校验须先于落库I9
// 空 markup 合法(视为清空/无键盘)。
// ValidateReplyMarkup 校验 markup constructor、结构与按钮校验须先于落库I9
// 空 inline markup 合法(视为清空/无键盘)。
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
if m == nil {
return nil
}
if len(m.Inline) > MaxMarkupRows {
kind := m.Kind()
if kind == "" {
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
}
switch kind {
case MessageReplyMarkupInline:
if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return validateMarkupRows(m.Inline, false)
case MessageReplyMarkupKeyboard:
if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
if len(m.Keyboard) == 0 {
return ErrButtonInvalid
}
return validateMarkupRows(m.Keyboard, true)
case MessageReplyMarkupHide:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
case MessageReplyMarkupForceReply:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
return nil
default:
return ErrButtonInvalid
}
}
func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error {
if len(rows) > MaxMarkupRows {
return ErrButtonInvalid
}
total := 0
for _, row := range m.Inline {
if len(row) > MaxMarkupButtonsPerRow {
for _, row := range rows {
if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
return ErrButtonInvalid
}
total += len(row)
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return ErrButtonInvalid
}
for i := range row {
if err := validateMarkupButton(row[i]); err != nil {
if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
return err
}
}
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return nil
}
func validateMarkupButton(b MarkupButton) error {
func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
text := strings.TrimSpace(b.Text)
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
return ErrButtonInvalid
}
switch b.Style {
case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess:
default:
return ErrButtonInvalid
}
if b.IconCustomEmojiID < 0 {
return ErrButtonInvalid
}
if replyKeyboard {
if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen {
return ErrButtonInvalid
}
switch b.Type {
case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation:
case MarkupButtonRequestPoll:
if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" {
return ErrButtonInvalid
}
case MarkupButtonRequestPeer:
if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 ||
(b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") {
return ErrButtonInvalid
}
if b.RequestPeerFilter != nil {
filter := b.RequestPeerFilter
if b.RequestPeerType == "user" {
if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember ||
filter.UserAdminRights != nil || filter.BotAdminRights != nil {
return ErrButtonInvalid
}
} else {
if filter.UserIsBotSet || filter.UserIsPremiumSet ||
(b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) {
return ErrButtonInvalid
}
}
}
case MarkupButtonSimpleWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
default:
return ErrButtonTypeInvalid
}
if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" {
return ErrButtonInvalid
}
return nil
}
switch b.Type {
case MarkupButtonCallback:
if len(b.Data) > MaxCallbackDataLen {
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonSwitchInline:
if utf8.RuneCountInString(b.Query) > 256 {
return ErrButtonInvalid
}
case MarkupButtonCopy:
if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
return ErrButtonInvalid
}
default:
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
return ErrButtonTypeInvalid

View file

@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) {
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
{"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil},
{"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid},
{"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid},
{"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid},
{"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid},
{"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid},
{"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid},
{"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil},
{"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil},
{"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
{"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) {
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
t.Fatal("markup with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() {
t.Fatal("reply keyboard with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() {
t.Fatal("hide keyboard constructor must not be zero")
}
}

View file

@ -24,29 +24,85 @@ type StarGift struct {
UpgradeIssued int // 当前已发行数量
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
// Layer 228 regular-gift shape. Static release facts live in the immutable
// catalog revision; AvailabilityRemains/AvailabilityResale are the current
// inventory projection maintained on the catalog aggregate.
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityRemains int
AvailabilityTotal int
AvailabilityResale int64
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
ReleasedBy Peer
PerUserTotal int
PerUserRemains int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *StarGiftBackground
}
// StarGiftBackground is the release-level palette used by auction cards and
// gift previews before a collectible backdrop is selected.
type StarGiftBackground struct {
CenterColor int
EdgeColor int
TextColor int
}
// 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
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终态从列表排除
LifecycleStatus StarGiftLifecycleStatus
ConvertStars int64 // 转换可退回的 Stars
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement
GiftNum int // auction-acquired release number for regular gifts
Message string // 附言(可选)
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
TransferStars int64
CanExportAt int
CanTransferAt int
CanResellAt int
DropOriginalDetailsStars int64
CanCraftAt int
UpgradeMsgID int // 当前 owner 侧承载 messageActionStarGiftUnique 的消息 id所有权转移时随新消息更新
PinnedOrder int // >0 表示资料页置顶顺序
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
Unique *UniqueStarGift
}
type StarGiftLifecycleStatus string
const (
StarGiftLifecycleActive StarGiftLifecycleStatus = "active"
StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted"
StarGiftLifecycleBurned StarGiftLifecycleStatus = "burned"
StarGiftLifecycleExported StarGiftLifecycleStatus = "exported"
)
func (s StarGiftLifecycleStatus) Live() bool {
return s == StarGiftLifecycleActive
}
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
@ -58,8 +114,31 @@ const (
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
)
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only
// kind eligible for a regular upgrade draw; named rarities are currently used by
// craft-only models and must still be preserved in the published attribute directory.
type StarGiftAttributeRarityKind string
const (
StarGiftRarityPermille StarGiftAttributeRarityKind = "permille"
StarGiftRarityUncommon StarGiftAttributeRarityKind = "uncommon"
StarGiftRarityRare StarGiftAttributeRarityKind = "rare"
StarGiftRarityEpic StarGiftAttributeRarityKind = "epic"
StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary"
)
func (k StarGiftAttributeRarityKind) Valid() bool {
switch k {
case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare,
StarGiftRarityEpic, StarGiftRarityLegendary:
return true
default:
return false
}
}
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille
// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。
type StarGiftCollectibleAttribute struct {
ID int64
CollectibleRevisionID int64
@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct {
EdgeColor int
PatternColor int
TextColor int
RarityKind StarGiftAttributeRarityKind
RarityPermille int
Crafted bool
OfficialDocumentID int64
SortOrder int
Animation *StarGiftAnimation
Blob *FileBlob
@ -79,33 +161,37 @@ type StarGiftCollectibleAttribute struct {
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
type StarGiftCollectibleRevision struct {
ID int64
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
ID int64
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
OfficialGiftID int64
SourceManifestSHA256 []byte
}
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
type StarGiftCollectibleWrite struct {
GiftID int64
UpgradeStars int64
SupplyTotal int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
Actor string
CommandID string
GiftID int64
UpgradeStars int64
SupplyTotal int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
Actor string
CommandID string
OfficialGiftID int64
SourceManifestSHA256 []byte
}
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
@ -118,6 +204,26 @@ type UniqueStarGift struct {
Slug string
Num int
Owner Peer
RequirePremium bool
ResaleTonOnly bool
ThemeAvailable bool
Burned bool
Crafted bool
OwnerName string
OwnerAddress string
GiftAddress string
ResellAmount *StarGiftAmount
ResellVersion int64
ReleasedBy Peer
ValueAmount int64
ValueCurrency string
ValueUSD int64
ThemePeer Peer
Host Peer
OfferMinStars int
CraftChancePermille int
LastSaleDate int
LastSaleAmount *StarGiftAmount
Model StarGiftCollectibleAttribute
Pattern StarGiftCollectibleAttribute
Backdrop StarGiftCollectibleAttribute
@ -132,6 +238,56 @@ type UniqueStarGift struct {
CreatedAt time.Time
}
// CollectibleEmojiStatus projects an immutable unique gift into the complete
// status shape consumed by Telegram clients. Ownership/lifecycle validation
// is intentionally performed by the caller because it depends on the actor;
// this helper validates only the immutable renderable facts.
func CollectibleEmojiStatus(g UniqueStarGift) (EmojiStatusCollectible, bool) {
status := EmojiStatusCollectible{
CollectibleID: g.ID,
Title: g.Title,
Slug: g.Slug,
CenterColor: g.Backdrop.CenterColor,
EdgeColor: g.Backdrop.EdgeColor,
PatternColor: g.Backdrop.PatternColor,
TextColor: g.Backdrop.TextColor,
}
if g.Model.Document != nil {
status.DocumentID = g.Model.Document.ID
}
if g.Pattern.Document != nil {
status.PatternDocumentID = g.Pattern.Document.ID
}
return status, status.Valid()
}
type StarGiftCurrency string
const (
StarGiftCurrencyStars StarGiftCurrency = "XTR"
StarGiftCurrencyTON StarGiftCurrency = "TON"
)
type StarGiftAmount struct {
Currency StarGiftCurrency
Amount int64
Nanos int
}
func (a StarGiftAmount) Valid() bool {
if a.Amount <= 0 {
return false
}
switch a.Currency {
case StarGiftCurrencyStars:
return a.Nanos >= -999999999 && a.Nanos <= 999999999
case StarGiftCurrencyTON:
return a.Nanos == 0
default:
return false
}
}
// StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
type StarGiftUpgradePreview struct {
GiftID int64
@ -171,7 +327,205 @@ type StarGiftUpgradeRequest struct {
OriginSessionID int64
}
type StarGiftPurchaseRequest struct {
BuyerUserID int64
BuyerPremium bool
To Peer
GiftID int64
RevisionID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
FormID int64
CommandKey string
Date int
RecipientBlocked bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// StarGiftPurchaseForm is the server-issued, short-lived payment intent that
// binds payments.getPaymentForm to one later payments.sendStarsForm call. A
// fresh form represents a fresh purchase even when every invoice field is the
// same; retrying one form represents the same purchase command.
type StarGiftPurchaseForm struct {
FormID int64
BuyerUserID int64
To Peer
GiftID int64
RevisionID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
IssuedAt int
ExpiresAt int
}
type StarGiftPurchaseResult struct {
Gift StarGift
Saved SavedStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
// StarGiftConvertRequest identifies one owner-scoped regular gift conversion.
// ActorUserID is the authenticated user who owns the user gift or administers
// the channel gift; authorization is checked again at the RPC boundary.
type StarGiftConvertRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
Date int
}
// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is
// the post-credit balance of either the user or the channel internal Stars
// ledger selected by Saved.Owner.
type StarGiftConvertResult struct {
Saved SavedStarGift
OwnerBalance int64
}
type StarGiftUpgradeResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Send SendPrivateTextResult
SourceEdits []EditedMessageForUser
Duplicate bool
}
// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a
// committed upgrade after the saved gift has entered its unique terminal state.
// In particular, a paid replay must not be rebound to a later catalog price.
type StarGiftUpgradeReceipt struct {
UserID int64
SourceSavedGiftID int64
FormID int64
UniqueGiftID int64
ChargeStars int64
BalanceAfter int64
SourceEditPts int
RequirePrepaid bool
KeepOriginalDetails bool
}
type StarGiftPrepaidUpgradeRequest struct {
PayerUserID int64
Owner Peer
Hash string
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftPrepaidUpgradeResult struct {
Saved SavedStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftDropOriginalDetailsRequest struct {
UserID int64
Ref SavedStarGiftRef
ChargeStars int64
FormID int64
CommandKey string
Date int
}
type StarGiftDropOriginalDetailsResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Duplicate bool
}
// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular
// gift becomes collectible. It deliberately contains no wallet/node/provider
// configuration: TON remains only a currency unit in the local ledger.
type StarGiftLifecyclePolicy struct {
TransferStars int64
DropOriginalDetailsStars int64
OfferMinStars int
ExportDelaySeconds int
TransferDelaySeconds int
ResellDelaySeconds int
CraftDelaySeconds int
CraftChancePermille int
}
func (p StarGiftLifecyclePolicy) Valid() bool {
return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 &&
p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 &&
p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000
}
// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator.
// Proceeds permille is the seller share; the remainder is recorded as platform
// commission. TON is still only a unit in the local ledger.
type StarGiftMarketPolicy struct {
StarsProceedsPermille int
TONProceedsPermille int
}
func (p StarGiftMarketPolicy) Valid() bool {
return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 &&
p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000
}
type StarGiftResaleFilter struct {
GiftID int64
SortByPrice bool
SortByNum bool
ForCraft bool
StarsOnly bool
ModelIDs []int64
PatternIDs []int64
BackdropIDs []int64
Offset string
Limit int
}
type StarGiftResalePage struct {
Gifts []UniqueStarGift
Count int
NextOffset string
}
type StarGiftValueInfo struct {
Currency string
Value int64
ValueIsAverage bool
InitialSaleDate int
InitialSaleStars int64
InitialSalePrice int64
LastSaleDate int
LastSalePrice int64
FloorPrice int64
AveragePrice int64
ListedCount int
}
type StarGiftTransferRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftTransferResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
@ -179,6 +533,161 @@ type StarGiftUpgradeResult struct {
Duplicate bool
}
type StarGiftListingRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
Amount *StarGiftAmount
Date int
}
type StarGiftResalePurchaseRequest struct {
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOfferRequest struct {
BuyerUserID int64
Owner Peer
Slug string
Price StarGiftAmount
Duration int
RandomID int64
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOffer struct {
ID int64
BuyerUserID int64
Owner Peer
UniqueGiftID int64
Price StarGiftAmount
RandomID int64
OfferMsgID int
BuyerMsgID int
Status string
CreatedAt int
ExpiresAt int
ResolvedAt int
Gift UniqueStarGift
}
type StarGiftOfferResult struct {
Offer StarGiftOffer
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftResolveOfferRequest struct {
OwnerUserID int64
OfferMsgID int
Decline bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftCraftRequest struct {
UserID int64
Refs []SavedStarGiftRef
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftCraftResult struct {
Success bool
Chance int
Gift *UniqueStarGift
Send SendPrivateTextResult
SourceEdits []EditedMessageForUser
Duplicate bool
}
type StarGiftAuction struct {
Gift StarGift
Version int
StartDate int
EndDate int
MinBidAmount int64
NextRoundAt int
LastGiftNum int
GiftsLeft int
CurrentRound int
TotalRounds int
RoundDuration int
BidLevels []StarGiftAuctionBidLevel
TopBidders []int64
UserState StarGiftAuctionUserState
Finished bool
AveragePrice int64
ListedCount int
}
type StarGiftAuctionBidLevel struct {
Pos int
Amount int64
Date int
}
type StarGiftAuctionUserState struct {
Returned bool
BidAmount int64
BidDate int
MinBidAmount int64
BidPeer Peer
AcquiredCount int
}
type StarGiftAuctionBidRequest struct {
UserID int64
GiftID int64
Peer Peer
BidAmount int64
HideName bool
Message string
UpdateBid bool
FormID int64
Date int
}
type StarGiftAuctionAcquired struct {
Peer Peer
Date int
BidAmount int64
Round int
Pos int
Message string
GiftNum int
NameHidden bool
}
type StarGiftWithdrawalRequest struct {
UserID int64
Ref SavedStarGiftRef
Date int
}
type StarGiftWithdrawal struct {
ProviderRequestID string
URL string
ExpiresAt int
Status string
Gift UniqueStarGift
}
// StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
type StarGiftCollection struct {
Owner Peer
@ -223,17 +732,53 @@ type StarGiftAnimation struct {
// 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
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
OfficialGiftID int64
SourceManifestSHA256 []byte
OfficialSourceJSON []byte
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityRemains int
AvailabilityTotal int
AvailabilityResale int64
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
ReleasedBy Peer
PerUserTotal int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *StarGiftBackground
}
// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional
// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID.
type StarGiftCatalogBundleWrite struct {
Catalog StarGiftCatalogWrite
Collectible *StarGiftCollectibleWrite
}
type StarGiftCatalogBundleResult struct {
Catalog StarGiftCatalogEntry
Collectible *StarGiftCollectibleRevision
}
// StarGiftCatalogEntry 是管理后台目录视图。
@ -255,20 +800,24 @@ type StarGiftCatalogEntry struct {
}
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id
// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。
type SavedStarGiftRef struct {
Owner Peer
MsgID int
SavedID int64
Slug string
}
// Valid reports whether the reference has the identity required by its owner kind.
func (r SavedStarGiftRef) Valid() bool {
slug := strings.TrimSpace(r.Slug)
validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0
switch r.Owner.Type {
case PeerTypeUser:
return r.Owner.ID != 0 && r.MsgID > 0
return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "")
case PeerTypeChannel:
return r.Owner.ID != 0 && r.SavedID > 0
return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "")
default:
return false
}
@ -281,6 +830,14 @@ type SavedStarGiftPage struct {
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
}
// SavedStarGiftListCursor is the composite keyset cursor for the profile gift
// order: pinned gifts first by PinnedOrder, then unpinned gifts by ID DESC.
// PinnedOrder == 0 identifies the unpinned segment.
type SavedStarGiftListCursor struct {
PinnedOrder int
ID int64
}
// 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
@ -317,7 +874,8 @@ const (
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
MaxStarGiftCatalogSize = 500
MaxStarGiftTitleRunes = 128
MaxStarGiftCollectibleAttributesPerKind = 256
MaxStarGiftSlugBytes = 255
MaxStarGiftCollectibleAttributesPerKind = 512
MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000
@ -339,6 +897,18 @@ var (
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
ErrStarGiftUnavailable = errors.New("stargift: unavailable")
ErrStarGiftOwnerInvalid = errors.New("stargift: owner invalid")
ErrStarGiftTransferUnavailable = errors.New("stargift: transfer unavailable")
ErrStarGiftResaleUnavailable = errors.New("stargift: resale unavailable")
ErrStarGiftOfferInvalid = errors.New("stargift: offer invalid")
ErrStarGiftOfferExpired = errors.New("stargift: offer expired")
ErrStarGiftCraftUnavailable = errors.New("stargift: craft unavailable")
ErrStarGiftAuctionUnavailable = errors.New("stargift: auction unavailable")
ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable")
ErrStarGiftFormExpired = errors.New("stargift: payment form expired")
ErrStarGiftFormPurposeInvalid = errors.New("stargift: payment form purpose invalid")
ErrStarGiftFormAmountMismatch = errors.New("stargift: payment form amount mismatch")
)
var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
@ -351,6 +921,11 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
return ErrStarGiftCollectibleInvalid
}
if write.OfficialGiftID < 0 ||
(write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) ||
(write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) {
return ErrStarGiftCollectibleInvalid
}
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
return err
}
@ -380,11 +955,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid
}
seen := make(map[string]struct{}, len(attributes))
total := 0
selectable := 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 {
rarityKind := attribute.RarityKind
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() {
return ErrStarGiftCollectibleInvalid
}
if rarityKind == StarGiftRarityPermille {
if attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 || attribute.Crafted {
return ErrStarGiftCollectibleInvalid
}
selectable++
} else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel {
return ErrStarGiftCollectibleInvalid
}
key := strings.ToLower(name)
@ -392,19 +975,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
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() ||
if requireStoredAsset && (attribute.Document == nil ||
!validStarGiftCollectibleDocument(*attribute.Document, kind) ||
attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) {
return ErrStarGiftCollectibleInvalid
}
case StarGiftCollectibleBackdrop:
if attribute.BackdropID <= 0 || attribute.Document != nil ||
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 ||
@ -415,12 +998,44 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid
}
}
if total != 1000 {
if selectable == 0 {
return ErrStarGiftCollectibleInvalid
}
return nil
}
// validStarGiftCollectibleDocument enforces the client-visible document roles
// materialized by the Star Gift write boundary. Models are ordinary stickers.
// Patterns are text-color custom emoji with an inline PhotoPathSize so Android
// can classify and tint the TGS before its full first frame is downloaded.
func validStarGiftCollectibleDocument(document Document, kind StarGiftCollectibleAttributeKind) bool {
renderAttributes := 0
validRenderAttribute := false
for _, attribute := range document.Attributes {
switch attribute.Kind {
case DocAttrSticker:
renderAttributes++
validRenderAttribute = validRenderAttribute || kind == StarGiftCollectibleModel
case DocAttrCustomEmoji:
renderAttributes++
validRenderAttribute = validRenderAttribute ||
(kind == StarGiftCollectiblePattern && attribute.TextColor)
}
}
if renderAttributes != 1 || !validRenderAttribute {
return false
}
if kind == StarGiftCollectibleModel {
return true
}
for _, thumb := range document.Thumbs {
if thumb.Kind == PhotoSizeKindPath && strings.TrimSpace(thumb.Type) != "" && len(thumb.Bytes) > 0 {
return true
}
}
return false
}
// StarGiftCatalogHash 由客户端可见目录字段折叠出稳定 hash供 getStarGifts NotModified。
func StarGiftCatalogHash(catalog []StarGift) int {
var h uint64
@ -462,7 +1077,44 @@ func StarGiftCollectionHash(title string, giftIDs []int64) int64 {
return int64(h & 0x7fffffffffffffff)
}
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id
// EncodeSavedStarGiftListCursor encodes the exact profile-order key of the last
// visible gift. The version prefix keeps this cursor distinct from other star
// gift lists that are ordered only by instance ID.
func EncodeSavedStarGiftListCursor(pinnedOrder int, id int64) string {
if pinnedOrder < 0 || id <= 0 {
return ""
}
raw := "v1:" + strconv.Itoa(pinnedOrder) + ":" + strconv.FormatInt(id, 10)
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
// DecodeSavedStarGiftListCursor decodes a profile gift list cursor. Invalid or
// obsolete cursor shapes are rejected instead of being normalized on read.
func DecodeSavedStarGiftListCursor(s string) (SavedStarGiftListCursor, bool) {
if s == "" {
return SavedStarGiftListCursor{}, false
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return SavedStarGiftListCursor{}, false
}
parts := strings.Split(string(raw), ":")
if len(parts) != 3 || parts[0] != "v1" {
return SavedStarGiftListCursor{}, false
}
order, err := strconv.ParseInt(parts[1], 10, 32)
if err != nil || order < 0 {
return SavedStarGiftListCursor{}, false
}
id, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || id <= 0 {
return SavedStarGiftListCursor{}, false
}
return SavedStarGiftListCursor{PinnedOrder: int(order), ID: id}, true
}
// EncodeStarGiftCursor / DecodeStarGiftCursor are simple instance-ID cursors
// used by star gift lists whose order is strictly ID DESC (for example craft).
func EncodeStarGiftCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}

View file

@ -0,0 +1,147 @@
package domain
import (
"crypto/sha256"
"errors"
"strings"
"testing"
)
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
user := Peer{Type: PeerTypeUser, ID: 42}
channel := Peer{Type: PeerTypeChannel, ID: 84}
tests := []struct {
name string
ref SavedStarGiftRef
want bool
}{
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.ref.Valid(); got != tt.want {
t.Fatalf("Valid() = %v, want %v", got, tt.want)
}
})
}
}
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
if StarGiftLifecycleStatus("").Live() {
t.Fatal("empty lifecycle status must not be treated as active")
}
if !StarGiftLifecycleActive.Live() {
t.Fatal("active lifecycle status must be live")
}
}
func validCollectibleDraft() StarGiftCollectibleWrite {
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
return StarGiftCollectibleWrite{
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
Models: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
},
Patterns: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
},
Backdrops: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
},
}
}
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
write := validCollectibleDraft()
write.OfficialGiftID = 10
write.SourceManifestSHA256 = make([]byte, sha256.Size)
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
t.Fatalf("valid official draft: %v", err)
}
tests := map[string]StarGiftCollectibleWrite{}
withoutHash := write
withoutHash.SourceManifestSHA256 = nil
tests["official ID without hash"] = withoutHash
withoutID := write
withoutID.OfficialGiftID = 0
tests["hash without official ID"] = withoutID
negativeID := write
negativeID.OfficialGiftID = -1
tests["negative official ID"] = negativeID
for name, invalid := range tests {
t.Run(name, func(t *testing.T) {
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
write := validCollectibleDraft()
write.Models[0].RarityKind = ""
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
}
func storedCollectibleWrite() StarGiftCollectibleWrite {
write := validCollectibleDraft()
for i := range write.Models {
write.Models[i].Document = &Document{
ID: int64(100 + i), MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
}
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
}
write.Patterns[0].Document = &Document{
ID: 200, MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
}
write.Patterns[0].Blob = &FileBlob{LocationKey: "pattern"}
return write
}
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
t.Fatalf("valid stored collectible: %v", err)
}
tests := map[string]func(*StarGiftCollectibleWrite){
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
},
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes[0].TextColor = false
},
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Thumbs = nil
},
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
},
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
write := storedCollectibleWrite()
mutate(&write)
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}

View file

@ -0,0 +1,31 @@
package domain
import "testing"
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
got, ok := DecodeSavedStarGiftListCursor(encoded)
if !ok || got != want {
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
}
unpinned := SavedStarGiftListCursor{ID: 42}
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
if !ok || got != unpinned {
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
}
}
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
for _, cursor := range []string{
"not-base64!",
EncodeStarGiftCursor(42),
EncodeSavedStarGiftListCursor(-1, 42),
EncodeSavedStarGiftListCursor(1, 0),
} {
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
}
}
}

View file

@ -3,6 +3,7 @@ package domain
import (
"encoding/base64"
"errors"
"fmt"
"strconv"
)
@ -20,13 +21,20 @@ type StarsBalance struct {
type StarsTransactionReason string
const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
@ -52,6 +60,28 @@ type StarsTransactionPage struct {
Users []User // History 中提到的对手方用户,供 tg Users 富化
}
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
// a TON node, or any blockchain service.
type TonTransaction struct {
ID int64
UserID int64
Peer Peer
GiftID int64
Amount int64 // signed nanoton amount
Date int
Reason StarsTransactionReason
Title string
Description string
}
type TonTransactionPage struct {
Balance int64
Transactions []TonTransaction
NextOffset string
Users []User
}
// Stars 账本边界常量。
const (
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
@ -70,6 +100,17 @@ var (
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
)
// StarsPaymentRequiredError reports the minimum paid-message authorization the
// sender must include in allow_paid_stars. The authorization is a ceiling; the
// ledger debits only the channel's current configured price.
type StarsPaymentRequiredError struct {
Stars int64
}
func (e *StarsPaymentRequiredError) Error() string {
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
}
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id编码为客户端不透明字符串。
func EncodeStarsCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))

View file

@ -12,6 +12,9 @@ const (
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
UpdateEventEditMessage UpdateEventType = "edit_message"
// UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号
// pts/difference/outbox。
UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query"
// UpdateEventWebPage 映射 updateWebPage异步解析完成后把消息里的 pending 链接预览
// 占位就地替换为已解析卡片。携带账号 pts非 LacksWirePts消息快照经 box JOIN 重建,
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
@ -29,8 +32,11 @@ const (
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新TL
// 构造器不携 pts事件仍占账号 pts以便其它设备在线/离线保持同一水位。
UpdateEventUserPhone UpdateEventType = "user_phone"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
UpdateEventUserPhone UpdateEventType = "user_phone"
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
// It consumes account pts even though updateUserEmojiStatus has no pts.
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages私聊置顶/取消
// 置顶MessageIDs 是该 owner 自己视角的 box idBool 为 pinned
// TL 构造器自带账号 pts/pts_count不属于 LacksWirePts。
@ -81,6 +87,7 @@ type UpdateEvent struct {
Peers []Peer
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Settings PeerSettings
MessageIDs []int
MaxID int
@ -106,6 +113,11 @@ type UpdateEvent struct {
QuickReplies []QuickReply
QuickReply QuickReply
QuickReplyMessage QuickReplyMessage
BotCallbackQuery *BotCallbackQuery
// BotAPIUpdateID is the HTTP Bot API update_id. It is intentionally separate
// from MTProto Pts: Bot API ephemeral envelopes never advance account state.
BotAPIUpdateID int64
EphemeralMessage *EphemeralMessage
}
// LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有
@ -127,6 +139,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerSettings,
UpdateEventPeerStoryBlocked,
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -1,5 +1,7 @@
package domain
import "time"
// UserIDSequenceBase 是普通用户 ID 的起始值。
//
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
@ -14,6 +16,73 @@ type PeerColor struct {
BackgroundEmojiID int64
}
// EmojiStatusCollectible is the immutable projection needed to render a
// collectible gift as an emoji status. The source of truth remains the owned
// UniqueStarGift; users store an immutable snapshot so every user projection,
// online update and offline difference observes the same shape without an
// RPC-layer lookup.
type EmojiStatusCollectible struct {
CollectibleID int64 `json:"collectible_id"`
DocumentID int64 `json:"document_id"`
Title string `json:"title"`
Slug string `json:"slug"`
PatternDocumentID int64 `json:"pattern_document_id"`
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
PatternColor int `json:"pattern_color"`
TextColor int `json:"text_color"`
}
// Empty reports whether no collectible status is present.
func (s EmojiStatusCollectible) Empty() bool {
return s == (EmojiStatusCollectible{})
}
// Valid enforces the complete collectible status shape. Partial snapshots
// are forbidden because clients would otherwise render a gradient without its
// model/pattern or be unable to resolve the collectible link.
func (s EmojiStatusCollectible) Valid() bool {
if s.CollectibleID <= 0 || s.DocumentID <= 0 || s.PatternDocumentID <= 0 ||
s.Title == "" || s.Slug == "" {
return false
}
for _, color := range []int{s.CenterColor, s.EdgeColor, s.PatternColor, s.TextColor} {
if color < 0 || color > 0xffffff {
return false
}
}
return true
}
// UserEmojiStatus is the protocol-neutral mutation value accepted by the user
// service/store boundary. Exactly one of a normal document or a complete
// collectible snapshot may be active; the zero value clears the status.
type UserEmojiStatus struct {
DocumentID int64 `json:"document_id"`
Until int `json:"until,omitempty"`
Collectible EmojiStatusCollectible `json:"collectible,omitempty"`
}
func (s UserEmojiStatus) Empty() bool {
return s.DocumentID == 0 && s.Collectible.Empty()
}
func (s UserEmojiStatus) Valid() bool {
if s.Until < 0 {
return false
}
if s.Empty() {
return s.Until == 0
}
if s.DocumentID <= 0 {
return false
}
if s.Collectible.Empty() {
return true
}
return s.Collectible.Valid() && s.DocumentID == s.Collectible.DocumentID
}
// Empty reports whether no explicit color/profile color state is set.
func (c PeerColor) Empty() bool {
return !c.HasColor && c.BackgroundEmojiID == 0
@ -50,14 +119,19 @@ type User struct {
PremiumUntil int
// EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status
//premium 专属account.updateEmojiStatus。DocumentID==0 表示未设置;
// Until==0 表示永久。
EmojiStatusDocumentID int64
EmojiStatusUntil int
// Until==0 表示永久。EmojiStatusCollectible 非零时 DocumentID 必须等于
// collectible 的 model document id。
EmojiStatusDocumentID int64
EmojiStatusUntil int
EmojiStatusCollectible EmojiStatusCollectible
// Birthday 是用户公开生日account.updateBirthday。零值表示未设置。
Birthday Birthday
// PersonalChannelID 是资料页展示的「个人频道」account.updatePersonalChannel
// 0 表示未设置。资料投影时按它取频道对象与最新一帖。
PersonalChannelID int64
// LinkedCommunityID is the single Community containing this bot. Ordinary
// users must keep it zero; the community aggregate enforces that invariant.
LinkedCommunityID int64
Color PeerColor
ProfileColor PeerColor
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
@ -68,6 +142,15 @@ type User struct {
PhotoHasVideo bool
LastSeenAt int
Status UserStatus
// Deleted is the durable tombstone state. Deleted users remain addressable by
// ID so historical messages can render "Deleted Account", but all profile
// and reusable identity fields are cleared at the store boundary.
Deleted bool
DeletedAt int64
DeletionSource AccountDeletionSource
DeletionReason string
CreatedAt time.Time
AccountDeleteAt time.Time
}
// PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。
@ -80,12 +163,40 @@ func (u User) PremiumActiveAt(now int64) bool {
// 已设置且未过期Until==0 表示永久。emoji status 是 premium 专属,到期
// 降级后即便列仍有残值也不再下发。
func (u User) EmojiStatusActiveAt(now int64) bool {
if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 {
if !u.PremiumActiveAt(now) || !u.EmojiStatus().Valid() || u.EmojiStatusDocumentID == 0 {
return false
}
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
}
// EmojiStatus returns the complete status snapshot carried by this user.
func (u User) EmojiStatus() UserEmojiStatus {
return UserEmojiStatus{
DocumentID: u.EmojiStatusDocumentID,
Until: u.EmojiStatusUntil,
Collectible: u.EmojiStatusCollectible,
}
}
// DeletedTombstone strips every viewer-dependent or personally identifying
// field while preserving the immutable id and lifecycle audit facts.
func (u User) DeletedTombstone() User {
if !u.Deleted {
return u
}
return User{
ID: u.ID,
AccessHash: u.AccessHash,
Deleted: true,
DeletedAt: u.DeletedAt,
DeletionSource: u.DeletionSource,
DeletionReason: u.DeletionReason,
CreatedAt: u.CreatedAt,
AccountDeleteAt: u.AccountDeleteAt,
Status: UserStatus{Kind: UserStatusEmpty},
}
}
// UserStatusKind is a protocol-neutral account presence state.
type UserStatusKind int

View file

@ -1366,15 +1366,19 @@ func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, use
func (m *SessionManager) PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
// Secret-chat qts is the durable source of truth, so online delivery is an accelerator just
// like account pts fan-out. Do not synchronously wait for every PFS/raw connection's socket.
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, 2*time.Second)
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, 2*time.Second)
}
// PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transienttypingbest-effort 版本。
func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, timeout)
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, timeout)
}
func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
func (m *SessionManager) PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, minLayer, t, msg, timeout)
}
func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err()
}
@ -1397,7 +1401,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us
defer cancel()
}
getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, func(c *Conn) error {
return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, minLayer, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@ -1420,7 +1424,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us
})
}
func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, send func(*Conn) error) (int, error) {
func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, send func(*Conn) error) (int, error) {
m.mu.Lock()
candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID)
conns := make([]*Conn, 0, len(candidates))
@ -1432,6 +1436,9 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64
// 未就绪:密聊消息靠 getDifference 补typing 直接丢——都不进 pending。
continue
}
if !sessionSupportsMinimumLayer(c, minLayer) {
continue
}
conns = append(conns, c)
}
m.mu.Unlock()
@ -1476,7 +1483,7 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64
func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error {
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, true, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@ -1499,7 +1506,25 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu
// 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。
func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, getUpdates, false, func(c *Conn) error {
return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, false, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
updates, err := getUpdates()
if err != nil {
return err
}
encoded, err := updates.prepareForConn(ctx, c)
if err != nil {
return err
}
return c.SendBestEffortEncoded(ctx, t, encoded, timeout)
})
}
func (m *SessionManager) PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
return m.pushToUserWithSender(ctx, userID, nil, 0, minLayer, t, getUpdates, false, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@ -1520,6 +1545,10 @@ func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Co
}
func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
return m.pushToUserBestEffortAtLeastLayer(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, msg, timeout)
}
func (m *SessionManager) pushToUserBestEffortAtLeastLayer(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err()
}
@ -1545,7 +1574,7 @@ func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64,
defer cancel()
}
getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error {
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, minLayer, t, getUpdates, true, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@ -1592,7 +1621,7 @@ func onceLayerUpdatesFanout(ctx context.Context, msg tg.UpdatesClass) func() (*l
}
}
func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) {
func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) {
// push fan-out 是连接层最热路径之一debug 日志的字段构造(含 auth_key hex 格式化)
// 在关闭 debug 时也会求值,先查级别一次、按需记日志。
debug := m.log.Core().Enabled(zapcore.DebugLevel)
@ -1612,6 +1641,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
excluded++
continue
}
if !sessionSupportsMinimumLayer(c, minLayer) {
skipped++
continue
}
if !c.receivesUpdates.Load() {
if !queueWhenNotReady {
// transienttyping/presence未就绪即丢不进 pending。这些 update 不写
@ -1640,6 +1673,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
excluded++
continue
}
if !sessionSupportsMinimumLayer(c, minLayer) {
skipped++
continue
}
if !c.receivesUpdates.Load() {
if !queueWhenNotReady {
skipped++
@ -2478,6 +2515,17 @@ func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID i
return c.authKeyID == *excludeAuthKeyID
}
func sessionSupportsMinimumLayer(c *Conn, minLayer int) bool {
if minLayer <= 0 {
return true
}
if c == nil {
return false
}
state := c.LayerProfileState()
return state.Origin != LayerProfileUnknown && int(state.Profile) >= minLayer
}
func sessionKeyLog(id [8]byte) string {
return fmt.Sprintf("%x", id)
}

View file

@ -3,11 +3,13 @@ package mtprotoedge
import (
"context"
"testing"
"time"
"go.uber.org/zap/zaptest"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
)
// TestPushTransientSkipsNotReadySession 锁定不变量transient 推送typing/presence
@ -51,3 +53,57 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
t.Fatalf("durable push queued %d pending, want 1", n)
}
}
// Layer-228-only transient constructors must be filtered before encoding. A
// Layer 227 or unknown session is skipped without disconnecting it or queuing
// an unreplayable update, while the ready Layer 228 session receives it.
func TestPushTransientAtLeastLayerSkipsOldAndUnknownProfiles(t *testing.T) {
sm := NewSessionManager(zaptest.NewLogger(t))
const userID = int64(101)
makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn {
c := &Conn{
sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)},
outbound: make(chan outboundOp, 2), outboundControl: make(chan outboundOp, 2),
outboundStop: make(chan struct{}),
}
c.userID.Store(userID)
c.userIDResolved.Store(true)
c.receivesUpdates.Store(true)
if known {
if err := c.FreezeLayerProfile(profile); err != nil {
t.Fatal(err)
}
}
if err := sm.Register(c); err != nil {
t.Fatal(err)
}
return c
}
old := makeConn(1, tlprofile.Profile227, true)
current := makeConn(2, tlprofile.Profile228, true)
unknown := makeConn(3, 0, false)
message := tg.EphemeralMessage{
ID: 7, FromID: &tg.PeerUser{UserID: 2001}, PeerID: &tg.PeerChannel{ChannelID: 3001},
ReceiverID: userID, Date: 1_900_000_000, Message: "private",
}
updates := &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateNewEphemeralMessage{Message: message}}, Date: 1_900_000_000}
sent, err := sm.PushToUserTransientAtLeastLayer(context.Background(), userID, 228, proto.MessageFromServer, updates, time.Second)
if err != nil || sent != 1 {
t.Fatalf("sent=%d err=%v", sent, err)
}
if len(old.outbound) != 0 || len(unknown.outbound) != 0 || len(current.outbound) != 1 {
t.Fatalf("queues old=%d unknown=%d current=%d", len(old.outbound), len(unknown.outbound), len(current.outbound))
}
if old.isRetired() || unknown.isRetired() {
t.Fatal("unsupported transient update retired an old/unknown session")
}
for _, c := range []*Conn{old, current, unknown} {
sm.mu.RLock()
pending := len(sm.pending[connSessionKey(c)])
sm.mu.RUnlock()
if pending != 0 {
t.Fatalf("session %d queued %d transient updates", c.sessionID, pending)
}
}
}

View file

@ -0,0 +1,533 @@
// Package officialgifts reads the local, immutable snapshot produced by cmd/giftfetch.
// It never performs network I/O. Selected document bytes are accepted only after their
// manifest size and SHA-256 have been verified beneath the configured snapshot root.
package officialgifts
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"telesrv/internal/branding"
)
const manifestSchema = 2
var (
ErrUnavailable = errors.New("official gifts snapshot is unavailable")
ErrInvalid = errors.New("official gifts snapshot is invalid")
ErrNotFound = errors.New("official gift not found")
)
type Catalog struct {
root string
once sync.Once
snap *snapshot
err error
}
type GiftSummary struct {
ID int64
Title string
Stars int64
ConvertStars int64
UpgradeStars int64
AvailabilityTotal int
AvailabilityRemains int
AvailabilityResale int64
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
PerUserTotal int
PerUserRemains int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *Background
ModelCount int
PatternCount int
BackdropCount int
CraftedModelCount int
DocumentID int64
AnimationValidated bool
}
// CanUpgrade reports whether this snapshot contains the complete immutable
// attribute pool and a positive official upgrade price required to mint a
// collectible from the regular gift.
func (g GiftSummary) CanUpgrade() bool {
return g.UpgradeStars > 0 && g.ModelCount > 0 && g.PatternCount > 0 && g.BackdropCount > 0
}
// CanCraft reports whether upgraded collectibles from this gift can reach an
// official craft-only model. Craft is not advertised without a valid upgrade
// path even if a malformed snapshot were to contain a crafted model.
func (g GiftSummary) CanCraft() bool {
return g.CanUpgrade() && g.CraftedModelCount > 0
}
type Bundle struct {
ManifestSHA256 []byte
SourceJSON []byte
Gift Gift
BaseDocument Document
Collectible *CollectibleSet
}
type Gift struct {
ID int64
Title string
Stars int64
ConvertStars int64
UpgradeStars int64
AvailabilityTotal int
AvailabilityRemains int
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityResale int64
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
PerUserTotal int
PerUserRemains int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *Background
DocumentID int64
}
type Background struct {
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
TextColor int `json:"text_color"`
}
type CollectibleSet struct {
Models []Model
Patterns []Pattern
Backdrops []Backdrop
}
type Rarity struct {
Kind string `json:"kind"`
Permille *int `json:"permille,omitempty"`
}
type Model struct {
Name string
DocumentID int64
Crafted bool
Rarity Rarity
Document Document
}
type Pattern struct {
Name string
DocumentID int64
Rarity Rarity
Document Document
}
type Backdrop struct {
Name string
BackdropID int
CenterColor int
EdgeColor int
PatternColor int
TextColor int
Rarity Rarity
}
type Document struct {
ID int64
FileName string
Path string
Size int64
SHA256 string
AnimationValidated bool
ValidationError string
Data []byte
}
type manifest struct {
Schema int `json:"schema"`
GiftCount int `json:"gift_count"`
Gifts []giftManifest `json:"gifts"`
UpgradeAttributeSets []collectibleManifest `json:"upgrade_attribute_sets"`
Documents []documentManifest `json:"documents"`
}
type giftManifest struct {
Index int `json:"index"`
Kind string `json:"kind"`
ID int64 `json:"id"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
UpgradeStars int64 `json:"upgrade_stars"`
Limited bool `json:"limited"`
SoldOut bool `json:"sold_out"`
Birthday bool `json:"birthday"`
RequirePremium bool `json:"require_premium"`
LimitedPerUser bool `json:"limited_per_user"`
PeerColorAvailable bool `json:"peer_color_available"`
Auction bool `json:"auction"`
AvailabilityRemains int `json:"availability_remains"`
AvailabilityTotal int `json:"availability_total"`
AvailabilityResale int64 `json:"availability_resale"`
FirstSaleDate int `json:"first_sale_date"`
LastSaleDate int `json:"last_sale_date"`
ResellMinStars int64 `json:"resell_min_stars"`
PerUserTotal int `json:"per_user_total"`
PerUserRemains int `json:"per_user_remains"`
LockedUntilDate int `json:"locked_until_date"`
AuctionSlug string `json:"auction_slug"`
GiftsPerRound int `json:"gifts_per_round"`
AuctionStartDate int `json:"auction_start_date"`
UpgradeVariants int `json:"upgrade_variants"`
Background *Background `json:"background"`
DocumentIDs []int64 `json:"document_ids"`
SourceJSON []byte `json:"-"`
}
func (g *giftManifest) UnmarshalJSON(data []byte) error {
type plain giftManifest
var value plain
if err := json.Unmarshal(data, &value); err != nil {
return err
}
*g = giftManifest(value)
var compact bytes.Buffer
if err := json.Compact(&compact, data); err != nil {
return err
}
g.SourceJSON = append([]byte(nil), compact.Bytes()...)
return nil
}
type collectibleManifest struct {
GiftID64 int64 `json:"gift_id"`
AttributeCount int `json:"attribute_count"`
Models []modelManifest `json:"models"`
Patterns []patternManifest `json:"patterns"`
Backdrops []backdropManifest `json:"backdrops"`
}
type modelManifest struct {
Name string `json:"name"`
DocumentID int64 `json:"document_id"`
Crafted bool `json:"crafted"`
Rarity Rarity `json:"rarity"`
}
type patternManifest struct {
Name string `json:"name"`
DocumentID int64 `json:"document_id"`
Rarity Rarity `json:"rarity"`
}
type backdropManifest 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"`
Rarity Rarity `json:"rarity"`
}
type documentManifest struct {
ID64 int64 `json:"id"`
FileName string `json:"file_name"`
File fileArtifact `json:"file"`
AnimationValidated bool `json:"animation_validated"`
ValidationError string `json:"validation_error"`
}
type fileArtifact struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type snapshot struct {
manifestSHA []byte
gifts map[int64]giftManifest
sets map[int64]collectibleManifest
documents map[int64]documentManifest
ordered []int64
}
func New(root string) *Catalog {
return &Catalog{root: strings.TrimSpace(root)}
}
func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) {
snap, err := c.load()
if err != nil {
return nil, err
}
out := make([]GiftSummary, 0, len(snap.ordered))
for _, id := range snap.ordered {
if err := ctx.Err(); err != nil {
return nil, err
}
gift := snap.gifts[id]
doc := snap.documents[gift.DocumentIDs[0]]
summary := GiftSummary{
ID: id, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale,
Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday,
RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate,
ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal,
PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate,
AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""), GiftsPerRound: gift.GiftsPerRound,
AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants,
Background: cloneBackground(gift.Background), DocumentID: doc.ID64,
AnimationValidated: doc.AnimationValidated,
}
if set, ok := snap.sets[id]; ok {
summary.ModelCount, summary.PatternCount, summary.BackdropCount = len(set.Models), len(set.Patterns), len(set.Backdrops)
for _, model := range set.Models {
if model.Crafted {
summary.CraftedModelCount++
}
}
}
out = append(out, summary)
}
return out, nil
}
func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (Bundle, error) {
snap, err := c.load()
if err != nil {
return Bundle{}, err
}
gift, ok := snap.gifts[giftID]
if !ok {
return Bundle{}, ErrNotFound
}
base, err := c.readDocument(ctx, snap.documents[gift.DocumentIDs[0]])
if err != nil {
return Bundle{}, fmt.Errorf("base document %d: %w", gift.DocumentIDs[0], err)
}
out := Bundle{
ManifestSHA256: append([]byte(nil), snap.manifestSHA...),
SourceJSON: append([]byte(nil), gift.SourceJSON...),
Gift: Gift{ID: gift.ID, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut,
Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate,
LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars,
PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains,
LockedUntilDate: gift.LockedUntilDate, AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""),
GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate,
UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background),
DocumentID: gift.DocumentIDs[0]},
BaseDocument: base,
}
if !includeCollectible {
return out, nil
}
set, ok := snap.sets[giftID]
if !ok {
return Bundle{}, fmt.Errorf("%w: gift %d has no collectible attribute set", ErrInvalid, giftID)
}
collectible := &CollectibleSet{
Models: make([]Model, 0, len(set.Models)), Patterns: make([]Pattern, 0, len(set.Patterns)),
Backdrops: make([]Backdrop, 0, len(set.Backdrops)),
}
for _, value := range set.Models {
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
if err != nil {
return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err)
}
collectible.Models = append(collectible.Models, Model{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc})
}
for _, value := range set.Patterns {
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
if err != nil {
return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err)
}
collectible.Patterns = append(collectible.Patterns, Pattern{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc})
}
for _, value := range set.Backdrops {
collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: branding.UserVisibleText(value.Name, ""), BackdropID: value.BackdropID,
CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor,
TextColor: value.TextColor, Rarity: value.Rarity})
}
out.Collectible = collectible
return out, nil
}
func cloneBackground(value *Background) *Background {
if value == nil {
return nil
}
copy := *value
return &copy
}
func (c *Catalog) load() (*snapshot, error) {
c.once.Do(func() {
if c.root == "" {
c.err = ErrUnavailable
return
}
manifestPath := filepath.Join(c.root, "manifest.json")
raw, err := os.ReadFile(manifestPath)
if err != nil {
c.err = fmt.Errorf("%w: %v", ErrUnavailable, err)
return
}
var value manifest
decoder := json.NewDecoder(bytes.NewReader(raw))
if err := decoder.Decode(&value); err != nil {
c.err = fmt.Errorf("%w: decode manifest: %v", ErrInvalid, err)
return
}
if value.Schema != manifestSchema || value.GiftCount != len(value.Gifts) || len(value.Gifts) == 0 {
c.err = fmt.Errorf("%w: unexpected schema or gift count", ErrInvalid)
return
}
sum := sha256.Sum256(raw)
snap := &snapshot{manifestSHA: append([]byte(nil), sum[:]...), gifts: make(map[int64]giftManifest, len(value.Gifts)),
sets: make(map[int64]collectibleManifest, len(value.UpgradeAttributeSets)), documents: make(map[int64]documentManifest, len(value.Documents))}
for _, doc := range value.Documents {
if doc.ID64 <= 0 || doc.File.Size <= 0 || len(doc.File.SHA256) != 64 || strings.TrimSpace(doc.File.Path) == "" {
c.err = fmt.Errorf("%w: invalid document %d", ErrInvalid, doc.ID64)
return
}
if _, duplicate := snap.documents[doc.ID64]; duplicate {
c.err = fmt.Errorf("%w: duplicate document %d", ErrInvalid, doc.ID64)
return
}
snap.documents[doc.ID64] = doc
}
for _, gift := range value.Gifts {
if gift.Kind != "regular" || gift.ID <= 0 || gift.Stars <= 0 || len(gift.DocumentIDs) != 1 {
c.err = fmt.Errorf("%w: invalid gift %d", ErrInvalid, gift.ID)
return
}
if _, ok := snap.documents[gift.DocumentIDs[0]]; !ok {
c.err = fmt.Errorf("%w: gift %d document missing", ErrInvalid, gift.ID)
return
}
if _, duplicate := snap.gifts[gift.ID]; duplicate {
c.err = fmt.Errorf("%w: duplicate gift %d", ErrInvalid, gift.ID)
return
}
snap.gifts[gift.ID] = gift
snap.ordered = append(snap.ordered, gift.ID)
}
for _, set := range value.UpgradeAttributeSets {
if _, ok := snap.gifts[set.GiftID64]; !ok || set.AttributeCount != len(set.Models)+len(set.Patterns)+len(set.Backdrops) ||
len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 {
c.err = fmt.Errorf("%w: invalid collectible set %d", ErrInvalid, set.GiftID64)
return
}
if _, duplicate := snap.sets[set.GiftID64]; duplicate {
c.err = fmt.Errorf("%w: duplicate collectible set %d", ErrInvalid, set.GiftID64)
return
}
for _, model := range set.Models {
if _, ok := snap.documents[model.DocumentID]; !ok || !validRarity(model.Rarity, model.Crafted) {
c.err = fmt.Errorf("%w: invalid model %q", ErrInvalid, model.Name)
return
}
}
for _, pattern := range set.Patterns {
if _, ok := snap.documents[pattern.DocumentID]; !ok || !validRarity(pattern.Rarity, false) {
c.err = fmt.Errorf("%w: invalid pattern %q", ErrInvalid, pattern.Name)
return
}
}
for _, backdrop := range set.Backdrops {
if backdrop.BackdropID < 0 || !validRarity(backdrop.Rarity, false) {
c.err = fmt.Errorf("%w: invalid backdrop %q", ErrInvalid, backdrop.Name)
return
}
}
snap.sets[set.GiftID64] = set
}
sort.SliceStable(snap.ordered, func(i, j int) bool { return snap.gifts[snap.ordered[i]].Index < snap.gifts[snap.ordered[j]].Index })
c.snap = snap
})
return c.snap, c.err
}
func validRarity(rarity Rarity, crafted bool) bool {
if rarity.Kind == "permille" {
return !crafted && rarity.Permille != nil && *rarity.Permille > 0 && *rarity.Permille <= 1000
}
return crafted && rarity.Permille == nil && (rarity.Kind == "uncommon" || rarity.Kind == "rare" || rarity.Kind == "epic" || rarity.Kind == "legendary")
}
func (c *Catalog) readDocument(ctx context.Context, doc documentManifest) (Document, error) {
if err := ctx.Err(); err != nil {
return Document{}, err
}
root, err := filepath.Abs(c.root)
if err != nil {
return Document{}, err
}
clean := filepath.Clean(filepath.FromSlash(doc.File.Path))
if filepath.IsAbs(clean) {
return Document{}, ErrInvalid
}
full := filepath.Join(root, clean)
rel, err := filepath.Rel(root, full)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return Document{}, ErrInvalid
}
data, err := os.ReadFile(full)
if err != nil {
return Document{}, err
}
if int64(len(data)) != doc.File.Size {
return Document{}, fmt.Errorf("%w: size mismatch", ErrInvalid)
}
sum := sha256.Sum256(data)
if !strings.EqualFold(hex.EncodeToString(sum[:]), doc.File.SHA256) {
return Document{}, fmt.Errorf("%w: sha256 mismatch", ErrInvalid)
}
name := strings.TrimSpace(doc.FileName)
if name == "" {
name = filepath.Base(clean)
}
return Document{ID: doc.ID64, FileName: name, Path: doc.File.Path, Size: doc.File.Size,
SHA256: strings.ToLower(doc.File.SHA256), AnimationValidated: doc.AnimationValidated,
ValidationError: doc.ValidationError, Data: data}, nil
}

View file

@ -0,0 +1,119 @@
package officialgifts
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestCatalogVerifiesSelectedDocument(t *testing.T) {
root := t.TempDir()
data := []byte("official-tgs")
sum := sha256.Sum256(data)
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), data, 0o644); err != nil {
t.Fatal(err)
}
value := manifest{Schema: manifestSchema, GiftCount: 1,
Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Title: "Telegram Pin", Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}},
Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}},
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
catalog := New(root)
items, err := catalog.List(context.Background())
if err != nil || len(items) != 1 || items[0].ID != 1 || items[0].Title != "Telesrv Pin" {
t.Fatalf("items=%+v err=%v", items, err)
}
bundle, err := catalog.Bundle(context.Background(), 1, false)
if err != nil || string(bundle.BaseDocument.Data) != string(data) || bundle.Gift.Title != "Telesrv Pin" {
t.Fatalf("bundle=%+v err=%v", bundle, err)
}
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := catalog.Bundle(context.Background(), 1, false); err == nil {
t.Fatal("tampered document was accepted")
}
}
func TestConfiguredOfficialSnapshotIsComplete(t *testing.T) {
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
if root == "" {
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
}
catalog := New(root)
items, err := catalog.List(context.Background())
if err != nil {
t.Fatal(err)
}
var sets, attributes, crafted, upgradable, craftable int
verifiedDocuments := map[int64]struct{}{}
for _, item := range items {
if item.CanUpgrade() {
upgradable++
}
if item.CanCraft() {
craftable++
}
include := item.ModelCount+item.PatternCount+item.BackdropCount > 0
bundle, err := catalog.Bundle(context.Background(), item.ID, include)
if err != nil {
t.Fatalf("gift %d: %v", item.ID, err)
}
verifiedDocuments[bundle.BaseDocument.ID] = struct{}{}
if bundle.Collectible == nil {
continue
}
sets++
attributes += len(bundle.Collectible.Models) + len(bundle.Collectible.Patterns) + len(bundle.Collectible.Backdrops)
for _, model := range bundle.Collectible.Models {
verifiedDocuments[model.Document.ID] = struct{}{}
if model.Crafted {
crafted++
}
}
for _, pattern := range bundle.Collectible.Patterns {
verifiedDocuments[pattern.Document.ID] = struct{}{}
}
}
if len(items) != 149 || sets != 116 || attributes != 40332 || crafted != 108 || upgradable != 114 || craftable != 2 || len(verifiedDocuments) != 8333 {
t.Fatalf("gifts=%d sets=%d attributes=%d crafted=%d upgradable=%d craftable=%d documents=%d",
len(items), sets, attributes, crafted, upgradable, craftable, len(verifiedDocuments))
}
}
func TestGiftSummaryCapabilitiesRequireCompleteOfficialFacts(t *testing.T) {
complete := GiftSummary{UpgradeStars: 25, ModelCount: 1, PatternCount: 1, BackdropCount: 1}
if !complete.CanUpgrade() || complete.CanCraft() {
t.Fatalf("complete regular pool capabilities = upgrade:%v craft:%v", complete.CanUpgrade(), complete.CanCraft())
}
craftable := complete
craftable.CraftedModelCount = 1
if !craftable.CanUpgrade() || !craftable.CanCraft() {
t.Fatalf("crafted pool capabilities = upgrade:%v craft:%v", craftable.CanUpgrade(), craftable.CanCraft())
}
for name, invalid := range map[string]GiftSummary{
"zero upgrade price": {ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
"missing model": {UpgradeStars: 25, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
"missing pattern": {UpgradeStars: 25, ModelCount: 1, BackdropCount: 1, CraftedModelCount: 1},
"missing backdrop": {UpgradeStars: 25, ModelCount: 1, PatternCount: 1, CraftedModelCount: 1},
} {
t.Run(name, func(t *testing.T) {
if invalid.CanUpgrade() || invalid.CanCraft() {
t.Fatalf("invalid facts advertised capabilities: %+v", invalid)
}
})
}
}

View file

@ -29,6 +29,7 @@ const (
PurposeLoginEmailSetup Purpose = "login_email_setup"
PurposeLoginEmailChange Purpose = "login_email_change"
PurposeChangePhone Purpose = "change_phone"
PurposeConfirmPhone Purpose = "confirm_phone"
)
type Request struct {
@ -46,7 +47,7 @@ func (r Request) Validate(now time.Time) error {
return fmt.Errorf("delivery id is empty or too long")
}
switch r.Purpose {
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone:
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone:
default:
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
}

View file

@ -8,6 +8,7 @@ import (
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/branding"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
@ -15,6 +16,15 @@ import (
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
return r.onAccountSendConfirmPhoneCode(ctx, req)
})
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
return r.onAccountConfirmPhone(ctx, req)
})
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
return true, nil
})
@ -110,11 +120,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
Hash)
})
registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) {
hash := layerRequest.
Hash
_ = hash
return tdesktop.CollectibleEmojiStatuses(), nil
return r.onAccountGetCollectibleEmojiStatuses(ctx, layerRequest.Hash)
})
registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) {
hash := layerRequest.
@ -902,7 +908,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
if err != nil {
return false, internalErr()
}
if ttl.Days <= 0 {
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
return false, tgerr400("TTL_DAYS_INVALID")
}
if svc, ok := r.accountSettingsSvc(); ok {
@ -1602,10 +1608,10 @@ func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg.
return true, nil
}
// onAccountUpdateEmojiStatus 持久化用户自定义 emoji statuspremium 专属)。
// emojiStatusEmpty 与未支持的 collectible 类型按清除处理collectible 依赖
// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给
// 本人全部在线 sessionself user 对象同时携带最新 emoji_status 字段)。
// onAccountUpdateEmojiStatus persists either a normal custom emoji or a
// complete collectible snapshot. Collectibles must still be locally owned by
// the actor; unsupported constructors are rejected instead of being mistaken
// for a clear operation.
func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -1615,33 +1621,105 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
if !ok {
return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义
}
var documentID int64
var until int
if s, ok := status.(*tg.EmojiStatus); ok {
documentID = s.DocumentID
if v, ok := s.GetUntil(); ok {
until = v
}
value, err := r.domainUserEmojiStatus(ctx, userID, status)
if err != nil {
return false, err
}
var (
u domain.User
event domain.UpdateEvent
durableWrite bool
)
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
if durable, ok := r.deps.Users.(UserEmojiStatusDurableService); ok {
u, event, durableWrite, err = durable.UpdateEmojiStatusWithEvent(
ctx, userID, value, int(r.clock.Now().Unix()), rawAuthKeyIDForOrigin(ctx), sessionID,
)
} else {
u, err = svc.UpdateEmojiStatus(ctx, userID, value)
}
u, err := svc.UpdateEmojiStatus(ctx, userID, documentID, until)
if err != nil {
if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
return false, tgerr400("COLLECTIBLE_INVALID")
}
return false, internalErr()
}
r.invalidateRPCProjectionForUser(u.ID)
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{
UserID: u.ID,
EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()),
}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Date: int(r.clock.Now().Unix()),
})
update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)}
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
})
} else if updates, ok := r.deps.Updates.(UserEmojiStatusUpdatesService); ok {
event, _, recordErr := updates.RecordUserEmojiStatus(ctx, authKeyID, userID, value, rawAuthKeyIDForOrigin(ctx), sessionID)
if recordErr != nil {
return false, internalErr()
}
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
})
} else {
// Lightweight test deployments without the durable extension retain the
// previous online-only behavior; production wiring implements it.
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: int(r.clock.Now().Unix()),
})
}
return true, nil
}
func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input tg.EmojiStatusClass) (domain.UserEmojiStatus, error) {
switch status := input.(type) {
case *tg.EmojiStatusEmpty:
return domain.UserEmojiStatus{}, nil
case *tg.EmojiStatus:
value := domain.UserEmojiStatus{DocumentID: status.DocumentID}
if until, ok := status.GetUntil(); ok {
value.Until = until
}
if !value.Valid() {
return domain.UserEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
return value, nil
case *tg.InputEmojiStatusCollectible:
if r.deps.Gifts == nil || status.CollectibleID <= 0 {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID)
if err != nil {
return domain.UserEmojiStatus{}, internalErr()
}
owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
collectible, valid := domain.CollectibleEmojiStatus(gift)
if !valid {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
if until, ok := status.GetUntil(); ok {
value.Until = until
}
if !value.Valid() {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
return value, nil
default:
return domain.UserEmojiStatus{}, inputConstructorInvalidErr()
}
}
// onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。
// 普通 peerColor 可清除color flag absent、可显式设置 color=0collectible
// 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。
@ -1737,6 +1815,41 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
}
// onAccountGetCollectibleEmojiStatuses returns the actor's active locally
// owned unique gifts as complete emojiStatusCollectible values. The bounded
// list order and hash are stable, so Android can safely reuse its cache.
func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Gifts == nil {
return tdesktop.CollectibleEmojiStatuses(), nil
}
gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit)
if err != nil {
return nil, internalErr()
}
ids := make([]int64, 0, len(gifts))
statuses := make([]tg.EmojiStatusClass, 0, len(gifts))
for _, gift := range gifts {
collectible, ok := domain.CollectibleEmojiStatus(gift)
if !ok {
continue
}
ids = append(ids, collectible.CollectibleID)
statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{
DocumentID: collectible.DocumentID,
Collectible: collectible,
}))
}
catalogHash := mediaCatalogHash(ids)
if hash != 0 && hash == catalogHash {
return &tg.AccountEmojiStatusesNotModified{}, nil
}
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
}
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
if u.ID == 0 {
return
@ -1777,12 +1890,12 @@ func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int)
Current: a.AuthKeyID == currentAuthKeyID,
OfficialApp: true,
Hash: a.Hash,
DeviceModel: a.DeviceModel,
Platform: a.Platform,
SystemVersion: a.SystemVersion,
DeviceModel: branding.UserVisibleText(a.DeviceModel, ""),
Platform: branding.UserVisibleClientPlatform(a.Platform),
SystemVersion: branding.UserVisibleText(a.SystemVersion, ""),
APIID: a.APIID,
AppName: "Telegram Desktop",
AppVersion: a.AppVersion,
AppName: branding.ClientAppName(a.Platform),
AppVersion: branding.UserVisibleText(a.AppVersion, ""),
DateCreated: created,
DateActive: active,
IP: a.IP,

View file

@ -0,0 +1,134 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type collectibleEmojiGiftService struct {
GiftsService
gifts map[int64]domain.UniqueStarGift
}
func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) {
gift, ok := s.gifts[id]
return gift, ok, nil
}
func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
out := make([]domain.UniqueStarGift, 0, len(s.gifts))
for _, gift := range s.gifts {
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
out = append(out, gift)
}
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift {
return domain.UniqueStarGift{
ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1",
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}},
Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}},
Backdrop: domain.StarGiftCollectibleAttribute{
CenterColor: 0x102030, EdgeColor: 0x405060,
PatternColor: 0x708090, TextColor: 0xa0b0c0,
},
}
}
func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"})
if err != nil {
t.Fatal(err)
}
users := appusers.NewService(userStore)
if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil {
t.Fatalf("grant premium: %v", err)
}
gift := collectibleEmojiTestGift(owner.ID)
gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}}
r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0)
if err != nil {
t.Fatalf("get collectible statuses: %v", err)
}
statuses, ok := listed.(*tg.AccountEmojiStatuses)
if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 {
t.Fatalf("collectible list = %T %#v", listed, listed)
}
collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible)
if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID ||
collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor {
t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0])
}
if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil {
t.Fatalf("get cached collectible statuses: %v", err)
} else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok {
t.Fatalf("cached collectible statuses = %T, want notModified", cached)
}
input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}
input.SetUntil(2_000_000_000)
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok {
t.Fatalf("set collectible status: ok=%v err=%v", ok, err)
}
self, err := users.Self(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID ||
self.EmojiStatusUntil != 2_000_000_000 {
t.Fatalf("persisted collectible status = %+v", self.EmojiStatus())
}
wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible)
if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor {
t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire)
}
stolen := gift
stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
gifts.gifts[gift.ID] = stolen
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") {
t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err)
}
}
func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) {
collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1))
if !ok {
t.Fatal("test gift should project")
}
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{
UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value,
}).(*tg.UpdateUserEmojiStatus)
if !ok {
t.Fatal("durable event did not produce updateUserEmojiStatus")
}
if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID {
t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus)
}
}

View file

@ -0,0 +1,152 @@
package rpc
import (
"context"
"errors"
"fmt"
"time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
)
type accountDeletionService interface {
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
}
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
svc, ok := r.deps.Account.(accountDeletionService)
return svc, ok
}
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 || (!authorized && !passwordPending) {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok || authKeyID == ([8]byte{}) {
return false, authKeyUnregisteredErr()
}
var password *domain.PasswordCheck
if check, present := req.GetPassword(); present {
converted := domainPasswordCheck(check)
password = &converted
}
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
if outcome.Kind == domain.AccountDeleteDelayed {
wait := outcome.WaitSeconds
if wait < 1 {
wait = 1
}
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
}
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
r.invalidateRPCProjectionForUser(userID)
dispatchNotifications := func() {
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.runAccountLifecycleOnce(dispatchCtx, 500)
}
if !postresponse.Register(ctx, dispatchNotifications) {
go dispatchNotifications()
}
return true, nil
}
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return nil, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return nil, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !authorized || userID == 0 {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
return true, nil
}
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
current, _ := AuthKeyIDFrom(ctx)
for _, authorization := range revoked {
a := authorization
finish := func() {
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
r.revokeAuthKeySessions(a.AuthKeyID)
}
if a.AuthKeyID == current {
if postresponse.Register(ctx, finish) {
continue
}
}
finish()
}
}
func accountDeletionErr(err error) error {
switch {
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
return passwordErr(err)
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
return tgerr.New(400, "HASH_INVALID")
case errors.Is(err, domain.ErrPhoneCodeEmpty):
return phoneCodeEmptyErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid):
return phoneCodeInvalidErr()
case errors.Is(err, domain.ErrPhoneCodeExpired):
return phoneCodeExpiredErr()
case errors.Is(err, domain.ErrAccountDeletionForbidden):
return botMethodInvalidErr()
case errors.Is(err, domain.ErrAccountDeleted):
return authKeyUnregisteredErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,177 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
current := [8]byte{1}
other := [8]byte{2}
accountSvc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
outcome: domain.AccountDeleteOutcome{
Kind: domain.AccountDeleteImmediate,
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
{AuthKeyID: current, UserID: 42},
{AuthKeyID: other, UserID: 42},
}},
},
}
sessions := &deletionCaptureSessions{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
if err != nil || !ok {
t.Fatalf("delete account ok=%v err=%v", ok, err)
}
if sessions.wasClosed(current) {
t.Fatal("current auth key closed before rpc_result delivery")
}
if !sessions.wasClosed(other) {
t.Fatal("other auth key was not revoked immediately")
}
postresponse.Run(ctx)
if !sessions.wasClosed(current) {
t.Fatal("current auth key not closed after rpc_result delivery")
}
}
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
accountSvc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
}
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
}
}
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
}
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
t.Fatal("confirm-phone methods must remain fully authorized")
}
}
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
sessions := &offlineDeletionSessions{}
svc := &deletionWorkerService{}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
})
if len(svc.completed) != 1 || svc.completed[0] != 9 {
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
}
}
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
revoked := [8]byte{3}
svc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
sweepResults: []domain.AccountDeletionResult{{
Changed: true,
User: domain.User{ID: 42, Deleted: true},
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
}},
sweepErr: errors.New("later candidate failed"),
}
sessions := &deletionCaptureSessions{}
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.runAccountLifecycleOnce(context.Background(), 10)
if !sessions.wasClosed(revoked) {
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
}
}
type rpcDeletionAccountService struct {
*appaccount.Service
outcome domain.AccountDeleteOutcome
err error
sweepResults []domain.AccountDeletionResult
sweepErr error
}
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
return s.outcome, s.err
}
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
}
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
return nil, nil
}
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
return "", domain.AuthCodeDelivery{}, false, nil
}
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
return false, nil
}
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return s.sweepResults, s.sweepErr
}
type deletionCaptureSessions struct {
captureSessions
closed [][8]byte
}
type offlineDeletionSessions struct{ captureSessions }
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
return 0, nil
}
type deletionWorkerService struct{ completed []int64 }
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return nil, nil
}
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
s.completed = append(s.completed, id)
return nil
}
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
s.closed = append(s.closed, id)
return 1
}
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
for _, closed := range s.closed {
if closed == id {
return true
}
}
return false
}

View file

@ -0,0 +1,99 @@
package rpc
import (
"context"
"time"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
type accountLifecycleWorkerService interface {
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}
// RunAccountLifecycle executes all due account deletion sources through one
// tombstone path and drains the durable non-pts updateUser queue. The queue is
// a crash-safe, bounded online nudge: offline users are completed after the
// first attempt because getDialogs/getHistory hydration independently returns
// the authoritative tombstone. This avoids an immortal retry queue for a
// non-pts update that cannot participate in getDifference.
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
if interval <= 0 {
interval = time.Minute
}
if batch <= 0 {
batch = 500
}
r.runAccountLifecycleOnce(ctx, batch)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.runAccountLifecycleOnce(ctx, batch)
}
}
}
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
if !ok {
return
}
now := r.clock.Now().UTC()
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
cancel()
for _, result := range results {
if !result.Changed {
continue
}
r.invalidateRPCProjectionForUser(result.User.ID)
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
}
if err != nil {
// SweepDueAccountDeletions may return already-committed results before a
// later candidate fails. Always finish those sessions/caches and drain
// their durable notifications; the failed and remaining candidates are
// retried from their authoritative due rows on the next tick.
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
}
for {
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
claimCancel()
if err != nil {
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
return
}
for _, notification := range notifications {
r.dispatchAccountDeletionNotification(ctx, svc, notification)
}
if len(notifications) < batch {
return
}
}
}
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
now := r.clock.Now().UTC()
updates := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
Users: []tg.UserClass{tgUser(domain.User{
ID: notification.DeletedUserID,
Deleted: true,
})},
Date: int(now.Unix()),
}
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
}
}

View file

@ -35,6 +35,12 @@ func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass)
return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true
case *tg.InputNotifyBroadcasts:
return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true
case *tg.InputNotifyCommunity:
ref, ok := inputChannelRef(p.Community)
if !ok {
return domain.NotifyScope{}, false
}
return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: ref.ID}}, true
case *tg.InputNotifyPeer:
peer, ok := r.domainPeerFromInputPeer(userID, p.Peer)
if !ok {
@ -145,6 +151,7 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
updates := make([]tg.UpdateClass, 0, len(exceptions))
userIDs := make([]int64, 0)
channelIDs := make([]int64, 0)
communityIDs := make([]int64, 0)
for _, ex := range exceptions {
if filterPeer != nil && ex.Peer != *filterPeer {
continue
@ -163,15 +170,23 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
userIDs = append(userIDs, ex.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, ex.Peer.ID)
case domain.PeerTypeCommunity:
communityIDs = append(communityIDs, ex.Peer.ID)
}
}
if len(updates) == 0 {
return empty, nil
}
chats := r.tgChatsForChannelIDs(ctx, userID, channelIDs)
if r.deps.Communities != nil && len(communityIDs) > 0 {
if views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs); err == nil {
chats = appendUniqueTGChats(chats, tgCommunityChats(views)...)
}
}
return &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs),
Chats: chats,
Date: int(r.clock.Now().Unix()),
}, nil
}
@ -260,6 +275,9 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
case domain.NotifyScopeBroadcasts:
return &tg.NotifyBroadcasts{}
case domain.NotifyScopePeer:
if scope.Peer.Type == domain.PeerTypeCommunity {
return &tg.NotifyCommunity{CommunityID: scope.Peer.ID}
}
peer := tgPeer(scope.Peer)
if scope.TopicID != 0 {
return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID}
@ -274,7 +292,7 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
// 显示且跨重启恢复。perf从 per-user notify 缓存读取(命中即 0 PG而非每次 getDialogs
// 都查 notify_settings——绝大多数用户没有任何自定义静音缓存命中后零数据库开销。
func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList {
if len(list.Dialogs) == 0 {
if len(list.Dialogs) == 0 && len(list.Communities) == 0 {
return list
}
settings := r.userNotifySettings(ctx, viewerUserID)
@ -287,6 +305,13 @@ func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int6
list.Dialogs[i].NotifySettings = &sc
}
}
for i := range list.Communities {
peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: list.Communities[i].Community.ID}
if s, ok := settings[peer]; ok {
sc := s.Clone()
list.Communities[i].State.NotifySettings = &sc
}
}
return list
}

View file

@ -8,6 +8,7 @@ import (
"strings"
"time"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -42,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
Hash: aiComposeToneWebPageHash(tone),
Date: int(now.Unix()),
Type: aiComposeToneWebPageType,
SiteName: "Telegram",
SiteName: branding.ProductName,
Title: tone.Title,
Description: tone.Prompt,
ComposeToneEmojiID: tone.EmojiID,

View file

@ -18,6 +18,7 @@ import (
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/app/auth"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -589,6 +590,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
}
}
var hash string
var err error
if scoped, ok := r.deps.Auth.(interface {
@ -606,6 +620,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
}
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return false, accountDeletionErr(err)
}
return true, nil
}
}
}
var err error
if scoped, ok := r.deps.Auth.(interface {
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
@ -1000,13 +1026,13 @@ func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User,
if ci, ok := ClientInfoFrom(ctx); ok {
parts := []string{}
if ci.DeviceModel != "" {
parts = append(parts, ci.DeviceModel)
parts = append(parts, branding.UserVisibleText(ci.DeviceModel, ""))
}
if ci.SystemVersion != "" {
parts = append(parts, ci.SystemVersion)
parts = append(parts, branding.UserVisibleText(ci.SystemVersion, ""))
}
if ci.AppVersion != "" {
parts = append(parts, ci.AppVersion)
parts = append(parts, branding.UserVisibleText(ci.AppVersion, ""))
}
if len(parts) > 0 {
client = strings.Join(parts, " / ")

View file

@ -39,6 +39,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
tg.AuthReportMissingCodeRequestTypeID,
tg.AuthResetLoginEmailRequestTypeID,
tg.AccountGetPasswordRequestTypeID,
// deleteAccount may complete the narrow password_pending login path when
// the user forgot 2FA. The handler resolves only that bound identity.
tg.AccountDeleteAccountRequestTypeID,
// 登录邮箱 setupemailVerifyPurposeLoginSetup发生在登录流程中、尚未鉴权
// 故这两个 account.* 方法必须放行 pre-authloginChange 分支内部仍校验 userID。
tg.AccountSendVerifyEmailCodeRequestTypeID,
@ -46,6 +49,7 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
tg.HelpGetConfigRequestTypeID,
tg.HelpGetNearestDCRequestTypeID,
tg.HelpGetInviteTextRequestTypeID,
tg.HelpSaveAppLogRequestTypeID,
tg.HelpGetAppConfigRequestTypeID,
tg.HelpGetCountriesListRequestTypeID,
tg.HelpGetTimezonesListRequestTypeID,

View file

@ -8,7 +8,10 @@ import (
"time"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) (
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
}
func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return nil
}
return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed)
}
func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return nil
}
return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID)
}
func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return 0, nil
}
return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID)
}
func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
if !ok || botID <= 0 {
return true, nil
}
return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl)
}
func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error {
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
if !ok || botID <= 0 {
return nil
}
return leases.ReleaseBotAPIPollLease(ctx, botID, owner)
}
func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return errors.New("WEBHOOK_UNSUPPORTED")
}
return webhooks.SetBotAPIWebhook(ctx, config, dropPending)
}
func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return errors.New("WEBHOOK_UNSUPPORTED")
}
return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending)
}
func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return domain.BotAPIWebhook{}, false, nil
}
return webhooks.BotAPIWebhook(ctx, botID)
}
func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil, nil
}
return webhooks.ListDueBotAPIWebhooks(ctx, limit)
}
func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return false, nil
}
return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl)
}
func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner)
}
func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message)
}
func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt)
}
func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 {
return nil
}
return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID)
}
// BotAPISendMessage sends a text message as a bot through the normal private
// or channel message state machine. Positive chat_id is a user private chat;
// -1000000000000-channel_id is a supergroup/channel chat.
@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if text == "" {
return domain.Message{}, errors.New("MESSAGE_EMPTY")
}
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
}
@ -164,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
return res.SenderMessage, nil
}
func (r *Router) BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) {
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 {
return domain.EphemeralMessage{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(input.ChatID)
if !ok || peer.Type != domain.PeerTypeChannel {
return domain.EphemeralMessage{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(input.ReplyMarkup); err != nil {
return domain.EphemeralMessage{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, input.ReplyMarkup); err != nil {
return domain.EphemeralMessage{}, err
}
baseContent := domain.EphemeralContent{
Message: input.Text, Entities: append([]domain.MessageEntity(nil), input.Entities...), ReplyMarkup: input.ReplyMarkup,
}
if !utf8.ValidString(baseContent.Message) || utf8.RuneCountInString(baseContent.Message) > domain.MaxMessageTextLength || len(baseContent.Entities) > domain.MaxMessageEntityCount ||
!validEphemeralEntityBounds(baseContent.Message, baseContent.Entities) {
return domain.EphemeralMessage{}, errors.New("ENTITY_BOUNDS_INVALID")
}
message, _, err := r.deps.Ephemeral.SendFromBotLazy(ctx, domain.SendBotEphemeralRequest{
BotUserID: input.BotUserID, ReceiverUserID: input.ReceiverUserID, Peer: peer,
TopMessageID: input.TopMessageID, ReplyToEphemeralID: input.ReplyToEphemeralID,
ActionMessageID: input.ReplyToEphemeralID, CallbackQueryID: input.CallbackQueryID,
}, func(buildCtx context.Context) (domain.EphemeralContent, error) {
content := baseContent
if input.DirectMedia != nil {
content.Media = input.DirectMedia
if content.Media.Geo != nil && content.Media.Geo.AccessHash == 0 {
content.Media.Geo.AccessHash, _ = randomGeoAccessHash()
}
if content.Media.Venue != nil && content.Media.Venue.Geo.AccessHash == 0 {
content.Media.Venue.Geo.AccessHash, _ = randomGeoAccessHash()
}
} else if input.Kind != "message" {
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.Kind, input.File, input.SecondaryFile)
if err != nil {
return domain.EphemeralContent{}, err
}
content.Media = media
}
return content, nil
})
if err != nil {
return domain.EphemeralMessage{}, ephemeralBotAPIError(err)
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
return message, nil
}
func (r *Router) BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) {
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 || input.MessageID <= 0 {
return false, errors.New("MESSAGE_ID_INVALID")
}
peer, ok := botAPIPeerFromChatID(input.ChatID)
if !ok || peer.Type != domain.PeerTypeChannel {
return false, errors.New("CHAT_ID_INVALID")
}
fields := input.Fields
if fields.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(fields.ReplyMarkup); err != nil {
return false, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, fields.ReplyMarkup); err != nil {
return false, err
}
}
if fields.SetMessage && (!utf8.ValidString(fields.Message) || !validEphemeralEntityBounds(fields.Message, fields.Entities) || utf8.RuneCountInString(fields.Message) > domain.MaxMessageTextLength) {
return false, errors.New("ENTITY_BOUNDS_INVALID")
}
message, err := r.deps.Ephemeral.EditFieldsFromBotLazy(ctx, input.BotUserID, input.ReceiverUserID, peer, input.MessageID, input.Mode, func(buildCtx context.Context) (domain.EditEphemeralFields, error) {
built := fields
if input.MediaKind != "" {
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.MediaKind, input.File, input.SecondaryFile)
if err != nil {
return domain.EditEphemeralFields{}, err
}
built.SetMedia = true
built.Media = media
}
return built, nil
})
if err != nil {
return false, ephemeralBotAPIError(err)
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushEdit, TargetUserID: message.ReceiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
return true, nil
}
func (r *Router) BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) {
peer, ok := botAPIPeerFromChatID(chatID)
if r == nil || r.deps.Ephemeral == nil || !ok || peer.Type != domain.PeerTypeChannel {
return false, errors.New("CHAT_ID_INVALID")
}
message, deleted, err := r.deps.Ephemeral.Delete(ctx, botUserID, receiverUserID, peer, messageID)
if err != nil {
return false, ephemeralBotAPIError(err)
}
if deleted {
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: receiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
}
return true, nil
}
func ephemeralBotAPIError(err error) error {
switch {
case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), errors.Is(err, domain.ErrEphemeralDeleted):
return errors.New("EPHEMERAL_MESSAGE_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralReplyExpired):
return errors.New("EPHEMERAL_ACTION_EXPIRED")
case errors.Is(err, domain.ErrEphemeralPeerInvalid):
return errors.New("CHAT_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralReceiverInvalid):
return errors.New("USER_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch):
return errors.New("CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrEphemeralVersionConflict):
return errors.New("MESSAGE_NOT_MODIFIED")
default:
return err
}
}
func (r *Router) botAPIEphemeralMedia(ctx context.Context, botID int64, kind string, file, secondary domain.BotAPIFileInput) (*domain.MessageMedia, error) {
if kind == "live_photo" {
photo, err := r.botAPIMedia(ctx, botID, "photo", file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
if err != nil {
return nil, err
}
video, err := r.botAPIDocumentMedia(ctx, botID, "video", secondary)
if err != nil {
return nil, err
}
photo.LivePhotoVideo = video.Document
return photo, nil
}
if kind == "photo" {
return r.botAPIMedia(ctx, botID, kind, file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
}
return r.botAPIDocumentMedia(ctx, botID, kind, file)
}
func (r *Router) botAPIDocumentMedia(ctx context.Context, botID int64, kind string, file domain.BotAPIFileInput) (*domain.MessageMedia, error) {
if r.deps.Files == nil {
return nil, errors.New("MEDIA_INVALID")
}
attrs, forceFile, ok := botAPIDocumentKindAttributes(kind, file)
if !ok {
return nil, errors.New("MEDIA_INVALID")
}
var document domain.Document
var err error
switch {
case len(file.Bytes) > 0:
document, err = r.deps.Files.CreateDocumentFromBytes(ctx, file.Bytes, domain.DocumentSpec{MimeType: file.MimeType, Attributes: attrs, ForceFile: forceFile})
case file.RemoteURL != "":
document, err = r.deps.Files.CreateDocumentFromURL(ctx, file.RemoteURL)
document.Attributes = mergeDocumentAttributes(document.Attributes, attrs)
case file.LocationKey != "":
id, valid := botAPIDocumentID(file.LocationKey)
if !valid {
return nil, errors.New("FILE_ID_INVALID")
}
var found bool
document, found, err = r.deps.Files.GetDocument(ctx, id)
if err == nil && !found {
err = errors.New("FILE_ID_INVALID")
}
default:
err = errors.New("FILE_ID_INVALID")
}
if err != nil {
return nil, botAPIMediaErr(err)
}
if !botAPIDocumentMatchesKind(document, kind) {
return nil, errors.New("MEDIA_INVALID")
}
return messageMediaFromDocument(document, false, 0), nil
}
func botAPIDocumentKindAttributes(kind string, file domain.BotAPIFileInput) ([]domain.DocumentAttribute, bool, bool) {
filename := botAPIDocumentAttributes(file.FileName)
w, h, duration := file.Width, file.Height, file.Duration
if w <= 0 {
w = 1
}
if h <= 0 {
h = 1
}
if duration <= 0 {
duration = 1
}
switch kind {
case "document":
return filename, true, true
case "animation":
return append(filename,
domain.DocumentAttribute{Kind: domain.DocAttrAnimated},
domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), NoSound: true}), false, true
case "audio":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Title: file.Title, Performer: file.Performer}), false, true
case "sticker":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrSticker, W: w, H: h, Alt: file.Emoji}), false, true
case "video":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), SupportsStreaming: true}), false, true
case "video_note":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), RoundMessage: true, SupportsStreaming: true}), false, true
case "voice":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Voice: true}), false, true
default:
return nil, false, false
}
}
func mergeDocumentAttributes(base, additional []domain.DocumentAttribute) []domain.DocumentAttribute {
out := append([]domain.DocumentAttribute(nil), base...)
seen := make(map[domain.DocumentAttributeKind]struct{}, len(base)+len(additional))
for _, attribute := range base {
seen[attribute.Kind] = struct{}{}
}
for _, attribute := range additional {
if _, exists := seen[attribute.Kind]; exists {
continue
}
seen[attribute.Kind] = struct{}{}
out = append(out, attribute)
}
return out
}
func botAPIDocumentMatchesKind(document domain.Document, kind string) bool {
has := func(target domain.DocumentAttributeKind, predicate func(domain.DocumentAttribute) bool) bool {
for _, attribute := range document.Attributes {
if attribute.Kind == target && (predicate == nil || predicate(attribute)) {
return true
}
}
return false
}
switch kind {
case "document":
return document.ID > 0
case "animation":
return has(domain.DocAttrAnimated, nil)
case "audio":
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return !a.Voice })
case "sticker":
return document.IsSticker()
case "video":
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return !a.RoundMessage })
case "video_note":
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return a.RoundMessage })
case "voice":
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return a.Voice })
default:
return false
}
}
func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
switch {
case chatID > 0:
@ -400,6 +792,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
return self.Message, nil
}
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
}
if text == "" {
return false, errors.New("MESSAGE_EMPTY")
}
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
return false, errors.New("MESSAGE_TOO_LONG")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
req := &tg.MessagesEditInlineBotMessageRequest{
ID: tgInputBotInlineMessageID(inlineMessageID),
NoWebpage: disableWebPagePreview,
}
req.SetMessage(text)
if len(entities) > 0 {
req.SetEntities(tgMessageEntities(entities))
}
if setReplyMarkup {
wire := tgReplyMarkup(replyMarkup)
if wire == nil {
wire = &tg.ReplyInlineMarkup{}
}
req.SetReplyMarkup(wire)
}
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
// the target user's MTProto clients observe the normal delete update.
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
@ -440,12 +863,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal
if cacheTime < 0 {
cacheTime = 0
}
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
Alert: showAlert,
Message: text,
URL: url,
CacheTime: cacheTime,
})
if resolveErr != nil {
return false, resolveErr
}
if !resolved {
return false, errors.New("QUERY_ID_INVALID")
}
return true, nil
}

View file

@ -2,11 +2,14 @@ package rpc
import (
"context"
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appbots "telesrv/internal/app/bots"
@ -17,6 +20,170 @@ import (
"telesrv/internal/store/memory"
)
func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
data := []byte("private-confirm")
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
}}}}
sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{
SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID,
RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if _, err := fixture.router.resolveBotCallbackQuery(
fixture.ctx,
fixture.owner.ID,
domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID},
sent.RecipientMessage.ID,
[]byte("forged-callback-data"),
); !tgerr.Is(err, "DATA_INVALID") {
t.Fatalf("forged callback data err = %v, want DATA_INVALID", err)
}
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
defer cancel()
answerCh := make(chan struct {
answer *tg.MessagesBotCallbackAnswer
err error
}, 1)
go func() {
req := &tg.MessagesGetBotCallbackAnswerRequest{
Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash},
MsgID: sent.RecipientMessage.ID,
}
req.SetData(data)
answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
answerCh <- struct {
answer *tg.MessagesBotCallbackAnswer
err error
}{answer: answer, err: err}
}()
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out {
t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID)
}
callback := event.BotCallbackQuery
if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) ||
callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) {
t.Fatalf("callback = %+v", callback)
}
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok {
t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err)
}
select {
case result := <-answerCh:
if result.err != nil || result.answer == nil || result.answer.Message != "accepted" {
t.Fatalf("callback answer = %+v err=%v", result.answer, result.err)
}
case <-ctx.Done():
t.Fatal("callback answer did not unblock requester")
}
}
func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") {
t.Fatalf("unknown answer = ok=%v err=%v", ok, err)
}
item := domain.BotAPIUpdate{
ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1,
Date: 100,
Callback: &domain.BotCallbackQuery{
ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3,
},
}
if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok {
t.Fatal("callback at answer deadline remained deliverable")
}
}
func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) {
now := time.Unix(200, 0)
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988}
item := domain.BotAPIUpdate{
ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()),
Callback: &domain.BotCallbackQuery{
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99,
Data: []byte("inline"), InlineMessage: inline,
},
}
event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now)
if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) ||
event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline {
t.Fatalf("inline callback event=%#v ok=%v", event, ok)
}
}
func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
data := []byte("group-confirm")
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
}}}}
sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{
UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002,
Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true,
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
defer cancel()
answerCh := make(chan error, 1)
go func() {
req := &tg.MessagesGetBotCallbackAnswerRequest{
Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash},
MsgID: sent.Message.ID,
}
req.SetData(data)
_, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
answerCh <- err
}()
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
callback := event.BotCallbackQuery
if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) ||
callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out {
t.Fatalf("group callback event = %+v", event)
}
if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil {
t.Fatalf("BotAPIAnswerCallbackQuery: %v", err)
}
select {
case err := <-answerCh:
if err != nil {
t.Fatalf("group callback answer: %v", err)
}
case <-ctx.Done():
t.Fatal("group callback answer did not unblock requester")
}
}
func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent {
t.Helper()
for {
events, err := router.BotAPIUpdates(ctx, botID, 0)
if err != nil {
t.Fatalf("BotAPIUpdates: %v", err)
}
for _, event := range events {
if event.Type == domain.UpdateEventBotCallbackQuery {
return event
}
}
select {
case <-ctx.Done():
t.Fatal("callback query did not reach Bot API queue")
case <-time.After(10 * time.Millisecond):
}
}
}
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
}, zaptest.NewLogger(t), clock.System)
chatID := -botAPIChannelChatIDBase - created.Channel.ID
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0)
replyKeyboard := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
Resize: true,
}
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0)
if err != nil {
t.Fatalf("BotAPISendMessage: %v", err)
}
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body {
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body ||
history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" {
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
}
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {

View file

@ -2,11 +2,16 @@ package rpc
import (
"context"
"errors"
"time"
"telesrv/internal/domain"
)
const botAPIGetUpdatesLimit = 100
const (
botAPIGetUpdatesLimit = 100
botAPIMaxNegativeOffset = 10000
)
type botAPIChannelBotMemberProvider interface {
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
return nil, nil
}
fromID := int64(1)
if offset > 0 {
var items []domain.BotAPIUpdate
if offset < 0 {
if offset < -botAPIMaxNegativeOffset {
return nil, errors.New("OFFSET_INVALID")
}
var err error
items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit)
if err != nil {
return nil, err
}
if len(items) > 0 && items[0].ID > 1 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil {
return nil, err
}
}
} else if offset > 0 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
return nil, err
}
fromID = offset
} else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil {
return nil, err
} else if found {
fromID = confirmed + 1
}
items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
if err != nil {
return nil, err
if offset >= 0 {
confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
if err != nil {
return nil, err
}
if found {
fromID = confirmed + 1
}
items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
if err != nil {
return nil, err
}
}
if len(items) == 0 {
return nil, nil
}
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
if leadingSkipped > 0 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
return nil, err
@ -46,13 +70,19 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
return r.enrichUpdateEvents(ctx, botID, events), nil
}
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) {
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) {
privateIDs := make([]int, 0)
privateSeen := make(map[int]struct{})
channelIDs := make(map[int64][]int)
channelSeen := make(map[int64]map[int]struct{})
for _, item := range items {
if _, ok := botAPIQueuedUpdateKind(botID, item); !ok {
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
continue
}
if item.Ephemeral != nil {
continue
}
if item.Callback != nil && item.Callback.InlineMessage != nil {
continue
}
switch item.Peer.Type {
@ -79,7 +109,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
events := make([]domain.UpdateEvent, 0, len(items))
leadingSkipped := int64(0)
for _, item := range items {
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
if !ok {
if len(events) == 0 {
leadingSkipped = item.ID
@ -101,7 +131,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i
}
out := make(map[int]domain.Message, len(list.Messages))
for _, msg := range list.Messages {
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
if msg.ID <= 0 || msg.OwnerUserID != botID {
continue
}
out[msg.ID] = msg
@ -127,10 +157,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
continue
}
projected := botAPIMessageFromChannel(botID, msg)
if projected.Out || !botAPIMessageProjectable(projected) {
continue
}
byID[msg.ID] = msg
}
if len(byID) > 0 {
@ -140,14 +166,40 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
return out
}
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
if item.ID <= 0 || item.BotUserID != botID {
return "", false
}
eventType, ok := botAPIUpdateEventType(item.Kind)
if !ok {
return "", false
}
if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) {
return "", false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery {
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
return "", false
}
cb := item.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 ||
cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen {
return "", false
}
if cb.InlineMessage != nil {
inline := cb.InlineMessage
if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) ||
inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 {
return "", false
}
return eventType, true
}
if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID {
return "", false
}
} else if item.MessageID <= 0 {
return "", false
}
switch item.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if item.Peer.ID <= 0 {
@ -159,26 +211,89 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
return eventType, true
}
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) {
eventType, ok := botAPIQueuedUpdateKind(botID, item)
func botAPIQueuedEphemeralValid(botID int64, item domain.BotAPIUpdate, now time.Time) bool {
if item.Ephemeral == nil {
return true
}
message := item.Ephemeral.Message
if item.Ephemeral.Validate() != nil || item.SourcePts != 0 || item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID <= 0 ||
message.ID != item.MessageID || message.Peer != item.Peer || message.Expired(now) ||
message.SenderUserID <= 0 || message.ReceiverUserID <= 0 {
return false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery {
return message.SenderUserID == botID
}
return message.ReceiverUserID == botID
}
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
if !ok {
return domain.UpdateEvent{}, false
}
if item.Ephemeral != nil {
message := item.Ephemeral.EphemeralMessage()
event := domain.UpdateEvent{
UserID: botID, Type: eventType, Date: item.Date, Peer: item.Peer,
BotAPIUpdateID: item.ID, EphemeralMessage: &message,
}
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
event.BotCallbackQuery = &callback
}
return event, true
}
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
inline := *item.Callback.InlineMessage
callback.InlineMessage = &inline
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
BotCallbackQuery: &callback,
}, true
}
switch item.Peer.Type {
case domain.PeerTypeUser:
msg, found := privateMessages[item.MessageID]
if !found {
return domain.UpdateEvent{}, false
}
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: item.Peer,
Message: msg,
BotCallbackQuery: &callback,
}, true
}
if msg.Out || !botAPIMessageProjectable(msg) {
return domain.UpdateEvent{}, false
}
msg.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: msg.Peer,
Message: msg,
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: msg.Peer,
Message: msg,
}, true
case domain.PeerTypeChannel:
msg, found := channelMessages[item.Peer.ID][item.MessageID]
@ -186,15 +301,34 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
return domain.UpdateEvent{}, false
}
projected := botAPIMessageFromChannel(botID, msg)
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: item.Peer,
Message: projected,
BotCallbackQuery: &callback,
}, true
}
if projected.Out || !botAPIMessageProjectable(projected) {
return domain.UpdateEvent{}, false
}
projected.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: projected.Peer,
Message: projected,
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: projected.Peer,
Message: projected,
}, true
default:
return domain.UpdateEvent{}, false
@ -207,6 +341,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
return domain.UpdateEventNewMessage, true
case domain.BotAPIUpdateEditedMessage:
return domain.UpdateEventEditMessage, true
case domain.BotAPIUpdateCallbackQuery:
return domain.UpdateEventBotCallbackQuery, true
default:
return "", false
}
@ -410,7 +546,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
return media.Photo != nil
case domain.MessageMediaKindDocument:
return media.Document != nil
case domain.MessageMediaKindContact:
return media.Contact != nil
case domain.MessageMediaKindGeo:
return media.Geo != nil
case domain.MessageMediaKindVenue:
return media.Venue != nil
case domain.MessageMediaKindPoll:
return media.Poll != nil
case domain.MessageMediaKindGeoLive:
return media.GeoLive != nil
case domain.MessageMediaKindService:
if media.ServiceAction == nil {
return false
}
switch media.ServiceAction.Kind {
case domain.MessageServiceActionWebViewDataSent:
return media.ServiceAction.WebViewData != nil
case domain.MessageServiceActionRequestedPeer:
return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer)
default:
return false
}
default:
return false
}
}
func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool {
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity {
return false
}
details := make(map[domain.Peer]struct{}, len(action.Details))
for _, detail := range action.Details {
if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) {
return false
}
details[detail.Peer] = struct{}{}
}
requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested
allUsers := true
for _, peer := range action.Peers {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return false
}
if requiresDetails {
if _, ok := details[peer]; !ok {
return false
}
}
allUsers = allUsers && peer.Type == domain.PeerTypeUser
}
return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel)
}

View file

@ -0,0 +1,70 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) {
validRequestedUsers := &domain.MessageRequestedPeerAction{
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}},
}
tests := []struct {
name string
media *domain.MessageMedia
want bool
}{
{"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true},
{"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true},
{"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true},
{"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true},
{"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true},
{"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{},
}}, true},
{"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers,
}}, true},
{"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true,
},
}}, false},
{"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}},
},
}}, false},
{"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{},
}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := botAPIMessageMediaProjectable(tt.media); got != tt.want {
t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media)
}
})
}
}
func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) {
users := map[int64]struct{}{}
channels := map[int64]struct{}{}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{
{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55},
}},
},
}}, 0, users, channels)
if _, ok := users[1001]; !ok {
t.Fatalf("requested user refs=%v", users)
}
if _, ok := channels[55]; !ok {
t.Fatalf("requested channel refs=%v", channels)
}
}

View file

@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool {
func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
out := make([]domain.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description})
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
}
return out
}
@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
func tgBotCommands(in []domain.BotCommand) []tg.BotCommand {
out := make([]tg.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description})
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
}
return out
}

View file

@ -1,12 +1,14 @@
package rpc
import (
"bytes"
"context"
"time"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain"
)
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
// 推给 bot挂起等待 bot 的 setBotCallbackAnswer或超时回 BOT_RESPONSE_TIMEOUT。
type privateMessageByUIDService interface {
GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
}
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query
// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的
// setBotCallbackAnswer/answerCallbackQuery或超时回 BOT_RESPONSE_TIMEOUT。
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
return nil, dataInvalidErr()
}
botUserID := peer.ID
// game 按钮getBotCallbackAnswer.gameP3 不支持:返回空答案(客户端不弹任何东西),
// 不挂起、不推送(避免给 bot 投递无法处理的 game query
if req.Game {
@ -46,47 +48,218 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if !hasData {
return nil, dataInvalidErr()
}
if len(data) > domain.MaxCallbackDataLen {
return nil, dataInvalidErr()
}
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data)
if err != nil {
return nil, err
}
botUserID := callback.BotUserID
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return nil, internalErr()
}
if !ok || msg.Peer != peer {
return nil, messageIDInvalidErr()
defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
callback.ID = queryID
// Bot API callback_query shares the dedicated durable update_id queue with message and
// edited_message. The callback answer waiter itself remains ephemeral/process-local.
if r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: botUserID,
Kind: domain.BotAPIUpdateCallbackQuery,
Peer: callback.Peer,
MessageID: callback.MessageID,
Date: int(r.clock.Now().Unix()),
Callback: &callback,
}); err != nil {
r.log.Warn("enqueue bot api callback query",
zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(botUserID)
}
}
queryID, pending := r.callbacks.register(botUserID, userID)
defer r.callbacks.deregister(queryID)
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference仅在线推给
// botbot 离线则投递 0但仍走超时窗口I5给 bot 上线追答机会)。
// MsgID 透传请求者侧的 box idP3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo
update := &tg.UpdateBotCallbackQuery{
QueryID: queryID,
UserID: userID,
Peer: &tg.PeerUser{UserID: userID},
MsgID: req.MsgID,
ChatInstance: chatInstanceFor(botUserID, userID),
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference私聊 MessageID
// 已翻译为 bot 视角 box idchannel 使用共享 message id。
var update tg.UpdateClass
if callback.InlineMessage != nil {
inline := &tg.UpdateInlineBotCallbackQuery{
QueryID: queryID, UserID: userID,
MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
}
inline.SetData(data)
update = inline
} else {
direct := &tg.UpdateBotCallbackQuery{
QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer),
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
}
direct.SetData(data)
update = direct
}
update.SetData(data)
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(r.clock.Now().Unix()),
})
return r.waitBotCallbackAnswer(ctx, botUserID, queryID, pending)
}
func (r *Router) waitBotCallbackAnswer(ctx context.Context, botUserID, queryID int64, pending *pendingCallback) (*tg.MessagesBotCallbackAnswer, error) {
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel()
select {
case ans := <-pending.ch:
return tgBotCallbackAnswer(ans), nil
case <-waitCtx.Done():
return nil, botResponseTimeoutErr()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case ans := <-pending.ch:
return tgBotCallbackAnswer(ans), nil
case <-ticker.C:
ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID)
if err != nil {
r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
continue
}
if found {
return tgBotCallbackAnswer(ans), nil
}
case <-waitCtx.Done():
return nil, botResponseTimeoutErr()
}
}
}
// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message
// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline
// ids and therefore remain an explicit blocked path instead of being misrouted here.
func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) {
if peer.Type == domain.PeerTypeUser {
msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID)
if err != nil {
return domain.BotCallbackQuery{}, internalErr()
}
if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
if msg.ViaBotID != 0 {
if !r.userIsBot(ctx, msg.ViaBotID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
if !ok {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.ViaBotID, UserID: userID,
ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...),
InlineMessage: domainInlineMessageID(inlineID),
}, nil
}
if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
provider, ok := r.deps.Messages.(privateMessageByUIDService)
if !ok || msg.UID == 0 {
return domain.BotCallbackQuery{}, internalErr()
}
botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID)
if err != nil {
return domain.BotCallbackQuery{}, internalErr()
}
if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID ||
botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.From.ID,
UserID: userID,
Peer: botMessage.Peer,
MessageID: botMessage.ID,
ChatInstance: chatInstanceFor(msg.From.ID, userID),
Data: append([]byte(nil), data...),
}, nil
}
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return domain.BotCallbackQuery{}, peerIDInvalidErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
if err != nil {
return domain.BotCallbackQuery{}, channelInvalidErr(err)
}
if len(history.Messages) != 1 {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
msg := history.Messages[0]
if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
if msg.ViaBotID != 0 {
if !r.userIsBot(ctx, msg.ViaBotID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
if !ok {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.ViaBotID, UserID: userID,
ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...),
InlineMessage: domainInlineMessageID(inlineID),
}, nil
}
if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.SenderUserID,
UserID: userID,
Peer: peer,
MessageID: msg.ID,
ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer),
Data: append([]byte(nil), data...),
}, nil
}
func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID {
if id == nil {
return nil
}
return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
}
func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass {
return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
}
func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool {
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return false
}
for _, row := range markup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
return true
}
}
}
return false
}
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
// getBotCallbackAnswer。仅属主 bot 可解挂callerBotID==pending.botUserIDI6
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
@ -106,7 +279,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes
}
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
// 被等待者接收无关紧要(官方恒返回 true但非属主必须拒绝投递防钓鱼弹窗
r.callbacks.resolve(botID, req.QueryID, ans)
if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil {
return false, internalErr()
}
return true, nil
}

View file

@ -422,7 +422,7 @@ func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUp
if !ok {
return false, userPermissionDeniedErr()
}
u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until)
u, err := svc.UpdateEmojiStatus(ctx, target.ID, domain.UserEmojiStatus{DocumentID: documentID, Until: until})
if err != nil {
if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg
case *tg.InputKeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType)
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity
out.NameRequested = b.NameRequested
out.UsernameRequested = b.UsernameRequested
out.PhotoRequested = b.PhotoRequested
case *tg.KeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType)
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity
default:
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key
return &tg.KeyboardButtonRequestPeer{
Text: button.Text,
ButtonID: button.ButtonID,
PeerType: tgRequestPeerType(button.PeerType),
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
MaxQuantity: button.MaxQuantity,
}
}

View file

@ -1,22 +1,29 @@
package rpc
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"hash/fnv"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// callbackRegistry 是 bot callback query 的进程内挂起表messages.getBotCallbackAnswer
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待bot 经
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
// getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
// a short-lived shared store. The shared CAS is the source of truth when wired:
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
// without accepting two answers or trusting a process-local owner map.
type callbackRegistry struct {
mu sync.Mutex
pending map[int64]*pendingCallback
shared store.BotCallbackRegistryStore
}
type pendingCallback struct {
@ -26,35 +33,77 @@ type pendingCallback struct {
userID int64
}
func newCallbackRegistry() *callbackRegistry {
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
var sharedStore store.BotCallbackRegistryStore
if len(shared) > 0 {
sharedStore = shared[0]
}
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
}
// register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
defer c.mu.Unlock()
var queryID int64
for {
queryID = randomNonZeroInt64()
if _, exists := c.pending[queryID]; !exists {
break
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
return queryID, pending
}
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
for attempts := 0; attempts < 32; attempts++ {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
queryID := randomNonZeroInt64()
if _, exists := c.pending[queryID]; exists {
c.mu.Unlock()
continue
}
c.pending[queryID] = p
c.mu.Unlock()
if c.shared == nil {
return queryID, p, nil
}
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
}, ttl)
if err != nil {
c.removeLocal(queryID)
return 0, nil, err
}
if created {
return queryID, p, nil
}
c.removeLocal(queryID)
}
c.pending[queryID] = p
return queryID, p
return 0, nil, fmt.Errorf("allocate bot callback query id")
}
// deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU
func (c *callbackRegistry) deregister(queryID int64) {
c.deregisterContext(context.Background(), 0, queryID)
}
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
if botUserID == 0 {
botUserID = p.botUserID
}
delete(c.pending, queryID)
close(p.done)
}
c.mu.Unlock()
if c.shared != nil && botUserID > 0 {
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
}
}
func (c *callbackRegistry) removeLocal(queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
delete(c.pending, queryID)
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID
// 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
return resolved
}
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
if c.shared != nil {
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
if err != nil || !resolved {
return resolved, err
}
c.deliver(callerBotID, queryID, ans)
return true, nil
}
return c.deliver(callerBotID, queryID, ans), nil
}
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
c.mu.Lock()
p, ok := c.pending[queryID]
if !ok || p.botUserID != callerBotID {
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
return true
}
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
if c.shared == nil {
return domain.BotCallbackAnswer{}, false, nil
}
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
}
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
return
}
for ctx.Err() == nil {
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
})
if ctx.Err() != nil {
return
}
if err != nil && r.log != nil {
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
}
timer := time.NewTimer(time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
// 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
}
return v
}
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
// channel id to collide with a numerically equal private user id.
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
h := fnv.New64a()
var buf [17]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
switch peer.Type {
case domain.PeerTypeChannel:
buf[16] = 2
default:
buf[16] = 1
}
_, _ = h.Write(buf[:])
v := int64(h.Sum64())
if v == 0 {
return 1
}
return v
}

View file

@ -993,6 +993,22 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
})
}
// enqueueMonoforumMessageFanout only targets the subscriber sub-dialog and active parent-channel
// admins. A monoforum has no ordinary members, so member recomputation would either drop the
// message or leak it to an invalid historical membership.
func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID})
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res)
})
}
// skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合nil 表示无排除)。
func skipDeliverySet(ids []int64) map[int64]struct{} {
if len(ids) == 0 {

View file

@ -2,9 +2,12 @@ package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) {
@ -71,37 +74,54 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
channelIDs := make([]int64, 0, len(ids))
for _, input := range ids {
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 || r.deps.Channels == nil {
if !ok || ref.ID == 0 {
continue
}
refs = append(refs, ref)
channelIDs = append(channelIDs, ref.ID)
}
if len(channelIDs) == 0 || r.deps.Channels == nil {
if len(channelIDs) == 0 || (r.deps.Channels == nil && r.deps.Communities == nil) {
return &tg.MessagesChats{}, nil
}
views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
var views []domain.ChannelView
if r.deps.Channels != nil {
views, err = r.deps.Channels.GetChannels(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
}
}
byID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
byID[view.Channel.ID] = view
}
communityByID := make(map[int64]domain.CommunityView)
if r.deps.Communities != nil {
communityViews, err := r.deps.Communities.GetMany(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
}
for _, view := range communityViews {
communityByID[view.Community.ID] = view
}
}
chats := make([]tg.ChatClass, 0, len(refs))
for _, ref := range refs {
view, ok := byID[ref.ID]
if !ok || !inputChannelAccessHashMatches(ref, view.Channel) {
if view, ok := communityByID[ref.ID]; ok {
if !ref.CheckAccessHash || ref.AccessHash == view.Community.AccessHash {
chats = append(chats, tgCommunityChat(view))
}
continue
}
chats = append(chats, tgChannelChatForView(userID, view))
if view, ok := byID[ref.ID]; ok && inputChannelAccessHashMatches(ref, view.Channel) {
chats = append(chats, tgChannelChatForView(userID, view))
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return &tg.MessagesChatFull{}, nil
}
userID, _, err := r.currentUserID(ctx)
@ -112,6 +132,34 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
if !ok {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
if r.deps.Communities != nil {
view, communityErrValue := r.deps.Communities.Get(ctx, userID, ref.ID)
if communityErrValue == nil {
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return nil, channelInvalidErr(domain.ErrCommunityPrivate)
}
if settings := r.userNotifySettings(ctx, userID); len(settings) > 0 {
if setting, ok := settings[domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID}]; ok {
copy := setting.Clone()
view.State.NotifySettings = &copy
}
}
return &tg.MessagesChatFull{
FullChat: tgCommunityFull(view),
Chats: tgCommunityHydratedChats(userID, view),
Users: tgUsers(view.Users),
}, nil
}
if errors.Is(communityErrValue, domain.ErrCommunityPrivate) {
return nil, communityErr(communityErrValue)
}
if !errors.Is(communityErrValue, domain.ErrCommunityInvalid) {
return nil, communityErr(communityErrValue)
}
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
loadEpoch := r.channelFullProjectionCache.LoadEpoch()
if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok {
if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) {

View file

@ -227,7 +227,7 @@ func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEd
}
func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return false, notImplementedErr()
}
if utf8.RuneCountInString(req.About) > maxChannelAboutLength {
@ -237,6 +237,22 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
if err != nil {
return false, internalErr()
}
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
if err != nil {
return false, err
}
view, changed, err := r.deps.Communities.EditAbout(ctx, userID, community.Community.ID, req.About)
if err != nil {
return false, communityErr(err)
}
if changed {
r.pushCommunityState(ctx, userID, view)
}
return true, nil
}
if r.deps.Channels == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
if err != nil {
return false, err
@ -255,13 +271,26 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
}
func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditDefaultBannedRights(ctx, userID, community.Community.ID, domainChannelBannedRights(req.BannedRights))
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err

View file

@ -23,6 +23,13 @@ func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg
if req.ByLocation {
return &tg.MessagesChats{}, nil
}
if req.ForCommunityPeer {
channels, err := r.deps.Channels.ListCommunityLinkableChannels(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
}
channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID)
if err != nil {
return nil, internalErr()
@ -107,7 +114,7 @@ func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.Channel
}
func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return &tg.ChannelsChannelParticipants{}, nil
}
userID, _, err := r.currentUserID(ctx)
@ -122,6 +129,26 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
return nil, limitInvalidErr()
}
if community, isCommunity, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); isCommunity {
if err != nil {
return nil, err
}
list, err := r.deps.Communities.Participants(ctx, userID, community.Community.ID, filter, req.Offset, req.Limit)
if err != nil {
return nil, communityErr(err)
}
if req.Hash != 0 && list.Hash == req.Hash {
return &tg.ChannelsChannelParticipantsNotModified{}, nil
}
participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants))
for _, member := range list.Participants {
participants = append(participants, tgCommunityMember(userID, member))
}
return &tg.ChannelsChannelParticipants{Count: list.Count, Participants: participants, Chats: []tg.ChatClass{tgCommunityChat(community)}, Users: tgUsers(list.Users)}, nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit)
if err != nil {
return nil, channelInvalidErr(err)
@ -390,17 +417,13 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI
}
func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
@ -408,6 +431,33 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
if !found || target.ID == 0 {
return nil, peerIDInvalidErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditAdmin(ctx, userID, domain.CommunityEditAdminRequest{
CommunityID: community.Community.ID,
UserID: target.ID,
Rights: domainChannelAdminRights(req.AdminRights),
Rank: req.Rank,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, communityErr(err)
}
updates := r.communityMutationUpdates(ctx, userID, view, changed)
if changed && target.ID != userID {
r.refreshAndPushCommunityState(ctx, target.ID, community.Community.ID, community.Community)
}
return updates, nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{
UserID: userID,
ChannelID: channelID,

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