Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -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.

View file

@ -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
}

View 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))
}
}

View 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
}

View file

@ -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
}

View file

@ -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",
})
},

View file

@ -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) {

View file

@ -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)

View 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
}

View 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()
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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 {

View file

@ -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()

View file

@ -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,

View 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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -10,10 +10,9 @@ import (
"telesrv/internal/domain"
)
// 本文件集中 Layer 227 富文本消息richMessage的 tg.* ↔ domain 转换。
// Phase 1仅支持 inputRichMessageblocks 形态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 1HTML/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 {

View file

@ -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 flagbit28独立位
// emoji status。判断用真实时钟premium 的权威来源是 premium_expires_at 本身,
// 到期即停发,正确性不依赖后台 sweeper它只负责清理与 updateUser 通知);

View 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)
}
}

View file

@ -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

View 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)
}
}

View file

@ -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") }

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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()

View file

@ -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")

View file

@ -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)
})

View file

@ -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"}}},

View file

@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr
}
}
// rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误
// rich_messageLayer 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

View file

@ -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) {

View file

@ -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}

View 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)
}
}
}

View 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)
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View 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
}

View file

@ -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)
}

View file

@ -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 DrKLOMessagesController 只按 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
// DrKLOMessagesController 只按 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、必然 readyPushToSessionForAuthKey 对
// 暂未 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 修正)。

View file

@ -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 fakecaptureSessions 只留最后一条)。
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()

View 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)
}

View file

@ -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)
}

View 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
}

View file

@ -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 linkbase 为空时
// 保持 <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 handlerregisterHelp/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")

View file

@ -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)
}

View 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
}

View 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)
}

View 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)))
}
}

View file

@ -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,

View file

@ -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 {

View file

@ -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,
},