chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
718
internal/store/postgres/scheduled_message.go
Normal file
718
internal/store/postgres/scheduled_message.go
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxScheduledMessagePage = 100
|
||||
|
||||
// scheduledRetryBackoffSeconds 是后台派发投递失败后的最小重试间隔:投递失败的
|
||||
// pending 行(last_error 非空)在该退避窗口内不再被 ClaimDueScheduledMessages
|
||||
// 认领,避免对端注销等永久失败的到期消息每秒被反复 claim+send。
|
||||
const scheduledRetryBackoffSeconds = 30
|
||||
|
||||
func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) {
|
||||
if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ScheduleDate <= 0 {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid request")
|
||||
}
|
||||
if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer")
|
||||
}
|
||||
if req.Message == "" && req.Media.IsZero() {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message")
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
entities, err := encodeMessageEntities(req.Entities)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
media, err := encodeMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("begin create scheduled message: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("lock scheduled owner: %w", err)
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
existing, ok, err := getPendingScheduledByRandomID(ctx, tx, req.OwnerUserID, req.RandomID)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
if ok {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("commit duplicate scheduled message: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
nextID, err := nextScheduledMessageID(ctx, tx, req.OwnerUserID)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
sendAsType, sendAsID := "", int64(0)
|
||||
if req.SendAs != nil {
|
||||
sendAsType = string(req.SendAs.Type)
|
||||
sendAsID = req.SendAs.ID
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO scheduled_messages (
|
||||
owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
|
||||
body, entities, media, silent, noforwards,
|
||||
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
quote_text, quote_entities, quote_offset,
|
||||
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
|
||||
send_as_peer_type, send_as_peer_id,
|
||||
schedule_date, schedule_repeat_period, state, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8::jsonb, $9::jsonb, $10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17::jsonb, $18,
|
||||
$19, $20, $21, $22,
|
||||
$23, $24,
|
||||
$25, $26, 'pending', $27, $27
|
||||
)`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date,
|
||||
req.Message, entities, media, req.Silent, req.NoForwards,
|
||||
meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID,
|
||||
meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset,
|
||||
meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate,
|
||||
sendAsType, sendAsID,
|
||||
req.ScheduleDate, req.ScheduleRepeatPeriod, req.Date); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("insert scheduled message: %w", err)
|
||||
}
|
||||
if err := setHasScheduledTx(ctx, tx, req.OwnerUserID, req.Peer, true); err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
msg, ok, err := getScheduledByID(ctx, tx, req.OwnerUserID, nextID)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: inserted row missing")
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("commit create scheduled message: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
|
||||
return listScheduledMessages(ctx, s.db, filter, false)
|
||||
}
|
||||
|
||||
func (s *MessageStore) EditScheduledMessage(ctx context.Context, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) {
|
||||
if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ScheduleDate <= 0 {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("edit scheduled message: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("begin edit scheduled message: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("lock scheduled edit owner: %w", err)
|
||||
}
|
||||
current, ok, err := getPendingScheduledForEdit(ctx, tx, req.OwnerUserID, req.Peer, req.ID)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
message := current.Message
|
||||
entities := append([]domain.MessageEntity(nil), current.Entities...)
|
||||
if req.SetMessage {
|
||||
if req.Message == "" && current.Media.IsZero() {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
|
||||
}
|
||||
message = req.Message
|
||||
entities = append([]domain.MessageEntity(nil), req.Entities...)
|
||||
}
|
||||
encodedEntities, err := encodeMessageEntities(entities)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET body = $5,
|
||||
entities = $6::jsonb,
|
||||
schedule_date = $7,
|
||||
updated_at = $8
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = $4
|
||||
AND state = 'pending'
|
||||
RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, req.ScheduleDate, req.Date)
|
||||
msg, err := scanScheduledMessage(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("commit edit scheduled message: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
|
||||
return listScheduledMessages(ctx, s.db, filter, true)
|
||||
}
|
||||
|
||||
func (s *MessageStore) DeleteScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) {
|
||||
if filter.OwnerUserID == 0 || filter.Peer.ID == 0 || len(filter.IDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("delete scheduled messages: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin delete scheduled messages: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
if err := lockUsersForUpdate(ctx, tx, filter.OwnerUserID); err != nil {
|
||||
return nil, fmt.Errorf("lock scheduled delete owner: %w", err)
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET state = 'deleted',
|
||||
updated_at = $5
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = ANY($4::int[])
|
||||
AND state IN ('pending', 'dispatching')
|
||||
RETURNING `+scheduledMessageSelectColumns(), filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, intSliceToInt32(filter.IDs), date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete scheduled messages: %w", err)
|
||||
}
|
||||
deleted, err := scanScheduledMessages(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := refreshHasScheduledTx(ctx, tx, filter.OwnerUserID, filter.Peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit delete scheduled messages: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ClaimScheduledMessages(ctx context.Context, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) {
|
||||
if claim.OwnerUserID == 0 || claim.Peer.ID == 0 || len(claim.IDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if claim.Now == 0 {
|
||||
claim.Now = int(time.Now().Unix())
|
||||
}
|
||||
if claim.LeaseUntil == 0 {
|
||||
claim.LeaseUntil = claim.Now + 60
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET state = 'dispatching',
|
||||
lease_until = $6,
|
||||
updated_at = $5,
|
||||
last_error = ''
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = ANY($4::int[])
|
||||
AND (state = 'pending' OR (state = 'dispatching' AND lease_until <= $5))
|
||||
RETURNING `+scheduledMessageSelectColumns(), claim.OwnerUserID, string(claim.Peer.Type), claim.Peer.ID, intSliceToInt32(claim.IDs), claim.Now, claim.LeaseUntil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim scheduled messages: %w", err)
|
||||
}
|
||||
return scanScheduledMessages(rows)
|
||||
}
|
||||
|
||||
func (s *MessageStore) ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) {
|
||||
if now <= 0 || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > maxScheduledMessagePage {
|
||||
limit = maxScheduledMessagePage
|
||||
}
|
||||
if leaseSeconds <= 0 {
|
||||
leaseSeconds = 60
|
||||
}
|
||||
// retryFloor 让上一次投递失败(last_error 非空)的 pending 行至少退避
|
||||
// scheduledRetryBackoffSeconds 秒再被认领;同时回收 lease 已过期却仍卡在
|
||||
// 'dispatching' 的行——派发器在 claim 之后、MarkScheduledMessageSent 之前
|
||||
// 崩溃/部署/OOM 会留下这种行,旧实现只认领 'pending' 导致该定时消息永久不
|
||||
// 投递且 has_scheduled 永远为真。重投由 random_id 幂等兜底(命中 Duplicate
|
||||
// 仍会 MarkScheduledMessageSent 清状态)。
|
||||
retryFloor := now - scheduledRetryBackoffSeconds
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH due AS (
|
||||
SELECT sm.owner_user_id, sm.scheduled_id
|
||||
FROM scheduled_messages sm
|
||||
WHERE sm.schedule_date <= $1
|
||||
AND (
|
||||
(sm.state = 'pending' AND (sm.last_error = '' OR sm.updated_at <= $4))
|
||||
OR (sm.state = 'dispatching' AND sm.lease_until <= $1)
|
||||
)
|
||||
ORDER BY sm.schedule_date ASC, sm.owner_user_id ASC, sm.scheduled_id ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE scheduled_messages sm
|
||||
SET state = 'dispatching',
|
||||
lease_until = $1 + $3,
|
||||
updated_at = $1,
|
||||
last_error = ''
|
||||
FROM due
|
||||
WHERE sm.owner_user_id = due.owner_user_id
|
||||
AND sm.scheduled_id = due.scheduled_id
|
||||
RETURNING `+scheduledMessageSelectColumnsFor("sm"), now, limit, leaseSeconds, retryFloor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim due scheduled messages: %w", err)
|
||||
}
|
||||
return scanScheduledMessages(rows)
|
||||
}
|
||||
|
||||
func (s *MessageStore) MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error {
|
||||
if ownerUserID == 0 || id <= 0 {
|
||||
return nil
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return fmt.Errorf("mark scheduled sent: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin mark scheduled sent: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
if err := lockUsersForUpdate(ctx, tx, ownerUserID); err != nil {
|
||||
return fmt.Errorf("lock scheduled sent owner: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET state = 'sent',
|
||||
sent_message_id = $3,
|
||||
lease_until = 0,
|
||||
updated_at = $4
|
||||
WHERE owner_user_id = $1
|
||||
AND scheduled_id = $2
|
||||
RETURNING peer_type, peer_id`, ownerUserID, id, sentMessageID, date)
|
||||
var peerType string
|
||||
var peerID int64
|
||||
if err := row.Scan(&peerType, &peerID); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mark scheduled sent: %w", err)
|
||||
}
|
||||
if err := refreshHasScheduledTx(ctx, tx, ownerUserID, domain.Peer{Type: domain.PeerType(peerType), ID: peerID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit mark scheduled sent: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error {
|
||||
if ownerUserID == 0 || id <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET state = 'pending',
|
||||
lease_until = 0,
|
||||
last_error = $3,
|
||||
updated_at = EXTRACT(EPOCH FROM now())::int
|
||||
WHERE owner_user_id = $1
|
||||
AND scheduled_id = $2
|
||||
AND state = 'dispatching'`, ownerUserID, id, errText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("release scheduled message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) HasScheduledMessages(ctx context.Context, ownerUserID int64, peer domain.Peer) (bool, error) {
|
||||
if ownerUserID == 0 || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND state IN ('pending', 'dispatching')
|
||||
LIMIT 1
|
||||
)::boolean`, ownerUserID, string(peer.Type), peer.ID).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("has scheduled messages: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func listScheduledMessages(ctx context.Context, db interface {
|
||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||
}, filter domain.ScheduledMessageFilter, exact bool) (domain.ScheduledMessageList, error) {
|
||||
if filter.OwnerUserID == 0 || filter.Peer.ID == 0 {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > maxScheduledMessagePage {
|
||||
limit = maxScheduledMessagePage
|
||||
}
|
||||
var (
|
||||
rows pgx.Rows
|
||||
err error
|
||||
)
|
||||
if exact {
|
||||
if len(filter.IDs) == 0 {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
rows, err = db.Query(ctx, `
|
||||
SELECT `+scheduledMessageSelectColumns()+`
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = ANY($4::int[])
|
||||
AND state IN ('pending', 'dispatching')
|
||||
ORDER BY schedule_date ASC, scheduled_id ASC
|
||||
LIMIT $5`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, intSliceToInt32(filter.IDs), limit)
|
||||
} else {
|
||||
rows, err = db.Query(ctx, `
|
||||
SELECT `+scheduledMessageSelectColumns()+`
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND state IN ('pending', 'dispatching')
|
||||
ORDER BY schedule_date ASC, scheduled_id ASC
|
||||
LIMIT $4`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.ScheduledMessageList{}, fmt.Errorf("list scheduled messages: %w", err)
|
||||
}
|
||||
msgs, err := scanScheduledMessages(rows)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessageList{}, err
|
||||
}
|
||||
out := domain.ScheduledMessageList{Messages: msgs, Count: len(msgs), Hash: scheduledMessageListHash(msgs)}
|
||||
if filter.Hash != 0 && filter.Hash == out.Hash {
|
||||
out.Messages = nil
|
||||
out.Count = 0
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scheduledMessageSelectColumns() string {
|
||||
return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
|
||||
body, entities::text, media::text, silent, noforwards,
|
||||
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
quote_text, quote_entities::text, quote_offset,
|
||||
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
|
||||
send_as_peer_type, send_as_peer_id,
|
||||
schedule_date, schedule_repeat_period, state, sent_message_id, created_at, updated_at`
|
||||
}
|
||||
|
||||
func scheduledMessageSelectColumnsFor(alias string) string {
|
||||
if alias == "" {
|
||||
return scheduledMessageSelectColumns()
|
||||
}
|
||||
prefix := alias + "."
|
||||
return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date,
|
||||
` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `silent, ` + prefix + `noforwards,
|
||||
` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id,
|
||||
` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset,
|
||||
` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date,
|
||||
` + prefix + `send_as_peer_type, ` + prefix + `send_as_peer_id,
|
||||
` + prefix + `schedule_date, ` + prefix + `schedule_repeat_period, ` + prefix + `state, ` + prefix + `sent_message_id, ` + prefix + `created_at, ` + prefix + `updated_at`
|
||||
}
|
||||
|
||||
func scanScheduledMessages(rows pgx.Rows) ([]domain.ScheduledMessage, error) {
|
||||
defer rows.Close()
|
||||
out := make([]domain.ScheduledMessage, 0)
|
||||
for rows.Next() {
|
||||
msg, err := scanScheduledMessage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate scheduled messages: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.ScheduledMessage, error) {
|
||||
var (
|
||||
msg domain.ScheduledMessage
|
||||
peerType string
|
||||
entitiesJSON string
|
||||
mediaJSON string
|
||||
replyToMsgID int32
|
||||
replyToPeerType string
|
||||
replyToPeerID int64
|
||||
replyToTopID int32
|
||||
quoteText string
|
||||
quoteEntitiesJSON string
|
||||
quoteOffset int32
|
||||
fwdFromPeerType string
|
||||
fwdFromPeerID int64
|
||||
fwdFromName string
|
||||
fwdDate int32
|
||||
sendAsPeerType string
|
||||
sendAsPeerID int64
|
||||
scheduleRepeatPeriod int32
|
||||
)
|
||||
if err := scanner.Scan(
|
||||
&msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt,
|
||||
&msg.Message, &entitiesJSON, &mediaJSON, &msg.Silent, &msg.NoForwards,
|
||||
&replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID,
|
||||
"eText, "eEntitiesJSON, "eOffset,
|
||||
&fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate,
|
||||
&sendAsPeerType, &sendAsPeerID,
|
||||
&msg.ScheduleDate, &scheduleRepeatPeriod, &msg.State, &msg.SentMessageID, &msg.CreatedAt, &msg.UpdatedAt,
|
||||
); err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("scan scheduled message: %w", err)
|
||||
}
|
||||
msg.Peer.Type = domain.PeerType(peerType)
|
||||
msg.ScheduleRepeatPeriod = int(scheduleRepeatPeriod)
|
||||
entities, err := decodeMessageEntities(entitiesJSON)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled entities: %w", err)
|
||||
}
|
||||
msg.Entities = entities
|
||||
media, err := decodeMessageMedia(mediaJSON)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err)
|
||||
}
|
||||
msg.Media = media
|
||||
// scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时
|
||||
// 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。
|
||||
_, _, reply, forward, err := messageMetadataFromFields(
|
||||
msg.Silent, msg.NoForwards, replyToMsgID, replyToPeerType, replyToPeerID, replyToTopID,
|
||||
0, // scheduled_messages 不持久化 story 回复(定时回复 story 是边角,恒 0)
|
||||
quoteText, quoteEntitiesJSON, quoteOffset,
|
||||
fwdFromPeerType, fwdFromPeerID, fwdFromName, fwdDate,
|
||||
"", 0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled metadata: %w", err)
|
||||
}
|
||||
msg.ReplyTo = reply
|
||||
msg.Forward = forward
|
||||
if sendAsPeerType != "" && sendAsPeerID != 0 {
|
||||
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsPeerType), ID: sendAsPeerID}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func getPendingScheduledByRandomID(ctx context.Context, db pgx.Tx, ownerUserID, randomID int64) (domain.ScheduledMessage, bool, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT `+scheduledMessageSelectColumns()+`
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND random_id = $2
|
||||
AND random_id <> 0
|
||||
AND state IN ('pending', 'dispatching')
|
||||
ORDER BY scheduled_id ASC
|
||||
LIMIT 1`, ownerUserID, randomID)
|
||||
msg, err := scanScheduledMessage(row)
|
||||
if err == nil {
|
||||
return msg, true, nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ScheduledMessage{}, false, nil
|
||||
}
|
||||
return domain.ScheduledMessage{}, false, err
|
||||
}
|
||||
|
||||
func getScheduledByID(ctx context.Context, db pgx.Tx, ownerUserID int64, id int) (domain.ScheduledMessage, bool, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT `+scheduledMessageSelectColumns()+`
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND scheduled_id = $2
|
||||
LIMIT 1`, ownerUserID, id)
|
||||
msg, err := scanScheduledMessage(row)
|
||||
if err == nil {
|
||||
return msg, true, nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ScheduledMessage{}, false, nil
|
||||
}
|
||||
return domain.ScheduledMessage{}, false, err
|
||||
}
|
||||
|
||||
func getPendingScheduledForEdit(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer, id int) (domain.ScheduledMessage, bool, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT `+scheduledMessageSelectColumns()+`
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = $4
|
||||
AND state = 'pending'
|
||||
FOR UPDATE`, ownerUserID, string(peer.Type), peer.ID, id)
|
||||
msg, err := scanScheduledMessage(row)
|
||||
if err == nil {
|
||||
return msg, true, nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ScheduledMessage{}, false, nil
|
||||
}
|
||||
return domain.ScheduledMessage{}, false, err
|
||||
}
|
||||
|
||||
func nextScheduledMessageID(ctx context.Context, db pgx.Tx, ownerUserID int64) (int, error) {
|
||||
var id int
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(scheduled_id), 0)::int + 1
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1`, ownerUserID).Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("next scheduled message id: %w", err)
|
||||
}
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return 0, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func setHasScheduledTx(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer, has bool) error {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, err := db.Exec(ctx, `
|
||||
INSERT INTO dialogs (user_id, peer_type, peer_id, has_scheduled)
|
||||
VALUES ($1, 'user', $2, $3)
|
||||
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
|
||||
has_scheduled = EXCLUDED.has_scheduled,
|
||||
updated_at = now()`, ownerUserID, peer.ID, has)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set private has_scheduled: %w", err)
|
||||
}
|
||||
return nil
|
||||
case domain.PeerTypeChannel:
|
||||
_, err := db.Exec(ctx, `
|
||||
UPDATE channel_dialogs
|
||||
SET has_scheduled = $3,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1
|
||||
AND channel_id = $2`, ownerUserID, peer.ID, has)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set channel has_scheduled: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func refreshHasScheduledTx(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer) error {
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM scheduled_messages
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND state IN ('pending', 'dispatching')
|
||||
LIMIT 1
|
||||
)::boolean`, ownerUserID, string(peer.Type), peer.ID).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("refresh scheduled flag: %w", err)
|
||||
}
|
||||
return setHasScheduledTx(ctx, db, ownerUserID, peer, exists)
|
||||
}
|
||||
|
||||
func intSliceToInt32(ids []int) []int32 {
|
||||
out := make([]int32, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 && id <= domain.MaxMessageBoxID {
|
||||
out = append(out, int32(id))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scheduledMessageListHash(messages []domain.ScheduledMessage) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [8]byte
|
||||
for _, msg := range messages {
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(msg.ID))
|
||||
_, _ = h.Write(buf[:])
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(msg.Peer.ID))
|
||||
_, _ = h.Write(buf[:])
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(msg.ScheduleDate))
|
||||
_, _ = h.Write(buf[:])
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(msg.UpdatedAt))
|
||||
_, _ = h.Write(buf[:])
|
||||
}
|
||||
return int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue