oups
This commit is contained in:
+43
-16
@@ -10,14 +10,15 @@ import (
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
Session *discordgo.Session
|
||||
Config *config.Provider
|
||||
Tickets *db.TicketRepo
|
||||
Claims *db.ClaimMessageRepo
|
||||
GuildID string
|
||||
Session *discordgo.Session
|
||||
Config *config.Provider
|
||||
Tickets *db.TicketRepo
|
||||
Claims *db.ClaimMessageRepo
|
||||
GuildID string
|
||||
AllowedGuilds map[string]bool // set of allowed guild IDs; if empty, all guilds allowed
|
||||
}
|
||||
|
||||
func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string) (*Bot, error) {
|
||||
func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string, allowedGuilds []string) (*Bot, error) {
|
||||
s, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
@@ -27,15 +28,29 @@ func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.
|
||||
discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsMessageContent
|
||||
|
||||
allowed := make(map[string]bool, len(allowedGuilds))
|
||||
for _, id := range allowedGuilds {
|
||||
if id != "" {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
return &Bot{
|
||||
Session: s,
|
||||
Config: cfg,
|
||||
Tickets: tickets,
|
||||
Claims: claims,
|
||||
GuildID: guildID,
|
||||
Session: s,
|
||||
Config: cfg,
|
||||
Tickets: tickets,
|
||||
Claims: claims,
|
||||
GuildID: guildID,
|
||||
AllowedGuilds: allowed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bot) IsGuildAllowed(guildID string) bool {
|
||||
if len(b.AllowedGuilds) == 0 {
|
||||
return true
|
||||
}
|
||||
return b.AllowedGuilds[guildID]
|
||||
}
|
||||
|
||||
func (b *Bot) Open() error {
|
||||
return b.Session.Open()
|
||||
}
|
||||
@@ -46,12 +61,24 @@ func (b *Bot) Close() {
|
||||
|
||||
func (b *Bot) RegisterCommands(appID string) error {
|
||||
cmds := ApplicationCommands()
|
||||
for _, cmd := range cmds {
|
||||
_, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register %s: %w", cmd.Name, err)
|
||||
for guildID := range b.AllowedGuilds {
|
||||
for _, cmd := range cmds {
|
||||
_, err := b.Session.ApplicationCommandCreate(appID, guildID, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register %s in guild %s: %w", cmd.Name, guildID, err)
|
||||
}
|
||||
slog.Info("registered command", "name", cmd.Name, "guild_id", guildID)
|
||||
}
|
||||
}
|
||||
// If no allowed guilds configured, register globally (or in default guild)
|
||||
if len(b.AllowedGuilds) == 0 {
|
||||
for _, cmd := range cmds {
|
||||
_, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register %s: %w", cmd.Name, err)
|
||||
}
|
||||
slog.Info("registered command", "name", cmd.Name)
|
||||
}
|
||||
slog.Info("registered command", "name", cmd.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/leolionad58/ticketbot/internal/db"
|
||||
"github.com/leolionad58/ticketbot/internal/panel/handlers"
|
||||
)
|
||||
|
||||
// BotServiceImpl implements panel.BotService and handlers.DiscordInfo.
|
||||
type BotServiceImpl struct {
|
||||
session *discordgo.Session
|
||||
panelRepo *db.PanelConfigRepo
|
||||
ticketRepo *db.TicketRepo
|
||||
convocRepo *db.ConvocationConfigRepo
|
||||
guildID string
|
||||
allowedGuilds []string
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func NewBotService(
|
||||
s *discordgo.Session,
|
||||
panelRepo *db.PanelConfigRepo,
|
||||
ticketRepo *db.TicketRepo,
|
||||
convocRepo *db.ConvocationConfigRepo,
|
||||
guildID string,
|
||||
allowedGuilds []string,
|
||||
) *BotServiceImpl {
|
||||
return &BotServiceImpl{
|
||||
session: s,
|
||||
panelRepo: panelRepo,
|
||||
ticketRepo: ticketRepo,
|
||||
convocRepo: convocRepo,
|
||||
guildID: guildID,
|
||||
allowedGuilds: allowedGuilds,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetBotStatus returns live bot metrics.
|
||||
func (b *BotServiceImpl) GetBotStatus() handlers.BotStatus {
|
||||
guildName := ""
|
||||
if g, err := b.session.State.Guild(b.guildID); err == nil && g != nil {
|
||||
guildName = g.Name
|
||||
}
|
||||
return handlers.BotStatus{
|
||||
Online: true,
|
||||
StartedAt: b.startedAt,
|
||||
LatencyMs: b.session.HeartbeatLatency().Milliseconds(),
|
||||
GuildName: guildName,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGuildList returns all whitelisted guilds by name.
|
||||
func (b *BotServiceImpl) GetGuildList() []handlers.GuildInfo {
|
||||
seen := map[string]bool{}
|
||||
ids := append([]string{}, b.allowedGuilds...)
|
||||
if b.guildID != "" && !seen[b.guildID] {
|
||||
ids = append(ids, b.guildID)
|
||||
}
|
||||
out := make([]handlers.GuildInfo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] || id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
name := id
|
||||
if g, err := b.session.State.Guild(id); err == nil && g != nil {
|
||||
name = g.Name
|
||||
}
|
||||
out = append(out, handlers.GuildInfo{ID: id, Name: name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SendPanel sends or updates the Discord panel message for the given panel ID.
|
||||
func (b *BotServiceImpl) SendPanel(panelID int64) error {
|
||||
ctx := context.Background()
|
||||
p, err := b.panelRepo.GetByID(ctx, panelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get panel: %w", err)
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("panel %d not found", panelID)
|
||||
}
|
||||
if !p.ChannelID.Valid || p.ChannelID.String == "" {
|
||||
return fmt.Errorf("panel %d has no channel configured", panelID)
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: p.EmbedTitle,
|
||||
Description: p.EmbedDescription.String,
|
||||
Color: parseHexColor(p.EmbedColor),
|
||||
}
|
||||
if p.EmbedThumbnail != "" {
|
||||
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: p.EmbedThumbnail}
|
||||
}
|
||||
if p.EmbedImage != "" {
|
||||
embed.Image = &discordgo.MessageEmbedImage{URL: p.EmbedImage}
|
||||
}
|
||||
|
||||
// Build action rows (max 5 buttons per row)
|
||||
var components []discordgo.MessageComponent
|
||||
var row []discordgo.MessageComponent
|
||||
for i, t := range p.Types {
|
||||
btn := discordgo.Button{
|
||||
Label: t.ButtonLabel,
|
||||
Style: buttonStyle(t.ButtonColor),
|
||||
CustomID: fmt.Sprintf("panel:open:%s:%s", p.Name, t.Name),
|
||||
}
|
||||
if t.ButtonEmoji != "" {
|
||||
emoji := discordgo.ComponentEmoji{Name: t.ButtonEmoji}
|
||||
btn.Emoji = &emoji
|
||||
}
|
||||
row = append(row, btn)
|
||||
if len(row) == 5 || i == len(p.Types)-1 {
|
||||
components = append(components, discordgo.ActionsRow{Components: row})
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
|
||||
channelID := p.ChannelID.String
|
||||
if p.MessageID.Valid && p.MessageID.String != "" {
|
||||
_, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: channelID,
|
||||
ID: p.MessageID.String,
|
||||
Embeds: &[]*discordgo.MessageEmbed{embed},
|
||||
Components: &components,
|
||||
})
|
||||
if editErr == nil {
|
||||
return nil
|
||||
}
|
||||
// Message no longer exists — clear the stored ID and fall through to send new.
|
||||
_ = b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, "")
|
||||
}
|
||||
|
||||
msg, err := b.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
Components: components,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("send panel message: %w", err)
|
||||
}
|
||||
return b.panelRepo.SetDiscordMessage(ctx, panelID, channelID, msg.ID)
|
||||
}
|
||||
|
||||
// DeletePanelMessage deletes the Discord panel message.
|
||||
func (b *BotServiceImpl) DeletePanelMessage(panelID int64) error {
|
||||
ctx := context.Background()
|
||||
p, err := b.panelRepo.GetByID(ctx, panelID)
|
||||
if err != nil || p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.MessageID.Valid && p.ChannelID.Valid && p.MessageID.String != "" {
|
||||
_ = b.session.ChannelMessageDelete(p.ChannelID.String, p.MessageID.String)
|
||||
_ = b.panelRepo.SetDiscordMessage(ctx, panelID, "", "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGuildRoles returns all roles for the given guild.
|
||||
func (b *BotServiceImpl) GetGuildRoles(guildID string) ([]handlers.GuildRole, error) {
|
||||
roles, err := b.session.GuildRoles(guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]handlers.GuildRole, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
if r.Name == "@everyone" {
|
||||
continue
|
||||
}
|
||||
color := "#99aab5"
|
||||
if r.Color != 0 {
|
||||
color = fmt.Sprintf("#%06x", r.Color)
|
||||
}
|
||||
out = append(out, handlers.GuildRole{ID: r.ID, Name: r.Name, Color: color})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetGuildChannels returns all channels for the given guild.
|
||||
func (b *BotServiceImpl) GetGuildChannels(guildID string) ([]handlers.GuildChannel, error) {
|
||||
channels, err := b.session.GuildChannels(guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]handlers.GuildChannel, 0, len(channels))
|
||||
for _, c := range channels {
|
||||
out = append(out, handlers.GuildChannel{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Type: int(c.Type),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateConvocation creates a convocation channel on Discord and records it in the DB.
|
||||
// staffDiscordID is the Discord ID of the admin creating the convocation.
|
||||
func (b *BotServiceImpl) CreateConvocation(guildID, staffDiscordID, targetDiscordID, reason string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get convocation category from DB config
|
||||
cfg, err := b.convocRepo.Get(ctx, guildID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get convoc config: %w", err)
|
||||
}
|
||||
categoryID := ""
|
||||
if cfg != nil {
|
||||
categoryID = cfg.CategoryID
|
||||
}
|
||||
if categoryID == "" {
|
||||
return "", fmt.Errorf("aucune catégorie de convocation configurée pour cette guild")
|
||||
}
|
||||
|
||||
// Get target member info
|
||||
target, err := b.session.GuildMember(guildID, targetDiscordID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("utilisateur introuvable dans la guild : %w", err)
|
||||
}
|
||||
|
||||
n, err := b.ticketRepo.NextConvocationNumber(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("next convocation number: %w", err)
|
||||
}
|
||||
|
||||
username := target.User.Username
|
||||
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocUsername(username), n)
|
||||
if len(channelName) > 100 {
|
||||
channelName = channelName[:100]
|
||||
}
|
||||
|
||||
ch, err := b.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
|
||||
Name: channelName,
|
||||
Type: discordgo.ChannelTypeGuildText,
|
||||
ParentID: categoryID,
|
||||
PermissionOverwrites: []*discordgo.PermissionOverwrite{
|
||||
{
|
||||
ID: guildID,
|
||||
Type: discordgo.PermissionOverwriteTypeRole,
|
||||
Deny: discordgo.PermissionViewChannel,
|
||||
},
|
||||
{
|
||||
ID: staffDiscordID,
|
||||
Type: discordgo.PermissionOverwriteTypeMember,
|
||||
Allow: discordgo.PermissionViewChannel |
|
||||
discordgo.PermissionSendMessages |
|
||||
discordgo.PermissionReadMessageHistory |
|
||||
discordgo.PermissionAttachFiles,
|
||||
},
|
||||
{
|
||||
ID: targetDiscordID,
|
||||
Type: discordgo.PermissionOverwriteTypeMember,
|
||||
Allow: discordgo.PermissionViewChannel |
|
||||
discordgo.PermissionSendMessages |
|
||||
discordgo.PermissionReadMessageHistory,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create channel: %w", err)
|
||||
}
|
||||
|
||||
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffDiscordID)
|
||||
if len(deleteCustomID) > 100 {
|
||||
deleteCustomID = "convoc:delete:" + ch.ID
|
||||
}
|
||||
|
||||
b.session.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
|
||||
Content: fmt.Sprintf("<@%s>", targetDiscordID),
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: "Convocation",
|
||||
Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffDiscordID, reason),
|
||||
Color: 0x5865f2,
|
||||
},
|
||||
},
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Supprimer",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: deleteCustomID,
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
ticket := &db.Ticket{
|
||||
UserID: targetDiscordID,
|
||||
Panel: "convocation",
|
||||
Type: "convocation",
|
||||
ChannelID: ch.ID,
|
||||
OpenedAt: time.Now(),
|
||||
Status: "open",
|
||||
TicketNumber: n,
|
||||
}
|
||||
_ = b.ticketRepo.InsertWithNumber(ctx, ticket)
|
||||
|
||||
return ch.ID, nil
|
||||
}
|
||||
|
||||
// SendConvocPanel sends (or updates) the convocation panel embed+button in the configured Discord channel.
|
||||
func (b *BotServiceImpl) SendConvocPanel(guildID string) error {
|
||||
ctx := context.Background()
|
||||
cfg, err := b.convocRepo.Get(ctx, guildID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get convoc config: %w", err)
|
||||
}
|
||||
if cfg == nil || cfg.PanelChannelID == "" {
|
||||
return fmt.Errorf("aucun salon de panel convocation configuré")
|
||||
}
|
||||
|
||||
title := cfg.PanelEmbedTitle
|
||||
if title == "" {
|
||||
title = "Créer une convocation"
|
||||
}
|
||||
color := parseHexColor(cfg.PanelEmbedColor)
|
||||
if color == 0 {
|
||||
color = 0x5865f2
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: title,
|
||||
Description: cfg.PanelEmbedDescription,
|
||||
Color: color,
|
||||
}
|
||||
components := []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Créer une convocation",
|
||||
Style: discordgo.PrimaryButton,
|
||||
CustomID: "convoc:panel:" + guildID,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
if cfg.PanelMessageID != "" {
|
||||
_, editErr := b.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: cfg.PanelChannelID,
|
||||
ID: cfg.PanelMessageID,
|
||||
Embeds: &[]*discordgo.MessageEmbed{embed},
|
||||
Components: &components,
|
||||
})
|
||||
if editErr == nil {
|
||||
return nil
|
||||
}
|
||||
// Message deleted — fall through to send new
|
||||
_ = b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, "")
|
||||
}
|
||||
|
||||
msg, err := b.session.ChannelMessageSendComplex(cfg.PanelChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
Components: components,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("send convoc panel: %w", err)
|
||||
}
|
||||
return b.convocRepo.SetConvocPanelMessage(ctx, guildID, cfg.PanelChannelID, msg.ID)
|
||||
}
|
||||
|
||||
func sanitizeConvocUsername(username string) string {
|
||||
var out []byte
|
||||
for _, b := range []byte(username) {
|
||||
if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {
|
||||
out = append(out, b)
|
||||
} else if b >= 'A' && b <= 'Z' {
|
||||
out = append(out, b+32)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "user"
|
||||
}
|
||||
if len(out) > 20 {
|
||||
out = out[:20]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func parseHexColor(s string) int {
|
||||
s = strings.TrimPrefix(s, "#")
|
||||
n, _ := strconv.ParseInt(s, 16, 64)
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func buttonStyle(color string) discordgo.ButtonStyle {
|
||||
switch color {
|
||||
case "secondary":
|
||||
return discordgo.SecondaryButton
|
||||
case "success":
|
||||
return discordgo.SuccessButton
|
||||
case "danger":
|
||||
return discordgo.DangerButton
|
||||
default:
|
||||
return discordgo.PrimaryButton
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/leolionad58/ticketbot/internal/db"
|
||||
"github.com/leolionad58/ticketbot/internal/logger"
|
||||
"github.com/leolionad58/ticketbot/internal/tickets"
|
||||
)
|
||||
|
||||
// ConvocationComponent handles the convocation panel button and modal.
|
||||
type ConvocationComponent struct {
|
||||
ConvocRepo *db.ConvocationConfigRepo
|
||||
TicketRepo *db.TicketRepo
|
||||
Auth *tickets.AuthService
|
||||
LogSvc *logger.DiscordLogger
|
||||
}
|
||||
|
||||
// HandlePanel handles clicks on convoc:panel:<guildID> buttons.
|
||||
// Shows a modal asking for target user ID and reason.
|
||||
func (c *ConvocationComponent) HandlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
staffID := interactionUserID(i)
|
||||
memberRoles := memberRoleIDs(i)
|
||||
|
||||
if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) {
|
||||
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
|
||||
slog.Warn("convoc panel: denied", "user_id", staffID)
|
||||
return
|
||||
}
|
||||
|
||||
guildID := interactionGuildID(i)
|
||||
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3)
|
||||
if len(parts) == 3 && parts[2] != "" {
|
||||
guildID = parts[2]
|
||||
}
|
||||
|
||||
modalID := "modal:convoc:" + guildID
|
||||
if len(modalID) > 100 {
|
||||
modalID = modalID[:100]
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: modalID,
|
||||
Title: "Créer une convocation",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "target",
|
||||
Label: "ID Discord de l'utilisateur",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
MaxLength: 50,
|
||||
Placeholder: "123456789012345678",
|
||||
},
|
||||
}},
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "reason",
|
||||
Label: "Raison de la convocation",
|
||||
Style: discordgo.TextInputParagraph,
|
||||
Required: true,
|
||||
MaxLength: 500,
|
||||
Placeholder: "Motif de la convocation...",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleModalSubmit handles modal:convoc:<guildID> submissions.
|
||||
func (c *ConvocationComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return
|
||||
}
|
||||
guildID := parts[2]
|
||||
if guildID == "" {
|
||||
guildID = interactionGuildID(i)
|
||||
}
|
||||
|
||||
var targetID, reason string
|
||||
for _, row := range i.ModalSubmitData().Components {
|
||||
if ar, ok := row.(*discordgo.ActionsRow); ok {
|
||||
for _, comp := range ar.Components {
|
||||
if ti, ok := comp.(*discordgo.TextInput); ok {
|
||||
switch ti.CustomID {
|
||||
case "target":
|
||||
targetID = strings.TrimSpace(ti.Value)
|
||||
case "reason":
|
||||
reason = strings.TrimSpace(ti.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetID == "" || reason == "" {
|
||||
ephemeral(s, i, "L'ID utilisateur et la raison sont requis.")
|
||||
return
|
||||
}
|
||||
// Strip mention format <@ID> or <@!ID>
|
||||
targetID = strings.TrimPrefix(targetID, "<@!")
|
||||
targetID = strings.TrimPrefix(targetID, "<@")
|
||||
targetID = strings.TrimSuffix(targetID, ">")
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
|
||||
staffID := interactionUserID(i)
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := c.ConvocRepo.Get(ctx, guildID)
|
||||
if err != nil || cfg == nil || cfg.CategoryID == "" {
|
||||
followup(s, i, "La catégorie de convocation n'est pas configurée.")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.GuildMember(guildID, targetID)
|
||||
if err != nil {
|
||||
followup(s, i, fmt.Sprintf("Utilisateur introuvable dans ce serveur : %s", targetID))
|
||||
return
|
||||
}
|
||||
if target.User.Bot {
|
||||
followup(s, i, "Tu ne peux pas convoquer un bot.")
|
||||
return
|
||||
}
|
||||
if target.User.ID == staffID {
|
||||
followup(s, i, "Tu ne peux pas te convoquer toi-même.")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := c.TicketRepo.NextConvocationNumber(ctx)
|
||||
if err != nil {
|
||||
slog.Error("convoc modal: next number", "err", err)
|
||||
followup(s, i, "Erreur interne.")
|
||||
return
|
||||
}
|
||||
|
||||
username := target.User.Username
|
||||
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocN(username), n)
|
||||
if len(channelName) > 100 {
|
||||
channelName = channelName[:100]
|
||||
}
|
||||
|
||||
ch, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
|
||||
Name: channelName,
|
||||
Type: discordgo.ChannelTypeGuildText,
|
||||
ParentID: cfg.CategoryID,
|
||||
PermissionOverwrites: []*discordgo.PermissionOverwrite{
|
||||
{
|
||||
ID: guildID,
|
||||
Type: discordgo.PermissionOverwriteTypeRole,
|
||||
Deny: discordgo.PermissionViewChannel,
|
||||
},
|
||||
{
|
||||
ID: staffID,
|
||||
Type: discordgo.PermissionOverwriteTypeMember,
|
||||
Allow: discordgo.PermissionViewChannel |
|
||||
discordgo.PermissionSendMessages |
|
||||
discordgo.PermissionReadMessageHistory |
|
||||
discordgo.PermissionAttachFiles,
|
||||
},
|
||||
{
|
||||
ID: target.User.ID,
|
||||
Type: discordgo.PermissionOverwriteTypeMember,
|
||||
Allow: discordgo.PermissionViewChannel |
|
||||
discordgo.PermissionSendMessages |
|
||||
discordgo.PermissionReadMessageHistory,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("convoc modal: create channel", "err", err)
|
||||
followup(s, i, "Erreur lors de la création du salon.")
|
||||
return
|
||||
}
|
||||
|
||||
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID)
|
||||
if len(deleteCustomID) > 100 {
|
||||
deleteCustomID = "convoc:delete:" + ch.ID
|
||||
}
|
||||
|
||||
s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
|
||||
Content: fmt.Sprintf("<@%s>", target.User.ID),
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: "Convocation",
|
||||
Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffID, reason),
|
||||
Color: 0x5865f2,
|
||||
},
|
||||
},
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Supprimer",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: deleteCustomID,
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
ticket := &db.Ticket{
|
||||
UserID: target.User.ID,
|
||||
Panel: "convocation",
|
||||
Type: "convocation",
|
||||
ChannelID: ch.ID,
|
||||
GuildID: guildID,
|
||||
LogChannelID: cfg.LogChannelID,
|
||||
OpenedAt: time.Now(),
|
||||
Status: "open",
|
||||
TicketNumber: n,
|
||||
}
|
||||
if err := c.TicketRepo.InsertWithNumber(ctx, ticket); err != nil {
|
||||
slog.Error("convoc modal: insert DB", "err", err)
|
||||
}
|
||||
|
||||
c.LogSvc.LogConvocationOpened(staffID, target.User.ID, reason, ch.ID)
|
||||
slog.Info("convocation created via panel", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.User.ID)
|
||||
|
||||
followup(s, i, fmt.Sprintf("Convocation créée : <#%s>", ch.ID))
|
||||
}
|
||||
|
||||
func sanitizeConvocN(username string) string {
|
||||
var out []byte
|
||||
for _, b := range []byte(username) {
|
||||
if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {
|
||||
out = append(out, b)
|
||||
} else if b >= 'A' && b <= 'Z' {
|
||||
out = append(out, b+32)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "user"
|
||||
}
|
||||
if len(out) > 20 {
|
||||
out = out[:20]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type PanelComponent struct {
|
||||
TicketSvc *tickets.Service
|
||||
TicketRepo *db.TicketRepo
|
||||
PanelRepo *db.PanelConfigRepo
|
||||
ClaimMgr *claim.Manager
|
||||
LogSvc *logger.DiscordLogger
|
||||
Config *config.Provider
|
||||
@@ -123,26 +124,48 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
|
||||
ticket.TicketTitle = db.NullStr(ticketTitle)
|
||||
ticket.TicketDescription = db.NullStr(ticketDescription)
|
||||
|
||||
cfg := c.Config.Get()
|
||||
var embedTitle, embedText, embedColor string
|
||||
if panel, ok := cfg.Panels[panelName]; ok {
|
||||
var embedTitle, embedText, embedColor, thumbnailURL, imageURL string
|
||||
yamlCfg := c.Config.Get()
|
||||
if panel, ok := yamlCfg.Panels[panelName]; ok {
|
||||
if typeCfg, ok := panel.Types[ticketType]; ok {
|
||||
embedTitle = typeCfg.EmbedTitle
|
||||
embedText = typeCfg.EmbedText
|
||||
embedColor = typeCfg.EmbedColor
|
||||
}
|
||||
} else if c.PanelRepo != nil {
|
||||
if dbPanel, err := c.PanelRepo.GetByName(context.Background(), panelName); err == nil && dbPanel != nil {
|
||||
for _, t := range dbPanel.Types {
|
||||
if t.Name == ticketType {
|
||||
embedTitle = t.EmbedTitle
|
||||
embedText = t.EmbedText
|
||||
embedColor = t.EmbedColor
|
||||
thumbnailURL = t.ThumbnailURL
|
||||
imageURL = t.ImageURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a) Welcome embed (bot identity, no webhook)
|
||||
welcomeEmbed := &discordgo.MessageEmbed{
|
||||
Title: embedTitle,
|
||||
Description: embedText,
|
||||
Color: parseColor(embedColor),
|
||||
}
|
||||
if thumbnailURL != "" {
|
||||
welcomeEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: thumbnailURL}
|
||||
}
|
||||
if imageURL != "" {
|
||||
welcomeEmbed.Image = &discordgo.MessageEmbedImage{URL: imageURL}
|
||||
}
|
||||
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
|
||||
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{Title: embedTitle, Description: embedText, Color: parseColor(embedColor)},
|
||||
},
|
||||
Embeds: []*discordgo.MessageEmbed{welcomeEmbed},
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Label: "Supprimer le ticket",
|
||||
Label: "Fermer le ticket",
|
||||
Style: discordgo.DangerButton,
|
||||
CustomID: deleteCustomID,
|
||||
},
|
||||
@@ -155,8 +178,21 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
|
||||
// b) Webhook impersonation message
|
||||
postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, i.GuildID)
|
||||
|
||||
// c) Claim channel message (includes title + description)
|
||||
c.ClaimMgr.StartTicket(ctx, s, ticket)
|
||||
// c) Claim channel message — only if claim mode is enabled for this panel type
|
||||
claimEnabled := true
|
||||
if c.PanelRepo != nil {
|
||||
if dbPanel, err2 := c.PanelRepo.GetByName(context.Background(), panelName); err2 == nil && dbPanel != nil {
|
||||
for _, t := range dbPanel.Types {
|
||||
if t.Name == ticketType {
|
||||
claimEnabled = t.ClaimMode != 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimEnabled {
|
||||
c.ClaimMgr.StartTicket(ctx, s, ticket)
|
||||
}
|
||||
c.LogSvc.LogTicketOpened(ticket, uID)
|
||||
|
||||
slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
|
||||
@@ -175,6 +211,9 @@ func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, t
|
||||
user := member.User
|
||||
avatarURL := user.AvatarURL("128")
|
||||
displayName := user.Username
|
||||
if user.GlobalName != "" {
|
||||
displayName = user.GlobalName
|
||||
}
|
||||
if member.Nick != "" {
|
||||
displayName = member.Nick
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Es-tu sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
Content: "Es-tu sûr de vouloir fermer ce ticket ?",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID},
|
||||
discordgo.Button{Label: "Oui, fermer", Style: discordgo.DangerButton, CustomID: yesID},
|
||||
discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID},
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -11,14 +11,20 @@ import (
|
||||
|
||||
// Router dispatches Discord interactions to the appropriate handler.
|
||||
type Router struct {
|
||||
PanelCmd *commands.PanelCommand
|
||||
TicketCmd *commands.TicketCommand
|
||||
ConvocCmd *commands.ConvocationCommand
|
||||
PanelComp *components.PanelComponent
|
||||
TicketComp *components.TicketComponent
|
||||
PanelCmd *commands.PanelCommand
|
||||
TicketCmd *commands.TicketCommand
|
||||
ConvocCmd *commands.ConvocationCommand
|
||||
PanelComp *components.PanelComponent
|
||||
TicketComp *components.TicketComponent
|
||||
ConvocComp *components.ConvocationComponent
|
||||
IsGuildAllowed func(guildID string) bool
|
||||
}
|
||||
|
||||
func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if r.IsGuildAllowed != nil && !r.IsGuildAllowed(i.GuildID) {
|
||||
slog.Debug("ignoring interaction from unlisted guild", "guild_id", i.GuildID)
|
||||
return
|
||||
}
|
||||
switch i.Type {
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
r.routeCommand(s, i)
|
||||
@@ -61,6 +67,10 @@ func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
r.TicketComp.HandleDeleteCancel(s, i)
|
||||
case strings.HasPrefix(customID, "convoc:delete:"):
|
||||
r.TicketComp.HandleConvocationDelete(s, i)
|
||||
case strings.HasPrefix(customID, "convoc:panel:"):
|
||||
if r.ConvocComp != nil {
|
||||
r.ConvocComp.HandlePanel(s, i)
|
||||
}
|
||||
default:
|
||||
slog.Warn("unknown component", "custom_id", customID)
|
||||
}
|
||||
@@ -72,6 +82,10 @@ func (r *Router) routeModal(s *discordgo.Session, i *discordgo.InteractionCreate
|
||||
switch {
|
||||
case strings.HasPrefix(customID, "modal:ticket:"):
|
||||
r.PanelComp.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(customID, "modal:convoc:"):
|
||||
if r.ConvocComp != nil {
|
||||
r.ConvocComp.HandleModalSubmit(s, i)
|
||||
}
|
||||
default:
|
||||
slog.Warn("unknown modal", "custom_id", customID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user