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:
Astra 2026-09-15 15:43:52 +01:00
parent 97711c9d2e
commit 206bde18e0
16 changed files with 482 additions and 42 deletions

View file

@ -114,17 +114,20 @@ func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, auth
return out, nil
}
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。也实现
// store.ChannelParticipantQueueStore两者共用同一个 reserved 水位 map一台
// 设备一条 qts 序列),与 postgres 两张表共用一张 secret_qts_watermarks 对齐。
type EncryptedQueueStore struct {
mu sync.Mutex
byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息
reserved map[int64]int
confirmed map[int64]int
dedup map[emqDedupKey]int // → qts
stateEvents []domain.EncryptedStateEvent
delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true
nextEventID int64
files map[int64]domain.EncryptedFileRef // file id → 快照
mu sync.Mutex
byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息
byDeviceParticipant map[int64][]domain.DeviceChannelParticipantEvent // receiverAuthKeyID → qts 升序事件
reserved map[int64]int
confirmed map[int64]int
dedup map[emqDedupKey]int // → qts
stateEvents []domain.EncryptedStateEvent
delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true
nextEventID int64
files map[int64]domain.EncryptedFileRef // file id → 快照
}
type emqDedupKey struct {
@ -136,14 +139,56 @@ type emqDedupKey struct {
// NewEncryptedQueueStore 创建内存实现。
func NewEncryptedQueueStore() *EncryptedQueueStore {
return &EncryptedQueueStore{
byDevice: make(map[int64][]domain.SecretChatMessage),
reserved: make(map[int64]int),
confirmed: make(map[int64]int),
dedup: make(map[emqDedupKey]int),
delivered: make(map[int64]map[int64]bool),
byDevice: make(map[int64][]domain.SecretChatMessage),
byDeviceParticipant: make(map[int64][]domain.DeviceChannelParticipantEvent),
reserved: make(map[int64]int),
confirmed: make(map[int64]int),
dedup: make(map[emqDedupKey]int),
delivered: make(map[int64]map[int64]bool),
}
}
func cloneChannelParticipantEvent(ev domain.DeviceChannelParticipantEvent) domain.DeviceChannelParticipantEvent {
return ev
}
// AppendChannelParticipantEvent implements store.ChannelParticipantQueueStore,
// reserving from the same per-device qts counter as AppendEncryptedMessage.
func (s *EncryptedQueueStore) AppendChannelParticipantEvent(_ context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reserved[ev.ReceiverAuthKeyID]++
ev.Qts = s.reserved[ev.ReceiverAuthKeyID]
stored := cloneChannelParticipantEvent(ev)
s.byDeviceParticipant[ev.ReceiverAuthKeyID] = append(s.byDeviceParticipant[ev.ReceiverAuthKeyID], stored)
return cloneChannelParticipantEvent(stored), nil
}
func (s *EncryptedQueueStore) ListChannelParticipantEventsSince(_ context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 {
limit = 1000
}
var out []domain.DeviceChannelParticipantEvent
for _, ev := range s.byDeviceParticipant[receiverAuthKeyID] {
if ev.Qts > sinceQts {
out = append(out, cloneChannelParticipantEvent(ev))
if len(out) >= limit {
break
}
}
}
return out, nil
}
// AckChannelParticipantEvents is a no-op in the memory store: unlike postgres
// it does no row-level GC, and confirmed_qts is already advanced by
// AckEncryptedMessages against the shared reserved/confirmed watermark.
func (s *EncryptedQueueStore) AckChannelParticipantEvents(_ context.Context, _ int64, _ int) error {
return nil
}
func cloneSecretMessage(m domain.SecretChatMessage) domain.SecretChatMessage {
m.Bytes = append([]byte(nil), m.Bytes...)
if m.File != nil {