owpengram-server/internal/store/postgres/scheduled_message.go
2026-09-09 02:49:30 +03:00

737 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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() && req.RichMessage.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
}
richMessage, err := encodeRichMessage(req.RichMessage)
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, rich_message, 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::jsonb, $11, $12,
$13, $14, $15, $16,
$17, $18::jsonb, $19,
$20, $21, $22, $23,
$24, $25,
$26, $27, 'pending', $28, $28
)`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date,
req.Message, entities, media, richMessage, 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...)
richMessage := current.RichMessage
if req.SetMessage {
message = req.Message
entities = append([]domain.MessageEntity(nil), req.Entities...)
}
if req.SetRichMessage {
richMessage = req.RichMessage
}
if message == "" && current.Media.IsZero() && richMessage.IsZero() {
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
}
encodedEntities, err := encodeMessageEntities(entities)
if err != nil {
return domain.ScheduledMessage{}, err
}
encodedRichMessage, err := encodeRichMessage(richMessage)
if err != nil {
return domain.ScheduledMessage{}, err
}
row := tx.QueryRow(ctx, `
UPDATE scheduled_messages
SET body = $5,
entities = $6::jsonb,
rich_message = $7::jsonb,
schedule_date = $8,
updated_at = $9
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, encodedRichMessage, 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, rich_message::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 + `rich_message::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
richMessageJSON 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, &richMessageJSON, &msg.Silent, &msg.NoForwards,
&replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID,
&quoteText, &quoteEntitiesJSON, &quoteOffset,
&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
richMessage, err := decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled rich message: %w", err)
}
msg.RichMessage = richMessage
// scheduled_messages 不存 saved_from到点投递经 SendPrivateText 实时
// 重算 saved 语义self-chat 直发归 selffwd 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)
}