Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
442 lines
17 KiB
Go
442 lines
17 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
|
||
"github.com/iamxvbaba/td/tg"
|
||
"go.uber.org/zap"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.UpdatesGetChannelDifferenceRequest) (tg.UpdatesChannelDifferenceClass, error) {
|
||
if r.deps.Channels == nil {
|
||
return &tg.UpdatesChannelDifferenceEmpty{Final: true, Pts: req.Pts, Timeout: 30}, nil
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.checkFrozenChannelParticipants(ctx, userID, channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
// difference 类 catch-up FLOOD_WAIT(设计 Phase 2 / §10.3):nudge 被消费后客户端会触发
|
||
// getChannelDifference,大群 nudge 全速前需限速防风暴。未配置阈值时不限速。
|
||
// participant gate 必须先于限流写入,保证冻结拒绝没有副作用。
|
||
if err := r.checkCatchupRateLimit(ctx, userID, channelDifferenceRateLimitKeyPrefix); err != nil {
|
||
return nil, err
|
||
}
|
||
r.trackChannelInterest(ctx, userID, channelID)
|
||
diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{
|
||
UserID: userID,
|
||
ChannelID: channelID,
|
||
Pts: req.Pts,
|
||
Limit: req.Limit,
|
||
Force: req.Force,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrPersistentTimestamp) {
|
||
return nil, persistentTimestampInvalidErr()
|
||
}
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
if diff.Channel.Username != "" && diff.Self.Status != domain.ChannelMemberActive {
|
||
// Telegram's public-channel passive delivery is enabled only after a
|
||
// successful short-poll difference. The runtime subscription is renewed
|
||
// by subsequent polls and never creates membership/dialog/read state.
|
||
r.refreshPublicChannelSubscription(ctx, userID, channelID)
|
||
}
|
||
diff = r.enrichChannelDifference(ctx, userID, diff)
|
||
out := tgChannelDifference(userID, diff)
|
||
if linked, ok := r.linkedDiscussionChat(ctx, userID, channelID); ok {
|
||
switch value := out.(type) {
|
||
case *tg.UpdatesChannelDifference:
|
||
value.Chats = replaceTGChat(value.Chats, linked)
|
||
case *tg.UpdatesChannelDifferenceTooLong:
|
||
value.Chats = replaceTGChat(value.Chats, linked)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates {
|
||
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r))
|
||
}
|
||
|
||
func (r *Router) channelOperationUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates {
|
||
if cache == nil {
|
||
cache = newViewerPeerCache(r)
|
||
}
|
||
userIDs := make(map[int64]struct{}, len(res.Members)+4)
|
||
channelIDs := make(map[int64]struct{})
|
||
if res.Channel.CreatorUserID != 0 {
|
||
userIDs[res.Channel.CreatorUserID] = struct{}{}
|
||
}
|
||
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
|
||
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
|
||
for _, member := range res.Members {
|
||
if member.UserID != 0 {
|
||
userIDs[member.UserID] = struct{}{}
|
||
}
|
||
if member.InviterUserID != 0 {
|
||
userIDs[member.InviterUserID] = struct{}{}
|
||
}
|
||
}
|
||
updates := make([]tg.UpdateClass, 0, 2)
|
||
if res.Event.Pts != 0 {
|
||
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
}
|
||
if res.Channel.ID != 0 {
|
||
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
|
||
}
|
||
chats := []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, channelMemberForUser(res.Members, viewerUserID))}
|
||
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...)
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
|
||
Chats: chats,
|
||
Date: int(r.clock.Now().Unix()),
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func (r *Router) tdesktopCreateChatUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates {
|
||
updates := r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
|
||
if updates == nil {
|
||
return updates
|
||
}
|
||
self := channelMemberForUser(res.Members, viewerUserID)
|
||
legacy := tgMigratedLegacyChat(viewerUserID, res.Channel, self)
|
||
if legacy == nil {
|
||
return updates
|
||
}
|
||
chats := make([]tg.ChatClass, 0, len(updates.Chats)+1)
|
||
chats = append(chats, legacy)
|
||
chats = append(chats, updates.Chats...)
|
||
updates.Chats = chats
|
||
return updates
|
||
}
|
||
|
||
func (r *Router) channelStateUpdates(viewerUserID int64, channel domain.Channel) *tg.Updates {
|
||
return &tg.Updates{
|
||
Updates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: channel.ID}},
|
||
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
|
||
Date: int(r.clock.Now().Unix()),
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
// appendChannelStateUpdates 把 extra 的 update/chat 合并进 dst(chats 按 id 去重)。用于在一次响应里
|
||
// 叠加关联频道的状态更新,例如随母频道删除一并下发的 monoforum ChannelForbidden 墓碑。
|
||
func appendChannelStateUpdates(dst *tg.Updates, extra *tg.Updates) {
|
||
if dst == nil || extra == nil {
|
||
return
|
||
}
|
||
dst.Updates = append(dst.Updates, extra.Updates...)
|
||
for _, ch := range extra.Chats {
|
||
dst.Chats = appendUniqueTGChats(dst.Chats, ch)
|
||
}
|
||
}
|
||
|
||
// channelStateUpdatesWithLinkedMonoforum builds one recipient's channel-state
|
||
// update. Peer-wide overlays are passed in already resolved rather than read
|
||
// here: this builder runs once per online recipient, so reads inside it would
|
||
// turn one state change into one query per member.
|
||
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) *tg.Updates {
|
||
updates := r.channelStateUpdates(viewerUserID, channel)
|
||
// 母广播频道有/曾有关联 monoforum 时,按完整(非 min)形态下发。关闭 Direct Messages 时
|
||
// linked_monoforum_id 在投影里被隐藏,只有完整频道对象才能覆盖客户端缓存里旧的 linked_monoforum_id,
|
||
// 触发 applyMonoforumLinkedId(parent,0) → monoforum 的 MonoforumDisabled → 「已停用私信」停用页脚;
|
||
// min 母频道不覆盖缓存字段,页脚出不来。与官方 disable(parent 完整下发 + 隐藏 linked id)一致。
|
||
// 普通频道(无 linked monoforum)仍走 min,不受影响。
|
||
if channel.LinkedMonoforumID != 0 {
|
||
updates.Chats = []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}
|
||
}
|
||
if includeMono {
|
||
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannelChat(viewerUserID, mono, nil))
|
||
}
|
||
// The third-party mark travels with the state mutation for the same reason
|
||
// verified:flags.7 does -- it is part of how the peer identifies itself -- but it
|
||
// lives in a read model rather than on domain.Channel, so it is stamped on here
|
||
// instead of being projected from the row.
|
||
applyBotVerificationIconToChannelChats(updates.Chats, channel.ID, botVerificationIcon)
|
||
applyUsernamesFromRegistry(nil, updates.Chats, usernames)
|
||
return updates
|
||
}
|
||
|
||
func (r *Router) linkedMonoforumForChannelState(ctx context.Context, userID int64, channel domain.Channel) (domain.Channel, bool) {
|
||
if r.deps.Channels == nil || userID == 0 || !channel.BroadcastMessagesAllowed || channel.LinkedMonoforumID == 0 {
|
||
return domain.Channel{}, false
|
||
}
|
||
mono, err := r.deps.Channels.GetJoinableChannel(ctx, userID, channel.LinkedMonoforumID)
|
||
if err != nil || !mono.Monoforum || mono.LinkedMonoforumID != channel.ID {
|
||
return domain.Channel{}, false
|
||
}
|
||
return mono, true
|
||
}
|
||
|
||
func (r *Router) channelMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64, cache *viewerPeerCache) *tg.Updates {
|
||
randomIDs := []int64(nil)
|
||
includeMessageIDs := randomID != 0
|
||
if includeMessageIDs {
|
||
randomIDs = []int64{randomID}
|
||
}
|
||
return r.channelMessagesUpdatesWithPeerCache(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil, cache)
|
||
}
|
||
|
||
func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) {
|
||
if discussion == nil || discussion.Channel.ID == 0 || discussion.Event.Pts == 0 {
|
||
return
|
||
}
|
||
res := domain.SendChannelMessageResult{
|
||
Channel: discussion.Channel,
|
||
Message: discussion.Message,
|
||
Event: discussion.Event,
|
||
Recipients: discussion.Recipients,
|
||
MentionUserIDs: discussion.MentionUserIDs,
|
||
}
|
||
// 讨论组联动(broadcast↔linked megagroup)的第二轮 fan-out 也异步化 + 跨 viewer 投影预热
|
||
// (设计 Phase 0/Phase 1)。cache 仅被本 fan-out 闭包/预热使用、由单分片 worker 串行执行,
|
||
// 无跨 goroutine 竞态。
|
||
r.enqueueChannelMessageFanout(ctx, originUserID, res, nil)
|
||
}
|
||
|
||
func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64, cache *viewerPeerCache) *tg.Updates {
|
||
if cache == nil {
|
||
cache = newViewerPeerCache(r)
|
||
}
|
||
updates := make([]tg.UpdateClass, 0, len(results)*2)
|
||
userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs))
|
||
for _, id := range extraUserIDs {
|
||
if id != 0 {
|
||
userIDs[id] = struct{}{}
|
||
}
|
||
}
|
||
channelIDs := make(map[int64]struct{}, len(results))
|
||
var channel domain.Channel
|
||
date := 0
|
||
for i, res := range results {
|
||
if res.Channel.ID != 0 {
|
||
channel = res.Channel
|
||
}
|
||
if includeMessageIDs && res.Message.ID != 0 && i < len(randomIDs) && randomIDs[i] != 0 {
|
||
updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomIDs[i]})
|
||
}
|
||
if res.Event.Pts != 0 {
|
||
event := projectChannelMentionForViewer(res.Event, res.MentionUserIDs, viewerUserID)
|
||
if update := tgChannelUpdate(viewerUserID, event); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
}
|
||
if res.Duplicate && res.ReplayDeleteEvent != nil {
|
||
if update := tgChannelUpdate(viewerUserID, *res.ReplayDeleteEvent); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
if res.ReplayDeleteEvent.Date > date {
|
||
date = res.ReplayDeleteEvent.Date
|
||
}
|
||
}
|
||
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
|
||
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
|
||
if date == 0 {
|
||
date = res.Event.Date
|
||
}
|
||
if date == 0 {
|
||
date = res.Message.Date
|
||
}
|
||
}
|
||
chats := []tg.ChatClass(nil)
|
||
if channel.ID != 0 {
|
||
chats = []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}
|
||
chats = r.appendLinkedDiscussionChat(ctx, viewerUserID, channel.ID, chats)
|
||
}
|
||
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), channel.ID)))...)
|
||
if date == 0 {
|
||
date = int(r.clock.Now().Unix())
|
||
}
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
|
||
Chats: chats,
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates {
|
||
return r.channelEditMessageUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r))
|
||
}
|
||
|
||
func (r *Router) channelEditMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult, cache *viewerPeerCache) *tg.Updates {
|
||
if cache == nil {
|
||
cache = newViewerPeerCache(r)
|
||
}
|
||
updates := make([]tg.UpdateClass, 0, 2)
|
||
userIDs := make(map[int64]struct{}, 2)
|
||
channelIDs := make(map[int64]struct{})
|
||
if res.Event.Pts != 0 {
|
||
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
|
||
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
|
||
}
|
||
if res.ServiceEvent.Pts != 0 {
|
||
if update := tgChannelUpdate(viewerUserID, res.ServiceEvent); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs)
|
||
collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs)
|
||
}
|
||
chats := []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}
|
||
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...)
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
|
||
Chats: chats,
|
||
Date: int(r.clock.Now().Unix()),
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates {
|
||
updates := make([]tg.UpdateClass, 0, 1)
|
||
if update := tgChannelUpdate(viewerUserID, event); update != nil {
|
||
updates = append(updates, update)
|
||
}
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
|
||
Date: int(r.clock.Now().Unix()),
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func (r *Router) channelAvailableMessagesUpdates(viewerUserID int64, channel domain.Channel, availableMinID int) *tg.Updates {
|
||
updates := make([]tg.UpdateClass, 0, 1)
|
||
if channel.ID != 0 && availableMinID > 0 {
|
||
updates = append(updates, &tg.UpdateChannelAvailableMessages{
|
||
ChannelID: channel.ID,
|
||
AvailableMinID: availableMinID,
|
||
})
|
||
}
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
|
||
Date: int(r.clock.Now().Unix()),
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
// projectChannelMentionForViewer 把全局消息快照按接收者投影 viewer-specific
|
||
// 的 mentioned/media_unread:被 @ 的在线成员必须在实时推送里就看到 @ 角标,
|
||
// 不能等到离线差量或重开会话才出现。
|
||
func projectChannelMentionForViewer(event domain.ChannelUpdateEvent, mentionUserIDs []int64, viewerUserID int64) domain.ChannelUpdateEvent {
|
||
if viewerUserID == 0 || event.Message.ID == 0 || viewerUserID == event.Message.SenderUserID {
|
||
return event
|
||
}
|
||
for _, id := range mentionUserIDs {
|
||
if id != viewerUserID {
|
||
continue
|
||
}
|
||
// 客户端的未读提及判定要求 mentioned 与 media_unread 同时置位,
|
||
// 与消息是否含媒体无关。
|
||
event.Message.Mentioned = true
|
||
event.Message.MediaUnread = true
|
||
return event
|
||
}
|
||
return event
|
||
}
|
||
|
||
type channelUpdatesBuilder func(viewerUserID int64) *tg.Updates
|
||
|
||
func (r *Router) pushChannelReadOutboxUpdates(ctx context.Context, channelID int64, updates []domain.ChannelReadOutboxUpdate) {
|
||
if r.deps.Sessions == nil || channelID == 0 || len(updates) == 0 {
|
||
return
|
||
}
|
||
seen := make(map[int64]int, len(updates))
|
||
for _, update := range updates {
|
||
if update.UserID == 0 || update.MaxID <= 0 {
|
||
continue
|
||
}
|
||
if seen[update.UserID] < update.MaxID {
|
||
seen[update.UserID] = update.MaxID
|
||
}
|
||
}
|
||
date := int(r.clock.Now().Unix())
|
||
for userID, maxID := range seen {
|
||
r.pushUserUpdates(ctx, userID, &tg.Updates{
|
||
Updates: []tg.UpdateClass{&tg.UpdateReadChannelOutbox{ChannelID: channelID, MaxID: maxID}},
|
||
Date: date,
|
||
Seq: 0,
|
||
})
|
||
}
|
||
}
|
||
|
||
func (r *Router) pushChannelUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
|
||
r.pushChannelUpdatesWithScope(ctx, channelFanoutMembers, originUserID, channelID, recipients, build)
|
||
}
|
||
|
||
func (r *Router) pushChannelViewerUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
|
||
r.pushChannelUpdatesWithScope(ctx, channelFanoutViewers, originUserID, channelID, recipients, build)
|
||
}
|
||
|
||
func (r *Router) pushChannelExplicitUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
|
||
r.pushChannelUpdatesWithScope(ctx, channelFanoutExplicit, originUserID, channelID, recipients, build)
|
||
}
|
||
|
||
func (r *Router) pushChannelUpdatesWithScope(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
|
||
if r.deps.Sessions == nil || build == nil {
|
||
return
|
||
}
|
||
explicit := recipients
|
||
recipients = r.channelFanoutRecipients(ctx, scope, channelID, recipients)
|
||
r.log.Debug("push channel updates fanout",
|
||
zap.Int64("channel_id", channelID),
|
||
zap.Int("scope", int(scope)),
|
||
zap.Int64("origin_user_id", originUserID),
|
||
zap.Int64s("explicit", explicit),
|
||
zap.Int64s("recipients", recipients),
|
||
)
|
||
seen := make(map[int64]struct{}, len(recipients))
|
||
pushed := false
|
||
for _, userID := range recipients {
|
||
if userID == 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[userID]; ok {
|
||
continue
|
||
}
|
||
seen[userID] = struct{}{}
|
||
updates := build(userID)
|
||
if updates == nil {
|
||
continue
|
||
}
|
||
r.pushUserUpdates(ctx, userID, updates)
|
||
pushed = true
|
||
}
|
||
if !pushed && originUserID != 0 {
|
||
updates := build(originUserID)
|
||
if updates == nil {
|
||
return
|
||
}
|
||
r.pushUserUpdates(ctx, originUserID, updates)
|
||
}
|
||
}
|
||
|
||
func tgEmptyUpdates(date int) *tg.Updates {
|
||
return &tg.Updates{
|
||
Updates: []tg.UpdateClass{},
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
}
|