269 lines
7.9 KiB
Go
269 lines
7.9 KiB
Go
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 (with staff ping) 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, true)
|
|
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
|
|
}
|
|
|
|
// MarkClaimed edits the claim channel message to show the claimed state (green embed, no button,
|
|
// no ping) and removes it from tracking so the close flow leaves it untouched.
|
|
func (m *Manager) MarkClaimed(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, staffID string) {
|
|
cm, err := m.claims.Get(ctx, ticket.ID)
|
|
if err != nil || cm == nil {
|
|
m.claims.Delete(ctx, ticket.ID) //nolint
|
|
return
|
|
}
|
|
cfg := m.cfg.Get()
|
|
claimChannelID := cfg.Bot.ClaimChannel
|
|
if claimChannelID == "" {
|
|
m.claims.Delete(ctx, ticket.ID) //nolint
|
|
return
|
|
}
|
|
|
|
claimedEmbed := &discordgo.MessageEmbed{
|
|
Title: "Ticket pris en charge",
|
|
Color: 0x57f287,
|
|
Fields: []*discordgo.MessageEmbedField{
|
|
{Name: "Numéro", Value: fmt.Sprintf("#%04d", ticket.TicketNumber), Inline: true},
|
|
{Name: "Type", Value: ticket.Type, Inline: true},
|
|
{Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true},
|
|
{Name: "Pris par", Value: fmt.Sprintf("<@%s>", staffID), Inline: true},
|
|
{Name: "Salon", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true},
|
|
},
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
content := ""
|
|
emptyComponents := []discordgo.MessageComponent{}
|
|
if _, editErr := s.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
|
Channel: claimChannelID,
|
|
ID: cm.MessageID,
|
|
Content: &content,
|
|
Embeds: &[]*discordgo.MessageEmbed{claimedEmbed},
|
|
Components: &emptyComponents,
|
|
}); editErr != nil {
|
|
slog.Warn("claim: edit message to claimed", "ticket_id", ticket.ID, "err", editErr)
|
|
}
|
|
|
|
m.claims.Delete(ctx, ticket.ID) //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")
|
|
}
|
|
|
|
// Determine embed color from type config, fallback to blurple
|
|
embedColor := 0x5865f2
|
|
var staffRoleID string
|
|
if panel, ok := cfg.Panels[ticket.Panel]; ok {
|
|
if t, ok := panel.Types[ticket.Type]; ok {
|
|
if c := parseHexColor(t.EmbedColor); c != 0 {
|
|
embedColor = c
|
|
}
|
|
staffRoleID = t.StaffRole
|
|
}
|
|
}
|
|
|
|
fields := []*discordgo.MessageEmbedField{
|
|
{Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true},
|
|
{Name: "Numéro", Value: fmt.Sprintf("#%04d", ticket.TicketNumber), Inline: true},
|
|
{Name: "Salon", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true},
|
|
}
|
|
|
|
if ticket.TicketTitle.Valid && ticket.TicketTitle.String != "" {
|
|
fields = append(fields, &discordgo.MessageEmbedField{Name: "Titre", Value: ticket.TicketTitle.String, Inline: false})
|
|
}
|
|
if ticket.TicketDescription.Valid && ticket.TicketDescription.String != "" {
|
|
desc := ticket.TicketDescription.String
|
|
if len([]rune(desc)) > 500 {
|
|
runes := []rune(desc)
|
|
desc = string(runes[:497]) + "..."
|
|
}
|
|
fields = append(fields, &discordgo.MessageEmbedField{Name: "Description", Value: desc, Inline: false})
|
|
}
|
|
|
|
embed := &discordgo.MessageEmbed{
|
|
Title: fmt.Sprintf("Nouveau ticket — %s", ticket.Type),
|
|
Color: embedColor,
|
|
Fields: fields,
|
|
Timestamp: ticket.OpenedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
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,
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
// Always ping staff role
|
|
if withPing && staffRoleID != "" {
|
|
send.Content = fmt.Sprintf("<@&%s>", staffRoleID)
|
|
send.AllowedMentions = &discordgo.MessageAllowedMentions{
|
|
Roles: []string{staffRoleID},
|
|
}
|
|
}
|
|
|
|
msg, err := s.ChannelMessageSendComplex(claimChannelID, send)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return msg.ID, nil
|
|
}
|
|
|
|
func parseHexColor(hex string) int {
|
|
if len(hex) > 0 && hex[0] == '#' {
|
|
hex = hex[1:]
|
|
}
|
|
if len(hex) == 0 {
|
|
return 0
|
|
}
|
|
var v int64
|
|
fmt.Sscanf(hex, "%x", &v)
|
|
return int(v)
|
|
}
|