| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- package telegram
- import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "time"
- "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
- )
- // Handler executes parsed Commands against the database. It
- // is the bridge between incoming bot messages and the
- // individuals / subscriptions tables.
- //
- // Handler is safe to call from multiple goroutines (it
- // acquires a fresh connection per call from the pool).
- type Handler struct {
- Pool *postgres.Pool
- Logger *slog.Logger
- }
- // NewHandler returns a Handler.
- func NewHandler(pool *postgres.Pool, logger *slog.Logger) *Handler {
- return &Handler{Pool: pool, Logger: logger}
- }
- // ErrNotLinked is returned when a command requires the
- // individual to be linked (telegram_user_id set) but isn't.
- // The handler turns this into a "please /start first" reply.
- var ErrNotLinked = errors.New("telegram account not linked; run /start <invite_code> first")
- // Handle dispatches one parsed command. The returned string
- // is the text to reply to the user (may be empty for
- // non-applicable commands).
- //
- // Handle is idempotent w.r.t. /start (re-running with the
- // same code is a no-op) and /subscribe (re-running upserts
- // the subscription).
- func (h *Handler) Handle(ctx context.Context, msg *Message) (string, error) {
- if msg == nil || msg.From == nil {
- return "", nil
- }
- cmd := Parse(msg.Text)
- h.Logger.Info("telegram command",
- "user_id", msg.From.ID,
- "chat_id", msg.Chat.ID,
- "raw", cmd.Raw,
- )
- switch {
- case cmd.Start != nil:
- return h.handleStart(ctx, msg, cmd.Start)
- case cmd.Subscribe != nil:
- return h.handleSubscribe(ctx, msg, cmd.Subscribe)
- case cmd.Unsubscribe != nil:
- return h.handleUnsubscribe(ctx, msg, cmd.Unsubscribe)
- case cmd.Preferences:
- return h.handlePreferences(ctx, msg)
- case cmd.Status != nil:
- return h.handleStatus(ctx, msg, cmd.Status)
- case cmd.Mute != nil:
- return h.handleMute(ctx, msg, cmd.Mute)
- case cmd.Unmute:
- return h.handleUnmute(ctx, msg)
- default:
- // Unknown / non-command. Don't reply.
- return "", nil
- }
- }
- // handleStart links the telegram_user_id to the individual
- // whose telegram_invite_code matches.
- //
- // SPEC §8: "admin must create the individual and issue the
- // code first; unknown users are rejected." The handler
- // returns a friendly error if the invite code is unknown.
- func (h *Handler) handleStart(ctx context.Context, msg *Message, c *StartCmd) (string, error) {
- if c.InviteCode == "" {
- return "Welcome. Please run /start <your_invite_code> (your admin should have given you the code).", nil
- }
- // Atomic: claim the invite code for this user, only if it
- // hasn't been claimed by someone else.
- tag, err := h.Pool.Exec(ctx, `
- UPDATE individuals
- SET telegram_user_id = $1,
- telegram_chat_id = $2::text,
- telegram_invite_code = NULL
- WHERE telegram_invite_code = $3
- AND (telegram_user_id IS NULL OR telegram_user_id = $1)
- RETURNING id, full_name
- `, msg.From.ID, fmt.Sprintf("%d", msg.Chat.ID), c.InviteCode)
- if err != nil {
- return "", fmt.Errorf("start update: %w", err)
- }
- if tag.RowsAffected() == 0 {
- // Either unknown code or already linked to a
- // different user. Disambiguate.
- var existing string
- err := h.Pool.QueryRow(ctx, `
- SELECT COALESCE(telegram_user_id::text, 'NULL')
- FROM individuals WHERE telegram_invite_code = $1
- `, c.InviteCode).Scan(&existing)
- if err == nil {
- return fmt.Sprintf("Invite code %q is already linked to another account.", c.InviteCode), nil
- }
- return fmt.Sprintf("Unknown invite code %q. Please check with your admin.", c.InviteCode), nil
- }
- // Fetch the individual to greet them by name.
- var name string
- _ = h.Pool.QueryRow(ctx, `
- SELECT full_name FROM individuals
- WHERE telegram_user_id = $1
- `, msg.From.ID).Scan(&name)
- if name == "" {
- name = "operator"
- }
- return fmt.Sprintf("Linked. Welcome, %s.\n\nRun /preferences to see your current subscriptions, or /subscribe <source_id> [min_severity] to opt in.", name), nil
- }
- // handleSubscribe requires the individual to be linked
- // (we look them up by telegram_user_id). It upserts a
- // subscription with the given source_id and min_severity.
- func (h *Handler) handleSubscribe(ctx context.Context, msg *Message, c *SubscribeCmd) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- // Validate the source exists in this company.
- var ok bool
- err = h.Pool.QueryRow(ctx, `
- SELECT EXISTS (
- SELECT 1 FROM sources WHERE company_id = $1 AND id = $2
- )
- `, ind.CompanyID, c.SourceID).Scan(&ok)
- if err != nil {
- return "", fmt.Errorf("source check: %w", err)
- }
- if !ok {
- return fmt.Sprintf("Unknown source %q for your company. Run /preferences to see what's available.", c.SourceID), nil
- }
- _, err = h.Pool.Exec(ctx, `
- INSERT INTO subscriptions
- (individual_id, company_id, source_id, min_severity, channel_mask, status)
- VALUES ($1, $2, $3, $4, '["fcm","telegram"]'::jsonb, 'active')
- ON CONFLICT (individual_id, source_id) DO UPDATE
- SET min_severity = EXCLUDED.min_severity,
- status = 'active',
- channel_mask = '["fcm","telegram"]'::jsonb
- `, ind.ID, ind.CompanyID, c.SourceID, c.MinSeverity)
- if err != nil {
- return "", fmt.Errorf("subscribe upsert: %w", err)
- }
- return fmt.Sprintf("Subscribed to %s (min severity: %s).", c.SourceID, c.MinSeverity), nil
- }
- // handleUnsubscribe soft-deletes a subscription by setting
- // status='paused'. We keep the row so /subscribe re-enables
- // the same row without a new id.
- func (h *Handler) handleUnsubscribe(ctx context.Context, msg *Message, c *UnsubscribeCmd) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- tag, err := h.Pool.Exec(ctx, `
- UPDATE subscriptions
- SET status = 'paused'
- WHERE individual_id = $1 AND source_id = $2
- `, ind.ID, c.SourceID)
- if err != nil {
- return "", fmt.Errorf("unsubscribe: %w", err)
- }
- if tag.RowsAffected() == 0 {
- return fmt.Sprintf("You were not subscribed to %s.", c.SourceID), nil
- }
- return fmt.Sprintf("Unsubscribed from %s.", c.SourceID), nil
- }
- // handlePreferences shows the current subscriptions and
- // the global mute_until timestamp.
- func (h *Handler) handlePreferences(ctx context.Context, msg *Message) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- rows, err := h.Pool.Query(ctx, `
- SELECT source_id, min_severity, channel_mask, status
- FROM subscriptions
- WHERE individual_id = $1
- ORDER BY source_id
- `, ind.ID)
- if err != nil {
- return "", fmt.Errorf("preferences query: %w", err)
- }
- defer rows.Close()
- var lines []string
- for rows.Next() {
- var src, minSev, status string
- var channels []byte
- if err := rows.Scan(&src, &minSev, &channels, &status); err != nil {
- return "", err
- }
- if minSev == "" {
- minSev = "any"
- }
- lines = append(lines, fmt.Sprintf(" • %s [min=%s, channels=%s, %s]",
- src, minSev, string(channels), status))
- }
- if len(lines) == 0 {
- lines = append(lines, " (no subscriptions yet)")
- }
- reply := "Your subscriptions:\n" + joinLines(lines)
- if ind.MuteUntil != nil && ind.MuteUntil.After(time.Now()) {
- reply += fmt.Sprintf("\n\nMuted until %s UTC.", ind.MuteUntil.UTC().Format("2006-01-02 15:04"))
- } else {
- reply += "\n\nNot muted."
- }
- return reply, nil
- }
- // handleStatus lists the last N deliveries for this
- // individual. M3 ships a simple per-individual view.
- func (h *Handler) handleStatus(ctx context.Context, msg *Message, c *StatusCmd) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- rows, err := h.Pool.Query(ctx, `
- SELECT alert_id, channel, status, sent_at
- FROM deliveries
- WHERE individual_id = $1
- ORDER BY id DESC
- LIMIT $2
- `, ind.ID, c.Limit)
- if err != nil {
- return "", fmt.Errorf("status query: %w", err)
- }
- defer rows.Close()
- var lines []string
- for rows.Next() {
- var alertID, channel, status string
- var sentAt *time.Time
- if err := rows.Scan(&alertID, &channel, &status, &sentAt); err != nil {
- return "", err
- }
- ts := "(pending)"
- if sentAt != nil {
- ts = sentAt.UTC().Format("15:04:05")
- }
- short := alertID
- if len(short) > 12 {
- short = short[:12]
- }
- lines = append(lines, fmt.Sprintf(" • %s %s [%s] %s", ts, short, channel, status))
- }
- if len(lines) == 0 {
- lines = append(lines, " (no deliveries yet)")
- }
- return "Recent deliveries:\n" + joinLines(lines), nil
- }
- // handleMute sets mute_until to now+duration (or now+until).
- func (h *Handler) handleMute(ctx context.Context, msg *Message, c *MuteCmd) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- var until time.Time
- if c.Duration > 0 {
- until = time.Now().UTC().Add(c.Duration)
- } else {
- until = c.Until
- }
- _, err = h.Pool.Exec(ctx, `
- UPDATE individuals SET mute_until = $1 WHERE id = $2
- `, until, ind.ID)
- if err != nil {
- return "", fmt.Errorf("mute: %w", err)
- }
- return fmt.Sprintf("Muted until %s UTC.", until.Format("2006-01-02 15:04")), nil
- }
- // handleUnmute clears mute_until.
- func (h *Handler) handleUnmute(ctx context.Context, msg *Message) (string, error) {
- ind, err := h.lookupByTelegramUser(ctx, msg.From.ID)
- if err != nil {
- return err.Error(), nil
- }
- _, err = h.Pool.Exec(ctx, `
- UPDATE individuals SET mute_until = NULL WHERE id = $1
- `, ind.ID)
- if err != nil {
- return "", fmt.Errorf("unmute: %w", err)
- }
- return "Unmuted.", nil
- }
- // individual is the small projection of individuals we need
- // for command handling.
- type individual struct {
- ID string
- CompanyID string
- FullName string
- MuteUntil *time.Time
- }
- // lookupByTelegramUser returns the individual linked to a
- // given telegram_user_id, or ErrNotLinked.
- func (h *Handler) lookupByTelegramUser(ctx context.Context, telegramUserID int64) (*individual, error) {
- var ind individual
- err := h.Pool.QueryRow(ctx, `
- SELECT id, company_id, full_name, mute_until
- FROM individuals
- WHERE telegram_user_id = $1
- `, telegramUserID).Scan(&ind.ID, &ind.CompanyID, &ind.FullName, &ind.MuteUntil)
- if err != nil {
- return nil, ErrNotLinked
- }
- return &ind, nil
- }
- func joinLines(ls []string) string {
- out := ""
- for i, s := range ls {
- if i > 0 {
- out += "\n"
- }
- out += s
- }
- return out
- }
|