axe/internal/repository/telegram.go

276 lines
11 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// TelegramRepo — привязка Telegram, токены собственных ботов и выборка
// заявок-кандидатов на уведомление (миграции 0011–0012). Держим отдельно
// от профилей и заявок: тема одна, а SQL здесь — про «куда слать»,
// «чем слать» и «сколько уже слали».
type TelegramRepo struct {
db Querier
}
// NewTelegramRepo создаёт репозиторий поверх Querier (пул или транзакция).
func NewTelegramRepo(db Querier) *TelegramRepo {
return &TelegramRepo{db: db}
}
// Target — куда слать уведомления ("" — не настроено; NULL в базе
// нормализуется в пустую строку). ErrNotFound — профиля нет.
func (r *TelegramRepo) Target(ctx context.Context, userID string) (string, error) {
var target *string
err := r.db.QueryRow(ctx,
`SELECT telegram_target FROM profiles WHERE id = $1`, userID).Scan(&target)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("telegram target: %w", err)
}
if target == nil {
return "", nil
}
return *target, nil
}
// SetTarget записывает цель уведомлений; пустая строка очищает
// (NULLIF кладёт NULL — «отключено» физически, а не пустым текстом).
// ErrNotFound — профиля нет (сессия могла пережить удаление).
func (r *TelegramRepo) SetTarget(ctx context.Context, userID, target string) error {
tag, err := r.db.Exec(ctx, `
UPDATE profiles SET telegram_target = NULLIF($2, '')
WHERE id = $1`, userID, target)
if err != nil {
return fmt.Errorf("set telegram target: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// CreateBindCode выпускает одноразовый код привязки: прежние
// неиспользованные коды пользователя гаснут — действует ровно один
// свежий, как и токены сброса пароля (CreateResetToken).
// Возвращает срок жизни (time.Now()+ttl).
func (r *TelegramRepo) CreateBindCode(ctx context.Context, userID, codeHash string, ttl time.Duration) (time.Time, error) {
if _, err := r.db.Exec(ctx, `
DELETE FROM telegram_bind_codes
WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return time.Time{}, fmt.Errorf("supersede bind codes: %w", err)
}
expires := time.Now().Add(ttl)
if _, err := r.db.Exec(ctx, `
INSERT INTO telegram_bind_codes (user_id, code_hash, expires_at)
VALUES ($1, $2, $3)`, userID, codeHash, expires); err != nil {
return time.Time{}, fmt.Errorf("create bind code: %w", err)
}
return expires, nil
}
// ConsumeBindCode гасит код одним UPDATE — одноразовость и срок живут
// в самом WHERE, поэтому две параллельные привязки невозможны.
// Возвращает владельца кода; ErrNotFound — неизвестен, уже использован
// или истёк (причина наружу не раскрывается).
func (r *TelegramRepo) ConsumeBindCode(ctx context.Context, codeHash string) (string, error) {
var userID string
err := r.db.QueryRow(ctx, `
UPDATE telegram_bind_codes
SET used_at = now()
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id`, codeHash).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("consume bind code: %w", err)
}
return userID, nil
}
// CleanupBindCodes удаляет протухшие строки (использованные тоже:
// история кодов никому не нужна, таблица должна оставаться маленькой).
func (r *TelegramRepo) CleanupBindCodes(ctx context.Context, before time.Time) (int64, error) {
tag, err := r.db.Exec(ctx,
`DELETE FROM telegram_bind_codes WHERE expires_at < $1`, before)
if err != nil {
return 0, fmt.Errorf("cleanup bind codes: %w", err)
}
return tag.RowsAffected(), nil
}
// BotTokenInfo — есть ли у пользователя свой токен бота и как его бот
// называется (имя из getMe при сохранении). Токен сюда не входит: он
// наружу не отдаётся никому, включая владельца.
// ErrNotFound — профиля нет (сессия могла пережить удаление).
func (r *TelegramRepo) BotTokenInfo(ctx context.Context, userID string) (has bool, username string, err error) {
var enc []byte
var name *string
err = r.db.QueryRow(ctx, `
SELECT telegram_bot_token, telegram_bot_username
FROM profiles WHERE id = $1`, userID).Scan(&enc, &name)
if errors.Is(err, pgx.ErrNoRows) {
return false, "", ErrNotFound
}
if err != nil {
return false, "", fmt.Errorf("telegram bot token info: %w", err)
}
if name != nil {
username = *name
}
return enc != nil, username, nil
}
// SetBotToken сохраняет зашифрованный токен и имя бота (оба поля одной
// операцией — чтобы статус не показывал имя бота без токена и наоборот).
// ErrNotFound — профиля нет.
func (r *TelegramRepo) SetBotToken(ctx context.Context, userID string, encToken, username string) error {
tag, err := r.db.Exec(ctx, `
UPDATE profiles
SET telegram_bot_token = $2, telegram_bot_username = NULLIF($3, '')
WHERE id = $1`, userID, []byte(encToken), username)
if err != nil {
return fmt.Errorf("set telegram bot token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ClearBotToken убирает свой токен: уведомления перестают уходить через
// этого бота, его поллер останавливается (см. notify.Registry).
// ErrNotFound — профиля нет.
func (r *TelegramRepo) ClearBotToken(ctx context.Context, userID string) error {
tag, err := r.db.Exec(ctx, `
UPDATE profiles
SET telegram_bot_token = NULL, telegram_bot_username = NULL
WHERE id = $1`, userID)
if err != nil {
return fmt.Errorf("clear telegram bot token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// UserBot — пользователь со своим ботом: токен приходит зашифрованным,
// расшифровывает вызывающий (репозиторий не знает про ключ).
type UserBot struct {
UserID string
// TokenEnc — зашифрованный токен (AES-GCM от crypt.Cipher).
TokenEnc string
// Username — имя бота из getMe, чтобы показывать его до первого
// сетевого вызова.
Username string
}
// BotTokens — все сохранённые токены: читается один раз при старте,
// чтобы поднять клиенты ботов и их поллеры.
func (r *TelegramRepo) BotTokens(ctx context.Context) ([]UserBot, error) {
rows, err := r.db.Query(ctx, `
SELECT id, telegram_bot_token, telegram_bot_username
FROM profiles
WHERE telegram_bot_token IS NOT NULL
ORDER BY created_at`)
if err != nil {
return nil, fmt.Errorf("list telegram bot tokens: %w", err)
}
defer rows.Close()
out := make([]UserBot, 0, 8)
for rows.Next() {
var u UserBot
var enc []byte
var name *string
if err := rows.Scan(&u.UserID, &enc, &name); err != nil {
return nil, fmt.Errorf("list telegram bot tokens scan: %w", err)
}
u.TokenEnc = string(enc)
if name != nil {
u.Username = *name
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list telegram bot tokens: %w", err)
}
return out, nil
}
// StaleTicket — заявка, которую пора напомнить, и куда напоминание слать.
type StaleTicket struct {
ID int64
Address string
Category string
Priority string
ClientName string
// ClientID — владелец заявки: по нему выбирается бот, через который
// уйдёт сообщение (свой токен пользователя или глобальный).
ClientID string
// Target — profiles.telegram_target: числовой chat_id или @группа.
Target string
}
// StaleTickets — кандидаты на уведомление: всё ещё 'new', созданы раньше
// cutoff, и последнее уведомление либо не уходило, либо тоже старше cutoff
// (повтор — раз в сутки). Без telegram_target и с выключенным аккаунтом —
// не попадают: слать некуда. Каким ботом слать — выбирается по ClientID
// на стороне нотификатора (личный токен пользователя или глобальный).
func (r *TelegramRepo) StaleTickets(ctx context.Context, cutoff time.Time, limit int) ([]StaleTicket, error) {
rows, err := r.db.Query(ctx, `
SELECT t.id, t.address, t.category, t.priority, p.name,
t.client_id, p.telegram_target
FROM tickets t
JOIN profiles p ON p.id = t.client_id
WHERE t.status = 'new'
AND t.created_at <= $1
AND (t.tg_notified_at IS NULL OR t.tg_notified_at <= $1)
AND p.is_active
AND p.telegram_target IS NOT NULL
ORDER BY t.created_at
LIMIT $2`, cutoff, limit)
if err != nil {
return nil, fmt.Errorf("stale tickets: %w", err)
}
defer rows.Close()
out := make([]StaleTicket, 0, 16)
for rows.Next() {
var s StaleTicket
if err := rows.Scan(&s.ID, &s.Address, &s.Category, &s.Priority,
&s.ClientName, &s.ClientID, &s.Target); err != nil {
return nil, fmt.Errorf("stale tickets scan: %w", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("stale tickets: %w", err)
}
return out, nil
}
// MarkNotified фиксирует отправку: со следующего тика заявка молчит
// сутки. Условие status='new' — гонка со взятием заявки в работу:
// если её успели назначить, напоминание уже неактуально.
func (r *TelegramRepo) MarkNotified(ctx context.Context, ticketID int64) error {
tag, err := r.db.Exec(ctx, `
UPDATE tickets SET tg_notified_at = now()
WHERE id = $1 AND status = 'new'`, ticketID)
if err != nil {
return fmt.Errorf("mark notified: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}