channels: give kicked/banned/promoted/transferred users a real qts so their client applies it
updateChannelParticipant carries the account's qts per the MTProto spec, but the server always sent Qts: 0, so real clients silently discarded it as a stale duplicate -- the banned/kicked user's channel never vanished locally and no correct "removed by admin" message showed, even though the update was delivered successfully at the transport layer. Add a durable per-device qts queue (channel_participant_event_queue) sharing its qts number space with the existing secret-chat queue (one qts sequence per device, per spec), and use it to stamp a correct, monotonically increasing qts on the update for every device of the affected user -- for channel bans/kicks, admin promotion/demotion, and ownership transfer. A device offline when it happened can now recover the event via updates.getDifference instead of missing it permanently.
This commit is contained in:
parent
97711c9d2e
commit
206bde18e0
16 changed files with 482 additions and 42 deletions
|
|
@ -383,6 +383,8 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
|||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, res.OldOwner.UserID, res.NewOwner.UserID)
|
||||
r.deliverChannelParticipantSelfEvent(ctx, res.OldOwner.UserID, userID, res.Channel, res.PreviousOwner, res.OldOwner, res.Date)
|
||||
r.deliverChannelParticipantSelfEvent(ctx, res.NewOwner.UserID, userID, res.Channel, res.PreviousNewOwner, res.NewOwner, res.Date)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOwnershipTransferUpdatesWithPeerCache(ctx, userID, userID, res, cache)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -451,6 +452,58 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI
|
|||
}
|
||||
}
|
||||
|
||||
// deliverChannelParticipantSelfEvent durably enqueues, and best-effort live
|
||||
// pushes, a correctly qts-numbered updateChannelParticipant to every device of
|
||||
// the affected (non-actor) user. updateChannelParticipant carries the account's
|
||||
// qts per the MTProto spec; a client discards any update whose qts isn't a
|
||||
// proper increment over its own, so the generic pts-based fanout in
|
||||
// pushChannelUpdates -- which always sends qts=0 -- is silently ignored by
|
||||
// real clients for this specific update type. This is the only path that
|
||||
// actually reaches the affected user's own client, both live and (via
|
||||
// AppendChannelParticipantEvent's durable queue, replayed by
|
||||
// updates.getDifference) after being offline when it happened.
|
||||
func (r *Router) deliverChannelParticipantSelfEvent(ctx context.Context, targetUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int) {
|
||||
if r.deps.SecretChats == nil || r.deps.Auth == nil || targetUserID == 0 || targetUserID == actorUserID {
|
||||
return
|
||||
}
|
||||
auths, err := r.deps.Auth.ListAuthorizations(ctx, targetUserID)
|
||||
if err != nil || len(auths) == 0 {
|
||||
return
|
||||
}
|
||||
targeted, canTarget := r.deps.Sessions.(AuthKeyTargetedSessionBinder)
|
||||
for _, a := range auths {
|
||||
deviceAuthKeyID := businessAuthKeyInt64(a.AuthKeyID)
|
||||
if deviceAuthKeyID == 0 {
|
||||
continue
|
||||
}
|
||||
stored, err := r.deps.SecretChats.AppendChannelParticipantEvent(ctx, domain.DeviceChannelParticipantEvent{
|
||||
ReceiverAuthKeyID: deviceAuthKeyID,
|
||||
ReceiverUserID: targetUserID,
|
||||
ChannelID: channel.ID,
|
||||
ActorUserID: actorUserID,
|
||||
Date: date,
|
||||
Previous: previous,
|
||||
Participant: participant,
|
||||
})
|
||||
if err != nil || stored.Qts == 0 {
|
||||
continue
|
||||
}
|
||||
if !canTarget {
|
||||
continue
|
||||
}
|
||||
upd := r.channelParticipantUpdates(ctx, targetUserID, actorUserID, channel, previous, participant, date)
|
||||
if upd == nil {
|
||||
continue
|
||||
}
|
||||
for _, u := range upd.Updates {
|
||||
if uc, ok := u.(*tg.UpdateChannelParticipant); ok {
|
||||
uc.Qts = stored.Qts
|
||||
}
|
||||
}
|
||||
_, _ = targeted.PushToUserAuthKey(ctx, targetUserID, deviceAuthKeyBytes(deviceAuthKeyID), proto.MessageFromServer, upd)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) {
|
||||
if r.deps.Channels == nil && r.deps.Communities == nil {
|
||||
return nil, notImplementedErr()
|
||||
|
|
@ -511,6 +564,7 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
|
|||
} else {
|
||||
r.removeOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID)
|
||||
}
|
||||
r.deliverChannelParticipantSelfEvent(ctx, res.Participant.UserID, userID, res.Channel, res.Previous, res.Participant, res.Date)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelParticipantUpdatesWithPeerCache(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
|
|
@ -557,6 +611,7 @@ func (r *Router) onChannelsEditBanned(ctx context.Context, req *tg.ChannelsEditB
|
|||
if res.Participant.Status == domain.ChannelMemberKicked && res.Previous.Status == domain.ChannelMemberActive {
|
||||
r.recordChannelStateForUser(ctx, res.Participant.UserID, res.Channel.ID, false)
|
||||
}
|
||||
r.deliverChannelParticipantSelfEvent(ctx, res.Participant.UserID, userID, res.Channel, res.Previous, res.Participant, res.Date)
|
||||
cache := newViewerPeerCache(r)
|
||||
build := func(viewerUserID int64) *tg.Updates {
|
||||
updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
|
||||
|
|
|
|||
|
|
@ -1237,6 +1237,17 @@ type SecretChatService interface {
|
|||
MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error
|
||||
PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error
|
||||
GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error)
|
||||
|
||||
// AppendChannelParticipantEvent durably enqueues a channel-membership
|
||||
// self-notification (kick/ban/promote/transfer) for one device, reserving
|
||||
// that device's next qts from the same watermark as secret chat messages.
|
||||
AppendChannelParticipantEvent(ctx context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error)
|
||||
// ListChannelParticipantEventsSince returns a device's channel-membership
|
||||
// self-notifications with qts > sinceQts (getDifference catch-up).
|
||||
ListChannelParticipantEventsSince(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error)
|
||||
// AckChannelParticipantEvents marks a device's channel-membership
|
||||
// self-notifications up to maxQts as delivered.
|
||||
AckChannelParticipantEvents(ctx context.Context, deviceAuthKeyID int64, maxQts int) error
|
||||
}
|
||||
|
||||
// PhoneService 抽象私聊 1:1 通话信令状态机(app/phone)。所有返回值都是状态快照;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ func newEncryptedFixture(t *testing.T) *encryptedFixture {
|
|||
queueStore := memory.NewEncryptedQueueStore()
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
SecretChats: appsecret.NewService(secretStore, queueStore),
|
||||
SecretChats: appsecret.NewService(secretStore, queueStore, queueStore),
|
||||
Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
Files: &fakeFiles{},
|
||||
Sessions: sessions,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
|
@ -103,6 +104,10 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
|
|||
if err := r.deps.SecretChats.AckQueue(ctx, deviceKey, maxQts); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
// Best-effort GC mark for the sibling channel-participant-event queue,
|
||||
// which shares this device's qts sequence; a failure here doesn't affect
|
||||
// correctness (only retention), so it isn't fatal to the RPC.
|
||||
_ = r.deps.SecretChats.AckChannelParticipantEvents(ctx, deviceKey, maxQts)
|
||||
return []int64{}, nil
|
||||
}
|
||||
|
||||
|
|
@ -143,38 +148,84 @@ func (r *Router) deviceEncryptedQts(ctx context.Context) int {
|
|||
return qts
|
||||
}
|
||||
|
||||
// encryptedDifference 返回当前设备 qts > sinceQts 的连续前缀、推进后的 qts 与是否还有
|
||||
// 下一页。存储错误或 qts gap 必须 fail-fast,禁止越过缺口推进客户端水位。
|
||||
func (r *Router) encryptedDifference(ctx context.Context, sinceQts int) ([]tg.EncryptedMessageClass, int, bool, error) {
|
||||
// encryptedDifference 返回当前设备 qts > sinceQts 的连续前缀(加密消息 +
|
||||
// channel 成员关系自通知,二者共用同一设备 qts 序列,按 qts 归并后统一做缺口
|
||||
// 检查)、推进后的 qts 与是否还有下一页。存储错误或 qts gap 必须 fail-fast,
|
||||
// 禁止越过缺口推进客户端水位。
|
||||
func (r *Router) encryptedDifference(ctx context.Context, sinceQts int) ([]tg.EncryptedMessageClass, []tg.UpdateClass, []int64, []int64, int, bool, error) {
|
||||
if r.deps.SecretChats == nil {
|
||||
return nil, sinceQts, false, nil
|
||||
return nil, nil, nil, nil, sinceQts, false, nil
|
||||
}
|
||||
deviceKey, ok := businessAuthKeyIDFrom(ctx)
|
||||
if !ok {
|
||||
return nil, sinceQts, false, nil
|
||||
return nil, nil, nil, nil, sinceQts, false, nil
|
||||
}
|
||||
msgs, err := r.deps.SecretChats.ListNewMessages(ctx, deviceKey, sinceQts, encryptedDifferencePageSize+1)
|
||||
if err != nil {
|
||||
return nil, sinceQts, false, err
|
||||
return nil, nil, nil, nil, sinceQts, false, err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil, sinceQts, false, nil
|
||||
participantEvents, err := r.deps.SecretChats.ListChannelParticipantEventsSince(ctx, deviceKey, sinceQts, encryptedDifferencePageSize+1)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, sinceQts, false, err
|
||||
}
|
||||
partial := len(msgs) > encryptedDifferencePageSize
|
||||
if partial {
|
||||
msgs = msgs[:encryptedDifferencePageSize]
|
||||
if len(msgs) == 0 && len(participantEvents) == 0 {
|
||||
return nil, nil, nil, nil, sinceQts, false, nil
|
||||
}
|
||||
out := make([]tg.EncryptedMessageClass, 0, len(msgs))
|
||||
items := make([]deviceQtsItem, 0, len(msgs)+len(participantEvents))
|
||||
for i := range msgs {
|
||||
items = append(items, deviceQtsItem{qts: msgs[i].Qts, msg: &msgs[i]})
|
||||
}
|
||||
for i := range participantEvents {
|
||||
items = append(items, deviceQtsItem{qts: participantEvents[i].Qts, participant: &participantEvents[i]})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].qts < items[j].qts })
|
||||
partial := len(msgs) > encryptedDifferencePageSize || len(participantEvents) > encryptedDifferencePageSize
|
||||
if len(items) > encryptedDifferencePageSize {
|
||||
items = items[:encryptedDifferencePageSize]
|
||||
partial = true
|
||||
}
|
||||
encMsgs := make([]tg.EncryptedMessageClass, 0, len(items))
|
||||
participantUpdates := make([]tg.UpdateClass, 0, len(items))
|
||||
participantPeerIDs := make([]int64, 0, len(items))
|
||||
participantChannelIDs := make([]int64, 0, len(items))
|
||||
newQts := sinceQts
|
||||
for i, m := range msgs {
|
||||
for i, item := range items {
|
||||
expected := newQts + 1
|
||||
if m.Qts != expected {
|
||||
return nil, sinceQts, false, fmt.Errorf("secret chat qts gap at index %d: got %d want %d", i, m.Qts, expected)
|
||||
if item.qts != expected {
|
||||
return nil, nil, nil, nil, sinceQts, false, fmt.Errorf("device qts gap at index %d: got %d want %d", i, item.qts, expected)
|
||||
}
|
||||
out = append(out, tgEncryptedMessage(m))
|
||||
newQts = m.Qts
|
||||
if item.msg != nil {
|
||||
encMsgs = append(encMsgs, tgEncryptedMessage(*item.msg))
|
||||
} else {
|
||||
ev := item.participant
|
||||
update := &tg.UpdateChannelParticipant{
|
||||
ChannelID: ev.ChannelID,
|
||||
Date: ev.Date,
|
||||
ActorID: ev.ActorUserID,
|
||||
UserID: ev.Participant.UserID,
|
||||
Qts: ev.Qts,
|
||||
}
|
||||
if ev.Previous.UserID != 0 {
|
||||
update.SetPrevParticipant(tgChannelParticipantForUpdate(ev.ReceiverUserID, ev.Previous))
|
||||
}
|
||||
if ev.Participant.UserID != 0 {
|
||||
update.SetNewParticipant(tgChannelParticipantForUpdate(ev.ReceiverUserID, ev.Participant))
|
||||
}
|
||||
participantUpdates = append(participantUpdates, update, &tg.UpdateChannel{ChannelID: ev.ChannelID})
|
||||
participantPeerIDs = append(participantPeerIDs, ev.ActorUserID, ev.Participant.UserID, ev.Participant.InviterUserID, ev.Previous.UserID, ev.Previous.InviterUserID)
|
||||
participantChannelIDs = append(participantChannelIDs, ev.ChannelID)
|
||||
}
|
||||
newQts = item.qts
|
||||
}
|
||||
return out, newQts, partial, nil
|
||||
return encMsgs, participantUpdates, participantPeerIDs, participantChannelIDs, newQts, partial, nil
|
||||
}
|
||||
|
||||
// deviceQtsItem is one entry in a device's merged qts stream: either an
|
||||
// encrypted message or a channel-participant self-notification, never both.
|
||||
type deviceQtsItem struct {
|
||||
qts int
|
||||
msg *domain.SecretChatMessage
|
||||
participant *domain.DeviceChannelParticipantEvent
|
||||
}
|
||||
|
||||
// injectEncryptedMessages 把加密消息与推进后的 qts 注入差分响应(按类型分别写 State /
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ func TestInvokeWithoutUpdatesBaselineCommitsResultAndSecretEventsWithoutSubscrib
|
|||
authKeyID := [8]byte{21}
|
||||
deviceKey := businessAuthKeyInt64(authKeyID)
|
||||
queue := memory.NewEncryptedQueueStore()
|
||||
secret := appsecret.NewService(memory.NewSecretChatStore(), queue)
|
||||
secret := appsecret.NewService(memory.NewSecretChatStore(), queue, queue)
|
||||
eventID, err := queue.AppendStateEvent(context.Background(), domain.EncryptedStateEvent{
|
||||
TargetUserID: userID,
|
||||
ChatID: 77,
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
|
|||
break
|
||||
}
|
||||
}
|
||||
// 密聊设备级 qts 消息(独立于账号级 pts 事件):按当前设备 req.Qts 补回。
|
||||
encMsgs, newQts, encryptedPartial, err := r.encryptedDifference(ctx, req.Qts)
|
||||
// 密聊设备级 qts 消息 + channel 成员关系自通知(共用同一设备 qts 序列,独立于
|
||||
// 账号级 pts 事件):按当前设备 req.Qts 补回。
|
||||
encMsgs, participantUpdates, participantPeerIDs, participantChannelIDs, newQts, encryptedPartial, err := r.encryptedDifference(ctx, req.Qts)
|
||||
if err != nil {
|
||||
r.log.Error("load secret chat qts difference", zap.Error(err))
|
||||
return nil, internalErr()
|
||||
|
|
@ -130,6 +131,8 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
|
|||
r.log.Error("load secret chat state difference", zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
stateUpdates = append(stateUpdates, participantUpdates...)
|
||||
statePeerUserIDs = append(statePeerUserIDs, participantPeerIDs...)
|
||||
// 账号 pts、设备 qts 和无序号状态事件任一被截断,都必须返回 differenceSlice。
|
||||
st.Partial = st.Partial || encryptedPartial || encryptedStatePartial
|
||||
if !st.Partial && len(st.Events) == 0 && len(st.ChannelNudges) == 0 && len(encMsgs) == 0 && len(stateUpdates) == 0 {
|
||||
|
|
@ -158,12 +161,40 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
|
|||
zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
diff = r.injectChannelParticipantEventChats(ctx, userID, diff, participantChannelIDs)
|
||||
returnedCursor := st.State
|
||||
returnedCursor.Qts = newQts
|
||||
r.stageUpdatesBaselineAfterDelivery(ctx, userID, &returnedCursor, domain.UpdateStateCommitDeliveredOnly, stateEventIDs, true)
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// injectChannelParticipantEventChats resolves each channel referenced by a
|
||||
// recovered channel-participant self-notification through GetChannels (which
|
||||
// already projects Forbidden for a kicked/banned viewer, see
|
||||
// tgChannelChatForView) and appends the resulting chat objects to diff --
|
||||
// the bare updateChannel nudge alone carries no chat payload, and the
|
||||
// receiving client needs the full projection to apply the removal.
|
||||
func (r *Router) injectChannelParticipantEventChats(ctx context.Context, userID int64, diff tg.UpdatesDifferenceClass, channelIDs []int64) tg.UpdatesDifferenceClass {
|
||||
if r.deps.Channels == nil || len(channelIDs) == 0 {
|
||||
return diff
|
||||
}
|
||||
views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs)
|
||||
if err != nil || len(views) == 0 {
|
||||
return diff
|
||||
}
|
||||
chats := make([]tg.ChatClass, 0, len(views))
|
||||
for _, view := range views {
|
||||
chats = append(chats, tgChannelChatForView(userID, view))
|
||||
}
|
||||
switch v := diff.(type) {
|
||||
case *tg.UpdatesDifference:
|
||||
v.Chats = appendUniqueTGChats(v.Chats, chats...)
|
||||
case *tg.UpdatesDifferenceSlice:
|
||||
v.Chats = appendUniqueTGChats(v.Chats, chats...)
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
func (r *Router) accountChannelDifferenceNudges(ctx context.Context, userID int64, sinceDate int) []domain.ChannelDifferenceNudge {
|
||||
if r.deps.Channels == nil || userID == 0 || sinceDate <= 0 {
|
||||
return nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue