owpengram-server/internal/store/postgres/channel_participant_queue.go
Astra 206bde18e0 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.
2026-09-15 15:43:52 +01:00

149 lines
5.3 KiB
Go

package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// ChannelParticipantQueueStore is the PostgreSQL implementation of
// store.ChannelParticipantQueueStore. It reserves qts from the same
// secret_qts_watermarks table as EncryptedQueueStore (one qts sequence per
// device, shared across event kinds), keeping this table's own schema and
// queries entirely separate from the encrypted-message hot path.
type ChannelParticipantQueueStore struct {
db sqlcgen.DBTX
}
// NewChannelParticipantQueueStore builds the store on a pgx pool or transaction.
func NewChannelParticipantQueueStore(db sqlcgen.DBTX) *ChannelParticipantQueueStore {
return &ChannelParticipantQueueStore{db: db}
}
const channelParticipantEventColumns = `receiver_auth_key_id, qts, receiver_user_id, channel_id, actor_user_id,
date, previous_participant, new_participant`
func scanChannelParticipantEvent(row rowScanner) (domain.DeviceChannelParticipantEvent, error) {
var ev domain.DeviceChannelParticipantEvent
var previousRaw, newRaw []byte
if err := row.Scan(
&ev.ReceiverAuthKeyID, &ev.Qts, &ev.ReceiverUserID, &ev.ChannelID, &ev.ActorUserID,
&ev.Date, &previousRaw, &newRaw,
); err != nil {
return domain.DeviceChannelParticipantEvent{}, err
}
if len(previousRaw) > 0 {
if err := json.Unmarshal(previousRaw, &ev.Previous); err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("unmarshal previous participant: %w", err)
}
}
if len(newRaw) > 0 {
if err := json.Unmarshal(newRaw, &ev.Participant); err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("unmarshal new participant: %w", err)
}
}
return ev, nil
}
func (s *ChannelParticipantQueueStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return nil, fmt.Errorf("%s: db does not support transactions", op)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin %s: %w", op, err)
}
return tx, nil
}
// AppendChannelParticipantEvent reserves the receiving device's next qts
// (shared secret_qts_watermarks sequence) and writes the event, in one
// transaction so the device's qts never has a hole.
func (s *ChannelParticipantQueueStore) AppendChannelParticipantEvent(ctx context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error) {
tx, err := s.begin(ctx, "append channel participant event")
if err != nil {
return domain.DeviceChannelParticipantEvent{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
var qts int
if err := tx.QueryRow(ctx, `
INSERT INTO secret_qts_watermarks (auth_key_id, reserved_qts)
VALUES ($1, 1)
ON CONFLICT (auth_key_id) DO UPDATE SET reserved_qts = secret_qts_watermarks.reserved_qts + 1, updated_at = now()
RETURNING reserved_qts`, ev.ReceiverAuthKeyID).Scan(&qts); err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("reserve device qts: %w", err)
}
ev.Qts = qts
previousRaw, err := marshalJSON(ev.Previous, "null")
if err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("marshal previous participant: %w", err)
}
newRaw, err := marshalJSON(ev.Participant, "null")
if err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("marshal new participant: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO channel_participant_event_queue (receiver_auth_key_id, qts, receiver_user_id, channel_id, actor_user_id,
date, previous_participant, new_participant)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
ev.ReceiverAuthKeyID, ev.Qts, ev.ReceiverUserID, ev.ChannelID, ev.ActorUserID,
ev.Date, previousRaw, newRaw); err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("insert channel participant event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("commit append channel participant event: %w", err)
}
committed = true
return ev, nil
}
func (s *ChannelParticipantQueueStore) ListChannelParticipantEventsSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error) {
if limit <= 0 || limit > 1001 {
limit = 1000
}
rows, err := s.db.Query(ctx,
`SELECT `+channelParticipantEventColumns+` FROM channel_participant_event_queue
WHERE receiver_auth_key_id = $1 AND qts > $2 ORDER BY qts ASC LIMIT $3`,
receiverAuthKeyID, sinceQts, limit)
if err != nil {
return nil, fmt.Errorf("list channel participant events: %w", err)
}
defer rows.Close()
var out []domain.DeviceChannelParticipantEvent
for rows.Next() {
ev, err := scanChannelParticipantEvent(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ChannelParticipantQueueStore) AckChannelParticipantEvents(ctx context.Context, receiverAuthKeyID int64, maxQts int) error {
if maxQts <= 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE channel_participant_event_queue SET acked = true
WHERE receiver_auth_key_id = $1 AND qts <= $2 AND NOT acked`, receiverAuthKeyID, maxQts); err != nil {
return fmt.Errorf("ack channel participant events: %w", err)
}
return nil
}