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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -309,7 +309,7 @@ func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.Ch
}
func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
if !validChannelTitle(req.Title) {
@ -319,6 +319,19 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditTitle(ctx, userID, community.Community.ID, req.Title)
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
@ -354,7 +367,7 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi
}
func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
if req.Photo == nil {
@ -364,11 +377,24 @@ func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPh
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo)
if err != nil {
return nil, err
}
photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo)
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.SetPhoto(ctx, userID, community.Community.ID, photo, int(r.clock.Now().Unix()))
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}

View file

@ -61,13 +61,29 @@ func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.Channe
}
func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, input); ok {
if err != nil {
return nil, err
}
view, _, err := r.deps.Communities.Delete(ctx, userID, community.Community.ID, int(r.clock.Now().Unix()))
if err != nil {
return nil, communityErr(err)
}
for _, serviceMessage := range view.ServiceMessages {
r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil)
}
return r.communityMutationUpdates(ctx, userID, view, true), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, input)
if err != nil {
return nil, err
@ -669,6 +685,8 @@ func channelInvalidErr(err error) error {
return tgerr400("CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrChannelAdminRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrChannelMonoforumUnsupported):
return tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED")
case errors.Is(err, domain.ErrUserAlreadyParticipant):
return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrReplyMessageIDInvalid):

480
internal/rpc/communities.go Normal file
View file

@ -0,0 +1,480 @@
package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
const communitiesLayer = 228
func communityErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrCommunityPrivate):
return tgerr400("CHANNEL_PRIVATE")
case errors.Is(err, domain.ErrCommunityAdminRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrCommunityCreatorRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrCommunityPeersTooMuch):
return tgerr400("COMMUNITY_PEERS_TOO_MUCH")
case errors.Is(err, domain.ErrCommunityRequestCreated):
return tgerr400("COMMUNITY_REQUEST_CREATED")
case errors.Is(err, domain.ErrCommunityRequestMissing):
return tgerr400("COMMUNITY_REQUEST_MISSING")
case errors.Is(err, domain.ErrCommunityPeerLinked):
return tgerr400("COMMUNITY_PEER_ALREADY_LINKED")
case errors.Is(err, domain.ErrCommunityPeerInvalid), errors.Is(err, domain.ErrCommunityParticipantInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrChannelTitleInvalid):
return tgerr400("CHAT_TITLE_EMPTY")
case errors.Is(err, domain.ErrAboutTooLong):
return aboutTooLongErr()
case errors.Is(err, domain.ErrCommunityInvalid):
return channelInvalidErr(err)
default:
return internalErr()
}
}
func (r *Router) communityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, error) {
if r.deps.Communities == nil {
return domain.CommunityView{}, notImplementedErr()
}
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 {
return domain.CommunityView{}, channelInvalidErr(domain.ErrCommunityInvalid)
}
view, err := r.deps.Communities.Get(ctx, userID, ref.ID)
if err != nil {
return domain.CommunityView{}, communityErr(err)
}
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return domain.CommunityView{}, communityErr(domain.ErrCommunityPrivate)
}
return view, nil
}
// maybeCommunityFromInput distinguishes a Community from an ordinary channel.
// IDs share one allocator, so ErrCommunityInvalid is the only fallthrough case.
func (r *Router) maybeCommunityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, bool, error) {
if r.deps.Communities == nil {
return domain.CommunityView{}, false, nil
}
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 {
return domain.CommunityView{}, false, nil
}
view, err := r.deps.Communities.Get(ctx, userID, ref.ID)
if errors.Is(err, domain.ErrCommunityInvalid) {
return domain.CommunityView{}, false, nil
}
if err != nil {
return domain.CommunityView{}, true, communityErr(err)
}
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return domain.CommunityView{}, true, communityErr(domain.ErrCommunityPrivate)
}
return view, true, nil
}
func (r *Router) maybeCommunityFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.CommunityView, bool, error) {
ref, ok := inputPeerChannelRef(peer)
if !ok {
return domain.CommunityView{}, false, nil
}
input := &tg.InputChannel{ChannelID: ref.ID, AccessHash: ref.AccessHash}
return r.maybeCommunityFromInput(ctx, userID, input)
}
func (r *Router) communityPeerFromInput(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) {
peer, ok := r.domainPeerFromInputPeer(userID, input)
if peer.ID == 0 || (peer.Type != domain.PeerTypeChannel && peer.Type != domain.PeerTypeUser) {
return domain.Peer{}, peerIDInvalidErr()
}
if !ok {
return domain.Peer{}, peerIDInvalidErr()
}
if peer.Type == domain.PeerTypeChannel {
ref, ok := inputPeerChannelRef(input)
if !ok || r.deps.Channels == nil {
return domain.Peer{}, peerIDInvalidErr()
}
// A Community admin may approve or unlink a channel without being a
// member of that channel. Resolve the immutable base row for constructor
// and access_hash validation; aggregate authorization remains in the
// Community transaction (direct links still require channel admin rights,
// approvals require an existing validated request).
if resolver, ok := r.deps.Channels.(interface {
GetChannelByID(context.Context, int64) (domain.Channel, error)
}); ok {
channel, err := resolver.GetChannelByID(ctx, peer.ID)
if err != nil || channel.ID == 0 || channel.Deleted {
return domain.Peer{}, peerIDInvalidErr()
}
if ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, channel) {
return domain.Peer{}, channelInvalidErr(domain.ErrChannelPrivate)
}
} else if err := r.validateInputPeerChannelAccess(ctx, userID, input, peer.ID); err != nil {
return domain.Peer{}, err
}
}
return peer, nil
}
func (r *Router) communityUpdates(view domain.CommunityView) *tg.Updates {
return &tg.Updates{Updates: []tg.UpdateClass{}, Users: tgUsers(view.Users), Chats: tgCommunityHydratedChats(view.Self.UserID, view), Date: int(r.clock.Now().Unix())}
}
func (r *Router) pushCommunityState(ctx context.Context, userID int64, view domain.CommunityView) {
r.pushUserUpdates(ctx, userID, r.communityUpdates(view))
}
func (r *Router) refreshAndPushCommunityState(ctx context.Context, viewerUserID, communityID int64, fallback domain.Community) {
if viewerUserID == 0 || r.deps.Communities == nil {
return
}
view, err := r.deps.Communities.Get(ctx, viewerUserID, communityID)
if err == nil {
r.pushCommunityState(ctx, viewerUserID, view)
return
}
if errors.Is(err, domain.ErrCommunityPrivate) {
r.pushCommunityState(ctx, viewerUserID, domain.CommunityView{
Community: fallback,
Self: domain.CommunityMember{CommunityID: communityID, UserID: viewerUserID},
Forbidden: true,
})
}
}
func (r *Router) communityMutationUpdates(ctx context.Context, userID int64, view domain.CommunityView, changed bool) *tg.Updates {
out := r.communityUpdates(view)
if changed {
r.pushCommunityState(ctx, userID, view)
}
return out
}
func (r *Router) withCommunityDialogList(ctx context.Context, userID int64, filter domain.DialogFilter, list domain.DialogList) (domain.DialogList, error) {
if LayerFrom(ctx) < communitiesLayer {
return list, nil
}
return r.withCollapsedCommunityDialogs(ctx, userID, filter, list)
}
// withCollapsedCommunityDialogs applies the account-level Community dialog
// state without a wire-layer visibility decision. Business invariants such as
// the shared pinned limit use this path; RPC response construction must use
// withCommunityDialogList instead.
func (r *Router) withCollapsedCommunityDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, list domain.DialogList) (domain.DialogList, error) {
if r.deps.Communities == nil || (filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID) {
return list, nil
}
views, err := r.deps.Communities.ListJoined(ctx, userID)
if err != nil {
return domain.DialogList{}, err
}
for _, view := range views {
if !view.State.Collapsed || (filter.PinnedOnly && !view.State.Pinned) || (filter.ExcludePinned && view.State.Pinned) {
continue
}
list.Communities = append(list.Communities, view)
list.Count++
}
return list, nil
}
func (r *Router) communityDialogPeerFromInput(ctx context.Context, userID int64, input tg.InputDialogPeerClass) (domain.CommunityView, bool, error) {
peer, ok := input.(*tg.InputDialogPeerCommunity)
if !ok || peer == nil || peer.Community == nil {
return domain.CommunityView{}, false, nil
}
view, err := r.communityFromInput(ctx, userID, peer.Community)
return view, true, err
}
func (r *Router) onCommunitiesCreate(ctx context.Context, req *tg.CommunitiesCreateRequest) (tg.UpdatesClass, error) {
if req == nil || r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
visibility := domain.CommunityPeerVisible
if req.Hidden {
visibility = domain.CommunityPeerHidden
}
view, err := r.deps.Communities.Create(ctx, userID, domain.CreateCommunityRequest{Title: req.Title, About: req.About, InitialPeer: peer, Visibility: visibility, Date: int(r.clock.Now().Unix())})
if err != nil {
return nil, communityErr(err)
}
for _, serviceMessage := range view.ServiceMessages {
r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil)
}
return r.communityUpdates(view), nil
}
func (r *Router) emitCommunityLinkService(ctx context.Context, actorUserID int64, result domain.CommunityTogglePeerLinkResult) {
if result.ServiceMessage == nil {
return
}
r.enqueueChannelMessageFanout(ctx, actorUserID, *result.ServiceMessage, nil)
}
func (r *Router) onCommunitiesTogglePeerLink(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (bool, error) {
if req == nil || r.deps.Communities == nil {
return false, notImplementedErr()
}
actions := 0
if req.Visible {
actions++
}
if req.Hidden {
actions++
}
if req.Deleted {
actions++
}
if actions != 1 {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return false, err
}
visibility := domain.CommunityPeerVisible
if req.Hidden {
visibility = domain.CommunityPeerHidden
}
result, err := r.deps.Communities.TogglePeerLink(ctx, userID, domain.CommunityTogglePeerLinkRequest{CommunityID: view.Community.ID, Peer: peer, Visibility: visibility, Deleted: req.Deleted, Date: int(r.clock.Now().Unix())})
if err != nil {
return false, communityErr(err)
}
if result.RequestCreated {
return false, tgerr400("COMMUNITY_REQUEST_CREATED")
}
r.emitCommunityLinkService(ctx, userID, result)
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community)
return true, nil
}
func (r *Router) onCommunitiesGetJoined(ctx context.Context) (tg.MessagesChatsClass, error) {
if r.deps.Communities == nil {
return &tg.MessagesChats{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
views, err := r.deps.Communities.ListJoined(ctx, userID)
if err != nil {
return nil, communityErr(err)
}
return &tg.MessagesChats{Chats: tgCommunityChats(views)}, nil
}
func (r *Router) onCommunitiesToggleCollapsed(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
wasPinned := view.State.Pinned
view, changed, err := r.deps.Communities.SetCollapsed(ctx, userID, view.Community.ID, req.Collapsed)
if err != nil {
return nil, communityErr(err)
}
out := r.communityUpdates(view)
if changed && !req.Collapsed && wasPinned {
out.Updates = append(out.Updates, &tg.UpdateDialogPinned{Peer: &tg.DialogPeerCommunity{CommunityID: view.Community.ID}})
}
if changed {
r.pushCommunityState(ctx, userID, view)
}
return out, nil
}
func (r *Router) onCommunitiesGetPeerLinkRequests(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (*tg.CommunitiesPeerLinkRequests, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
page, err := r.deps.Communities.ListPeerLinkRequests(ctx, userID, view.Community.ID, req.Offset, req.Limit)
if err != nil {
return nil, communityErr(err)
}
requests := make([]tg.CommunityPeerRequest, 0, len(page.Requests))
for _, item := range page.Requests {
requests = append(requests, tg.CommunityPeerRequest{Visible: item.Visibility == domain.CommunityPeerVisible, Peer: tgPeer(item.Peer), RequestedBy: item.RequestedBy, Date: item.Date})
}
out := &tg.CommunitiesPeerLinkRequests{TotalCount: page.TotalCount, Requests: requests, Chats: tgChannels(userID, page.Channels), Users: tgUsers(page.Users)}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
return out, nil
}
func (r *Router) onCommunitiesTogglePeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return false, err
}
result, err := r.deps.Communities.DecidePeerLinkRequest(ctx, userID, view.Community.ID, peer, req.Reject, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
if !req.Reject {
r.emitCommunityLinkService(ctx, userID, result)
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community)
if result.RequestedBy != userID {
r.refreshAndPushCommunityState(ctx, result.RequestedBy, view.Community.ID, result.Community)
}
}
return true, nil
}
func (r *Router) onCommunitiesToggleAllPeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
results, err := r.deps.Communities.DecideAllPeerLinkRequests(ctx, userID, view.Community.ID, req.Reject, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
if !req.Reject {
requesters := map[int64]struct{}{}
for _, result := range results {
r.emitCommunityLinkService(ctx, userID, result)
if result.RequestedBy != 0 && result.RequestedBy != userID {
requesters[result.RequestedBy] = struct{}{}
}
}
if len(results) > 0 {
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, results[0].Community)
for requester := range requesters {
r.refreshAndPushCommunityState(ctx, requester, view.Community.ID, results[0].Community)
}
}
}
return true, nil
}
func (r *Router) onCommunitiesToggleParticipantBanned(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Participant)
if err != nil || peer.Type != domain.PeerTypeUser {
return false, peerIDInvalidErr()
}
result, err := r.deps.Communities.ToggleParticipantBanned(ctx, userID, view.Community.ID, peer.ID, req.Unban, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
for _, removed := range result.RemovedLinks {
r.emitCommunityLinkService(ctx, userID, removed)
}
for _, ban := range result.ChannelBans {
r.invalidateChannelFullBotInfoCacheForChannel(ban.Channel.ID)
r.removeOnlineChannelMemberships(ban.Channel.ID, peer.ID)
r.recordChannelStateForUser(ctx, peer.ID, ban.Channel.ID, false)
cache := newViewerPeerCache(r)
build := func(viewerUserID int64) *tg.Updates {
updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, ban.Channel, ban.Previous, ban.Participant, ban.Date, cache)
if updates != nil && ban.ServiceEvent.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, ban.ServiceEvent); update != nil {
updates.Updates = append([]tg.UpdateClass{update}, updates.Updates...)
}
}
return updates
}
r.pushChannelUpdates(ctx, userID, ban.Channel.ID, ban.Recipients, build)
}
if result.Changed && !req.Unban {
forbidden := domain.CommunityView{Community: view.Community, Forbidden: true, Self: domain.CommunityMember{UserID: peer.ID}}
r.pushUserUpdates(ctx, peer.ID, r.communityUpdates(forbidden))
}
return true, nil
}
func (r *Router) onCommunitiesGetParticipantJoinedChats(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (*tg.CommunitiesParticipantJoinedChats, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Participant)
if err != nil || peer.Type != domain.PeerTypeUser {
return nil, peerIDInvalidErr()
}
joined, err := r.deps.Communities.ParticipantJoinedChats(ctx, userID, view.Community.ID, peer.ID)
if err != nil {
return nil, communityErr(err)
}
return &tg.CommunitiesParticipantJoinedChats{CreatorChatIDs: joined.CreatorChatIDs, JoinedChatIDs: joined.JoinedChatIDs, Chats: tgChannels(userID, joined.Channels), Users: tgUsers(joined.Users)}, nil
}

View file

@ -0,0 +1,38 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
)
func (r *Router) registerCommunities(d *tlprofile.Dispatcher) {
registerRPC[*tg.CommunitiesCreateRequest](d, tlprofile.SemanticMethodCommunitiesCreate, func(ctx context.Context, req *tg.CommunitiesCreateRequest) (any, error) {
return r.onCommunitiesCreate(ctx, req)
})
registerRPC[*tg.CommunitiesTogglePeerLinkRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLink, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (any, error) {
return r.onCommunitiesTogglePeerLink(ctx, req)
})
registerRPC[*tg.CommunitiesGetJoinedCommunitiesRequest](d, tlprofile.SemanticMethodCommunitiesGetJoinedCommunities, func(ctx context.Context, req *tg.CommunitiesGetJoinedCommunitiesRequest) (any, error) {
return r.onCommunitiesGetJoined(ctx)
})
registerRPC[*tg.CommunitiesToggleCommunityCollapsedInDialogsRequest](d, tlprofile.SemanticMethodCommunitiesToggleCommunityCollapsedInDialogs, func(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (any, error) {
return r.onCommunitiesToggleCollapsed(ctx, req)
})
registerRPC[*tg.CommunitiesGetPeerLinkRequestsRequest](d, tlprofile.SemanticMethodCommunitiesGetPeerLinkRequests, func(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (any, error) {
return r.onCommunitiesGetPeerLinkRequests(ctx, req)
})
registerRPC[*tg.CommunitiesTogglePeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (any, error) {
return r.onCommunitiesTogglePeerLinkRequestApproval(ctx, req)
})
registerRPC[*tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesToggleAllPeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (any, error) {
return r.onCommunitiesToggleAllPeerLinkRequestApproval(ctx, req)
})
registerRPC[*tg.CommunitiesToggleParticipantBannedRequest](d, tlprofile.SemanticMethodCommunitiesToggleParticipantBanned, func(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (any, error) {
return r.onCommunitiesToggleParticipantBanned(ctx, req)
})
registerRPC[*tg.CommunitiesGetParticipantJoinedChatsRequest](d, tlprofile.SemanticMethodCommunitiesGetParticipantJoinedChats, func(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (any, error) {
return r.onCommunitiesGetParticipantJoinedChats(ctx, req)
})
}

View file

@ -0,0 +1,334 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appcommunities "telesrv/internal/app/communities"
appdialogs "telesrv/internal/app/dialogs"
appstories "telesrv/internal/app/stories"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func communityRPCChannel(t *testing.T, service *appchannels.Service, creator domain.User, title string, members ...domain.User) domain.Channel {
t.Helper()
memberIDs := make([]int64, 0, len(members))
for _, member := range members {
memberIDs = append(memberIDs, member.ID)
}
created, err := service.CreateChannel(context.Background(), creator.ID, domain.CreateChannelRequest{
CreatorUserID: creator.ID,
Title: title,
Megagroup: true,
MemberUserIDs: memberIDs,
Date: 1_800_100_000,
})
if err != nil {
t.Fatalf("create channel %q: %v", title, err)
}
return created.Channel
}
func TestCommunityDialogsSharePinnedLimit(t *testing.T) {
ctx := WithLayer(context.Background(), communitiesLayer)
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 711, Phone: "15552000011", FirstName: "Pin Owner"})
if err != nil {
t.Fatal(err)
}
channels := memory.NewChannelStore()
channelService := appchannels.NewService(channels)
communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil))
r := New(Config{}, Deps{
Users: appusers.NewService(users), Channels: channelService,
Communities: communityService, Dialogs: appdialogs.NewService(memory.NewDialogStore(), channels),
}, zaptest.NewLogger(t), clock.System)
inputs := make([]*tg.InputChannel, 0, domain.MaxPinnedDialogsMainFolder)
for i := 0; i < domain.MaxPinnedDialogsMainFolder; i++ {
channel := communityRPCChannel(t, channelService, owner, "Pinned Community Channel")
view, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{
Title: "Pinned Community", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
Visibility: domain.CommunityPeerVisible, Date: 1_800_110_000 + i,
})
if err != nil {
t.Fatalf("create community %d: %v", i, err)
}
if _, _, err := communityService.SetCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil {
t.Fatalf("collapse community %d: %v", i, err)
}
inputs = append(inputs, &tg.InputChannel{ChannelID: view.Community.ID, AccessHash: view.Community.AccessHash})
}
for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ {
input := inputs[i]
toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: input}}
toggle.SetPinned(true)
ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), toggle)
if err != nil || !ok {
t.Fatalf("pin community %d = %v, %v", i, ok, err)
}
}
joined, err := communityService.ListJoined(ctx, owner.ID)
if err != nil || len(joined) != domain.MaxPinnedDialogsMainFolder {
t.Fatalf("joined Communities before ordinary pin = %+v, %v", joined, err)
}
for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ {
if !joined[i].State.Pinned || !joined[i].State.Collapsed {
t.Fatalf("joined Community %d state before ordinary pin = %+v", i, joined[i].State)
}
}
ordinary := communityRPCChannel(t, channelService, owner, "Ordinary Pinned Channel")
ordinaryToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{
ChannelID: ordinary.ID, AccessHash: ordinary.AccessHash,
}}}
ordinaryToggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), ordinaryToggle); err != nil || !ok {
t.Fatalf("pin ordinary dialog at shared limit = %v, %v", ok, err)
}
pinned, err := r.pinnedDialogsList(ctx, owner.ID, domain.DialogMainFolderID)
if err != nil {
t.Fatal(err)
}
order := combinedPinnedDialogPeers(pinned)
if len(order) != domain.MaxPinnedDialogsMainFolder || order[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: ordinary.ID}) {
t.Fatalf("combined pinned order = %+v (dialogs=%+v communities=%+v count=%d), want ordinary dialog promoted above Communities", order, pinned.Dialogs, pinned.Communities, pinned.Count)
}
legacyPinned, err := r.pinnedDialogsList(WithLayer(ctx, 227), owner.ID, domain.DialogMainFolderID)
if err != nil {
t.Fatal(err)
}
if len(legacyPinned.Communities) != 0 || len(legacyPinned.Dialogs) != 1 || legacyPinned.Count != 1 {
t.Fatalf("Layer 227 pinned dialogs = %+v, want only the ordinary pinned dialog", legacyPinned)
}
legacyOrdinary := communityRPCChannel(t, channelService, owner, "Legacy Ordinary Pinned Channel")
legacyToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{
ChannelID: legacyOrdinary.ID, AccessHash: legacyOrdinary.AccessHash,
}}}
legacyToggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(WithLayer(WithUserID(ctx, owner.ID), 227), legacyToggle); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") {
t.Fatalf("Layer 227 pin beyond shared account limit = %v, %v", ok, err)
}
overLimit := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: inputs[len(inputs)-1]}}
overLimit.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), overLimit); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") {
t.Fatalf("pin over shared limit = %v, %v", ok, err)
}
if _, err := r.onMessagesReorderPinnedDialogs(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedDialogsRequest{
FolderID: domain.DialogArchiveFolderID,
Order: []tg.InputDialogPeerClass{&tg.InputDialogPeerCommunity{Community: inputs[0]}},
}); err == nil || !tgerr.Is(err, "FOLDER_ID_INVALID") {
t.Fatalf("archive Community reorder error = %v", err)
}
}
func TestCommunitiesRPCLayer228Lifecycle(t *testing.T) {
ctx := WithLayer(context.Background(), communitiesLayer)
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 701, Phone: "15552000001", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 702, Phone: "15552000002", FirstName: "Member"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
initial := communityRPCChannel(t, channelService, owner, "Initial", member)
communityService := appcommunities.NewService(memory.NewCommunityStore(userStore, channelStore, nil, nil))
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, ID: 1,
Date: 1_800_100_001, ExpireDate: 1_900_100_001, Public: true,
}}); err != nil {
t.Fatalf("seed owner story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Communities: communityService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_800_100_100, 0)})
createdResult, err := r.onCommunitiesCreate(WithUserID(ctx, owner.ID), &tg.CommunitiesCreateRequest{
Hidden: true,
Title: "Official Community",
About: "Layer 228",
Peer: &tg.InputPeerChannel{ChannelID: initial.ID, AccessHash: initial.AccessHash},
})
if err != nil {
t.Fatalf("communities.create: %v", err)
}
created, ok := createdResult.(*tg.Updates)
if !ok || len(created.Chats) != 1 {
t.Fatalf("create result = %#v, want Updates with Community", createdResult)
}
community, ok := created.Chats[0].(*tg.Community)
if !ok || community.Title != "Official Community" || !community.Creator {
t.Fatalf("create chat = %#v", created.Chats[0])
}
inputCommunity := &tg.InputChannel{ChannelID: community.ID, AccessHash: community.AccessHash}
joinedResult, err := r.onCommunitiesGetJoined(WithUserID(ctx, member.ID))
if err != nil {
t.Fatalf("communities.getJoinedCommunities: %v", err)
}
joined := joinedResult.(*tg.MessagesChats)
if len(joined.Chats) != 1 || joined.Chats[0].(*tg.Community).ID != community.ID {
t.Fatalf("joined communities = %+v", joined.Chats)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputCommunity)
if err != nil {
t.Fatalf("channels.getFullChannel community: %v", err)
}
communityFull, ok := full.FullChat.(*tg.CommunityFull)
if !ok || communityFull.About != "Layer 228" || len(communityFull.LinkedPeers) != 1 || len(full.Chats) != 2 {
t.Fatalf("community full = %#v chats=%+v", full.FullChat, full.Chats)
}
collapsedResult, err := r.onCommunitiesToggleCollapsed(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleCommunityCollapsedInDialogsRequest{
Collapsed: true,
Community: inputCommunity,
})
if err != nil {
t.Fatalf("toggle collapsed: %v", err)
}
collapsed := collapsedResult.(*tg.Updates)
if len(collapsed.Chats) == 0 || !collapsed.Chats[0].(*tg.Community).CollapsedInDialogs {
t.Fatalf("collapsed updates = %+v", collapsed.Chats)
}
legacyList, err := r.withCommunityDialogList(WithLayer(ctx, 227), owner.ID, domain.DialogFilter{}, domain.DialogList{Count: 7})
if err != nil || len(legacyList.Communities) != 0 || legacyList.Count != 7 {
t.Fatalf("Layer 227 community dialog projection = %+v err=%v, want unchanged list", legacyList, err)
}
list, err := r.withCommunityDialogList(ctx, owner.ID, domain.DialogFilter{}, domain.DialogList{})
if err != nil || len(list.Communities) != 1 || list.Count != 1 {
t.Fatalf("community dialog list = %+v err=%v", list, err)
}
dialogs := tgMessagesDialogs(owner.ID, list).(*tg.MessagesDialogs)
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want dialogCommunity", dialogs.Dialogs)
}
if dialog, ok := dialogs.Dialogs[0].(*tg.DialogCommunity); !ok || dialog.CommunityID != community.ID {
t.Fatalf("dialog = %#v, want community %d", dialogs.Dialogs[0], community.ID)
}
ownedOne := communityRPCChannel(t, channelService, member, "Owned One")
ownedTwo := communityRPCChannel(t, channelService, member, "Owned Two")
requestLink := func(channel domain.Channel) {
t.Helper()
ok, err := r.onCommunitiesTogglePeerLink(WithUserID(ctx, member.ID), &tg.CommunitiesTogglePeerLinkRequest{
Visible: true,
Community: inputCommunity,
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err == nil || ok || !tgerr.Is(err, "COMMUNITY_REQUEST_CREATED") {
t.Fatalf("request link %d = %v, %v", channel.ID, ok, err)
}
}
requestLink(ownedOne)
requestLink(ownedTwo)
requests, err := r.onCommunitiesGetPeerLinkRequests(WithUserID(ctx, owner.ID), &tg.CommunitiesGetPeerLinkRequestsRequest{
Community: inputCommunity,
Limit: 20,
})
if err != nil || requests.TotalCount != 2 || len(requests.Requests) != 2 {
t.Fatalf("peer link requests = %+v err=%v", requests, err)
}
// The Community owner is deliberately not a member of Owned One. Approval
// must use the request's validated ownership rather than ordinary channel
// membership access.
approved, err := r.onCommunitiesTogglePeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesTogglePeerLinkRequestApprovalRequest{
Community: inputCommunity,
Peer: &tg.InputPeerChannel{ChannelID: ownedOne.ID, AccessHash: ownedOne.AccessHash},
})
if err != nil || !approved {
t.Fatalf("approve peer link = %v, %v", approved, err)
}
approved, err = r.onCommunitiesToggleAllPeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest{Community: inputCommunity})
if err != nil || !approved {
t.Fatalf("approve all peer links = %v, %v", approved, err)
}
joinedChats, err := r.onCommunitiesGetParticipantJoinedChats(WithUserID(ctx, owner.ID), &tg.CommunitiesGetParticipantJoinedChatsRequest{
Community: inputCommunity,
Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash},
})
if err != nil || len(joinedChats.JoinedChatIDs) != 3 || len(joinedChats.CreatorChatIDs) != 2 {
t.Fatalf("participant joined chats = %+v err=%v", joinedChats, err)
}
participantsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsSearch{Q: "memBER"},
Limit: 20,
})
if err != nil {
t.Fatalf("community participants search: %v", err)
}
participants := participantsResult.(*tg.ChannelsChannelParticipants)
if participants.Count != 1 || len(participants.Participants) != 1 {
t.Fatalf("community participants = %+v", participants)
}
adminsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 100,
})
if err != nil {
t.Fatalf("ordinary member Community admins: %v", err)
}
admins := adminsResult.(*tg.ChannelsChannelParticipants)
if admins.Count != 1 || len(admins.Participants) != 1 {
t.Fatalf("ordinary member Community admins = %+v, want creator", admins)
}
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsBanned{Q: ""},
Limit: 100,
}); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
t.Fatalf("ordinary member Community banned list err = %v, want CHAT_ADMIN_REQUIRED", err)
}
recent, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{
&tg.InputPeerSelf{},
&tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash},
&tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("stories.getPeerMaxIDs with Community slot: %v", err)
}
if len(recent) != 3 {
t.Fatalf("stories.getPeerMaxIDs slots = %d, want 3", len(recent))
}
for _, index := range []int{0, 2} {
if maxID, ok := recent[index].GetMaxID(); !ok || maxID != 1 {
t.Fatalf("stories.getPeerMaxIDs[%d] max_id = %d ok=%v, want 1 true", index, maxID, ok)
}
}
if maxID, ok := recent[1].GetMaxID(); ok || maxID != 0 || recent[1].Live {
t.Fatalf("stories.getPeerMaxIDs Community slot = %+v, want empty recentStory", recent[1])
}
if _, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{
&tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash + 1},
}); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") {
t.Fatalf("stories.getPeerMaxIDs Community wrong hash err = %v, want CHANNEL_PRIVATE", err)
}
banned, err := r.onCommunitiesToggleParticipantBanned(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleParticipantBannedRequest{
Community: inputCommunity,
Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash},
})
if err != nil || !banned {
t.Fatalf("toggle participant banned = %v, %v", banned, err)
}
joinedResult, err = r.onCommunitiesGetJoined(WithUserID(ctx, member.ID))
if err != nil || len(joinedResult.(*tg.MessagesChats).Chats) != 0 {
t.Fatalf("banned member joined communities = %#v err=%v", joinedResult, err)
}
}

View file

@ -82,7 +82,7 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
return nil
}
peer := &tg.PeerChannel{ChannelID: m.ChannelID}
outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel
outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && (m.SavedPeer.ID != 0 || m.From.Type != domain.PeerTypeChannel)
from := tg.PeerClass(nil)
if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 {
from = tgPeer(*m.SendAs)
@ -139,6 +139,12 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
// 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
}
if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok {
msg.SetSuggestedPost(suggested)
}
if m.PaidMessageStars > 0 {
msg.SetPaidMessageStars(m.PaidMessageStars)
}
if m.Pinned {
msg.SetPinned(true)
}
@ -284,11 +290,19 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
}
case domain.ChannelActionStarGift:
return tgMessageActionStarGift(action.StarGift)
case domain.ChannelActionStarGiftUnique:
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
case domain.ChannelActionSetChatWallpaper:
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
}
return nil
case domain.ChannelActionChangeCommunity:
out := &tg.MessageActionChangeCommunity{}
if action.CommunityID != 0 {
out.SetCommunityID(action.CommunityID)
}
return out
default:
return nil
}
@ -423,6 +437,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed {
out.SetLinkedMonoforumID(ch.LinkedMonoforumID)
}
if ch.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(ch.LinkedCommunityID)
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
@ -792,21 +809,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
Other: true,
ManageRanks: rights.ManageRanks,
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
Other: true,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
ManageDirectMessages: rights.ManageDirectMessages,
@ -832,36 +851,40 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
ManageChat: rights.Other,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
return tg.ChatBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}
@ -875,29 +898,30 @@ func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedR
func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights {
return domain.ChannelBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}

View file

@ -0,0 +1,104 @@
package rpc
import (
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func tgCommunityPhoto(c domain.Community) tg.ChatPhotoClass {
if c.PhotoID == 0 {
return &tg.ChatPhotoEmpty{}
}
out := &tg.ChatPhoto{PhotoID: c.PhotoID, DCID: c.PhotoDCID}
if len(c.PhotoStripped) > 0 {
out.SetStrippedThumb(c.PhotoStripped)
}
return out
}
func tgCommunityFullPhoto(c domain.Community) tg.PhotoClass {
if c.PhotoID == 0 {
return &tg.PhotoEmpty{}
}
sizes := syntheticAvatarSizes()
if len(c.PhotoStripped) > 0 {
sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: c.PhotoStripped}}, sizes...)
}
return &tg.Photo{ID: c.PhotoID, DCID: c.PhotoDCID, Sizes: sizes}
}
func tgCommunityChat(view domain.CommunityView) tg.ChatClass {
c := view.Community
if c.Deleted || view.Forbidden {
return &tg.CommunityForbidden{ID: c.ID, AccessHash: c.AccessHash, Title: c.Title}
}
out := &tg.Community{Creator: view.Creator(), CollapsedInDialogs: view.State.Collapsed, ID: c.ID, Title: c.Title, Photo: tgCommunityPhoto(c), Date: c.Date}
out.SetAccessHash(c.AccessHash)
if view.Self.Role == domain.CommunityRoleCreator {
out.SetAdminRights(tgChatAdminRights(domain.CreatorChannelAdminRights()))
} else if view.Self.Role == domain.CommunityRoleAdmin {
out.SetAdminRights(tgChatAdminRights(view.Self.AdminRights))
}
out.SetDefaultBannedRights(tgDefaultChatBannedRights(c.DefaultBannedRights))
return out
}
func tgCommunityChats(views []domain.CommunityView) []tg.ChatClass {
out := make([]tg.ChatClass, 0, len(views))
for _, v := range views {
out = append(out, tgCommunityChat(v))
}
return out
}
func tgCommunityPeer(link domain.CommunityPeerLink) tg.CommunityPeer {
out := tg.CommunityPeer{CanViewHistory: link.CanViewHistory, Peer: tgPeer(link.Peer)}
out.SetVisible(link.Visible())
return out
}
func tgCommunityFull(view domain.CommunityView) *tg.CommunityFull {
links := make([]tg.CommunityPeer, 0, len(view.Links))
for _, l := range view.Links {
links = append(links, tgCommunityPeer(l))
}
out := &tg.CommunityFull{ID: view.Community.ID, About: view.Community.About, ChatPhoto: tgCommunityFullPhoto(view.Community), LinkedPeers: links}
if view.AdminsCount > 0 {
out.SetAdminsCount(view.AdminsCount)
}
if view.KickedCount > 0 {
out.SetKickedCount(view.KickedCount)
}
if view.PendingRequests > 0 {
out.SetPeerLinkRequestsPending(view.PendingRequests)
}
return out
}
func tgCommunityHydratedChats(viewerUserID int64, view domain.CommunityView) []tg.ChatClass {
out := []tg.ChatClass{tgCommunityChat(view)}
for _, ch := range view.Channels {
out = appendUniqueTGChats(out, tgChannelChatMin(viewerUserID, ch))
}
return out
}
func tgCommunityMember(viewerUserID int64, m domain.CommunityMember) tg.ChannelParticipantClass {
cm := domain.ChannelMember{ChannelID: m.CommunityID, UserID: m.UserID, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleMember, AdminRights: m.AdminRights, Rank: m.Rank, JoinedAt: m.Date}
if m.Status == domain.CommunityMemberKicked {
cm.Status = domain.ChannelMemberKicked
cm.BannedRights = domain.ChannelBannedRights{ViewMessages: true}
}
switch m.Role {
case domain.CommunityRoleCreator:
cm.Role = domain.ChannelRoleCreator
case domain.CommunityRoleAdmin:
cm.Role = domain.ChannelRoleAdmin
}
return tgChannelParticipant(viewerUserID, cm)
}
func tgCommunityDialog(view domain.CommunityView, notify *domain.PeerNotifySettings) *tg.DialogCommunity {
return &tg.DialogCommunity{Pinned: view.State.Pinned, CommunityID: view.Community.ID, NotifySettings: *tgPeerNotifySettings(notify)}
}

View file

@ -1,10 +1,56 @@
package rpc
import (
"sort"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
type dialogProjection struct {
dialog tg.DialogClass
pinned bool
pinnedOrder int
sequence int
}
func projectedDialogs(list domain.DialogList) []tg.DialogClass {
items := make([]dialogProjection, 0, len(list.Dialogs)+len(list.Communities))
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
items = append(items, dialogProjection{dialog: dialog, pinned: d.Pinned, pinnedOrder: d.PinnedOrder, sequence: len(items)})
}
}
for _, community := range list.Communities {
items = append(items, dialogProjection{
dialog: tgCommunityDialog(community, community.State.NotifySettings), pinned: community.State.Pinned,
pinnedOrder: community.State.PinnedOrder, sequence: len(items),
})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].pinned != items[j].pinned {
return items[i].pinned
}
if items[i].pinned && items[i].pinnedOrder != items[j].pinnedOrder {
return items[i].pinnedOrder > items[j].pinnedOrder
}
return items[i].sequence < items[j].sequence
})
out := make([]tg.DialogClass, 0, len(items))
for _, item := range items {
out = append(out, item.dialog)
}
return out
}
func appendCommunityDialogObjects(viewerUserID int64, list domain.DialogList, chats []tg.ChatClass, users []tg.UserClass) ([]tg.ChatClass, []tg.UserClass) {
for _, community := range list.Communities {
chats = appendUniqueTGChats(chats, tgCommunityHydratedChats(viewerUserID, community)...)
users = appendUniqueTGUsers(users, tgUsersForViewer(viewerUserID, community.Users)...)
}
return chats, users
}
func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass {
dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)+1)
// dialogFolder 条目排在最前TDesktop 据它发现 archive folder 并渲染
@ -12,11 +58,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
dialogs = append(dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
dialogs = append(dialogs, dialog)
}
}
dialogs = append(dialogs, projectedDialogs(list)...)
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
@ -30,6 +72,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
}
users := tgUsersForViewer(viewerUserID, list.Users)
chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)
chats, users = appendCommunityDialogObjects(viewerUserID, list, chats, users)
if list.Count > len(dialogs) {
return &tg.MessagesDialogsSlice{
Count: list.Count,
@ -61,11 +104,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
out.Dialogs = append(out.Dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
out.Dialogs = append(out.Dialogs, dialog)
}
}
out.Dialogs = append(out.Dialogs, projectedDialogs(list)...)
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
out.Messages = append(out.Messages, item)
@ -84,6 +123,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS
}
}
out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...)
out.Chats, out.Users = appendCommunityDialogObjects(viewerUserID, list, out.Chats, out.Users)
return out
}
@ -165,6 +205,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
out.SetRichMessage(*rich)
}
if suggested, ok := tgSuggestedPost(d.SuggestedPost); ok {
out.SetSuggestedPost(suggested)
}
return out
}
@ -201,6 +244,9 @@ func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass {
}
func tgDialogPeer(p domain.Peer) tg.DialogPeerClass {
if p.Type == domain.PeerTypeCommunity && p.ID > 0 {
return &tg.DialogPeerCommunity{CommunityID: p.ID}
}
peer := tgPeer(p)
if peer == nil {
return nil

View file

@ -1,6 +1,7 @@
package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
@ -9,9 +10,30 @@ import (
"telesrv/internal/domain"
)
// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
// valid in both megagroups and broadcasts.
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
return nil
}
if r == nil || r.deps.Channels == nil {
return channelInvalidErr(domain.ErrChannelInvalid)
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return channelInvalidErr(err)
}
if view.Channel.Broadcast && !view.Channel.Megagroup {
return replyMarkupInvalidErr()
}
return nil
}
// P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") }
func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。
@ -21,18 +43,21 @@ func replyMarkupErr(err error) error {
return buttonDataInvalidErr()
case errors.Is(err, domain.ErrButtonURLInvalid):
return buttonURLInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid):
case errors.Is(err, domain.ErrButtonTypeInvalid):
return buttonTypeInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid):
return buttonInvalidErr()
default:
return replyMarkupInvalidErr()
}
}
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义:
// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markupinline
// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。
// 语义:
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐
// 官方「普通用户 markup 无效」I1
// - 仅 ReplyInlineMarkup 被处理reply keyboard 家族(自定义键盘/隐藏/强制回复)
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4
// - 仅 ReplyInlineMarkup 被处理bot 的 reply keyboard 家族在这些上下文中显式拒绝。
// - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/
// request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
}
inline, ok := markup.(*tg.ReplyInlineMarkup)
if !ok {
// reply keyboard / hide / force-replyP3 不支持,丢弃。
return nil, nil
return nil, domain.ErrButtonTypeInvalid
}
parsed, err := domainInlineMarkup(inline)
if err != nil {
@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
return parsed, nil
}
// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。
// 非 bot 携带 markup 仍按官方权限边界静默丢弃bot 的未知/未实现按钮则拒绝整条消息,
// 避免客户端看到一个被服务端悄悄改形的键盘。
func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) {
if markup == nil || !senderIsBot {
return nil, nil
}
switch v := markup.(type) {
case *tg.ReplyInlineMarkup:
return domainReplyMarkupForSender(v, true)
case *tg.ReplyKeyboardMarkup:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)),
Resize: v.Resize,
SingleUse: v.SingleUse,
Selective: v.Selective,
Persistent: v.Persistent,
Placeholder: v.Placeholder,
}
for _, row := range v.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, button := range row.Buttons {
parsed, err := domainReplyKeyboardButton(button)
if err != nil {
return nil, err
}
domainRow = append(domainRow, parsed)
}
out.Keyboard = append(out.Keyboard, domainRow)
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardHide:
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardForceReply:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupForceReply,
SingleUse: v.SingleUse,
Selective: v.Selective,
Placeholder: v.Placeholder,
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
default:
return nil, domain.ErrButtonTypeInvalid
}
}
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(button)
if err != nil {
return domain.MarkupButton{}, err
}
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
switch b := button.(type) {
case *tg.KeyboardButton:
base.Type, base.Text = domain.MarkupButtonText, b.Text
case *tg.KeyboardButtonRequestPhone:
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
case *tg.KeyboardButtonRequestGeoLocation:
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
case *tg.KeyboardButtonRequestPoll:
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
if quiz, ok := b.GetQuiz(); ok {
if quiz {
base.PollType = "quiz"
} else {
base.PollType = "regular"
}
}
case *tg.KeyboardButtonRequestPeer:
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
case *tg.KeyboardButtonSimpleWebView:
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
default:
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
return base, nil
}
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range row.Buttons {
@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
}
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
if err != nil {
return domain.MarkupButton{}, err
}
switch b := btn.(type) {
case *tg.KeyboardButtonCallback:
return domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: b.Text,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
Type: domain.MarkupButtonCallback,
Text: b.Text,
Style: style,
IconCustomEmojiID: icon,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil
case *tg.KeyboardButtonURL:
return domain.MarkupButton{
Type: domain.MarkupButtonURL,
Text: b.Text,
URL: b.URL,
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonSwitchInline:
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
if err != nil {
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonCopy:
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
}
// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
style, ok := btn.GetStyle()
if !ok {
return "", 0, nil
}
colors := 0
var out domain.MarkupButtonStyle
if style.GetBgPrimary() {
colors++
out = domain.MarkupButtonStylePrimary
}
if style.GetBgDanger() {
colors++
out = domain.MarkupButtonStyleDanger
}
if style.GetBgSuccess() {
colors++
out = domain.MarkupButtonStyleSuccess
}
icon, hasIcon := style.GetIcon()
if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) {
return "", 0, domain.ErrButtonInvalid
}
return out, icon, nil
}
func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) {
var out tg.KeyboardButtonStyle
switch btn.Style {
case domain.MarkupButtonStylePrimary:
out.SetBgPrimary(true)
case domain.MarkupButtonStyleDanger:
out.SetBgDanger(true)
case domain.MarkupButtonStyleSuccess:
out.SetBgSuccess(true)
}
if btn.IconCustomEmojiID > 0 {
out.SetIcon(btn.IconCustomEmojiID)
}
return out, btn.Style != "" || btn.IconCustomEmojiID > 0
}
// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。
func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
if m.IsZero() {
return nil
}
switch m.Kind() {
case domain.MessageReplyMarkupKeyboard:
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
for _, row := range m.Keyboard {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgReplyKeyboardButton(btn))
}
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
}
return &tg.ReplyKeyboardMarkup{
Resize: m.Resize,
SingleUse: m.SingleUse,
Selective: m.Selective,
Persistent: m.Persistent,
Rows: rows,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupHide:
return &tg.ReplyKeyboardHide{Selective: m.Selective}
case domain.MessageReplyMarkupForceReply:
return &tg.ReplyKeyboardForceReply{
SingleUse: m.SingleUse,
Selective: m.Selective,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupInline:
// Continue below.
default:
return nil
}
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
switch btn.Type {
case domain.MarkupButtonURL:
return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonWebView:
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonSwitchInline:
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
if len(btn.PeerTypes) > 0 {
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonCopy:
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
if btn.RequiresPassword {
out.SetRequiresPassword(true)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
}
}
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
var out tg.KeyboardButtonClass
switch btn.Type {
case domain.MarkupButtonRequestPhone:
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
case domain.MarkupButtonRequestLocation:
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
case domain.MarkupButtonRequestPoll:
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
if btn.PollType == "quiz" {
button.SetQuiz(true)
} else if btn.PollType == "regular" {
button.SetQuiz(false)
}
out = button
case domain.MarkupButtonRequestPeer:
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
case domain.MarkupButtonSimpleWebView:
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
default:
out = &tg.KeyboardButton{Text: btn.Text}
}
if style, ok := tgMarkupButtonStyle(btn); ok {
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
setter.SetStyle(style)
}
}
return out
}
func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) {
filter := &domain.BotRequestPeerFilter{}
switch v := peerType.(type) {
case *tg.RequestPeerTypeUser:
if value, ok := v.GetBot(); ok {
filter.UserIsBotSet, filter.UserIsBot = true, value
}
if value, ok := v.GetPremium(); ok {
filter.UserIsPremiumSet, filter.UserIsPremium = true, value
}
if !filter.UserIsBotSet && !filter.UserIsPremiumSet {
return "user", nil
}
return "user", filter
case *tg.RequestPeerTypeChat:
filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if value, ok := v.GetForum(); ok {
filter.ChatIsForumSet, filter.ChatIsForum = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "chat", nil
}
return "chat", filter
case *tg.RequestPeerTypeBroadcast:
filter.ChatIsCreated = v.Creator
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "broadcast", nil
}
return "broadcast", filter
default:
return "", nil
}
}
func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool {
return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet &&
!filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil)
}
func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass {
switch kind {
case "chat":
out := &tg.RequestPeerTypeChat{}
if filter != nil {
out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.ChatIsForumSet {
out.SetForum(filter.ChatIsForum)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
case "broadcast":
out := &tg.RequestPeerTypeBroadcast{}
if filter != nil {
out.Creator = filter.ChatIsCreated
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
default:
out := &tg.RequestPeerTypeUser{}
if filter != nil {
if filter.UserIsBotSet {
out.SetBot(filter.UserIsBot)
}
if filter.UserIsPremiumSet {
out.SetPremium(filter.UserIsPremium)
}
}
return out
}
}
func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights {
return domain.BotRequestAdminRights{
Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages,
ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages,
ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}

View file

@ -0,0 +1,171 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
in := &tg.ReplyKeyboardMarkup{
Resize: true,
SingleUse: true,
Selective: true,
Persistent: true,
Placeholder: "Choose",
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButton{Text: "Help"},
func() *tg.KeyboardButton {
button := &tg.KeyboardButton{Text: "Status"}
style := tg.KeyboardButtonStyle{}
style.SetBgPrimary(true)
style.SetIcon(123456)
button.SetStyle(style)
return button
}(),
}}},
}
got, err := domainOutgoingReplyMarkupForSender(in, true)
if err != nil {
t.Fatalf("domainOutgoingReplyMarkupForSender: %v", err)
}
if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 ||
len(got.Keyboard[0]) != 2 || got.Keyboard[0][0].Text != "Help" || !got.Resize ||
!got.SingleUse || !got.Selective || !got.Persistent || got.Placeholder != "Choose" {
t.Fatalf("domain markup = %#v", got)
}
if got.Keyboard[0][1].Style != domain.MarkupButtonStylePrimary || got.Keyboard[0][1].IconCustomEmojiID != 123456 {
t.Fatalf("second button decoration = %#v", got.Keyboard[0][1])
}
wire, ok := tgReplyMarkup(got).(*tg.ReplyKeyboardMarkup)
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 {
t.Fatalf("wire markup = %#v", wire)
}
if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" {
t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1])
} else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 {
t.Fatalf("second button style = %#v ok=%v", style, ok)
}
if !wire.Resize || !wire.SingleUse || !wire.Selective || !wire.Persistent || wire.Placeholder != "Choose" {
t.Fatalf("wire flags = %#v", wire)
}
}
func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")}
style := tg.KeyboardButtonStyle{}
style.SetBgDanger(true)
button.SetStyle(style)
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
if err != nil {
t.Fatalf("domainReplyMarkupForSender: %v", err)
}
if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger {
t.Fatalf("domain style = %#v", got.Inline[0][0])
}
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() {
t.Fatalf("wire style = %#v ok=%v", roundTrip, ok)
}
}
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
if err != nil {
t.Fatalf("hide parse: %v", err)
}
if wire, ok := tgReplyMarkup(hide).(*tg.ReplyKeyboardHide); !ok || !wire.Selective {
t.Fatalf("hide wire = %#v", wire)
}
force, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardForceReply{
SingleUse: true, Selective: true, Placeholder: "Answer",
}, true)
if err != nil {
t.Fatalf("force parse: %v", err)
}
if wire, ok := tgReplyMarkup(force).(*tg.ReplyKeyboardForceReply); !ok || !wire.SingleUse || !wire.Selective || wire.Placeholder != "Answer" {
t.Fatalf("force wire = %#v", wire)
}
}
func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}},
}}}, true)
if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 ||
markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone {
t.Fatalf("request_phone markup = %#v err=%v", markup, err)
}
wire, ok := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 {
t.Fatalf("request_phone wire = %#v", wire)
}
if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok {
t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0])
}
if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil {
t.Fatal("inline-only edit/result parser must reject reply-keyboard constructors")
}
}
func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
userType := &tg.RequestPeerTypeUser{}
userType.SetBot(false)
userType.SetPremium(true)
chatType := &tg.RequestPeerTypeChat{Creator: true, BotParticipant: true}
chatType.SetHasUsername(false)
chatType.SetForum(true)
chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true})
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2},
&tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1},
}}}}
markup, err := domainOutgoingReplyMarkupForSender(in, true)
if err != nil {
t.Fatalf("parse request peer filters: %v", err)
}
userFilter := markup.Keyboard[0][0].RequestPeerFilter
chatFilter := markup.Keyboard[0][1].RequestPeerFilter
if userFilter == nil || !userFilter.UserIsBotSet || userFilter.UserIsBot || !userFilter.UserIsPremiumSet || !userFilter.UserIsPremium {
t.Fatalf("user filter = %#v", userFilter)
}
if chatFilter == nil || !chatFilter.ChatIsCreated || !chatFilter.BotIsMember || !chatFilter.ChatHasUsernameSet ||
chatFilter.ChatHasUsername || !chatFilter.ChatIsForumSet || !chatFilter.ChatIsForum ||
chatFilter.UserAdminRights == nil || !chatFilter.UserAdminRights.DeleteMessages || !chatFilter.UserAdminRights.ManageTopics {
t.Fatalf("chat filter = %#v", chatFilter)
}
wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
if bot, ok := wireUser.GetBot(); !ok || bot {
t.Fatalf("wire user bot=%v ok=%v", bot, ok)
}
if premium, ok := wireUser.GetPremium(); !ok || !premium {
t.Fatalf("wire user premium=%v ok=%v", premium, ok)
}
wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
if !wireChat.Creator || !wireChat.BotParticipant {
t.Fatalf("wire chat = %#v", wireChat)
}
if hasUsername, ok := wireChat.GetHasUsername(); !ok || hasUsername {
t.Fatalf("wire has_username=%v ok=%v", hasUsername, ok)
}
if rights, ok := wireChat.GetUserAdminRights(); !ok || !rights.DeleteMessages || !rights.ManageTopics {
t.Fatalf("wire rights=%#v ok=%v", rights, ok)
}
}
func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) {
button := &tg.InputKeyboardButtonRequestPeer{
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
}
got, err := domainRequestedButtonFromTG(1001, nil, button)
if err != nil {
t.Fatal(err)
}
if !got.NameRequested || !got.UsernameRequested || !got.PhotoRequested || got.MaxQuantity != 3 {
t.Fatalf("requested button=%#v", got)
}
}

View file

@ -27,6 +27,10 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
if m.TTLSeconds > 0 {
out.TTLSeconds = m.TTLSeconds
}
if m.LivePhotoVideo != nil {
out.LivePhoto = true
out.SetVideo(tgDocument(*m.LivePhotoVideo))
}
return out
case domain.MessageMediaKindDocument:
nopremium := m.Nopremium

View file

@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
msg.SetInvertMedia(true)
}
}
// reply_markupbot inline keyboard仅普通 tg.Message 携带service 消息不带)。
// reply_markupbot reply/inline keyboard仅普通 tg.Message 携带service 消息不带)。
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
if msg.Out {
return &tg.MessageActionRequestedPeerSentMe{
ButtonID: shared.ButtonID,
Peers: tgRequestedPeers(shared.Peers),
Peers: tgRequestedPeers(shared),
}
}
return &tg.MessageActionRequestedPeer{
@ -221,29 +221,69 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
case domain.MessageServiceActionStarGiftUnique:
action := m.ServiceAction.StarGiftUnique
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:
action := m.ServiceAction.StarGiftOffer
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Gift: tgUniqueStarGift(action.Gift),
return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
case domain.MessageServiceActionStarGiftOfferDeclined:
action := m.ServiceAction.StarGiftOfferDeclined
if action == nil {
return &tg.MessageActionEmpty{}
}
if action.FromUserID != 0 {
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
default:
return &tg.MessageActionEmpty{}
}
}
func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass {
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned,
FromOffer: action.FromOffer, Craft: action.Craft,
Gift: tgUniqueStarGift(action.Gift),
}
if action.CanExportAt > 0 {
out.SetCanExportAt(action.CanExportAt)
}
if action.TransferStars > 0 {
out.SetTransferStars(action.TransferStars)
}
if action.ResaleAmount != nil {
out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount))
}
if action.CanTransferAt > 0 {
out.SetCanTransferAt(action.CanTransferAt)
}
if action.CanResellAt > 0 {
out.SetCanResellAt(action.CanResellAt)
}
if action.DropOriginalDetailsStars > 0 {
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
}
if action.CanCraftAt > 0 {
out.SetCanCraftAt(action.CanCraftAt)
}
if action.FromUserID != 0 {
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
}
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers))
for _, peer := range peers {
@ -254,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass {
return out
}
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass {
out := make([]tg.RequestedPeerClass, 0, len(peers))
for _, peer := range peers {
func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass {
if action == nil {
return nil
}
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
for _, detail := range action.Details {
details[detail.Peer] = detail
}
out := make([]tg.RequestedPeerClass, 0, len(action.Peers))
for _, peer := range action.Peers {
detail := details[peer]
switch peer.Type {
case domain.PeerTypeUser:
out = append(out, &tg.RequestedPeerUser{UserID: peer.ID})
item := &tg.RequestedPeerUser{UserID: peer.ID}
if action.NameRequested {
item.SetFirstName(detail.FirstName)
item.SetLastName(detail.LastName)
}
if action.UsernameRequested {
item.SetUsername(detail.Username)
}
if action.PhotoRequested && detail.Photo != nil {
item.SetPhoto(tgPhoto(*detail.Photo))
}
out = append(out, item)
case domain.PeerTypeChannel:
out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID})
item := &tg.RequestedPeerChannel{ChannelID: peer.ID}
if action.NameRequested {
item.SetTitle(detail.Title)
}
if action.UsernameRequested {
item.SetUsername(detail.Username)
}
if action.PhotoRequested && detail.Photo != nil {
item.SetPhoto(tgPhoto(*detail.Photo))
}
out = append(out, item)
}
}
return out

View file

@ -237,6 +237,11 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone}
case domain.UpdateEventUserEmojiStatus:
if event.UserID == 0 || !event.EmojiStatus.Valid() {
return nil
}
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -9,6 +9,9 @@ import (
// tgSelfUser 把 domain.User 转为 self 标记的 tg.Useroptional 字段由 Encode 自动 SetFlags
func tgSelfUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,
@ -27,6 +30,9 @@ func tgSelfUser(u domain.User) *tg.User {
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if u.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(u.LinkedCommunityID)
}
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}
@ -34,6 +40,9 @@ func tgSelfUser(u domain.User) *tg.User {
}
func tgUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,
@ -51,6 +60,9 @@ func tgUser(u domain.User) *tg.User {
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if u.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(u.LinkedCommunityID)
}
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}
@ -79,9 +91,35 @@ func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass {
if !u.EmojiStatusActiveAt(now) {
return &tg.EmojiStatusEmpty{}
}
status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID}
if u.EmojiStatusUntil > 0 {
status.SetUntil(u.EmojiStatusUntil)
return tgUserEmojiStatusValue(u.EmojiStatus())
}
// tgUserEmojiStatusValue converts an already validated absolute snapshot. It
// is shared by inline user projections and durable updateUserEmojiStatus.
func tgUserEmojiStatusValue(value domain.UserEmojiStatus) tg.EmojiStatusClass {
if !value.Valid() || value.Empty() {
return &tg.EmojiStatusEmpty{}
}
if collectible := value.Collectible; !collectible.Empty() {
status := &tg.EmojiStatusCollectible{
CollectibleID: collectible.CollectibleID,
DocumentID: collectible.DocumentID,
Title: collectible.Title,
Slug: collectible.Slug,
PatternDocumentID: collectible.PatternDocumentID,
CenterColor: collectible.CenterColor,
EdgeColor: collectible.EdgeColor,
PatternColor: collectible.PatternColor,
TextColor: collectible.TextColor,
}
if value.Until > 0 {
status.SetUntil(value.Until)
}
return status
}
status := &tg.EmojiStatus{DocumentID: value.DocumentID}
if value.Until > 0 {
status.SetUntil(value.Until)
}
return status
}

View file

@ -0,0 +1,56 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
u := domain.User{
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
}
got := tgUser(u)
if got.ID != u.ID || !got.Deleted {
t.Fatalf("deleted user = %+v", got)
}
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
t.Fatalf("deleted user leaked profile state: %+v", got)
}
self := tgSelfUser(u)
if !self.Deleted || self.Self || self.ID != u.ID {
t.Fatalf("deleted self projection = %+v", self)
}
}
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
viewer := domain.User{ID: 7, FirstName: "Viewer"}
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewer.ID: viewer, deleted.ID: deleted,
}}}, zaptest.NewLogger(t), clock.System)
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
Messages: []domain.Message{{
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Body: "retained history",
}},
// Simulate an old denormalized message query row. The authoritative
// Users.ByIDs hydration must replace it, not keep an empty active user.
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
})
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
}
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
t.Fatalf("history TL user = %+v", got)
}
}

View file

@ -180,6 +180,15 @@ type AuthKeyTargetedSessionBinder interface {
PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// ExactLayerTransientSessionBinder is the admission boundary for updates whose
// constructors do not exist in older profiles. Implementations must filter the
// live session index before encoding, skip unknown/not-ready profiles, and must
// never queue the transient payload for later delivery.
type ExactLayerTransientSessionBinder interface {
PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout.
type OnlineUserProvider interface {
IsUserOnline(userID int64) bool
@ -298,7 +307,14 @@ type UserIdentityService interface {
type UserPremiumService interface {
GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error)
SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
}
// UserEmojiStatusDurableService exposes the aggregate state+event write used
// by account.updateEmojiStatus. The bool is false for lightweight stores that
// require the RPC Updates service to append the event separately.
type UserEmojiStatusDurableService interface {
UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error)
}
// UserColorService 是 UsersService 的个人色板扩展能力。用于 account.updateColor
@ -429,6 +445,13 @@ type UpdatesService interface {
RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// UserEmojiStatusUpdatesService is the optional durable settings-update
// extension used by account.updateEmojiStatus. Keeping it separate preserves
// lightweight test/service implementations of the core UpdatesService.
type UserEmojiStatusUpdatesService interface {
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// ContactsService 抽象通讯录查询。
type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
@ -597,6 +620,7 @@ type ChannelsService interface {
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error)
@ -710,6 +734,32 @@ type ChannelsService interface {
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
// CommunitiesService abstracts the Layer 228 Community aggregation domain.
// Community containers never expose tg types and never own message/read/pts state.
type CommunitiesService interface {
Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error)
Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error)
GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error)
ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error)
TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error)
SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error)
ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error)
DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error)
DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error)
ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error)
ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error)
Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error)
EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error)
EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error)
EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error)
EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error)
SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error)
Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error)
SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error)
ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error)
SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error)
}
// FilesService 抽象文件上传分片、下载与媒体document/photo组装。
// 方法只用 domain 类型rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。
type FilesService interface {
@ -791,6 +841,22 @@ type AIComposeService interface {
Compose(ctx context.Context, req domain.AIComposeRequest) (domain.AIComposeResult, error)
}
// EphemeralService owns Layer 228 short-lived bot/member state. It must never
// write ordinary messages, dialogs, pts/qts/seq logs or durable update outbox.
type EphemeralService interface {
SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error)
SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error)
SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error)
EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error)
EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error)
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)
Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error)
DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error)
Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error)
PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error)
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct {
Auth AuthService
@ -803,10 +869,14 @@ type Deps struct {
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Users UsersService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
@ -814,6 +884,7 @@ type Deps struct {
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
Bots BotsService
Polls PollsService
@ -871,7 +942,9 @@ type GiftsService interface {
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error)
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
@ -879,13 +952,36 @@ type GiftsService interface {
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error)
ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error)
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
TonBalance(ctx context.Context, userID int64) (int64, error)
TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error)
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
}
// StarsService 抽象 Stars 本地账本app/stars余额查询、贷记/借记、流水分页。

View file

@ -3,16 +3,28 @@ package rpc
import (
"context"
"fmt"
"sort"
"telesrv/internal/domain"
)
func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {
if r == nil || r.deps.Dialogs == nil {
list, err := r.pinnedDialogsBaseList(ctx, userID, folderID)
if err != nil {
return domain.DialogList{}, err
}
return r.withCommunityDialogList(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list)
}
func (r *Router) pinnedDialogsBaseList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {
if r == nil {
return domain.DialogList{}, nil
}
key := fmt.Sprintf("%d:%d", userID, folderID)
value, err, _ := r.dialogsPinnedSF.Do(key, func() (any, error) {
if r.deps.Dialogs == nil {
return domain.DialogList{}, nil
}
return r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
@ -28,3 +40,97 @@ func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID i
}
return domain.DialogList{}, nil
}
func (r *Router) combinedPinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {
list, err := r.pinnedDialogsBaseList(ctx, userID, folderID)
if err != nil {
return domain.DialogList{}, err
}
return r.withCollapsedCommunityDialogs(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list)
}
// combinedPinnedDialogPeers merges ordinary dialogs and collapsed Communities
// by their shared server order. The two persistence implementations deliberately
// store their own rows, but Layer 228 exposes one messages.getPinnedDialogs list.
func combinedPinnedDialogPeers(list domain.DialogList) []domain.Peer {
type item struct {
peer domain.Peer
order int
sequence int
}
items := make([]item, 0, len(list.Dialogs)+len(list.Communities))
seen := make(map[domain.Peer]struct{}, cap(items))
appendItem := func(peer domain.Peer, pinned bool, order int) {
if !pinned || peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
items = append(items, item{peer: peer, order: order, sequence: len(items)})
}
for _, dialog := range list.Dialogs {
appendItem(dialog.Peer, dialog.Pinned, dialog.PinnedOrder)
}
for _, community := range list.Communities {
appendItem(domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID}, community.State.Pinned, community.State.PinnedOrder)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].order != items[j].order {
return items[i].order > items[j].order
}
return items[i].sequence < items[j].sequence
})
out := make([]domain.Peer, 0, len(items))
for _, item := range items {
out = append(out, item.peer)
}
return out
}
func (r *Router) ensureCombinedPinCapacity(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
list, err := r.combinedPinnedDialogsList(ctx, userID, folderID)
if err != nil {
return err
}
peers := combinedPinnedDialogPeers(list)
for _, pinned := range peers {
if pinned == peer {
return nil
}
}
if len(peers) >= domain.PinnedDialogsLimit(folderID, r.userIsPremium(ctx, userID)) {
return domain.ErrPinnedDialogsTooMuch
}
return nil
}
// promoteCombinedPinnedDialog assigns one collision-free order across ordinary
// dialogs and Communities. It is called after the underlying row is pinned so
// both stores can project the same mixed order without owning each other's data.
func (r *Router) promoteCombinedPinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
list, err := r.combinedPinnedDialogsList(ctx, userID, folderID)
if err != nil {
return err
}
current := combinedPinnedDialogPeers(list)
order := make([]domain.Peer, 0, len(current)+1)
order = append(order, peer)
for _, candidate := range current {
if candidate != peer {
order = append(order, candidate)
}
}
if r.deps.Dialogs != nil {
if _, err := r.deps.Dialogs.ReorderPinned(ctx, userID, folderID, order, false); err != nil {
return err
}
}
if folderID == domain.DialogMainFolderID && r.deps.Communities != nil {
if _, err := r.deps.Communities.ReorderPinned(ctx, userID, order, false); err != nil {
return err
}
}
return nil
}

460
internal/rpc/ephemeral.go Normal file
View file

@ -0,0 +1,460 @@
package rpc
import (
"context"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func (r *Router) registerEphemeral(d *tlprofile.Dispatcher) {
registerRPC[*tg.EphemeralSendMessageRequest](d, tlprofile.SemanticMethodEphemeralSendMessage, func(ctx context.Context, request *tg.EphemeralSendMessageRequest) (any, error) {
return r.onEphemeralSendMessage(ctx, request)
})
registerRPC[*tg.EphemeralDeleteMessageRequest](d, tlprofile.SemanticMethodEphemeralDeleteMessage, func(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (any, error) {
return r.onEphemeralDeleteMessage(ctx, request)
})
registerRPC[*tg.EphemeralReportMessageRequest](d, tlprofile.SemanticMethodEphemeralReportMessage, func(ctx context.Context, request *tg.EphemeralReportMessageRequest) (any, error) {
return r.onEphemeralReportMessage(ctx, request)
})
registerRPC[*tg.EphemeralGetCallbackAnswerRequest](d, tlprofile.SemanticMethodEphemeralGetCallbackAnswer, func(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (any, error) {
return r.onEphemeralGetCallbackAnswer(ctx, request)
})
}
func (r *Router) onEphemeralSendMessage(ctx context.Context, request *tg.EphemeralSendMessageRequest) (tg.UpdatesClass, error) {
if request == nil || r.deps.Ephemeral == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
return nil, peerIDInvalidErr()
}
receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID)
if err != nil {
return nil, internalErr()
}
if !found || !receiver.Bot {
return nil, userBotInvalidErr()
}
content, err := r.domainEphemeralInputContent(ctx, userID, request)
if err != nil {
return nil, err
}
topMessageID, replyID, err := ephemeralReplyFromInput(request.ReplyTo)
if err != nil {
return nil, err
}
queryID, _ := request.GetQueryID()
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return nil, internalErr()
}
message, fresh, err := r.deps.Ephemeral.SendFromClient(ctx, domain.SendClientEphemeralRequest{
SenderUserID: userID, ReceiverBotID: receiver.ID, Peer: peer,
QueryID: queryID, RandomID: request.RandomID, TopMessageID: topMessageID,
ReplyToEphemeralID: replyID, Content: content,
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID},
})
if err != nil {
return nil, ephemeralRPCError(err)
}
if fresh && r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: receiver.ID,
Kind: domain.BotAPIUpdateMessage,
Peer: message.Peer,
MessageID: message.ID,
Date: message.Date,
Ephemeral: domain.NewBotAPIEphemeralPayload(message),
}); err != nil {
r.log.Warn("enqueue bot api ephemeral message", zap.Int64("bot_user_id", receiver.ID), zap.Int("ephemeral_message_id", message.ID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(receiver.ID)
}
}
if fresh {
// OriginDevice belongs to the human sender and must not constrain the
// receiving bot's sessions.
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, Message: message,
})
}
// A lost create response can be retried after the ephemeral message was
// deleted. The random-id index deliberately returns its tombstone; reflect
// that final fact instead of projecting an impossible empty new message.
if message.Deleted {
return ephemeralDeleteUpdates(message, int(r.clock.Now().Unix())), nil
}
return r.ephemeralMessageUpdates(ctx, userID, message, false)
}
func (r *Router) onEphemeralGetCallbackAnswer(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel {
return nil, peerIDInvalidErr()
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return nil, err
}
data, _ := request.GetData()
callback, err := r.deps.Ephemeral.Callback(ctx, userID, device, peer, request.ID, data)
if err != nil {
return nil, ephemeralRPCError(err)
}
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), callback.BotUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Error(err))
return nil, internalErr()
}
defer r.callbacks.deregisterContext(context.Background(), callback.BotUserID, queryID)
created, err := r.deps.Ephemeral.PutCallbackAction(ctx, domain.EphemeralCallbackAction{
QueryID: queryID, BotUserID: callback.BotUserID, UserID: userID, Peer: peer,
MessageID: request.ID, TopMessageID: callback.Message.TopMessageID, Device: callback.Device, CreatedAt: callback.OccurredAt,
ExpiresAt: callback.OccurredAt.Add(domain.EphemeralReplyWindow),
})
if err != nil || !created {
return nil, internalErr()
}
botCallback := domain.BotCallbackQuery{
ID: queryID, BotUserID: callback.BotUserID, UserID: userID,
Peer: peer, MessageID: request.ID, ChatInstance: chatInstanceForPeer(callback.BotUserID, peer),
Data: append([]byte(nil), data...),
}
if r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: callback.BotUserID,
Kind: domain.BotAPIUpdateCallbackQuery,
Peer: peer,
MessageID: request.ID,
Date: int(callback.OccurredAt.Unix()),
Callback: &botCallback,
Ephemeral: domain.NewBotAPIEphemeralPayload(callback.Message),
}); err != nil {
r.log.Warn("enqueue bot api ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Int64("query_id", queryID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(callback.BotUserID)
}
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushCallback, TargetUserID: callback.BotUserID,
Message: callback.Message, Callback: &botCallback, Date: int(callback.OccurredAt.Unix()),
})
return r.waitBotCallbackAnswer(ctx, callback.BotUserID, queryID, pending)
}
func (r *Router) onEphemeralDeleteMessage(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (bool, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return false, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return false, err
}
receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID)
if err != nil {
return false, internalErr()
}
if !found {
return false, userIDInvalidErr()
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return false, err
}
message, deleted, err := r.deps.Ephemeral.DeleteFromDevice(ctx, userID, receiver.ID, device, peer, request.ID)
if err != nil {
return false, ephemeralRPCError(err)
}
if deleted {
for _, targetUserID := range []int64{message.SenderUserID, message.ReceiverUserID} {
var targetAuthKey [8]byte
if message.OriginDevice.UserID == targetUserID {
targetAuthKey = message.OriginDevice.BusinessAuthKeyID
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: targetUserID,
TargetBusinessAuthKey: targetAuthKey, Message: message,
})
}
}
return true, nil
}
func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.EphemeralReportMessageRequest) (tg.ReportResultClass, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return nil, err
}
target, err := r.deps.Ephemeral.ReportTarget(ctx, userID, device, peer, request.ID)
if err != nil {
return nil, ephemeralRPCError(err)
}
if utf8.RuneCountInString(request.Message) > 1024 {
return nil, messageTooLongErr()
}
result, err := reportResultForOption(string(request.Option))
if err != nil {
return nil, err
}
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
if r.deps.EphemeralReports == nil {
return nil, internalErr()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
return nil, internalErr()
}
return result, nil
}
func (r *Router) domainEphemeralInputContent(ctx context.Context, userID int64, request *tg.EphemeralSendMessageRequest) (domain.EphemeralContent, error) {
if !utf8.ValidString(request.Message) || utf8.RuneCountInString(request.Message) > domain.MaxMessageTextLength || len(request.Entities) > domain.MaxMessageEntityCount {
return domain.EphemeralContent{}, messageTooLongErr()
}
entities := domainMessageEntitiesForViewer(userID, request.Entities)
if len(entities) != len(request.Entities) || !validEphemeralEntityBounds(request.Message, entities) {
return domain.EphemeralContent{}, tgerr.New(400, "ENTITY_BOUNDS_INVALID")
}
var media *domain.MessageMedia
if request.Media != nil {
resolved, err := r.resolveInputMedia(ctx, userID, request.Media)
if err != nil {
return domain.EphemeralContent{}, err
}
if !ephemeralMediaAllowed(resolved) {
return domain.EphemeralContent{}, mediaTypeInvalidErr()
}
media = resolved
}
var markup *domain.MessageReplyMarkup
if request.ReplyMarkup != nil {
var err error
markup, err = domainReplyMarkupForSender(request.ReplyMarkup, false)
if err != nil {
return domain.EphemeralContent{}, replyMarkupErr(err)
}
}
// Layer 228 exposes f_rich_message on the request but its
// ephemeralMessage result has no field capable of carrying that content.
// Official TDesktop always sends an empty InputRichMessage here. Reject the
// otherwise lossy shape instead of acknowledging content the receiver could
// never reconstruct.
if request.RichMessage != nil {
return domain.EphemeralContent{}, inputConstructorInvalidErr()
}
if request.Message == "" && media == nil {
return domain.EphemeralContent{}, messageEmptyErr()
}
content := domain.EphemeralContent{Message: request.Message, Entities: entities, Media: media, ReplyMarkup: markup}
if domain.ValidateEphemeralContent(content) != nil {
return domain.EphemeralContent{}, inputRequestInvalidErr()
}
return content, nil
}
func ephemeralReplyFromInput(reply tg.InputReplyToClass) (topMessageID, ephemeralID int, err error) {
switch value := reply.(type) {
case nil:
return 0, 0, nil
case *tg.InputReplyToEphemeralMessage:
if value.ID <= 0 || value.ID > domain.MaxMessageBoxID {
return 0, 0, messageIDInvalidErr()
}
return 0, value.ID, nil
case *tg.InputReplyToMessage:
topMessageID = value.ReplyToMsgID
if explicit, ok := value.GetTopMsgID(); ok {
topMessageID = explicit
}
if topMessageID <= 0 || topMessageID > domain.MaxMessageBoxID {
return 0, 0, messageIDInvalidErr()
}
if value.ReplyToPeerID != nil || value.QuoteText != "" || len(value.QuoteEntities) != 0 || value.QuoteOffset != 0 ||
value.MonoforumPeerID != nil || value.TodoItemID != 0 || len(value.PollOption) != 0 {
return 0, 0, inputConstructorInvalidErr()
}
return topMessageID, 0, nil
default:
return 0, 0, inputConstructorInvalidErr()
}
}
func validEphemeralEntityBounds(message string, entities []domain.MessageEntity) bool {
utf16Length := 0
for _, runeValue := range message {
utf16Length++
if runeValue > 0xffff {
utf16Length++
}
}
for _, entity := range entities {
if entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || entity.Length > utf16Length-entity.Offset {
return false
}
}
return true
}
func ephemeralMediaAllowed(media *domain.MessageMedia) bool {
if media == nil || media.IsZero() {
return false
}
switch media.Kind {
case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument, domain.MessageMediaKindContact,
domain.MessageMediaKindGeo, domain.MessageMediaKindVenue:
return true
default:
return false
}
}
func (r *Router) ephemeralMessageUpdates(ctx context.Context, viewerUserID int64, message domain.EphemeralMessage, edited bool) (*tg.Updates, error) {
if r.deps.Users == nil || r.deps.Channels == nil {
return nil, internalErr()
}
users, err := r.deps.Users.ByIDs(ctx, viewerUserID, []int64{message.SenderUserID, message.ReceiverUserID})
if err != nil {
return nil, internalErr()
}
view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, message.Peer.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
wire := tgEphemeralMessage(viewerUserID, message)
var update tg.UpdateClass = &tg.UpdateNewEphemeralMessage{Message: wire}
if edited {
update = &tg.UpdateEditEphemeralMessage{Message: wire}
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Users: tgUsersForViewer(viewerUserID, users),
Chats: []tg.ChatClass{tgChannelChatForView(viewerUserID, view)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}, nil
}
func ephemeralDeleteUpdates(message domain.EphemeralMessage, date int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateDeleteEphemeralMessages{
Peer: tgPeer(message.Peer), IDs: []int{message.ID},
}},
Date: date,
Seq: 0,
}
}
func tgEphemeralMessage(viewerUserID int64, message domain.EphemeralMessage) tg.EphemeralMessage {
out := tg.EphemeralMessage{
Out: viewerUserID == message.SenderUserID,
ID: message.ID,
FromID: &tg.PeerUser{UserID: message.SenderUserID},
PeerID: tgPeer(message.Peer),
ReceiverID: message.ReceiverUserID,
Date: message.Date,
Message: message.Content.Message,
}
if message.TopMessageID > 0 {
out.SetTopMsgID(message.TopMessageID)
}
if len(message.Content.Entities) != 0 {
out.SetEntities(tgMessageEntities(message.Content.Entities))
}
if message.Content.Media != nil && !message.Content.Media.IsZero() {
out.SetMedia(tgMessageMedia(message.Content.Media))
}
if message.Content.ReplyMarkup != nil && !message.Content.ReplyMarkup.IsZero() {
out.SetReplyMarkup(tgReplyMarkup(message.Content.ReplyMarkup))
}
if message.ReplyToEphemeralID > 0 {
reply := &tg.MessageReplyHeader{ReplyToEphemeral: true}
reply.SetReplyToMsgID(message.ReplyToEphemeralID)
if message.TopMessageID > 0 {
reply.ForumTopic = true
reply.SetReplyToTopID(message.TopMessageID)
}
out.SetReplyTo(reply)
}
return out
}
func ephemeralDeviceFromContext(ctx context.Context, userID int64) (domain.EphemeralDevice, error) {
authKeyID, authOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return domain.EphemeralDevice{}, internalErr()
}
return domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, nil
}
func ephemeralRPCError(err error) error {
switch {
case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired),
errors.Is(err, domain.ErrEphemeralDeleted), errors.Is(err, domain.ErrEphemeralReplyExpired):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralPeerInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralSenderInvalid), errors.Is(err, domain.ErrEphemeralReceiverInvalid):
return userIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralCommandInvalid):
return tgerr.New(400, "BOT_COMMAND_INVALID")
case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch):
return tgerr.New(403, "CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrEphemeralCallbackInvalid):
return dataInvalidErr()
case errors.Is(err, domain.ErrEphemeralInvalid), errors.Is(err, domain.ErrEphemeralRandomIDConflict),
errors.Is(err, domain.ErrEphemeralVersionConflict):
return inputRequestInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,107 @@
package rpc
import (
"context"
"time"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/store"
)
const ephemeralPushSubscribeRetry = time.Second
func (r *Router) RunEphemeralPushSubscriber(ctx context.Context) {
if r == nil || r.deps.EphemeralPush == nil {
return
}
for {
err := r.deps.EphemeralPush.SubscribeEphemeralPushes(ctx, func(ctx context.Context, event store.EphemeralPush) {
if event.SourceID == "" || event.SourceID == r.instanceID {
return
}
r.deliverEphemeralPushLocal(ctx, event)
})
if ctx.Err() != nil {
return
}
if err != nil {
r.log.Warn("ephemeral push subscriber stopped", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-time.After(ephemeralPushSubscribeRetry):
}
}
}
func (r *Router) publishEphemeralPush(ctx context.Context, event store.EphemeralPush) {
if r == nil || event.TargetUserID <= 0 {
return
}
event.SourceID = r.instanceID
if event.Date <= 0 {
event.Date = int(r.clock.Now().Unix())
}
r.deliverEphemeralPushLocal(ctx, event)
if r.deps.EphemeralPush != nil {
if err := r.deps.EphemeralPush.PublishEphemeralPush(ctx, event); err != nil {
r.log.Debug("publish ephemeral push", zap.String("kind", string(event.Kind)), zap.Int64("target_user_id", event.TargetUserID), zap.Error(err))
}
}
}
func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.EphemeralPush) {
if r == nil || r.deps.Sessions == nil || event.TargetUserID <= 0 || event.Message.ID <= 0 {
return
}
if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(event.TargetUserID) {
return
}
binder, ok := r.deps.Sessions.(ExactLayerTransientSessionBinder)
if !ok {
return
}
var updates tg.UpdatesClass
switch event.Kind {
case store.EphemeralPushNew, store.EphemeralPushEdit:
if event.TargetUserID != event.Message.ReceiverUserID || event.Message.Deleted {
return
}
built, err := r.ephemeralMessageUpdates(ctx, event.TargetUserID, event.Message, event.Kind == store.EphemeralPushEdit)
if err != nil {
return
}
updates = built
case store.EphemeralPushDelete:
if !event.Message.Deleted || (event.TargetUserID != event.Message.SenderUserID && event.TargetUserID != event.Message.ReceiverUserID) {
return
}
updates = ephemeralDeleteUpdates(event.Message, event.Date)
case store.EphemeralPushCallback:
callback := event.Callback
if callback == nil || callback.BotUserID != event.TargetUserID || callback.MessageID != event.Message.ID || callback.Peer != event.Message.Peer {
return
}
update := &tg.UpdateBotCallbackQuery{
QueryID: callback.ID, UserID: callback.UserID, Peer: tgPeer(callback.Peer),
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
}
update.SetData(callback.Data)
updates = &tg.Updates{Updates: []tg.UpdateClass{update}, Date: event.Date}
default:
return
}
minLayer := 228
if event.Kind == store.EphemeralPushCallback {
minLayer = 225
}
if event.TargetBusinessAuthKey != ([8]byte{}) {
_, _ = binder.PushToUserAuthKeyTransientAtLeastLayer(ctx, event.TargetUserID, event.TargetBusinessAuthKey, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
return
}
_, _ = binder.PushToUserTransientAtLeastLayer(ctx, event.TargetUserID, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
}

View file

@ -0,0 +1,220 @@
package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type ephemeralPushChannels struct {
ChannelsService
view domain.ChannelView
calls int
}
func (s *ephemeralPushChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
s.calls++
return s.view, nil
}
type ephemeralPushSessions struct {
SessionBinder
OnlineUserProvider
mu sync.Mutex
online bool
broadcasts []ephemeralPushCapture
targeted []ephemeralPushCapture
}
type ephemeralPushCapture struct {
userID int64
authKey [8]byte
minLayer int
message tg.UpdatesClass
}
func (s *ephemeralPushSessions) IsUserOnline(int64) bool { return s.online }
func (s *ephemeralPushSessions) PushToUserTransientAtLeastLayer(_ context.Context, userID int64, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, minLayer: minLayer, message: message})
return 1, nil
}
func (s *ephemeralPushSessions) PushToUserAuthKeyTransientAtLeastLayer(_ context.Context, userID int64, authKey [8]byte, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, minLayer: minLayer, message: message})
return 1, nil
}
func (s *ephemeralPushSessions) counts() (int, int) {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.broadcasts), len(s.targeted)
}
type inMemoryEphemeralBroker struct {
mu sync.Mutex
subscribers []func(context.Context, store.EphemeralPush)
registered chan struct{}
published []store.EphemeralPush
}
func newInMemoryEphemeralBroker() *inMemoryEphemeralBroker {
return &inMemoryEphemeralBroker{registered: make(chan struct{}, 8)}
}
func (b *inMemoryEphemeralBroker) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error {
b.mu.Lock()
b.published = append(b.published, event)
handlers := append([]func(context.Context, store.EphemeralPush){}, b.subscribers...)
b.mu.Unlock()
for _, handler := range handlers {
handler(ctx, event)
}
return nil
}
func (b *inMemoryEphemeralBroker) SubscribeEphemeralPushes(ctx context.Context, handler func(context.Context, store.EphemeralPush)) error {
b.mu.Lock()
b.subscribers = append(b.subscribers, handler)
b.mu.Unlock()
b.registered <- struct{}{}
<-ctx.Done()
return ctx.Err()
}
func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker := newInMemoryEphemeralBroker()
users := mapUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Bot", Bot: true},
2001: {ID: 2001, FirstName: "Alice"},
}}
view := domain.ChannelView{
Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true},
Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive},
}
channels1, channels2 := &ephemeralPushChannels{view: view}, &ephemeralPushChannels{view: view}
sessions1, sessions2 := &ephemeralPushSessions{online: true}, &ephemeralPushSessions{online: true}
r1 := New(Config{InstanceID: "one"}, Deps{Users: users, Channels: channels1, Sessions: sessions1, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System)
r2 := New(Config{InstanceID: "two"}, Deps{Users: users, Channels: channels2, Sessions: sessions2, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System)
go r1.RunEphemeralPushSubscriber(ctx)
go r2.RunEphemeralPushSubscriber(ctx)
for range 2 {
select {
case <-broker.registered:
case <-time.After(time.Second):
t.Fatal("subscriber did not register")
}
}
now := time.Now()
message := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
r1.publishEphemeralPush(ctx, store.EphemeralPush{Kind: store.EphemeralPushNew, TargetUserID: 2001, Message: message})
if broadcast, targeted := sessions1.counts(); broadcast != 1 || targeted != 0 {
t.Fatalf("source delivery broadcast=%d targeted=%d", broadcast, targeted)
}
if broadcast, targeted := sessions2.counts(); broadcast != 1 || targeted != 0 {
t.Fatalf("remote delivery broadcast=%d targeted=%d", broadcast, targeted)
}
if sessions1.broadcasts[0].minLayer != 228 || sessions2.broadcasts[0].minLayer != 228 {
t.Fatalf("min layers source=%d remote=%d", sessions1.broadcasts[0].minLayer, sessions2.broadcasts[0].minLayer)
}
if len(broker.published) != 1 || broker.published[0].SourceID != "one" {
t.Fatalf("published=%+v", broker.published)
}
key := [8]byte{9, 8, 7}
message.Deleted = true
message.Version++
message.Content = domain.EphemeralContent{}
r2.deliverEphemeralPushLocal(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: 2001,
TargetBusinessAuthKey: key, Message: message, Date: int(time.Now().Unix()),
})
_, targeted := sessions2.counts()
if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].minLayer != 228 {
t.Fatalf("targeted=%+v", sessions2.targeted)
}
deletedUpdates, ok := sessions2.targeted[0].message.(*tg.Updates)
if !ok || deletedUpdates.Seq != 0 || len(deletedUpdates.Updates) != 1 {
t.Fatalf("delete updates=%#v", sessions2.targeted[0].message)
}
deleted, ok := deletedUpdates.Updates[0].(*tg.UpdateDeleteEphemeralMessages)
if !ok || len(deleted.IDs) != 1 || deleted.IDs[0] != message.ID {
t.Fatalf("delete update=%#v", deletedUpdates.Updates[0])
}
}
func TestEphemeralMessageUpdatesAreTransientAndPtsFree(t *testing.T) {
now := time.Now()
router := New(Config{}, Deps{
Users: mapUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Bot", Bot: true},
2001: {ID: 2001, FirstName: "Alice"},
}},
Channels: &ephemeralPushChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true},
Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive},
}},
}, zaptest.NewLogger(t), clock.System)
message := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
updates, err := router.ephemeralMessageUpdates(context.Background(), 2001, message, false)
if err != nil || updates.Seq != 0 || len(updates.Updates) != 1 {
t.Fatalf("updates=%#v err=%v", updates, err)
}
if _, ok := updates.Updates[0].(*tg.UpdateNewEphemeralMessage); !ok {
t.Fatalf("update type=%T", updates.Updates[0])
}
deleted := ephemeralDeleteUpdates(domain.EphemeralMessage{ID: message.ID, Peer: message.Peer}, int(now.Unix()))
if deleted.Seq != 0 {
t.Fatalf("delete seq=%d", deleted.Seq)
}
}
func TestEphemeralPushOfflineSkipsHydration(t *testing.T) {
channels := &ephemeralPushChannels{view: domain.ChannelView{Channel: domain.Channel{ID: 3001}}}
sessions := &ephemeralPushSessions{online: false}
now := time.Now()
router := New(Config{InstanceID: "offline"}, Deps{
Users: mapUsersService{users: map[int64]domain.User{}}, Channels: channels, Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
router.deliverEphemeralPushLocal(context.Background(), store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: 2001,
Message: domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
},
})
if channels.calls != 0 {
t.Fatalf("offline push performed %d channel hydrations", channels.calls)
}
if broadcast, targeted := sessions.counts(); broadcast != 0 || targeted != 0 {
t.Fatalf("offline delivery broadcast=%d targeted=%d", broadcast, targeted)
}
}

View file

@ -0,0 +1,96 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type ephemeralReportChannels struct {
ChannelsService
view domain.ChannelView
}
func (s *ephemeralReportChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
return s.view, nil
}
type ephemeralReportService struct {
EphemeralService
target domain.EphemeralMessage
calls int
}
func (s *ephemeralReportService) ReportTarget(_ context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
s.calls++
if userID != s.target.ReceiverUserID || device.UserID != userID || device.BusinessAuthKeyID != s.target.OriginDevice.BusinessAuthKeyID ||
peer != s.target.Peer || id != s.target.ID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
return s.target, nil
}
func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
const userID int64 = 2001
const channelID int64 = 3001
now := time.Now()
authKey := [8]byte{1, 2, 3}
target := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
SenderUserID: 1001, ReceiverUserID: userID, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "abuse"},
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
reports := memory.NewEphemeralReportStore()
ephemeral := &ephemeralReportService{target: target}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
}}
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
}
result, err := router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultChooseOption); !ok || len(reports.Reports()) != 0 {
t.Fatalf("initial result=%T reports=%+v", result, reports.Reports())
}
request.Option = []byte("other")
result, err = router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultAddComment); !ok || len(reports.Reports()) != 0 {
t.Fatalf("comment result=%T reports=%+v", result, reports.Reports())
}
request.Option, request.Message = []byte("spam"), "evidence comment"
for range 2 {
result, err = router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultReported); !ok {
t.Fatalf("final result=%T", result)
}
}
stored := reports.Reports()
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
t.Fatalf("reports=%+v", stored)
}
if ephemeral.calls != 4 {
t.Fatalf("ReportTarget calls=%d", ephemeral.calls)
}
}

View file

@ -129,6 +129,10 @@ func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") }
func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") }
func allowPaymentRequiredErr(stars int64) error {
return tgerr.New(403, fmt.Sprintf("ALLOW_PAYMENT_REQUIRED_%d", stars))
}
func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
@ -137,6 +141,10 @@ func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOU
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
func formExpiredErr() error { return tgerr.New(400, "FORM_EXPIRED") }
func purposeInvalidErr() error { return tgerr.New(400, "PURPOSE_INVALID") }
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }

View file

@ -7,6 +7,7 @@ import (
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/branding"
androidcompat "telesrv/internal/compat/android"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
@ -21,7 +22,10 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
return tdesktop.NearestDC(r.cfg.DC), nil
})
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
})
registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
return r.onHelpSaveAppLog(ctx)
})
registerRPC[*tg.HelpGetAppUpdateRequest](d, tlprofile.SemanticMethodHelpGetAppUpdate, func(ctx context.Context, layerRequest *tg.HelpGetAppUpdateRequest) (any, error) {
source := layerRequest.
@ -113,6 +117,21 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
})
}
// onHelpSaveAppLog 为官方客户端的 fire-and-forget 应用遥测提供有界兼容应答。
// telesrv 当前不运营遥测产品,因此不读取、记录或持久化事件内容;请求已在 exact
// Layer admission 处受 wire/vector/aggregate/depth 限制。该方法按 TL 访问约束允许
// 未授权连接调用,但已登录 bot 必须拒绝。
func (r *Router) onHelpSaveAppLog(ctx context.Context) (bool, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if authorized && r.userIsBot(ctx, userID) {
return false, botMethodInvalidErr()
}
return true, nil
}
func (r *Router) onHelpGetConfig(ctx context.Context) (*tg.Config, error) {
config := tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL)
userID, authorized, err := r.currentUserID(ctx)
@ -164,7 +183,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
promo := &tg.HelpPremiumPromo{
StatusText: "Telegram Premium is not active on this account.",
StatusText: branding.PremiumName + " is not active on this account.",
StatusEntities: []tg.MessageEntityClass{},
VideoSections: []string{},
Videos: []tg.DocumentClass{},
@ -181,7 +200,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
}
if u.PremiumActiveAt(r.clock.Now().Unix()) {
until := time.Unix(int64(u.PremiumUntil), 0)
promo.StatusText = "Telegram Premium is active until " + until.Format("2006-01-02") + "."
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
}
return promo, nil
}

View file

@ -0,0 +1,74 @@
package rpc
import (
"context"
"fmt"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestHelpSaveAppLogCompatibilityAckAcrossExactProfiles(t *testing.T) {
r := New(Config{}, Deps{Auth: &captureAuthService{}}, zaptest.NewLogger(t), clock.System)
requests := map[string]*tg.HelpSaveAppLogRequest{
"empty": {},
"android_device_stat": {
Events: []tg.InputAppEvent{{
Time: 1_721_234_567.25,
Type: "android_sdcard_exists",
Peer: 1,
Data: &tg.JSONBool{Value: true},
}},
},
}
contexts := map[string]context.Context{
"unauthenticated": context.Background(),
"user": WithUserID(context.Background(), 42),
}
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
for contextName, ctx := range contexts {
for requestName, req := range requests {
name := fmt.Sprintf("layer_%d/%s/%s", profile, contextName, requestName)
t.Run(name, func(t *testing.T) {
for attempt := 1; attempt <= 2; attempt++ {
result, method := dispatchExactLayerRPCTest(t, r, ctx, profile, req)
if method != "help.saveAppLog" {
t.Fatalf("attempt %d method = %q, want help.saveAppLog", attempt, method)
}
if value, ok := dispatchCanonicalValue(result).(bool); !ok || !value {
t.Fatalf("attempt %d response = %#v (%T), want true", attempt, dispatchCanonicalValue(result), result)
}
}
})
}
}
}
}
func TestHelpSaveAppLogRejectsBot(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
bot, err := users.Create(ctx, domain.User{
Phone: "+10000000001",
FirstName: "TelemetryBot",
AccessHash: 101,
Bot: true,
})
if err != nil {
t.Fatal(err)
}
r := New(Config{}, Deps{Users: appusers.NewService(users)}, zaptest.NewLogger(t), clock.System)
if _, err := r.onHelpSaveAppLog(WithUserID(ctx, bot.ID)); !tgerr.Is(err, "BOT_METHOD_INVALID") {
t.Fatalf("bot saveAppLog err = %v, want BOT_METHOD_INVALID", err)
}
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"fmt"
"strconv"
"strings"
"unicode/utf8"
@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
if err != nil {
return nil, messageSendErr(err)
}
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if !found || !botUser.Bot {
return nil, botInvalidErr()
}
webAppReqID, ok := req.GetWebappReqID()
if !ok || webAppReqID == "" {
return nil, buttonDataInvalidErr()
}
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
if err != nil {
return nil, internalErr()
}
if !found || button.ButtonID != req.ButtonID {
return nil, buttonDataInvalidErr()
webAppReqID, fromWebApp := req.GetWebappReqID()
idempotencyKey := webAppReqID
var button domain.BotRequestedWebViewButton
if fromWebApp {
if webAppReqID == "" {
return nil, buttonDataInvalidErr()
}
var found bool
button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
if err != nil {
return nil, internalErr()
}
if !found || button.ButtonID != req.ButtonID {
return nil, buttonDataInvalidErr()
}
} else {
idempotencyKey = "message:" + strconv.Itoa(req.MsgID)
button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID)
if err != nil {
return nil, err
}
}
if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity {
return nil, buttonDataInvalidErr()
@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
if !requestedPeerTypeMatches(button.PeerType, resolved) {
if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil {
return nil, internalErr()
} else if !matches {
return nil, buttonDataInvalidErr()
}
peers = append(peers, resolved)
}
details, err := r.requestedPeerDetails(ctx, userID, peers, button)
if err != nil {
return nil, internalErr()
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID)
if err != nil {
return nil, err
@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: botUser.ID,
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers),
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: button.ButtonID,
Peers: peers,
ButtonID: button.ButtonID,
Peers: peers,
Details: details,
NameRequested: button.NameRequested,
UsernameRequested: button.UsernameRequested,
PhotoRequested: button.PhotoRequested,
},
},
},
@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, internalErr()
}
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
if fromWebApp {
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
}
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil
}
type requestedPeerPhotoProvider interface {
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
}
func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) {
details := make([]domain.MessageRequestedPeerDetails, len(peers))
for i, peer := range peers {
details[i].Peer = peer
}
if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested {
return details, nil
}
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, peer := range peers {
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
cache := newViewerPeerCache(r)
users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))
channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))
userByID := make(map[int64]domain.User, len(users))
channelByID := make(map[int64]domain.Channel, len(channels))
photoIDs := make([]int64, 0, len(peers))
for _, user := range users {
userByID[user.ID] = user
if button.PhotoRequested && user.PhotoID != 0 {
photoIDs = append(photoIDs, user.PhotoID)
}
}
for _, channel := range channels {
channelByID[channel.ID] = channel
if button.PhotoRequested && channel.PhotoID != 0 {
photoIDs = append(photoIDs, channel.PhotoID)
}
}
photoByID := make(map[int64]domain.Photo, len(photoIDs))
if len(photoIDs) > 0 {
provider, ok := r.deps.Files.(requestedPeerPhotoProvider)
if !ok {
return nil, fmt.Errorf("requested peer photo provider unavailable")
}
photos, err := provider.GetPhotos(ctx, photoIDs)
if err != nil {
return nil, err
}
for _, photo := range photos {
photoByID[photo.ID] = photo
}
}
for i, peer := range peers {
detail := &details[i]
switch peer.Type {
case domain.PeerTypeUser:
user, ok := userByID[peer.ID]
if !ok {
return nil, fmt.Errorf("requested user %d not hydrated", peer.ID)
}
if button.NameRequested {
detail.FirstName, detail.LastName = user.FirstName, user.LastName
}
if button.UsernameRequested {
detail.Username = user.Username
}
if button.PhotoRequested && user.PhotoID != 0 {
photo, ok := photoByID[user.PhotoID]
if !ok {
return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID)
}
detail.Photo = &photo
}
case domain.PeerTypeChannel:
channel, ok := channelByID[peer.ID]
if !ok {
return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID)
}
if button.NameRequested {
detail.Title = channel.Title
}
if button.UsernameRequested {
detail.Username = channel.Username
}
if button.PhotoRequested && channel.PhotoID != 0 {
photo, ok := photoByID[channel.PhotoID]
if !ok {
return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID)
}
detail.Photo = &photo
}
}
}
return details, nil
}
func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) {
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 {
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
if err != nil {
return domain.BotRequestedWebViewButton{}, internalErr()
}
if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) ||
message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil ||
message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard {
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
for _, row := range message.ReplyMarkup.Keyboard {
for _, item := range row {
if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID {
continue
}
return domain.BotRequestedWebViewButton{
BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID,
PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter,
NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested,
PhotoRequested: item.PhotoRequested,
}, nil
}
}
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
switch kind {
case "user", "":
@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
}
}
func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) {
if !requestedPeerTypeMatches(button.PeerType, peer) {
return false, nil
}
filter := button.PeerFilter
if filter == nil {
return true, nil
}
if peer.Type == domain.PeerTypeUser {
if r.deps.Users == nil {
return false, nil
}
user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID)
if err != nil || !found {
return false, err
}
if filter.UserIsBotSet && user.Bot != filter.UserIsBot {
return false, nil
}
if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium {
return false, nil
}
return true, nil
}
if r.deps.Channels == nil {
return false, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return false, err
}
channel := view.Channel
if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) {
return false, nil
}
if button.PeerType == "broadcast" && !channel.Broadcast {
return false, nil
}
if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername {
return false, nil
}
if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum {
return false, nil
}
if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator {
return false, nil
}
if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) {
return false, nil
}
if filter.BotIsMember || filter.BotAdminRights != nil {
botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID)
if err != nil {
return false, err
}
if botMember.Status != domain.ChannelMemberActive {
return false, nil
}
if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) {
return false, nil
}
}
return true, nil
}
func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool {
if member.Role == domain.ChannelRoleCreator {
return true
}
if member.Role != domain.ChannelRoleAdmin {
return false
}
rights := member.AdminRights
return (!required.Anonymous || rights.Anonymous) &&
(!required.ManageChat || rights.ManageChat) &&
(!required.DeleteMessages || rights.DeleteMessages) &&
(!required.ManageVideoChats || rights.ManageCall) &&
(!required.RestrictMembers || rights.BanUsers) &&
(!required.PromoteMembers || rights.AddAdmins) &&
(!required.ChangeInfo || rights.ChangeInfo) &&
(!required.InviteUsers || rights.InviteUsers) &&
(!required.PostStories || rights.PostStories) &&
(!required.EditStories || rights.EditStories) &&
(!required.DeleteStories || rights.DeleteStories) &&
(!required.PostMessages || rights.PostMessages) &&
(!required.EditMessages || rights.EditMessages) &&
(!required.PinMessages || rights.PinMessages) &&
(!required.ManageTopics || rights.ManageTopics) &&
(!required.ManageDirectMessages || rights.ManageDirectMessages)
}
func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 {
parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)}
for _, peer := range peers {

View file

@ -11,11 +11,13 @@ import (
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
ownerCtx := WithUserID(ctx, f.owner.ID)
updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID {
t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID)
}
botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil ||
botAPIEvents[0].Message.Media.ServiceAction == nil ||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil ||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` {
t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err)
}
botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
HasPeer: true,
@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing
}
}
func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
ownerCtx := WithUserID(ctx, f.owner.ID)
button := domain.MarkupButton{
Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77,
RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true,
}
requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{
SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose",
ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}},
Date: 1700000100,
})
if err != nil {
t.Fatalf("send request message: %v", err)
}
if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{
Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID,
RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)},
}); err != nil {
t.Fatalf("send requested peer: %v", err)
}
events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
if err != nil || len(events) != 1 {
t.Fatalf("bot api requested-peer events=%#v err=%v", events, err)
}
action := events[0].Message.Media.ServiceAction.RequestedPeer
if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID ||
len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName ||
!action.NameRequested || !action.UsernameRequested {
t.Fatalf("requested-peer action=%#v", action)
}
}
func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)

View file

@ -3,10 +3,12 @@ package rpc
import (
"context"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) {
@ -159,8 +161,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
if len(req.Entities) > maxMessageEntityCount {
return domain.DialogDraft{}, limitInvalidErr()
}
if !req.SuggestedPost.Zero() {
return domain.DialogDraft{}, suggestedPostPeerInvalidErr()
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost)
if err != nil {
return domain.DialogDraft{}, err
}
if hasSuggestedPost {
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return domain.DialogDraft{}, suggestedPostPeerInvalidErr()
}
if _, _, resolveErr := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID); resolveErr != nil {
return domain.DialogDraft{}, suggestedPostPeerInvalidErr()
}
}
replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo)
if err != nil {
@ -182,17 +194,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
topMessageID = replyTo.TopMessageID
}
return domain.DialogDraft{
Peer: peer,
TopMessageID: topMessageID,
Date: date,
NoWebpage: req.NoWebpage,
InvertMedia: req.InvertMedia,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
ReplyTo: replyTo,
WebPage: webpage,
Effect: req.Effect,
RichMessage: richMessage,
Peer: peer,
TopMessageID: topMessageID,
Date: date,
NoWebpage: req.NoWebpage,
InvertMedia: req.InvertMedia,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
ReplyTo: replyTo,
WebPage: webpage,
Effect: req.Effect,
SuggestedPost: suggestedPost,
RichMessage: richMessage,
}, nil
}
@ -560,6 +573,50 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
if folderPeer, ok := req.Peer.(*tg.InputDialogPeerFolder); ok {
return r.toggleArchiveFolderPin(ctx, userID, folderPeer.FolderID, req.GetPinned())
}
if community, ok, err := r.communityDialogPeerFromInput(ctx, userID, req.Peer); ok {
if err != nil {
return false, err
}
pinned := req.GetPinned()
peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID}
if pinned && !community.State.Pinned {
if err := r.ensureCombinedPinCapacity(ctx, userID, domain.DialogMainFolderID, peer); err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
return false, pinnedTooMuchErr()
}
return false, internalErr()
}
}
changed, err := r.deps.Communities.SetPinned(ctx, userID, community.Community.ID, pinned)
if err != nil {
return false, communityErr(err)
}
if !changed {
return true, nil
}
if pinned {
if err := r.promoteCombinedPinnedDialog(ctx, userID, domain.DialogMainFolderID, peer); err != nil {
return false, internalErr()
}
}
date := int(r.clock.Now().Unix())
var recorded domain.UpdateEvent
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peer, pinned, domain.DialogMainFolderID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
date, recorded = state.Date, event
}
r.bookkeepAuxPtsForCurrentSession(ctx, recorded)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateDialogPinned{Pinned: pinned, Peer: tgDialogPeer(peer)}}, recorded),
Chats: []tg.ChatClass{tgCommunityChat(community)}, Date: date,
})
return true, nil
}
peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer})
if err != nil {
return false, err
@ -571,6 +628,25 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
if r.deps.Dialogs == nil {
return true, nil
}
if pinned {
folderID := domain.DialogMainFolderID
current, err := r.deps.Dialogs.GetPeerDialogs(ctx, userID, peers)
if err != nil {
return false, internalErr()
}
for _, dialog := range current.Dialogs {
if dialog.Peer == peers[0] {
folderID = dialog.FolderID
break
}
}
if err := r.ensureCombinedPinCapacity(ctx, userID, folderID, peers[0]); err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
return false, pinnedTooMuchErr()
}
return false, internalErr()
}
}
changed, folderID, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned)
if err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
@ -579,6 +655,11 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
return false, internalErr()
}
if changed {
if pinned {
if err := r.promoteCombinedPinnedDialog(ctx, userID, folderID, peers[0]); err != nil {
return false, internalErr()
}
}
date := int(r.clock.Now().Unix())
var recorded domain.UpdateEvent
if r.deps.Updates != nil {
@ -667,12 +748,36 @@ func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.Mes
if err != nil {
return false, err
}
if r.deps.Dialogs == nil {
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if _, duplicate := seen[peer]; duplicate {
return false, peerIDInvalidErr()
}
seen[peer] = struct{}{}
if req.FolderID != domain.DialogMainFolderID && peer.Type == domain.PeerTypeCommunity {
return false, folderIDInvalidErr()
}
}
if len(peers) > domain.PinnedDialogsLimit(req.FolderID, r.userIsPremium(ctx, userID)) {
return false, pinnedTooMuchErr()
}
if r.deps.Dialogs == nil && r.deps.Communities == nil {
return true, nil
}
changed, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce())
if err != nil {
return false, internalErr()
changed := false
if r.deps.Dialogs != nil {
dialogsChanged, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce())
if err != nil {
return false, internalErr()
}
changed = dialogsChanged
}
if r.deps.Communities != nil && req.FolderID == domain.DialogMainFolderID {
communitiesChanged, err := r.deps.Communities.ReorderPinned(ctx, userID, peers, req.GetForce())
if err != nil {
return false, communityErr(err)
}
changed = changed || communitiesChanged
}
if !changed {
return true, nil
@ -871,6 +976,15 @@ func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items [
hasFolder := false
for _, item := range items {
switch p := item.(type) {
case *tg.InputDialogPeerCommunity:
view, ok, err := r.communityDialogPeerFromInput(ctx, userID, p)
if err != nil {
return nil, err
}
if !ok {
return nil, inputConstructorInvalidErr()
}
peers = append(peers, domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID})
case *tg.InputDialogPeer:
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer)
if err != nil {

View file

@ -528,19 +528,17 @@ func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesG
}
func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) {
// Layer 228 adds an optional community scope. telesrv has no Communities
// membership/link read model yet, so treating this as an ordinary global
// search would leak results outside the requested scope. Reject it before
// any search/store work until that model exists.
if _, ok := req.GetCommunity(); ok || req.Community != nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
if req.BroadcastsOnly && req.GroupsOnly {
return &tg.MessagesMessages{}, nil
}
query := normalizeSearchQuery(req.Q)
musicOnly := messagesSearchFilterMusic(req.Filter)
if query == "" && !musicOnly {
communityInput, hasCommunity := req.GetCommunity()
if !hasCommunity && req.Community != nil {
communityInput, hasCommunity = req.Community, true
}
emptyCommunitySearch := query == "" && !musicOnly && hasCommunity && messagesSearchFilterEmpty(req.Filter)
if query == "" && !musicOnly && !emptyCommunitySearch {
return nil, searchQueryEmptyErr()
}
if utf8.RuneCountInString(query) > maxMessageSearchQLength {
@ -553,6 +551,19 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
if err != nil {
return nil, internalErr()
}
var communityView *domain.CommunityView
var communityScope domain.CommunitySearchScope
if hasCommunity {
view, err := r.communityFromInput(ctx, userID, communityInput)
if err != nil {
return nil, err
}
scope, err := r.deps.Communities.SearchScope(ctx, userID, view.Community.ID)
if err != nil {
return nil, communityErr(err)
}
communityView, communityScope = &view, scope
}
limit := req.Limit
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
limit = domain.MaxChannelGlobalSearchLimit
@ -565,6 +576,9 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
if err != nil {
return nil, err
}
if emptyCommunitySearch {
return appendCommunitySearchChat(&tg.MessagesMessages{}, communityView), nil
}
var private domain.MessageList
if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil {
filter := domain.MessageFilter{
@ -574,6 +588,10 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
Limit: limit + 1,
MusicOnly: musicOnly,
}
if communityView != nil {
filter.RestrictPeerIDs = true
filter.PeerIDs = communityScope.BotUserIDs
}
if req.MaxDate > 0 {
filter.OffsetDate = req.MaxDate
}
@ -586,30 +604,49 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
}
}
if req.UsersOnly || r.deps.Channels == nil {
return tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), nil
return appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView), nil
}
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
Query: query,
BroadcastsOnly: req.BroadcastsOnly,
GroupsOnly: req.GroupsOnly,
MusicOnly: musicOnly,
HasFolderID: hasFolderID,
FolderID: folderID,
OffsetRate: req.OffsetRate,
OffsetChannelID: channelOffsetID,
OffsetID: req.OffsetID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
Limit: limit,
Query: query,
ChannelIDs: communityScope.ChannelIDs,
RestrictChannelIDs: communityView != nil,
AllowPublicPreview: communityView != nil,
BroadcastsOnly: req.BroadcastsOnly,
GroupsOnly: req.GroupsOnly,
MusicOnly: musicOnly,
HasFolderID: hasFolderID,
FolderID: folderID,
OffsetRate: req.OffsetRate,
OffsetChannelID: channelOffsetID,
OffsetID: req.OffsetID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
Limit: limit,
})
if err != nil {
return nil, channelInvalidErr(err)
}
channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory)
if req.BroadcastsOnly || req.GroupsOnly {
return r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), nil
return appendCommunitySearchChat(r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), communityView), nil
}
return r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), nil
return appendCommunitySearchChat(r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), communityView), nil
}
func appendCommunitySearchChat(result tg.MessagesMessagesClass, view *domain.CommunityView) tg.MessagesMessagesClass {
if result == nil || view == nil {
return result
}
chat := tgCommunityChat(*view)
switch out := result.(type) {
case *tg.MessagesMessages:
out.Chats = appendUniqueTGChats(out.Chats, chat)
case *tg.MessagesMessagesSlice:
out.Chats = appendUniqueTGChats(out.Chats, chat)
case *tg.MessagesChannelMessages:
out.Chats = appendUniqueTGChats(out.Chats, chat)
}
return result
}
func limitMessageList(list domain.MessageList, limit int) domain.MessageList {
@ -791,6 +828,11 @@ func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool {
return ok
}
func messagesSearchFilterEmpty(filter tg.MessagesFilterClass) bool {
_, ok := filter.(*tg.InputMessagesFilterEmpty)
return ok
}
func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool {
_, ok := filter.(*tg.InputMessagesFilterChatPhotos)
return ok

View file

@ -11,24 +11,127 @@ import (
"go.uber.org/zap/zaptest"
"strings"
appchannels "telesrv/internal/app/channels"
appcommunities "telesrv/internal/app/communities"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestMessagesSearchGlobalRejectsUnsupportedCommunityScope(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
func TestMessagesSearchGlobalRestrictsCommunityScope(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "15550000084", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
viewer, err := users.Create(ctx, domain.User{AccessHash: 85, Phone: "15550000085", FirstName: "Viewer"})
if err != nil {
t.Fatal(err)
}
channels := memory.NewChannelStore()
channelService := appchannels.NewService(channels)
linked, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Linked", Megagroup: true, MemberUserIDs: []int64{viewer.ID}, Date: 100})
if err != nil {
t.Fatal(err)
}
publicPreview, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Public Preview", Megagroup: true, Date: 101})
if err != nil {
t.Fatal(err)
}
publicPreview.Channel, err = channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID, ChannelID: publicPreview.Channel.ID, Username: "community_public_preview",
})
if err != nil {
t.Fatal(err)
}
outside, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Outside", Megagroup: true, Date: 101})
if err != nil {
t.Fatal(err)
}
communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil))
community, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{
Title: "Scope", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: linked.Channel.ID},
Visibility: domain.CommunityPeerVisible, Date: 102,
})
if err != nil {
t.Fatal(err)
}
if _, err := communityService.TogglePeerLink(ctx, owner.ID, domain.CommunityTogglePeerLinkRequest{
CommunityID: community.Community.ID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: publicPreview.Channel.ID},
Visibility: domain.CommunityPeerVisible,
Date: 103,
}); err != nil {
t.Fatal(err)
}
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channelService, Communities: communityService}, zaptest.NewLogger(t), clock.System)
for i, channel := range []domain.Channel{linked.Channel, publicPreview.Channel, outside.Channel} {
_, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Message: "scoped result", RandomID: int64(9000 + i),
})
if err != nil {
t.Fatalf("send channel %d: %v", channel.ID, err)
}
}
req := &tg.MessagesSearchGlobalRequest{
Q: "scoped",
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
req.SetCommunity(&tg.InputChannel{ChannelID: 42, AccessHash: 84})
req.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash})
if _, err := r.onMessagesSearchGlobal(WithUserID(context.Background(), 1000000001), req); !tgerr.Is(err, "CHANNEL_INVALID") {
t.Fatalf("community-scoped messages.searchGlobal err = %v, want CHANNEL_INVALID", err)
result, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req)
if err != nil {
t.Fatalf("community-scoped messages.searchGlobal: %v", err)
}
response, ok := result.(*tg.MessagesMessages)
if !ok || len(response.Messages) != 2 {
t.Fatalf("community search result = %#v, want joined and public-preview linked messages", result)
}
gotChannels := map[int64]bool{}
for _, item := range response.Messages {
message, ok := item.(*tg.Message)
if !ok {
t.Fatalf("community search message = %#v, want channel message", item)
}
peer, ok := message.PeerID.(*tg.PeerChannel)
if !ok {
t.Fatalf("community search message peer = %#v", message.PeerID)
}
gotChannels[peer.ChannelID] = true
}
if !gotChannels[linked.Channel.ID] || !gotChannels[publicPreview.Channel.ID] || gotChannels[outside.Channel.ID] {
t.Fatalf("community search channels = %+v", gotChannels)
}
emptyReq := &tg.MessagesSearchGlobalRequest{
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
emptyReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash})
emptyResult, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), emptyReq)
if err != nil {
t.Fatalf("empty community-scoped messages.searchGlobal: %v", err)
}
emptyResponse, ok := emptyResult.(*tg.MessagesMessages)
if !ok || len(emptyResponse.Messages) != 0 || len(emptyResponse.Chats) != 1 {
t.Fatalf("empty community search result = %#v, want empty messages with validated Community chat", emptyResult)
}
if got, ok := emptyResponse.Chats[0].(*tg.Community); !ok || got.ID != community.Community.ID {
t.Fatalf("empty community search chat = %#v, want Community %d", emptyResponse.Chats[0], community.Community.ID)
}
badHashReq := &tg.MessagesSearchGlobalRequest{
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
badHashReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash + 1})
if _, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), badHashReq); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") {
t.Fatalf("empty community search wrong access hash err = %v, want CHANNEL_PRIVATE", err)
}
}

View file

@ -103,8 +103,8 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d
}, nil
}
// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身(直接投影,管理员
// 非其成员故不能走可见性受限的 GetChannels)+ 母广播频道(管理员是其成员)
// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影,
// 再按 viewer 补母广播频道。订阅者没有 monoforum member row管理员身份也只来自母频道
func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass {
chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})}
if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil {
@ -151,8 +151,8 @@ func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dia
return r.tgUsers(found)
}
// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否带 monoforum_peer_id(频道私信发送的唯一标志)
// 普通发送恒不带,故据此 gate monoforum 分支,普通发送热路径零额外成本
// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。
// 管理员回复必须带目标订阅者;普通订阅者按官方 TDesktop 行为不携带 reply_to目标由调用者推导
func monoforumReplyPresent(input tg.InputReplyToClass) bool {
switch v := input.(type) {
case *tg.InputReplyToMonoForum:
@ -189,46 +189,75 @@ func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToCla
return r.domainPeerFromInputPeer(userID, inputPeer)
}
func (r *Router) monoforumSavedPeerForSender(userID int64, isAdmin bool, replyTo tg.InputReplyToClass) (domain.Peer, error) {
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
if monoforumReplyPresent(replyTo) {
var valid bool
savedPeer, valid = r.monoforumReplyTargetPeer(userID, replyTo)
if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
} else if isAdmin {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
if !isAdmin && savedPeer.ID != userID {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
return savedPeer, nil
}
// monoforumMessageReplyFromInput separates the sub-dialog selector from the actual message reply.
// InputReplyToMonoForum only selects a subscriber; InputReplyToMessage may carry both the selector
// and a real reply_to_msg_id, so clear flags.5 before reusing the common structural validator.
func (r *Router) monoforumMessageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) {
switch value := input.(type) {
case nil, *tg.InputReplyToMonoForum:
return nil, nil
case *tg.InputReplyToMessage:
if value == nil {
return nil, nil
}
clean := *value
clean.Flags.Unset(5)
clean.MonoforumPeerID = nil
return r.messageReplyFromInput(ctx, userID, peer, &clean)
default:
return r.messageReplyFromInput(ctx, userID, peer, input)
}
}
// sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。
// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest, fingerprint []byte, preflighted bool) (tg.UpdatesClass, error) {
// saved_peer 对订阅者由调用者推导、对管理员来自 reply_to;管理员可写任意订阅者子会话,订阅者只能写自己的。
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, mono domain.Channel, isAdmin bool, req domain.SendMonoforumMessageRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
// 带 monoforum_peer_id 却不是 monoforum 频道。
return nil, tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED")
}
return nil, internalErr()
}
savedPeer, ok := r.monoforumReplyTargetPeer(userID, req.ReplyTo)
if !ok || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 {
if mono.ID != peer.ID || !mono.Monoforum || req.SavedPeer.Type != domain.PeerTypeUser || req.SavedPeer.ID == 0 {
return nil, replyToMonoforumPeerInvalidErr()
}
if !isAdmin && savedPeer.ID != userID {
if !isAdmin && req.SavedPeer.ID != userID {
// 普通订阅者只能写自己的子会话,不能写他人的。
return nil, replyToMonoforumPeerInvalidErr()
}
res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID,
SenderUserID: userID,
SavedPeer: savedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
IdempotencyPreflighted: preflighted,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
Date: int(r.clock.Now().Unix()),
})
req.MonoforumID = mono.ID
req.SenderUserID = userID
if req.Date == 0 {
req.Date = int(r.clock.Now().Unix())
}
res, err := r.deps.Channels.SendMonoforumMessage(ctx, req)
if err != nil {
return nil, messageSendErr(err)
}
return r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res), nil
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, req.ReplyTo)
}
if !res.Duplicate {
r.enqueueMonoforumMessageFanout(ctx, userID, mono, req.SavedPeer, res)
}
return r.monoforumSendUpdates(ctx, userID, mono, req.SavedPeer, res), nil
}
// monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage
@ -245,6 +274,11 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
}
updates = append(updates, newMsg)
if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID {
updates = append(updates, &tg.UpdateStarsBalance{
Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance},
})
}
date := int(r.clock.Now().Unix())
if res.Duplicate && res.ReplayDeleteEvent != nil {
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {
@ -261,3 +295,18 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
Date: date,
}
}
func (r *Router) monoforumDeliveryUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) *tg.Updates {
updates, _ := r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res).(*tg.Updates)
if updates == nil {
return nil
}
filtered := make([]tg.UpdateClass, 0, len(updates.Updates))
for _, update := range updates.Updates {
if _, randomMapping := update.(*tg.UpdateMessageID); !randomMapping {
filtered = append(filtered, update)
}
}
updates.Updates = filtered
return updates
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"testing"
"github.com/iamxvbaba/td/bin"
@ -10,6 +11,7 @@ import (
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
@ -17,7 +19,7 @@ import (
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
// (消息带 saved_peer_id);非管理员被拒
// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -98,12 +100,31 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
if !seenChats[monoID] || !seenChats[created.Channel.ID] {
t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID)
}
var deniedRaw bin.Buffer
if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&deniedRaw); err != nil {
var subscriberRaw bin.Buffer
if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&subscriberRaw); err != nil {
t.Fatalf("encode non-admin getHistory(monoforum): %v", err)
}
if _, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &deniedRaw); err == nil {
t.Fatalf("non-admin getHistory(monoforum) = nil err, want denied")
subscriberEnc, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &subscriberRaw)
if err != nil {
t.Fatalf("non-admin getHistory(monoforum): %v", err)
}
subscriberHistory, ok := subscriberEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("non-admin getHistory(monoforum) = %T, want *tg.MessagesChannelMessages", subscriberEnc)
}
if len(subscriberHistory.Messages) != 1 {
t.Fatalf("non-admin getHistory(monoforum) = %d msgs, want own sublist message", len(subscriberHistory.Messages))
}
subscriberMessage, ok := subscriberHistory.Messages[0].(*tg.Message)
if !ok || subscriberMessage.Message != "hello channel" {
t.Fatalf("non-admin history[0] = %#v, want own 'hello channel'", subscriberHistory.Messages[0])
}
subscriberSavedPeer, ok := subscriberMessage.GetSavedPeerID()
if !ok {
t.Fatalf("non-admin history message missing saved_peer_id")
}
if peer, ok := subscriberSavedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("non-admin history saved_peer_id = %#v, want self %d", subscriberSavedPeer, sub.ID)
}
// 管理员看私信列表。
@ -179,9 +200,9 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
}
}
// TestMonoforumSendMessageWritePath 验证写侧:订阅者经 sendMessage(peer=monoforum,
// reply_to=InputReplyToMonoForum{自己}) 发私信;管理员回复到目标订阅者;订阅者不能写他人子会话;
// 普通发送(无 monoforum_peer_id)不受影响
// TestMonoforumSendMessageWritePath 验证写侧:订阅者按 TDesktop 实际请求仅以
// peer=monoforum 发到自己的子会话;管理员必须显式指定目标订阅者;suggested_post 被持久化返回;
// 订阅者不能写他人子会话
func TestMonoforumSendMessageWritePath(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -196,39 +217,160 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
dialogSvc := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: dialogSvc,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 10, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
monoInput := &tg.InputPeerChannel{ChannelID: monoID}
mono, err := channelStore.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum: %v", err)
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
monoChannelInput := &tg.InputChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
// 订阅者发私信到自己的子会话。
// 订阅者不是 monoforum 成员,但 TDesktop 打开会话时必须能读取 full channel shell。
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, sub.ID), monoChannelInput)
if err != nil {
t.Fatalf("subscriber getFullChannel(monoforum): %v", err)
}
if full == nil || full.FullChat == nil {
t.Fatalf("subscriber getFullChannel(monoforum) = %#v, want full chat", full)
}
// monoforum 永远不能通过 join 变成普通频道成员,否则会生成错误的 joined service message。
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, sub.ID), monoChannelInput); err == nil || !strings.Contains(err.Error(), "CHANNEL_MONOFORUM_UNSUPPORTED") {
t.Fatalf("subscriber joinChannel(monoforum) err = %v, want CHANNEL_MONOFORUM_UNSUPPORTED", err)
}
// TDesktop 在发送前保存相同 suggested_post 草稿;它必须可写、可恢复,不能变成 CHANNEL_PRIVATE。
draftSuggested := tg.SuggestedPost{}
draftSuggested.SetPrice(&tg.StarsAmount{Amount: 10})
draftSuggested.SetScheduleDate(1_700_100_000)
draftReq := &tg.MessagesSaveDraftRequest{Peer: monoInput, Message: "pending suggested post"}
draftReq.SetSuggestedPost(draftSuggested)
if ok, err := r.onMessagesSaveDraft(WithUserID(ctx, sub.ID), draftReq); err != nil || !ok {
t.Fatalf("subscriber saveDraft(monoforum) = %v, %v; want true, nil", ok, err)
}
storedDraft, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0)
if err != nil || !found {
t.Fatalf("get persisted monoforum draft = %+v, %v, %v; want found", storedDraft, found, err)
}
if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 {
t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft)
}
tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554}
tooLow.SetAllowPaidStars(9)
if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") {
t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err)
}
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555}
subReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}})
subReq.ClearDraft = true
subReq.SetAllowPaidStars(20)
suggestedInput := tg.SuggestedPost{}
suggestedInput.SetPrice(&tg.StarsAmount{Amount: 10})
suggestedInput.SetScheduleDate(1_700_100_000)
subReq.SetSuggestedPost(suggestedInput)
subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil {
t.Fatalf("subscriber sendMessage(monoforum): %v", err)
}
if _, ok := subUpd.(*tg.Updates); !ok {
subUpdates, ok := subUpd.(*tg.Updates)
if !ok {
t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd)
}
var subMessageID int
var subPaidStars, subBalance int64
for _, update := range subUpdates.Updates {
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
if message, ok := newMessage.Message.(*tg.Message); ok {
subMessageID = message.ID
subPaidStars, _ = message.GetPaidMessageStars()
}
}
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
subBalance = amount.Amount
}
}
}
if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 {
t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates)
}
if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found {
t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err)
}
duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil {
t.Fatalf("subscriber paid replay: %v", err)
}
duplicateUpdates, ok := duplicateUpd.(*tg.Updates)
if !ok {
t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd)
}
var duplicateBalance int64
for _, update := range duplicateUpdates.Updates {
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
duplicateBalance = amount.Amount
}
}
}
if duplicateBalance != 990 {
t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance)
}
// 管理员回复到该订阅者的子会话。
// 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id
// 和 monoforum target两部分都必须保留。
adminReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556}
adminReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq); err != nil {
adminReply := &tg.InputReplyToMessage{ReplyToMsgID: subMessageID}
adminReply.SetMonoforumPeerID(&tg.InputPeerUser{UserID: sub.ID})
adminReq.SetReplyTo(adminReply)
adminReq.SetAllowPaidStars(100)
adminUpd, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq)
if err != nil {
t.Fatalf("admin reply sendMessage(monoforum): %v", err)
}
if updates, ok := adminUpd.(*tg.Updates); ok {
for _, update := range updates.Updates {
if _, balance := update.(*tg.UpdateStarsBalance); balance {
t.Fatalf("admin free reply emitted a balance debit: %#v", updates.Updates)
}
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
if message, ok := newMessage.Message.(*tg.Message); ok {
if stars, paid := message.GetPaidMessageStars(); paid || stars != 0 {
t.Fatalf("admin reply paid_message_stars = %d/%v, want 0/false", stars, paid)
}
}
}
}
}
// 带媒体的 suggested post 必须走同一 monoforum 子会话,不能退化为普通频道消息或丢 flags。
mediaSuggested := tg.SuggestedPost{}
mediaSuggested.SetPrice(&tg.StarsAmount{Amount: 15})
mediaReq := &tg.MessagesSendMediaRequest{
Peer: monoInput, RandomID: 558, Message: "media suggestion",
Media: &tg.InputMediaContact{PhoneNumber: "+15550003003", FirstName: "Media", LastName: "Contact", Vcard: ""},
}
mediaReq.SetSuggestedPost(mediaSuggested)
mediaReq.SetAllowPaidStars(15)
if _, err := r.onMessagesSendMedia(WithUserID(ctx, sub.ID), mediaReq); err != nil {
t.Fatalf("subscriber sendMedia(monoforum): %v", err)
}
// 订阅者不能写他人(owner)的子会话。
sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557}
@ -237,7 +379,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
t.Fatalf("subscriber writing another's sublist = nil err, want REPLY_TO_MONOFORUM_PEER_INVALID")
}
// 经管理员读历史:子会话含两条(订阅者发 + 管理员回复),倒序。
// 经管理员读历史:子会话含三条(订阅者文本 + 管理员回复 + 订阅者媒体),倒序。
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
hreq.SetParentPeer(monoInput)
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
@ -248,11 +390,54 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if !ok {
t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres)
}
if len(slice.Messages) != 2 {
t.Fatalf("history = %d msgs, want 2 (sub + admin)", len(slice.Messages))
if len(slice.Messages) != 3 {
t.Fatalf("history = %d msgs, want 3 (sub text + admin + sub media)", len(slice.Messages))
}
top, ok := slice.Messages[0].(*tg.Message)
if !ok || top.Message != "admin reply" {
t.Fatalf("history[0] = %#v, want newest 'admin reply'", slice.Messages[0])
if !ok || top.Message != "media suggestion" {
t.Fatalf("history[0] = %#v, want newest media suggestion", slice.Messages[0])
}
if _, ok := top.Media.(*tg.MessageMediaContact); !ok {
t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media)
}
if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 {
t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok)
}
topSuggested, ok := top.GetSuggestedPost()
if !ok {
t.Fatalf("media message missing suggested_post")
}
topPrice, ok := topSuggested.GetPrice()
if !ok {
t.Fatalf("media suggested_post missing price")
}
if stars, ok := topPrice.(*tg.StarsAmount); !ok || stars.Amount != 15 {
t.Fatalf("media suggested_post price = %#v, want 15 Stars", topPrice)
}
adminMessage, ok := slice.Messages[1].(*tg.Message)
if !ok || adminMessage.Message != "admin reply" {
t.Fatalf("history[1] = %#v, want admin reply", slice.Messages[1])
}
if header, ok := adminMessage.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != subMessageID {
t.Fatalf("admin reply header = %#v, want reply_to_msg_id %d", adminMessage.ReplyTo, subMessageID)
}
suggestedMessage, ok := slice.Messages[2].(*tg.Message)
if !ok {
t.Fatalf("history[2] = %T, want *tg.Message", slice.Messages[2])
}
suggested, ok := suggestedMessage.GetSuggestedPost()
if !ok {
t.Fatalf("subscriber message missing suggested_post")
}
price, ok := suggested.GetPrice()
if !ok {
t.Fatalf("suggested_post missing price")
}
stars, ok := price.(*tg.StarsAmount)
if !ok || stars.Amount != 10 {
t.Fatalf("suggested_post price = %#v, want 10 Stars", price)
}
if scheduleDate, ok := suggested.GetScheduleDate(); !ok || scheduleDate != 1_700_100_000 {
t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok)
}
}

View file

@ -402,7 +402,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, err
}
if filter.Hash != 0 {
if filter.Hash != 0 && r.deps.Communities == nil {
hashCheck, err := r.deps.Dialogs.GetDialogsHash(ctx, userID, filter)
if err != nil {
return nil, internalErr()
@ -415,6 +415,10 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, internalErr()
}
list, err = r.withCommunityDialogList(ctx, userID, filter, list)
if err != nil {
return nil, communityErr(err)
}
if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) {
pinned, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID)
if err != nil {
@ -422,7 +426,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
}
list = tdesktop.MergeInitialDialogsWithPinned(list, pinned)
}
if filter.Hash != 0 && list.Hash == filter.Hash {
if filter.Hash != 0 && r.deps.Communities == nil && list.Hash == filter.Hash {
return &tg.MessagesDialogsNotModified{Count: list.Count}, nil
}
return r.tgMessagesDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list)), nil
@ -481,14 +485,31 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil {
return nil, err
}
regularPeers := make([]domain.Peer, 0, len(domainPeers))
communityIDs := make([]int64, 0)
for _, peer := range domainPeers {
if peer.Type == domain.PeerTypeCommunity {
communityIDs = append(communityIDs, peer.ID)
} else {
regularPeers = append(regularPeers, peer)
}
}
var list domain.DialogList
if len(domainPeers) > 0 && r.deps.Dialogs != nil {
if len(regularPeers) > 0 && r.deps.Dialogs != nil {
var err error
list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers)
list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, regularPeers)
if err != nil {
return nil, internalErr()
}
}
if len(communityIDs) > 0 && r.deps.Communities != nil {
views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs)
if err != nil {
return nil, communityErr(err)
}
list.Communities = append(list.Communities, views...)
list.Count += len(views)
}
st := domain.UpdateState{Date: int(r.clock.Now().Unix())}
if r.deps.Updates != nil {
var err error

View file

@ -3,10 +3,12 @@ package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) {
@ -63,12 +65,39 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = internalErr()
return nil, sendErr
}
// 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带,
// 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。
if monoforumReplyPresent(req.ReplyTo) {
savedPeer, valid := r.monoforumReplyTargetPeer(userID, req.ReplyTo)
if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 {
sendErr = replyToMonoforumPeerInvalidErr()
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
// monoforum 普通用户发送不带 reply_tosaved_peer 必须由服务端推导为自己;管理员回复才必须
// 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。
var mono domain.Channel
var monoforum, monoforumAdmin bool
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID)
switch {
case err == nil:
monoforum = true
case !errors.Is(err, domain.ErrChannelInvalid):
sendErr = internalErr()
return nil, sendErr
}
}
if hasSuggestedPost && !monoforum {
sendErr = suggestedPostPeerInvalidErr()
return nil, sendErr
}
if monoforum {
suggestedPost, suggestedErr := domainSuggestedPost(suggestedInput, hasSuggestedPost)
if suggestedErr != nil {
sendErr = suggestedErr
return nil, sendErr
}
savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo)
if err != nil {
sendErr = err
return nil, sendErr
}
replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo)
if err != nil {
sendErr = err
return nil, sendErr
}
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint)
@ -78,6 +107,9 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
if replay.found {
duplicate = true
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
@ -89,13 +121,30 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = err
return nil, sendErr
}
updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, req, idempotencyFingerprint, replay.checked)
updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{
SavedPeer: savedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: idempotencyFingerprint,
IdempotencyPreflighted: replay.checked,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
ReplyTo: replyTo,
Silent: req.Silent,
NoForwards: req.Noforwards,
SuggestedPost: suggestedPost,
AllowPaidStars: req.AllowPaidStars,
ClearDraft: req.ClearDraft,
})
if err != nil {
sendErr = err
return nil, sendErr
}
return updates, nil
}
if req.AllowPaidStars > 0 {
sendErr = paymentUnsupportedErr()
return nil, sendErr
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
@ -120,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = err
return nil, sendErr
}
// reply_markupbot inline keyboard仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
// reply_markupbot 可发送 inline keyboard 与普通 reply keyboard/hide/force
// 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。
// 仅在请求携带 markup 时才查 is_bot避免普通发送多打一次查询。
var replyMarkup *domain.MessageReplyMarkup
if req.ReplyMarkup != nil {
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
if err != nil {
sendErr = replyMarkupErr(err)
return nil, sendErr
}
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
sendErr = err
return nil, sendErr
}
}
// rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误。
@ -201,7 +255,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
func messageSendErr(err error) error {
var paymentRequired *domain.StarsPaymentRequiredError
switch {
case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0:
return allowPaymentRequiredErr(paymentRequired.Stars)
case errors.Is(err, domain.ErrStarsInsufficient):
return balanceTooLowErr()
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
@ -314,10 +373,8 @@ func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error {
// req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。
case req.AllowPaidStars < 0:
return starsAmountInvalidErr()
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip:
case req.AllowPaidFloodskip:
return paymentUnsupportedErr()
case !req.SuggestedPost.Zero():
return suggestedPostPeerInvalidErr()
default:
return nil
}

View file

@ -0,0 +1,73 @@
package rpc
import (
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
const (
minSuggestedPostStars int64 = 5
maxSuggestedPostStars int64 = 100_000
minSuggestedPostNanoTON int64 = 10_000_000
maxSuggestedPostNanoTON int64 = 10_000_000_000_000
)
func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) {
if !present {
return nil, nil
}
if input.GetAccepted() || input.GetRejected() {
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
}
out := &domain.SuggestedPost{}
if date, ok := input.GetScheduleDate(); ok {
if date <= 0 {
return nil, scheduleDateInvalidErr()
}
out.ScheduleDate = date
}
if price, ok := input.GetPrice(); ok {
switch value := price.(type) {
case *tg.StarsAmount:
if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars ||
value.Nanos < 0 || value.Nanos >= 1_000_000_000 || value.Amount == maxSuggestedPostStars && value.Nanos != 0 {
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
}
out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: value.Amount, Nanos: value.Nanos}
case *tg.StarsTonAmount:
if value == nil || value.Amount < minSuggestedPostNanoTON || value.Amount > maxSuggestedPostNanoTON {
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
}
out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: value.Amount}
default:
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
}
}
return out, nil
}
func tgSuggestedPost(input *domain.SuggestedPost) (tg.SuggestedPost, bool) {
if input == nil {
return tg.SuggestedPost{}, false
}
out := tg.SuggestedPost{}
if input.Accepted {
out.SetAccepted(true)
}
if input.Rejected {
out.SetRejected(true)
}
if input.ScheduleDate > 0 {
out.SetScheduleDate(input.ScheduleDate)
}
if input.Price != nil {
switch input.Price.Kind {
case domain.SuggestedPostPriceStars:
out.SetPrice(&tg.StarsAmount{Amount: input.Price.Amount, Nanos: input.Price.Nanos})
case domain.SuggestedPostPriceTON:
out.SetPrice(&tg.StarsTonAmount{Amount: input.Price.Amount})
}
}
return out, true
}

View file

@ -213,7 +213,7 @@ func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t
if !ok {
t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueAdsAccountURL", got)
}
if url.URL != "https://ads.telegram.org/" {
if url.URL != "https://telesrv.net" {
t.Fatalf("url = %q, want ads compat URL", url.URL)
}

View file

@ -33,12 +33,11 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) {
return r.onPaymentsCheckCanSendGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
hash := layerRequest.
Hash
_ = hash
return tdesktop.StarGiftActiveAuctions(), nil
return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
return r.onPaymentsGetStarGifts(ctx, layerRequest.
@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
GiftID)
})
registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) {
return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
Slug)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) {
return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req)
})
registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) {
return r.onPaymentsGetResaleStarGifts(ctx, req)
})
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
})
@ -75,6 +83,36 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
return r.onPaymentsUpgradeStarGift(ctx, layerRequest)
})
registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) {
return r.onPaymentsUpdateStarGiftPrice(ctx, req)
})
registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) {
return r.onPaymentsTransferStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) {
return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req)
})
registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) {
return r.onPaymentsSendStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) {
return r.onPaymentsResolveStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) {
return r.onPaymentsGetCraftStarGifts(ctx, req)
})
registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) {
return r.onPaymentsCraftStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionState(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req)
})
registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) {
return r.onPaymentsToggleChatStarGiftNotifications(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest)
})
@ -105,39 +143,119 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
return nil, err
}
return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil
return &tg.PaymentsStarsRevenueAdsAccountURL{URL: r.publicLink("")}, nil
})
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil {
return nil, peerIDInvalidErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
return tdesktop.StarsRevenueStats(req.GetTon()), nil
return r.onPaymentsGetStarsRevenueStats(ctx, req)
})
}
// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。
// 响应必须是 payments.starsStatusbalance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balanceDrKLO StarsAmount 反序列化 / TDesktop vbalance())。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
if req != nil && req.GetTon() {
// TON 余额未建模:返回 0 nanoton 的合法响应。
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
// and bot revenue remain the bounded compatibility response because their
// revenue bucket is distinct from the general Stars balance and is not modeled.
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil {
return nil, peerIDInvalidErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
ton := req.GetTon()
if owner.Type != domain.PeerTypeChannel {
return tdesktop.StarsRevenueStats(ton), nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return nil, err
}
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
return tdesktop.StarsRevenueStats(ton), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return nil, internalErr()
}
stats := tdesktop.StarsRevenueStats(ton)
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
}
// Channel ledgers currently only receive collectible conversion/marketplace
// proceeds and have no withdrawal/debit path, so balance equals lifetime
// revenue. Withdrawal stays disabled because no external payout exists.
stats.Status.CurrentBalance = amount
stats.Status.AvailableBalance = amount
stats.Status.OverallRevenue = amount
return stats, nil
}
type channelGiftLedgerReader interface {
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error)
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error)
}
// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本
// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。
// 响应必须是 payments.starsStatusbalance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balanceDrKLO StarsAmount 反序列化 / TDesktop vbalance())。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
userID, owner, err := r.starGiftLedgerOwner(ctx, req)
if err != nil {
return nil, err
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return nil, internalErr()
}
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
}
out := emptyStarsStatus(amount)
out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID})
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
balance, err := r.deps.Gifts.TonBalance(ctx, userID)
if err != nil {
return nil, internalErr()
}
return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
bal, err := r.deps.Stars.GetBalance(ctx, userID)
if err != nil {
return nil, starsErr(err)
@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
// 末页必须省略 next_offsetflag 不置),否则 DrKLO 会无限翻页。
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
if req != nil && req.GetTon() {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
userID, _, err := r.currentUserID(ctx)
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
if err != nil {
return nil, internalErr()
return nil, err
}
offset := ""
limit := domain.MaxStarsTransactionsLimit
offset, limit := "", domain.MaxStarsTransactionsLimit
if req != nil {
offset = req.Offset
if req.Limit > 0 {
limit = req.Limit
}
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
if ton {
page, err := ledger.ChannelTonTransactions(ctx, owner.ID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
page, err := r.deps.Gifts.TonTransactions(ctx, userID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
ids := make([]int64, 0)
for _, txn := range page.Transactions {
if txn.Peer.Type == domain.PeerTypeUser {
ids = append(ids, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids)))
return out, nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit)
if err != nil {
return nil, starsErr(err)
@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay
return out, nil
}
func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, domain.Peer{}, internalErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return 0, domain.Peer{}, err
}
if owner.Type == domain.PeerTypeUser {
if owner.ID != userID {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return userID, owner, nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return 0, domain.Peer{}, err
}
return userID, owner, nil
}
func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
}
func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
}
// emptyStarsStatus 构造一个合法的最小 payments.starsStatuschats/users 非空 vector 但可空)。
func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
return &tg.PaymentsStarsStatus{
@ -212,8 +453,53 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
switch t.Reason {
case domain.StarsReasonReaction:
item.Reaction = true
case domain.StarsReasonPaidMessage:
item.SetPaidMessages(1)
case domain.StarsReasonGift:
item.Gift = true
case domain.StarsReasonGiftUpgrade:
item.StargiftUpgrade = true
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftOffer:
item.Offer = true
}
out = append(out, item)
}
return out
}
func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction {
out := make([]tg.StarsTransaction, 0, len(in))
for _, t := range in {
item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount},
Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})}
if t.Amount > 0 {
item.Refund = true
}
if t.Title != "" {
item.SetTitle(t.Title)
}
if t.Description != "" {
item.SetDescription(t.Description)
}
switch t.Reason {
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftOffer:
item.Offer = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
}
out = append(out, item)
}

View file

@ -0,0 +1,98 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) {
base := domain.StarGift{
ID: 8001,
RevisionID: 9001,
Stars: 100,
ConvertStars: 85,
Title: "Fresh Socks",
FirstSaleDate: 100,
LastSaleDate: 200,
Sticker: domain.Document{
ID: 700,
AccessHash: 7,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
}
tests := []struct {
name string
gift domain.StarGift
wantSoldOut bool
wantSaleDate bool
}{
{name: "unlimited live gift with operational sale history", gift: base},
{name: "limited live gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.AvailabilityRemains = 9
gift.AvailabilityTotal = 10
return gift
}()},
{name: "sold out gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.SoldOut = true
gift.AvailabilityTotal = 10
return gift
}(), wantSoldOut: true, wantSaleDate: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, profile := range []tlprofile.Profile{
tlprofile.Profile225,
tlprofile.Profile226,
tlprofile.Profile227,
tlprofile.Profile228,
} {
response := &tg.PaymentsStarGifts{
Hash: 1,
Gifts: []tg.StarGiftClass{tgStarGift(test.gift)},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, response, wire); err != nil {
t.Fatalf("encode Layer %d catalog: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d catalog: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.PaymentsStarGifts)
if !ok || len(decoded.Gifts) != 1 {
t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject)
}
gift, ok := decoded.Gifts[0].(*tg.StarGift)
if !ok {
t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0])
}
if gift.SoldOut != test.wantSoldOut {
t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut)
}
first, firstSet := gift.GetFirstSaleDate()
last, lastSet := gift.GetLastSaleDate()
if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate {
t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate)
}
if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) {
t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -26,18 +26,37 @@ func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, i
}
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
chargeStars := int64(0)
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 {
return nil, starGiftInvalidErr()
}
chargeStars = receipt.ChargeStars
} else {
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
}
chargeStars = preview.UpgradeStars
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars,
FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails),
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
FormID: formID, CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
if err != nil {
return nil, internalErr()
}
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift)
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
if err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 {
return nil, starGiftInvalidErr()
}
} else {
if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
}
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails),
CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
@ -79,33 +113,60 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
}
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
}
if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID {
// Channel gift upgrades require a channel pts aggregate and are not silently
// routed through the private-message transaction.
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
return saved, preview, err
}
func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, err
}
if !ok {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil {
return domain.SavedStarGift{}, err
}
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
return domain.SavedStarGift{}, internalErr()
}
if !found || saved.Converted || saved.UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
if !found {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
return saved, nil
}
func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) {
if saved.Converted || saved.UniqueGiftID != 0 {
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
return domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
return saved, preview, nil
return preview, nil
}
func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef {
ref := domain.SavedStarGiftRef{Owner: saved.Owner}
if saved.Owner.Type == domain.PeerTypeChannel {
ref.SavedID = saved.SavedID
} else {
ref.MsgID = saved.MsgID
}
return ref
}
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
@ -116,6 +177,17 @@ func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64
updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(ctx, ownerUserID, message))
for _, edit := range result.SourceEdits {
if edit.UserID != ownerUserID {
continue
}
if update := tgOtherUpdateFromEvent(edit.Event); update != nil {
updates.Updates = append(updates.Updates, update)
if edit.Event.Date > updates.Date {
updates.Date = edit.Event.Date
}
}
}
if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
}
@ -175,6 +247,20 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID
}, nil
}
func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) {
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil
}
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr()
@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attribute := range preview.Models {
if attribute.Crafted {
continue
}
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Patterns {
@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta
return out
}
func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} {
for _, attribute := range attributes {
out = append(out, tgStarGiftAttribute(attribute))
}
}
return out
}
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
rarity := tgStarGiftAttributeRarity(attribute)
switch attribute.Kind {
case domain.StarGiftCollectibleModel:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted}
case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
@ -248,6 +347,21 @@ func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarG
}
}
func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass {
switch attribute.RarityKind {
case domain.StarGiftRarityUncommon:
return &tg.StarGiftAttributeRarityUncommon{}
case domain.StarGiftRarityRare:
return &tg.StarGiftAttributeRarityRare{}
case domain.StarGiftRarityEpic:
return &tg.StarGiftAttributeRarityEpic{}
case domain.StarGiftRarityLegendary:
return &tg.StarGiftAttributeRarityLegendary{}
default:
return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
}
}
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model),
@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes = append(attributes, original)
}
out := &tg.StarGiftUnique{
RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly,
ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted,
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
}
if owner := tgPeer(unique.Owner); owner != nil {
if unique.OwnerAddress != "" {
out.SetOwnerAddress(unique.OwnerAddress)
} else if owner := tgPeer(unique.Owner); owner != nil {
out.SetOwnerID(owner)
} else if unique.OwnerName != "" {
out.SetOwnerName(unique.OwnerName)
}
if unique.GiftAddress != "" {
out.SetGiftAddress(unique.GiftAddress)
}
if unique.ResellAmount != nil {
out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)})
}
if peer := tgPeer(unique.ReleasedBy); peer != nil {
out.SetReleasedBy(peer)
}
if unique.ValueAmount > 0 {
out.SetValueAmount(unique.ValueAmount)
}
if unique.ValueCurrency != "" {
out.SetValueCurrency(unique.ValueCurrency)
}
if unique.ValueUSD > 0 {
out.SetValueUsdAmount(unique.ValueUSD)
}
if peer := tgPeer(unique.ThemePeer); peer != nil {
out.SetThemePeer(peer)
}
if peer := tgPeer(unique.Host); peer != nil {
out.SetHostID(peer)
}
if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser {
out.SetOfferMinStars(unique.OfferMinStars)
}
if unique.CraftChancePermille > 0 {
out.SetCraftChancePermille(unique.CraftChancePermille)
}
return out
}
func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass {
if amount.Currency == domain.StarGiftCurrencyTON {
return &tg.StarsTonAmount{Amount: amount.Amount}
}
return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos}
}
func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) {
switch value := amount.(type) {
case *tg.StarsAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos}
return out, out.Valid()
case *tg.StarsTonAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount}
return out, out.Valid()
default:
return domain.StarGiftAmount{}, false
}
}

View file

@ -2,12 +2,17 @@ package rpc
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -80,6 +85,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
return r.starGiftTransferPaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
return r.starGiftResalePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
return r.starGiftAuctionBidPaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
return r.starGiftPrepaidUpgradePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
return r.starGiftDropDetailsPaymentForm(ctx, userID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -92,16 +112,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if err != nil {
return nil, err
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
// Channel upgrades remain blocked until they can advance channel pts and
// publish a durable channel update. Never collect a prepaid upgrade that
// the recipient cannot consume.
return nil, starGiftInvalidErr()
}
gift, err := r.starGiftFromCatalog(ctx, inv.GiftID)
if err != nil {
return nil, err
}
if gift.RequirePremium && !r.viewerPremium(ctx, userID) {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
@ -109,8 +126,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}
upgradeStars = gift.UpgradeStars
}
giftMessage := ""
if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text)
}
now := int(r.clock.Now().Unix())
form, err := r.deps.Gifts.IssuePurchaseForm(ctx, domain.StarGiftPurchaseForm{
BuyerUserID: userID, To: peer, GiftID: gift.ID, RevisionID: gift.RevisionID,
IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
ChargeStars: gift.Stars + upgradeStars, IssuedAt: now, ExpiresAt: now + 600,
})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade),
FormID: form.FormID,
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}},
@ -139,11 +169,29 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
return r.sendStarGiftTransferForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
return r.sendStarGiftResaleForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
return r.sendStarGiftAuctionBidForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
return r.sendStarGiftPrepaidUpgradeForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
return r.sendStarGiftDropDetailsForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
if req.FormID == 0 {
return nil, formIDEmptyErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
@ -151,15 +199,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
return nil, starGiftInvalidErr()
}
if r.deps.Stars == nil || r.deps.Gifts == nil {
return nil, notImplementedErr()
}
if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil {
return nil, notImplementedErr()
}
if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil {
return nil, notImplementedErr()
}
@ -167,6 +209,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if err != nil {
return nil, err
}
buyerPremium := r.viewerPremium(ctx, userID)
if gift.RequirePremium && !buyerPremium {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
@ -174,27 +220,58 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
}
upgradeStars = gift.UpgradeStars
}
if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) {
return nil, starsFormAmountMismatchErr()
}
giftMessage := ""
if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text)
}
now := int(r.clock.Now().Unix())
purchaseReq := domain.StarGiftPurchaseRequest{BuyerUserID: userID, BuyerPremium: buyerPremium, To: peer,
GiftID: gift.ID, RevisionID: gift.RevisionID, IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
recipientBlocked := false
if peer.Type == domain.PeerTypeUser {
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, internalErr()
}
}
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
return nil, starGiftLifecycleErr(err)
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
return r.sendStarGiftMemoryPurchase(ctx, userID, peer, gift, inv, giftMessage, upgradeStars)
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
purchaseReq.RecipientBlocked = recipientBlocked
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
if err != nil {
return nil, starGiftLifecycleErr(err)
}
updates := r.starGiftSendUpdates(ctx, userID, result.Send)
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance)
r.invalidateStarGiftOwner(peer)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
// 1. Debit 送礼人不足→BALANCE_TOO_LOW
func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift,
inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) {
purchaseStars := gift.Stars + upgradeStars
balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
if err != nil {
return nil, starsErr(err)
}
var updates *tg.Updates
switch peer.Type {
case domain.PeerTypeUser:
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
case domain.PeerTypeChannel:
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
default:
err = domain.ErrStarGiftInvalid
}
@ -202,18 +279,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
return nil, internalErr()
}
// 4. 构建送礼人 Updates服务消息 + updateStarsBalance
if updates != nil {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
} else {
updates = &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
}
if updates == nil {
updates = emptyGiftUpdates(r.clock.Now().Unix())
}
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
@ -256,11 +325,11 @@ func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePayme
return &tg.PaymentsPaymentFormStars{
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
BotID: domain.OfficialSystemUserID,
Title: "Telegram Stars",
Title: branding.StarsName,
Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}},
},
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
}
@ -295,8 +364,16 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, err
}
prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars)
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash)
if err != nil {
return nil, err
}
@ -312,6 +389,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,
Message: message,
}); err != nil {
return nil, err
@ -324,38 +402,40 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
}
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
now := int(r.clock.Now().Unix())
sticker := gift.Sticker
action := domain.ChannelMessageAction{
Type: domain.ChannelActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
CanUpgrade: false,
PrepaidUpgrade: false,
UpgradeStars: 0,
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
},
}
savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: 0,
SavedID: 0,
Date: now,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
Message: message,
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: 0,
SavedID: 0,
Date: now,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
Message: message,
})
if err != nil {
return nil, err
@ -375,7 +455,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
}
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) {
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64, prepaidUpgradeHash string) (domain.SendPrivateTextResult, error) {
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -387,19 +467,21 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
PeerUserID: recipientID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: gift.UpgradeStars,
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
PeerUserID: recipientID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
PrepaidUpgradeHash: prepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
},
},
}
@ -529,13 +611,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav
return true, nil
}
// onPaymentsConvertStarGift 把收到的礼物转换回 StarsCredit + 标记 converted
// onPaymentsConvertStarGift atomically destroys the regular gift and credits
// the owner-scoped internal Stars ledger. Channel proceeds never leak to the
// acting administrator's personal balance.
func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if r.deps.Gifts == nil || r.deps.Stars == nil {
if r.deps.Gifts == nil {
return false, notImplementedErr()
}
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
@ -545,10 +629,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
if !ok {
return false, starGiftInvalidErr()
}
if dref.Owner.Type == domain.PeerTypeChannel {
return false, notImplementedErr()
if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil {
return false, err
}
saved, err := r.deps.Gifts.Convert(ctx, dref)
// The isolated memory RPC adapter intentionally has no aggregate store. Keep
// its conversion primitive usable for tests, but never use this split write
// path when the production lifecycle coordinator is configured. Channel
// balances have no memory adapter because crediting an administrator would
// violate owner-scoped accounting.
if converter, ok := r.deps.Gifts.(interface {
AtomicPurchaseConfigured() bool
Convert(context.Context, domain.SavedStarGiftRef) (domain.SavedStarGift, error)
}); ok && !converter.AtomicPurchaseConfigured() {
if dref.Owner.Type != domain.PeerTypeUser || dref.Owner.ID != userID {
return false, notImplementedErr()
}
updated, convertErr := converter.Convert(ctx, dref)
if convertErr != nil {
if errors.Is(convertErr, domain.ErrStarGiftNotFound) || errors.Is(convertErr, domain.ErrStarGiftAlreadyConverted) {
return false, starGiftInvalidErr()
}
return false, internalErr()
}
if updated.ConvertStars > 0 {
if _, creditErr := r.deps.Stars.Credit(ctx, userID, updated.ConvertStars, domain.StarsReasonGift,
dref.Owner, "Star gift conversion", "Converted Star Gift"); creditErr != nil {
return false, internalErr()
}
}
r.invalidateStarGiftOwnerProjection(dref.Owner)
return true, nil
}
result, err := r.deps.Gifts.ConvertAggregate(ctx, domain.StarGiftConvertRequest{
ActorUserID: userID,
Ref: dref,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound):
@ -559,15 +675,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
return false, internalErr()
}
}
if saved.ConvertStars > 0 {
fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID}
if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil {
r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err))
return false, internalErr()
}
}
// 转换移除一份展示礼物 → 失效 owner full 投影。
r.invalidateStarGiftOwnerProjection(dref.Owner)
r.invalidateStarGiftOwnerProjection(result.Saved.Owner)
return true, nil
}
@ -622,6 +731,24 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
return domain.SavedStarGiftRef{}, false, peerIDInvalidErr()
}
return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil
case *tg.InputSavedStarGiftSlug:
if v == nil || r.deps.Gifts == nil {
return domain.SavedStarGiftRef{}, false, nil
}
slug := strings.ToLower(strings.TrimSpace(v.Slug))
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
return domain.SavedStarGiftRef{}, false, nil
}
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
if err != nil {
return domain.SavedStarGiftRef{}, false, internalErr()
}
if !found || unique.Slug == "" || unique.Owner.ID == 0 ||
(unique.Owner.Type != domain.PeerTypeUser && unique.Owner.Type != domain.PeerTypeChannel) {
return domain.SavedStarGiftRef{}, false, nil
}
resolved := domain.SavedStarGiftRef{Owner: unique.Owner, Slug: strings.ToLower(strings.TrimSpace(unique.Slug))}
return resolved, resolved.Valid(), nil
default:
return domain.SavedStarGiftRef{}, false, nil
}
@ -718,11 +845,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif
if gift.UniqueGiftID != 0 {
continue
}
if gift.Owner.Type != domain.PeerTypeUser {
// Channel upgrade RPCs are deliberately blocked until the channel pts
// aggregate exists, so do not advertise a dead-end action.
continue
}
if _, ok := seen[gift.GiftID]; ok {
continue
}
@ -755,10 +877,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass {
// tgStarGift 把目录项投影为 tg.StarGiftSticker 须为带 sticker 属性的有效 Document
func tgStarGift(g domain.StarGift) *tg.StarGift {
gift := &tg.StarGift{
ID: g.ID,
Sticker: tgDocument(g.Sticker),
Stars: g.Stars,
ConvertStars: g.ConvertStars,
Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday,
RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser,
PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction,
ID: g.ID, Sticker: tgDocument(g.Sticker), Stars: g.Stars, ConvertStars: g.ConvertStars,
}
if g.Limited {
gift.SetAvailabilityRemains(g.AvailabilityRemains)
gift.SetAvailabilityTotal(g.AvailabilityTotal)
}
if g.AvailabilityResale > 0 {
gift.SetAvailabilityResale(g.AvailabilityResale)
}
// sold_out, first_sale_date and last_sale_date share TL flags.1. The store
// retains sale timestamps for live gifts as operational facts, but exposing
// either timestamp would make every client decode the gift as sold out.
if g.SoldOut {
gift.SetFirstSaleDate(g.FirstSaleDate)
gift.SetLastSaleDate(g.LastSaleDate)
}
if g.Title != "" {
gift.SetTitle(g.Title)
@ -766,6 +902,31 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
gift.SetUpgradeStars(g.UpgradeStars)
}
if g.ResellMinStars > 0 {
gift.SetResellMinStars(g.ResellMinStars)
}
if releasedBy := tgPeer(g.ReleasedBy); releasedBy != nil {
gift.SetReleasedBy(releasedBy)
}
if g.LimitedPerUser {
gift.SetPerUserTotal(g.PerUserTotal)
gift.SetPerUserRemains(g.PerUserRemains)
}
if g.LockedUntilDate > 0 {
gift.SetLockedUntilDate(g.LockedUntilDate)
}
if g.Auction {
gift.SetAuctionSlug(g.AuctionSlug)
gift.SetGiftsPerRound(g.GiftsPerRound)
gift.SetAuctionStartDate(g.AuctionStartDate)
}
if g.UpgradeVariants > 0 {
gift.SetUpgradeVariants(g.UpgradeVariants)
}
if g.Background != nil {
gift.SetBackground(tg.StarGiftBackground{CenterColor: g.Background.CenterColor,
EdgeColor: g.Background.EdgeColor, TextColor: g.Background.TextColor})
}
return gift
}
@ -787,6 +948,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
if in.Title != "" {
gift.SetTitle(in.Title)
}
if in.UpgradePriceStars > 0 {
gift.SetUpgradeStars(in.UpgradePriceStars)
}
action := &tg.MessageActionStarGift{Gift: gift}
if in.NameHidden {
action.NameHidden = true
@ -799,12 +963,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
}
action.CanUpgrade = in.CanUpgrade
action.PrepaidUpgrade = in.PrepaidUpgrade
action.UpgradeSeparate = in.UpgradeSeparate
action.AuctionAcquired = in.AuctionAcquired
if in.UpgradeStars > 0 {
action.SetUpgradeStars(in.UpgradeStars)
}
if in.UpgradeMsgID > 0 {
action.SetUpgradeMsgID(in.UpgradeMsgID)
}
if in.PrepaidUpgradeHash != "" {
action.SetPrepaidUpgradeHash(in.PrepaidUpgradeHash)
}
if in.GiftMsgID > 0 {
action.SetGiftMsgID(in.GiftMsgID)
}
if in.GiftNum > 0 {
action.SetGiftNum(in.GiftNum)
}
if to := tgPeer(in.To); to != nil {
action.SetToID(to)
}
if in.ConvertStars > 0 {
action.SetConvertStars(in.ConvertStars)
}
@ -889,6 +1067,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true
}
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
}
}
if g.PinnedOrder > 0 {
item.PinnedToTop = true
@ -898,6 +1079,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
}
if g.Unique != nil {
item.SetGiftNum(g.Unique.Num)
} else if g.GiftNum > 0 {
item.SetGiftNum(g.GiftNum)
}
out = append(out, item)
}
@ -944,24 +1127,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids
}
func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 {
return starGiftFormIDWithUpgrade(userID, peer, gift, false)
}
func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 {
id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654
if includeUpgrade {
id ^= gift.UpgradeStars<<29 ^ 0x55504752414445
}
for _, ch := range string(peer.Type) {
id = id*131 + int64(ch)
}
if id == 0 {
id = 0x5347
}
return id
}
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency {

View file

@ -21,6 +21,10 @@ import (
)
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
return starGiftTestRouterWithPremium(t, false)
}
func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, domain.User, domain.User, domain.StarGift) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
t.Fatalf("create recipient: %v", err)
}
gift := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", RequirePremium: requirePremium,
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
}
giftStore := memory.NewStarGiftStore()
@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
return r, sender, recipient, gift
}
func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouterWithPremium(t, true)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") {
t.Fatalf("non-premium gift form err = %v, want PREMIUM_ACCOUNT_REQUIRED", err)
}
premium, ok := r.deps.Users.(UserPremiumService)
if !ok {
t.Fatalf("users service %T does not implement premium grants", r.deps.Users)
}
if _, err := premium.GrantPremium(context.Background(), sender.ID, 1); err != nil {
t.Fatalf("grant premium: %v", err)
}
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("premium gift form: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok {
t.Fatalf("premium gift form = %T", formRes)
}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
t.Fatalf("premium gift purchase: %v", err)
}
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
return s.unique, slug == s.unique.Slug, nil
}
type craftStarGiftRPCService struct {
GiftsService
uniques map[string]domain.UniqueStarGift
saved map[int64]domain.SavedStarGift
result domain.StarGiftCraftResult
craftReq domain.StarGiftCraftRequest
craftCall int
}
func (s *craftStarGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
unique, ok := s.uniques[slug]
return unique, ok, nil
}
func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
for _, saved := range s.saved {
if saved.Owner != ref.Owner {
continue
}
if ref.Slug != "" {
unique, ok := s.uniques[ref.Slug]
if ok && unique.ID == saved.UniqueGiftID {
return saved, true, nil
}
continue
}
if saved.MsgID == ref.MsgID {
return saved, true, nil
}
}
return domain.SavedStarGift{}, false, nil
}
func (s *craftStarGiftRPCService) Craft(_ context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
s.craftCall++
s.craftReq = req
return s.result, nil
}
func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 7102}
service := &craftStarGiftRPCService{
uniques: map[string]domain.UniqueStarGift{
"official-8001-2": {ID: 902, Slug: "official-8001-2", Owner: owner, SourceSavedGiftID: 52},
},
saved: map[int64]domain.SavedStarGift{
50: {ID: 50, Owner: owner, MsgID: 115, UniqueGiftID: 901, UpgradeMsgID: 116},
52: {ID: 52, Owner: owner, MsgID: 111, UniqueGiftID: 902, UpgradeMsgID: 112},
},
result: domain.StarGiftCraftResult{Chance: 500, SourceEdits: []domain.EditedMessageForUser{{
UserID: owner.ID,
Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100},
Event: domain.UpdateEvent{UserID: owner.ID, Type: domain.UpdateEventEditMessage, Pts: 41, PtsCount: 1,
Date: 100, Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}},
}}},
}
r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), owner.ID)
updates, err := r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 115},
&tg.InputSavedStarGiftSlug{Slug: "OFFICIAL-8001-2"},
}})
if err != nil || updates == nil {
t.Fatalf("craft mixed official refs: updates=%T err=%v", updates, err)
}
if service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50,52" || len(service.craftReq.Refs) != 2 ||
service.craftReq.Refs[1].Slug != "official-8001-2" {
t.Fatalf("craft request = %+v calls=%d", service.craftReq, service.craftCall)
}
full, ok := updates.(*tg.Updates)
if !ok || len(full.Updates) != 2 {
t.Fatalf("craft failure updates = %T %#v", updates, updates)
}
if edit, ok := full.Updates[0].(*tg.UpdateEditMessage); !ok || edit.Pts != 41 || edit.PtsCount != 1 {
t.Fatalf("craft failure source update = %T %#v", full.Updates[0], full.Updates[0])
}
if _, ok := full.Updates[1].(*tg.UpdateStarGiftCraftFail); !ok {
t.Fatalf("craft terminal update = %T %#v", full.Updates[1], full.Updates[1])
}
service.craftCall = 0
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 111},
&tg.InputSavedStarGiftSlug{Slug: "official-8001-2"},
}})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
}
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 116},
}})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall)
}
}
type upgradeReplayRPCService struct {
GiftsService
saved domain.SavedStarGift
receipt domain.StarGiftUpgradeReceipt
result domain.StarGiftUpgradeResult
upgradeCalls int
previewCalls int
lastRequest domain.StarGiftUpgradeRequest
}
func (s *upgradeReplayRPCService) GetSaved(_ context.Context, _ domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.saved, true, nil
}
func (s *upgradeReplayRPCService) UpgradeReceipt(_ context.Context, userID int64, _ string) (domain.StarGiftUpgradeReceipt, bool, error) {
if userID != s.receipt.UserID {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
return s.receipt, true, nil
}
func (s *upgradeReplayRPCService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
s.previewCalls++
return domain.StarGiftUpgradePreview{}, false, nil
}
func (s *upgradeReplayRPCService) Upgrade(_ context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
s.upgradeCalls++
s.lastRequest = req
return s.result, nil
}
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
attribute := domain.StarGiftCollectibleAttribute{
Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}
if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233
@ -76,6 +238,10 @@ func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id in
MimeType: "application/x-tgsticker", Size: 3, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}, {Kind: domain.DocAttrFilename, FileName: "gift.tgs"}},
}
if kind == domain.StarGiftCollectiblePattern {
attribute.Document.Attributes[0] = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, TextColor: true}
attribute.Document.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}}
}
attribute.Animation = &domain.StarGiftAnimation{
SourceName: "gift.tgs", SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: make([]byte, 32), Width: 512, Height: 512,
@ -148,6 +314,120 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
}
}
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("ordinary action = %T", ordinary)
}
ordinaryGift, ok := ordinary.Gift.(*tg.StarGift)
if !ok {
t.Fatalf("ordinary inner gift = %T", ordinary.Gift)
}
if price, set := ordinaryGift.GetUpgradeStars(); !set || price != 75 {
t.Fatalf("ordinary inner upgrade_stars = %d set=%v, want paid price 75", price, set)
}
if amount, set := ordinary.GetUpgradeStars(); set || amount != 0 || ordinary.PrepaidUpgrade {
t.Fatalf("ordinary outer upgrade_stars = %d set=%v prepaid=%v, want absent", amount, set, ordinary.PrepaidUpgrade)
}
prepaid, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, PrepaidUpgrade: true,
UpgradePriceStars: 75, UpgradeStars: 75,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("prepaid action = %T", prepaid)
}
if amount, set := prepaid.GetUpgradeStars(); !set || amount != 75 || !prepaid.PrepaidUpgrade {
t.Fatalf("prepaid outer upgrade_stars = %d set=%v prepaid=%v, want 75", amount, set, prepaid.PrepaidUpgrade)
}
upgraded, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, UpgradeMsgID: 88,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("upgraded action = %T", upgraded)
}
if msgID, set := upgraded.GetUpgradeMsgID(); !set || msgID != 88 {
t.Fatalf("upgrade_msg_id = %d set=%v, want 88", msgID, set)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, ordinary, wire); err != nil {
t.Fatalf("encode Layer %d ordinary action: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d ordinary action: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("decode Layer %d action = %T", profile, decodedObject)
}
inner, ok := decoded.Gift.(*tg.StarGift)
if !ok || inner.UpgradeStars != 75 || decoded.UpgradeStars != 0 || decoded.PrepaidUpgrade {
t.Fatalf("Layer %d ordinary action lost paid/prepaid split: %#v", profile, decoded)
}
upgradedWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, upgraded, upgradedWire); err != nil {
t.Fatalf("encode Layer %d upgraded action: %v", profile, err)
}
decodedUpgradedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: upgradedWire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d upgraded action: %v", profile, err)
}
decodedUpgraded, ok := decodedUpgradedObject.(*tg.MessageActionStarGift)
if !ok || !decodedUpgraded.Upgraded || decodedUpgraded.UpgradeMsgID != 88 || decodedUpgraded.CanUpgrade {
t.Fatalf("Layer %d upgraded action lost transition flags: %#v", profile, decodedUpgradedObject)
}
}
}
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
saved := domain.SavedStarGift{
ID: 47, Owner: ownerPeer, FromUserID: sender.ID, GiftID: gift.ID, RevisionID: gift.RevisionID,
MsgID: 105, UniqueGiftID: 9200000000000004,
}
result := domain.StarGiftUpgradeResult{
Saved: saved, Unique: domain.UniqueStarGift{ID: saved.UniqueGiftID, GiftID: gift.ID, Owner: ownerPeer},
Balance: domain.StarsBalance{UserID: owner.ID, Balance: 1000}, Duplicate: true,
Send: domain.SendPrivateTextResult{
RecipientMessage: domain.Message{ID: 107, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Date: 1700000001},
RecipientEvent: domain.UpdateEvent{UserID: owner.ID, Pts: 42, PtsCount: 1, Date: 1700000001},
},
}
service := &upgradeReplayRPCService{GiftsService: r.deps.Gifts, saved: saved, result: result,
receipt: domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
UniqueGiftID: saved.UniqueGiftID, RequirePrepaid: true, KeepOriginalDetails: true, BalanceAfter: 1000}}
r.deps.Gifts = service
ctx := WithUserID(context.Background(), owner.ID)
if _, err := r.onPaymentsUpgradeStarGift(ctx, &tg.PaymentsUpgradeStarGiftRequest{
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
}); err != nil {
t.Fatalf("replay prepaid upgrade after terminal transition: %v", err)
}
if service.upgradeCalls != 1 || service.previewCalls != 0 || !service.lastRequest.RequirePrepaid || service.lastRequest.ChargeStars != 0 {
t.Fatalf("prepaid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
}
const paidFormID int64 = -7611777087885039132
service.receipt = domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
FormID: paidFormID, UniqueGiftID: saved.UniqueGiftID, ChargeStars: 25,
KeepOriginalDetails: true, BalanceAfter: 975}
service.upgradeCalls, service.previewCalls = 0, 0
if _, err := r.sendStarGiftUpgradeForm(ctx, owner.ID, paidFormID, &tg.InputInvoiceStarGiftUpgrade{
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
}); err != nil {
t.Fatalf("replay paid upgrade after terminal transition: %v", err)
}
if service.upgradeCalls != 1 || service.previewCalls != 0 || service.lastRequest.ChargeStars != 25 || service.lastRequest.FormID != paidFormID {
t.Fatalf("paid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
}
}
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
@ -157,11 +437,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
t.Fatalf("gift service = %T", r.deps.Gifts)
}
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora")
crafted.Crafted = true
crafted.RarityKind = domain.StarGiftRarityLegendary
crafted.RarityPermille = 0
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
@ -177,6 +461,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err)
}
attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID)
if err != nil || len(attributes.Attributes) != 4 {
t.Fatalf("upgrade attributes = %#v err %v", attributes, err)
}
craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel)
if !ok || !craftedTG.Crafted {
t.Fatalf("crafted attribute = %T %#v", attributes.Attributes[1], attributes.Attributes[1])
}
if _, ok := craftedTG.Rarity.(*tg.StarGiftAttributeRarityLegendary); !ok {
t.Fatalf("crafted rarity = %T", craftedTG.Rarity)
}
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
@ -208,7 +503,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true,
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, SavedID: 444, Upgrade: true, Saved: true,
},
}}}
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
@ -224,6 +519,9 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer)
}
if savedID, ok := action.GetSavedID(); !ok || savedID != 444 {
t.Fatalf("unique service action saved_id = %d set=%v, want 444", savedID, ok)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
responseWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil {
@ -254,7 +552,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if !ok {
t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject)
}
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedAction.SavedID != 444 || decodedActionGift.Slug != unique.Slug {
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
}
}
@ -556,10 +854,15 @@ func TestStarGiftChannelSaga(t *testing.T) {
}); err != nil {
t.Fatalf("publish channel collectible pool: %v", err)
}
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
upgradeFormRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true,
}}); err == nil {
t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked")
}})
if err != nil {
t.Fatalf("getPaymentForm(channel include_upgrade): %v", err)
}
upgradeForm, ok := upgradeFormRes.(*tg.PaymentsPaymentFormStarGift)
if !ok || len(upgradeForm.Invoice.Prices) != 1 || upgradeForm.Invoice.Prices[0].Amount != gift.Stars+75 {
t.Fatalf("channel include_upgrade form = %T %+v, want total %d", upgradeFormRes, upgradeFormRes, gift.Stars+75)
}
inv := &tg.InputInvoiceStarGift{
Peer: channelPeer,
@ -612,8 +915,8 @@ func TestStarGiftChannelSaga(t *testing.T) {
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
}
if savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked")
if !savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
@ -728,8 +1031,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}, zaptest.NewLogger(t), clock.System)
senderCtx := WithUserID(ctx, sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil {
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("get expensive gift form: %v", err)
}
form := formRes.(*tg.PaymentsPaymentFormStarGift)
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "BALANCE_TOO_LOW") {
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
}
// 余额未变。
@ -738,22 +1045,60 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}
}
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
func TestStarGiftPurchaseFormsAreFreshAndBindPurpose(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
base := starGiftFormID(sender.ID, peer, gift)
changedRevision := gift
changedRevision.RevisionID++
changedPrice := gift
changedPrice.Stars++
changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1}
if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) {
t.Fatal("star gift form id must bind revision, price and recipient")
}
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("bad form err=%v", err)
firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("first form: %v", err)
}
secondRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("second form: %v", err)
}
first := firstRes.(*tg.PaymentsPaymentFormStarGift)
second := secondRes.(*tg.PaymentsPaymentFormStarGift)
if first.FormID == 0 || second.FormID == 0 || first.FormID == second.FormID {
t.Fatalf("fresh form ids = %d/%d, want distinct non-zero TL longs", first.FormID, second.FormID)
}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID + second.FormID, Invoice: inv}); !tgerr.Is(err, "FORM_EXPIRED") {
t.Fatalf("unknown form err=%v, want FORM_EXPIRED", err)
}
tampered := *inv
tampered.HideName = true
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID, Invoice: &tampered}); !tgerr.Is(err, "PURPOSE_INVALID") {
t.Fatalf("tampered form err=%v, want PURPOSE_INVALID", err)
}
}
func TestStarGiftCanPurchaseSameCatalogGiftTwice(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
var formIDs []int64
for i := 0; i < 2; i++ {
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("get form %d: %v", i, err)
}
form := formRes.(*tg.PaymentsPaymentFormStarGift)
formIDs = append(formIDs, form.FormID)
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
t.Fatalf("purchase %d: %v", i, err)
}
}
if formIDs[0] == formIDs[1] {
t.Fatalf("repeated purchase reused form id %d", formIDs[0])
}
saved, err := r.onPaymentsGetSavedStarGifts(WithUserID(context.Background(), recipient.ID), &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{}, Limit: 10,
})
if err != nil {
t.Fatalf("get recipient gifts: %v", err)
}
if saved.Count != 2 || len(saved.Gifts) != 2 {
t.Fatalf("recipient gifts = count %d len %d, want two independent gifts", saved.Count, len(saved.Gifts))
}
}

View file

@ -85,6 +85,22 @@ func TestOnPaymentsGetStarsTransactions(t *testing.T) {
}
}
func TestTGStarsTransactionsPaidMessage(t *testing.T) {
out := tgStarsTransactions([]domain.StarsTransaction{{
ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50},
Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message",
}})
if len(out) != 1 {
t.Fatalf("paid-message transactions = %d, want 1", len(out))
}
if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 {
t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok)
}
if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 {
t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount)
}
}
// deps.Stars==nil 兜底:返回合法的空 starsStatus余额 0不崩。
func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
@ -98,3 +114,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
}
_ = domain.DefaultStarsStartingGrant
}
type channelLedgerGifts struct {
GiftsService
starsBalance int64
tonBalance int64
starsPage domain.StarsTransactionPage
tonPage domain.TonTransactionPage
}
func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) {
return s.starsBalance, nil
}
func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, string, int) (domain.StarsTransactionPage, error) {
return s.starsPage, nil
}
func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) {
return s.tonBalance, nil
}
func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, string, int) (domain.TonTransactionPage, error) {
return s.tonPage, nil
}
type channelLedgerChannels struct {
ChannelsService
view domain.ChannelView
}
func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
return s.view, nil
}
func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) {
return []domain.ChannelView{s.view}, nil
}
func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive},
}
gifts := &channelLedgerGifts{
starsBalance: 20,
tonBalance: 900,
starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{
ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift,
}}},
tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{
ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale,
}}},
}
r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars status: %v", err)
}
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 {
t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats))
}
revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars revenue: %v", err)
}
if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 {
t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance)
}
if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled {
t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled)
}
txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20}
txnReq.SetTon(true)
transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq)
if err != nil {
t.Fatalf("get channel ton transactions: %v", err)
}
history, ok := transactions.GetHistory()
if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale {
t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history)
}
revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}
revenueReq.SetTon(true)
tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq)
if err != nil {
t.Fatalf("get channel ton revenue: %v", err)
}
if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 {
t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance)
}
}
func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
}
r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
_, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}})
if err == nil {
t.Fatal("non-admin channel ledger read unexpectedly succeeded")
}
}

View file

@ -93,7 +93,7 @@ type Config struct {
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handlertlprofile.Dispatcher
//
// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler
// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler
// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳:
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
type Router struct {
@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
if instanceID == "" {
instanceID = fmt.Sprintf("%016x", randomNonZeroInt64())
}
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
@ -260,6 +260,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerMessages(d)
r.registerStickers(d)
r.registerChannels(d)
r.registerCommunities(d)
r.registerUpload(d)
r.registerPhotos(d)
r.registerFolders(d)
@ -274,6 +275,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerPremium(d)
r.registerAiCompose(d)
r.registerBots(d)
r.registerEphemeral(d)
r.dispatcher = d
return r

View file

@ -1431,7 +1431,7 @@ func TestMessagesSearchGlobalExactLayerProfiles(t *testing.T) {
}
}
func TestMessagesSearchGlobalCommunityProjectionFailsClosedForLayer227(t *testing.T) {
func TestMessagesSearchGlobalCommunityFieldIsAbsentFromLayer227Wire(t *testing.T) {
request := &tg.MessagesSearchGlobalRequest{
Q: "scoped",
Filter: &tg.InputMessagesFilterEmpty{},
@ -1453,11 +1453,25 @@ func TestMessagesSearchGlobalCommunityProjectionFailsClosedForLayer227(t *testin
}
var body227 bin.Buffer
if err := tlprofile.EncodeObject(tlprofile.Profile227, request, &body227); err == nil {
t.Fatal("Layer 227 projection accepted a Layer 228-only community scope")
if err := tlprofile.EncodeObject(tlprofile.Profile227, request, &body227); err != nil {
t.Fatalf("encode Layer 227 searchGlobal: %v", err)
}
decoded, err := tlprofile.DecodeObject(tlprofile.Profile227, &bin.Buffer{Buf: body227.Copy()}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer 227 searchGlobal: %v", err)
}
legacy, ok := decoded.(*tg.MessagesSearchGlobalRequest)
if !ok {
t.Fatalf("decoded Layer 227 request = %T", decoded)
}
if _, ok := legacy.GetCommunity(); ok {
t.Fatal("Layer 227 wire retained the Layer 228-only community scope")
}
if _, err := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System).AdmitLayer(tlprofile.Profile227, &body227, tlprofile.Limits{}); err != nil {
t.Fatalf("admit Layer 227 searchGlobal: %v", err)
}
if body227.Len() != 0 {
t.Fatalf("failed Layer 227 projection emitted %d partial bytes", body227.Len())
t.Fatalf("Layer 227 community-free admission left %d bytes", body227.Len())
}
}

View file

@ -37,7 +37,7 @@ type outgoingSend struct {
sendAs *domain.Peer
sendAsReady bool
clearDraft bool
// replyMarkup 是 bot inline keyboard已解析+校验;非 bot 恒 nil
// replyMarkup 是 bot reply/inline keyboard已解析+校验;非 bot 恒 nil
replyMarkup *domain.MessageReplyMarkup
viaBotID int64
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil
@ -274,6 +274,89 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
var mono domain.Channel
var monoforum, monoforumAdmin bool
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID)
switch {
case err == nil:
monoforum = true
case !errors.Is(err, domain.ErrChannelInvalid):
return nil, internalErr()
}
}
if hasSuggestedPost && !monoforum {
return nil, suggestedPostPeerInvalidErr()
}
if monoforum {
if req.AllowPaidStars < 0 {
return nil, starsAmountInvalidErr()
}
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return nil, scheduleDateInvalidErr()
}
suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost)
if err != nil {
return nil, err
}
savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo)
if err != nil {
return nil, err
}
replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo)
if err != nil {
return nil, err
}
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint)
if err != nil {
return nil, err
}
if replay.found {
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil
}
if r.messageEffectInvalid(ctx, req.Effect) {
return nil, effectIDInvalidErr()
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
return nil, err
}
checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
media, err := r.resolveInputMedia(ctx, userID, req.Media)
if err != nil {
return nil, err
}
if media == nil {
return nil, mediaInvalidErr()
}
return r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{
SavedPeer: savedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: idempotencyFingerprint,
IdempotencyPreflighted: replay.checked,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
Media: media,
ReplyTo: replyTo,
Silent: req.Silent,
NoForwards: req.Noforwards,
SuggestedPost: suggestedPost,
AllowPaidStars: req.AllowPaidStars,
ClearDraft: req.ClearDraft,
})
}
if req.AllowPaidStars > 0 || req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
return nil, err
@ -299,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if media == nil {
return nil, mediaInvalidErr()
}
// reply_markupbot inline keyboard on media仅 bot 接受+校验,非 bot 静默丢弃。
// reply_markupbot 可发送 inline keyboard 与普通 reply keyboard/hide/force
// 非 bot 静默丢弃。
var replyMarkup *domain.MessageReplyMarkup
if req.ReplyMarkup != nil {
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
if err != nil {
return nil, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
return nil, err
}
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{

View file

@ -1067,21 +1067,36 @@ func (r *Router) onStoriesGetPeerMaxIDs(ctx context.Context, id []tg.InputPeerCl
return nil, internalErr()
}
peers := make([]domain.Peer, 0, len(id))
for _, input := range id {
positions := make([]int, 0, len(id))
result := make([]tg.RecentStory, len(id))
for i, input := range id {
if _, community, err := r.maybeCommunityFromInputPeer(ctx, userID, input); err != nil {
return nil, err
} else if community {
// Communities are projected as channel peers in dialog lists, but do
// not own stories. Keep the batch positional by returning an empty
// recentStory at this index and resolve every ordinary peer normally.
continue
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return nil, err
}
peers = append(peers, peer)
positions = append(positions, i)
}
if r.deps.Stories == nil || userID == 0 {
return make([]tg.RecentStory, len(id)), nil
return result, nil
}
recent, err := r.deps.Stories.GetPeerMaxIDs(ctx, userID, peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, storyErr(err)
}
return tgRecentStories(alignStoryRecentByPeer(peers, recent)), nil
aligned := tgRecentStories(alignStoryRecentByPeer(peers, recent))
for i, position := range positions {
result[position] = aligned[i]
}
return result, nil
}
func alignStoryRecentByPeer(peers []domain.Peer, recent []domain.RecentStory) []domain.RecentStory {

View file

@ -45,6 +45,15 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
if message := out[i].EphemeralMessage; message != nil {
collectEphemeralMessagePeerRefs(*message, userIDs, channelIDs)
if message.BotAPIReply != nil {
collectEphemeralMessagePeerRefs(*message.BotAPIReply, userIDs, channelIDs)
}
}
if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
}
removeKnownChannelRefs(channelIDs, out[i].Channels)
refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs}
for id := range userIDs {
@ -63,6 +72,24 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
return out
}
func collectEphemeralMessagePeerRefs(message domain.EphemeralMessage, userIDs, channelIDs map[int64]struct{}) {
if message.SenderUserID != 0 {
userIDs[message.SenderUserID] = struct{}{}
}
if message.ReceiverUserID != 0 {
userIDs[message.ReceiverUserID] = struct{}{}
}
addDomainPeerRef(message.Peer, 0, userIDs, channelIDs)
for _, entity := range message.Content.Entities {
if entity.UserID != 0 {
userIDs[entity.UserID] = struct{}{}
}
}
if message.Content.Media != nil && message.Content.Media.Contact != nil && message.Content.Media.Contact.UserID != 0 {
userIDs[message.Content.Media.Contact.UserID] = struct{}{}
}
}
type updateEventPeerRefs struct {
userIDs map[int64]struct{}
channelIDs map[int64]struct{}
@ -220,6 +247,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
userIDs[msg.Media.Contact.UserID] = struct{}{}
}
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
}
}
collectPollMediaUserRefs(msg.Media, userIDs)
collectTodoMediaUserRefs(msg.Media, userIDs)
if msg.Reactions != nil {

View file

@ -215,7 +215,7 @@ func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) {
if update.Popup || update.InboxDate == 0 || update.Media == nil {
t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media)
}
for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} {
for _, want := range []string{"New login.", "Test User", "Telesrv Desktop", "Settings > Devices"} {
if !strings.Contains(update.Message, want) {
t.Fatalf("notification message %q missing %q", update.Message, want)
}

View file

@ -629,7 +629,11 @@ func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) t
if len(profile.Commands) > 0 {
cmds := make([]tg.BotCommand, 0, len(profile.Commands))
for _, c := range profile.Commands {
cmds = append(cmds, tg.BotCommand{Command: c.Command, Description: c.Description})
cmds = append(cmds, tg.BotCommand{
Command: c.Command,
Description: c.Description,
Ephemeral: c.Ephemeral,
})
}
info.SetCommands(cmds)
}

View file

@ -0,0 +1,25 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
func TestTGBotInfoPreservesEphemeralCommandMarker(t *testing.T) {
got := tgBotInfoFromProfile(42, domain.BotProfile{
Commands: []domain.BotCommand{
{Command: "public", Description: "visible everywhere"},
{Command: "private", Description: "Layer 228 only", Ephemeral: true},
},
}, true)
if len(got.Commands) != 2 {
t.Fatalf("commands = %+v, want two", got.Commands)
}
if got.Commands[0].Ephemeral {
t.Fatalf("public command = %+v, want ephemeral=false", got.Commands[0])
}
if !got.Commands[1].Ephemeral {
t.Fatalf("private command = %+v, want ephemeral=true", got.Commands[1])
}
}