save first version

This commit is contained in:
lfirmin
2026-05-02 02:55:53 +02:00
parent 65d91d8de2
commit 8482f0cc06
35 changed files with 5579 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package tickets
import (
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
)
type Action int
const (
ActionAdmin Action = iota
ActionClaim
ActionClose
ActionAdd
ActionRemove
ActionRename
ActionTranscript
ActionConvocation
ActionReclaim
)
type AuthService struct {
Config *config.Provider
}
func NewAuthService(cfg *config.Provider) *AuthService {
return &AuthService{Config: cfg}
}
// Can returns true if the member (by their role IDs) is authorized to perform action on ticket.
// ticket may be nil for actions that don't require a specific ticket context (e.g. ActionAdmin, ActionConvocation).
func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket) bool {
cfg := a.Config.Get()
hasRole := func(roleID string) bool {
for _, r := range memberRoles {
if r == roleID {
return true
}
}
return false
}
isAdmin := hasRole(cfg.Bot.AdminRole)
staffRoleForTicket := func() string {
if ticket == nil {
return ""
}
if p, ok := cfg.Panels[ticket.Panel]; ok {
if t, ok := p.Types[ticket.Type]; ok {
return t.StaffRole
}
}
return ""
}
switch action {
case ActionAdmin:
return isAdmin
case ActionClaim:
if isAdmin {
return true
}
role := staffRoleForTicket()
return role != "" && hasRole(role)
case ActionClose, ActionAdd, ActionRemove, ActionRename, ActionTranscript, ActionReclaim:
if isAdmin {
return true
}
role := staffRoleForTicket()
return role != "" && hasRole(role)
case ActionConvocation:
if isAdmin {
return true
}
for _, panel := range cfg.Panels {
for _, t := range panel.Types {
if hasRole(t.StaffRole) {
return true
}
}
}
return false
default:
return false
}
}
+95
View File
@@ -0,0 +1,95 @@
package tickets
import (
"testing"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
)
func makeProvider() *config.Provider {
cfg := &config.Config{
Bot: config.Bot{AdminRole: "admin-role"},
Panels: map[string]config.Panel{
"support_panel": {
Types: map[string]config.TicketType{
"support": {StaffRole: "staff-role"},
"mod": {StaffRole: "mod-role"},
},
},
},
}
return config.NewProvider(cfg)
}
func supportTicket() *db.Ticket {
return &db.Ticket{Panel: "support_panel", Type: "support"}
}
func TestCanAdmin(t *testing.T) {
auth := NewAuthService(makeProvider())
if !auth.Can([]string{"admin-role"}, ActionAdmin, nil) {
t.Error("admin should pass ActionAdmin")
}
if auth.Can([]string{"random"}, ActionAdmin, nil) {
t.Error("non-admin should fail ActionAdmin")
}
}
func TestCanClaim(t *testing.T) {
auth := NewAuthService(makeProvider())
ticket := supportTicket()
if !auth.Can([]string{"staff-role"}, ActionClaim, ticket) {
t.Error("staff should claim")
}
if !auth.Can([]string{"admin-role"}, ActionClaim, ticket) {
t.Error("admin should claim")
}
if auth.Can([]string{"mod-role"}, ActionClaim, ticket) {
t.Error("wrong-staff should not claim support ticket")
}
if auth.Can([]string{"random"}, ActionClaim, ticket) {
t.Error("non-staff should not claim")
}
}
func TestCanTicketActions(t *testing.T) {
auth := NewAuthService(makeProvider())
ticket := supportTicket()
for _, action := range []Action{ActionClose, ActionAdd, ActionRemove, ActionRename, ActionTranscript} {
if !auth.Can([]string{"staff-role"}, action, ticket) {
t.Errorf("staff should be able to action %d", action)
}
if !auth.Can([]string{"admin-role"}, action, ticket) {
t.Errorf("admin should be able to action %d", action)
}
if auth.Can([]string{"random"}, action, ticket) {
t.Errorf("random should not be able to action %d", action)
}
}
}
func TestCanConvocation(t *testing.T) {
auth := NewAuthService(makeProvider())
// any staff role across all panels
if !auth.Can([]string{"staff-role"}, ActionConvocation, nil) {
t.Error("support staff should convocation")
}
if !auth.Can([]string{"mod-role"}, ActionConvocation, nil) {
t.Error("mod staff should convocation")
}
if !auth.Can([]string{"admin-role"}, ActionConvocation, nil) {
t.Error("admin should convocation")
}
if auth.Can([]string{"random"}, ActionConvocation, nil) {
t.Error("non-staff should not convocation")
}
}
func TestCanNilTicket(t *testing.T) {
auth := NewAuthService(makeProvider())
// Should not panic and should return false for staff actions without ticket
if auth.Can([]string{"staff-role"}, ActionClaim, nil) {
t.Error("should not claim nil ticket")
}
}
+152
View File
@@ -0,0 +1,152 @@
package tickets
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"unicode"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
)
type Service struct {
db *db.TicketRepo
auth *AuthService
session *discordgo.Session
cfg *config.Provider
opening sync.Map // key: userID+":"+ticketType prevents double-click
}
func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service {
return &Service{db: repo, auth: auth, session: s, cfg: cfg}
}
var ErrAlreadyOpen = fmt.Errorf("already_open")
// Open creates a ticket channel and inserts into DB. Returns the created Ticket.
// Returns ErrAlreadyOpen (wrapped with channel ID) if user already has an open ticket of the same type.
func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticketType string) (*db.Ticket, error) {
lockKey := userID + ":" + ticketType
if _, loaded := svc.opening.LoadOrStore(lockKey, struct{}{}); loaded {
return nil, fmt.Errorf("creation already in progress")
}
defer svc.opening.Delete(lockKey)
existing, err := svc.db.HasOpenTicket(ctx, userID, ticketType)
if err != nil {
return nil, fmt.Errorf("check existing: %w", err)
}
if existing != nil {
return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, existing.ChannelID)
}
cfg := svc.cfg.Get()
panel, ok := cfg.Panels[panelName]
if !ok {
return nil, fmt.Errorf("panel %q not found", panelName)
}
typeCfg, ok := panel.Types[ticketType]
if !ok {
return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName)
}
// Reserve a placeholder to get the ticket number atomically
ticket := &db.Ticket{
UserID: userID,
Panel: panelName,
Type: ticketType,
ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()),
OpenedAt: time.Now(),
}
if err := svc.db.Insert(ctx, ticket); err != nil {
return nil, fmt.Errorf("insert ticket: %w", err)
}
channelName := fmt.Sprintf("%s-%04d", ticketType, ticket.TicketNumber)
ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: channelName,
Type: discordgo.ChannelTypeGuildText,
ParentID: typeCfg.Category,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: guildID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: discordgo.PermissionViewChannel,
},
{
ID: userID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory |
discordgo.PermissionAttachFiles,
},
},
})
if err != nil {
slog.Error("create channel", "ticket_id", ticket.ID, "err", err)
return nil, fmt.Errorf("create channel: %w", err)
}
if err := svc.db.UpdateChannelID(ctx, ticket.ID, ch.ID); err != nil {
slog.Error("update channel_id", "ticket_id", ticket.ID, "err", err)
}
ticket.ChannelID = ch.ID
return ticket, nil
}
// AddMember adds a Discord user to a ticket channel with full read/send permissions.
func (svc *Service) AddMember(ctx context.Context, channelID, userID string) error {
return svc.session.ChannelPermissionSet(channelID, userID,
discordgo.PermissionOverwriteTypeMember,
discordgo.PermissionViewChannel|discordgo.PermissionSendMessages|
discordgo.PermissionReadMessageHistory|discordgo.PermissionAttachFiles,
0)
}
// RemoveMember removes a Discord user from a ticket channel.
func (svc *Service) RemoveMember(ctx context.Context, channelID, userID string) error {
return svc.session.ChannelPermissionDelete(channelID, userID)
}
// Rename sanitizes and renames a ticket channel. Returns error if name is invalid.
func (svc *Service) Rename(ctx context.Context, channelID, name string) error {
safe := sanitizeChannelName(name)
if safe == "" {
return fmt.Errorf("invalid channel name after sanitization")
}
if len(safe) > 100 {
safe = safe[:100]
}
_, err := svc.session.ChannelEdit(channelID, &discordgo.ChannelEdit{Name: safe})
return err
}
// ClaimChannel adds a staff member to a ticket channel.
func (svc *Service) ClaimChannel(ctx context.Context, channelID, staffID string) error {
return svc.AddMember(ctx, channelID, staffID)
}
// DeleteChannel permanently deletes a Discord channel.
func (svc *Service) DeleteChannel(channelID string) error {
_, err := svc.session.ChannelDelete(channelID)
return err
}
func sanitizeChannelName(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}