save first version
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ButtonColor string
|
||||
|
||||
const (
|
||||
ColorPrimary ButtonColor = "primary"
|
||||
ColorSecondary ButtonColor = "secondary"
|
||||
ColorSuccess ButtonColor = "success"
|
||||
ColorDanger ButtonColor = "danger"
|
||||
)
|
||||
|
||||
type TicketType struct {
|
||||
ButtonLabel string `yaml:"button_label"`
|
||||
ButtonColor ButtonColor `yaml:"button_color"`
|
||||
ButtonEmoji string `yaml:"button_emoji"`
|
||||
EmbedColor string `yaml:"embed_color"`
|
||||
EmbedTitle string `yaml:"embed_title"`
|
||||
EmbedText string `yaml:"embed_text"`
|
||||
StaffRole string `yaml:"staff_role"`
|
||||
Category string `yaml:"category"`
|
||||
}
|
||||
|
||||
type Panel struct {
|
||||
EmbedTitle string `yaml:"embed_title"`
|
||||
EmbedDescription string `yaml:"embed_description"`
|
||||
EmbedColor string `yaml:"embed_color"`
|
||||
Types map[string]TicketType `yaml:"types"`
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
AdminRole string `yaml:"admin_role"`
|
||||
LogsChannel string `yaml:"logs_channel"`
|
||||
ClaimChannel string `yaml:"claim_channel"`
|
||||
ConvocationCategory string `yaml:"convocation_category"`
|
||||
ClaimReupMinutes int `yaml:"claim_reup_minutes"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Bot Bot `yaml:"bot"`
|
||||
Panels map[string]Panel `yaml:"panels"`
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.Bot.AdminRole == "" {
|
||||
return fmt.Errorf("bot.admin_role is required")
|
||||
}
|
||||
if c.Bot.ClaimReupMinutes <= 0 {
|
||||
c.Bot.ClaimReupMinutes = 30
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Provider holds the current config and allows atomic swaps.
|
||||
type Provider struct {
|
||||
ptr atomic.Pointer[Config]
|
||||
}
|
||||
|
||||
func NewProvider(cfg *Config) *Provider {
|
||||
p := &Provider{}
|
||||
p.ptr.Store(cfg)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Get() *Config {
|
||||
return p.ptr.Load()
|
||||
}
|
||||
|
||||
func (p *Provider) Swap(cfg *Config) {
|
||||
p.ptr.Store(cfg)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const validConfig = `
|
||||
bot:
|
||||
admin_role: "123456789"
|
||||
logs_channel: "111"
|
||||
claim_channel: "222"
|
||||
convocation_category: "333"
|
||||
claim_reup_minutes: 15
|
||||
panels:
|
||||
support_panel:
|
||||
embed_title: "Support"
|
||||
embed_description: "Choisis"
|
||||
embed_color: "#2b2d31"
|
||||
types:
|
||||
support:
|
||||
button_label: "Support"
|
||||
button_color: "primary"
|
||||
embed_color: "#5865f2"
|
||||
embed_title: "Ticket support"
|
||||
embed_text: "Décris ton problème."
|
||||
staff_role: "444"
|
||||
category: "555"
|
||||
`
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(f, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestLoadValidConfig(t *testing.T) {
|
||||
cfg, err := Load(writeConfig(t, validConfig))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Bot.AdminRole != "123456789" {
|
||||
t.Errorf("admin_role = %q, want 123456789", cfg.Bot.AdminRole)
|
||||
}
|
||||
if cfg.Bot.ClaimReupMinutes != 15 {
|
||||
t.Errorf("claim_reup_minutes = %d, want 15", cfg.Bot.ClaimReupMinutes)
|
||||
}
|
||||
panel, ok := cfg.Panels["support_panel"]
|
||||
if !ok {
|
||||
t.Fatal("support_panel missing")
|
||||
}
|
||||
if _, ok := panel.Types["support"]; !ok {
|
||||
t.Fatal("support type missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingAdminRole(t *testing.T) {
|
||||
f := writeConfig(t, "bot:\n logs_channel: \"111\"\n")
|
||||
_, err := Load(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing admin_role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReupMinutes(t *testing.T) {
|
||||
f := writeConfig(t, "bot:\n admin_role: \"123\"\n")
|
||||
cfg, err := Load(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Bot.ClaimReupMinutes != 30 {
|
||||
t.Errorf("default reup = %d, want 30", cfg.Bot.ClaimReupMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSwap(t *testing.T) {
|
||||
cfg1 := &Config{Bot: Bot{AdminRole: "a"}}
|
||||
p := NewProvider(cfg1)
|
||||
if p.Get().Bot.AdminRole != "a" {
|
||||
t.Fatal("initial get failed")
|
||||
}
|
||||
cfg2 := &Config{Bot: Bot{AdminRole: "b"}}
|
||||
p.Swap(cfg2)
|
||||
if p.Get().Bot.AdminRole != "b" {
|
||||
t.Fatal("swap failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// Watch watches the config file and calls onReload with the new config on change.
|
||||
// Debounces Write/Create events by 500ms to avoid multiple triggers on a single save.
|
||||
func Watch(path string, provider *Provider, onReload func(*Config)) error {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := watcher.Add(path); err != nil {
|
||||
watcher.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
var debounce *time.Timer
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(500*time.Millisecond, func() {
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
slog.Error("hot reload: invalid config, keeping old", "err", err)
|
||||
return
|
||||
}
|
||||
provider.Swap(cfg)
|
||||
slog.Info("config reloaded")
|
||||
if onReload != nil {
|
||||
onReload(cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("config watcher error", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user