fix(channels): sync preserve locally cleared dialogs

This commit is contained in:
iamxvbaba 2026-07-29 16:19:04 +08:00
parent e22fac1c3f
commit 73cf6a8184
50 changed files with 1525 additions and 327 deletions

View file

@ -394,10 +394,14 @@ func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDe
return nil, channelDeleteErr(err)
}
if res.Event.Pts == 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID)
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event)
r.pushUserUpdates(ctx, userID, updates)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
if res.AvailableMinChanged {
// updateChannelAvailableMessages is an absolute owner-local boundary
// with no account/channel pts. Other online sessions consume it
// immediately; future cold/offline sessions discover the same
// boundary through account/channel difference recovery.
r.pushUserUpdates(ctx, userID, updates)
}
return updates, nil
}
pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates {

View file

@ -174,6 +174,7 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
@ -193,7 +194,14 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
if err != nil {
t.Fatalf("send channel message: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
newChannelUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
msg := newChannelUpdate.Message.(*tg.Message)
clearSince := int(time.Now().Unix()) - 1
stateBefore, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
pushesBefore := len(sessions.pushedUserIDs())
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
@ -202,42 +210,144 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("delete channel history local: %v", err)
}
updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 2 {
t.Fatalf("clear response = %T %+v, want available update plus pts bookkeeping", cleared, cleared)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want one no-pts available update", cleared, cleared)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID {
t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID)
}
// updateChannelAvailableMessages 不带账号 pts事件占用的 pts 槽位
// 必须用空 updateDeleteMessages 显式同步给客户端。
bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages)
if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts <= 0 || bookkeeping.PtsCount != 1 {
t.Fatalf("clear bookkeeping = %#v, want empty updateDeleteMessages carrying the account pts step", updates.Updates[1])
stateAfter, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("account pts advanced on local channel clear: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
if pushed.userID != owner.ID {
t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 2 {
t.Fatalf("pushed clear update = %T %+v, want available update plus pts bookkeeping", pushed.message, pushed.message)
if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("pushed clear update = %T %+v, want one no-pts available update", pushed.message, pushed.message)
}
if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok {
t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0])
}
diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0})
if got := len(sessions.pushedUserIDs()) - pushesBefore; got != 1 {
t.Fatalf("clear online pushes = %d, want exactly one owner-session fanout", got)
}
diff, err := updateSvc.GetDifference(ctx, [8]byte{}, owner.ID, stateBefore)
if err != nil {
t.Fatalf("get difference: %v", err)
}
full, ok := diff.(*tg.UpdatesDifference)
if !ok || len(full.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v, want one other update", diff, diff)
if len(diff.Events) != 0 || diff.State.Pts != stateBefore.Pts {
t.Fatalf("difference after no-pts clear = %+v, want no durable account event", diff)
}
if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID {
t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0])
offline, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{
Pts: stateBefore.Pts,
Date: clearSince,
})
if err != nil {
t.Fatalf("account difference after offline local clear: %v", err)
}
offlineDiff, ok := offline.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("offline account difference = %T %+v, want updates.difference", offline, offline)
}
var accountAvailable *tg.UpdateChannelAvailableMessages
for _, update := range offlineDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
accountAvailable = value
break
}
}
if accountAvailable == nil ||
accountAvailable.ChannelID != channel.ID ||
accountAvailable.AvailableMinID != msg.ID {
t.Fatalf("offline account updates = %+v, want updateChannelAvailableMessages channel=%d min=%d",
offlineDiff.OtherUpdates, channel.ID, msg.ID)
}
if offlineDiff.State.Pts != stateBefore.Pts || offlineDiff.State.Date < clearSince {
t.Fatalf("offline account state = %+v, want unchanged pts=%d and non-regressing date", offlineDiff.State, stateBefore.Pts)
}
channelOffline, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newChannelUpdate.Pts,
Limit: 100,
})
if err != nil {
t.Fatalf("channel difference after offline local clear: %v", err)
}
channelDiff, ok := channelOffline.(*tg.UpdatesChannelDifference)
if !ok {
t.Fatalf("offline channel difference = %T %+v, want non-empty difference with absolute update", channelOffline, channelOffline)
}
var channelAvailable *tg.UpdateChannelAvailableMessages
for _, update := range channelDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
channelAvailable = value
break
}
}
if channelAvailable == nil ||
channelAvailable.ChannelID != channel.ID ||
channelAvailable.AvailableMinID != msg.ID ||
channelDiff.Pts != newChannelUpdate.Pts {
t.Fatalf("offline channel updates = %+v pts=%d, want available boundary %d with unchanged channel pts=%d",
channelDiff.OtherUpdates, channelDiff.Pts, msg.ID, newChannelUpdate.Pts)
}
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs after clear: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs after clear: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Messages) != 1 {
t.Fatalf("dialogs after cold projection = %T %+v, want one anchored dialog/message", enc, enc)
}
dialog := dialogs.Dialogs[0].(*tg.Dialog)
service, ok := dialogs.Messages[0].(*tg.MessageService)
if !ok || dialog.TopMessage != msg.ID || service.ID != msg.ID {
t.Fatalf("cold dialog projection = dialog=%+v message=%T %+v, want history-clear top %d", dialog, dialogs.Messages[0], dialogs.Messages[0], msg.ID)
}
if _, ok := service.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold dialog service action = %T, want messageActionHistoryClear", service.Action)
}
historyReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 20,
}
b.Reset()
if err := historyReq.Encode(&b); err != nil {
t.Fatalf("encode get history after clear: %v", err)
}
historyEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get history after clear: %v", err)
}
history, ok := historyEnc.(*tg.MessagesChannelMessages)
if !ok || len(history.Messages) != 1 {
t.Fatalf("history after cold projection = %T %+v, want one history-clear marker", historyEnc, historyEnc)
}
historyService, ok := history.Messages[0].(*tg.MessageService)
if !ok || historyService.ID != msg.ID {
t.Fatalf("cold history projection = %T %+v, want service marker %d", history.Messages[0], history.Messages[0], msg.ID)
}
if _, ok := historyService.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold history service action = %T, want messageActionHistoryClear", historyService.Action)
}
stalePushesBefore := len(sessions.pushedUserIDs())
stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID - 1,
@ -246,13 +356,16 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("stale delete channel history local: %v", err)
}
staleUpdates, ok := stale.(*tg.Updates)
if !ok || len(staleUpdates.Updates) != 2 {
t.Fatalf("stale clear response = %T %+v, want monotonic update plus pts bookkeeping", stale, stale)
if !ok || len(staleUpdates.Updates) != 1 {
t.Fatalf("stale clear response = %T %+v, want monotonic absolute update", stale, stale)
}
staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID {
t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID)
}
if got := len(sessions.pushedUserIDs()); got != stalePushesBefore {
t.Fatalf("stale clear pushed another online update: before=%d after=%d", stalePushesBefore, got)
}
}
func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) {

View file

@ -290,27 +290,6 @@ func peerIDsExcept(ids []int64, skipIDs ...int64) []int64 {
type channelFanoutScope int
func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent {
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventChannelAvailable,
Date: int(r.clock.Now().Unix()),
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}
if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 {
return event
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return event
}
return recorded
}
func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) {
if !read.Changed || read.ChannelID == 0 {
return domain.UpdateEvent{}, nil

View file

@ -222,6 +222,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
switch action.Type {
case domain.ChannelActionCreate:
return &tg.MessageActionChannelCreate{Title: action.Title}
case domain.ChannelActionHistoryClear:
return &tg.MessageActionHistoryClear{}
case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined:
return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)}
case domain.ChannelActionChatJoinedByLink:

View file

@ -55,11 +55,23 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
if nudge.ChannelID == 0 {
continue
}
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
if nudge.AvailableMinID > 0 {
out.OtherUpdates = append(out.OtherUpdates, &tg.UpdateChannelAvailableMessages{
ChannelID: nudge.ChannelID,
AvailableMinID: nudge.AvailableMinID,
})
}
// Preserve compatibility for older domain callers that only supplied
// Pts: a nudge without an owner-local boundary is a shared channel
// update nudge. New store results set ChannelUpdatesDirty explicitly so
// a channel can carry both absolute clear and too-long updates.
if nudge.ChannelUpdatesDirty || nudge.AvailableMinID == 0 {
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
}
out.OtherUpdates = append(out.OtherUpdates, update)
}
out.OtherUpdates = append(out.OtherUpdates, update)
if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 {
addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel))
}
@ -110,7 +122,7 @@ func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.U
Users: tgUsersForViewer(viewerUserID, diff.Users),
}
}
if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 {
if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 && diff.AvailableMinID == 0 {
return &tg.UpdatesChannelDifferenceEmpty{
Final: diff.Final,
Pts: diff.Pts,
@ -136,6 +148,12 @@ func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.U
updates = append(updates, update)
}
}
if diff.AvailableMinID > 0 {
updates = append(updates, &tg.UpdateChannelAvailableMessages{
ChannelID: diff.Channel.ID,
AvailableMinID: diff.AvailableMinID,
})
}
chats := tgChannelDifferenceChats(viewerUserID, diff)
users := tgUsersForViewer(viewerUserID, diff.Users)
return &tg.UpdatesChannelDifference{
@ -483,14 +501,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventChannelAvailable:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 {
return nil
}
return &tg.UpdateChannelAvailableMessages{
ChannelID: event.Peer.ID,
AvailableMinID: event.MaxID,
}
default:
return nil
}

View file

@ -496,7 +496,6 @@ type UpdatesService interface {
RecordDialogFilterOrder(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelDiscussionInbox(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)

View file

@ -74,16 +74,13 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
})
return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil
}
if res.AvailableMinID > 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID)
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event)
if res.AvailableMinChanged && res.AvailableMinID > 0 {
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
r.pushUserUpdates(ctx, userID, updates)
if event.Pts != 0 {
return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil
}
}
return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil
// messages.affectedHistory.pts is the caller's account-state snapshot.
// The local channel clear itself consumes no account/channel pts.
return r.affectedHistory(ctx, authKeyID, userID, res.Offset)
}
if peer.Type != domain.PeerTypeUser {
return nil, peerIDInvalidErr()

View file

@ -7,11 +7,74 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestMessagesDeleteHistoryChannelLocalClearReturnsAccountStateWithoutPTS(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 7,
Title: "local clear no pts",
Megagroup: true,
Date: 1_700_002_100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 7,
ChannelID: created.Channel.ID,
RandomID: 210_001,
Message: "clear locally",
Date: 1_700_002_101,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
stateBefore, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Channels: appchannels.NewService(channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, 7), &tg.MessagesDeleteHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
MaxID: sent.Message.ID,
})
if err != nil {
t.Fatalf("messages.deleteHistory local channel: %v", err)
}
if affected.Pts != stateBefore.Pts || affected.PtsCount != 0 || affected.Offset != 0 {
t.Fatalf("affected history = %+v, want account pts=%d pts_count=0 offset=0", affected, stateBefore.Pts)
}
stateAfter, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("local channel clear advanced account pts: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
updates, ok := pushed.message.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("pushed local clear = %T %+v, want one available update", pushed.message, pushed.message)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != created.Channel.ID || available.AvailableMinID != sent.Message.ID {
t.Fatalf("available update = %#v, want channel=%d min=%d", updates.Updates[0], created.Channel.ID, sent.Message.ID)
}
}
func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()

