Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery). Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
683 lines
24 KiB
Go
683 lines
24 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/postgres/sqlcgen"
|
||
)
|
||
|
||
// BotAPIUpdateStore persists Bot API getUpdates queues in PostgreSQL.
|
||
type BotAPIUpdateStore struct {
|
||
db sqlcgen.DBTX
|
||
}
|
||
|
||
func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
|
||
return &BotAPIUpdateStore{db: db}
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
|
||
return fmt.Errorf("invalid bot api webhook")
|
||
}
|
||
var allowed []string
|
||
if len(config.AllowedUpdates) > 0 {
|
||
allowed = make([]string, 0, len(config.AllowedUpdates))
|
||
for _, kind := range config.AllowedUpdates {
|
||
if kind != "" {
|
||
allowed = append(allowed, string(kind))
|
||
}
|
||
}
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
WITH policy AS (
|
||
SELECT CASE WHEN $6::boolean THEN $5::text[]
|
||
ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1)
|
||
END AS allowed_updates
|
||
), configured AS (
|
||
INSERT INTO bot_api_webhooks (
|
||
bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||
failure_count, last_error_date, last_error_message, next_attempt_at,
|
||
delivery_owner, delivery_expires_at, updated_at
|
||
)
|
||
SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now()
|
||
FROM policy
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET url = EXCLUDED.url,
|
||
secret_token = EXCLUDED.secret_token,
|
||
max_connections = EXCLUDED.max_connections,
|
||
allowed_updates = EXCLUDED.allowed_updates,
|
||
failure_count = 0,
|
||
last_error_date = 0,
|
||
last_error_message = '',
|
||
next_attempt_at = now(),
|
||
delivery_owner = '',
|
||
delivery_expires_at = NULL,
|
||
updated_at = now()
|
||
RETURNING bot_user_id
|
||
), boundary AS (
|
||
SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
)
|
||
INSERT INTO bot_api_update_states (
|
||
bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized
|
||
)
|
||
SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean
|
||
FROM boundary, configured, policy
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET confirmed_update_id = CASE WHEN $7::boolean
|
||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||
ELSE bot_api_update_states.confirmed_update_id
|
||
END,
|
||
allowed_updates = EXCLUDED.allowed_updates,
|
||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||
updated_at = now()
|
||
`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed,
|
||
config.AllowedUpdatesSet, dropPending); err != nil {
|
||
return fmt.Errorf("set bot api webhook: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error {
|
||
if botUserID <= 0 {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
WITH deleted AS (
|
||
DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id
|
||
), boundary AS (
|
||
SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
)
|
||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||
SELECT $1, confirmed_update_id, $2::boolean
|
||
FROM boundary
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET confirmed_update_id = CASE WHEN $2::boolean
|
||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||
ELSE bot_api_update_states.confirmed_update_id
|
||
END,
|
||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||
updated_at = now()
|
||
`, botUserID, dropPending); err != nil {
|
||
return fmt.Errorf("delete bot api webhook: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
|
||
config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, `
|
||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||
FROM bot_api_webhooks
|
||
WHERE bot_user_id = $1
|
||
`, botUserID))
|
||
if err == pgx.ErrNoRows {
|
||
return domain.BotAPIWebhook{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err)
|
||
}
|
||
return config, true, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||
if limit <= 0 || limit > 1000 {
|
||
limit = 100
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||
FROM bot_api_webhooks
|
||
WHERE next_attempt_at <= now()
|
||
AND (delivery_owner = '' OR delivery_expires_at <= now())
|
||
ORDER BY next_attempt_at, bot_user_id
|
||
LIMIT $1
|
||
`, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list due bot api webhooks: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.BotAPIWebhook, 0, limit)
|
||
for rows.Next() {
|
||
config, err := scanBotAPIWebhook(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, config)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("list due bot api webhook rows: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||
return false, fmt.Errorf("invalid bot api webhook lease")
|
||
}
|
||
var acquiredOwner string
|
||
err := s.db.QueryRow(ctx, `
|
||
UPDATE bot_api_webhooks
|
||
SET delivery_owner = $2,
|
||
delivery_expires_at = now() + make_interval(secs => $3),
|
||
updated_at = now()
|
||
WHERE bot_user_id = $1
|
||
AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now())
|
||
RETURNING delivery_owner
|
||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||
if err == pgx.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, fmt.Errorf("acquire bot api webhook lease: %w", err)
|
||
}
|
||
return acquiredOwner == owner, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error {
|
||
if botUserID <= 0 || owner == "" {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
UPDATE bot_api_webhooks
|
||
SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||
`, botUserID, owner); err != nil {
|
||
return fmt.Errorf("release bot api webhook lease: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
|
||
if len(message) > 512 {
|
||
message = message[:512]
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
UPDATE bot_api_webhooks
|
||
SET failure_count = failure_count + 1,
|
||
last_error_date = EXTRACT(EPOCH FROM now())::integer,
|
||
last_error_message = $3,
|
||
next_attempt_at = $4,
|
||
delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||
`, botUserID, owner, message, nextAttempt); err != nil {
|
||
return fmt.Errorf("record bot api webhook failure: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
|
||
if _, err := s.db.Exec(ctx, `
|
||
UPDATE bot_api_webhooks
|
||
SET failure_count = 0, last_error_date = 0, last_error_message = '',
|
||
next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||
`, botUserID, owner, nextAttempt); err != nil {
|
||
return fmt.Errorf("record bot api webhook success: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) {
|
||
var config domain.BotAPIWebhook
|
||
var allowed []string
|
||
if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed,
|
||
&config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil {
|
||
return domain.BotAPIWebhook{}, err
|
||
}
|
||
if allowed != nil {
|
||
config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed))
|
||
for _, kind := range allowed {
|
||
config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind))
|
||
}
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||
return false, fmt.Errorf("invalid bot api poll lease")
|
||
}
|
||
var acquiredOwner string
|
||
err := s.db.QueryRow(ctx, `
|
||
INSERT INTO bot_api_update_states (
|
||
bot_user_id, confirmed_update_id, poll_owner, poll_expires_at
|
||
) VALUES ($1, 0, $2, now() + make_interval(secs => $3))
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET poll_owner = EXCLUDED.poll_owner,
|
||
poll_expires_at = EXCLUDED.poll_expires_at,
|
||
updated_at = now()
|
||
WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner
|
||
OR bot_api_update_states.poll_expires_at IS NULL
|
||
OR bot_api_update_states.poll_expires_at <= now()
|
||
RETURNING poll_owner
|
||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||
if err == pgx.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, fmt.Errorf("acquire bot api poll lease: %w", err)
|
||
}
|
||
return acquiredOwner == owner, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error {
|
||
if botUserID <= 0 || owner == "" {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
UPDATE bot_api_update_states
|
||
SET poll_owner = '', poll_expires_at = NULL, updated_at = now()
|
||
WHERE bot_user_id = $1 AND poll_owner = $2
|
||
`, botUserID, owner); err != nil {
|
||
return fmt.Errorf("release bot api poll lease: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
|
||
if err := validateBotAPIUpdateRequest(req); err != nil {
|
||
return domain.BotAPIUpdate{}, false, err
|
||
}
|
||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||
var callbackInlineDCID, callbackInlineMessageID int
|
||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||
var callbackData []byte
|
||
if req.Callback != nil {
|
||
callbackQueryID = req.Callback.ID
|
||
callbackUserID = req.Callback.UserID
|
||
callbackChatInstance = req.Callback.ChatInstance
|
||
callbackData = req.Callback.Data
|
||
if req.Callback.InlineMessage != nil {
|
||
callbackInlineDCID = req.Callback.InlineMessage.DCID
|
||
callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID
|
||
callbackInlineMessageID = req.Callback.InlineMessage.ID
|
||
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
|
||
}
|
||
}
|
||
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||
WITH inserted AS (
|
||
INSERT INTO bot_api_updates (
|
||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||
WHERE NOT EXISTS (
|
||
SELECT 1
|
||
FROM bot_api_update_states
|
||
WHERE bot_user_id = $1
|
||
AND allowed_updates IS NOT NULL
|
||
AND NOT ($2::text = ANY(allowed_updates))
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
), wake_webhook AS (
|
||
UPDATE bot_api_webhooks
|
||
SET next_attempt_at = now(), updated_at = now()
|
||
WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted)
|
||
RETURNING bot_user_id
|
||
)
|
||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
FROM inserted
|
||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
|
||
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
|
||
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash))
|
||
if err == nil {
|
||
return row, true, nil
|
||
}
|
||
if err != pgx.ErrNoRows {
|
||
return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
|
||
}
|
||
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
AND update_kind = $2
|
||
AND (
|
||
(update_kind = 'callback_query' AND callback_query_id = $7)
|
||
OR
|
||
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND source_pts = $6)
|
||
)
|
||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID))
|
||
if err != nil {
|
||
if err == pgx.ErrNoRows {
|
||
return domain.BotAPIUpdate{}, false, nil
|
||
}
|
||
return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
|
||
}
|
||
return row, false, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
|
||
if botUserID == 0 || tail <= 0 {
|
||
return nil, nil
|
||
}
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 100
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
FROM (
|
||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||
ORDER BY id DESC
|
||
LIMIT $2
|
||
) AS tail_updates
|
||
ORDER BY id
|
||
LIMIT $3
|
||
`, botUserID, tail, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list bot api tail updates: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.BotAPIUpdate, 0, limit)
|
||
for rows.Next() {
|
||
item, err := scanBotAPIUpdateRows(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("list bot api tail update rows: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
|
||
if botUserID == 0 {
|
||
return nil, nil
|
||
}
|
||
if fromUpdateID <= 0 {
|
||
fromUpdateID = 1
|
||
}
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 100
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1 AND id >= $2
|
||
ORDER BY id
|
||
LIMIT $3
|
||
`, botUserID, fromUpdateID, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list bot api updates: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.BotAPIUpdate, 0, limit)
|
||
for rows.Next() {
|
||
item, err := scanBotAPIUpdateRows(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("list bot api updates rows: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error {
|
||
if botUserID == 0 || confirmedUpdateID <= 0 {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
WITH bounded AS (
|
||
SELECT COALESCE(MAX(id), 0) AS max_update_id,
|
||
LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
)
|
||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||
SELECT $1, confirmed_update_id, true
|
||
FROM bounded
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET confirmed_update_id = GREATEST(
|
||
bot_api_update_states.confirmed_update_id,
|
||
CASE
|
||
WHEN $2::bigint > (SELECT max_update_id FROM bounded)
|
||
AND bot_api_update_states.cursor_initialized
|
||
THEN bot_api_update_states.confirmed_update_id
|
||
ELSE EXCLUDED.confirmed_update_id
|
||
END
|
||
),
|
||
cursor_initialized = true,
|
||
updated_at = now()
|
||
`, botUserID, confirmedUpdateID); err != nil {
|
||
return fmt.Errorf("confirm bot api updates: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
|
||
if botUserID == 0 {
|
||
return nil
|
||
}
|
||
var values []string
|
||
if len(allowed) > 0 {
|
||
values = make([]string, 0, len(allowed))
|
||
for _, kind := range allowed {
|
||
if kind != "" {
|
||
values = append(values, string(kind))
|
||
}
|
||
}
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates)
|
||
VALUES ($1, 0, $2::text[])
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET allowed_updates = EXCLUDED.allowed_updates,
|
||
updated_at = now()
|
||
`, botUserID, values); err != nil {
|
||
return fmt.Errorf("set bot api allowed updates: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
|
||
if botUserID == 0 {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||
SELECT $1, COALESCE(MAX(id), 0), true
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
ON CONFLICT (bot_user_id) DO UPDATE
|
||
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
|
||
cursor_initialized = true,
|
||
updated_at = now()
|
||
`, botUserID); err != nil {
|
||
return fmt.Errorf("drop pending bot api updates: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) {
|
||
if botUserID == 0 {
|
||
return 0, nil
|
||
}
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM bot_api_updates
|
||
WHERE bot_user_id = $1
|
||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||
`, botUserID).Scan(&count); err != nil {
|
||
return 0, fmt.Errorf("count pending bot api updates: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
// DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1):
|
||
// 1. 已确认(id <= bot_api_update_states.confirmed_update_id)且入队超过 confirmedGrace 的行——
|
||
// 官方 Bot API 语义下确认即弃,getUpdates 的 fromID 恒 > confirmed,删除不影响任何读路径;
|
||
// 宽限仅防御 offset 回拨调试场景。
|
||
// 2. 按队列 created_at 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」
|
||
// 语义,同时封顶 MTProto-only bot(从不调 getUpdates、无 state 行)成员身份带来的无界增长。
|
||
//
|
||
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
|
||
// Bot API 队列没有该约束。返回两步合计删除行数。
|
||
func (s *BotAPIUpdateStore) DeleteDeliveredOrExpired(ctx context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
||
if limit <= 0 {
|
||
limit = 10000
|
||
}
|
||
if limit > 100000 {
|
||
limit = 100000
|
||
}
|
||
total := 0
|
||
if confirmedGrace > 0 {
|
||
// 从 states 小表出发,每 bot 走 bot_api_updates_bot_scan_idx(bot_user_id, id) 范围扫描。
|
||
tag, err := s.db.Exec(ctx, `
|
||
DELETE FROM bot_api_updates
|
||
WHERE id IN (
|
||
SELECT u.id
|
||
FROM bot_api_update_states s
|
||
JOIN bot_api_updates u ON u.bot_user_id = s.bot_user_id AND u.id <= s.confirmed_update_id
|
||
WHERE u.created_at < now() - make_interval(secs => $1)
|
||
LIMIT $2
|
||
)`, int64(confirmedGrace/time.Second), limit)
|
||
if err != nil {
|
||
return total, fmt.Errorf("delete confirmed bot api updates: %w", err)
|
||
}
|
||
total += int(tag.RowsAffected())
|
||
}
|
||
if maxAge > 0 {
|
||
cutoff := time.Now().Add(-maxAge)
|
||
// 走 bot_api_updates_created_retention_idx(created_at, id)。
|
||
tag, err := s.db.Exec(ctx, `
|
||
DELETE FROM bot_api_updates
|
||
WHERE id IN (
|
||
SELECT id
|
||
FROM bot_api_updates
|
||
WHERE created_at < $1
|
||
ORDER BY created_at, id
|
||
LIMIT $2
|
||
)`, cutoff, limit)
|
||
if err != nil {
|
||
return total, fmt.Errorf("delete expired bot api updates: %w", err)
|
||
}
|
||
total += int(tag.RowsAffected())
|
||
}
|
||
return total, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, error) {
|
||
if botUserID == 0 {
|
||
return 0, false, nil
|
||
}
|
||
var id int64
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT confirmed_update_id
|
||
FROM bot_api_update_states
|
||
WHERE bot_user_id = $1
|
||
`, botUserID).Scan(&id); err != nil {
|
||
if err == pgx.ErrNoRows {
|
||
return 0, false, nil
|
||
}
|
||
return 0, false, fmt.Errorf("get bot api update state: %w", err)
|
||
}
|
||
return id, true, nil
|
||
}
|
||
|
||
func (s *BotAPIUpdateStore) scanBotAPIUpdate(row pgx.Row) (domain.BotAPIUpdate, error) {
|
||
return scanBotAPIUpdateRows(row)
|
||
}
|
||
|
||
type botAPIUpdateScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
|
||
var item domain.BotAPIUpdate
|
||
var kind, peerType string
|
||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||
var callbackInlineDCID, callbackInlineMessageID int
|
||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||
var callbackData []byte
|
||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
|
||
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
|
||
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil {
|
||
return domain.BotAPIUpdate{}, err
|
||
}
|
||
item.Kind = domain.BotAPIUpdateKind(kind)
|
||
item.Peer.Type = domain.PeerType(peerType)
|
||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||
item.Callback = &domain.BotCallbackQuery{
|
||
ID: callbackQueryID,
|
||
BotUserID: item.BotUserID,
|
||
UserID: callbackUserID,
|
||
Peer: item.Peer,
|
||
MessageID: item.MessageID,
|
||
ChatInstance: callbackChatInstance,
|
||
Data: append([]byte(nil), callbackData...),
|
||
}
|
||
if callbackInlineMessageID > 0 {
|
||
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
|
||
}
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
||
if req.BotUserID == 0 {
|
||
return fmt.Errorf("invalid bot api update")
|
||
}
|
||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
|
||
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
|
||
}
|
||
switch req.Peer.Type {
|
||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||
if req.Peer.ID <= 0 || req.MessageID <= 0 {
|
||
return fmt.Errorf("invalid bot api update peer")
|
||
}
|
||
case "":
|
||
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
|
||
return fmt.Errorf("invalid bot api update peer")
|
||
}
|
||
default:
|
||
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
|
||
}
|
||
if req.Kind == domain.BotAPIUpdateCallbackQuery {
|
||
cb := req.Callback
|
||
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
|
||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
|
||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
|
||
return fmt.Errorf("invalid bot api callback query")
|
||
}
|
||
inline := cb.InlineMessage
|
||
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
|
||
return fmt.Errorf("invalid bot api inline callback query")
|
||
}
|
||
if req.MessageID > 0 && inline != nil {
|
||
return fmt.Errorf("ambiguous bot api callback query")
|
||
}
|
||
} else if req.Callback != nil {
|
||
return fmt.Errorf("unexpected bot api callback query")
|
||
}
|
||
return nil
|
||
}
|