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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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