Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
83
internal/admin/official_gift_snapshot_test.go
Normal file
83
internal/admin/official_gift_snapshot_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
361
internal/app/account/lifecycle.go
Normal file
361
internal/app/account/lifecycle.go
Normal 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)
|
||||
}
|
||||
149
internal/app/account/lifecycle_test.go
Normal file
149
internal/app/account/lifecycle_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
185
internal/app/communities/service.go
Normal file
185
internal/app/communities/service.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
617
internal/app/ephemeral/service.go
Normal file
617
internal/app/ephemeral/service.go
Normal 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
|
||||
}
|
||||
385
internal/app/ephemeral/service_test.go
Normal file
385
internal/app/ephemeral/service_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal file
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
internal/app/stargifts/local_withdrawal.go
Normal file
50
internal/app/stargifts/local_withdrawal.go
Normal 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)
|
||||
34
internal/app/stargifts/local_withdrawal_test.go
Normal file
34
internal/app/stargifts/local_withdrawal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
56
internal/app/stargifts/official_snapshot_test.go
Normal file
56
internal/app/stargifts/official_snapshot_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 列持久化)。
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
19
internal/app/userprojection/deleted_test.go
Normal file
19
internal/app/userprojection/deleted_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int)
|
|||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;documentID=0 清除)。
|
||||
// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;零值清除)。
|
||||
// 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。
|
||||
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) {
|
||||
|
|
|
|||
104
internal/botapi/bot_commands.go
Normal file
104
internal/botapi/bot_commands.go
Normal 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)
|
||||
}
|
||||
351
internal/botapi/ephemeral.go
Normal file
351
internal/botapi/ephemeral.go
Normal 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
|
||||
}
|
||||
1029
internal/botapi/formatted_text.go
Normal file
1029
internal/botapi/formatted_text.go
Normal file
File diff suppressed because it is too large
Load diff
255
internal/botapi/formatted_text_test.go
Normal file
255
internal/botapi/formatted_text_test.go
Normal 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 < b && b > 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(``)
|
||||
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||", "", "😀"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ©) != 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)+":")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
259
internal/botapi/webhook.go
Normal 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))
|
||||
}
|
||||
127
internal/botapi/webhook_test.go
Normal file
127
internal/botapi/webhook_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
95
internal/branding/branding.go
Normal file
95
internal/branding/branding.go
Normal 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
|
||||
}
|
||||
64
internal/branding/branding_test.go
Normal file
64
internal/branding/branding_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
internal/domain/account_deletion.go
Normal file
99
internal/domain/account_deletion.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 侧回答
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
41
internal/domain/botapi_update_test.go
Normal file
41
internal/domain/botapi_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
internal/domain/botapi_webhook.go
Normal file
21
internal/domain/botapi_webhook.go
Normal 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
|
||||
}
|
||||
20
internal/domain/branding_test.go
Normal file
20
internal/domain/branding_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 映射 messageActionStarGiftUnique:collectible
|
||||
// 升级、转赠等所有权变更只进入 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 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
214
internal/domain/community.go
Normal file
214
internal/domain/community.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
362
internal/domain/ephemeral.go
Normal file
362
internal/domain/ephemeral.go
Normal 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)
|
||||
}
|
||||
64
internal/domain/ephemeral_test.go
Normal file
64
internal/domain/ephemeral_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.getSavedHistory);Peer 必须同时是 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
|
||||
|
|
|
|||
|
|
@ -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-trip(updateBotCallbackQuery.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 keyboard,Kind 会将其解释为 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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 // 收礼 peer(user/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 // 收礼 peer(user/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)))
|
||||
}
|
||||
|
|
|
|||
147
internal/domain/star_gift_collectible_test.go
Normal file
147
internal/domain/star_gift_collectible_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
internal/domain/star_gift_cursor_test.go
Normal file
31
internal/domain/star_gift_cursor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 id,Bool 为 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,
|
||||
|
|
|
|||
|
|
@ -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 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 的 transient(typing)best-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 {
|
||||
// transient(typing/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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
533
internal/officialgifts/catalog.go
Normal file
533
internal/officialgifts/catalog.go
Normal 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 ©
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
119
internal/officialgifts/catalog_test.go
Normal file
119
internal/officialgifts/catalog_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 status(premium 专属)。
|
||||
// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖
|
||||
// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给
|
||||
// 本人全部在线 session(self 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=0;collectible
|
||||
// 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
134
internal/rpc/account_collectible_emoji_status_test.go
Normal file
134
internal/rpc/account_collectible_emoji_status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
152
internal/rpc/account_deletion.go
Normal file
152
internal/rpc/account_deletion.go
Normal 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()
|
||||
}
|
||||
}
|
||||
177
internal/rpc/account_deletion_rpc_test.go
Normal file
177
internal/rpc/account_deletion_rpc_test.go
Normal 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
|
||||
}
|
||||
99
internal/rpc/account_lifecycle_worker.go
Normal file
99
internal/rpc/account_lifecycle_worker.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, " / ")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
70
internal/rpc/botapi_update_queue_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.game)P3 不支持:返回空答案(客户端不弹任何东西),
|
||||
// 不挂起、不推送(避免给 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):仅在线推给
|
||||
// bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。
|
||||
// MsgID 透传请求者侧的 box id(P3 不做 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 id,channel 使用共享 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.botUserID,I6)。
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 把答案投递到一个等待者已离开的 ch(TOCTOU)。
|
||||
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
|
||||
// 必须等于注册时的 botUserID,I6)。返回是否成功投递(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = ©
|
||||
}
|
||||
}
|
||||
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}) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue