Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/branding"
|
||||
ioscompat "telesrv/internal/compat/ios"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -15,6 +16,15 @@ import (
|
|||
|
||||
// registerAccount 注册 account.* RPC handler。
|
||||
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
||||
return r.onAccountDeleteAccount(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
|
||||
return r.onAccountSendConfirmPhoneCode(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
|
||||
return r.onAccountConfirmPhone(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
|
@ -110,11 +120,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
Hash)
|
||||
})
|
||||
registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
Hash
|
||||
_ = hash
|
||||
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
return r.onAccountGetCollectibleEmojiStatuses(ctx, layerRequest.Hash)
|
||||
})
|
||||
registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
|
|
@ -902,7 +908,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
|
|||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if ttl.Days <= 0 {
|
||||
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
|
||||
return false, tgerr400("TTL_DAYS_INVALID")
|
||||
}
|
||||
if svc, ok := r.accountSettingsSvc(); ok {
|
||||
|
|
@ -1602,10 +1608,10 @@ func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg.
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// onAccountUpdateEmojiStatus 持久化用户自定义 emoji status(premium 专属)。
|
||||
// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖
|
||||
// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给
|
||||
// 本人全部在线 session(self user 对象同时携带最新 emoji_status 字段)。
|
||||
// onAccountUpdateEmojiStatus persists either a normal custom emoji or a
|
||||
// complete collectible snapshot. Collectibles must still be locally owned by
|
||||
// the actor; unsupported constructors are rejected instead of being mistaken
|
||||
// for a clear operation.
|
||||
func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -1615,33 +1621,105 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
|
|||
if !ok {
|
||||
return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义
|
||||
}
|
||||
var documentID int64
|
||||
var until int
|
||||
if s, ok := status.(*tg.EmojiStatus); ok {
|
||||
documentID = s.DocumentID
|
||||
if v, ok := s.GetUntil(); ok {
|
||||
until = v
|
||||
}
|
||||
value, err := r.domainUserEmojiStatus(ctx, userID, status)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var (
|
||||
u domain.User
|
||||
event domain.UpdateEvent
|
||||
durableWrite bool
|
||||
)
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
if durable, ok := r.deps.Users.(UserEmojiStatusDurableService); ok {
|
||||
u, event, durableWrite, err = durable.UpdateEmojiStatusWithEvent(
|
||||
ctx, userID, value, int(r.clock.Now().Unix()), rawAuthKeyIDForOrigin(ctx), sessionID,
|
||||
)
|
||||
} else {
|
||||
u, err = svc.UpdateEmojiStatus(ctx, userID, value)
|
||||
}
|
||||
u, err := svc.UpdateEmojiStatus(ctx, userID, documentID, until)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrPremiumRequired) {
|
||||
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
return false, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return false, internalErr()
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(u.ID)
|
||||
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{
|
||||
UserID: u.ID,
|
||||
EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()),
|
||||
}},
|
||||
Users: []tg.UserClass{r.tgSelfUser(u)},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)}
|
||||
if durableWrite {
|
||||
if sessionID != 0 {
|
||||
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||
}
|
||||
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
|
||||
})
|
||||
} else if updates, ok := r.deps.Updates.(UserEmojiStatusUpdatesService); ok {
|
||||
event, _, recordErr := updates.RecordUserEmojiStatus(ctx, authKeyID, userID, value, rawAuthKeyIDForOrigin(ctx), sessionID)
|
||||
if recordErr != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if sessionID != 0 {
|
||||
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||
}
|
||||
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
|
||||
})
|
||||
} else {
|
||||
// Lightweight test deployments without the durable extension retain the
|
||||
// previous online-only behavior; production wiring implements it.
|
||||
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input tg.EmojiStatusClass) (domain.UserEmojiStatus, error) {
|
||||
switch status := input.(type) {
|
||||
case *tg.EmojiStatusEmpty:
|
||||
return domain.UserEmojiStatus{}, nil
|
||||
case *tg.EmojiStatus:
|
||||
value := domain.UserEmojiStatus{DocumentID: status.DocumentID}
|
||||
if until, ok := status.GetUntil(); ok {
|
||||
value.Until = until
|
||||
}
|
||||
if !value.Valid() {
|
||||
return domain.UserEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
case *tg.InputEmojiStatusCollectible:
|
||||
if r.deps.Gifts == nil || status.CollectibleID <= 0 {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID)
|
||||
if err != nil {
|
||||
return domain.UserEmojiStatus{}, internalErr()
|
||||
}
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
collectible, valid := domain.CollectibleEmojiStatus(gift)
|
||||
if !valid {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
if until, ok := status.GetUntil(); ok {
|
||||
value.Until = until
|
||||
}
|
||||
if !value.Valid() {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return domain.UserEmojiStatus{}, inputConstructorInvalidErr()
|
||||
}
|
||||
}
|
||||
|
||||
// onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。
|
||||
// 普通 peerColor 可清除(color flag absent)、可显式设置 color=0;collectible
|
||||
// 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。
|
||||
|
|
@ -1737,6 +1815,41 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6
|
|||
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
|
||||
}
|
||||
|
||||
// onAccountGetCollectibleEmojiStatuses returns the actor's active locally
|
||||
// owned unique gifts as complete emojiStatusCollectible values. The bounded
|
||||
// list order and hash are stable, so Android can safely reuse its cache.
|
||||
func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
}
|
||||
gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
ids := make([]int64, 0, len(gifts))
|
||||
statuses := make([]tg.EmojiStatusClass, 0, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(gift)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, collectible.CollectibleID)
|
||||
statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{
|
||||
DocumentID: collectible.DocumentID,
|
||||
Collectible: collectible,
|
||||
}))
|
||||
}
|
||||
catalogHash := mediaCatalogHash(ids)
|
||||
if hash != 0 && hash == catalogHash {
|
||||
return &tg.AccountEmojiStatusesNotModified{}, nil
|
||||
}
|
||||
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
|
||||
}
|
||||
|
||||
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
|
||||
if u.ID == 0 {
|
||||
return
|
||||
|
|
@ -1777,12 +1890,12 @@ func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int)
|
|||
Current: a.AuthKeyID == currentAuthKeyID,
|
||||
OfficialApp: true,
|
||||
Hash: a.Hash,
|
||||
DeviceModel: a.DeviceModel,
|
||||
Platform: a.Platform,
|
||||
SystemVersion: a.SystemVersion,
|
||||
DeviceModel: branding.UserVisibleText(a.DeviceModel, ""),
|
||||
Platform: branding.UserVisibleClientPlatform(a.Platform),
|
||||
SystemVersion: branding.UserVisibleText(a.SystemVersion, ""),
|
||||
APIID: a.APIID,
|
||||
AppName: "Telegram Desktop",
|
||||
AppVersion: a.AppVersion,
|
||||
AppName: branding.ClientAppName(a.Platform),
|
||||
AppVersion: branding.UserVisibleText(a.AppVersion, ""),
|
||||
DateCreated: created,
|
||||
DateActive: active,
|
||||
IP: a.IP,
|
||||
|
|
|
|||
134
internal/rpc/account_collectible_emoji_status_test.go
Normal file
134
internal/rpc/account_collectible_emoji_status_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type collectibleEmojiGiftService struct {
|
||||
GiftsService
|
||||
gifts map[int64]domain.UniqueStarGift
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) {
|
||||
gift, ok := s.gifts[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
out := make([]domain.UniqueStarGift, 0, len(s.gifts))
|
||||
for _, gift := range s.gifts {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift {
|
||||
return domain.UniqueStarGift{
|
||||
ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1",
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}},
|
||||
Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}},
|
||||
Backdrop: domain.StarGiftCollectibleAttribute{
|
||||
CenterColor: 0x102030, EdgeColor: 0x405060,
|
||||
PatternColor: 0x708090, TextColor: 0xa0b0c0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := appusers.NewService(userStore)
|
||||
if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil {
|
||||
t.Fatalf("grant premium: %v", err)
|
||||
}
|
||||
gift := collectibleEmojiTestGift(owner.ID)
|
||||
gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}}
|
||||
r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System)
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
|
||||
listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get collectible statuses: %v", err)
|
||||
}
|
||||
statuses, ok := listed.(*tg.AccountEmojiStatuses)
|
||||
if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 {
|
||||
t.Fatalf("collectible list = %T %#v", listed, listed)
|
||||
}
|
||||
collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible)
|
||||
if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID ||
|
||||
collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor {
|
||||
t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0])
|
||||
}
|
||||
if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil {
|
||||
t.Fatalf("get cached collectible statuses: %v", err)
|
||||
} else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok {
|
||||
t.Fatalf("cached collectible statuses = %T, want notModified", cached)
|
||||
}
|
||||
|
||||
input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}
|
||||
input.SetUntil(2_000_000_000)
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok {
|
||||
t.Fatalf("set collectible status: ok=%v err=%v", ok, err)
|
||||
}
|
||||
self, err := users.Self(ctx, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID ||
|
||||
self.EmojiStatusUntil != 2_000_000_000 {
|
||||
t.Fatalf("persisted collectible status = %+v", self.EmojiStatus())
|
||||
}
|
||||
wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible)
|
||||
if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor {
|
||||
t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire)
|
||||
}
|
||||
|
||||
stolen := gift
|
||||
stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
gifts.gifts[gift.ID] = stolen
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") {
|
||||
t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1))
|
||||
if !ok {
|
||||
t.Fatal("test gift should project")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{
|
||||
UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value,
|
||||
}).(*tg.UpdateUserEmojiStatus)
|
||||
if !ok {
|
||||
t.Fatal("durable event did not produce updateUserEmojiStatus")
|
||||
}
|
||||
if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID {
|
||||
t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus)
|
||||
}
|
||||
}
|
||||
152
internal/rpc/account_deletion.go
Normal file
152
internal/rpc/account_deletion.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
)
|
||||
|
||||
type accountDeletionService interface {
|
||||
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
|
||||
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
|
||||
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
|
||||
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
|
||||
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
|
||||
}
|
||||
|
||||
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
|
||||
svc, ok := r.deps.Account.(accountDeletionService)
|
||||
return svc, ok
|
||||
}
|
||||
|
||||
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
|
||||
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if userID == 0 || (!authorized && !passwordPending) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, ok := AuthKeyIDFrom(ctx)
|
||||
if !ok || authKeyID == ([8]byte{}) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
var password *domain.PasswordCheck
|
||||
if check, present := req.GetPassword(); present {
|
||||
converted := domainPasswordCheck(check)
|
||||
password = &converted
|
||||
}
|
||||
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
if outcome.Kind == domain.AccountDeleteDelayed {
|
||||
wait := outcome.WaitSeconds
|
||||
if wait < 1 {
|
||||
wait = 1
|
||||
}
|
||||
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
|
||||
r.invalidateRPCProjectionForUser(userID)
|
||||
dispatchNotifications := func() {
|
||||
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r.runAccountLifecycleOnce(dispatchCtx, 500)
|
||||
}
|
||||
if !postresponse.Register(ctx, dispatchNotifications) {
|
||||
go dispatchNotifications()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return nil, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return nil, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
|
||||
current, _ := AuthKeyIDFrom(ctx)
|
||||
for _, authorization := range revoked {
|
||||
a := authorization
|
||||
finish := func() {
|
||||
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
|
||||
r.revokeAuthKeySessions(a.AuthKeyID)
|
||||
}
|
||||
if a.AuthKeyID == current {
|
||||
if postresponse.Register(ctx, finish) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
func accountDeletionErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||
return passwordErr(err)
|
||||
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
|
||||
return tgerr.New(400, "HASH_INVALID")
|
||||
case errors.Is(err, domain.ErrPhoneCodeEmpty):
|
||||
return phoneCodeEmptyErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeInvalid):
|
||||
return phoneCodeInvalidErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||
return phoneCodeExpiredErr()
|
||||
case errors.Is(err, domain.ErrAccountDeletionForbidden):
|
||||
return botMethodInvalidErr()
|
||||
case errors.Is(err, domain.ErrAccountDeleted):
|
||||
return authKeyUnregisteredErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
177
internal/rpc/account_deletion_rpc_test.go
Normal file
177
internal/rpc/account_deletion_rpc_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appaccount "telesrv/internal/app/account"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
|
||||
current := [8]byte{1}
|
||||
other := [8]byte{2}
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{
|
||||
Kind: domain.AccountDeleteImmediate,
|
||||
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
|
||||
{AuthKeyID: current, UserID: 42},
|
||||
{AuthKeyID: other, UserID: 42},
|
||||
}},
|
||||
},
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete account ok=%v err=%v", ok, err)
|
||||
}
|
||||
if sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key closed before rpc_result delivery")
|
||||
}
|
||||
if !sessions.wasClosed(other) {
|
||||
t.Fatal("other auth key was not revoked immediately")
|
||||
}
|
||||
postresponse.Run(ctx)
|
||||
if !sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key not closed after rpc_result delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
|
||||
}
|
||||
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
|
||||
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
|
||||
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
|
||||
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
|
||||
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
|
||||
}
|
||||
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
|
||||
t.Fatal("confirm-phone methods must remain fully authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
|
||||
sessions := &offlineDeletionSessions{}
|
||||
svc := &deletionWorkerService{}
|
||||
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
|
||||
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
|
||||
})
|
||||
if len(svc.completed) != 1 || svc.completed[0] != 9 {
|
||||
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
|
||||
revoked := [8]byte{3}
|
||||
svc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
sweepResults: []domain.AccountDeletionResult{{
|
||||
Changed: true,
|
||||
User: domain.User{ID: 42, Deleted: true},
|
||||
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
|
||||
}},
|
||||
sweepErr: errors.New("later candidate failed"),
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.runAccountLifecycleOnce(context.Background(), 10)
|
||||
if !sessions.wasClosed(revoked) {
|
||||
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
|
||||
}
|
||||
}
|
||||
|
||||
type rpcDeletionAccountService struct {
|
||||
*appaccount.Service
|
||||
outcome domain.AccountDeleteOutcome
|
||||
err error
|
||||
sweepResults []domain.AccountDeletionResult
|
||||
sweepErr error
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
|
||||
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||
return "", domain.AuthCodeDelivery{}, false, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return s.sweepResults, s.sweepErr
|
||||
}
|
||||
|
||||
type deletionCaptureSessions struct {
|
||||
captureSessions
|
||||
closed [][8]byte
|
||||
}
|
||||
|
||||
type offlineDeletionSessions struct{ captureSessions }
|
||||
|
||||
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type deletionWorkerService struct{ completed []int64 }
|
||||
|
||||
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
|
||||
s.completed = append(s.completed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
|
||||
s.closed = append(s.closed, id)
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
|
||||
for _, closed := range s.closed {
|
||||
if closed == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
99
internal/rpc/account_lifecycle_worker.go
Normal file
99
internal/rpc/account_lifecycle_worker.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type accountLifecycleWorkerService interface {
|
||||
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
|
||||
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
||||
// RunAccountLifecycle executes all due account deletion sources through one
|
||||
// tombstone path and drains the durable non-pts updateUser queue. The queue is
|
||||
// a crash-safe, bounded online nudge: offline users are completed after the
|
||||
// first attempt because getDialogs/getHistory hydration independently returns
|
||||
// the authoritative tombstone. This avoids an immortal retry queue for a
|
||||
// non-pts update that cannot participate in getDifference.
|
||||
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 500
|
||||
}
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
|
||||
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := r.clock.Now().UTC()
|
||||
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
|
||||
cancel()
|
||||
for _, result := range results {
|
||||
if !result.Changed {
|
||||
continue
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(result.User.ID)
|
||||
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
|
||||
}
|
||||
if err != nil {
|
||||
// SweepDueAccountDeletions may return already-committed results before a
|
||||
// later candidate fails. Always finish those sessions/caches and drain
|
||||
// their durable notifications; the failed and remaining candidates are
|
||||
// retried from their authoritative due rows on the next tick.
|
||||
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
|
||||
}
|
||||
for {
|
||||
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||
claimCancel()
|
||||
if err != nil {
|
||||
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
r.dispatchAccountDeletionNotification(ctx, svc, notification)
|
||||
}
|
||||
if len(notifications) < batch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
|
||||
now := r.clock.Now().UTC()
|
||||
updates := &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
|
||||
Users: []tg.UserClass{tgUser(domain.User{
|
||||
ID: notification.DeletedUserID,
|
||||
Deleted: true,
|
||||
})},
|
||||
Date: int(now.Unix()),
|
||||
}
|
||||
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
|
||||
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
|
||||
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,12 @@ func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass)
|
|||
return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true
|
||||
case *tg.InputNotifyBroadcasts:
|
||||
return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true
|
||||
case *tg.InputNotifyCommunity:
|
||||
ref, ok := inputChannelRef(p.Community)
|
||||
if !ok {
|
||||
return domain.NotifyScope{}, false
|
||||
}
|
||||
return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: ref.ID}}, true
|
||||
case *tg.InputNotifyPeer:
|
||||
peer, ok := r.domainPeerFromInputPeer(userID, p.Peer)
|
||||
if !ok {
|
||||
|
|
@ -145,6 +151,7 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
|
|||
updates := make([]tg.UpdateClass, 0, len(exceptions))
|
||||
userIDs := make([]int64, 0)
|
||||
channelIDs := make([]int64, 0)
|
||||
communityIDs := make([]int64, 0)
|
||||
for _, ex := range exceptions {
|
||||
if filterPeer != nil && ex.Peer != *filterPeer {
|
||||
continue
|
||||
|
|
@ -163,15 +170,23 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
|
|||
userIDs = append(userIDs, ex.Peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, ex.Peer.ID)
|
||||
case domain.PeerTypeCommunity:
|
||||
communityIDs = append(communityIDs, ex.Peer.ID)
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
chats := r.tgChatsForChannelIDs(ctx, userID, channelIDs)
|
||||
if r.deps.Communities != nil && len(communityIDs) > 0 {
|
||||
if views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs); err == nil {
|
||||
chats = appendUniqueTGChats(chats, tgCommunityChats(views)...)
|
||||
}
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: r.tgUsersForIDs(ctx, userID, userIDs),
|
||||
Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs),
|
||||
Chats: chats,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -260,6 +275,9 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
|
|||
case domain.NotifyScopeBroadcasts:
|
||||
return &tg.NotifyBroadcasts{}
|
||||
case domain.NotifyScopePeer:
|
||||
if scope.Peer.Type == domain.PeerTypeCommunity {
|
||||
return &tg.NotifyCommunity{CommunityID: scope.Peer.ID}
|
||||
}
|
||||
peer := tgPeer(scope.Peer)
|
||||
if scope.TopicID != 0 {
|
||||
return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID}
|
||||
|
|
@ -274,7 +292,7 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
|
|||
// 显示且跨重启恢复。perf:从 per-user notify 缓存读取(命中即 0 PG),而非每次 getDialogs
|
||||
// 都查 notify_settings——绝大多数用户没有任何自定义静音,缓存命中后零数据库开销。
|
||||
func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList {
|
||||
if len(list.Dialogs) == 0 {
|
||||
if len(list.Dialogs) == 0 && len(list.Communities) == 0 {
|
||||
return list
|
||||
}
|
||||
settings := r.userNotifySettings(ctx, viewerUserID)
|
||||
|
|
@ -287,6 +305,13 @@ func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int6
|
|||
list.Dialogs[i].NotifySettings = &sc
|
||||
}
|
||||
}
|
||||
for i := range list.Communities {
|
||||
peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: list.Communities[i].Community.ID}
|
||||
if s, ok := settings[peer]; ok {
|
||||
sc := s.Clone()
|
||||
list.Communities[i].State.NotifySettings = &sc
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
|
|||
Hash: aiComposeToneWebPageHash(tone),
|
||||
Date: int(now.Unix()),
|
||||
Type: aiComposeToneWebPageType,
|
||||
SiteName: "Telegram",
|
||||
SiteName: branding.ProductName,
|
||||
Title: tone.Title,
|
||||
Description: tone.Prompt,
|
||||
ComposeToneEmojiID: tone.EmojiID,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/app/auth"
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -589,6 +590,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var hash string
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
|
|
@ -606,6 +620,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
}
|
||||
|
||||
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
|
||||
|
|
@ -1000,13 +1026,13 @@ func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User,
|
|||
if ci, ok := ClientInfoFrom(ctx); ok {
|
||||
parts := []string{}
|
||||
if ci.DeviceModel != "" {
|
||||
parts = append(parts, ci.DeviceModel)
|
||||
parts = append(parts, branding.UserVisibleText(ci.DeviceModel, ""))
|
||||
}
|
||||
if ci.SystemVersion != "" {
|
||||
parts = append(parts, ci.SystemVersion)
|
||||
parts = append(parts, branding.UserVisibleText(ci.SystemVersion, ""))
|
||||
}
|
||||
if ci.AppVersion != "" {
|
||||
parts = append(parts, ci.AppVersion)
|
||||
parts = append(parts, branding.UserVisibleText(ci.AppVersion, ""))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
client = strings.Join(parts, " / ")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
|||
tg.AuthReportMissingCodeRequestTypeID,
|
||||
tg.AuthResetLoginEmailRequestTypeID,
|
||||
tg.AccountGetPasswordRequestTypeID,
|
||||
// deleteAccount may complete the narrow password_pending login path when
|
||||
// the user forgot 2FA. The handler resolves only that bound identity.
|
||||
tg.AccountDeleteAccountRequestTypeID,
|
||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
|
||||
tg.AccountSendVerifyEmailCodeRequestTypeID,
|
||||
|
|
@ -46,6 +49,7 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
|||
tg.HelpGetConfigRequestTypeID,
|
||||
tg.HelpGetNearestDCRequestTypeID,
|
||||
tg.HelpGetInviteTextRequestTypeID,
|
||||
tg.HelpSaveAppLogRequestTypeID,
|
||||
tg.HelpGetAppConfigRequestTypeID,
|
||||
tg.HelpGetCountriesListRequestTypeID,
|
||||
tg.HelpGetTimezonesListRequestTypeID,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import (
|
|||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
|
||||
|
|
@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) (
|
|||
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return leases.ReleaseBotAPIPollLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.SetBotAPIWebhook(ctx, config, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return domain.BotAPIWebhook{}, false, nil
|
||||
}
|
||||
return webhooks.BotAPIWebhook(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return webhooks.ListDueBotAPIWebhooks(ctx, limit)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt)
|
||||
}
|
||||
|
||||
func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID)
|
||||
}
|
||||
|
||||
// BotAPISendMessage sends a text message as a bot through the normal private
|
||||
// or channel message state machine. Positive chat_id is a user private chat;
|
||||
// -1000000000000-channel_id is a supergroup/channel chat.
|
||||
|
|
@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if text == "" {
|
||||
return domain.Message{}, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
|
|
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
|
|
@ -164,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) {
|
||||
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 {
|
||||
return domain.EphemeralMessage{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(input.ChatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return domain.EphemeralMessage{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(input.ReplyMarkup); err != nil {
|
||||
return domain.EphemeralMessage{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, input.ReplyMarkup); err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
baseContent := domain.EphemeralContent{
|
||||
Message: input.Text, Entities: append([]domain.MessageEntity(nil), input.Entities...), ReplyMarkup: input.ReplyMarkup,
|
||||
}
|
||||
if !utf8.ValidString(baseContent.Message) || utf8.RuneCountInString(baseContent.Message) > domain.MaxMessageTextLength || len(baseContent.Entities) > domain.MaxMessageEntityCount ||
|
||||
!validEphemeralEntityBounds(baseContent.Message, baseContent.Entities) {
|
||||
return domain.EphemeralMessage{}, errors.New("ENTITY_BOUNDS_INVALID")
|
||||
}
|
||||
message, _, err := r.deps.Ephemeral.SendFromBotLazy(ctx, domain.SendBotEphemeralRequest{
|
||||
BotUserID: input.BotUserID, ReceiverUserID: input.ReceiverUserID, Peer: peer,
|
||||
TopMessageID: input.TopMessageID, ReplyToEphemeralID: input.ReplyToEphemeralID,
|
||||
ActionMessageID: input.ReplyToEphemeralID, CallbackQueryID: input.CallbackQueryID,
|
||||
}, func(buildCtx context.Context) (domain.EphemeralContent, error) {
|
||||
content := baseContent
|
||||
if input.DirectMedia != nil {
|
||||
content.Media = input.DirectMedia
|
||||
if content.Media.Geo != nil && content.Media.Geo.AccessHash == 0 {
|
||||
content.Media.Geo.AccessHash, _ = randomGeoAccessHash()
|
||||
}
|
||||
if content.Media.Venue != nil && content.Media.Venue.Geo.AccessHash == 0 {
|
||||
content.Media.Venue.Geo.AccessHash, _ = randomGeoAccessHash()
|
||||
}
|
||||
} else if input.Kind != "message" {
|
||||
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.Kind, input.File, input.SecondaryFile)
|
||||
if err != nil {
|
||||
return domain.EphemeralContent{}, err
|
||||
}
|
||||
content.Media = media
|
||||
}
|
||||
return content, nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, ephemeralBotAPIError(err)
|
||||
}
|
||||
r.publishEphemeralPush(ctx, store.EphemeralPush{
|
||||
Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID,
|
||||
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
|
||||
})
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) {
|
||||
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 || input.MessageID <= 0 {
|
||||
return false, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(input.ChatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return false, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
fields := input.Fields
|
||||
if fields.SetReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(fields.ReplyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, fields.ReplyMarkup); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if fields.SetMessage && (!utf8.ValidString(fields.Message) || !validEphemeralEntityBounds(fields.Message, fields.Entities) || utf8.RuneCountInString(fields.Message) > domain.MaxMessageTextLength) {
|
||||
return false, errors.New("ENTITY_BOUNDS_INVALID")
|
||||
}
|
||||
message, err := r.deps.Ephemeral.EditFieldsFromBotLazy(ctx, input.BotUserID, input.ReceiverUserID, peer, input.MessageID, input.Mode, func(buildCtx context.Context) (domain.EditEphemeralFields, error) {
|
||||
built := fields
|
||||
if input.MediaKind != "" {
|
||||
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.MediaKind, input.File, input.SecondaryFile)
|
||||
if err != nil {
|
||||
return domain.EditEphemeralFields{}, err
|
||||
}
|
||||
built.SetMedia = true
|
||||
built.Media = media
|
||||
}
|
||||
return built, nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, ephemeralBotAPIError(err)
|
||||
}
|
||||
r.publishEphemeralPush(ctx, store.EphemeralPush{
|
||||
Kind: store.EphemeralPushEdit, TargetUserID: message.ReceiverUserID,
|
||||
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) {
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if r == nil || r.deps.Ephemeral == nil || !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return false, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
message, deleted, err := r.deps.Ephemeral.Delete(ctx, botUserID, receiverUserID, peer, messageID)
|
||||
if err != nil {
|
||||
return false, ephemeralBotAPIError(err)
|
||||
}
|
||||
if deleted {
|
||||
r.publishEphemeralPush(ctx, store.EphemeralPush{
|
||||
Kind: store.EphemeralPushDelete, TargetUserID: receiverUserID,
|
||||
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
|
||||
})
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ephemeralBotAPIError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), errors.Is(err, domain.ErrEphemeralDeleted):
|
||||
return errors.New("EPHEMERAL_MESSAGE_ID_INVALID")
|
||||
case errors.Is(err, domain.ErrEphemeralReplyExpired):
|
||||
return errors.New("EPHEMERAL_ACTION_EXPIRED")
|
||||
case errors.Is(err, domain.ErrEphemeralPeerInvalid):
|
||||
return errors.New("CHAT_ID_INVALID")
|
||||
case errors.Is(err, domain.ErrEphemeralReceiverInvalid):
|
||||
return errors.New("USER_ID_INVALID")
|
||||
case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch):
|
||||
return errors.New("CHAT_WRITE_FORBIDDEN")
|
||||
case errors.Is(err, domain.ErrEphemeralVersionConflict):
|
||||
return errors.New("MESSAGE_NOT_MODIFIED")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) botAPIEphemeralMedia(ctx context.Context, botID int64, kind string, file, secondary domain.BotAPIFileInput) (*domain.MessageMedia, error) {
|
||||
if kind == "live_photo" {
|
||||
photo, err := r.botAPIMedia(ctx, botID, "photo", file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
video, err := r.botAPIDocumentMedia(ctx, botID, "video", secondary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
photo.LivePhotoVideo = video.Document
|
||||
return photo, nil
|
||||
}
|
||||
if kind == "photo" {
|
||||
return r.botAPIMedia(ctx, botID, kind, file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
|
||||
}
|
||||
return r.botAPIDocumentMedia(ctx, botID, kind, file)
|
||||
}
|
||||
|
||||
func (r *Router) botAPIDocumentMedia(ctx context.Context, botID int64, kind string, file domain.BotAPIFileInput) (*domain.MessageMedia, error) {
|
||||
if r.deps.Files == nil {
|
||||
return nil, errors.New("MEDIA_INVALID")
|
||||
}
|
||||
attrs, forceFile, ok := botAPIDocumentKindAttributes(kind, file)
|
||||
if !ok {
|
||||
return nil, errors.New("MEDIA_INVALID")
|
||||
}
|
||||
var document domain.Document
|
||||
var err error
|
||||
switch {
|
||||
case len(file.Bytes) > 0:
|
||||
document, err = r.deps.Files.CreateDocumentFromBytes(ctx, file.Bytes, domain.DocumentSpec{MimeType: file.MimeType, Attributes: attrs, ForceFile: forceFile})
|
||||
case file.RemoteURL != "":
|
||||
document, err = r.deps.Files.CreateDocumentFromURL(ctx, file.RemoteURL)
|
||||
document.Attributes = mergeDocumentAttributes(document.Attributes, attrs)
|
||||
case file.LocationKey != "":
|
||||
id, valid := botAPIDocumentID(file.LocationKey)
|
||||
if !valid {
|
||||
return nil, errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
var found bool
|
||||
document, found, err = r.deps.Files.GetDocument(ctx, id)
|
||||
if err == nil && !found {
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
default:
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, botAPIMediaErr(err)
|
||||
}
|
||||
if !botAPIDocumentMatchesKind(document, kind) {
|
||||
return nil, errors.New("MEDIA_INVALID")
|
||||
}
|
||||
return messageMediaFromDocument(document, false, 0), nil
|
||||
}
|
||||
|
||||
func botAPIDocumentKindAttributes(kind string, file domain.BotAPIFileInput) ([]domain.DocumentAttribute, bool, bool) {
|
||||
filename := botAPIDocumentAttributes(file.FileName)
|
||||
w, h, duration := file.Width, file.Height, file.Duration
|
||||
if w <= 0 {
|
||||
w = 1
|
||||
}
|
||||
if h <= 0 {
|
||||
h = 1
|
||||
}
|
||||
if duration <= 0 {
|
||||
duration = 1
|
||||
}
|
||||
switch kind {
|
||||
case "document":
|
||||
return filename, true, true
|
||||
case "animation":
|
||||
return append(filename,
|
||||
domain.DocumentAttribute{Kind: domain.DocAttrAnimated},
|
||||
domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), NoSound: true}), false, true
|
||||
case "audio":
|
||||
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Title: file.Title, Performer: file.Performer}), false, true
|
||||
case "sticker":
|
||||
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrSticker, W: w, H: h, Alt: file.Emoji}), false, true
|
||||
case "video":
|
||||
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), SupportsStreaming: true}), false, true
|
||||
case "video_note":
|
||||
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), RoundMessage: true, SupportsStreaming: true}), false, true
|
||||
case "voice":
|
||||
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Voice: true}), false, true
|
||||
default:
|
||||
return nil, false, false
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDocumentAttributes(base, additional []domain.DocumentAttribute) []domain.DocumentAttribute {
|
||||
out := append([]domain.DocumentAttribute(nil), base...)
|
||||
seen := make(map[domain.DocumentAttributeKind]struct{}, len(base)+len(additional))
|
||||
for _, attribute := range base {
|
||||
seen[attribute.Kind] = struct{}{}
|
||||
}
|
||||
for _, attribute := range additional {
|
||||
if _, exists := seen[attribute.Kind]; exists {
|
||||
continue
|
||||
}
|
||||
seen[attribute.Kind] = struct{}{}
|
||||
out = append(out, attribute)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botAPIDocumentMatchesKind(document domain.Document, kind string) bool {
|
||||
has := func(target domain.DocumentAttributeKind, predicate func(domain.DocumentAttribute) bool) bool {
|
||||
for _, attribute := range document.Attributes {
|
||||
if attribute.Kind == target && (predicate == nil || predicate(attribute)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "document":
|
||||
return document.ID > 0
|
||||
case "animation":
|
||||
return has(domain.DocAttrAnimated, nil)
|
||||
case "audio":
|
||||
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return !a.Voice })
|
||||
case "sticker":
|
||||
return document.IsSticker()
|
||||
case "video":
|
||||
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return !a.RoundMessage })
|
||||
case "video_note":
|
||||
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return a.RoundMessage })
|
||||
case "voice":
|
||||
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return a.Voice })
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
|
||||
switch {
|
||||
case chatID > 0:
|
||||
|
|
@ -400,6 +792,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return self.Message, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if text == "" {
|
||||
return false, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
return false, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
if len(entities) > 0 {
|
||||
req.SetEntities(tgMessageEntities(entities))
|
||||
}
|
||||
if setReplyMarkup {
|
||||
wire := tgReplyMarkup(replyMarkup)
|
||||
if wire == nil {
|
||||
wire = &tg.ReplyInlineMarkup{}
|
||||
}
|
||||
req.SetReplyMarkup(wire)
|
||||
}
|
||||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
|
||||
// the target user's MTProto clients observe the normal delete update.
|
||||
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
|
||||
|
|
@ -440,12 +863,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal
|
|||
if cacheTime < 0 {
|
||||
cacheTime = 0
|
||||
}
|
||||
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
|
||||
resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
|
||||
Alert: showAlert,
|
||||
Message: text,
|
||||
URL: url,
|
||||
CacheTime: cacheTime,
|
||||
})
|
||||
if resolveErr != nil {
|
||||
return false, resolveErr
|
||||
}
|
||||
if !resolved {
|
||||
return false, errors.New("QUERY_ID_INVALID")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appbots "telesrv/internal/app/bots"
|
||||
|
|
@ -17,6 +20,170 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("private-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID,
|
||||
RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
if _, err := fixture.router.resolveBotCallbackQuery(
|
||||
fixture.ctx,
|
||||
fixture.owner.ID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID},
|
||||
sent.RecipientMessage.ID,
|
||||
[]byte("forged-callback-data"),
|
||||
); !tgerr.Is(err, "DATA_INVALID") {
|
||||
t.Fatalf("forged callback data err = %v, want DATA_INVALID", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash},
|
||||
MsgID: sent.RecipientMessage.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}{answer: answer, err: err}
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out {
|
||||
t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID)
|
||||
}
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) ||
|
||||
callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) {
|
||||
t.Fatalf("callback = %+v", callback)
|
||||
}
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err)
|
||||
}
|
||||
select {
|
||||
case result := <-answerCh:
|
||||
if result.err != nil || result.answer == nil || result.answer.Message != "accepted" {
|
||||
t.Fatalf("callback answer = %+v err=%v", result.answer, result.err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") {
|
||||
t.Fatalf("unknown answer = ok=%v err=%v", ok, err)
|
||||
}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1,
|
||||
Date: 100,
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3,
|
||||
},
|
||||
}
|
||||
if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok {
|
||||
t.Fatal("callback at answer deadline remained deliverable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()),
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99,
|
||||
Data: []byte("inline"), InlineMessage: inline,
|
||||
},
|
||||
}
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now)
|
||||
if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) ||
|
||||
event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline {
|
||||
t.Fatalf("inline callback event=%#v ok=%v", event, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("group-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002,
|
||||
Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash},
|
||||
MsgID: sent.Message.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
_, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- err
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) ||
|
||||
callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out {
|
||||
t.Fatalf("group callback event = %+v", event)
|
||||
}
|
||||
if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-answerCh:
|
||||
if err != nil {
|
||||
t.Fatalf("group callback answer: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("group callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent {
|
||||
t.Helper()
|
||||
for {
|
||||
events, err := router.BotAPIUpdates(ctx, botID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Type == domain.UpdateEventBotCallbackQuery {
|
||||
return event
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback query did not reach Bot API queue")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
chatID := -botAPIChannelChatIDBase - created.Channel.ID
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0)
|
||||
replyKeyboard := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
}
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
|
|
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body {
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body ||
|
||||
history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" {
|
||||
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const botAPIGetUpdatesLimit = 100
|
||||
const (
|
||||
botAPIGetUpdatesLimit = 100
|
||||
botAPIMaxNegativeOffset = 10000
|
||||
)
|
||||
|
||||
type botAPIChannelBotMemberProvider interface {
|
||||
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||
|
|
@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return nil, nil
|
||||
}
|
||||
fromID := int64(1)
|
||||
if offset > 0 {
|
||||
var items []domain.BotAPIUpdate
|
||||
if offset < 0 {
|
||||
if offset < -botAPIMaxNegativeOffset {
|
||||
return nil, errors.New("OFFSET_INVALID")
|
||||
}
|
||||
var err error
|
||||
items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > 0 && items[0].ID > 1 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if offset > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromID = offset
|
||||
} else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if offset >= 0 {
|
||||
confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
|
||||
if leadingSkipped > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -46,13 +70,19 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return r.enrichUpdateEvents(ctx, botID, events), nil
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) {
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) {
|
||||
privateIDs := make([]int, 0)
|
||||
privateSeen := make(map[int]struct{})
|
||||
channelIDs := make(map[int64][]int)
|
||||
channelSeen := make(map[int64]map[int]struct{})
|
||||
for _, item := range items {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item); !ok {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
|
||||
continue
|
||||
}
|
||||
if item.Ephemeral != nil {
|
||||
continue
|
||||
}
|
||||
if item.Callback != nil && item.Callback.InlineMessage != nil {
|
||||
continue
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
|
|
@ -79,7 +109,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
|
|||
events := make([]domain.UpdateEvent, 0, len(items))
|
||||
leadingSkipped := int64(0)
|
||||
for _, item := range items {
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
|
||||
if !ok {
|
||||
if len(events) == 0 {
|
||||
leadingSkipped = item.ID
|
||||
|
|
@ -101,7 +131,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i
|
|||
}
|
||||
out := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
|
||||
if msg.ID <= 0 || msg.OwnerUserID != botID {
|
||||
continue
|
||||
}
|
||||
out[msg.ID] = msg
|
||||
|
|
@ -127,10 +157,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
continue
|
||||
}
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
if len(byID) > 0 {
|
||||
|
|
@ -140,14 +166,40 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
return out
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID {
|
||||
return "", false
|
||||
}
|
||||
eventType, ok := botAPIUpdateEventType(item.Kind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) {
|
||||
return "", false
|
||||
}
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
|
||||
return "", false
|
||||
}
|
||||
cb := item.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 ||
|
||||
cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen {
|
||||
return "", false
|
||||
}
|
||||
if cb.InlineMessage != nil {
|
||||
inline := cb.InlineMessage
|
||||
if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) ||
|
||||
inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 {
|
||||
return "", false
|
||||
}
|
||||
return eventType, true
|
||||
}
|
||||
if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID {
|
||||
return "", false
|
||||
}
|
||||
} else if item.MessageID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if item.Peer.ID <= 0 {
|
||||
|
|
@ -159,26 +211,89 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
|
|||
return eventType, true
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item)
|
||||
func botAPIQueuedEphemeralValid(botID int64, item domain.BotAPIUpdate, now time.Time) bool {
|
||||
if item.Ephemeral == nil {
|
||||
return true
|
||||
}
|
||||
message := item.Ephemeral.Message
|
||||
if item.Ephemeral.Validate() != nil || item.SourcePts != 0 || item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID <= 0 ||
|
||||
message.ID != item.MessageID || message.Peer != item.Peer || message.Expired(now) ||
|
||||
message.SenderUserID <= 0 || message.ReceiverUserID <= 0 {
|
||||
return false
|
||||
}
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
return message.SenderUserID == botID
|
||||
}
|
||||
return message.ReceiverUserID == botID
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if item.Ephemeral != nil {
|
||||
message := item.Ephemeral.EphemeralMessage()
|
||||
event := domain.UpdateEvent{
|
||||
UserID: botID, Type: eventType, Date: item.Date, Peer: item.Peer,
|
||||
BotAPIUpdateID: item.ID, EphemeralMessage: &message,
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
event.BotCallbackQuery = &callback
|
||||
}
|
||||
return event, true
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
inline := *item.Callback.InlineMessage
|
||||
callback.InlineMessage = &inline
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
BotAPIUpdateID: item.ID,
|
||||
Date: item.Date,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
msg, found := privateMessages[item.MessageID]
|
||||
if !found {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
BotAPIUpdateID: item.ID,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: msg,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if msg.Out || !botAPIMessageProjectable(msg) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
msg.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: msg.Peer,
|
||||
Message: msg,
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
BotAPIUpdateID: item.ID,
|
||||
Date: item.Date,
|
||||
Peer: msg.Peer,
|
||||
Message: msg,
|
||||
}, true
|
||||
case domain.PeerTypeChannel:
|
||||
msg, found := channelMessages[item.Peer.ID][item.MessageID]
|
||||
|
|
@ -186,15 +301,34 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
|
|||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
BotAPIUpdateID: item.ID,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: projected,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: projected.Peer,
|
||||
Message: projected,
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
BotAPIUpdateID: item.ID,
|
||||
Date: item.Date,
|
||||
Peer: projected.Peer,
|
||||
Message: projected,
|
||||
}, true
|
||||
default:
|
||||
return domain.UpdateEvent{}, false
|
||||
|
|
@ -207,6 +341,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
|
|||
return domain.UpdateEventNewMessage, true
|
||||
case domain.BotAPIUpdateEditedMessage:
|
||||
return domain.UpdateEventEditMessage, true
|
||||
case domain.BotAPIUpdateCallbackQuery:
|
||||
return domain.UpdateEventBotCallbackQuery, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -410,7 +546,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
|
|||
return media.Photo != nil
|
||||
case domain.MessageMediaKindDocument:
|
||||
return media.Document != nil
|
||||
case domain.MessageMediaKindContact:
|
||||
return media.Contact != nil
|
||||
case domain.MessageMediaKindGeo:
|
||||
return media.Geo != nil
|
||||
case domain.MessageMediaKindVenue:
|
||||
return media.Venue != nil
|
||||
case domain.MessageMediaKindPoll:
|
||||
return media.Poll != nil
|
||||
case domain.MessageMediaKindGeoLive:
|
||||
return media.GeoLive != nil
|
||||
case domain.MessageMediaKindService:
|
||||
if media.ServiceAction == nil {
|
||||
return false
|
||||
}
|
||||
switch media.ServiceAction.Kind {
|
||||
case domain.MessageServiceActionWebViewDataSent:
|
||||
return media.ServiceAction.WebViewData != nil
|
||||
case domain.MessageServiceActionRequestedPeer:
|
||||
return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool {
|
||||
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity {
|
||||
return false
|
||||
}
|
||||
details := make(map[domain.Peer]struct{}, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
details[detail.Peer] = struct{}{}
|
||||
}
|
||||
requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested
|
||||
allUsers := true
|
||||
for _, peer := range action.Peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
if requiresDetails {
|
||||
if _, ok := details[peer]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
allUsers = allUsers && peer.Type == domain.PeerTypeUser
|
||||
}
|
||||
return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
|
|
|||
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) {
|
||||
validRequestedUsers := &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
media *domain.MessageMedia
|
||||
want bool
|
||||
}{
|
||||
{"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true},
|
||||
{"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true},
|
||||
{"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true},
|
||||
{"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true},
|
||||
{"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true},
|
||||
{"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{},
|
||||
}}, true},
|
||||
{"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers,
|
||||
}}, true},
|
||||
{"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true,
|
||||
},
|
||||
}}, false},
|
||||
{"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}},
|
||||
},
|
||||
}}, false},
|
||||
{"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{},
|
||||
}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := botAPIMessageMediaProjectable(tt.media); got != tt.want {
|
||||
t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55},
|
||||
}},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1001]; !ok {
|
||||
t.Fatalf("requested user refs=%v", users)
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("requested channel refs=%v", channels)
|
||||
}
|
||||
}
|
||||
|
|
@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool {
|
|||
func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
|
||||
out := make([]domain.BotCommand, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description})
|
||||
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
|
|||
func tgBotCommands(in []domain.BotCommand) []tg.BotCommand {
|
||||
out := make([]tg.BotCommand, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description})
|
||||
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
|
|||
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
|
||||
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
|
||||
// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
type privateMessageByUIDService interface {
|
||||
GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
|
||||
}
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query
|
||||
// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的
|
||||
// setBotCallbackAnswer/answerCallbackQuery,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
|
||||
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
botUserID := peer.ID
|
||||
// game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西),
|
||||
// 不挂起、不推送(避免给 bot 投递无法处理的 game query)。
|
||||
if req.Game {
|
||||
|
|
@ -46,47 +48,218 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if !hasData {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
if len(data) > domain.MaxCallbackDataLen {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
|
||||
callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
botUserID := callback.BotUserID
|
||||
|
||||
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
|
||||
if err != nil {
|
||||
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !ok || msg.Peer != peer {
|
||||
return nil, messageIDInvalidErr()
|
||||
defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
|
||||
callback.ID = queryID
|
||||
|
||||
// Bot API callback_query shares the dedicated durable update_id queue with message and
|
||||
// edited_message. The callback answer waiter itself remains ephemeral/process-local.
|
||||
if r.deps.BotAPIUpdates != nil {
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botUserID,
|
||||
Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: callback.Peer,
|
||||
MessageID: callback.MessageID,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Callback: &callback,
|
||||
}); err != nil {
|
||||
r.log.Warn("enqueue bot api callback query",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
} else if created {
|
||||
r.notifyBotAPIUpdate(botUserID)
|
||||
}
|
||||
}
|
||||
|
||||
queryID, pending := r.callbacks.register(botUserID, userID)
|
||||
defer r.callbacks.deregister(queryID)
|
||||
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给
|
||||
// bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。
|
||||
// MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。
|
||||
update := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID,
|
||||
UserID: userID,
|
||||
Peer: &tg.PeerUser{UserID: userID},
|
||||
MsgID: req.MsgID,
|
||||
ChatInstance: chatInstanceFor(botUserID, userID),
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference);私聊 MessageID
|
||||
// 已翻译为 bot 视角 box id,channel 使用共享 message id。
|
||||
var update tg.UpdateClass
|
||||
if callback.InlineMessage != nil {
|
||||
inline := &tg.UpdateInlineBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID,
|
||||
MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
inline.SetData(data)
|
||||
update = inline
|
||||
} else {
|
||||
direct := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer),
|
||||
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
direct.SetData(data)
|
||||
update = direct
|
||||
}
|
||||
update.SetData(data)
|
||||
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
|
||||
return r.waitBotCallbackAnswer(ctx, botUserID, queryID, pending)
|
||||
}
|
||||
|
||||
func (r *Router) waitBotCallbackAnswer(ctx context.Context, botUserID, queryID int64, pending *pendingCallback) (*tg.MessagesBotCallbackAnswer, error) {
|
||||
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-ticker.C:
|
||||
ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID)
|
||||
if err != nil {
|
||||
r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
}
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message
|
||||
// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline
|
||||
// ids and therefore remain an explicit blocked path instead of being misrouted here.
|
||||
func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) {
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
provider, ok := r.deps.Messages.(privateMessageByUIDService)
|
||||
if !ok || msg.UID == 0 {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID ||
|
||||
botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.From.ID,
|
||||
UserID: userID,
|
||||
Peer: botMessage.Peer,
|
||||
MessageID: botMessage.ID,
|
||||
ChatInstance: chatInstanceFor(msg.From.ID, userID),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
|
||||
return domain.BotCallbackQuery{}, peerIDInvalidErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, channelInvalidErr(err)
|
||||
}
|
||||
if len(history.Messages) != 1 {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
msg := history.Messages[0]
|
||||
if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.SenderUserID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
MessageID: msg.ID,
|
||||
ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass {
|
||||
return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return false
|
||||
}
|
||||
for _, row := range markup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
|
||||
// getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。
|
||||
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
|
||||
|
|
@ -106,7 +279,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
}
|
||||
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
|
||||
// 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。
|
||||
r.callbacks.resolve(botID, req.QueryID, ans)
|
||||
if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUp
|
|||
if !ok {
|
||||
return false, userPermissionDeniedErr()
|
||||
}
|
||||
u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until)
|
||||
u, err := svc.UpdateEmojiStatus(ctx, target.ID, domain.UserEmojiStatus{DocumentID: documentID, Until: until})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrPremiumRequired) {
|
||||
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
|
|
@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg
|
|||
case *tg.InputKeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
out.NameRequested = b.NameRequested
|
||||
out.UsernameRequested = b.UsernameRequested
|
||||
out.PhotoRequested = b.PhotoRequested
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
default:
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
|
|
@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key
|
|||
return &tg.KeyboardButtonRequestPeer{
|
||||
Text: button.Text,
|
||||
ButtonID: button.ButtonID,
|
||||
PeerType: tgRequestPeerType(button.PeerType),
|
||||
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
|
||||
MaxQuantity: button.MaxQuantity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer
|
||||
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经
|
||||
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
|
||||
// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
|
||||
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
|
||||
// a short-lived shared store. The shared CAS is the source of truth when wired:
|
||||
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
|
||||
// without accepting two answers or trusting a process-local owner map.
|
||||
type callbackRegistry struct {
|
||||
mu sync.Mutex
|
||||
pending map[int64]*pendingCallback
|
||||
shared store.BotCallbackRegistryStore
|
||||
}
|
||||
|
||||
type pendingCallback struct {
|
||||
|
|
@ -26,35 +33,77 @@ type pendingCallback struct {
|
|||
userID int64
|
||||
}
|
||||
|
||||
func newCallbackRegistry() *callbackRegistry {
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
|
||||
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
|
||||
var sharedStore store.BotCallbackRegistryStore
|
||||
if len(shared) > 0 {
|
||||
sharedStore = shared[0]
|
||||
}
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
|
||||
}
|
||||
|
||||
// register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须
|
||||
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
|
||||
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var queryID int64
|
||||
for {
|
||||
queryID = randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; !exists {
|
||||
break
|
||||
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
|
||||
return queryID, pending
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
|
||||
for attempts := 0; attempts < 32; attempts++ {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
queryID := randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; exists {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
c.mu.Unlock()
|
||||
if c.shared == nil {
|
||||
return queryID, p, nil
|
||||
}
|
||||
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
|
||||
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
|
||||
}, ttl)
|
||||
if err != nil {
|
||||
c.removeLocal(queryID)
|
||||
return 0, nil, err
|
||||
}
|
||||
if created {
|
||||
return queryID, p, nil
|
||||
}
|
||||
c.removeLocal(queryID)
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
return queryID, p
|
||||
return 0, nil, fmt.Errorf("allocate bot callback query id")
|
||||
}
|
||||
|
||||
// deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在
|
||||
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。
|
||||
func (c *callbackRegistry) deregister(queryID int64) {
|
||||
c.deregisterContext(context.Background(), 0, queryID)
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
if botUserID == 0 {
|
||||
botUserID = p.botUserID
|
||||
}
|
||||
delete(c.pending, queryID)
|
||||
close(p.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if c.shared != nil && botUserID > 0 {
|
||||
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) removeLocal(queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
delete(c.pending, queryID)
|
||||
|
|
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
|
|||
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID
|
||||
// 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。
|
||||
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
|
||||
if c.shared != nil {
|
||||
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
|
||||
if err != nil || !resolved {
|
||||
return resolved, err
|
||||
}
|
||||
c.deliver(callerBotID, queryID, ans)
|
||||
return true, nil
|
||||
}
|
||||
return c.deliver(callerBotID, queryID, ans), nil
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
c.mu.Lock()
|
||||
p, ok := c.pending[queryID]
|
||||
if !ok || p.botUserID != callerBotID {
|
||||
|
|
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
|
|||
return true
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
|
||||
if c.shared == nil {
|
||||
return domain.BotCallbackAnswer{}, false, nil
|
||||
}
|
||||
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
|
||||
}
|
||||
|
||||
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
|
||||
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
|
||||
return
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
|
||||
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil && r.log != nil {
|
||||
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
|
||||
}
|
||||
timer := time.NewTimer(time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
|
||||
// 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的
|
||||
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
|
||||
|
|
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
|
|||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
|
||||
// channel id to collide with a numerically equal private user id.
|
||||
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [17]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
buf[16] = 2
|
||||
default:
|
||||
buf[16] = 1
|
||||
}
|
||||
_, _ = h.Write(buf[:])
|
||||
v := int64(h.Sum64())
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -993,6 +993,22 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
|
|||
})
|
||||
}
|
||||
|
||||
// enqueueMonoforumMessageFanout only targets the subscriber sub-dialog and active parent-channel
|
||||
// admins. A monoforum has no ordinary members, so member recomputation would either drop the
|
||||
// message or leak it to an invalid historical membership.
|
||||
func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) {
|
||||
fanoutCache := newViewerPeerCache(r)
|
||||
ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID})
|
||||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients,
|
||||
0,
|
||||
func(bgCtx context.Context, viewers []int64) {
|
||||
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
|
||||
},
|
||||
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||||
return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res)
|
||||
})
|
||||
}
|
||||
|
||||
// skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。
|
||||
func skipDeliverySet(ids []int64) map[int64]struct{} {
|
||||
if len(ids) == 0 {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"telesrv/internal/domain"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) {
|
||||
|
|
@ -71,37 +74,54 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
|
|||
channelIDs := make([]int64, 0, len(ids))
|
||||
for _, input := range ids {
|
||||
ref, ok := inputChannelRef(input)
|
||||
if !ok || ref.ID == 0 || r.deps.Channels == nil {
|
||||
if !ok || ref.ID == 0 {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
channelIDs = append(channelIDs, ref.ID)
|
||||
}
|
||||
if len(channelIDs) == 0 || r.deps.Channels == nil {
|
||||
if len(channelIDs) == 0 || (r.deps.Channels == nil && r.deps.Communities == nil) {
|
||||
return &tg.MessagesChats{}, nil
|
||||
}
|
||||
views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
var views []domain.ChannelView
|
||||
if r.deps.Channels != nil {
|
||||
views, err = r.deps.Channels.GetChannels(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
}
|
||||
byID := make(map[int64]domain.ChannelView, len(views))
|
||||
for _, view := range views {
|
||||
byID[view.Channel.ID] = view
|
||||
}
|
||||
communityByID := make(map[int64]domain.CommunityView)
|
||||
if r.deps.Communities != nil {
|
||||
communityViews, err := r.deps.Communities.GetMany(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
for _, view := range communityViews {
|
||||
communityByID[view.Community.ID] = view
|
||||
}
|
||||
}
|
||||
chats := make([]tg.ChatClass, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
view, ok := byID[ref.ID]
|
||||
if !ok || !inputChannelAccessHashMatches(ref, view.Channel) {
|
||||
if view, ok := communityByID[ref.ID]; ok {
|
||||
if !ref.CheckAccessHash || ref.AccessHash == view.Community.AccessHash {
|
||||
chats = append(chats, tgCommunityChat(view))
|
||||
}
|
||||
continue
|
||||
}
|
||||
chats = append(chats, tgChannelChatForView(userID, view))
|
||||
if view, ok := byID[ref.ID]; ok && inputChannelAccessHashMatches(ref, view.Channel) {
|
||||
chats = append(chats, tgChannelChatForView(userID, view))
|
||||
}
|
||||
}
|
||||
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
|
||||
return &tg.MessagesChats{Chats: chats}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) {
|
||||
if r.deps.Channels == nil {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return &tg.MessagesChatFull{}, nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
|
|
@ -112,6 +132,34 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
if !ok {
|
||||
return nil, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
if r.deps.Communities != nil {
|
||||
view, communityErrValue := r.deps.Communities.Get(ctx, userID, ref.ID)
|
||||
if communityErrValue == nil {
|
||||
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
|
||||
return nil, channelInvalidErr(domain.ErrCommunityPrivate)
|
||||
}
|
||||
if settings := r.userNotifySettings(ctx, userID); len(settings) > 0 {
|
||||
if setting, ok := settings[domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID}]; ok {
|
||||
copy := setting.Clone()
|
||||
view.State.NotifySettings = ©
|
||||
}
|
||||
}
|
||||
return &tg.MessagesChatFull{
|
||||
FullChat: tgCommunityFull(view),
|
||||
Chats: tgCommunityHydratedChats(userID, view),
|
||||
Users: tgUsers(view.Users),
|
||||
}, nil
|
||||
}
|
||||
if errors.Is(communityErrValue, domain.ErrCommunityPrivate) {
|
||||
return nil, communityErr(communityErrValue)
|
||||
}
|
||||
if !errors.Is(communityErrValue, domain.ErrCommunityInvalid) {
|
||||
return nil, communityErr(communityErrValue)
|
||||
}
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
loadEpoch := r.channelFullProjectionCache.LoadEpoch()
|
||||
if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok {
|
||||
if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) {
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEd
|
|||
}
|
||||
|
||||
func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) {
|
||||
if r.deps.Channels == nil {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.About) > maxChannelAboutLength {
|
||||
|
|
@ -237,6 +237,22 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
|
|||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
view, changed, err := r.deps.Communities.EditAbout(ctx, userID, community.Community.ID, req.About)
|
||||
if err != nil {
|
||||
return false, communityErr(err)
|
||||
}
|
||||
if changed {
|
||||
r.pushCommunityState(ctx, userID, view)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return false, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -255,13 +271,26 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
|
|||
}
|
||||
|
||||
func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) {
|
||||
if r.deps.Channels == nil {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view, changed, err := r.deps.Communities.EditDefaultBannedRights(ctx, userID, community.Community.ID, domainChannelBannedRights(req.BannedRights))
|
||||
if err != nil {
|
||||
return nil, communityErr(err)
|
||||
}
|
||||
return r.communityMutationUpdates(ctx, userID, view, changed), nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg
|
|||
if req.ByLocation {
|
||||
return &tg.MessagesChats{}, nil
|
||||
}
|
||||
if req.ForCommunityPeer {
|
||||
channels, err := r.deps.Channels.ListCommunityLinkableChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
|
||||
}
|
||||
channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -107,7 +114,7 @@ func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.Channel
|
|||
}
|
||||
|
||||
func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) {
|
||||
if r.deps.Channels == nil {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return &tg.ChannelsChannelParticipants{}, nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
|
|
@ -122,6 +129,26 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels
|
|||
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
if community, isCommunity, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); isCommunity {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := r.deps.Communities.Participants(ctx, userID, community.Community.ID, filter, req.Offset, req.Limit)
|
||||
if err != nil {
|
||||
return nil, communityErr(err)
|
||||
}
|
||||
if req.Hash != 0 && list.Hash == req.Hash {
|
||||
return &tg.ChannelsChannelParticipantsNotModified{}, nil
|
||||
}
|
||||
participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants))
|
||||
for _, member := range list.Participants {
|
||||
participants = append(participants, tgCommunityMember(userID, member))
|
||||
}
|
||||
return &tg.ChannelsChannelParticipants{Count: list.Count, Participants: participants, Chats: []tg.ChatClass{tgCommunityChat(community)}, Users: tgUsers(list.Users)}, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit)
|
||||
if err != nil {
|
||||
return nil, channelInvalidErr(err)
|
||||
|
|
@ -390,17 +417,13 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI
|
|||
}
|
||||
|
||||
func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) {
|
||||
if r.deps.Channels == nil {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -408,6 +431,33 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
|
|||
if !found || target.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view, changed, err := r.deps.Communities.EditAdmin(ctx, userID, domain.CommunityEditAdminRequest{
|
||||
CommunityID: community.Community.ID,
|
||||
UserID: target.ID,
|
||||
Rights: domainChannelAdminRights(req.AdminRights),
|
||||
Rank: req.Rank,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, communityErr(err)
|
||||
}
|
||||
updates := r.communityMutationUpdates(ctx, userID, view, changed)
|
||||
if changed && target.ID != userID {
|
||||
r.refreshAndPushCommunityState(ctx, target.ID, community.Community.ID, community.Community)
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
480
internal/rpc/communities.go
Normal 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
|
||||
}
|
||||
38
internal/rpc/communities_register.go
Normal file
38
internal/rpc/communities_register.go
Normal 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)
|
||||
})
|
||||
}
|
||||
334
internal/rpc/communities_rpc_test.go
Normal file
334
internal/rpc/communities_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
104
internal/rpc/convert_communities.go
Normal file
104
internal/rpc/convert_communities.go
Normal 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)}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_markup(inline
|
||||
// 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-reply:P3 不支持,丢弃。
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
internal/rpc/convert_markup_test.go
Normal file
171
internal/rpc/convert_markup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
|||
msg.SetInvertMedia(true)
|
||||
}
|
||||
}
|
||||
// reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
// reply_markup(bot 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import (
|
|||
|
||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 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
|
||||
}
|
||||
|
|
|
|||
56
internal/rpc/convert_users_deleted_test.go
Normal file
56
internal/rpc/convert_users_deleted_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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):余额查询、贷记/借记、流水分页。
|
||||
|
|
|
|||
|
|
@ -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
460
internal/rpc/ephemeral.go
Normal 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()
|
||||
}
|
||||
}
|
||||
107
internal/rpc/ephemeral_push.go
Normal file
107
internal/rpc/ephemeral_push.go
Normal 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)
|
||||
}
|
||||
220
internal/rpc/ephemeral_push_test.go
Normal file
220
internal/rpc/ephemeral_push_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
96
internal/rpc/ephemeral_rpc_test.go
Normal file
96
internal/rpc/ephemeral_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
74
internal/rpc/help_save_app_log_test.go
Normal file
74
internal/rpc/help_save_app_log_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_to,saved_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_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 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_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
|
||||
// Phase 1 仅认 inputRichMessage(blocks 形态),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
|
||||
}
|
||||
|
|
|
|||
73
internal/rpc/messages_suggested_post.go
Normal file
73
internal/rpc/messages_suggested_post.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
|
||||
// 两端客户端无条件读取 balance(DrKLO 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.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
|
||||
// 两端客户端无条件读取 balance(DrKLO 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_offset(flag 不置),否则 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.starsStatus(chats/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)
|
||||
}
|
||||
|
|
|
|||
98
internal/rpc/payments_star_gift_catalog_projection_test.go
Normal file
98
internal/rpc/payments_star_gift_catalog_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1019
internal/rpc/payments_star_gift_lifecycle.go
Normal file
1019
internal/rpc/payments_star_gift_lifecycle.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 把收到的礼物转换回 Stars(Credit + 标记 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.StarGift(Sticker 须为带 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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ type Config struct {
|
|||
|
||||
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handler(tlprofile.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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
25
internal/rpc/users_bot_info_test.go
Normal file
25
internal/rpc/users_bot_info_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue