234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const (
|
|
maxChannelStarGiftNotificationRecipients = 256
|
|
channelStarGiftNotificationLeaseSeconds = 60
|
|
)
|
|
|
|
type channelStarGiftNotificationJob struct {
|
|
SavedGiftID int64
|
|
TargetUserID int64
|
|
GiftDate int
|
|
Action domain.MessageStarGiftAction
|
|
Attempts int
|
|
}
|
|
|
|
func enqueueChannelStarGiftNotifications(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
savedGiftID int64,
|
|
channelID int64,
|
|
giftDate int,
|
|
action *domain.MessageStarGiftAction,
|
|
) error {
|
|
if savedGiftID <= 0 || channelID <= 0 || giftDate <= 0 || action == nil ||
|
|
action.PeerChannelID != channelID || action.SavedID <= 0 {
|
|
return fmt.Errorf("enqueue channel star gift notifications: invalid intent")
|
|
}
|
|
actionJSON, err := json.Marshal(action)
|
|
if err != nil {
|
|
return fmt.Errorf("encode channel star gift notification: %w", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
WITH candidates AS (
|
|
SELECT creator_user_id AS user_id
|
|
FROM channels
|
|
WHERE id=$2 AND NOT deleted
|
|
UNION
|
|
SELECT user_id
|
|
FROM channel_members
|
|
WHERE channel_id=$2 AND status='active'
|
|
AND (role='creator' OR (
|
|
role='admin'
|
|
AND COALESCE((admin_rights->>'PostMessages')::boolean,false)
|
|
))
|
|
), bounded AS (
|
|
SELECT user_id FROM candidates
|
|
WHERE user_id>0
|
|
ORDER BY user_id
|
|
LIMIT $5
|
|
)
|
|
INSERT INTO star_gift_channel_notification_jobs
|
|
(saved_gift_id,target_user_id,gift_date,action,next_attempt_at)
|
|
SELECT $1,bounded.user_id,$3,$4::jsonb,$3
|
|
FROM bounded
|
|
LEFT JOIN star_gift_notification_settings settings
|
|
ON settings.user_id=bounded.user_id AND settings.channel_id=$2
|
|
WHERE COALESCE(settings.enabled,TRUE)
|
|
ON CONFLICT(saved_gift_id,target_user_id) DO NOTHING`,
|
|
savedGiftID, channelID, giftDate, string(actionJSON), maxChannelStarGiftNotificationRecipients)
|
|
if err != nil {
|
|
return fmt.Errorf("enqueue channel star gift notifications: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *StarGiftLifecycleStore) dispatchChannelStarGiftNotifications(
|
|
ctx context.Context,
|
|
now int,
|
|
limit int,
|
|
savedGiftID int64,
|
|
) (int, error) {
|
|
if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 {
|
|
return 0, domain.ErrStarGiftUnavailable
|
|
}
|
|
if limit > maxChannelStarGiftNotificationRecipients {
|
|
limit = maxChannelStarGiftNotificationRecipients
|
|
}
|
|
jobs, err := s.claimChannelStarGiftNotificationJobs(ctx, now, limit, savedGiftID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var firstErr error
|
|
delivered := 0
|
|
for _, job := range jobs {
|
|
messageID, sendErr := s.deliverChannelStarGiftNotification(ctx, job)
|
|
if sendErr == nil {
|
|
tag, markErr := s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
|
|
SET delivered_at=$3,message_id=$4,lease_until=0,last_error='',updated_at=now()
|
|
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
|
|
job.SavedGiftID, job.TargetUserID, now, messageID)
|
|
if markErr == nil && tag.RowsAffected() == 1 {
|
|
delivered++
|
|
continue
|
|
}
|
|
if markErr == nil {
|
|
markErr = fmt.Errorf("channel star gift notification job disappeared")
|
|
}
|
|
sendErr = markErr
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = sendErr
|
|
}
|
|
retryAt := now + channelStarGiftNotificationRetrySeconds(job.Attempts)
|
|
_, _ = s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
|
|
SET next_attempt_at=$3,lease_until=0,last_error=$4,updated_at=now()
|
|
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
|
|
job.SavedGiftID, job.TargetUserID, retryAt, truncateStarGiftNotificationError(sendErr))
|
|
}
|
|
return delivered, firstErr
|
|
}
|
|
|
|
func (s *StarGiftLifecycleStore) claimChannelStarGiftNotificationJobs(
|
|
ctx context.Context,
|
|
now int,
|
|
limit int,
|
|
savedGiftID int64,
|
|
) ([]channelStarGiftNotificationJob, error) {
|
|
jobs := make([]channelStarGiftNotificationJob, 0, limit)
|
|
err := withTx(ctx, s.db, "claim channel star gift notifications", func(tx pgx.Tx) error {
|
|
rows, err := tx.Query(ctx, `
|
|
WITH picked AS (
|
|
SELECT saved_gift_id,target_user_id
|
|
FROM star_gift_channel_notification_jobs
|
|
WHERE delivered_at=0 AND next_attempt_at<=$1 AND lease_until<$1
|
|
AND ($3::bigint=0 OR saved_gift_id=$3)
|
|
ORDER BY next_attempt_at,saved_gift_id,target_user_id
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT $2
|
|
)
|
|
UPDATE star_gift_channel_notification_jobs job
|
|
SET attempts=job.attempts+1,
|
|
lease_until=$1+$4,
|
|
updated_at=now()
|
|
FROM picked
|
|
WHERE job.saved_gift_id=picked.saved_gift_id
|
|
AND job.target_user_id=picked.target_user_id
|
|
RETURNING job.saved_gift_id,job.target_user_id,job.gift_date,job.action,job.attempts`,
|
|
now, limit, savedGiftID, channelStarGiftNotificationLeaseSeconds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var job channelStarGiftNotificationJob
|
|
var actionJSON []byte
|
|
if err := rows.Scan(&job.SavedGiftID, &job.TargetUserID, &job.GiftDate, &actionJSON, &job.Attempts); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(actionJSON, &job.Action); err != nil {
|
|
return fmt.Errorf("decode channel star gift notification: %w", err)
|
|
}
|
|
if job.SavedGiftID <= 0 || job.TargetUserID <= 0 || job.GiftDate <= 0 ||
|
|
job.Action.PeerChannelID <= 0 || job.Action.SavedID <= 0 {
|
|
return fmt.Errorf("decode channel star gift notification: invalid intent")
|
|
}
|
|
jobs = append(jobs, job)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
return jobs, err
|
|
}
|
|
|
|
func (s *StarGiftLifecycleStore) deliverChannelStarGiftNotification(
|
|
ctx context.Context,
|
|
job channelStarGiftNotificationJob,
|
|
) (int, error) {
|
|
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
|
|
"telesrv:channel-star-gift-notification:v1:%d:%d",
|
|
job.SavedGiftID, job.TargetUserID,
|
|
)))
|
|
action := job.Action
|
|
request := domain.SendPrivateTextRequest{
|
|
SenderUserID: domain.OfficialSystemUserID,
|
|
RecipientUserID: job.TargetUserID,
|
|
RandomID: lifecycleCommandRandomID("channel-star-gift-notification", job.SavedGiftID, job.TargetUserID),
|
|
Date: job.GiftDate,
|
|
IdempotencyFingerprint: fingerprint[:],
|
|
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
|
Kind: domain.MessageServiceActionStarGift,
|
|
StarGift: &action,
|
|
}},
|
|
}
|
|
sent, err := s.messages.sendPrivateTextWithHooks(ctx, request, privateSendTxHooks{
|
|
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
|
if sent.RecipientMessage.ID <= 0 {
|
|
return fmt.Errorf("channel star gift notification missing recipient box")
|
|
}
|
|
return registerChannelNotificationMessageRef(ctx, tx, job.TargetUserID,
|
|
sent.RecipientMessage.ID, job.SavedGiftID)
|
|
},
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if sent.RecipientMessage.ID <= 0 {
|
|
return 0, fmt.Errorf("channel star gift notification replay missing recipient box")
|
|
}
|
|
return sent.RecipientMessage.ID, nil
|
|
}
|
|
|
|
func channelStarGiftNotificationRetrySeconds(attempt int) int {
|
|
if attempt < 1 {
|
|
attempt = 1
|
|
}
|
|
delay := attempt * attempt * 5
|
|
if delay > 3600 {
|
|
return 3600
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func truncateStarGiftNotificationError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
value := strings.TrimSpace(err.Error())
|
|
runes := []rune(value)
|
|
if len(runes) > 1000 {
|
|
value = string(runes[:1000])
|
|
}
|
|
return value
|
|
}
|