View file

@ -604,14 +604,15 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
return &tg.MessagesMessages{}, nil
}
history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{
ChannelID: filter.Peer.ID,
OffsetID: filter.OffsetID,
OffsetDate: filter.OffsetDate,
AddOffset: filter.AddOffset,
Limit: filter.Limit,
MaxID: filter.MaxID,
MinID: filter.MinID,
Hash: filter.Hash,
ChannelID: filter.Peer.ID,
OffsetID: filter.OffsetID,
OffsetDate: filter.OffsetDate,
AddOffset: filter.AddOffset,
Limit: filter.Limit,
MaxID: filter.MaxID,
MinID: filter.MinID,
Hash: filter.Hash,
IncludeHistoryClearAnchor: true,
})
if err != nil {
return nil, channelInvalidErr(err)

View file

@ -284,15 +284,6 @@ func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte,
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)})
}
func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
})
}
func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{

View file

@ -102,6 +102,21 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
return nil, internalErr()
}
st.ChannelNudges = r.accountChannelDifferenceNudges(ctx, userID, req.Date)
if !st.Partial {
for _, nudge := range st.ChannelNudges {
if nudge.AvailableMinID <= 0 {
continue
}
// No-PTS owner-local clear recovery is date-indexed. Advance only
// on the final account page and never beyond the server clock; the
// indexed query deliberately overlaps equality, so a same-second
// reconnect can receive an idempotent duplicate rather than miss.
if now := int(r.clock.Now().Unix()); now > st.State.Date {
st.State.Date = now
}
break
}
}
// 密聊设备级 qts 消息(独立于账号级 pts 事件):按当前设备 req.Qts 补回。
encMsgs, newQts := r.encryptedDifference(ctx, req.Qts)
// 密聊握手/已读状态事件(无 qts按未投递标记补回 OtherUpdates。
@ -154,10 +169,19 @@ func (r *Router) accountChannelDifferenceNudges(ctx context.Context, userID int6
}
}
for _, item := range dirty {
if len(out) >= maxNudges {
break
}
if item.ChannelID == 0 {
continue
}
nudge := domain.ChannelDifferenceNudge{ChannelID: item.ChannelID, Pts: item.Pts}
nudge := domain.ChannelDifferenceNudge{
ChannelID: item.ChannelID,
Pts: item.Pts,
ChannelUpdatesDirty: item.ChannelUpdatesDirty,
AvailableMinID: item.AvailableMinID,
HistoryClearDate: item.HistoryClearDate,
}
if view, ok := viewsByID[item.ChannelID]; ok {
nudge.Channel = &view
}