package claim import ( "context" "fmt" "log/slog" "sync" "time" "github.com/bwmarrin/discordgo" "github.com/leolionad58/ticketbot/internal/config" "github.com/leolionad58/ticketbot/internal/db" ) type Manager struct { cfg *config.Provider repo *db.TicketRepo claims *db.ClaimMessageRepo mu sync.Mutex stops map[int64]context.CancelFunc } func NewManager(cfg *config.Provider, repo *db.TicketRepo, claims *db.ClaimMessageRepo) *Manager { return &Manager{ cfg: cfg, repo: repo, claims: claims, stops: make(map[int64]context.CancelFunc), } } // StartTicket posts the initial claim message and starts the re-up goroutine. func (m *Manager) StartTicket(ctx context.Context, s *discordgo.Session, ticket *db.Ticket) { msgID, err := m.postClaim(ctx, s, ticket, false) if err != nil { slog.Error("claim: post initial message", "ticket_id", ticket.ID, "err", err) } else { if err := m.claims.Upsert(ctx, ticket.ID, msgID, time.Now()); err != nil { slog.Error("claim: upsert claim_message", "ticket_id", ticket.ID, "err", err) } } m.startReupLoop(s, ticket, time.Now()) } // ResumeTicket re-attaches the re-up loop after a bot restart. func (m *Manager) ResumeTicket(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { m.startReupLoop(s, ticket, lastReupAt) } // Stop cancels the re-up loop for a ticket (call on claim or close). func (m *Manager) Stop(ticketID int64) { m.mu.Lock() defer m.mu.Unlock() if cancel, ok := m.stops[ticketID]; ok { cancel() delete(m.stops, ticketID) } } // DeleteClaimMessage removes the claim message from the claim channel. func (m *Manager) DeleteClaimMessage(ctx context.Context, s *discordgo.Session, ticketID int64) { cm, err := m.claims.Get(ctx, ticketID) if err != nil || cm == nil { return } cfg := m.cfg.Get() if err := s.ChannelMessageDelete(cfg.Bot.ClaimChannel, cm.MessageID); err != nil { slog.Warn("claim: delete message", "ticket_id", ticketID, "err", err) } m.claims.Delete(ctx, ticketID) //nolint } func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { ctx, cancel := context.WithCancel(context.Background()) m.mu.Lock() // Cancel previous loop if any (shouldn't happen in normal flow) if old, ok := m.stops[ticket.ID]; ok { old() } m.stops[ticket.ID] = cancel m.mu.Unlock() go func() { defer func() { m.mu.Lock() delete(m.stops, ticket.ID) m.mu.Unlock() }() cfg := m.cfg.Get() interval := time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute elapsed := time.Since(lastReupAt) next := interval - elapsed if next <= 0 { next = time.Second // fire almost immediately if already overdue } timer := time.NewTimer(next) defer timer.Stop() reupCount := 0 for { select { case <-ctx.Done(): return case <-timer.C: // Verify ticket is still open before re-posting checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) t, err := m.repo.GetByID(checkCtx, ticket.ID) checkCancel() if err != nil || t == nil || t.Status != "open" { return } // Delete old claim message delCtx, delCancel := context.WithTimeout(ctx, 5*time.Second) m.DeleteClaimMessage(delCtx, s, ticket.ID) delCancel() // Re-post with staff ping (first re-up and beyond) postCtx, postCancel := context.WithTimeout(ctx, 10*time.Second) msgID, postErr := m.postClaim(postCtx, s, ticket, reupCount >= 0) postCancel() if postErr != nil { slog.Error("claim reup: post", "ticket_id", ticket.ID, "err", postErr) } else { saveCtx, saveCancel := context.WithTimeout(context.Background(), 3*time.Second) m.claims.Upsert(saveCtx, ticket.ID, msgID, time.Now()) //nolint saveCancel() } reupCount++ // Re-read config in case of hot reload cfg = m.cfg.Get() interval = time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute timer.Reset(interval) } } }() } func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, withPing bool) (string, error) { cfg := m.cfg.Get() claimChannelID := cfg.Bot.ClaimChannel if claimChannelID == "" { return "", fmt.Errorf("claim_channel not configured") } elapsed := time.Since(ticket.OpenedAt).Round(time.Second) embed := &discordgo.MessageEmbed{ Title: "Nouveau ticket à claim", Color: 0x5865f2, Fields: []*discordgo.MessageEmbedField{ {Name: "Type", Value: ticket.Type, Inline: true}, {Name: "Panel", Value: ticket.Panel, Inline: true}, {Name: "Utilisateur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true}, {Name: "Numéro", Value: fmt.Sprintf("%04d", ticket.TicketNumber), Inline: true}, {Name: "Channel", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, {Name: "Ouvert il y a", Value: elapsed.String(), Inline: true}, }, } customID := fmt.Sprintf("claim:take:%d", ticket.ID) send := &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{embed}, Components: []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{ Label: "Claim", Style: discordgo.SuccessButton, CustomID: customID, }, }}, }, } if withPing { if panel, ok := cfg.Panels[ticket.Panel]; ok { if t, ok := panel.Types[ticket.Type]; ok && t.StaffRole != "" { send.Content = fmt.Sprintf("<@&%s>", t.StaffRole) } } } msg, err := s.ChannelMessageSendComplex(claimChannelID, send) if err != nil { return "", err } return msg.ID, nil }