Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -391,14 +391,10 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
ID)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountGetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountGetWebAuthorizationsRequest) (any, error) {
|
||||
return tdesktop.WebAuthorizations(), nil
|
||||
return r.onAccountGetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorization, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
Hash
|
||||
_ = hash
|
||||
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorization(ctx, layerRequest.Hash)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationsRequest) (
|
||||
|
||||
|
|
@ -406,7 +402,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
// (无内置浏览器例外、不强制外部浏览器)。Android 启动时会拉取,缺它会反复 500
|
||||
// NOT_IMPLEMENTED。空结构 Hash=0,客户端按默认(内置浏览器、无例外)渲染。
|
||||
any, error) {
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebBrowserSettingsRequest](d, tlprofile.SemanticMethodAccountGetWebBrowserSettings, func(ctx context.Context, layerRequest *tg.AccountGetWebBrowserSettingsRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser
|
|||
settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser)
|
||||
}
|
||||
if settings.BusinessBotManageURL == "" {
|
||||
settings.BusinessBotManageURL = "telesrv://business-bot"
|
||||
settings.BusinessBotManageURL = r.publicAppLink("business-bot")
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
|
|
|||
107
internal/rpc/account_freeze_worker.go
Normal file
107
internal/rpc/account_freeze_worker.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type accountFreezeNotificationService interface {
|
||||
ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
|
||||
CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error
|
||||
}
|
||||
|
||||
// RunAccountFreezeNotifications drains the crash-safe, coalesced non-pts
|
||||
// updateUser queue. One attempt is enough for online delivery; offline clients
|
||||
// recover the current state from viewer-scoped user hydration.
|
||||
func (r *Router) RunAccountFreezeNotifications(ctx context.Context, interval time.Duration, batch int) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 500
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
r.drainAccountFreezeNotifications(ctx, batch)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-r.accountFreezeWake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int) {
|
||||
svc, ok := r.deps.AccountFreeze.(accountFreezeNotificationService)
|
||||
if !ok || r.deps.Users == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
now := r.clock.Now().UTC()
|
||||
claimCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
notifications, err := svc.ClaimAccountFreezeNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.log.Warn("claim account freeze notifications failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
r.dispatchAccountFreezeNotification(ctx, svc, notification)
|
||||
}
|
||||
if len(notifications) < batch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc accountFreezeNotificationService, notification domain.AccountFreezeNotification) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: notification.FrozenUserID}
|
||||
if contacts, ok := r.deps.Contacts.(interface{ InvalidateViewers(...int64) }); ok {
|
||||
contacts.InvalidateViewers(notification.TargetUserID)
|
||||
}
|
||||
if dialogs, ok := r.deps.Dialogs.(interface {
|
||||
InvalidateDialog(int64, domain.Peer)
|
||||
}); ok {
|
||||
dialogs.InvalidateDialog(notification.TargetUserID, peer)
|
||||
}
|
||||
r.invalidateRPCProjectionForPeer(notification.TargetUserID, peer)
|
||||
|
||||
loadCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
user, found, err := r.deps.Users.ByID(loadCtx, notification.TargetUserID, notification.FrozenUserID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.log.Warn("load frozen user projection for notification failed",
|
||||
zap.Int64("target_user_id", notification.TargetUserID),
|
||||
zap.Int64("frozen_user_id", notification.FrozenUserID),
|
||||
zap.Int64("version", notification.Version),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
user = domain.User{ID: notification.FrozenUserID, Deleted: true}
|
||||
}
|
||||
pushCtx, pushCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
r.pushUserUpdates(pushCtx, notification.TargetUserID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.FrozenUserID}},
|
||||
Users: r.tgUsersForViewer(notification.TargetUserID, []domain.User{user}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
pushCancel()
|
||||
|
||||
completeCtx, completeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err = svc.CompleteAccountFreezeNotification(completeCtx, notification.ID, notification.Version, r.clock.Now().UTC())
|
||||
completeCancel()
|
||||
if err != nil {
|
||||
r.log.Warn("complete account freeze notification failed",
|
||||
zap.Int64("notification_id", notification.ID),
|
||||
zap.Int64("version", notification.Version),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
136
internal/rpc/account_freeze_worker_test.go
Normal file
136
internal/rpc/account_freeze_worker_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestAccountFreezeNotificationPushesCurrentViewerProjection(t *testing.T) {
|
||||
const (
|
||||
viewerID = int64(1001)
|
||||
frozenID = int64(1002)
|
||||
)
|
||||
sessions := &captureSessions{}
|
||||
freezeSvc := &freezeWorkerService{}
|
||||
users := &freezeWorkerUsers{user: domain.User{
|
||||
ID: frozenID,
|
||||
FirstName: "Frozen",
|
||||
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
|
||||
}}
|
||||
r := New(Config{}, Deps{
|
||||
AccountFreeze: freezeSvc,
|
||||
Users: users,
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, domain.AccountFreezeNotification{
|
||||
ID: 7, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 4, Frozen: true,
|
||||
})
|
||||
|
||||
if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{7, 4} {
|
||||
t.Fatalf("completed = %v, want [[7 4]]", freezeSvc.completed)
|
||||
}
|
||||
if got := sessions.pushedUserIDs(); len(got) != 1 || got[0] != viewerID {
|
||||
t.Fatalf("pushed user IDs = %v, want [%d]", got, viewerID)
|
||||
}
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 || len(updates.Users) != 1 {
|
||||
t.Fatalf("push = %#v, want updateUser plus projected user", sessions.lastUserPush())
|
||||
}
|
||||
if update, ok := updates.Updates[0].(*tg.UpdateUser); !ok || update.UserID != frozenID {
|
||||
t.Fatalf("update = %#v, want updateUser(%d)", updates.Updates[0], frozenID)
|
||||
}
|
||||
projected, ok := updates.Users[0].(*tg.User)
|
||||
if !ok || !projected.Restricted {
|
||||
t.Fatalf("projected user = %#v, want restricted user", updates.Users[0])
|
||||
}
|
||||
reasons, ok := projected.GetRestrictionReason()
|
||||
if !ok || len(reasons) != 1 || reasons[0].Reason != "frozen" {
|
||||
t.Fatalf("projected restriction = %+v ok=%v", reasons, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezeNotificationLoadsCurrentStateAndRetriesLoadFailure(t *testing.T) {
|
||||
const (
|
||||
viewerID = int64(2001)
|
||||
frozenID = int64(2002)
|
||||
)
|
||||
sessions := &captureSessions{}
|
||||
freezeSvc := &freezeWorkerService{}
|
||||
users := &freezeWorkerUsers{err: errors.New("projection unavailable")}
|
||||
r := New(Config{}, Deps{
|
||||
AccountFreeze: freezeSvc,
|
||||
Users: users,
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
notification := domain.AccountFreezeNotification{
|
||||
ID: 8, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 5, Frozen: true,
|
||||
}
|
||||
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification)
|
||||
if len(freezeSvc.completed) != 0 || len(sessions.pushedUserIDs()) != 0 {
|
||||
t.Fatalf("failed load completed=%v pushes=%v, want retry without push", freezeSvc.completed, sessions.pushedUserIDs())
|
||||
}
|
||||
|
||||
// The queued payload may say frozen, but delivery must hydrate the latest
|
||||
// viewer projection so a newer unfreeze can never be overwritten by stale work.
|
||||
users.err = nil
|
||||
users.user = domain.User{ID: frozenID, FirstName: "Active"}
|
||||
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification)
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Users) != 1 {
|
||||
t.Fatalf("push = %#v", sessions.lastUserPush())
|
||||
}
|
||||
projected, ok := updates.Users[0].(*tg.User)
|
||||
if !ok || projected.Restricted {
|
||||
t.Fatalf("latest projected user = %#v, want unrestricted", updates.Users[0])
|
||||
}
|
||||
if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{8, 5} {
|
||||
t.Fatalf("completed = %v, want [[8 5]]", freezeSvc.completed)
|
||||
}
|
||||
}
|
||||
|
||||
type freezeWorkerService struct {
|
||||
completed [][2]int64
|
||||
}
|
||||
|
||||
func (*freezeWorkerService) AccountFreeze(context.Context, int64) (domain.AccountFreeze, bool, error) {
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
|
||||
func (*freezeWorkerService) ClaimAccountFreezeNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountFreezeNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *freezeWorkerService) CompleteAccountFreezeNotification(_ context.Context, id, version int64, _ time.Time) error {
|
||||
s.completed = append(s.completed, [2]int64{id, version})
|
||||
return nil
|
||||
}
|
||||
|
||||
type freezeWorkerUsers struct {
|
||||
user domain.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) Self(context.Context, int64) (domain.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) ByID(context.Context, int64, int64) (domain.User, bool, error) {
|
||||
return s.user, s.err == nil, s.err
|
||||
}
|
||||
|
||||
func (s *freezeWorkerUsers) ByIDs(context.Context, int64, []int64) ([]domain.User, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return []domain.User{s.user}, nil
|
||||
}
|
||||
|
|
@ -46,3 +46,20 @@ func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.S
|
|||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAccountFreezeChanged invalidates target-scoped projections immediately
|
||||
// and wakes the durable audience nudge worker. Cross-instance cache invalidation
|
||||
// is also carried by the committed user_visibility read-model notification.
|
||||
func (r *Router) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error {
|
||||
if r == nil || freeze.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(freeze.UserID)
|
||||
if r.accountFreezeWake != nil {
|
||||
select {
|
||||
case r.accountFreezeWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ import (
|
|||
)
|
||||
|
||||
type androidPrivateLayerFixture struct {
|
||||
name string
|
||||
privateID uint32
|
||||
semantic tlprofile.SemanticID
|
||||
method string
|
||||
wire func(*testing.T) []byte
|
||||
name string
|
||||
privateID uint32
|
||||
semantic tlprofile.SemanticID
|
||||
method string
|
||||
currentJoinResult bool
|
||||
wire func(*testing.T) []byte
|
||||
}
|
||||
|
||||
// TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary is the production
|
||||
|
|
@ -34,7 +35,7 @@ type androidPrivateLayerFixture struct {
|
|||
func TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
fixtures := androidPrivateLayerFixtures()
|
||||
if got, want := len(fixtures), 15; got != want {
|
||||
if got, want := len(fixtures), 17; got != want {
|
||||
t.Fatalf("private fixture count = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +87,17 @@ func TestAndroidPrivateLayerRPCsAdaptAcrossCanonicalBoundary(t *testing.T) {
|
|||
if !ok || call.WireID() != wantWireID {
|
||||
t.Fatalf("admitted exact id = %#x, want %#x (ok=%v)", call.WireID(), wantWireID, ok)
|
||||
}
|
||||
if fixture.currentJoinResult {
|
||||
var result bin.Buffer
|
||||
if err := call.EncodeResult(&tg.MessagesChatInviteJoinResultOk{
|
||||
Updates: &tg.UpdatesTooLong{},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("encode current join result: %v", err)
|
||||
}
|
||||
if wireID, err := result.PeekID(); err != nil || wireID != 0x445663a7 {
|
||||
t.Fatalf("result wire = %#x err=%v, want chatInviteJoinResultOk#445663a7", wireID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -109,6 +121,24 @@ func androidPrivateLayerFixtures() []androidPrivateLayerFixture {
|
|||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "messages.importChatInvite_alias", privateID: 0x6c50051c,
|
||||
semantic: tlprofile.SemanticMethodMessagesImportChatInvite, method: "messages.importChatInvite",
|
||||
currentJoinResult: true,
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x6c50051c, &tg.MessagesImportChatInviteRequest{Hash: "private-invite"})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "channels.joinChannel_alias", privateID: 0x24b524c5,
|
||||
semantic: tlprofile.SemanticMethodChannelsJoinChannel, method: "channels.joinChannel",
|
||||
currentJoinResult: true,
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x24b524c5, &tg.ChannelsJoinChannelRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: 45, AccessHash: 46},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updates.getDifference_alias", privateID: 0x25939651,
|
||||
semantic: tlprofile.SemanticMethodUpdatesGetDifference, method: "updates.getDifference",
|
||||
|
|
@ -121,7 +151,6 @@ func androidPrivateLayerFixtures() []androidPrivateLayerFixture {
|
|||
semantic: tlprofile.SemanticMethodMessagesCreateChat, method: "messages.createChat",
|
||||
wire: func(t *testing.T) []byte {
|
||||
return androidPrivateAliasWire(t, 0x0034a818, &tg.MessagesCreateChatRequest{
|
||||
Users: []tg.InputUserClass{&tg.InputUser{UserID: 51, AccessHash: 52}},
|
||||
Title: "private group",
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, replyMarkup, silent, reply)
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, nil, replyMarkup, silent, false, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
|
|
@ -229,6 +229,66 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
// BotAPISendRichMessage sends one durable rich message through the same
|
||||
// private/channel state machines as messages.sendMessage. The HTTP input is
|
||||
// parsed into canonical PageBlocks before any message row, pts or outbox entry
|
||||
// is written.
|
||||
func (r *Router) BotAPISendRichMessage(ctx context.Context, botID, chatID int64, input domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
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 effectID != 0 && (peer.Type != domain.PeerTypeUser || r.messageEffectInvalid(ctx, effectID)) {
|
||||
return domain.Message{}, effectIDInvalidErr()
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return domain.Message{}, richMessageInvalidErr()
|
||||
}
|
||||
var reply *domain.MessageReply
|
||||
if replyToMessageID > 0 {
|
||||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, "", nil, nil, richMessage, replyMarkup, silent, noForwards, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if r.deps.Users != nil && peer.ID != botID {
|
||||
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
|
||||
return domain.Message{}, err
|
||||
} else if !found {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botID, RecipientUserID: peer.ID, RandomID: randomNonZeroInt64(),
|
||||
RichMessage: richMessage, Silent: silent, NoForwards: noForwards, ReplyTo: reply,
|
||||
Date: int(time.Now().Unix()), ReplyMarkup: replyMarkup, Effect: effectID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
// BotAPISendMedia sends a photo/document message through the same files service
|
||||
// and private/channel message state machines used by MTProto sendMedia.
|
||||
func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
|
|
@ -257,7 +317,7 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, replyMarkup, silent, reply)
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, nil, replyMarkup, silent, false, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
|
|
@ -569,7 +629,7 @@ func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
|
|||
return domain.Peer{}, false
|
||||
}
|
||||
|
||||
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, replyMarkup *domain.MessageReplyMarkup, silent bool, reply *domain.MessageReply) (domain.Message, error) {
|
||||
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, richMessage *domain.MessageRichMessage, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, reply *domain.MessageReply) (domain.Message, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
|
|
@ -581,10 +641,12 @@ func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID
|
|||
Message: text,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
Media: media,
|
||||
RichMessage: richMessage,
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SkipRecipientLookup: true,
|
||||
PostAuthor: r.channelPostAuthorName(ctx, botID),
|
||||
Silent: silent,
|
||||
NoForwards: noForwards,
|
||||
ReplyTo: reply,
|
||||
ReplyMarkup: replyMarkup,
|
||||
Date: int(time.Now().Unix()),
|
||||
|
|
@ -772,6 +834,14 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID}
|
||||
if setReplyMarkup {
|
||||
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
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID,
|
||||
Peer: peer,
|
||||
|
|
@ -781,6 +851,9 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
EditDate: int(time.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
// An explicit plain-text edit replaces a previous rich payload. Keeping
|
||||
// both would create a state that neither Bot API nor TDesktop permits.
|
||||
SetRichMessage: true,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
|
|
@ -792,6 +865,70 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return self.Message, nil
|
||||
}
|
||||
|
||||
// BotAPIEditRichMessage replaces message content with one rich payload while
|
||||
// preserving the existing durable edit/pts/outbox semantics.
|
||||
func (r *Router) BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return domain.Message{}, errors.New("MESSAGE_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
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return domain.Message{}, richMessageInvalidErr()
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
res, err := r.deps.Channels.EditMessage(ctx, botID, domain.EditChannelMessageRequest{
|
||||
UserID: botID, ChannelID: peer.ID, ID: messageID, Message: "",
|
||||
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: true, RichMessage: richMessage, EditDate: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, channelEditErr(err)
|
||||
}
|
||||
r.enqueueChannelEditMessageFanout(ctx, botID, res)
|
||||
return botAPIMessageFromChannel(botID, res.Message), nil
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID, Peer: peer, ID: messageID, Message: "", EditDate: int(time.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: true, RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
r.enqueueBotAPIPrivateEditUpdatesAsync(ctx, res)
|
||||
self := res.Self()
|
||||
if self.Message.ID == 0 {
|
||||
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
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")
|
||||
|
|
@ -805,6 +942,11 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
|
|||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
|
|
@ -823,6 +965,34 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
|
|||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{ID: tgInputBotInlineMessageID(inlineMessageID)}
|
||||
req.SetRichMessage(wire)
|
||||
if setReplyMarkup {
|
||||
markup := tgReplyMarkup(replyMarkup)
|
||||
if markup == nil {
|
||||
markup = &tg.ReplyInlineMarkup{}
|
||||
}
|
||||
req.SetReplyMarkup(markup)
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -249,6 +249,112 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessagePrivateSendEditAndPlainReplacement(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Admin</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage: %v", err)
|
||||
}
|
||||
if sent.ID <= 0 || sent.Pts <= 0 || sent.Body != "" || sent.RichMessage == nil || len(sent.RichMessage.BotAPIProjection) == 0 ||
|
||||
sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "menu:info" {
|
||||
t.Fatalf("sent rich message = %+v", sent)
|
||||
}
|
||||
|
||||
botHistory := privateBotAPIHistory(t, fixture, fixture.bot.ID, fixture.owner.ID)
|
||||
if botHistory.ID != sent.ID || botHistory.RichMessage == nil || botHistory.Body != "" {
|
||||
t.Fatalf("bot rich history = %+v", botHistory)
|
||||
}
|
||||
ownerHistory := privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || len(ownerHistory.RichMessage.BotAPIProjection) == 0 || ownerHistory.ReplyMarkup == nil {
|
||||
t.Fatalf("owner rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated\n\nSubscription: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("edited rich message = %+v projection=%s", edited, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || !strings.Contains(string(ownerHistory.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("owner edited rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
plain, err := fixture.router.BotAPIEditMessageText(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, "Classic menu", nil, false, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditMessageText replacing rich: %v", err)
|
||||
}
|
||||
if plain.Body != "Classic menu" || plain.RichMessage != nil {
|
||||
t.Fatalf("plain replacement = %+v", plain)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.Body != "Classic menu" || ownerHistory.RichMessage != nil {
|
||||
t.Fatalf("owner plain replacement history = %+v", ownerHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessageSupergroupSendAndEdit(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
chatID := -botAPIChannelChatIDBase - fixture.channel.ID
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Status", Data: []byte("channel:status"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, chatID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Group menu</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage channel: %v", err)
|
||||
}
|
||||
if sent.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || sent.ID <= 0 || sent.Pts <= 0 ||
|
||||
sent.RichMessage == nil || sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "channel:status" {
|
||||
t.Fatalf("sent channel rich message = %+v", sent)
|
||||
}
|
||||
history, err := fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil || history.Messages[0].Body != "" {
|
||||
t.Fatalf("channel rich history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, chatID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated group menu\n\nStatus: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage channel: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel rich message = %+v sent_pts=%d projection=%s", edited, sent.Pts, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
history, err = fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil ||
|
||||
!strings.Contains(string(history.Messages[0].RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func privateBotAPIHistory(t *testing.T, fixture botAPIReceiveFixture, ownerID, peerID int64) domain.Message {
|
||||
t.Helper()
|
||||
history, err := fixture.messages.GetHistory(fixture.ctx, ownerID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("GetHistory owner=%d peer=%d len=%d err=%v", ownerID, peerID, len(history.Messages), err)
|
||||
}
|
||||
return history.Messages[0]
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
|
|
|
|||
305
internal/rpc/botapi_rich_message.go
Normal file
305
internal/rpc/botapi_rich_message.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
richbuilder "github.com/iamxvbaba/td/telegram/message/rich"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
botAPIRichSentinelScheme = "telesrv-rich"
|
||||
botAPIRichDateMaxUnix = int64(1<<31 - 1)
|
||||
)
|
||||
|
||||
type botAPIHTMLTableSpec struct {
|
||||
bordered bool
|
||||
striped bool
|
||||
cells []botAPIHTMLTableCellSpec
|
||||
}
|
||||
|
||||
type botAPIHTMLTableCellSpec struct {
|
||||
align string
|
||||
valign string
|
||||
}
|
||||
|
||||
func tgInputRichMessageFromBotAPI(input domain.BotAPIRichMessageInput) (tg.InputRichMessageClass, error) {
|
||||
if input.SourceCount() != 1 || len(input.BlocksJSON) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if len(input.MediaJSON) != 0 {
|
||||
return nil, richMessageMediaUnsupportedErr()
|
||||
}
|
||||
if input.HTML != "" {
|
||||
return &tg.InputRichMessageHTML{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, HTML: input.HTML,
|
||||
}, nil
|
||||
}
|
||||
if input.Markdown != "" {
|
||||
return &tg.InputRichMessageMarkdown{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, Markdown: input.Markdown,
|
||||
}, nil
|
||||
}
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
|
||||
func parseBotAPIRichHTML(source string) ([]tg.PageBlockClass, error) {
|
||||
doc, err := html.Parse(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
tables := make([]botAPIHTMLTableSpec, 0)
|
||||
var transform func(*html.Node) error
|
||||
transform = func(node *html.Node) error {
|
||||
if node.Type == html.ElementNode {
|
||||
switch node.Data {
|
||||
case "img", "video", "audio", "tg-map", "tg-collage", "tg-slideshow":
|
||||
// The current local blob backend cannot materialize an arbitrary
|
||||
// rich HTML media URL atomically. Fail explicitly so Bedolaga's
|
||||
// documented one-shot no-logo retry is used instead of losing media.
|
||||
return webpageMediaEmptyErr()
|
||||
case "tg-time":
|
||||
unixTime, err := strconv.ParseInt(htmlNodeAttr(node, "unix"), 10, 64)
|
||||
if err != nil || unixTime <= 0 || unixTime > botAPIRichDateMaxUnix {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
format := htmlNodeAttr(node, "format")
|
||||
if _, ok := botAPIRichDateFlags(format); !ok {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
node.Data = "a"
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: fmt.Sprintf("%s://time?unix=%d&format=%s", botAPIRichSentinelScheme, unixTime, url.QueryEscape(format))}}
|
||||
case "footer":
|
||||
node.Data = "p"
|
||||
node.Attr = nil
|
||||
anchor := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{{Key: "href", Val: botAPIRichSentinelScheme + "://footer"}}}
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
anchor.AppendChild(child)
|
||||
child = next
|
||||
}
|
||||
node.AppendChild(anchor)
|
||||
case "table":
|
||||
tables = append(tables, botAPIHTMLTableSpecFromNode(node))
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if err := transform(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := transform(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized bytes.Buffer
|
||||
if err := html.Render(&normalized, doc); err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := richbuilder.ParseHTML(strings.NewReader(normalized.String()))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
postProcessBotAPIRichHTML(blocks, tables)
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func parseBotAPIRichMarkdown(source string) ([]tg.PageBlockClass, error) {
|
||||
blocks, err := richbuilder.ParseMarkdown(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func botAPIHTMLTableSpecFromNode(table *html.Node) botAPIHTMLTableSpec {
|
||||
spec := botAPIHTMLTableSpec{bordered: htmlNodeHasAttr(table, "bordered"), striped: htmlNodeHasAttr(table, "striped")}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
|
||||
spec.cells = append(spec.cells, botAPIHTMLTableCellSpec{
|
||||
align: strings.ToLower(htmlNodeAttr(child, "align")), valign: strings.ToLower(htmlNodeAttr(child, "valign")),
|
||||
})
|
||||
}
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(table)
|
||||
return spec
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichHTML(blocks []tg.PageBlockClass, tables []botAPIHTMLTableSpec) {
|
||||
tableIndex := 0
|
||||
var visit func([]tg.PageBlockClass)
|
||||
visit = func(items []tg.PageBlockClass) {
|
||||
for index, block := range items {
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
if footer, ok := botAPIRichFooterText(value.Text); ok {
|
||||
items[index] = &tg.PageBlockFooter{Text: postProcessBotAPIRichText(footer)}
|
||||
} else {
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
case *tg.PageBlockHeading1:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockFooter:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockBlockquote:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockDetails:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockTable:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
if tableIndex < len(tables) {
|
||||
spec := tables[tableIndex]
|
||||
tableIndex++
|
||||
value.Bordered, value.Striped = spec.bordered, spec.striped
|
||||
cellIndex := 0
|
||||
for rowIndex := range value.Rows {
|
||||
for columnIndex := range value.Rows[rowIndex].Cells {
|
||||
cell := &value.Rows[rowIndex].Cells[columnIndex]
|
||||
cell.Text = postProcessBotAPIRichText(cell.Text)
|
||||
if cellIndex < len(spec.cells) {
|
||||
cellSpec := spec.cells[cellIndex]
|
||||
cell.AlignCenter = cellSpec.align == "center"
|
||||
cell.AlignRight = cellSpec.align == "right"
|
||||
cell.ValignMiddle = cellSpec.valign == "middle"
|
||||
cell.ValignBottom = cellSpec.valign == "bottom"
|
||||
}
|
||||
cellIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichText(text tg.RichTextClass) tg.RichTextClass {
|
||||
switch value := text.(type) {
|
||||
case *tg.TextConcat:
|
||||
for i := range value.Texts {
|
||||
value.Texts[i] = postProcessBotAPIRichText(value.Texts[i])
|
||||
}
|
||||
case *tg.TextBold:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextItalic:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextStrike:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextFixed:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMarked:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextURL:
|
||||
parsed, err := url.Parse(value.URL)
|
||||
if err == nil && parsed.Scheme == botAPIRichSentinelScheme && parsed.Host == "time" {
|
||||
unixTime, unixErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
|
||||
flags, ok := botAPIRichDateFlags(parsed.Query().Get("format"))
|
||||
if unixErr == nil && ok {
|
||||
return richbuilder.Date(postProcessBotAPIRichText(value.Text), int(unixTime), flags)
|
||||
}
|
||||
}
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextEmail:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextPhone:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextDate:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func botAPIRichFooterText(text tg.RichTextClass) (tg.RichTextClass, bool) {
|
||||
link, ok := text.(*tg.TextURL)
|
||||
if !ok || link.URL != botAPIRichSentinelScheme+"://footer" {
|
||||
return nil, false
|
||||
}
|
||||
return link.Text, true
|
||||
}
|
||||
|
||||
func botAPIRichDateFlags(format string) (richbuilder.DateFlags, bool) {
|
||||
if format == "r" || format == "R" {
|
||||
return richbuilder.DateFlags{Relative: true}, true
|
||||
}
|
||||
var flags richbuilder.DateFlags
|
||||
if format == "" {
|
||||
return flags, false
|
||||
}
|
||||
for _, value := range format {
|
||||
switch value {
|
||||
case 't':
|
||||
flags.ShortTime = true
|
||||
case 'T':
|
||||
flags.LongTime = true
|
||||
case 'd':
|
||||
flags.ShortDate = true
|
||||
case 'D':
|
||||
flags.LongDate = true
|
||||
case 'w', 'W':
|
||||
flags.DayOfWeek = true
|
||||
default:
|
||||
return richbuilder.DateFlags{}, false
|
||||
}
|
||||
}
|
||||
return flags, true
|
||||
}
|
||||
|
||||
func htmlNodeAttr(node *html.Node, key string) string {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return attribute.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func htmlNodeHasAttr(node *html.Node, key string) bool {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
503
internal/rpc/botapi_rich_projection.go
Normal file
503
internal/rpc/botapi_rich_projection.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func botAPIRichMessageProjection(blocks []tg.PageBlockClass, rtl bool) ([]byte, error) {
|
||||
projected, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(projected) == 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
out := map[string]any{"blocks": projected}
|
||||
if rtl {
|
||||
out["is_rtl"] = true
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func botAPIRichBlocks(blocks []tg.PageBlockClass) ([]any, error) {
|
||||
out := make([]any, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
projected, err := botAPIRichBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projected != nil {
|
||||
out = append(out, projected)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlock(block tg.PageBlockClass) (map[string]any, error) {
|
||||
textBlock := func(kind string, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
heading := func(size int, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": "heading", "text": value, "size": size}, nil
|
||||
}
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
return textBlock("paragraph", value.Text)
|
||||
case *tg.PageBlockTitle:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockSubtitle:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeader:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockSubheader:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockKicker:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockHeading1:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
return heading(4, value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
return heading(5, value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
out, err := textBlock("pre", value.Text)
|
||||
if err == nil && value.Language != "" {
|
||||
out["language"] = value.Language
|
||||
}
|
||||
return out, err
|
||||
case *tg.PageBlockFooter:
|
||||
return textBlock("footer", value.Text)
|
||||
case *tg.PageBlockDivider:
|
||||
return map[string]any{"type": "divider"}, nil
|
||||
case *tg.PageBlockMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.PageBlockAnchor:
|
||||
return map[string]any{"type": "anchor", "name": value.Name}, nil
|
||||
case *tg.PageBlockDetails:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := botAPIRichText(value.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "details", "summary": summary, "blocks": blocks}
|
||||
if value.Open {
|
||||
out["is_open"] = true
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquote:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": []any{map[string]any{"type": "paragraph", "text": text}}}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": blocks}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockPullquote:
|
||||
out, err := textBlock("pullquote", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockList:
|
||||
return botAPIUnorderedRichList(value)
|
||||
case *tg.PageBlockOrderedList:
|
||||
return botAPIOrderedRichList(value)
|
||||
case *tg.PageBlockTable:
|
||||
return botAPIRichTable(value)
|
||||
case *tg.PageBlockCollage:
|
||||
return botAPIRichBlockCollection("collage", value.Items, value.Caption)
|
||||
case *tg.PageBlockSlideshow:
|
||||
return botAPIRichBlockCollection("slideshow", value.Items, value.Caption)
|
||||
case *tg.PageBlockCover:
|
||||
return botAPIRichBlock(value.Cover)
|
||||
case *tg.PageBlockThinking:
|
||||
return textBlock("thinking", value.Text)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIUnorderedRichList(list *tg.PageBlockList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for _, raw := range list.Items {
|
||||
item := map[string]any{"label": "•"}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
if value.Checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if value.Checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
case *tg.PageListItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIOrderedRichList(list *tg.PageBlockOrderedList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for index, raw := range list.Items {
|
||||
item := map[string]any{"label": strconv.Itoa(index + 1)}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIFillOrderedListItem(item map[string]any, label string, value int, kind string, checkbox, checked bool) {
|
||||
if label != "" {
|
||||
item["label"] = label
|
||||
}
|
||||
if value != 0 {
|
||||
item["value"] = value
|
||||
}
|
||||
if kind != "" {
|
||||
item["type"] = kind
|
||||
}
|
||||
if checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichTable(table *tg.PageBlockTable) (map[string]any, error) {
|
||||
rows := make([]any, 0, len(table.Rows))
|
||||
for _, row := range table.Rows {
|
||||
cells := make([]any, 0, len(row.Cells))
|
||||
for _, cell := range row.Cells {
|
||||
item := map[string]any{"align": "left", "valign": "top"}
|
||||
if !botAPIRichTextEmpty(cell.Text) {
|
||||
text, err := botAPIRichText(cell.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["text"] = text
|
||||
}
|
||||
if cell.Header {
|
||||
item["is_header"] = true
|
||||
}
|
||||
if cell.Colspan > 1 {
|
||||
item["colspan"] = cell.Colspan
|
||||
}
|
||||
if cell.Rowspan > 1 {
|
||||
item["rowspan"] = cell.Rowspan
|
||||
}
|
||||
if cell.AlignCenter {
|
||||
item["align"] = "center"
|
||||
} else if cell.AlignRight {
|
||||
item["align"] = "right"
|
||||
}
|
||||
if cell.ValignMiddle {
|
||||
item["valign"] = "middle"
|
||||
} else if cell.ValignBottom {
|
||||
item["valign"] = "bottom"
|
||||
}
|
||||
cells = append(cells, item)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
out := map[string]any{"type": "table", "cells": rows}
|
||||
if table.Bordered {
|
||||
out["is_bordered"] = true
|
||||
}
|
||||
if table.Striped {
|
||||
out["is_striped"] = true
|
||||
}
|
||||
if !botAPIRichTextEmpty(table.Title) {
|
||||
caption, err := botAPIRichText(table.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = caption
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlockCollection(kind string, blocks []tg.PageBlockClass, caption tg.PageCaption) (map[string]any, error) {
|
||||
items, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": kind, "blocks": items}
|
||||
if !botAPIRichTextEmpty(caption.Text) || !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected := map[string]any{}
|
||||
if !botAPIRichTextEmpty(caption.Text) {
|
||||
projected["text"], err = botAPIRichText(caption.Text)
|
||||
}
|
||||
if err == nil && !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected["credit"], err = botAPIRichText(caption.Credit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichText(text tg.RichTextClass) (any, error) {
|
||||
wrapped := func(kind string, child tg.RichTextClass) (any, error) {
|
||||
value, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
valued := func(kind, field, value string, child tg.RichTextClass) (any, error) {
|
||||
out, err := wrapped(kind, child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)[field] = value
|
||||
return out, nil
|
||||
}
|
||||
switch value := text.(type) {
|
||||
case nil, *tg.TextEmpty:
|
||||
return "", nil
|
||||
case *tg.TextPlain:
|
||||
return value.Text, nil
|
||||
case *tg.TextConcat:
|
||||
items := make([]any, 0, len(value.Texts))
|
||||
for _, child := range value.Texts {
|
||||
item, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
case *tg.TextBold:
|
||||
return wrapped("bold", value.Text)
|
||||
case *tg.TextItalic:
|
||||
return wrapped("italic", value.Text)
|
||||
case *tg.TextUnderline:
|
||||
return wrapped("underline", value.Text)
|
||||
case *tg.TextStrike:
|
||||
return wrapped("strikethrough", value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
return wrapped("spoiler", value.Text)
|
||||
case *tg.TextFixed:
|
||||
return wrapped("code", value.Text)
|
||||
case *tg.TextSubscript:
|
||||
return wrapped("subscript", value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
return wrapped("superscript", value.Text)
|
||||
case *tg.TextMarked:
|
||||
return wrapped("marked", value.Text)
|
||||
case *tg.TextDate:
|
||||
out, err := wrapped("date_time", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := out.(map[string]any)
|
||||
item["unix_time"] = value.Date
|
||||
item["date_time_format"] = botAPIRichDateFormat(value)
|
||||
return item, nil
|
||||
case *tg.TextCustomEmoji:
|
||||
return map[string]any{"type": "custom_emoji", "custom_emoji_id": strconv.FormatInt(value.DocumentID, 10), "alternative_text": value.Alt}, nil
|
||||
case *tg.TextMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.TextURL:
|
||||
if strings.HasPrefix(value.URL, "#") {
|
||||
return valued("anchor_link", "anchor_name", strings.TrimPrefix(value.URL, "#"), value.Text)
|
||||
}
|
||||
return valued("url", "url", value.URL, value.Text)
|
||||
case *tg.TextEmail:
|
||||
return valued("email_address", "email_address", value.Email, value.Text)
|
||||
case *tg.TextPhone:
|
||||
return valued("phone_number", "phone_number", value.Phone, value.Text)
|
||||
case *tg.TextBankCard:
|
||||
return valued("bank_card_number", "bank_card_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMention:
|
||||
return valued("mention", "username", strings.TrimPrefix(botAPIRichPlainText(value.Text), "@"), value.Text)
|
||||
case *tg.TextHashtag:
|
||||
return valued("hashtag", "hashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "#"), value.Text)
|
||||
case *tg.TextCashtag:
|
||||
return valued("cashtag", "cashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "$"), value.Text)
|
||||
case *tg.TextBotCommand:
|
||||
return valued("bot_command", "bot_command", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoURL:
|
||||
return valued("url", "url", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoEmail:
|
||||
return valued("email_address", "email_address", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoPhone:
|
||||
return valued("phone_number", "phone_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMentionName:
|
||||
out, err := wrapped("text_mention", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)["user"] = map[string]any{"id": value.UserID, "is_bot": false, "first_name": "User " + strconv.FormatInt(value.UserID, 10)}
|
||||
return out, nil
|
||||
case *tg.TextAnchor:
|
||||
anchor := map[string]any{"type": "anchor", "name": value.Name}
|
||||
if botAPIRichTextEmpty(value.Text) {
|
||||
return anchor, nil
|
||||
}
|
||||
inner, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []any{anchor, inner}, nil
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichDateFormat(date *tg.TextDate) string {
|
||||
if date.Relative {
|
||||
return "r"
|
||||
}
|
||||
var out strings.Builder
|
||||
if date.ShortTime {
|
||||
out.WriteByte('t')
|
||||
}
|
||||
if date.LongTime {
|
||||
out.WriteByte('T')
|
||||
}
|
||||
if date.ShortDate {
|
||||
out.WriteByte('d')
|
||||
}
|
||||
if date.LongDate {
|
||||
out.WriteByte('D')
|
||||
}
|
||||
if date.DayOfWeek {
|
||||
out.WriteByte('w')
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func botAPIRichTextEmpty(text tg.RichTextClass) bool {
|
||||
return text == nil || botAPIRichPlainText(text) == ""
|
||||
}
|
||||
|
||||
func botAPIRichPlainText(text tg.RichTextClass) string {
|
||||
var out strings.Builder
|
||||
var walk func(tg.RichTextClass)
|
||||
walk = func(value tg.RichTextClass) {
|
||||
switch value := value.(type) {
|
||||
case *tg.TextPlain:
|
||||
out.WriteString(value.Text)
|
||||
case *tg.TextConcat:
|
||||
for _, child := range value.Texts {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.TextBold:
|
||||
walk(value.Text)
|
||||
case *tg.TextItalic:
|
||||
walk(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
walk(value.Text)
|
||||
case *tg.TextStrike:
|
||||
walk(value.Text)
|
||||
case *tg.TextFixed:
|
||||
walk(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextMarked:
|
||||
walk(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
walk(value.Text)
|
||||
case *tg.TextURL:
|
||||
walk(value.Text)
|
||||
case *tg.TextEmail:
|
||||
walk(value.Text)
|
||||
case *tg.TextPhone:
|
||||
walk(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
walk(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
walk(value.Text)
|
||||
case *tg.TextDate:
|
||||
walk(value.Text)
|
||||
case *tg.TextCustomEmoji:
|
||||
out.WriteString(value.Alt)
|
||||
}
|
||||
}
|
||||
walk(text)
|
||||
return out.String()
|
||||
}
|
||||
|
|
@ -300,6 +300,9 @@ func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req
|
|||
if err != nil {
|
||||
return domain.BotInlineResults{}, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil {
|
||||
return domain.BotInlineResults{}, replyMarkupErr(err)
|
||||
}
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
return domain.BotInlineResults{}, resultIDDuplicateErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
|||
zap.Int("member_ids", len(memberIDs)),
|
||||
zap.Int64s("member_user_ids", memberIDs),
|
||||
)
|
||||
if len(memberIDs) == 0 {
|
||||
return nil, usersTooFewErr()
|
||||
}
|
||||
createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{
|
||||
CreatorUserID: userID,
|
||||
Title: req.Title,
|
||||
|
|
@ -63,16 +60,25 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
|||
}
|
||||
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
canonicalUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
var inviteUpdates *tg.Updates
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
inviteUpdates = r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache)
|
||||
if inviteUpdates != nil {
|
||||
canonicalUpdates.Updates = append(canonicalUpdates.Updates, inviteUpdates.Updates...)
|
||||
}
|
||||
}
|
||||
updates := canonicalUpdates
|
||||
if createChatNeedsLegacyChat(ctx) {
|
||||
updates = r.tdesktopCreateChatUpdatesWithPeerCache(ctx, userID, responseRes, cache)
|
||||
}
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
inviteUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache)
|
||||
if inviteUpdates != nil {
|
||||
updates.Updates = append(updates.Updates, inviteUpdates.Updates...)
|
||||
}
|
||||
}
|
||||
// The rpc_result reaches only the calling session. Keep the creator's other
|
||||
// sessions in sync with the same canonical channel state; compatibility-only
|
||||
// legacy chat projection is needed solely by the synchronous create callback.
|
||||
r.pushUserUpdates(ctx, userID, canonicalUpdates)
|
||||
if inviteRes.Event.Pts != 0 {
|
||||
r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates {
|
||||
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
|
||||
|
|
|
|||
|
|
@ -594,7 +594,11 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
|
|||
}
|
||||
items := make([]domain.ContactInput, 0, len(input))
|
||||
for _, item := range input {
|
||||
note, entities := contactNote(item.GetNote())
|
||||
rawNote, hasNote := item.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -666,7 +670,11 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if !found {
|
||||
return nil, contactIDInvalidErr()
|
||||
}
|
||||
note, entities := contactNote(req.GetNote())
|
||||
rawNote, hasNote := req.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -682,19 +690,20 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if err != nil {
|
||||
return nil, contactErr(err)
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
|
||||
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
if hasNote {
|
||||
// TDesktop does not copy the submitted note into Data::User after
|
||||
// contacts.addContact. updateUser is the lightweight full-info refresh
|
||||
// signal; the private note itself remains available only from
|
||||
// users.getFullUser for this viewer.
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
}
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -709,6 +718,9 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
|
||||
if hasNote {
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
|
|
@ -736,13 +748,7 @@ func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserCla
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
|
|
@ -838,17 +844,27 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac
|
|||
if !found {
|
||||
return false, contactIDInvalidErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount {
|
||||
return false, limitInvalidErr()
|
||||
note, entities, err := contactNote(userID, req.Note, true)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil {
|
||||
contact, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, note, entities)
|
||||
if err != nil {
|
||||
return false, contactErr(err)
|
||||
}
|
||||
if err := r.recordContactsReset(ctx, userID); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushContactsReset(ctx, userID)
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
if r.hasReliableUpdateDispatch() {
|
||||
// contactsReset is already delivered by the durable outbox. updateUser
|
||||
// is intentionally a transient online refresh hint and must not copy a
|
||||
// private note into the shared update log.
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
} else {
|
||||
r.pushUserUpdates(ctx, userID, r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), true))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -983,11 +999,86 @@ func validContactInput(phone, firstName, lastName, note string, entities int) bo
|
|||
return true
|
||||
}
|
||||
|
||||
func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) {
|
||||
func contactNote(ownerUserID int64, note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity, error) {
|
||||
if !ok {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
return note.Text, domainMessageEntities(note.Entities)
|
||||
if !utf8.ValidString(note.Text) || utf8.RuneCountInString(note.Text) > maxContactNoteLength || len(note.Entities) > maxMessageEntityCount {
|
||||
return "", nil, limitInvalidErr()
|
||||
}
|
||||
limit := utf16CodeUnitLen(note.Text)
|
||||
for _, entity := range note.Entities {
|
||||
if messageEntityClassNil(entity) || !storyCaptionEntitySupported(entity) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
offset, length := entity.GetOffset(), entity.GetLength()
|
||||
if offset < 0 || length <= 0 || offset > limit || length > limit-offset {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
switch typed := entity.(type) {
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
if typed.DocumentID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.MessageEntityMentionName:
|
||||
if typed.UserID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.InputMessageEntityMentionName:
|
||||
if inputUserClassNil(typed.UserID) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
entities := domainMessageEntitiesForViewer(ownerUserID, note.Entities)
|
||||
if len(entities) != len(note.Entities) || !validEphemeralEntityBounds(note.Text, entities) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
return note.Text, entities, nil
|
||||
}
|
||||
|
||||
func contactUserForUpdates(contact domain.Contact) domain.User {
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
// contact.Phone must not overwrite peerUser.Phone here: it bypasses the
|
||||
// phone-number privacy check entirely (fixed upstream PR owpengram/owpengram-server#1 -
|
||||
// exposed the raw number to addContact/acceptContact callers even with
|
||||
// "Nobody" set). peerUser.Phone already carries the privacy-filtered value.
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
return peerUser
|
||||
}
|
||||
|
||||
func (r *Router) contactNoteRefreshUpdates(peerUser domain.User, date int, includeContactsReset bool) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 2)
|
||||
if includeContactsReset {
|
||||
updates = append(updates, &tg.UpdateContactsReset{})
|
||||
}
|
||||
updates = append(updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: []tg.UserClass{r.tgUser(peerUser)},
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
|
||||
// pushContactNoteRefreshIfReliableDispatch complements the durable
|
||||
// contactsReset event. Reliable dispatch already owns the reset, while this
|
||||
// best-effort online nudge makes other loaded TDesktop profiles refetch
|
||||
// users.getFullUser immediately. Offline correctness does not depend on it.
|
||||
func (r *Router) pushContactNoteRefreshIfReliableDispatch(ctx context.Context, userID int64, peerUser domain.User) {
|
||||
if !r.hasReliableUpdateDispatch() || peerUser.ID == 0 {
|
||||
return
|
||||
}
|
||||
r.pushUserMessageTransient(
|
||||
ctx,
|
||||
userID,
|
||||
"push contact note full-user refresh",
|
||||
r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), false),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
appprivacy "telesrv/internal/app/privacy"
|
||||
appstories "telesrv/internal/app/stories"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/userprojection"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
|
|
@ -724,6 +725,202 @@ func TestAccountUpdateProfileRPC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUsersGetFullUserProjectsOwnerScopedContactNoteAcrossCacheUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
rawContacts := memory.NewContactStore()
|
||||
cachedContacts := userprojection.NewCachedContactStore(rawContacts, time.Hour)
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
altOwner, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Alt"})
|
||||
if err != nil {
|
||||
t.Fatalf("create alternate owner: %v", err)
|
||||
}
|
||||
friend, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Friend"})
|
||||
if err != nil {
|
||||
t.Fatalf("create friend: %v", err)
|
||||
}
|
||||
contactsService := appcontacts.NewService(cachedContacts, userStore)
|
||||
usersService := appusers.NewService(userStore, appusers.WithContactStore(cachedContacts))
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{Users: usersService, Contacts: contactsService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
hasUserRefresh := func(updates *tg.Updates, userID int64) bool {
|
||||
t.Helper()
|
||||
if updates == nil {
|
||||
return false
|
||||
}
|
||||
for _, update := range updates.Updates {
|
||||
if changed, ok := update.(*tg.UpdateUser); ok && changed.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
add := &tg.ContactsAddContactRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
FirstName: "Friend",
|
||||
}
|
||||
add.SetNote(tg.TextWithEntities{
|
||||
Text: "owner note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 5}},
|
||||
})
|
||||
addedClass, err := r.onContactsAddContact(WithUserID(ctx, owner.ID), add)
|
||||
if err != nil {
|
||||
t.Fatalf("add owner contact through RPC: %v", err)
|
||||
}
|
||||
added, ok := addedClass.(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(added, friend.ID) {
|
||||
t.Fatalf("add contact updates = %T %+v, want updateUser refresh for note", addedClass, addedClass)
|
||||
}
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("add contact push = %T %+v, want other-session updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
if _, err := contactsService.AddContact(ctx, altOwner.ID, domain.ContactInput{
|
||||
ContactUserID: friend.ID,
|
||||
FirstName: "Friend",
|
||||
Note: "alternate note",
|
||||
}); err != nil {
|
||||
t.Fatalf("add alternate owner contact: %v", err)
|
||||
}
|
||||
getNote := func(viewer domain.User) (tg.TextWithEntities, bool) {
|
||||
t.Helper()
|
||||
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get full user for viewer %d: %v", viewer.ID, err)
|
||||
}
|
||||
return full.FullUser.GetNote()
|
||||
}
|
||||
|
||||
note, ok := getNote(owner)
|
||||
if !ok || note.Text != "owner note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("owner note = %+v present=%v, want owner note with entity", note, ok)
|
||||
}
|
||||
bold, ok := note.Entities[0].(*tg.MessageEntityBold)
|
||||
if !ok || bold.Offset != 0 || bold.Length != 5 {
|
||||
t.Fatalf("owner note entity = %T %+v, want bold 0/5", note.Entities[0], note.Entities[0])
|
||||
}
|
||||
// The large UserFull LRU intentionally excludes private notes; every response
|
||||
// overlays one from the already-loaded viewer contact projection.
|
||||
cachedFull, ok := r.userFullProjectionCache.Lookup(owner.ID, friend.ID)
|
||||
if !ok {
|
||||
t.Fatal("user full projection was not cached")
|
||||
}
|
||||
if cachedNote, present := cachedFull.GetNote(); present {
|
||||
t.Fatalf("cached user full leaked private note: %+v", cachedNote)
|
||||
}
|
||||
// Mutating one response must not leak through the cache or contact snapshot.
|
||||
bold.Length = 99
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Entities[0].(*tg.MessageEntityBold).Length != 5 {
|
||||
t.Fatalf("owner note after response mutation = %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
altNote, ok := getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate owner note = %+v present=%v, want isolated value", altNote, ok)
|
||||
}
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "bad",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 3, Length: 1}},
|
||||
},
|
||||
}); err == nil || ok || !strings.Contains(err.Error(), "ENTITY_BOUNDS_INVALID") {
|
||||
t.Fatalf("invalid contact note ok=%v err=%v, want ENTITY_BOUNDS_INVALID", ok, err)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "owner note" {
|
||||
t.Fatalf("invalid update mutated owner note: %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
updated := &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "fresh note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityItalic{Offset: 0, Length: 5}},
|
||||
},
|
||||
}
|
||||
sessions.clearMessages()
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), updated); err != nil || !ok {
|
||||
t.Fatalf("update contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
pushed, ok = sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("update contact note push = %T %+v, want updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
hasReset := false
|
||||
for _, update := range pushed.Updates {
|
||||
if _, ok := update.(*tg.UpdateContactsReset); ok {
|
||||
hasReset = true
|
||||
}
|
||||
}
|
||||
if !hasReset {
|
||||
t.Fatalf("update contact note push = %+v, want contactsReset for non-reliable dispatch", pushed)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "fresh note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("fresh owner note = %+v present=%v", note, ok)
|
||||
}
|
||||
if _, ok := note.Entities[0].(*tg.MessageEntityItalic); !ok {
|
||||
t.Fatalf("fresh owner note entity = %T, want italic", note.Entities[0])
|
||||
}
|
||||
altNote, ok = getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate note changed with owner update: %+v present=%v", altNote, ok)
|
||||
}
|
||||
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{},
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("clear contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
if note, present := getNote(owner); present {
|
||||
t.Fatalf("cleared contact note still present: %+v", note)
|
||||
}
|
||||
|
||||
// Simulate a write committed by another instance: both the shared contact
|
||||
// snapshot and RPC projection receive the existing contact_account NOTIFY.
|
||||
if _, found, err := rawContacts.UpdateNote(ctx, owner.ID, friend.ID, "remote note", nil); err != nil || !found {
|
||||
t.Fatalf("remote update found=%v err=%v", found, err)
|
||||
}
|
||||
cachedContacts.InvalidateViewers(owner.ID)
|
||||
r.InvalidateRPCProjectionReadModelForViewer(owner.ID)
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "remote note" {
|
||||
t.Fatalf("note after cross-instance invalidation = %+v present=%v", note, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactNoteReliableDispatchPushesOnlyTransientUserRefresh(t *testing.T) {
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{
|
||||
Sessions: sessions,
|
||||
Updates: &captureUpdates{reliableDispatch: true},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
peer := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Friend", Contact: true}
|
||||
|
||||
r.pushContactNoteRefreshIfReliableDispatch(WithUserID(context.Background(), 1000000001), 1000000001, peer)
|
||||
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("contact note refresh = %T, want *tg.Updates", sessions.lastUserPush())
|
||||
}
|
||||
if len(pushed.Updates) != 1 {
|
||||
t.Fatalf("contact note refresh updates = %+v, want one updateUser without duplicate contactsReset", pushed.Updates)
|
||||
}
|
||||
changed, ok := pushed.Updates[0].(*tg.UpdateUser)
|
||||
if !ok || changed.UserID != peer.ID {
|
||||
t.Fatalf("contact note refresh update = %T %+v, want updateUser(%d)", pushed.Updates[0], pushed.Updates[0], peer.ID)
|
||||
}
|
||||
if len(pushed.Users) != 1 || pushed.Users[0].GetID() != peer.ID {
|
||||
t.Fatalf("contact note refresh users = %+v, want peer companion", pushed.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersSavedMusicStubsValidateInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if msg.Action == nil {
|
||||
msg.Action = &tg.MessageActionEmpty{}
|
||||
}
|
||||
if m.SavedPeer.ID != 0 {
|
||||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if reply := tgMessageReplyHeader(domain.Message{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
|
||||
ReplyTo: m.ReplyTo,
|
||||
|
|
@ -140,7 +143,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
if m.Post {
|
||||
if m.SuggestedPost != nil && m.SuggestedPost.Accepted && m.SuggestedPost.Price != nil {
|
||||
switch m.SuggestedPost.Price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
msg.SetPaidSuggestedPostStars(true)
|
||||
case domain.SuggestedPostPriceTON:
|
||||
msg.SetPaidSuggestedPostTon(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
}
|
||||
}
|
||||
if m.PaidMessageStars > 0 {
|
||||
msg.SetPaidMessageStars(m.PaidMessageStars)
|
||||
|
|
@ -303,6 +317,42 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
out.SetCommunityID(action.CommunityID)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostApproval:
|
||||
out := &tg.MessageActionSuggestedPostApproval{
|
||||
Rejected: action.SuggestedPostRejected,
|
||||
BalanceTooLow: action.SuggestedPostBalanceTooLow,
|
||||
}
|
||||
if action.SuggestedPostRejectComment != "" {
|
||||
out.SetRejectComment(action.SuggestedPostRejectComment)
|
||||
}
|
||||
if action.SuggestedPostScheduleDate > 0 {
|
||||
out.SetScheduleDate(action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
out.SetPrice(price)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostSuccess:
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
return &tg.MessageActionSuggestedPostSuccess{Price: price}
|
||||
}
|
||||
return nil
|
||||
case domain.ChannelActionSuggestedPostRefund:
|
||||
return &tg.MessageActionSuggestedPostRefund{PayerInitiated: action.SuggestedPostPayerInitiated}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tgSuggestedPostPrice(price *domain.SuggestedPostPrice) tg.StarsAmountClass {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
return &tg.StarsAmount{Amount: price.Amount, Nanos: price.Nanos}
|
||||
case domain.SuggestedPostPriceTON:
|
||||
return &tg.StarsTonAmount{Amount: price.Amount}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -400,6 +450,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
out := &tg.Channel{
|
||||
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
Verified: ch.Verified,
|
||||
Scam: ch.Scam,
|
||||
Fake: ch.Fake,
|
||||
Gigagroup: ch.Gigagroup,
|
||||
Broadcast: ch.Broadcast,
|
||||
Megagroup: ch.Megagroup,
|
||||
Forum: ch.Forum,
|
||||
|
|
@ -490,6 +543,16 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
return out
|
||||
}
|
||||
|
||||
// channelAboutWithModerationWarning decorates the projected channel/supergroup
|
||||
// About with the scam/fake warning when set (group vs channel wording).
|
||||
func channelAboutWithModerationWarning(ch domain.Channel) string {
|
||||
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
|
||||
if ch.Megagroup && !ch.Broadcast {
|
||||
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
|
||||
}
|
||||
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
|
||||
}
|
||||
|
||||
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
|
||||
ch := view.Channel
|
||||
full := &tg.ChannelFull{
|
||||
|
|
@ -500,7 +563,7 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
|||
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
|
||||
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
|
||||
ID: ch.ID,
|
||||
About: ch.About,
|
||||
About: channelAboutWithModerationWarning(ch),
|
||||
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
|
||||
UnreadCount: view.Dialog.UnreadCount,
|
||||
|
|
|
|||
81
internal/rpc/convert_flags.go
Normal file
81
internal/rpc/convert_flags.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Scam/fake profile warnings surfaced in the full-profile About text.
|
||||
//
|
||||
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
|
||||
// warning paragraph, while iOS/Android show a localized warning. To make the
|
||||
// warning visible on every client, the server injects it into the projected
|
||||
// getFullUser/getFullChannel About field. Injection is non-destructive: the
|
||||
// stored bio/description is never overwritten, only the response is decorated,
|
||||
// so clearing the flag restores the original text and the warning survives the
|
||||
// owner editing their bio/description (it is re-applied from the flag on every
|
||||
// read).
|
||||
//
|
||||
// The text is server-provided (clients cannot localize it). Operators override
|
||||
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
|
||||
// per-peer-type English defaults are used. scam takes precedence over fake.
|
||||
const (
|
||||
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
|
||||
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
|
||||
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
|
||||
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
|
||||
)
|
||||
|
||||
// moderationWarningOverrides holds the operator-configured texts. They are set
|
||||
// once at startup (SetModerationWarnings) before any request is served, and
|
||||
// read on the hot path; atomic.Pointer keeps that race-free without locking.
|
||||
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
|
||||
|
||||
type moderationWarningConfig struct {
|
||||
scam string
|
||||
fake string
|
||||
}
|
||||
|
||||
// SetModerationWarnings installs operator overrides for the scam/fake profile
|
||||
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
|
||||
// override applies to every peer type (user/channel/group).
|
||||
func SetModerationWarnings(scam, fake string) {
|
||||
moderationWarningOverrides.Store(&moderationWarningConfig{
|
||||
scam: strings.TrimSpace(scam),
|
||||
fake: strings.TrimSpace(fake),
|
||||
})
|
||||
}
|
||||
|
||||
func moderationOverride() moderationWarningConfig {
|
||||
if cfg := moderationWarningOverrides.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return moderationWarningConfig{}
|
||||
}
|
||||
|
||||
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
|
||||
// It returns about unchanged when neither flag is set. The operator override
|
||||
// wins over the per-type default; scam wins over fake when both are set.
|
||||
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
|
||||
override := moderationOverride()
|
||||
warning := ""
|
||||
switch {
|
||||
case scam:
|
||||
if warning = override.scam; warning == "" {
|
||||
warning = scamDefault
|
||||
}
|
||||
case fake:
|
||||
if warning = override.fake; warning == "" {
|
||||
warning = fakeDefault
|
||||
}
|
||||
}
|
||||
if warning == "" {
|
||||
return about
|
||||
}
|
||||
if about = strings.TrimSpace(about); about == "" {
|
||||
return warning
|
||||
}
|
||||
return warning + "\n\n" + about
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
|
@ -14,6 +15,9 @@ import (
|
|||
// 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 err := r.prepareTelegramLoginMarkup(ctx, userID, markup); err != nil {
|
||||
return replyMarkupErr(err)
|
||||
}
|
||||
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -30,6 +34,92 @@ func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, p
|
|||
return nil
|
||||
}
|
||||
|
||||
// prepareTelegramLoginMarkup resolves every login_url target and validates its
|
||||
// linked web origin before persistence. It mutates only the freshly parsed
|
||||
// request DTO and assigns a deterministic flattened button id, which is later
|
||||
// re-read by messages.requestUrlAuth.
|
||||
func (r *Router) prepareTelegramLoginMarkup(ctx context.Context, senderBotID int64, markup *domain.MessageReplyMarkup) error {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return nil
|
||||
}
|
||||
hasLoginButton := false
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
if markup.Inline[rowIndex][buttonIndex].Type == domain.MarkupButtonLoginURL {
|
||||
hasLoginButton = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasLoginButton {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLoginButton {
|
||||
return nil
|
||||
}
|
||||
if r == nil || r.deps.TelegramLogin == nil || r.deps.Users == nil || senderBotID <= 0 {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
sender, found, err := r.deps.Users.ByID(ctx, senderBotID, senderBotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !sender.Bot || sender.Deleted {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
flatID := 0
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
button := &markup.Inline[rowIndex][buttonIndex]
|
||||
if button.Type != domain.MarkupButtonLoginURL {
|
||||
flatID++
|
||||
continue
|
||||
}
|
||||
botID := button.LoginBotUserID
|
||||
if button.LoginBotUsername != "" {
|
||||
resolver, ok := r.deps.Users.(UserIdentityService)
|
||||
if !ok {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
bot, found, err := resolver.ResolveUsername(ctx, senderBotID, strings.TrimPrefix(button.LoginBotUsername, "@"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
botID = bot.ID
|
||||
}
|
||||
if botID == 0 {
|
||||
botID = senderBotID
|
||||
}
|
||||
bot, found, err := r.deps.Users.ByID(ctx, senderBotID, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
normalized, _, err := r.deps.TelegramLogin.ValidateMessageButton(ctx, botID, button.URL)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginURLInvalid) || errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed) {
|
||||
return domain.ErrButtonURLInvalid
|
||||
}
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
button.URL = normalized
|
||||
button.LoginBotUserID = botID
|
||||
button.LoginBotUsername = ""
|
||||
button.ButtonID = flatID
|
||||
flatID++
|
||||
}
|
||||
}
|
||||
return domain.ValidateReplyMarkup(markup)
|
||||
}
|
||||
|
||||
// P3 reply_markup 错误码(对齐官方)。
|
||||
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
|
||||
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
|
||||
|
|
@ -175,21 +265,23 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt
|
|||
|
||||
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
|
||||
buttonID := 0
|
||||
for _, row := range inline.Rows {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
|
||||
for _, btn := range row.Buttons {
|
||||
db, err := domainMarkupButton(btn)
|
||||
db, err := domainMarkupButton(btn, buttonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRow = append(domainRow, db)
|
||||
buttonID++
|
||||
}
|
||||
out.Inline = append(out.Inline, domainRow)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
|
|
@ -209,6 +301,26 @@ func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error)
|
|||
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.InputKeyboardButtonURLAuth:
|
||||
botUserID := int64(0)
|
||||
switch bot := b.Bot.(type) {
|
||||
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
|
||||
case *tg.InputUser:
|
||||
botUserID = bot.UserID
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
|
||||
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURLAuth:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: b.ButtonID,
|
||||
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:
|
||||
|
|
@ -322,6 +434,15 @@ func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
|||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonLoginURL:
|
||||
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
|
||||
if btn.ForwardText != "" {
|
||||
out.SetFwdText(btn.ForwardText)
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,26 @@ func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonURLAuth{
|
||||
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
|
||||
}
|
||||
button.SetRequestWriteAccess(true)
|
||||
button.SetFwdText("Open login")
|
||||
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := markup.Inline[0][0]
|
||||
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
|
||||
t.Fatalf("domain login_url = %#v", got)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
|
||||
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
|
||||
t.Fatalf("wire login_url = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
||||
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t
|
|||
if action.DropOriginalDetailsStars > 0 {
|
||||
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
|
||||
}
|
||||
if action.CanCraftAt > 0 {
|
||||
// Channel Craft is not executable yet. Gate on the authoritative gift owner
|
||||
// as a final wire boundary so historical JSON/admin-log actions or a future
|
||||
// constructor cannot accidentally expose Android's Craft entry marker.
|
||||
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件集中 Layer 227 富文本消息(richMessage)的 tg.* ↔ domain 转换。
|
||||
// Phase 1:仅支持 inputRichMessage(blocks 形态);HTML/Markdown 变体(需服务端解析为
|
||||
// PageBlock)尚未实现,直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain(详见
|
||||
// domain.MessageRichMessage)。
|
||||
// 本文件集中 Layer 228 富文本消息(richMessage)的 tg.* ↔ domain 转换。
|
||||
// inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock;
|
||||
// blocks 以 TL 向量序列化为不透明字节存 domain(详见 domain.MessageRichMessage)。
|
||||
|
||||
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。
|
||||
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
|
||||
|
|
@ -127,22 +126,51 @@ func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
|
|||
}
|
||||
|
||||
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
|
||||
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
|
||||
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
|
||||
// HTML/Markdown 先在服务端解析为 PageBlock,再与 blocks 形态共用限额校验、
|
||||
// 序列化和 Bot API 输出投影;内嵌 photos/documents 复用 sendMedia 同款媒体解析。
|
||||
// 返回 nil 表示无富文本载荷。
|
||||
func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) {
|
||||
if input == nil {
|
||||
return nil, nil
|
||||
}
|
||||
in, ok := input.(*tg.InputRichMessage)
|
||||
if !ok {
|
||||
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
|
||||
return nil, mediaInvalidErr()
|
||||
var (
|
||||
in *tg.InputRichMessage
|
||||
sourceParsed bool
|
||||
)
|
||||
switch value := input.(type) {
|
||||
case *tg.InputRichMessage:
|
||||
in = value
|
||||
case *tg.InputRichMessageHTML:
|
||||
if value == nil || value.HTML == "" || len(value.Files) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := parseBotAPIRichHTML(value.HTML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
|
||||
sourceParsed = true
|
||||
case *tg.InputRichMessageMarkdown:
|
||||
if value == nil || value.Markdown == "" || len(value.Files) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := parseBotAPIRichMarkdown(value.Markdown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
|
||||
sourceParsed = true
|
||||
default:
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if len(in.Blocks) == 0 {
|
||||
if len(in.Photos) == 0 && len(in.Documents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, mediaInvalidErr()
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if err := validateRichMessageBlocks(in.Blocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
|
||||
return nil, notImplementedErr()
|
||||
|
|
@ -156,6 +184,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
|
|||
Rtl: in.Rtl,
|
||||
Blocks: blocks,
|
||||
}
|
||||
projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl)
|
||||
if projectionErr != nil && sourceParsed {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if projectionErr == nil {
|
||||
rich.BotAPIProjection = projection
|
||||
}
|
||||
for _, p := range in.Photos {
|
||||
id, ok := inputPhotoID(p)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func tgSelfUser(u domain.User) *tg.User {
|
|||
applyTgUserBotFields(out, u)
|
||||
applyTgUserPremiumFields(out, u)
|
||||
applyTgUserColorFields(out, u)
|
||||
applyTgUserRestrictionFields(out, u)
|
||||
if u.LinkedCommunityID != 0 {
|
||||
out.SetLinkedCommunityID(u.LinkedCommunityID)
|
||||
}
|
||||
|
|
@ -51,6 +52,8 @@ func tgUser(u domain.User) *tg.User {
|
|||
Username: u.Username,
|
||||
Phone: u.Phone,
|
||||
Verified: u.Verified,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
Support: u.Support,
|
||||
Contact: u.Contact,
|
||||
MutualContact: u.Mutual,
|
||||
|
|
@ -60,6 +63,7 @@ func tgUser(u domain.User) *tg.User {
|
|||
applyTgUserBotFields(out, u)
|
||||
applyTgUserPremiumFields(out, u)
|
||||
applyTgUserColorFields(out, u)
|
||||
applyTgUserRestrictionFields(out, u)
|
||||
if u.LinkedCommunityID != 0 {
|
||||
out.SetLinkedCommunityID(u.LinkedCommunityID)
|
||||
}
|
||||
|
|
@ -69,6 +73,28 @@ func tgUser(u domain.User) *tg.User {
|
|||
return out
|
||||
}
|
||||
|
||||
func applyTgUserRestrictionFields(out *tg.User, u domain.User) {
|
||||
if out == nil || len(u.RestrictionReasons) == 0 {
|
||||
return
|
||||
}
|
||||
reasons := make([]tg.RestrictionReason, 0, len(u.RestrictionReasons))
|
||||
for _, reason := range u.RestrictionReasons {
|
||||
if reason.Platform == "" || reason.Reason == "" || reason.Text == "" {
|
||||
continue
|
||||
}
|
||||
reasons = append(reasons, tg.RestrictionReason{
|
||||
Platform: reason.Platform,
|
||||
Reason: reason.Reason,
|
||||
Text: reason.Text,
|
||||
})
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return
|
||||
}
|
||||
out.Restricted = true
|
||||
out.SetRestrictionReason(reasons)
|
||||
}
|
||||
|
||||
// applyTgUserPremiumFields 由到期时间即时派生 premium flag(bit28,独立位)与
|
||||
// emoji status。判断用真实时钟:premium 的权威来源是 premium_expires_at 本身,
|
||||
// 到期即停发,正确性不依赖后台 sweeper(它只负责清理与 updateUser 通知);
|
||||
|
|
|
|||
62
internal/rpc/convert_users_restriction_test.go
Normal file
62
internal/rpc/convert_users_restriction_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestTgUserEncodesFrozenRestriction(t *testing.T) {
|
||||
user := tgUser(domain.User{
|
||||
ID: 1001,
|
||||
FirstName: "Frozen",
|
||||
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
|
||||
})
|
||||
if !user.Restricted {
|
||||
t.Fatal("tg user restricted=false, want true")
|
||||
}
|
||||
reasons, ok := user.GetRestrictionReason()
|
||||
if !ok || len(reasons) != 1 {
|
||||
t.Fatalf("restriction_reason = %+v ok=%v, want one reason", reasons, ok)
|
||||
}
|
||||
if got := reasons[0]; got.Platform != "all" || got.Reason != "frozen" || got.Text != "This account is frozen." {
|
||||
t.Fatalf("restriction_reason = %+v", got)
|
||||
}
|
||||
|
||||
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, user, wire); err != nil {
|
||||
t.Fatalf("encode layer %d frozen user: %v", profile, err)
|
||||
}
|
||||
decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode layer %d frozen user: %v", profile, err)
|
||||
}
|
||||
exact, ok := decoded.(*tg.User)
|
||||
if !ok || !exact.Restricted {
|
||||
t.Fatalf("layer %d user = %#v, want restricted", profile, decoded)
|
||||
}
|
||||
exactReasons, ok := exact.GetRestrictionReason()
|
||||
if !ok || len(exactReasons) != 1 || exactReasons[0].Reason != "frozen" {
|
||||
t.Fatalf("layer %d restriction = %+v ok=%v", profile, exactReasons, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgUserSkipsIncompleteRestriction(t *testing.T) {
|
||||
user := tgUser(domain.User{
|
||||
ID: 1001,
|
||||
FirstName: "Active",
|
||||
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "frozen"}},
|
||||
})
|
||||
if user.Restricted {
|
||||
t.Fatal("incomplete restriction was encoded")
|
||||
}
|
||||
if reasons, ok := user.GetRestrictionReason(); ok || len(reasons) != 0 {
|
||||
t.Fatalf("restriction_reason = %+v ok=%v, want omitted", reasons, ok)
|
||||
}
|
||||
}
|
||||
|
|
@ -253,6 +253,24 @@ type UsersService interface {
|
|||
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
|
||||
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
|
||||
// consent transitions; the RPC layer only projects domain state to TL.
|
||||
type TelegramLoginService interface {
|
||||
ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error)
|
||||
AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error)
|
||||
RequestByDeepLink(ctx context.Context, deepLink string) (domain.TelegramLoginRequest, error)
|
||||
RequestByDeepLinkForOrigin(ctx context.Context, deepLink, inAppOrigin string) (domain.TelegramLoginRequest, error)
|
||||
CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error)
|
||||
Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error)
|
||||
ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
|
||||
RevokeWebAuthorization(ctx context.Context, userID, hash int64) error
|
||||
RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user
|
||||
// (fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer
|
||||
// 与 ByIDs(viewer, ids) 字节等价(personal photo overlay 除外,见 users.ByIDsForViewers)。
|
||||
|
|
@ -885,6 +903,7 @@ type Deps struct {
|
|||
EphemeralPush store.EphemeralPushBroker
|
||||
EphemeralReports store.EphemeralReportStore
|
||||
Users UsersService
|
||||
TelegramLogin TelegramLoginService
|
||||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
|
|
|
|||
51
internal/rpc/deps_validation_test.go
Normal file
51
internal/rpc/deps_validation_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
)
|
||||
|
||||
func TestAssertNoTypedNilDepsRejectsTelegramLogin(t *testing.T) {
|
||||
var service *telegramloginapp.Service
|
||||
defer func() {
|
||||
value := recover()
|
||||
if value == nil {
|
||||
t.Fatal("assertNoTypedNilDeps accepted a typed-nil Telegram Login service")
|
||||
}
|
||||
message := fmt.Sprint(value)
|
||||
if !strings.Contains(message, "dependency TelegramLogin is a typed nil *telegramlogin.Service") {
|
||||
t.Fatalf("panic = %q, want TelegramLogin typed-nil diagnostic", message)
|
||||
}
|
||||
}()
|
||||
New(Config{}, Deps{TelegramLogin: service}, zap.NewNop(), clock.System)
|
||||
}
|
||||
|
||||
func TestAssertNoTypedNilDepsAcceptsAbsentTelegramLogin(t *testing.T) {
|
||||
assertNoTypedNilDeps(Deps{})
|
||||
}
|
||||
|
||||
func TestDisabledTelegramLoginWebAuthorizationRPCs(t *testing.T) {
|
||||
router := New(Config{}, Deps{}, zap.NewNop(), clock.System)
|
||||
ctx := WithUserID(context.Background(), 42)
|
||||
|
||||
listed, err := router.onAccountGetWebAuthorizations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get disabled web authorizations: %v", err)
|
||||
}
|
||||
if len(listed.Authorizations) != 0 || len(listed.Users) != 0 {
|
||||
t.Fatalf("disabled web authorizations = %#v, want empty vectors", listed)
|
||||
}
|
||||
if reset, err := router.onAccountResetWebAuthorization(ctx, 123); err != nil || !reset {
|
||||
t.Fatalf("reset disabled web authorization = %v, %v; want true, nil", reset, err)
|
||||
}
|
||||
if reset, err := router.onAccountResetWebAuthorizations(ctx); err != nil || !reset {
|
||||
t.Fatalf("reset all disabled web authorizations = %v, %v; want true, nil", reset, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,18 @@ func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") }
|
|||
|
||||
func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") }
|
||||
|
||||
func richMessageInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_INVALID") }
|
||||
|
||||
func richMessageTooLongErr() error { return tgerr.New(400, "RICH_MESSAGE_TOO_LONG") }
|
||||
|
||||
func richMessageDateInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_DATE_INVALID") }
|
||||
|
||||
func richMessageMediaUnsupportedErr() error {
|
||||
return tgerr.New(400, "RICH_MESSAGE_MEDIA_UNSUPPORTED")
|
||||
}
|
||||
|
||||
func webpageMediaEmptyErr() error { return tgerr.New(400, "WEBPAGE_MEDIA_EMPTY") }
|
||||
|
||||
func mediaTypeInvalidErr() error { return tgerr.New(400, "MEDIA_TYPE_INVALID") }
|
||||
|
||||
func urlInvalidErr() error { return tgerr.New(400, "URL_INVALID") }
|
||||
|
|
@ -231,8 +243,6 @@ func authTokenExceptionErr() error { return tgerr.New(400, "AUTH_TOKEN_EXCEPTION
|
|||
|
||||
func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") }
|
||||
|
||||
func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") }
|
||||
|
||||
func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") }
|
||||
|
||||
func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *t
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -114,7 +117,23 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message := target.Body
|
||||
entities := append([]domain.MessageEntity(nil), target.Entities...)
|
||||
if rawMessage, ok := req.GetMessage(); ok {
|
||||
richMessage := target.RichMessage
|
||||
setRichMessage := false
|
||||
rawRichMessage, hasRichMessage := req.GetRichMessage()
|
||||
rawMessage, hasMessage := req.GetMessage()
|
||||
if hasMessage && hasRichMessage {
|
||||
return false, mediaInvalidErr()
|
||||
}
|
||||
if hasRichMessage {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return false, richMessageInvalidErr()
|
||||
}
|
||||
message, entities, setRichMessage = "", nil, true
|
||||
} else if hasMessage {
|
||||
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
|
||||
return false, messageEmptyErr()
|
||||
}
|
||||
|
|
@ -127,6 +146,7 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message = rawMessage
|
||||
entities = domainMessageEntitiesForViewer(botID, rawEntities)
|
||||
richMessage, setRichMessage = nil, true
|
||||
} else if req.ReplyMarkup == nil && newMedia == nil {
|
||||
return false, messageNotModifiedErr()
|
||||
}
|
||||
|
|
@ -142,6 +162,11 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
_, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{
|
||||
OwnerUserID: target.OwnerUserID,
|
||||
Peer: target.Peer,
|
||||
|
|
@ -152,6 +177,8 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
EditDate: int(r.clock.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ViaBotEditBotID: botID,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -171,7 +198,23 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
message := target.Body
|
||||
entities := append([]domain.MessageEntity(nil), target.Entities...)
|
||||
var mentionUserIDs []int64
|
||||
if rawMessage, ok := req.GetMessage(); ok {
|
||||
richMessage := target.RichMessage
|
||||
setRichMessage := false
|
||||
rawRichMessage, hasRichMessage := req.GetRichMessage()
|
||||
rawMessage, hasMessage := req.GetMessage()
|
||||
if hasMessage && hasRichMessage {
|
||||
return false, mediaInvalidErr()
|
||||
}
|
||||
if hasRichMessage {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if richMessage.IsZero() {
|
||||
return false, richMessageInvalidErr()
|
||||
}
|
||||
message, entities, setRichMessage = "", nil, true
|
||||
} else if hasMessage {
|
||||
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
|
||||
return false, messageEmptyErr()
|
||||
}
|
||||
|
|
@ -184,6 +227,7 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
}
|
||||
message = rawMessage
|
||||
entities = domainMessageEntitiesForViewer(botID, rawEntities)
|
||||
richMessage, setRichMessage = nil, true
|
||||
var err error
|
||||
mentionUserIDs, err = r.mentionedUserIDsFromMessage(ctx, botID, message, rawEntities)
|
||||
if err != nil {
|
||||
|
|
@ -210,6 +254,11 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{
|
||||
UserID: target.SenderUserID,
|
||||
ChannelID: target.ChannelID,
|
||||
|
|
@ -221,6 +270,8 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
EditDate: int(r.clock.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ViaBotEditBotID: botID,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ func (r *Router) webPagePreviewMedia(ctx context.Context, message string, entiti
|
|||
|
||||
// resolveWebPageForRequest 为交互式读 RPC 解析链接预览:先查缓存(LookupWebPage,命中即返回,
|
||||
// 不抓取不阻塞);未命中才同步抓取,但用受限短预算(webpageRequestResolveBudget)而非异步解析
|
||||
// 的 20s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
|
||||
// 的 30s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
|
||||
// 决定;抓取失败返回 false。未启用返回 false。
|
||||
func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) {
|
||||
if page, ok := r.resolveAIComposeStyleWebPage(ctx, url); ok {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"strings"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appdialogs "telesrv/internal/app/dialogs"
|
||||
appusers "telesrv/internal/app/users"
|
||||
|
|
@ -120,22 +119,198 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesCreateChatRejectsEmptyInviteListRPC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550001021", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phone string
|
||||
users func(domain.User) []tg.InputUserClass
|
||||
}{
|
||||
{
|
||||
name: "empty vector",
|
||||
phone: "15550001021",
|
||||
users: func(domain.User) []tg.InputUserClass { return nil },
|
||||
},
|
||||
{
|
||||
name: "self references normalize to empty",
|
||||
phone: "15550001022",
|
||||
users: func(owner domain.User) []tg.InputUserClass {
|
||||
return []tg.InputUserClass{
|
||||
&tg.InputUserSelf{},
|
||||
&tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
|
||||
&tg.InputUserSelf{},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(memory.NewChannelStore()),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
if _, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
|
||||
Title: "No Invitees",
|
||||
}); err == nil || !strings.Contains(err.Error(), "USERS_TOO_FEW") {
|
||||
t.Fatalf("create chat without users err = %v, want USERS_TOO_FEW", err)
|
||||
for index, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: int64(21 + index), Phone: tc.phone, FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channels,
|
||||
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
authKeyID := [8]byte{0x60, byte(index + 1)}
|
||||
sessionID := int64(70 + index)
|
||||
requestCtx := WithClientInfo(
|
||||
WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), sessionID),
|
||||
ClientInfo{DeviceModel: "Android", AppVersion: "12.7.3"},
|
||||
)
|
||||
invited, err := r.onMessagesCreateChat(requestCtx, &tg.MessagesCreateChatRequest{
|
||||
Users: tc.users(owner),
|
||||
Title: "Owner Only Group",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner-only chat: %v", err)
|
||||
}
|
||||
if len(invited.MissingInvitees) != 0 {
|
||||
t.Fatalf("missing invitees = %+v, want empty", invited.MissingInvitees)
|
||||
}
|
||||
|
||||
updates, ok := invited.Updates.(*tg.Updates)
|
||||
if !ok || len(updates.Chats) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates)
|
||||
}
|
||||
legacy, ok := updates.Chats[0].(*tg.Chat)
|
||||
if !ok || !legacy.Deactivated || !legacy.Creator || legacy.ParticipantsCount != 1 {
|
||||
t.Fatalf("legacy chat = %#v, want migrated creator-only chat", updates.Chats[0])
|
||||
}
|
||||
channel, ok := updates.Chats[1].(*tg.Channel)
|
||||
if !ok || !channel.Megagroup || channel.Broadcast || !channel.Creator || channel.ParticipantsCount != 1 {
|
||||
t.Fatalf("channel = %#v, want owner-only megagroup", updates.Chats[1])
|
||||
}
|
||||
migrated, ok := legacy.GetMigratedTo()
|
||||
if !ok {
|
||||
t.Fatal("legacy chat missing migrated_to")
|
||||
}
|
||||
migratedChannel, ok := migrated.(*tg.InputChannel)
|
||||
if !ok || migratedChannel.ChannelID != channel.ID || migratedChannel.AccessHash != channel.AccessHash {
|
||||
t.Fatalf("migrated_to = %#v, want channel %d/%d", migrated, channel.ID, channel.AccessHash)
|
||||
}
|
||||
if len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates len = %d, want create service message + channel refresh only", len(updates.Updates))
|
||||
}
|
||||
created, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
|
||||
if !ok || created.Pts != 1 || created.PtsCount != 1 {
|
||||
t.Fatalf("create update = %#v, want pts=1/count=1", updates.Updates[0])
|
||||
}
|
||||
createdMessage, ok := created.Message.(*tg.MessageService)
|
||||
if !ok {
|
||||
t.Fatalf("create message = %T, want messageService", created.Message)
|
||||
}
|
||||
if _, ok := createdMessage.Action.(*tg.MessageActionChannelCreate); !ok {
|
||||
t.Fatalf("create action = %T, want messageActionChannelCreate", createdMessage.Action)
|
||||
}
|
||||
if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
|
||||
t.Fatalf("refresh = %#v, want channel %d", updates.Updates[1], channel.ID)
|
||||
}
|
||||
if len(updates.Users) != 1 {
|
||||
t.Fatalf("updates users len = %d, want creator only", len(updates.Users))
|
||||
}
|
||||
if user, ok := updates.Users[0].(*tg.User); !ok || user.ID != owner.ID {
|
||||
t.Fatalf("updates user = %#v, want owner %d", updates.Users[0], owner.ID)
|
||||
}
|
||||
|
||||
pushedUserIDs := sessions.pushedUserIDs()
|
||||
if len(pushedUserIDs) != 1 || pushedUserIDs[0] != owner.ID {
|
||||
t.Fatalf("push user ids = %v, want creator's other sessions", pushedUserIDs)
|
||||
}
|
||||
push := sessions.snapshot()
|
||||
if push.sessionID != sessionID || sessions.scopedAuthKey() != authKeyID {
|
||||
t.Fatalf("push exclusion = auth_key %x session %d, want %x/%d", sessions.scopedAuthKey(), push.sessionID, authKeyID, sessionID)
|
||||
}
|
||||
canonicalPush, ok := sessions.userMessage.(*tg.Updates)
|
||||
if !ok || len(canonicalPush.Chats) != 1 {
|
||||
t.Fatalf("creator push = %T %+v, want canonical channel updates", sessions.userMessage, sessions.userMessage)
|
||||
}
|
||||
if pushedChannel, ok := canonicalPush.Chats[0].(*tg.Channel); !ok || pushedChannel.ID != channel.ID {
|
||||
t.Fatalf("creator pushed chat = %#v, want channel %d", canonicalPush.Chats[0], channel.ID)
|
||||
}
|
||||
|
||||
participants, err := r.onChannelsGetParticipants(requestCtx, &tg.ChannelsGetParticipantsRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Filter: &tg.ChannelParticipantsRecent{},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get participants: %v", err)
|
||||
}
|
||||
participantList, ok := participants.(*tg.ChannelsChannelParticipants)
|
||||
if !ok || participantList.Count != 1 || len(participantList.Participants) != 1 || len(participantList.Users) != 1 {
|
||||
t.Fatalf("participants = %T %+v, want creator only", participants, participants)
|
||||
}
|
||||
if creator, ok := participantList.Participants[0].(*tg.ChannelParticipantCreator); !ok || creator.UserID != owner.ID {
|
||||
t.Fatalf("participant = %#v, want creator %d", participantList.Participants[0], owner.ID)
|
||||
}
|
||||
|
||||
view, err := channels.GetChannel(ctx, owner.ID, channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get created channel: %v", err)
|
||||
}
|
||||
if view.Self.Role != domain.ChannelRoleCreator || view.Self.Status != domain.ChannelMemberActive {
|
||||
t.Fatalf("self membership = %+v, want active creator", view.Self)
|
||||
}
|
||||
if view.Dialog.TopMessageID != createdMessage.ID || view.Dialog.ReadInboxMaxID != createdMessage.ID || view.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("creator dialog = %+v, want creation message %d read", view.Dialog, createdMessage.ID)
|
||||
}
|
||||
|
||||
var dialogsBuffer bin.Buffer
|
||||
if err := (&tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}).Encode(&dialogsBuffer); err != nil {
|
||||
t.Fatalf("encode getDialogs: %v", err)
|
||||
}
|
||||
dialogsResult, err := r.Dispatch(requestCtx, authKeyID, sessionID, &dialogsBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getDialogs: %v", err)
|
||||
}
|
||||
dialogs, ok := dialogsResult.(*tg.MessagesDialogs)
|
||||
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Chats) != 1 || len(dialogs.Messages) != 1 {
|
||||
t.Fatalf("dialogs = %T %+v, want persisted owner-only group", dialogsResult, dialogsResult)
|
||||
}
|
||||
|
||||
var historyBuffer bin.Buffer
|
||||
if err := (&tg.MessagesGetHistoryRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Limit: 20,
|
||||
}).Encode(&historyBuffer); err != nil {
|
||||
t.Fatalf("encode getHistory: %v", err)
|
||||
}
|
||||
historyResult, err := r.Dispatch(requestCtx, authKeyID, sessionID, &historyBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getHistory: %v", err)
|
||||
}
|
||||
history, ok := historyResult.(*tg.MessagesChannelMessages)
|
||||
if !ok || len(history.Messages) != 1 {
|
||||
t.Fatalf("history = %T %+v, want creation service message", historyResult, historyResult)
|
||||
}
|
||||
if message, ok := history.Messages[0].(*tg.MessageService); !ok || message.ID != createdMessage.ID {
|
||||
t.Fatalf("history message = %#v, want creation service %d", history.Messages[0], createdMessage.ID)
|
||||
}
|
||||
|
||||
difference, err := r.onUpdatesGetChannelDifference(requestCtx, &tg.UpdatesGetChannelDifferenceRequest{
|
||||
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Filter: &tg.ChannelMessagesFilterEmpty{},
|
||||
Pts: 0,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getChannelDifference from pts=0: %v", err)
|
||||
}
|
||||
fullDifference, ok := difference.(*tg.UpdatesChannelDifference)
|
||||
if !ok || fullDifference.Pts != 1 || len(fullDifference.NewMessages) != 1 {
|
||||
t.Fatalf("difference = %T %+v, want creation event at pts=1", difference, difference)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,8 +521,8 @@ func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) {
|
|||
sessions.mu.Lock()
|
||||
pushUserIDs := append([]int64(nil), sessions.pushUserIDs...)
|
||||
sessions.mu.Unlock()
|
||||
if len(pushUserIDs) != 1 || pushUserIDs[0] != friend.ID {
|
||||
t.Fatalf("push user ids = %v, want only invited friend %d", pushUserIDs, friend.ID)
|
||||
if len(pushUserIDs) != 2 || pushUserIDs[0] != owner.ID || pushUserIDs[1] != friend.ID {
|
||||
t.Fatalf("push user ids = %v, want creator then invited friend %d/%d", pushUserIDs, owner.ID, friend.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
return nil, richErr
|
||||
}
|
||||
}
|
||||
if hasMessage && hasRichMessage {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
// Explicit text and rich edits are replacement operations. A text edit must
|
||||
// clear a previously stored rich payload; a rich edit already replaces it.
|
||||
replaceRichMessage := hasRichMessage || hasMessage
|
||||
if hasMessage && richMessage == nil {
|
||||
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
|
||||
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
|
||||
|
|
@ -61,7 +67,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, replaceRichMessage, scheduleDate)
|
||||
}
|
||||
if media, ok := req.GetMedia(); ok {
|
||||
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
|
||||
|
|
@ -104,6 +110,11 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
|
|
@ -119,7 +130,9 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SetRichMessage: hasRichMessage,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: replaceRichMessage,
|
||||
RichMessage: richMessage,
|
||||
EditDate: int(r.clock.Now().Unix()),
|
||||
})
|
||||
|
|
@ -154,7 +167,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
OriginSessionID: sessionID,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: hasRichMessage,
|
||||
SetRichMessage: replaceRichMessage,
|
||||
RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// resolveMonoforumForAdmin 解析 parent_peer 指向的 monoforum 虚拟频道,并校验当前用户是其母广播频道
|
||||
// 的管理员/创建者(频道私信只有频道管理员可读/回复)。monoforum 是私有零成员频道,管理员并非其成员,
|
||||
// 故走 store 的 membership-agnostic 解析,在母频道上做授权。
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效频道但非 monoforum 时返回 (零, false, nil),
|
||||
// 由调用方回退良性空响应(兼容对普通频道传 parent_peer 的被动探测);是 monoforum 但非管理员→CHAT_ADMIN_REQUIRED。
|
||||
// resolveMonoforumForAdmin 解析 TDesktop messages.getSavedDialogs/getSavedHistory 的 parent_peer,
|
||||
// 并校验当前用户可管理母广播频道的 Direct Messages。TDesktop 的 SavedSublist 实际会把
|
||||
// parentChat()->input() 作为 parent_peer;根据客户端 materialize 路径,它既可能是 monoforum
|
||||
// 虚拟频道,也可能是与之关联的母广播频道。因此这里把两种 wire peer 归一到同一个 monoforum,
|
||||
// 授权仍只认母频道的 creator / ManageDirectMessages,绝不能把普通 admin 放进管理者视图。
|
||||
//
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效但未关联 Direct Messages 的普通频道时
|
||||
// 返回 (零, false, nil),由调用方保留良性空响应;关联频道的非管理者返回 CHAT_ADMIN_REQUIRED。
|
||||
func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Channel{}, false, notImplementedErr()
|
||||
|
|
@ -22,9 +25,30 @@ func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, par
|
|||
return domain.Channel{}, false, parentPeerInvalidErr()
|
||||
}
|
||||
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID)
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// TDesktop 当前的 Direct Messages subsection 会传母广播频道。只接受显式的
|
||||
// linked_monoforum 关系,不能把任意普通频道猜成 monoforum。
|
||||
views, viewErr := r.deps.Channels.GetChannels(ctx, userID, []int64{parent.ID})
|
||||
if viewErr != nil {
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
if len(views) != 1 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
parentChannel := views[0].Channel
|
||||
if parentChannel.ID != parent.ID || parentChannel.Deleted || parentChannel.Monoforum || parentChannel.LinkedMonoforumID == 0 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
mono, isAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, parentChannel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
// A visible parent that advertises linked_monoforum_id but cannot resolve
|
||||
// that target violates the durable channel-link invariant. Do not disguise
|
||||
// it as an ordinary channel probe.
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// 非 monoforum 频道(或不存在):非错误,交由调用方回退良性空响应。
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return domain.Channel{}, false, internalErr()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
|
||||
// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs/getSavedHistory 看订阅者子会话,parent_peer 同时兼容 TDesktop 实际发送的
|
||||
// 母广播频道和虚拟 monoforum;订阅者经普通 getHistory 只看自己的子会话。
|
||||
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -64,6 +64,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("get monoforum: %v", err)
|
||||
}
|
||||
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
|
||||
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
|
||||
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
|
||||
|
|
@ -129,7 +130,8 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看私信列表。
|
||||
dreq := &tg.MessagesGetSavedDialogsRequest{}
|
||||
dreq.SetParentPeer(monoInput)
|
||||
// TDesktop SavedSublist::loadAround() 的 parentChat()->input() 是母广播频道。
|
||||
dreq.SetParentPeer(parentInput)
|
||||
dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedDialogs(monoforum): %v", err)
|
||||
|
|
@ -160,7 +162,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看某订阅者会话历史。
|
||||
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
hreq.SetParentPeer(monoInput)
|
||||
hreq.SetParentPeer(parentInput)
|
||||
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(monoforum): %v", err)
|
||||
|
|
@ -194,6 +196,18 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID)
|
||||
}
|
||||
|
||||
// 虚拟 monoforum peer 仍是合法的等价入口,两个 parent 不能落到不同数据集。
|
||||
directMonoReq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
directMonoReq.SetParentPeer(monoInput)
|
||||
directMonoRes, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), directMonoReq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(direct monoforum): %v", err)
|
||||
}
|
||||
directMonoSlice, ok := directMonoRes.(*tg.MessagesMessagesSlice)
|
||||
if !ok || len(directMonoSlice.Messages) != 1 {
|
||||
t.Fatalf("getSavedHistory(direct monoforum) = %#v, want same single-message topic", directMonoRes)
|
||||
}
|
||||
|
||||
// 非管理员(订阅者本人)经管理员入口看列表被拒。
|
||||
if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil {
|
||||
t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ import (
|
|||
|
||||
// registerMessages 注册 messages.* RPC handler。
|
||||
func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.MessagesRequestURLAuthRequest](d, tlprofile.SemanticMethodMessagesRequestURLAuth, func(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (any, error) {
|
||||
return r.onMessagesRequestURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesAcceptURLAuthRequest](d, tlprofile.SemanticMethodMessagesAcceptURLAuth, func(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (any, error) {
|
||||
return r.onMessagesAcceptURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesDeclineURLAuthRequest](d, tlprofile.SemanticMethodMessagesDeclineURLAuth, func(ctx context.Context, req *tg.MessagesDeclineURLAuthRequest) (any, error) {
|
||||
return r.onMessagesDeclineURLAuth(ctx, req.URL)
|
||||
})
|
||||
registerRPC[*tg.MessagesCheckURLAuthMatchCodeRequest](d, tlprofile.SemanticMethodMessagesCheckURLAuthMatchCode, func(ctx context.Context, req *tg.MessagesCheckURLAuthMatchCodeRequest) (any, error) {
|
||||
return r.onMessagesCheckURLAuthMatchCode(ctx, req.URL, req.MatchCode)
|
||||
})
|
||||
registerRPC[*tg.MessagesReceivedMessagesRequest](d, tlprofile.SemanticMethodMessagesReceivedMessages, func(ctx context.Context, layerRequest *tg.MessagesReceivedMessagesRequest) (any, error) {
|
||||
return r.onMessagesReceivedMessages(ctx, layerRequest.
|
||||
MaxID)
|
||||
|
|
@ -79,6 +91,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.MessagesSendMessageRequest](d, tlprofile.SemanticMethodMessagesSendMessage, func(ctx context.Context, layerRequest *tg.MessagesSendMessageRequest) (any, error) {
|
||||
return r.onMessagesSendMessage(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesToggleSuggestedPostApprovalRequest](d, tlprofile.SemanticMethodMessagesToggleSuggestedPostApproval, func(ctx context.Context, layerRequest *tg.MessagesToggleSuggestedPostApprovalRequest) (any, error) {
|
||||
return r.onMessagesToggleSuggestedPostApproval(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesForwardMessagesRequest](d, tlprofile.SemanticMethodMessagesForwardMessages, func(ctx context.Context, layerRequest *tg.MessagesForwardMessagesRequest) (any, error) {
|
||||
return r.onMessagesForwardMessages(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
|
|
@ -248,6 +251,108 @@ func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
|
|||
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
|
||||
}
|
||||
|
||||
func TestBotAPIRichHTMLParsesBedolagaMenuStructures(t *testing.T) {
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
|
||||
Rtl: true,
|
||||
Noautolink: true,
|
||||
HTML: `<h4>Admin</h4>
|
||||
<table bordered striped><tr><th>Status</th><td align="right" valign="bottom"><tg-time unix="1700000000" format="R">now</tg-time></td></tr></table>
|
||||
<details open><summary>More</summary><p><blockquote><code>healthy</code></blockquote></p></details>
|
||||
<footer>Choose an option</footer>`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse Bedolaga rich HTML: %v", err)
|
||||
}
|
||||
decoded, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("decode rich HTML: %v", err)
|
||||
}
|
||||
if !decoded.Rtl {
|
||||
t.Fatal("rich HTML lost is_rtl")
|
||||
}
|
||||
var heading, table, details, footer bool
|
||||
for _, block := range decoded.Blocks {
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockHeading4:
|
||||
heading = true
|
||||
case *tg.PageBlockTable:
|
||||
table = true
|
||||
if !value.Bordered || !value.Striped || len(value.Rows) != 1 || len(value.Rows[0].Cells) != 2 {
|
||||
t.Fatalf("table shape = %+v", value)
|
||||
}
|
||||
cell := value.Rows[0].Cells[1]
|
||||
if !cell.AlignRight || !cell.ValignBottom || !richTextContainsDate(cell.Text, 1700000000) {
|
||||
t.Fatalf("table date/alignment = %+v", cell)
|
||||
}
|
||||
case *tg.PageBlockDetails:
|
||||
details = value.Open && len(value.Blocks) != 0
|
||||
case *tg.PageBlockFooter:
|
||||
footer = true
|
||||
}
|
||||
}
|
||||
if !heading || !table || !details || !footer {
|
||||
t.Fatalf("parsed blocks heading=%v table=%v details=%v footer=%v: %#v", heading, table, details, footer, decoded.Blocks)
|
||||
}
|
||||
var projected struct {
|
||||
RTL bool `json:"is_rtl"`
|
||||
Blocks []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"blocks"`
|
||||
}
|
||||
if err := json.Unmarshal(rich.BotAPIProjection, &projected); err != nil {
|
||||
t.Fatalf("decode Bot API projection: %v", err)
|
||||
}
|
||||
if !projected.RTL || len(projected.Blocks) != len(decoded.Blocks) {
|
||||
t.Fatalf("Bot API projection = %s", rich.BotAPIProjection)
|
||||
}
|
||||
|
||||
if _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
|
||||
HTML: `<h4>Admin</h4><img src="https://example.test/logo.png">`,
|
||||
}); err == nil || !tgerr.Is(err, "WEBPAGE_MEDIA_EMPTY") {
|
||||
t.Fatalf("HTML media err = %v, want WEBPAGE_MEDIA_EMPTY for Bedolaga no-logo retry", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMarkdownParsesAndProjects(t *testing.T) {
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageMarkdown{
|
||||
Markdown: "# Bedolaga\n\n**Subscription:** active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse rich Markdown: %v", err)
|
||||
}
|
||||
decoded, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("decode rich Markdown: %v", err)
|
||||
}
|
||||
if len(decoded.Blocks) < 2 || len(rich.BotAPIProjection) == 0 || !strings.Contains(string(rich.BotAPIProjection), "Bedolaga") {
|
||||
t.Fatalf("Markdown decoded=%#v projection=%s", decoded.Blocks, rich.BotAPIProjection)
|
||||
}
|
||||
}
|
||||
|
||||
func richTextContainsDate(text tg.RichTextClass, want int) bool {
|
||||
switch value := text.(type) {
|
||||
case *tg.TextDate:
|
||||
return value.Date == want
|
||||
case *tg.TextConcat:
|
||||
for _, child := range value.Texts {
|
||||
if richTextContainsDate(child, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *tg.TextBold:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextItalic:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextFixed:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextURL:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
|
@ -417,7 +522,7 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich",
|
||||
Message: "",
|
||||
RandomID: 7001,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
|
|
@ -642,7 +747,7 @@ func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich",
|
||||
Message: "",
|
||||
RandomID: 7002,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
|
|
@ -681,7 +786,7 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich+photo",
|
||||
Message: "",
|
||||
RandomID: 7003,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}},
|
||||
|
|
|
|||
|
|
@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
// rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
|
||||
// Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。
|
||||
// rich_message(Layer 228 富文本):blocks、HTML、Markdown 均在边界归一为
|
||||
// PageBlock + 内嵌媒体快照;普通消息恒 nil。
|
||||
var richMessage *domain.MessageRichMessage
|
||||
if req.RichMessage != nil {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
|
||||
|
|
@ -194,6 +194,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
if req.Message != "" && richMessage != nil {
|
||||
sendErr = mediaInvalidErr()
|
||||
return nil, sendErr
|
||||
}
|
||||
if req.Message == "" && richMessage == nil {
|
||||
sendErr = messageEmptyErr()
|
||||
return nil, sendErr
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
|
|
@ -27,6 +28,8 @@ func TestSendMessageAttachesWebPagePending(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
r, owner, friend := newMediaTestRouter(t)
|
||||
r.deps.Files.(*fakeFiles).webPagePreviewOn = true
|
||||
now := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
r.clock = fixedClock{now: now}
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
|
|
@ -55,11 +58,52 @@ func TestSendMessageAttachesWebPagePending(t *testing.T) {
|
|||
if url, _ := pending.GetURL(); url != wpTestURL {
|
||||
t.Errorf("pending url = %q, want %q", url, wpTestURL)
|
||||
}
|
||||
wantDeadline := int(now.Add(webPagePendingLifetime).Unix())
|
||||
if pending.Date != wantDeadline {
|
||||
t.Errorf("pending date = %d, want retry deadline %d", pending.Date, wantDeadline)
|
||||
}
|
||||
if time.Unix(int64(pending.Date), 0).Sub(now) <= webPageResolveTimeout {
|
||||
t.Errorf("pending deadline must outlive resolver timeout: deadline=%s timeout=%s", time.Unix(int64(pending.Date), 0), webPageResolveTimeout)
|
||||
}
|
||||
if !msg.InvertMedia {
|
||||
t.Errorf("invert_media not projected onto message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendChannelMessageUsesSameFutureWebPageDeadline 锁定频道发送也经过同一 pending
|
||||
// 截止时间构造路径,避免只修私聊 echo 而频道仍被客户端立即判定过期。
|
||||
func TestSendChannelMessageUsesSameFutureWebPageDeadline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, channel := newRichChannelTestRouter(t)
|
||||
r.deps.Files.(*fakeFiles).webPagePreviewOn = true
|
||||
// 本测试只验证发送投影,不启动异步解析 goroutine。
|
||||
r.webPageResolveSem = nil
|
||||
now := time.Date(2030, time.February, 3, 4, 5, 6, 0, time.UTC)
|
||||
r.clock = fixedClock{now: now}
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||
Message: wpTestMessage,
|
||||
Entities: wpURLEntities(),
|
||||
RandomID: 5106,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
msg := newMessageFromUpdates(t, updates)
|
||||
wrap, ok := msg.Media.(*tg.MessageMediaWebPage)
|
||||
if !ok {
|
||||
t.Fatalf("channel media = %T, want *tg.MessageMediaWebPage", msg.Media)
|
||||
}
|
||||
pending, ok := wrap.Webpage.(*tg.WebPagePending)
|
||||
if !ok {
|
||||
t.Fatalf("channel webpage = %T, want *tg.WebPagePending", wrap.Webpage)
|
||||
}
|
||||
if want := int(now.Add(webPagePendingLifetime).Unix()); pending.Date != want {
|
||||
t.Errorf("channel pending date = %d, want retry deadline %d", pending.Date, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMessageAttachesCachedDoneCard 验证:URL 已缓存解析时,发送 echo 直接带 done 卡片
|
||||
// (非 pending)——官方行为,TDesktop 据此立即渲染、不依赖异步换卡。
|
||||
func TestSendMessageAttachesCachedDoneCard(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -13,6 +18,71 @@ const (
|
|||
maxSuggestedPostNanoTON int64 = 10_000_000_000_000
|
||||
)
|
||||
|
||||
const (
|
||||
minSuggestedPostScheduleDelay = 5 * 60
|
||||
maxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
||||
maxSuggestedPostRejectComment = 1024
|
||||
)
|
||||
|
||||
type suggestedPostApprovalService interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req *tg.MessagesToggleSuggestedPostApprovalRequest) (tg.UpdatesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if req == nil || req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
comment, hasComment := req.GetRejectComment()
|
||||
if (!req.Reject && hasComment) || utf8.RuneCountInString(comment) > maxSuggestedPostRejectComment {
|
||||
return nil, tgerr400("SUGGESTED_POST_INVALID")
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
service, ok := r.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
scheduleDate, hasScheduleDate := req.GetScheduleDate()
|
||||
if hasScheduleDate && (req.Reject || scheduleDate < now+minSuggestedPostScheduleDelay || scheduleDate > now+maxSuggestedPostScheduleDelay) {
|
||||
return nil, scheduleDateInvalidErr()
|
||||
}
|
||||
result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: userID, MonoforumID: peer.ID, MessageID: req.MsgID, Reject: req.Reject,
|
||||
RejectComment: strings.TrimSpace(comment), ScheduleDate: scheduleDate, Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, suggestedPostApprovalErr(err)
|
||||
}
|
||||
if !result.Duplicate {
|
||||
r.enqueueSuggestedPostApprovalFanout(ctx, userID, result)
|
||||
}
|
||||
return r.suggestedPostApprovalUpdates(ctx, userID, result), nil
|
||||
}
|
||||
|
||||
func suggestedPostApprovalErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrSuggestedPostApprovalForbidden):
|
||||
return tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostAlreadyHandled):
|
||||
return tgerr400("SUGGESTED_POST_ALREADY_HANDLED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostInvalid), errors.Is(err, domain.ErrChannelInvalid), errors.Is(err, domain.ErrMessageIDInvalid):
|
||||
return tgerr400("SUGGESTED_POST_INVALID")
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) {
|
||||
if !present {
|
||||
return nil, nil
|
||||
|
|
@ -30,8 +100,7 @@ func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.Suggeste
|
|||
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 {
|
||||
if value == nil || value.Amount < minSuggestedPostStars || 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}
|
||||
|
|
|
|||
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15551110001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 102, Phone: "15551110002", FirstName: "Subscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelsStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelsStore)
|
||||
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Suggested", Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := channelsStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mono, err := channelsStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 91, Message: "RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
|
||||
req := &tg.MessagesToggleSuggestedPostApprovalRequest{Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash}, MsgID: suggestion.Message.ID}
|
||||
var raw bin.Buffer
|
||||
if err := req.Encode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 228), [8]byte{}, 0, &raw)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch toggleSuggestedPostApproval: %v", err)
|
||||
}
|
||||
updates, ok := response.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("response=%T, want *tg.Updates", response)
|
||||
}
|
||||
var edited, approval, published bool
|
||||
for _, update := range updates.Updates {
|
||||
switch item := update.(type) {
|
||||
case *tg.UpdateEditChannelMessage:
|
||||
message, ok := item.Message.(*tg.Message)
|
||||
if ok && message.ID == suggestion.Message.ID {
|
||||
post, present := message.GetSuggestedPost()
|
||||
edited = present && post.GetAccepted()
|
||||
}
|
||||
case *tg.UpdateNewChannelMessage:
|
||||
switch message := item.Message.(type) {
|
||||
case *tg.MessageService:
|
||||
action, ok := message.Action.(*tg.MessageActionSuggestedPostApproval)
|
||||
if ok {
|
||||
scheduleDate, hasScheduleDate := action.GetScheduleDate()
|
||||
approval = !action.Rejected && !action.BalanceTooLow && hasScheduleDate && scheduleDate > 0
|
||||
}
|
||||
if savedPeer, present := message.GetSavedPeerID(); !present {
|
||||
t.Fatalf("approval service missing saved_peer_id")
|
||||
} else if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != subscriber.ID {
|
||||
t.Fatalf("approval saved_peer=%#v", savedPeer)
|
||||
}
|
||||
case *tg.Message:
|
||||
published = message.PeerID.(*tg.PeerChannel).ChannelID == created.Channel.ID && message.Post && message.Message == "RPC suggestion"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edited || !approval || !published {
|
||||
t.Fatalf("updates missing edit/approval/publish: %#v", updates.Updates)
|
||||
}
|
||||
|
||||
var retryRaw bin.Buffer
|
||||
if err := req.Encode(&retryRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 227), [8]byte{}, 0, &retryRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("layer 227 retry: %v", err)
|
||||
}
|
||||
got, ok := retry.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("layer 227 response=%T", retry)
|
||||
}
|
||||
// Duplicate replay returns the persisted approval + published update to
|
||||
// the caller but is never fanned out again.
|
||||
if len(got.Updates) != 3 {
|
||||
t.Fatalf("layer 227 duplicate updates=%d, want 3", len(got.Updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
|
||||
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
|
||||
proposal := tgChannelMessage(20, original).(*tg.Message)
|
||||
if _, present := proposal.GetSuggestedPost(); !present || proposal.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("proposal flags=%+v", proposal)
|
||||
}
|
||||
published := original
|
||||
published.ChannelID, published.ID, published.Post, published.SavedPeer = 11, 2, true, domain.Peer{}
|
||||
post := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !post.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published Stars post missing paid flag")
|
||||
}
|
||||
if _, present := post.GetSuggestedPost(); present {
|
||||
t.Fatalf("published post leaked suggested_post")
|
||||
}
|
||||
published.SuggestedPost.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: 10_000_000}
|
||||
ton := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !ton.GetPaidSuggestedPostTon() || ton.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published TON flags=%+v", ton)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostApprovalScheduleDateSurvivesExactProfiles(t *testing.T) {
|
||||
action := tgChannelMessageAction(domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval,
|
||||
SuggestedPostScheduleDate: 1_700_000_200,
|
||||
})
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionSuggestedPostApproval)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d approval action = %T", profile, decodedObject)
|
||||
}
|
||||
date, present := decoded.GetScheduleDate()
|
||||
if !present || date != 1_700_000_200 {
|
||||
t.Fatalf("Layer %d approval date=%d/%v, want 1700000200/true", profile, date, present)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
internal/rpc/messages_suggested_post_updates.go
Normal file
65
internal/rpc/messages_suggested_post_updates.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 4)
|
||||
if result.OriginalEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.OriginalEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.ServiceEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.ServiceEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.Published.Event); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
|
||||
}
|
||||
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
|
||||
if result.Parent.ID != 0 {
|
||||
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, 3)
|
||||
if result.OriginalMessage.ID != 0 {
|
||||
messages = append(messages, result.OriginalMessage)
|
||||
}
|
||||
if result.ServiceMessage.ID != 0 {
|
||||
messages = append(messages, result.ServiceMessage)
|
||||
}
|
||||
if result.Published != nil {
|
||||
messages = append(messages, result.Published.Message)
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Chats: chats,
|
||||
Users: r.monoforumSubscriberUsers(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originUserID int64, result domain.ToggleSuggestedPostApprovalResult) {
|
||||
monoOnly := result
|
||||
monoOnly.Published = nil
|
||||
nudge := max(result.OriginalEvent.Pts, result.ServiceEvent.Pts)
|
||||
if nudge > 0 {
|
||||
r.enqueueChannelFanout(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients, func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||||
return r.suggestedPostApprovalUpdates(bgCtx, viewerUserID, monoOnly)
|
||||
})
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
r.enqueueChannelMessageFanout(ctx, originUserID, *result.Published, nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -314,6 +314,9 @@ func (r *Router) onMessagesSendWebViewResultMessage(ctx context.Context, req *tg
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.sendWebViewDomainResultMessage(ctx, botID, req.BotQueryID, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -336,6 +339,9 @@ func (r *Router) AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, b
|
|||
} else if !found {
|
||||
return "", userBotRequiredErr()
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return "", replyMarkupErr(err)
|
||||
}
|
||||
if err := r.sendWebViewDomainResultMessage(ctx, botID, botQueryID, result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -362,6 +368,9 @@ func (r *Router) SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID,
|
|||
} else if !found {
|
||||
return "", 0, userIDInvalidErr()
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return "", 0, replyMarkupErr(err)
|
||||
}
|
||||
id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, userID, result, peerTypes)
|
||||
return id, expireDate, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1007,9 +1007,11 @@ func starGiftLifecycleErr(err error) error {
|
|||
return tgerr.New(400, "STARGIFT_OWNER_INVALID")
|
||||
case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable):
|
||||
return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE")
|
||||
case errors.Is(err, domain.ErrStarGiftCraftUnavailable):
|
||||
return tgerr.New(400, "STARGIFT_CRAFT_UNAVAILABLE")
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable):
|
||||
return starGiftInvalidErr()
|
||||
|
|
|
|||
|
|
@ -269,9 +269,9 @@ func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, p
|
|||
var updates *tg.Updates
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
|
||||
_, 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, upgradeStars)
|
||||
_, updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
|
||||
default:
|
||||
err = domain.ErrStarGiftInvalid
|
||||
}
|
||||
|
|
@ -363,19 +363,19 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
|
|||
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
|
||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *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
|
||||
return domain.SavedStarGiftRef{}, nil, err
|
||||
}
|
||||
prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
|
||||
}
|
||||
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
|
||||
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.SavedStarGiftRef{}, nil, err
|
||||
}
|
||||
// 3. 记账:收礼人收到一份礼物实例(msg_id = 收礼人侧消息 id)。
|
||||
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
|
|
@ -392,17 +392,18 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
|
|||
PrepaidUpgradeHash: prepaidUpgradeHash,
|
||||
Message: message,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
return domain.SavedStarGiftRef{}, nil, err
|
||||
}
|
||||
// 收礼人 stargifts_count 变化 → 失效其 userFull 投影,资料页 Gifts 区段才会出现。
|
||||
r.invalidateRPCProjectionForUser(recipientID)
|
||||
|
||||
ref := domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, MsgID: send.RecipientMessage.ID}
|
||||
users := r.usersForMessageUpdate(ctx, senderID, send.SenderMessage)
|
||||
chats := r.chatsForMessageUpdate(ctx, senderID, send.SenderMessage)
|
||||
return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
|
||||
return ref, 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, prepaidUpgradeStars int64) (*tg.Updates, error) {
|
||||
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
|
||||
now := int(r.clock.Now().Unix())
|
||||
sticker := gift.Sticker
|
||||
action := domain.ChannelMessageAction{
|
||||
|
|
@ -438,7 +439,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
Message: message,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.SavedStarGiftRef{}, nil, err
|
||||
}
|
||||
action.StarGift.PeerChannelID = channelID
|
||||
action.StarGift.SavedID = savedID
|
||||
|
|
@ -451,7 +452,8 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
)
|
||||
}
|
||||
r.invalidateRPCProjectionForChannel(channelID)
|
||||
return nil, nil
|
||||
ref := domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, SavedID: savedID}
|
||||
return ref, nil, nil
|
||||
}
|
||||
|
||||
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。
|
||||
|
|
@ -521,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
|
|||
if r.deps.Gifts == nil {
|
||||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
// Gifts hidden from the profile (unsaved) are visible only to the owner (or a
|
||||
// channel admin). Never trust the client's exclude_unsaved flag for other
|
||||
// viewers: force-exclude hidden gifts unless the requester manages the owner.
|
||||
excludeUnsaved := req.ExcludeUnsaved
|
||||
if r.ensureCanManageStarGiftOwner(ctx, userID, owner) != nil {
|
||||
excludeUnsaved = true
|
||||
}
|
||||
collectionID, _ := req.GetCollectionID()
|
||||
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner,
|
||||
ExcludeUnsaved: req.ExcludeUnsaved,
|
||||
ExcludeUnsaved: excludeUnsaved,
|
||||
ExcludeSaved: req.ExcludeSaved,
|
||||
ExcludeUnlimited: req.ExcludeUnlimited,
|
||||
ExcludeUnique: req.ExcludeUnique,
|
||||
|
|
@ -554,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
gifts := make([]domain.SavedStarGift, 0, len(refs))
|
||||
// A gift hidden from the profile (unsaved) is visible only to the owner or a
|
||||
// channel admin. Memoize the manage check per owner to avoid repeat lookups.
|
||||
manageCache := make(map[domain.Peer]bool)
|
||||
canManageOwner := func(owner domain.Peer) bool {
|
||||
if v, ok := manageCache[owner]; ok {
|
||||
return v
|
||||
}
|
||||
v := r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil
|
||||
manageCache[owner] = v
|
||||
return v
|
||||
}
|
||||
for _, ref := range refs {
|
||||
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
||||
if err != nil {
|
||||
|
|
@ -567,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return nil, internalErr()
|
||||
}
|
||||
if found && !g.Converted {
|
||||
if g.Unsaved && !canManageOwner(g.Owner) {
|
||||
continue
|
||||
}
|
||||
gifts = append(gifts, g)
|
||||
}
|
||||
}
|
||||
|
|
@ -667,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
|||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound):
|
||||
return false, starGiftInvalidErr()
|
||||
case errors.Is(err, domain.ErrStarGiftAlreadyConverted):
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
|
||||
errors.Is(err, domain.ErrStarGiftOwnerInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftUnavailable):
|
||||
// These are known business conditions (e.g. converting an already
|
||||
// upgraded/unique gift). Surface a clean client error instead of a
|
||||
// 500 INTERNAL_SERVER_ERROR.
|
||||
return false, starGiftInvalidErr()
|
||||
default:
|
||||
return false, internalErr()
|
||||
|
|
@ -1071,6 +1099,27 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
|||
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
|
||||
}
|
||||
}
|
||||
if g.CanExportAt > 0 {
|
||||
item.SetCanExportAt(g.CanExportAt)
|
||||
}
|
||||
if g.TransferStars > 0 {
|
||||
item.SetTransferStars(g.TransferStars)
|
||||
}
|
||||
if g.CanTransferAt > 0 {
|
||||
item.SetCanTransferAt(g.CanTransferAt)
|
||||
}
|
||||
if g.CanResellAt > 0 {
|
||||
item.SetCanResellAt(g.CanResellAt)
|
||||
}
|
||||
if g.DropOriginalDetailsStars > 0 {
|
||||
item.SetDropOriginalDetailsStars(g.DropOriginalDetailsStars)
|
||||
}
|
||||
// Channel Craft execution is not implemented yet. Android uses this field
|
||||
// as the entry/capability marker, so only advertise the currently
|
||||
// executable user-owned path while retaining the durable DB entitlement.
|
||||
if g.Owner.Type == domain.PeerTypeUser && g.CanCraftAt > 0 {
|
||||
item.SetCanCraftAt(g.CanCraftAt)
|
||||
}
|
||||
if g.PinnedOrder > 0 {
|
||||
item.PinnedToTop = true
|
||||
}
|
||||
|
|
|
|||
98
internal/rpc/payments_star_gifts_admin.go
Normal file
98
internal/rpc/payments_star_gifts_admin.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type adminUniqueStarGiftGranter interface {
|
||||
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
|
||||
}
|
||||
|
||||
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
|
||||
// grant.SenderID without charging any Stars. It powers the admin console "Give
|
||||
// gift" action: the gift is loaded from the catalog and delivered through the
|
||||
// exact same path a paid send uses (messageActionStarGift service message for
|
||||
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
|
||||
//
|
||||
// SenderID must be zero or the official system account (777000). When Upgrade
|
||||
// is true, the store assigns a genuine collectible directly in the same
|
||||
// transaction as its service message and durable updates. The optional
|
||||
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
|
||||
// collectible facts (0 => random; number is always sequential). Upgraded
|
||||
// delivery is supported for user recipients only.
|
||||
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
|
||||
senderID := grant.SenderID
|
||||
if senderID <= 0 {
|
||||
senderID = domain.OfficialSystemUserID
|
||||
}
|
||||
if senderID != domain.OfficialSystemUserID {
|
||||
return fmt.Errorf("gift sender must be the official system account")
|
||||
}
|
||||
if grant.GiftID <= 0 {
|
||||
return fmt.Errorf("gift_id is required")
|
||||
}
|
||||
if grant.Recipient.ID <= 0 {
|
||||
return fmt.Errorf("recipient is required")
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return fmt.Errorf("gifts dependency is not configured")
|
||||
}
|
||||
gift, ok, err := r.deps.Gifts.GiftByID(ctx, grant.GiftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("gift %d not found", grant.GiftID)
|
||||
}
|
||||
if grant.Upgrade {
|
||||
return r.adminGrantUpgradedStarGift(ctx, senderID, gift, grant)
|
||||
}
|
||||
switch grant.Recipient.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, _, err = r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, _, err = r.sendStarGiftToChannel(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported recipient peer type %q", grant.Recipient.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// adminGrantUpgradedStarGift assigns a collectible through the atomic store
|
||||
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
|
||||
// outbox event behind.
|
||||
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
|
||||
if grant.Recipient.Type != domain.PeerTypeUser {
|
||||
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, gift.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 {
|
||||
return fmt.Errorf("gift %d has no published collectible upgrade", gift.ID)
|
||||
}
|
||||
if preview.Issued >= preview.SupplyTotal {
|
||||
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
|
||||
}
|
||||
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
|
||||
if !ok {
|
||||
return fmt.Errorf("atomic collectible grant is not configured")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grant.SenderID = senderID
|
||||
grant.Date = int(r.clock.Now().Unix())
|
||||
grant.RecipientBlocked = recipientBlocked
|
||||
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
||||
return err
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(grant.Recipient)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedSt
|
|||
}
|
||||
continue
|
||||
}
|
||||
if saved.MsgID == ref.MsgID {
|
||||
if saved.MsgID == ref.MsgID || saved.UpgradeMsgID == ref.MsgID {
|
||||
return saved, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -181,11 +181,12 @@ func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
|
|||
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
|
||||
}
|
||||
|
||||
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
updates, 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)
|
||||
if err != nil || updates == nil || service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50" ||
|
||||
len(service.craftReq.Refs) != 1 || service.craftReq.Refs[0].MsgID != 116 {
|
||||
t.Fatalf("upgrade message alias craft: updates=%T req=%+v err=%v calls=%d", updates, service.craftReq, err, service.craftCall)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +315,162 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
}
|
||||
}
|
||||
|
||||
func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
||||
const (
|
||||
giftID = int64(8001)
|
||||
revision = int64(9001)
|
||||
readyAt = 1_780_000_123
|
||||
exportAt = 1_780_000_200
|
||||
transferAt = 1_780_000_300
|
||||
resellAt = 1_780_000_400
|
||||
)
|
||||
unique := domain.UniqueStarGift{ID: 9901, GiftID: giftID, Title: "Craftable", Slug: "craftable-1", Num: 1,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, CraftChancePermille: 250}
|
||||
saved := domain.SavedStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 44, Date: 100, UniqueGiftID: unique.ID, Unique: &unique,
|
||||
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
|
||||
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
|
||||
}
|
||||
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("saved lifecycle projection count = %d", len(projected))
|
||||
}
|
||||
assertLifecycle := func(t *testing.T, item tg.SavedStarGift) {
|
||||
t.Helper()
|
||||
if value, ok := item.GetCanExportAt(); !ok || value != exportAt {
|
||||
t.Fatalf("can_export_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetTransferStars(); !ok || value != 25 {
|
||||
t.Fatalf("transfer_stars = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanTransferAt(); !ok || value != transferAt {
|
||||
t.Fatalf("can_transfer_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanResellAt(); !ok || value != resellAt {
|
||||
t.Fatalf("can_resell_at = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetDropOriginalDetailsStars(); !ok || value != 30 {
|
||||
t.Fatalf("drop_original_details_stars = %d set=%v", value, ok)
|
||||
}
|
||||
if value, ok := item.GetCanCraftAt(); !ok || value != readyAt {
|
||||
t.Fatalf("can_craft_at = %d set=%v", value, ok)
|
||||
}
|
||||
}
|
||||
assertLifecycle(t, projected[0])
|
||||
zero := tgSavedStarGifts([]domain.SavedStarGift{{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
|
||||
}}, nil, nil)[0]
|
||||
if _, ok := zero.GetCanExportAt(); ok {
|
||||
t.Fatal("zero can_export_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetTransferStars(); ok {
|
||||
t.Fatal("zero transfer_stars must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanTransferAt(); ok {
|
||||
t.Fatal("zero can_transfer_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanResellAt(); ok {
|
||||
t.Fatal("zero can_resell_at must be absent")
|
||||
}
|
||||
if _, ok := zero.GetDropOriginalDetailsStars(); ok {
|
||||
t.Fatal("zero drop_original_details_stars must be absent")
|
||||
}
|
||||
if _, ok := zero.GetCanCraftAt(); ok {
|
||||
t.Fatal("zero can_craft_at must be absent")
|
||||
}
|
||||
channelSaved := saved
|
||||
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
|
||||
channelSaved.MsgID = 0
|
||||
channelSaved.SavedID = 51
|
||||
channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0]
|
||||
if _, ok := channelProjected.GetCanCraftAt(); ok {
|
||||
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
|
||||
}
|
||||
if value, ok := channelProjected.GetSavedID(); !ok || value != channelSaved.SavedID {
|
||||
t.Fatalf("channel saved_id = %d set=%v", value, ok)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||||
encoded := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, wire, encoded); err != nil {
|
||||
t.Fatalf("encode Layer %d saved lifecycle: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d saved lifecycle: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.PaymentsSavedStarGifts)
|
||||
if !ok || len(decoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d saved lifecycle type = %T", profile, decodedObject)
|
||||
}
|
||||
assertLifecycle(t, decoded.Gifts[0])
|
||||
|
||||
channelWire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: []tg.SavedStarGift{channelProjected}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||||
channelEncoded := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, channelWire, channelEncoded); err != nil {
|
||||
t.Fatalf("encode Layer %d channel saved lifecycle: %v", profile, err)
|
||||
}
|
||||
channelDecodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: channelEncoded.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d channel saved lifecycle: %v", profile, err)
|
||||
}
|
||||
channelDecoded, ok := channelDecodedObject.(*tg.PaymentsSavedStarGifts)
|
||||
if !ok || len(channelDecoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d channel saved lifecycle type = %T", profile, channelDecodedObject)
|
||||
}
|
||||
if _, ok := channelDecoded.Gifts[0].GetCanCraftAt(); ok {
|
||||
t.Fatalf("Layer %d channel saved gift exposed can_craft_at", profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelUniqueActionSuppressesCraftReadinessAcrossProfiles(t *testing.T) {
|
||||
const readyAt = 1_780_000_123
|
||||
unique := domain.UniqueStarGift{
|
||||
ID: 9902, GiftID: 8002, Title: "Channel Craftable", Slug: "channel-craftable-1", Num: 1,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}, CraftChancePermille: 250,
|
||||
}
|
||||
action := tgMessageActionStarGiftUnique(&domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, Peer: unique.Owner, SavedID: 52, Saved: true, CanCraftAt: readyAt,
|
||||
}).(*tg.MessageActionStarGiftUnique)
|
||||
if _, ok := action.GetCanCraftAt(); ok {
|
||||
t.Fatal("channel unique action must not expose can_craft_at")
|
||||
}
|
||||
projectedGift, ok := action.Gift.(*tg.StarGiftUnique)
|
||||
if !ok || projectedGift.CraftChancePermille != unique.CraftChancePermille {
|
||||
t.Fatalf("channel unique gift lost intrinsic Craft chance: %#v", action.Gift)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d channel unique action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d channel unique action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionStarGiftUnique)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d channel unique action type = %T", profile, decodedObject)
|
||||
}
|
||||
if _, ok := decoded.GetCanCraftAt(); ok {
|
||||
t.Fatalf("Layer %d channel unique action exposed can_craft_at", profile)
|
||||
}
|
||||
gift, ok := decoded.Gift.(*tg.StarGiftUnique)
|
||||
if !ok || gift.CraftChancePermille != unique.CraftChancePermille {
|
||||
t.Fatalf("Layer %d channel unique gift = %#v", profile, decoded.Gift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftLifecycleCraftUnavailableError(t *testing.T) {
|
||||
if err := starGiftLifecycleErr(domain.ErrStarGiftCraftUnavailable); !tgerr.Is(err, "STARGIFT_CRAFT_UNAVAILABLE") {
|
||||
t.Fatalf("craft unavailable mapping = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
|
||||
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
|
||||
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,
|
||||
|
|
@ -437,16 +594,19 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
t.Fatalf("gift service = %T", r.deps.Gifts)
|
||||
}
|
||||
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
|
||||
modelTwo := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8104, "Aurora Two")
|
||||
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora")
|
||||
crafted.Crafted = true
|
||||
crafted.RarityKind = domain.StarGiftRarityLegendary
|
||||
crafted.RarityPermille = 0
|
||||
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
|
||||
patternTwo := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8105, "Orbit Two")
|
||||
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
|
||||
backdropTwo := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Daylight")
|
||||
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
|
||||
Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
|
||||
Models: []domain.StarGiftCollectibleAttribute{model, crafted, modelTwo}, Patterns: []domain.StarGiftCollectibleAttribute{pattern, patternTwo},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop, backdropTwo}, Actor: "test", CommandID: "collectible-rpc",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish collectible pool: %v", err)
|
||||
}
|
||||
|
|
@ -458,11 +618,11 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
}
|
||||
|
||||
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ownerCtx, gift.ID)
|
||||
if err != nil || len(preview.SampleAttributes) != 3 {
|
||||
if err != nil || len(preview.SampleAttributes) != 6 {
|
||||
t.Fatalf("upgrade preview = %#v err %v", preview, err)
|
||||
}
|
||||
attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID)
|
||||
if err != nil || len(attributes.Attributes) != 4 {
|
||||
if err != nil || len(attributes.Attributes) != 7 {
|
||||
t.Fatalf("upgrade attributes = %#v err %v", attributes, err)
|
||||
}
|
||||
craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel)
|
||||
|
|
@ -847,10 +1007,19 @@ func TestStarGiftChannelSaga(t *testing.T) {
|
|||
giftService := r.deps.Gifts.(*appstargifts.Service)
|
||||
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 10, SlugPrefix: "channel-cake",
|
||||
Models: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8201, "Aurora")},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8202, "Orbit")},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Midnight")},
|
||||
Actor: "test", CommandID: "channel-collectible-rpc",
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8201, "Aurora"),
|
||||
collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8204, "Aurora Two"),
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8202, "Orbit"),
|
||||
collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8205, "Orbit Two"),
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Midnight"),
|
||||
collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 3, "Daylight"),
|
||||
},
|
||||
Actor: "test", CommandID: "channel-collectible-rpc",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish channel collectible pool: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,27 +48,38 @@ func (r *Router) pushPhoneCall(ctx context.Context, targetUserID int64, call dom
|
|||
}
|
||||
|
||||
// pushPhoneCallToDevice 只把 phoneCall 状态推给【发起呼叫的那台设备】(originating
|
||||
// session),不广播到该用户的其它会话。
|
||||
// session),不广播到该用户的其它会话,且是 fail-closed 路径:目标锚点缺失、session
|
||||
// 已断开或编码/发送失败时都不得回退为 user 级广播。
|
||||
//
|
||||
// ⚠ 为什么必须定向:一个「呼出」通话只属于发起它的那台设备。若把呼叫方视角的
|
||||
// phoneCallWaiting(receive_date) 广播到该账号的所有会话,而【同一账号又登录在被叫
|
||||
// 的那台手机上】(多账号同机),手机上这份呼叫方副本会收到 phoneCallWaiting——它与
|
||||
// 来电是同一个 call_id,于是覆写 VoIPService.callIShouldHavePutIntoIntent 这个
|
||||
// 【静态全局】pending 来电(stock DrKLO:MessagesController 只按 call.id 匹配、不校验
|
||||
// 账号),把带 g_a_hash 的 phoneCallRequested 换成不含 g_a_hash 的 phoneCallWaiting。
|
||||
// 被叫接听后 SHA256(g_a)!=g_a_hash → 「Ga hash doesn't match」→ callFailed → 一接就断。
|
||||
// 定向到 CallerDevice 后,手机上的呼叫方副本收不到该更新,pending 来电不被污染。
|
||||
// See memory: call-ga-hash-multiaccount-clobber。
|
||||
// ⚠ 为什么必须定向、且不能回退广播:一个「呼出」通话只属于发起它的那台设备。若把
|
||||
// 呼叫方视角的 phoneCallWaiting(receive_date) 广播到该账号的所有会话,而【同一账号
|
||||
// 又登录在被叫的那台手机上】(多账号同机),手机上这份呼叫方副本会收到
|
||||
// phoneCallWaiting——它与来电是同一个 call_id,于是覆写
|
||||
// VoIPService.callIShouldHavePutIntoIntent 这个【静态全局】pending 来电(stock
|
||||
// DrKLO:MessagesController 只按 call.id 匹配、不校验账号),把带 g_a_hash 的
|
||||
// phoneCallRequested 换成不含 g_a_hash 的 phoneCallWaiting。被叫接听后
|
||||
// SHA256(g_a)!=g_a_hash → 「Ga hash doesn't match」→ callFailed → 一接就断。定向到
|
||||
// CallerDevice 后,手机上的呼叫方副本收不到该更新,pending 来电不被污染。早前版本在
|
||||
// 定向失败时回退广播,看似"正常路径不会走到",但那正是本条目要防的那类多账号同机场景
|
||||
// ——回退广播会重新引入污染,因此改为纯粹跳过。See memory: call-ga-hash-multiaccount-clobber。
|
||||
//
|
||||
// 呼叫方发起会话在 requestCall 时已发过 RPC、必然 ready;PushToSessionForAuthKey 对
|
||||
// 暂未 ready 的会话也会入队补发,不丢。CallerDevice 未知(理论上不会)时回退广播。
|
||||
// 暂未 ready 的会话也会入队补发,不丢。
|
||||
func (r *Router) pushPhoneCallToDevice(ctx context.Context, targetUserID int64, device domain.SessionRef, call domain.PhoneCall, logMessage string) {
|
||||
if device.Zero() || r.deps.Sessions == nil {
|
||||
r.pushPhoneCall(ctx, targetUserID, call, logMessage)
|
||||
if targetUserID == 0 || device.Zero() || r.deps.Sessions == nil {
|
||||
if r.log != nil {
|
||||
r.log.Debug(logMessage,
|
||||
zap.Int64("target_user_id", targetUserID),
|
||||
zap.Int64("call_id", call.ID),
|
||||
zap.Int64("target_session_id", device.SessionID),
|
||||
zap.String("delivery", "skipped_invalid_device_anchor"),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
upd := r.phoneCallUpdates(ctx, call, targetUserID)
|
||||
err := r.deps.Sessions.PushToSessionForAuthKey(ctx, device.RawAuthKeyID, device.SessionID, proto.MessageFromServer, upd)
|
||||
|
||||
updates := r.phoneCallUpdates(ctx, call, targetUserID)
|
||||
err := r.deps.Sessions.PushToSessionForAuthKey(ctx, device.RawAuthKeyID, device.SessionID, proto.MessageFromServer, updates)
|
||||
if r.log != nil {
|
||||
r.log.Info("push phoneCall to device",
|
||||
zap.String("stage", logMessage),
|
||||
|
|
@ -79,10 +90,6 @@ func (r *Router) pushPhoneCallToDevice(ctx context.Context, targetUserID int64,
|
|||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
// 定向失败(会话已不存在)才回退广播——正常路径不会走到,故不会重新引入污染。
|
||||
r.pushPhoneCall(ctx, targetUserID, call, logMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// pushPhoneCallStopRinging 向被叫其它设备推合成 phoneCallDiscarded 停振铃(P0-1 修正)。
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
// phonePushRecord 记录一次定向推送(目标用户、被排除的 session、载荷)。
|
||||
type phonePushRecord struct {
|
||||
userID int64
|
||||
rawAuthKeyID [8]byte
|
||||
targetSession int64
|
||||
excludeSession int64
|
||||
msg bin.Encoder
|
||||
|
|
@ -32,8 +33,9 @@ type phonePushRecord struct {
|
|||
|
||||
// phoneCaptureSessions 是带完整推送日志的 SessionBinder fake(captureSessions 只留最后一条)。
|
||||
type phoneCaptureSessions struct {
|
||||
mu sync.Mutex
|
||||
log []phonePushRecord
|
||||
mu sync.Mutex
|
||||
log []phonePushRecord
|
||||
pushErr error
|
||||
}
|
||||
|
||||
func (s *phoneCaptureSessions) BindAuthKeyForSession([8]byte, int64, [8]byte) {}
|
||||
|
|
@ -47,11 +49,11 @@ func (s *phoneCaptureSessions) UserIDResolvedForAuthKey([8]byte, int64) (int64,
|
|||
func (s *phoneCaptureSessions) UnbindAuthKey([8]byte) int { return 0 }
|
||||
func (s *phoneCaptureSessions) SetReceivesUpdatesForAuthKey([8]byte, int64, bool) {}
|
||||
|
||||
func (s *phoneCaptureSessions) PushToSessionForAuthKey(_ context.Context, _ [8]byte, sessionID int64, _ proto.MessageType, msg tg.UpdatesClass) error {
|
||||
func (s *phoneCaptureSessions) PushToSessionForAuthKey(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, _ proto.MessageType, msg tg.UpdatesClass) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.log = append(s.log, phonePushRecord{targetSession: sessionID, msg: msg})
|
||||
return nil
|
||||
s.log = append(s.log, phonePushRecord{rawAuthKeyID: rawAuthKeyID, targetSession: sessionID, msg: msg})
|
||||
return s.pushErr
|
||||
}
|
||||
|
||||
func (s *phoneCaptureSessions) PushToUserExceptAuthKeySession(_ context.Context, userID int64, _ [8]byte, excludeSessionID int64, _ proto.MessageType, msg tg.UpdatesClass) (int, error) {
|
||||
|
|
@ -73,6 +75,12 @@ func (s *phoneCaptureSessions) reset() {
|
|||
s.log = nil
|
||||
}
|
||||
|
||||
func (s *phoneCaptureSessions) setPushError(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pushErr = err
|
||||
}
|
||||
|
||||
// stubPrivacy 只为 CanSee 服务;其余接口方法不在通话链路使用。
|
||||
type stubPrivacy struct {
|
||||
deny map[domain.PrivacyKey]bool
|
||||
|
|
@ -104,8 +112,15 @@ type phoneFixture struct {
|
|||
}
|
||||
|
||||
const (
|
||||
phoneCallerSession = int64(101)
|
||||
phoneCalleeSession = int64(202)
|
||||
phoneCallerSession = int64(101)
|
||||
phoneCalleeSession = int64(202)
|
||||
phoneOtherCalleeSession = int64(303)
|
||||
)
|
||||
|
||||
var (
|
||||
phoneCallerRawAuthKey = [8]byte{0x11, 0x01}
|
||||
phoneCalleeRawAuthKey = [8]byte{0x22, 0x02}
|
||||
phoneOtherCalleeRawAuthKey = [8]byte{0x33, 0x03}
|
||||
)
|
||||
|
||||
func newPhoneFixture(t *testing.T, privacy PrivacyService) *phoneFixture {
|
||||
|
|
@ -133,11 +148,16 @@ func newPhoneFixture(t *testing.T, privacy PrivacyService) *phoneFixture {
|
|||
}
|
||||
|
||||
func (f *phoneFixture) callerCtx() context.Context {
|
||||
return WithSessionID(WithUserID(f.ctx, f.caller.ID), phoneCallerSession)
|
||||
return WithSessionID(WithRawAuthKeyID(WithUserID(f.ctx, f.caller.ID), phoneCallerRawAuthKey), phoneCallerSession)
|
||||
}
|
||||
|
||||
func (f *phoneFixture) calleeCtx() context.Context {
|
||||
return WithSessionID(WithUserID(f.ctx, f.callee.ID), phoneCalleeSession)
|
||||
return WithSessionID(WithRawAuthKeyID(WithUserID(f.ctx, f.callee.ID), phoneCalleeRawAuthKey), phoneCalleeSession)
|
||||
|
||||
}
|
||||
|
||||
func (f *phoneFixture) otherCalleeCtx() context.Context {
|
||||
return WithSessionID(WithRawAuthKeyID(WithUserID(f.ctx, f.callee.ID), phoneOtherCalleeRawAuthKey), phoneOtherCalleeSession)
|
||||
}
|
||||
|
||||
func phoneTestProtocol() tg.PhoneCallProtocol {
|
||||
|
|
@ -229,8 +249,8 @@ func TestPhoneCallRPCHappyPath(t *testing.T) {
|
|||
// ⚠ ringing(receive_date) 只定向到【发起呼叫的那台设备】(CallerDevice=主叫
|
||||
// requestCall 的 session),绝不广播到主叫账号所有会话——否则同账号登录在被叫手机上
|
||||
// 时会覆写来电 g_a_hash。See memory: call-ga-hash-multiaccount-clobber。
|
||||
if len(pushes) != 1 || pushes[0].targetSession != phoneCallerSession {
|
||||
t.Fatalf("receivedCall pushes = %+v, want one to caller device (session %d)", pushes, phoneCallerSession)
|
||||
if len(pushes) != 1 || pushes[0].rawAuthKeyID != phoneCallerRawAuthKey || pushes[0].targetSession != phoneCallerSession {
|
||||
t.Fatalf("receivedCall pushes = %+v, want caller device %x/%d", pushes, phoneCallerRawAuthKey, phoneCallerSession)
|
||||
}
|
||||
ringing, ok := phoneCallPayload(t, pushes[0]).(*tg.PhoneCallWaiting)
|
||||
if !ok || ringing.ReceiveDate == 0 {
|
||||
|
|
@ -239,6 +259,15 @@ func TestPhoneCallRPCHappyPath(t *testing.T) {
|
|||
}
|
||||
f.sessions.reset()
|
||||
|
||||
// 其它被叫设备晚到的 receivedCall 幂等成功,但不得再次推 ringing。
|
||||
if ok, err := f.router.onPhoneReceivedCall(f.otherCalleeCtx(), tg.InputPhoneCall{ID: callID, AccessHash: accessHash}); err != nil || !ok {
|
||||
t.Fatalf("duplicate receivedCall = %v err=%v", ok, err)
|
||||
}
|
||||
if pushes := f.sessions.records(); len(pushes) != 0 {
|
||||
t.Fatalf("duplicate receivedCall pushes = %+v, want none", pushes)
|
||||
}
|
||||
f.sessions.reset()
|
||||
|
||||
// --- acceptCall(被叫赢家设备) ---
|
||||
acceptRes, err := f.router.onPhoneAcceptCall(f.calleeCtx(), &tg.PhoneAcceptCallRequest{
|
||||
Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash},
|
||||
|
|
@ -391,6 +420,35 @@ func TestPhoneCallRPCHappyPath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPhoneReceivedCallDevicePushFailureDoesNotBroadcast(t *testing.T) {
|
||||
f := newPhoneFixture(t, stubPrivacy{})
|
||||
_, gaHash, _ := phoneTestKeys()
|
||||
|
||||
res, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{
|
||||
UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash},
|
||||
RandomID: 43,
|
||||
GAHash: gaHash,
|
||||
Protocol: phoneTestProtocol(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("requestCall: %v", err)
|
||||
}
|
||||
waiting := res.PhoneCall.(*tg.PhoneCallWaiting)
|
||||
f.sessions.reset()
|
||||
f.sessions.setPushError(errors.New("caller session gone"))
|
||||
|
||||
if ok, err := f.router.onPhoneReceivedCall(f.calleeCtx(), tg.InputPhoneCall{ID: waiting.ID, AccessHash: waiting.AccessHash}); err != nil || !ok {
|
||||
t.Fatalf("receivedCall = %v err=%v", ok, err)
|
||||
}
|
||||
pushes := f.sessions.records()
|
||||
if len(pushes) != 1 || pushes[0].rawAuthKeyID != phoneCallerRawAuthKey || pushes[0].targetSession != phoneCallerSession {
|
||||
t.Fatalf("receivedCall pushes = %+v, want one failed attempt to caller device", pushes)
|
||||
}
|
||||
if pushes[0].userID != 0 {
|
||||
t.Fatalf("receivedCall failure fell back to user broadcast: %+v", pushes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallRPCValidation(t *testing.T) {
|
||||
f := newPhoneFixture(t, stubPrivacy{})
|
||||
_, gaHash, gb := phoneTestKeys()
|
||||
|
|
|
|||
32
internal/rpc/public_app_links_test.go
Normal file
32
internal/rpc/public_app_links_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRouterPublicAppLinkUsesConfiguredBaseAndLegacyDefault(t *testing.T) {
|
||||
legacy := New(Config{}, Deps{}, zap.NewNop(), clock.System)
|
||||
if got, want := legacy.publicAppLink("business-bot"), "telesrv://business-bot"; got != want {
|
||||
t.Fatalf("legacy business bot link = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
hosted := New(Config{
|
||||
PublicAppScheme: "telesrv",
|
||||
PublicAppLinkBase: "owpg://tenant.example.test",
|
||||
}, Deps{}, zap.NewNop(), clock.System)
|
||||
if got, want := hosted.publicAppLink("business-bot"), "owpg://tenant.example.test/business-bot"; got != want {
|
||||
t.Fatalf("hosted business bot link = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRejectsInvalidPublicAppLinkConfig(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("New did not fail fast for an invalid public app link base")
|
||||
}
|
||||
}()
|
||||
_ = New(Config{PublicAppLinkBase: "owpg://tenant.example.test/root"}, Deps{}, zap.NewNop(), clock.System)
|
||||
}
|
||||
|
|
@ -22,6 +22,10 @@ func (r *Router) publicLinkHost() string {
|
|||
return links.Host(r.cfg.PublicBaseURL)
|
||||
}
|
||||
|
||||
func (r *Router) publicAppLink(route string) string {
|
||||
return r.appLinks.Build(route, nil)
|
||||
}
|
||||
|
||||
func publicLinkWithBaseURL(baseURL, path string) string {
|
||||
return links.Build(baseURL, path, nil)
|
||||
}
|
||||
|
|
|
|||
215
internal/rpc/rich_message_limits.go
Normal file
215
internal/rpc/rich_message_limits.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
richMessageLengthLimit = 32768
|
||||
richMessageMaxBlocks = 500
|
||||
richMessageMaxDepth = 16
|
||||
richMessageMaxMedia = 50
|
||||
richMessageMaxTableCols = 20
|
||||
)
|
||||
|
||||
type richMessageMetrics struct {
|
||||
textLength int
|
||||
blocks int
|
||||
depth int
|
||||
media int
|
||||
tableCols int
|
||||
}
|
||||
|
||||
func validateRichMessageBlocks(blocks []tg.PageBlockClass) error {
|
||||
metrics := richMessageMetrics{}
|
||||
collectRichMessageBlockMetrics(blocks, 1, &metrics)
|
||||
if metrics.textLength > richMessageLengthLimit || metrics.blocks > richMessageMaxBlocks ||
|
||||
metrics.depth > richMessageMaxDepth || metrics.media > richMessageMaxMedia || metrics.tableCols > richMessageMaxTableCols {
|
||||
return richMessageTooLongErr()
|
||||
}
|
||||
if metrics.blocks == 0 || metrics.textLength == 0 && metrics.media == 0 {
|
||||
return richMessageInvalidErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metrics *richMessageMetrics) {
|
||||
if depth > metrics.depth {
|
||||
metrics.depth = depth
|
||||
}
|
||||
for _, block := range blocks {
|
||||
metrics.blocks++
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockTitle:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockSubtitle:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeader:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockSubheader:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockKicker:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockParagraph:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockFooter:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading1:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockMath:
|
||||
metrics.textLength += utf16StringLength(value.Source)
|
||||
case *tg.PageBlockThinking:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockAuthorDate:
|
||||
metrics.textLength += richTextUTF16Length(value.Author)
|
||||
case *tg.PageBlockBlockquote:
|
||||
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
|
||||
case *tg.PageBlockPullquote:
|
||||
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption)
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockDetails:
|
||||
metrics.textLength += richTextUTF16Length(value.Title)
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range value.Items {
|
||||
switch item := item.(type) {
|
||||
case *tg.PageListItemText:
|
||||
metrics.textLength += richTextUTF16Length(item.Text)
|
||||
case *tg.PageListItemBlocks:
|
||||
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockOrderedList:
|
||||
for _, item := range value.Items {
|
||||
switch item := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
metrics.textLength += richTextUTF16Length(item.Text)
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockTable:
|
||||
metrics.textLength += richTextUTF16Length(value.Title)
|
||||
for _, row := range value.Rows {
|
||||
columns := 0
|
||||
for _, cell := range row.Cells {
|
||||
metrics.textLength += richTextUTF16Length(cell.Text)
|
||||
if cell.Colspan > 1 {
|
||||
columns += cell.Colspan
|
||||
} else {
|
||||
columns++
|
||||
}
|
||||
}
|
||||
if columns > metrics.tableCols {
|
||||
metrics.tableCols = columns
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCollage:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
|
||||
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
|
||||
case *tg.PageBlockSlideshow:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
|
||||
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
|
||||
case *tg.PageBlockCover:
|
||||
collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio:
|
||||
metrics.media++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func richTextUTF16Length(text tg.RichTextClass) int {
|
||||
switch value := text.(type) {
|
||||
case nil, *tg.TextEmpty:
|
||||
return 0
|
||||
case *tg.TextPlain:
|
||||
return utf16StringLength(value.Text)
|
||||
case *tg.TextConcat:
|
||||
total := 0
|
||||
for _, child := range value.Texts {
|
||||
total += richTextUTF16Length(child)
|
||||
}
|
||||
return total
|
||||
case *tg.TextBold:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextItalic:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextStrike:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextFixed:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMarked:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextURL:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMention:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextHashtag:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextBotCommand:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextCashtag:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoURL:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoEmail:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoPhone:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextBankCard:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextEmail:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextPhone:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextDate:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextCustomEmoji:
|
||||
return utf16StringLength(value.Alt)
|
||||
case *tg.TextMath:
|
||||
return utf16StringLength(value.Source)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func utf16StringLength(value string) int {
|
||||
length := 0
|
||||
for _, r := range value {
|
||||
length++
|
||||
if r > utf8.RuneSelf && r > 0xffff {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tlprofile"
|
||||
compatandroid "telesrv/internal/compat/android"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/observability/dbtrace"
|
||||
)
|
||||
|
||||
|
|
@ -82,6 +84,10 @@ type Config struct {
|
|||
RtmpIngestURL string
|
||||
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
|
||||
PublicBaseURL string
|
||||
// PublicAppScheme/PublicAppLinkBase 控制客户端 deep link;base 为空时
|
||||
// 保持 <scheme>://<route>,非空时生成 <base>/<route>。
|
||||
PublicAppScheme string
|
||||
PublicAppLinkBase string
|
||||
// TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key
|
||||
// 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。
|
||||
// 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的
|
||||
|
|
@ -98,6 +104,7 @@ type Config struct {
|
|||
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
|
||||
type Router struct {
|
||||
cfg Config
|
||||
appLinks links.AppLinkBuilder
|
||||
log *zap.Logger
|
||||
clock clock.Clock
|
||||
deps Deps
|
||||
|
|
@ -164,6 +171,7 @@ type Router struct {
|
|||
stickerCatalog *stickerCatalogCache
|
||||
transientPrivateBigReactions transientPrivateBigReactionCache
|
||||
accountSettings *accountSettingsCache
|
||||
accountFreezeWake chan struct{}
|
||||
// webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位
|
||||
// 解析为卡片并就地替换。满则丢弃任务(消息留 pending)。nil=未启用(测试可直接调
|
||||
// resolvePendingWebPage 同步验证)。
|
||||
|
|
@ -233,11 +241,16 @@ type authUserCacheEntry struct {
|
|||
|
||||
// New 创建 Router,由各业务域自行注册其 RPC handler(registerHelp/Auth/Users/Updates)。
|
||||
func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
||||
assertNoTypedNilDeps(deps)
|
||||
appLinks, err := links.NewAppLinkBuilder(cfg.PublicAppScheme, cfg.PublicAppLinkBase)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("initialize public app links: %v", err))
|
||||
}
|
||||
instanceID := cfg.InstanceID
|
||||
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(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 := &Router{cfg: cfg, appLinks: appLinks, 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), accountFreezeWake: make(chan struct{}, 1), instanceID: instanceID}
|
||||
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
|
||||
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
|
||||
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
|
||||
|
|
@ -281,6 +294,31 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
|||
return r
|
||||
}
|
||||
|
||||
// assertNoTypedNilDeps rejects partially constructed optional dependencies at
|
||||
// the composition boundary. A Go interface containing a nil concrete pointer
|
||||
// is not equal to nil, so handler-level availability checks would otherwise
|
||||
// admit it and panic only when the first method is invoked.
|
||||
//
|
||||
// This is an invariant check, not a compatibility fallback: callers must
|
||||
// either inject a fully constructed implementation or leave the interface nil.
|
||||
func assertNoTypedNilDeps(deps Deps) {
|
||||
value := reflect.ValueOf(deps)
|
||||
typeOfDeps := value.Type()
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
field := value.Field(i)
|
||||
if field.Kind() != reflect.Interface || field.IsNil() {
|
||||
continue
|
||||
}
|
||||
implementation := field.Elem()
|
||||
switch implementation.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
if implementation.IsNil() {
|
||||
panic(fmt.Sprintf("rpc: dependency %s is a typed nil %s", typeOfDeps.Field(i).Name, implementation.Type()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerRPC[T bin.Object](d *tlprofile.Dispatcher, method tlprofile.SemanticID, handler func(context.Context, T) (any, error)) {
|
||||
if d == nil || handler == nil {
|
||||
panic("rpc: register nil canonical RPC handler or dispatcher")
|
||||
|
|
|
|||
|
|
@ -202,17 +202,17 @@ func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) {
|
|||
botsService.SetTextDraftPusher(r)
|
||||
|
||||
sendStickersBotText(t, r, alice, "/newpack", 9101)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "sticker pack")
|
||||
lastBotReplyID := waitForStickersReplyAfter(t, messageStore, alice.ID, 0, "sticker pack")
|
||||
sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON")
|
||||
lastBotReplyID = waitForStickersReplyAfter(t, messageStore, alice.ID, lastBotReplyID, "Lottie JSON")
|
||||
sendStickersBotDocument(t, r, alice, 401, 4401, 9103)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "emoji")
|
||||
lastBotReplyID = waitForStickersReplyAfter(t, messageStore, alice.ID, lastBotReplyID, "emoji")
|
||||
sendStickersBotText(t, r, alice, "🙂", 9104)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "Added")
|
||||
lastBotReplyID = waitForStickersReplyAfter(t, messageStore, alice.ID, lastBotReplyID, "Added")
|
||||
sendStickersBotText(t, r, alice, "/publish", 9105)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "short name")
|
||||
lastBotReplyID = waitForStickersReplyAfter(t, messageStore, alice.ID, lastBotReplyID, "short name")
|
||||
sendStickersBotText(t, r, alice, "alice_bot_pack", 9106)
|
||||
waitForStickersReply(t, messageStore, alice.ID, "https://telesrv.net/addstickers/alice_bot_pack")
|
||||
waitForStickersReplyAfter(t, messageStore, alice.ID, lastBotReplyID, "https://telesrv.net/addstickers/alice_bot_pack")
|
||||
|
||||
created := files.sets[domain.StickerSetKindStickers]
|
||||
if len(created) != 1 || created[0].ShortName != "alice_bot_pack" || created[0].CreatorUserID != alice.ID {
|
||||
|
|
@ -286,7 +286,7 @@ func sendStickersBotDocument(t *testing.T, r *Router, user domain.User, docID, a
|
|||
}
|
||||
}
|
||||
|
||||
func waitForStickersReply(t *testing.T, messages *memory.MessageStore, userID int64, want string) string {
|
||||
func waitForStickersReplyAfter(t *testing.T, messages *memory.MessageStore, userID int64, afterID int, want string) int {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
|
|
@ -299,12 +299,12 @@ func waitForStickersReply(t *testing.T, messages *memory.MessageStore, userID in
|
|||
t.Fatalf("list @Stickers history: %v", err)
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.StickersBotUserID && strings.Contains(msg.Body, want) {
|
||||
return msg.Body
|
||||
if msg.ID > afterID && msg.From.ID == domain.StickersBotUserID && strings.Contains(msg.Body, want) {
|
||||
return msg.ID
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("no @Stickers reply containing %q; history=%+v", want, list.Messages)
|
||||
t.Fatalf("no @Stickers reply after id %d containing %q; history=%+v", afterID, want, list.Messages)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
|
|
|||
59
internal/rpc/suggested_post_dispatcher.go
Normal file
59
internal/rpc/suggested_post_dispatcher.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SuggestedPostDispatcher publishes scheduled suggestions and resolves paid
|
||||
// escrow after the minimum live age (or refunds it when the post is deleted).
|
||||
// Store-side row locks make multiple server instances safe.
|
||||
type SuggestedPostDispatcher struct {
|
||||
router *Router
|
||||
log *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewSuggestedPostDispatcher(router *Router, log *zap.Logger) *SuggestedPostDispatcher {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &SuggestedPostDispatcher{router: router, log: log, interval: time.Second, batch: 50}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) Run(ctx context.Context) {
|
||||
if d == nil || d.router == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(d.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.DispatchOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) DispatchOnce(ctx context.Context) bool {
|
||||
service, ok := d.router.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
results, err := service.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: int(d.router.clock.Now().Unix()), Limit: d.batch})
|
||||
if err != nil {
|
||||
d.log.Warn("process suggested post lifecycle", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
for _, result := range results {
|
||||
d.router.enqueueSuggestedPostApprovalFanout(ctx, 0, result)
|
||||
}
|
||||
return len(results) > 0
|
||||
}
|
||||
434
internal/rpc/telegram_login.go
Normal file
434
internal/rpc/telegram_login.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginOAuthInvalidErr() error { return tgerr.New(500, "OAUTH_REQUEST_INVALID") }
|
||||
func telegramLoginURLExpiredErr() error { return tgerr.New(400, "URL_EXPIRED") }
|
||||
func telegramLoginURLInvalidErr() error { return tgerr.New(400, "URL_INVALID") }
|
||||
func telegramLoginHashInvalidErr() error { return tgerr.New(400, "HASH_INVALID") }
|
||||
|
||||
func telegramLoginRPCError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestExpired):
|
||||
return telegramLoginURLExpiredErr()
|
||||
case errors.Is(err, domain.ErrTelegramLoginURLInvalid):
|
||||
return telegramLoginURLInvalidErr()
|
||||
case errors.Is(err, domain.ErrTelegramLoginMatchCodeInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginRequestInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginRequestConflict),
|
||||
errors.Is(err, domain.ErrTelegramLoginClientDisabled),
|
||||
errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed),
|
||||
errors.Is(err, domain.ErrTelegramLoginRedirectNotAllowed),
|
||||
errors.Is(err, domain.ErrTelegramLoginScopeInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginAuthorizationsTooMany):
|
||||
return telegramLoginOAuthInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) requireTelegramLoginUser(ctx context.Context) (int64, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil || userID <= 0 || r.deps.TelegramLogin == nil || r.deps.Users == nil {
|
||||
return 0, internalErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil || self.Bot || self.Deleted {
|
||||
return 0, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginRequestResult(ctx context.Context, viewerUserID int64, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
|
||||
switch request.Status {
|
||||
case domain.TelegramLoginRequestApproved:
|
||||
if request.AuthorizedUserID != viewerUserID {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
|
||||
case domain.TelegramLoginRequestPending:
|
||||
// Continue below.
|
||||
case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired:
|
||||
return nil, telegramLoginURLExpiredErr()
|
||||
default:
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
bot, found, err := r.deps.Users.ByID(ctx, viewerUserID, request.BotUserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
botTL := r.withBotProfileFlags(ctx, r.tgUser(bot))
|
||||
out := &tg.URLAuthResultRequest{
|
||||
RequestWriteAccess: request.Requests(domain.TelegramLoginScopeBotAccess),
|
||||
RequestPhoneNumber: request.Requests(domain.TelegramLoginScopePhone),
|
||||
MatchCodesFirst: request.MatchCodesFirst,
|
||||
IsApp: request.IsApp,
|
||||
Bot: botTL,
|
||||
Domain: request.Domain,
|
||||
}
|
||||
// OAuth requests carry the complete device tuple. Keep the four fields on
|
||||
// their shared flag together so old exact-layer codecs never see a partial
|
||||
// conditional shape.
|
||||
if request.Browser != "" && request.Platform != "" && request.IP != "" && request.Region != "" {
|
||||
out.SetBrowser(request.Browser)
|
||||
out.SetPlatform(request.Platform)
|
||||
out.SetIP(request.IP)
|
||||
out.SetRegion(request.Region)
|
||||
}
|
||||
if len(request.MatchCodes) > 0 {
|
||||
out.SetMatchCodes(append([]string(nil), request.MatchCodes...))
|
||||
}
|
||||
if request.UserIDHint > 0 {
|
||||
out.SetUserIDHint(request.UserIDHint)
|
||||
}
|
||||
if request.IsApp && request.VerifiedAppName != "" {
|
||||
out.SetVerifiedAppName(request.VerifiedAppName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginAcceptedResult(ctx context.Context, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
|
||||
accepted := &tg.URLAuthResultAccepted{}
|
||||
switch {
|
||||
case request.Source == domain.TelegramLoginRequestNative && request.IsApp:
|
||||
redirectURL, err := r.deps.TelegramLogin.FinalizeRedirectByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted.SetURL(redirectURL)
|
||||
case request.Source == domain.TelegramLoginRequestMiniApp:
|
||||
resultURL, err := r.deps.TelegramLogin.FinalizeInAppRedirectByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted.SetURL(resultURL)
|
||||
}
|
||||
return accepted, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesRequestURLAuth(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (tg.URLAuthResultClass, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hasPeer := req.GetPeer()
|
||||
urlValue, hasURL := req.GetURL()
|
||||
_, hasOrigin := req.GetInAppOrigin()
|
||||
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(urlValue) == "") || hasOrigin && !hasURL {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if hasPeer {
|
||||
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := url.Parse(button.URL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request := domain.TelegramLoginRequest{
|
||||
BotUserID: button.LoginBotUserID, Source: domain.TelegramLoginRequestMessageButton,
|
||||
ResponseType: "legacy_url", RedirectURI: button.URL, Domain: u.Hostname(),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
PeerType: peer.Type, PeerID: peer.ID, MessageID: req.MsgID, ButtonID: req.ButtonID,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
}
|
||||
if button.RequestWriteAccess {
|
||||
request.Scopes = append(request.Scopes, domain.TelegramLoginScopeBotAccess)
|
||||
}
|
||||
return r.telegramLoginRequestResult(ctx, userID, request, "")
|
||||
}
|
||||
if hasOrigin && req.InAppOrigin == "" {
|
||||
return nil, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLinkForOrigin(ctx, urlValue, req.InAppOrigin)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
return r.telegramLoginRequestResult(ctx, userID, request, urlValue)
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesAcceptURLAuth(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (tg.URLAuthResultClass, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hasPeer := req.GetPeer()
|
||||
deepLink, hasURL := req.GetURL()
|
||||
matchCode, hasMatchCode := req.GetMatchCode()
|
||||
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(deepLink) == "") || (hasMatchCode && matchCode == "") {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if hasPeer {
|
||||
if hasMatchCode || req.SharePhoneNumber {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Bots == nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
profile, found, err := r.deps.Bots.BotInfo(ctx, button.LoginBotUserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || profile.TokenSecret == "" {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
identity := r.telegramLoginIdentity(self)
|
||||
result, err := r.deps.TelegramLogin.AuthorizeMessageButton(ctx, domain.TelegramLoginMessageButtonAuthorization{
|
||||
UserID: userID, BotUserID: button.LoginBotUserID,
|
||||
BotToken: domain.FormatBotToken(button.LoginBotUserID, profile.TokenSecret), URL: button.URL,
|
||||
RequestWriteAccess: button.RequestWriteAccess, WriteAllowed: req.WriteAllowed,
|
||||
Peer: peer, MessageID: req.MsgID, ButtonID: req.ButtonID,
|
||||
Browser: "Telegram", Platform: "Telegram Client", IP: "Unknown IP", Region: "Unknown region",
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted := &tg.URLAuthResultAccepted{}
|
||||
accepted.SetURL(result.URL)
|
||||
return accepted, nil
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
if request.Status == domain.TelegramLoginRequestApproved {
|
||||
if request.AuthorizedUserID == userID {
|
||||
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
|
||||
}
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return nil, telegramLoginURLExpiredErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
identity := r.telegramLoginIdentity(self)
|
||||
approved, _, err := r.deps.TelegramLogin.Approve(ctx, deepLink, identity, req.WriteAllowed, req.SharePhoneNumber, matchCode)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
if approved.AuthorizedUserID != userID {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return r.telegramLoginAcceptedResult(ctx, approved, deepLink)
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginIdentity(self domain.User) domain.TelegramLoginIdentitySnapshot {
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: self.ID, Name: strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName)),
|
||||
GivenName: self.FirstName, FamilyName: self.LastName,
|
||||
PreferredUsername: self.Username, PhoneNumber: self.Phone,
|
||||
}
|
||||
if strings.TrimSpace(r.cfg.PublicBaseURL) != "" && self.Username != "" && self.PhotoID > 0 {
|
||||
identity.Picture = strings.TrimSuffix(r.cfg.PublicBaseURL, "/") + "/_public/avatar/" + url.PathEscape(self.Username) + "/" + strconv.FormatInt(self.PhotoID, 10)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginButtonFromMessage(ctx context.Context, userID int64, inputPeer tg.InputPeerClass, messageID, buttonID int) (domain.MarkupButton, domain.Peer, error) {
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID < 0 {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, err
|
||||
}
|
||||
var markup *domain.MessageReplyMarkup
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, internalErr()
|
||||
}
|
||||
if !found || message.Peer != peer {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
markup = message.ReplyMarkup
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{messageID})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != messageID {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
markup = history.Messages[0].ReplyMarkup
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
for _, row := range markup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonLoginURL && button.ButtonID == buttonID && button.LoginBotUserID > 0 {
|
||||
return button, peer, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesDeclineURLAuth(ctx context.Context, deepLink string) (bool, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(deepLink) == "" {
|
||||
return false, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
if request.Status == domain.TelegramLoginRequestDeclined {
|
||||
return true, nil
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return false, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if _, err := r.deps.TelegramLogin.Decline(ctx, deepLink, userID); err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesCheckURLAuthMatchCode(ctx context.Context, deepLink, matchCode string) (bool, error) {
|
||||
if _, err := r.requireTelegramLoginUser(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(deepLink) == "" || matchCode == "" {
|
||||
return false, telegramLoginURLInvalidErr()
|
||||
}
|
||||
ok, err := r.deps.TelegramLogin.CheckMatchCode(ctx, deepLink, matchCode)
|
||||
if err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountGetWebAuthorizations(ctx context.Context) (*tg.AccountWebAuthorizations, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return &tg.AccountWebAuthorizations{Authorizations: []tg.WebAuthorization{}, Users: []tg.UserClass{}}, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizations, err := r.deps.TelegramLogin.ListWebAuthorizations(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
result := &tg.AccountWebAuthorizations{
|
||||
Authorizations: make([]tg.WebAuthorization, 0, len(authorizations)),
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
botIDs := make([]int64, 0, len(authorizations))
|
||||
seenBots := make(map[int64]struct{}, len(authorizations))
|
||||
for _, authorization := range authorizations {
|
||||
result.Authorizations = append(result.Authorizations, tg.WebAuthorization{
|
||||
Hash: authorization.Hash, BotID: authorization.BotUserID, Domain: authorization.Domain,
|
||||
Browser: authorization.Browser, Platform: authorization.Platform,
|
||||
DateCreated: telegramLoginUnixInt(authorization.CreatedAt), DateActive: telegramLoginUnixInt(authorization.LastActiveAt),
|
||||
IP: authorization.IP, Region: authorization.Region,
|
||||
})
|
||||
if _, duplicate := seenBots[authorization.BotUserID]; !duplicate {
|
||||
seenBots[authorization.BotUserID] = struct{}{}
|
||||
botIDs = append(botIDs, authorization.BotUserID)
|
||||
}
|
||||
}
|
||||
if len(botIDs) > 0 {
|
||||
bots, err := r.deps.Users.ByIDs(ctx, userID, botIDs)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
for _, bot := range bots {
|
||||
if bot.Bot && !bot.Deleted {
|
||||
result.Users = append(result.Users, r.withBotProfileFlags(ctx, r.tgUser(bot)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountResetWebAuthorization(ctx context.Context, hash int64) (bool, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return false, telegramLoginHashInvalidErr()
|
||||
}
|
||||
if err := r.deps.TelegramLogin.RevokeWebAuthorization(ctx, userID, hash); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
|
||||
return false, telegramLoginHashInvalidErr()
|
||||
}
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountResetWebAuthorizations(ctx context.Context) (bool, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.deps.TelegramLogin.RevokeAllWebAuthorizations(ctx, userID); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func telegramLoginUnixInt(value time.Time) int {
|
||||
unix := value.Unix()
|
||||
if unix < 0 {
|
||||
return 0
|
||||
}
|
||||
if unix > math.MaxInt32 {
|
||||
return math.MaxInt32
|
||||
}
|
||||
return int(unix)
|
||||
}
|
||||
380
internal/rpc/telegram_login_rpc_test.go
Normal file
380
internal/rpc/telegram_login_rpc_test.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type telegramLoginBotPermissionAdapter struct{ bots BotsService }
|
||||
|
||||
func (a telegramLoginBotPermissionAdapter) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) {
|
||||
return a.bots.AllowSendMessage(ctx, userID, botUserID, fromRequest)
|
||||
}
|
||||
|
||||
type telegramLoginRPCFixture struct {
|
||||
ctx context.Context
|
||||
service *telegramloginapp.Service
|
||||
router *Router
|
||||
user domain.User
|
||||
intruder domain.User
|
||||
bot domain.User
|
||||
client telegramloginapp.ClientCredentials
|
||||
redirect string
|
||||
}
|
||||
|
||||
func newTelegramLoginRPCFixture(t *testing.T) *telegramLoginRPCFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "+15551001", FirstName: "Alice", LastName: "Example", Username: "alice", AccessHash: 11})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intruder, err := users.Create(ctx, domain.User{Phone: "+15551002", FirstName: "Mallory", Username: "mallory", AccessHash: 13})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot, err := users.Create(ctx, domain.User{FirstName: "Login Bot", Username: "login_rpc_bot", AccessHash: 12, Bot: true, BotInfoVersion: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 3
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 4
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
|
||||
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := service.CreateClient(ctx, bot.ID, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect := "https://rp.test/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedRedirectURI, redirect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := New(Config{}, Deps{Users: appusers.NewService(users), TelegramLogin: service}, zaptest.NewLogger(t), clock.System)
|
||||
return &telegramLoginRPCFixture{ctx: WithUserID(ctx, user.ID), service: service, router: router, user: user, intruder: intruder, bot: bot, client: client, redirect: redirect}
|
||||
}
|
||||
|
||||
func (f *telegramLoginRPCFixture) authorization(t *testing.T, match bool) telegramloginapp.CreatedAuthorization {
|
||||
t.Helper()
|
||||
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: f.redirect, ResponseType: "code",
|
||||
Scope: "openid profile telegram:bot_access", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
IncludeMatchCodes: match, MatchCodesFirst: match,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func TestTelegramLoginRPCsAcrossExactLayerProfiles(t *testing.T) {
|
||||
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
||||
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
|
||||
approve := f.authorization(t, true)
|
||||
// TDesktop normalizes the configured telesrv:// launcher to the
|
||||
// official internal tg://oauth form before invoking MTProto.
|
||||
canonicalURL := strings.Replace(approve.DeepLink, "telesrv://", "tg://", 1)
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetURL(canonicalURL)
|
||||
result, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, request)
|
||||
if method != "messages.requestUrlAuth" {
|
||||
t.Fatalf("method = %q", method)
|
||||
}
|
||||
prompt, ok := dispatchCanonicalValue(result).(*tg.URLAuthResultRequest)
|
||||
if !ok || prompt.Bot.GetID() != f.bot.ID || !prompt.RequestWriteAccess || len(prompt.MatchCodes) != 5 {
|
||||
t.Fatalf("request result = %#v", dispatchCanonicalValue(result))
|
||||
}
|
||||
|
||||
checked, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesCheckURLAuthMatchCodeRequest{
|
||||
URL: canonicalURL, MatchCode: approve.Request.MatchCode,
|
||||
})
|
||||
if method != "messages.checkUrlAuthMatchCode" || dispatchCanonicalValue(checked) != true {
|
||||
t.Fatalf("check result = %#v method=%q", dispatchCanonicalValue(checked), method)
|
||||
}
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{WriteAllowed: true}
|
||||
accept.SetURL(canonicalURL)
|
||||
accept.SetMatchCode(approve.Request.MatchCode)
|
||||
accepted, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, accept)
|
||||
if method != "messages.acceptUrlAuth" {
|
||||
t.Fatalf("accept method = %q", method)
|
||||
}
|
||||
if _, ok := dispatchCanonicalValue(accepted).(*tg.URLAuthResultAccepted); !ok {
|
||||
t.Fatalf("accept result = %#v", dispatchCanonicalValue(accepted))
|
||||
}
|
||||
|
||||
decline := f.authorization(t, false)
|
||||
declined, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesDeclineURLAuthRequest{URL: decline.DeepLink})
|
||||
if method != "messages.declineUrlAuth" || dispatchCanonicalValue(declined) != true {
|
||||
t.Fatalf("decline result = %#v method=%q", dispatchCanonicalValue(declined), method)
|
||||
}
|
||||
|
||||
listed, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountGetWebAuthorizationsRequest{})
|
||||
web, ok := dispatchCanonicalValue(listed).(*tg.AccountWebAuthorizations)
|
||||
if method != "account.getWebAuthorizations" || !ok || len(web.Authorizations) != 1 || web.Authorizations[0].BotID != f.bot.ID {
|
||||
t.Fatalf("getWebAuthorizations = %#v method=%q", dispatchCanonicalValue(listed), method)
|
||||
}
|
||||
reset, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationRequest{Hash: web.Authorizations[0].Hash})
|
||||
if method != "account.resetWebAuthorization" || dispatchCanonicalValue(reset) != true {
|
||||
t.Fatalf("resetWebAuthorization = %#v method=%q", dispatchCanonicalValue(reset), method)
|
||||
}
|
||||
|
||||
const nativeCallback = "bedolaga://telegram-login"
|
||||
if _, err := f.service.AddNativeApp(f.ctx, f.bot.ID, domain.TelegramLoginNativeAndroid,
|
||||
"dev.bedolaga.demo", strings.Repeat("A", 64), nativeCallback, "Bedolaga Android Demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: nativeCallback, ResponseType: "code",
|
||||
Scope: "profile", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
NativePlatform: domain.TelegramLoginNativeAndroid, IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nativeRequest := &tg.MessagesRequestURLAuthRequest{}
|
||||
nativeRequest.SetURL(native.DeepLink)
|
||||
nativeResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
|
||||
nativePrompt, ok := dispatchCanonicalValue(nativeResult).(*tg.URLAuthResultRequest)
|
||||
if !ok || !nativePrompt.IsApp || nativePrompt.VerifiedAppName != "Bedolaga Android Demo" || len(nativePrompt.MatchCodes) != 5 {
|
||||
t.Fatalf("native request result = %#v", dispatchCanonicalValue(nativeResult))
|
||||
}
|
||||
nativeAccept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
nativeAccept.SetURL(native.DeepLink)
|
||||
nativeAccept.SetMatchCode(native.Request.MatchCode)
|
||||
nativeAcceptedResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeAccept)
|
||||
nativeAccepted, ok := dispatchCanonicalValue(nativeAcceptedResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || !strings.HasPrefix(nativeAccepted.URL, nativeCallback+"?code=") {
|
||||
t.Fatalf("native accepted result = %#v", dispatchCanonicalValue(nativeAcceptedResult))
|
||||
}
|
||||
nativeRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
|
||||
nativeRetry, ok := dispatchCanonicalValue(nativeRetryResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || nativeRetry.URL != nativeAccepted.URL {
|
||||
t.Fatalf("native retry result = %#v, want URL %q", dispatchCanonicalValue(nativeRetryResult), nativeAccepted.URL)
|
||||
}
|
||||
|
||||
const miniAppOrigin = "https://rp.test"
|
||||
miniApp, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: miniAppOrigin + "/", ResponseType: "post_message",
|
||||
Scope: "openid profile", Origin: miniAppOrigin, InAppOrigin: miniAppOrigin,
|
||||
Source: domain.TelegramLoginRequestMiniApp, IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
miniAppRequest := &tg.MessagesRequestURLAuthRequest{}
|
||||
miniAppRequest.SetURL(miniApp.DeepLink)
|
||||
miniAppRequest.SetInAppOrigin(miniAppOrigin)
|
||||
miniAppResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
|
||||
miniAppPrompt, ok := dispatchCanonicalValue(miniAppResult).(*tg.URLAuthResultRequest)
|
||||
if method != "messages.requestUrlAuth" || !ok || len(miniAppPrompt.MatchCodes) != 5 {
|
||||
t.Fatalf("mini-app request result = %#v method=%q", dispatchCanonicalValue(miniAppResult), method)
|
||||
}
|
||||
miniAppAccept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
miniAppAccept.SetURL(miniApp.DeepLink)
|
||||
miniAppAccept.SetMatchCode(miniApp.Request.MatchCode)
|
||||
miniAppAcceptedResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppAccept)
|
||||
miniAppAccepted, ok := dispatchCanonicalValue(miniAppAcceptedResult).(*tg.URLAuthResultAccepted)
|
||||
if method != "messages.acceptUrlAuth" || !ok || !strings.HasPrefix(miniAppAccepted.URL, "https://oauth.test/inapp?token=") {
|
||||
t.Fatalf("mini-app accepted result = %#v method=%q", dispatchCanonicalValue(miniAppAcceptedResult), method)
|
||||
}
|
||||
miniAppRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
|
||||
miniAppRetry, ok := dispatchCanonicalValue(miniAppRetryResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || miniAppRetry.URL != miniAppAccepted.URL {
|
||||
t.Fatalf("mini-app retry result = %#v, want URL %q", dispatchCanonicalValue(miniAppRetryResult), miniAppAccepted.URL)
|
||||
}
|
||||
resetAll, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationsRequest{})
|
||||
if method != "account.resetWebAuthorizations" || dispatchCanonicalValue(resetAll) != true {
|
||||
t.Fatalf("resetWebAuthorizations = %#v method=%q", dispatchCanonicalValue(resetAll), method)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginApprovedDeepLinkRejectsAnotherUser(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
created := f.authorization(t, false)
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
accept.SetURL(created.DeepLink)
|
||||
if _, err := f.router.onMessagesAcceptURLAuth(f.ctx, accept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetURL(created.DeepLink)
|
||||
if _, err := f.router.onMessagesRequestURLAuth(WithUserID(context.Background(), f.intruder.ID), request); err == nil {
|
||||
t.Fatal("another user observed an approved deep link as accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) {
|
||||
f := newBotAPIReceiveFixture(t, false)
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 7
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 8
|
||||
loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots})
|
||||
login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{
|
||||
Issuer: "http://192.0.2.25:2401", AppScheme: "telesrv", AllowHTTP: true, ClientSecretPepper: pepper,
|
||||
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "http://rp.test:3000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.router.deps.TelegramLogin = login
|
||||
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "http://rp.test:3000/login?next=%2Fhome", RequestWriteAccess: true,
|
||||
}}}}
|
||||
if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
history, err := f.messages.GetHistory(f.ctx, f.owner.ID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) == 0 {
|
||||
t.Fatalf("GetHistory: messages=%d err=%v", len(history.Messages), err)
|
||||
}
|
||||
message := history.Messages[0]
|
||||
if message.ReplyMarkup == nil || message.ReplyMarkup.Inline[0][0].LoginBotUserID != f.bot.ID {
|
||||
t.Fatalf("persisted login button = %#v", message.ReplyMarkup)
|
||||
}
|
||||
|
||||
peer := &tg.InputPeerUser{UserID: f.bot.ID, AccessHash: f.bot.AccessHash}
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetPeer(peer)
|
||||
request.SetMsgID(message.ID)
|
||||
request.SetButtonID(0)
|
||||
requested, err := f.router.onMessagesRequestURLAuth(WithUserID(f.ctx, f.owner.ID), request)
|
||||
if err != nil {
|
||||
t.Fatalf("requestUrlAuth: %v", err)
|
||||
}
|
||||
prompt, ok := requested.(*tg.URLAuthResultRequest)
|
||||
if !ok || !prompt.RequestWriteAccess || prompt.Domain != "rp.test" {
|
||||
t.Fatalf("requestUrlAuth result = %#v", requested)
|
||||
}
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
accept.SetWriteAllowed(true)
|
||||
accept.SetPeer(peer)
|
||||
accept.SetMsgID(message.ID)
|
||||
accept.SetButtonID(0)
|
||||
accepted, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), accept)
|
||||
if err != nil {
|
||||
t.Fatalf("acceptUrlAuth: %v", err)
|
||||
}
|
||||
final, ok := accepted.(*tg.URLAuthResultAccepted)
|
||||
if !ok || final.URL == "" {
|
||||
t.Fatalf("acceptUrlAuth result = %#v", accepted)
|
||||
}
|
||||
verifyLegacyTelegramLoginURL(t, final.URL, domain.FormatBotToken(f.bot.ID, "secret"), f.owner.ID)
|
||||
if allowed, err := f.router.deps.Bots.CanSendMessage(f.ctx, f.owner.ID, f.bot.ID); err != nil || !allowed {
|
||||
t.Fatalf("bot write permission = %v,%v", allowed, err)
|
||||
}
|
||||
web, err := login.ListWebAuthorizations(f.ctx, f.owner.ID)
|
||||
if err != nil || len(web) != 1 || !web[0].BotAccessGranted || web[0].Domain != "rp.test" {
|
||||
t.Fatalf("web authorizations = %#v err=%v", web, err)
|
||||
}
|
||||
|
||||
// The server must re-read durable message state. A forged button id never
|
||||
// falls back to URL data supplied by the client.
|
||||
forged := &tg.MessagesAcceptURLAuthRequest{}
|
||||
forged.SetPeer(peer)
|
||||
forged.SetMsgID(message.ID)
|
||||
forged.SetButtonID(99)
|
||||
if _, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), forged); err == nil {
|
||||
t.Fatal("forged button id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginMarkupRequiresBotSender(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login",
|
||||
}}}}
|
||||
if err := f.router.prepareTelegramLoginMarkup(WithUserID(f.ctx, f.user.ID), f.user.ID, markup); !errors.Is(err, domain.ErrButtonTypeInvalid) {
|
||||
t.Fatalf("ordinary user login_url error = %v, want ErrButtonTypeInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyLegacyTelegramLoginURL(t *testing.T, raw, botToken string, wantUserID int64) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := u.Query()
|
||||
provided := query.Get("hash")
|
||||
query.Del("hash")
|
||||
if query.Get("id") != strconv.FormatInt(wantUserID, 10) || query.Get("auth_date") == "" || query.Get("next") != "/home" {
|
||||
t.Fatalf("legacy login query = %#v", query)
|
||||
}
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
if key != "next" { // Existing application query fields are not signed.
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, key+"="+query.Get(key))
|
||||
}
|
||||
secret := sha256.Sum256([]byte(botToken))
|
||||
mac := hmac.New(sha256.New, secret[:])
|
||||
_, _ = mac.Write([]byte(strings.Join(lines, "\n")))
|
||||
if !hmac.Equal([]byte(strings.ToLower(provided)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
t.Fatalf("legacy login hash = %q, want %s", provided, hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
|
|
@ -156,6 +157,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
|
||||
loadEpoch := r.userFullProjectionCache.LoadEpoch()
|
||||
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
|
||||
if !applyContactNoteToUserFull(u, &full) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -173,6 +177,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
return nil, err
|
||||
}
|
||||
r.userFullProjectionCache.StoreIfEpoch(currentUserID, u.ID, full, loadEpoch)
|
||||
if !applyContactNoteToUserFull(u, &full) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -186,6 +193,49 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
}, nil
|
||||
}
|
||||
|
||||
// applyContactNoteToUserFull overlays the viewer-scoped contact note after the
|
||||
// expensive UserFull projection cache. This keeps private notes out of the
|
||||
// large LRU while reusing the contact projection already loaded by Users.ByID,
|
||||
// so users.getFullUser adds neither a PostgreSQL query nor an N+1 read.
|
||||
func applyContactNoteToUserFull(user domain.User, full *tg.UserFull) bool {
|
||||
if full == nil {
|
||||
return false
|
||||
}
|
||||
full.Flags2.Unset(22)
|
||||
full.Note = tg.TextWithEntities{}
|
||||
if !user.Contact {
|
||||
return user.ContactNote == "" && len(user.ContactNoteEntities) == 0
|
||||
}
|
||||
if user.ContactNote == "" {
|
||||
return len(user.ContactNoteEntities) == 0
|
||||
}
|
||||
if !utf8.ValidString(user.ContactNote) || utf8.RuneCountInString(user.ContactNote) > maxContactNoteLength ||
|
||||
len(user.ContactNoteEntities) > maxMessageEntityCount || !validEphemeralEntityBounds(user.ContactNote, user.ContactNoteEntities) {
|
||||
return false
|
||||
}
|
||||
entities := tgMessageEntities(user.ContactNoteEntities)
|
||||
if len(entities) != len(user.ContactNoteEntities) {
|
||||
return false
|
||||
}
|
||||
for _, entity := range user.ContactNoteEntities {
|
||||
switch entity.Type {
|
||||
case domain.MessageEntityCustomEmoji:
|
||||
if entity.DocumentID <= 0 {
|
||||
return false
|
||||
}
|
||||
case domain.MessageEntityMentionName:
|
||||
if entity.UserID <= 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
full.SetNote(tg.TextWithEntities{
|
||||
Text: user.ContactNote,
|
||||
Entities: entities,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int64, u domain.User) (tg.UserFull, error) {
|
||||
about := u.About
|
||||
if r.deps.Privacy != nil && u.ID != currentUserID {
|
||||
|
|
@ -197,6 +247,11 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
|
|||
about = ""
|
||||
}
|
||||
}
|
||||
// Surface the scam/fake warning to other viewers (never to the account
|
||||
// itself), non-destructively over the projected About.
|
||||
if u.ID != currentUserID {
|
||||
about = aboutWithModerationWarning(about, defaultScamWarningUser, defaultFakeWarningUser, u.Scam, u.Fake)
|
||||
}
|
||||
full := tg.UserFull{
|
||||
ID: u.ID,
|
||||
About: about,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import (
|
|||
const (
|
||||
webPageResolveConcurrency = 16
|
||||
webPageResolveTimeout = 30 * time.Second
|
||||
// webPagePendingLifetime 是客户端在重新拉取 pending 消息前等待的窗口。
|
||||
// TDesktop 与 DrKLO 都把 webPagePending.date 解释为绝对截止时间,而不是处理开始时间;
|
||||
// 该窗口必须严格大于 resolver 的最大执行时间,避免正常的慢解析被客户端提前标记为失败。
|
||||
webPagePendingLifetime = 2 * time.Minute
|
||||
)
|
||||
|
||||
type webPageResolveJob struct {
|
||||
|
|
|
|||
|
|
@ -135,9 +135,10 @@ func (r *Router) webPagePendingOrCachedMedia(ctx context.Context, rawURL string,
|
|||
State: domain.MessageWebPageStatePending,
|
||||
ID: domain.WebPageURLHash(normalized),
|
||||
URL: normalized,
|
||||
// Date=「processing started」时刻:留 0(=1970)会被严格客户端判为 pending 早已
|
||||
// 过期 → 直接显示纯文本,须填发送时刻。
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
// date 是客户端重新拉取 pending 消息的绝对截止时间。TDesktop/DrKLO 在
|
||||
// date<=now 时立即重取;若 resolver 此时仍运行,TDesktop 会把占位记成 sticky
|
||||
// failed,后到的 done update 也不会重新显示卡片。
|
||||
Date: int(r.clock.Now().Add(webPagePendingLifetime).Unix()),
|
||||
ForceLargeMedia: forceLarge,
|
||||
ForceSmallMedia: forceSmall,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue