axe/internal/repository/telegram.go
2026-10-18 22:07:25 +03:00

276 lines
11 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 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
}