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

111 lines
4.2 KiB
Go
Raw 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 notify
import (
"context"
"log/slog"
"sync"
"building-mgmt/internal/telegram"
)
// Bots — выбор бота для пользователя: собственный токен приоритетнее
// глобального TELEGRAM_BOT_TOKEN. Именно так нотификатор решает, через
// кого слать, — интерфейс, чтобы в тестах подставить карту без поллеров.
type Bots interface {
BotFor(userID string) telegram.API
}
// Registry — боты пользователей и их личные поллеры.
//
// Роль процесса здесь одна: на каждый сохранённый токен нужен свой
// длинный поллинг (getUpdates у Telegram — очередь на бота, чужой её
// не заберёт). Реестр владеет дочерними контекстами от parent, поэтому
// остановка процесса гасит все поллеры разом (defer tgCancel в cmd/api).
//
// Потокобезопасен: BotFor вызывают нотификатор и хендлеры из разных
// горутин, Register/Unregister — в запросах на сохранение и удаление
// токена.
type Registry struct {
parent context.Context
store Store
// global — бот сервера (TELEGRAM_BOT_TOKEN); может быть выключенным,
// тогда BotFor отдаёт его же, а вызывающий проверяет Enabled().
global telegram.API
log *slog.Logger
mu sync.Mutex
users map[string]*userBot
}
// userBot — один личный бот: клиент API плюс отмена его поллера.
type userBot struct {
bot telegram.API
cancel context.CancelFunc
}
// NewRegistry создаёт реестр поверх родительского контекста.
func NewRegistry(ctx context.Context, store Store, global telegram.API, log *slog.Logger) *Registry {
return &Registry{
parent: ctx,
store: store,
global: global,
log: log,
users: make(map[string]*userBot),
}
}
// BotFor — бот пользователя: свой токен, иначе глобальный (может быть
// выключенным — проверяет вызывающий).
func (r *Registry) BotFor(userID string) telegram.API {
r.mu.Lock()
defer r.mu.Unlock()
if u, ok := r.users[userID]; ok {
return u.bot
}
return r.global
}
// Register поднимает поллер личного бота. Повторный вызов (пользователь
// заменил токен) сначала гасит прежний поллер: две горутины не должны
// тянуть одну очередь.
func (r *Registry) Register(userID string, bot telegram.API) {
if bot == nil || !bot.Enabled() {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if old, ok := r.users[userID]; ok {
old.cancel()
}
ctx, cancel := context.WithCancel(r.parent)
r.users[userID] = &userBot{bot: bot, cancel: cancel}
// Имя бота на старте процесса ещё пустое: getMe не делаем, чтобы не
// тормозить запуск (оно лежит в БД и в статусе). Поле добавляем
// только когда оно есть — пустое bot="" лишь путает в логе.
attrs := []any{"user_id", userID}
if name := bot.Username(); name != "" {
attrs = append(attrs, "bot", name)
}
r.log.Info("telegram user bot registered", attrs...)
go RunPoller(ctx, r.store, bot, r.log)
}
// Unregister гасит поллер и забывает бота: дальше BotFor отдаёт
// глобального (или выключенного, если его нет).
func (r *Registry) Unregister(userID string) {
r.mu.Lock()
defer r.mu.Unlock()
if u, ok := r.users[userID]; ok {
u.cancel()
delete(r.users, userID)
r.log.Info("telegram user bot removed", "user_id", userID)
}
}
// Users — сколько личных ботов сейчас поднято (для лога на старте).
func (r *Registry) Users() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.users)
}