From e7672fb7326df5f4b251610178c2fe2ab3d151b4 Mon Sep 17 00:00:00 2001 From: AK Date: Sun, 2 Aug 2026 03:43:54 -0700 Subject: [PATCH] feat: add channel plugin overrides and richer fun responses --- bot/bot.go | 28 +++++++++++++++++++++++++++ bot/config.go | 29 ++++++++++++++++------------ bot/plugin_override_test.go | 25 ++++++++++++++++++++++++ cmd/irc-bot/main.go | 3 ++- config.yaml | 8 +++++++- docs/configuration.md | 14 +++++++++++++- docs/games.md | 12 ++++++++---- plugins/alias.go | 28 +++++++++++++++++++++++++-- plugins/duckhunt.go | 31 ++++++++++++++++++++++++++++-- plugins/duckhunt_test.go | 15 ++++++++++++++- plugins/help.go | 6 ++++++ plugins/karma.go | 38 +++++++++++++++++++++++++++++++++---- plugins/karma_test.go | 14 ++++++++++++++ 13 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 bot/plugin_override_test.go diff --git a/bot/bot.go b/bot/bot.go index e2034d2..309af5c 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -207,6 +207,28 @@ func (b *Bot) ChannelWarming(channel string) bool { return b.channelWarming(channel) } +// PluginEnabledForChannel reports whether a plugin is allowed to operate in +// a channel. Per-channel overrides are intentionally opt-out: a channel that +// is not listed, or a plugin that is not listed for that channel, keeps the +// global plugin setting. +func (b *Bot) PluginEnabledForChannel(pluginName, channel string) bool { + channel = strings.TrimSpace(channel) + if channel == "" { + return true + } + for configuredChannel, overrides := range b.Config.PluginOverrides { + if !strings.EqualFold(strings.TrimSpace(configuredChannel), channel) { + continue + } + for configuredPlugin, enabled := range overrides { + if strings.EqualFold(strings.TrimSpace(configuredPlugin), pluginName) { + return enabled + } + } + } + return true +} + func validChannelName(channel string) bool { if len(channel) < 2 || len(channel) > 200 || (channel[0] != '#' && channel[0] != '&') { return false @@ -414,6 +436,9 @@ func (b *Bot) dispatch(msg Message) { } command := false for _, p := range b.Plugins { + if msg.IsChannel && !b.PluginEnabledForChannel(p.Name(), msg.Target) { + continue + } consumed := false func() { defer func() { @@ -437,6 +462,9 @@ func (b *Bot) dispatch(msg Message) { func (b *Bot) dispatchEvent(msg Message) { for _, p := range b.Plugins { + if msg.IsChannel && !b.PluginEnabledForChannel(p.Name(), msg.Target) { + continue + } handler, ok := p.(EventHandler) if !ok { continue diff --git a/bot/config.go b/bot/config.go index 372bbd9..a964e8f 100644 --- a/bot/config.go +++ b/bot/config.go @@ -3,14 +3,18 @@ package bot type Config struct { // Server, Identity, and Channels are retained for single-network config // compatibility. New installations should use Networks. - Server ServerConfig - Identity IdentityConfig - Channels []string - Networks []NetworkConfig - NetworkName string - CommandPrefix string `mapstructure:"command_prefix"` - OwnerAccounts []string `mapstructure:"owner_accounts"` - RateLimit struct { + Server ServerConfig + Identity IdentityConfig + Channels []string + Networks []NetworkConfig + // PluginOverrides can disable individual plugins for a specific channel + // on this network. A missing override leaves the global plugin setting in + // effect. + PluginOverrides map[string]map[string]bool `mapstructure:"plugin_overrides"` + NetworkName string + CommandPrefix string `mapstructure:"command_prefix"` + OwnerAccounts []string `mapstructure:"owner_accounts"` + RateLimit struct { MessagesPerSecond float64 `mapstructure:"messages_per_second"` Burst int CommandCooldownSeconds int `mapstructure:"command_cooldown_seconds"` @@ -51,10 +55,11 @@ type IdentityConfig struct { } type NetworkConfig struct { - Name string - Server ServerConfig - Identity IdentityConfig - Channels []string + Name string + Server ServerConfig + Identity IdentityConfig + Channels []string + PluginOverrides map[string]map[string]bool `mapstructure:"plugin_overrides"` } type PluginConfig map[string]interface{} diff --git a/bot/plugin_override_test.go b/bot/plugin_override_test.go new file mode 100644 index 0000000..c40ca23 --- /dev/null +++ b/bot/plugin_override_test.go @@ -0,0 +1,25 @@ +package bot + +import ( + "context" + "testing" + + "go.uber.org/zap" +) + +func TestPluginEnabledForChannelUsesCaseInsensitiveOptOuts(t *testing.T) { + b := New(Config{PluginOverrides: map[string]map[string]bool{ + "#Noisy": {"Banter": false, "weather": true}, + }}, nil, nil, zap.NewNop()) + + if b.PluginEnabledForChannel("banter", "#noisy") { + t.Fatal("expected banter to be disabled in the overridden channel") + } + if !b.PluginEnabledForChannel("weather", "#NOISY") { + t.Fatal("expected explicitly enabled weather override to remain enabled") + } + if !b.PluginEnabledForChannel("banter", "#other") { + t.Fatal("expected an unlisted channel to keep the plugin enabled") + } + b.Queue.Drain(context.Background()) +} diff --git a/cmd/irc-bot/main.go b/cmd/irc-bot/main.go index 90446b5..badc35e 100644 --- a/cmd/irc-bot/main.go +++ b/cmd/irc-bot/main.go @@ -48,7 +48,7 @@ func main() { networks := cfg.Networks if len(networks) == 0 && cfg.Server.Host != "" { - networks = []bot.NetworkConfig{{Name: "default", Server: cfg.Server, Identity: cfg.Identity, Channels: cfg.Channels}} + networks = []bot.NetworkConfig{{Name: "default", Server: cfg.Server, Identity: cfg.Identity, Channels: cfg.Channels, PluginOverrides: cfg.PluginOverrides}} } if len(networks) == 0 { panic("no IRC networks configured") @@ -105,6 +105,7 @@ func main() { networkCfg.Server = network.Server networkCfg.Identity = network.Identity networkCfg.Channels = network.Channels + networkCfg.PluginOverrides = network.PluginOverrides active := make([]bot.Plugin, 0) for _, p := range plugins.All() { if c, ok := cfg.Plugins[p.Name()]; ok && !c.Bool("enabled", true) { diff --git a/config.yaml b/config.yaml index 6e687bf..d2d26a5 100644 --- a/config.yaml +++ b/config.yaml @@ -20,6 +20,12 @@ networks: nickserv_ghost: false channels: - "#example" + # Optional per-channel opt-outs. Unlisted plugins keep the global setting. + # plugin_overrides: + # "#quiet-channel": + # banter: false + # urltitle: false + # duckhunt: false # For older single-network deployments, server/identity/channels can still be # used instead of networks. Networks takes precedence when it is non-empty. @@ -104,7 +110,7 @@ plugins: car: {enabled: true, data_file: "data/cars.txt", max_length: 240} # Optional activity-triggered duck hunt. Scores persist in BoltDB. # Start/stop controls require an authenticated account listed in owner_accounts. - duckhunt: {enabled: false, minimum_messages: 25, minimum_users: 2, min_delay_seconds: 60, max_delay_seconds: 300, timeout_seconds: 30, flavor_enabled: true, flavor_min_lead_seconds: 15, befriend_enabled: true, min_reaction_seconds: 1, retry_cooldown_seconds: 7} + duckhunt: {enabled: false, minimum_messages: 25, minimum_users: 2, min_delay_seconds: 60, max_delay_seconds: 300, timeout_seconds: 60, flavor_enabled: true, flavor_min_lead_seconds: 15, befriend_enabled: true, min_reaction_seconds: 1, retry_cooldown_seconds: 7} stats: enabled: true http_port: 8082 diff --git a/docs/configuration.md b/docs/configuration.md index 6fbcfe0..b5b99dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,6 +23,12 @@ networks: channels: - "#example" - "#bots" + # Optional per-channel opt-outs. Unlisted plugins keep their global setting. + plugin_overrides: + "#bots": + banter: false + urltitle: false + duckhunt: false - name: secondary server: @@ -42,7 +48,13 @@ networks: `networks` takes precedence over the older single-network top-level fields. Each network has its own IRC connection, identity, channels, SASL settings, -and plugin activity. +and plugin activity. To reduce chatter in one channel without changing the +global plugin configuration, add `plugin_overrides` under that network. The +map key is the channel name and each nested key is a canonical plugin name. +Set a plugin to `false` to disable it in that channel; omitted plugins remain +enabled. Channel and plugin names are matched case-insensitively. Overrides +also keep disabled plugins out of `!help` and `!alias`, and event-driven +plugins such as Duck Hunt are stopped cleanly for that channel. ## Main settings diff --git a/docs/games.md b/docs/games.md index 65267a7..70426d3 100644 --- a/docs/games.md +++ b/docs/games.md @@ -143,7 +143,7 @@ plugins: minimum_users: 2 min_delay_seconds: 60 max_delay_seconds: 300 - timeout_seconds: 30 + timeout_seconds: 60 flavor_enabled: true flavor_min_lead_seconds: 15 befriend_enabled: true @@ -186,9 +186,13 @@ early reaction window have a probability of success; slower shots succeed. Each user gets a short retry cooldown. Invalid shots when no duck is active are quietly ignored. -Announcements and results use standard mIRC IRC colors for the Duck Hunt -label, duck, quack, misses, and successful interactions. Clients without color -support still receive readable text and an ASCII duck. +If nobody shoots or befriends the duck before `timeout_seconds` expires, it +responds with one randomized escape line such as `The duck escapes into the +sky!` or `\\_o< *ZOOM* The speedy duck vanishes in a flash!`. This is one IRC +message, not a follow-up flood. Announcements and results use standard mIRC +IRC colors for the Duck Hunt label, duck, quack, misses, and successful +interactions. Clients without color support still receive readable text and an +ASCII duck. Settings: diff --git a/plugins/alias.go b/plugins/alias.go index 9d0ae8e..d77f0ff 100644 --- a/plugins/alias.go +++ b/plugins/alias.go @@ -22,7 +22,7 @@ func (p *Alias) Handle(b *bot.Bot, m bot.Message) bool { } arg = strings.ToLower(strings.TrimSpace(arg)) if arg != "" { - if aliases, plugin, ok := aliasesFor(b.Plugins, arg); ok { + if aliases, plugin, ok := aliasesForEnabled(b, m, arg); ok { if len(aliases) == 0 { b.Send(m.ReplyTarget(), ircColor(ircYellow, "!"+plugin+" has no aliases")) } else { @@ -33,10 +33,34 @@ func (p *Alias) Handle(b *bot.Bot, m bot.Message) bool { b.Send(m.ReplyTarget(), ircColor(ircRed, "unknown command; use !alias to list aliases")) return true } - b.Send(m.ReplyTarget(), ircBold+"aliases:"+ircReset+" "+formatAliasGroups(b.Plugins)) + plugins := make([]bot.Plugin, 0, len(b.Plugins)) + for _, plugin := range b.Plugins { + if m.IsChannel && !b.PluginEnabledForChannel(plugin.Name(), m.Target) { + continue + } + plugins = append(plugins, plugin) + } + b.Send(m.ReplyTarget(), ircBold+"aliases:"+ircReset+" "+formatAliasGroups(plugins)) return true } +func aliasesForEnabled(b *bot.Bot, m bot.Message, name string) ([]string, string, bool) { + for _, plugin := range b.Plugins { + if m.IsChannel && !b.PluginEnabledForChannel(plugin.Name(), m.Target) { + continue + } + if strings.EqualFold(plugin.Name(), name) { + return pluginAliases(plugin), plugin.Name(), true + } + for _, command := range plugin.Commands() { + if strings.EqualFold(command, name) { + return pluginAliases(plugin), plugin.Name(), true + } + } + } + return nil, "", false +} + func aliasesFor(plugins []bot.Plugin, name string) ([]string, string, bool) { for _, plugin := range plugins { if strings.EqualFold(plugin.Name(), name) { diff --git a/plugins/duckhunt.go b/plugins/duckhunt.go index 5f2a132..4f7d026 100644 --- a/plugins/duckhunt.go +++ b/plugins/duckhunt.go @@ -67,7 +67,7 @@ func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { minimumUsers := c.Int("minimum_users", 2) minDelay := c.Int("min_delay_seconds", 60) maxDelay := c.Int("max_delay_seconds", 300) - timeout := c.Int("timeout_seconds", 30) + timeout := c.Int("timeout_seconds", 60) flavorEnabled := c.Bool("flavor_enabled", true) flavorMinLead := c.Int("flavor_min_lead_seconds", 15) befriendEnabled := c.Bool("befriend_enabled", true) @@ -196,6 +196,19 @@ func (p *DuckHunt) tick(b *bot.Bot) { p.mu.Lock() for _, state := range p.states { + if !b.PluginEnabledForChannel(p.Name(), state.channel) { + // Drop pending activity when the channel override disables this + // plugin. New activity can schedule a fresh hunt if it is enabled + // again later. + state.active = false + state.spawnedAt = time.Time{} + state.nextSpawn = time.Time{} + state.flavorAt = time.Time{} + state.flavorSent = false + state.messages = 0 + state.users = make(map[string]struct{}) + continue + } if state.stopped { continue } @@ -211,7 +224,7 @@ func (p *DuckHunt) tick(b *bot.Bot) { state.users = make(map[string]struct{}) messages = append(messages, outgoing{ target: state.channel, - text: ircColor(ircYellow, "The duck flew away—too slow!"), + text: randomDuckEscape(), }) continue } @@ -461,6 +474,20 @@ func randomDuckAnnouncement() string { return fmt.Sprintf("%s %s %s Type %s to shoot it!", ircColor(ircGreen, "[Duck Hunt]"), ircColor(ircYellow, duck), ircColor(ircCyan, noise), ircColor(ircBold, "!bang")) } +func randomDuckEscape() string { + actions := []string{ + `The duck escapes into the sky! °°...`, + `The duck flaps away, living another day. °°°...`, + `The duck waddles behind a bush and gets away! \_o<`, + `\_o< *ZOOM* The speedy duck vanishes in a flash!`, + `The duck takes off in a hurry. QUACK! °°...`, + `The duck slips away through the reeds. Better luck next time!`, + `The duck spreads its wings and soars away. \_O<`, + `The duck makes a break for it—waddle waddle waddle!`, + } + return ircColor(ircYellow, "[Duck Hunt] "+actions[rand.Intn(len(actions))]) +} + func duckPlural(count uint64) string { if count == 1 { return "" diff --git a/plugins/duckhunt_test.go b/plugins/duckhunt_test.go index fb74183..a361d07 100644 --- a/plugins/duckhunt_test.go +++ b/plugins/duckhunt_test.go @@ -17,7 +17,7 @@ func TestDuckHuntDefaults(t *testing.T) { if plugin.cfg.minimumMessages != 25 || plugin.cfg.minimumUsers != 2 { t.Fatalf("unexpected activity defaults: %+v", plugin.cfg) } - if plugin.cfg.minDelay != time.Minute || plugin.cfg.maxDelay != 5*time.Minute || plugin.cfg.timeout != 30*time.Second { + if plugin.cfg.minDelay != time.Minute || plugin.cfg.maxDelay != 5*time.Minute || plugin.cfg.timeout != time.Minute { t.Fatalf("unexpected timing defaults: %+v", plugin.cfg) } if !plugin.cfg.befriendEnabled || plugin.cfg.minReaction != time.Second || plugin.cfg.retryCooldown != 7*time.Second { @@ -181,6 +181,19 @@ func TestDuckHuntFlavorIncludesColorAndMotion(t *testing.T) { } } +func TestDuckHuntEscapeIncludesColorAndMotion(t *testing.T) { + escape := randomDuckEscape() + if !strings.Contains(escape, "\x03") { + t.Fatal("expected mIRC color formatting in Duck Hunt escape") + } + if !strings.Contains(escape, "[Duck Hunt]") { + t.Fatal("expected Duck Hunt label in escape") + } + if strings.Contains(escape, "\n") || strings.Contains(escape, "\r") { + t.Fatal("escape must remain one IRC message") + } +} + func TestDuckHuntFlavorTimeIsBeforeSpawn(t *testing.T) { now := time.Unix(1000, 0) nextSpawn := now.Add(60 * time.Second) diff --git a/plugins/help.go b/plugins/help.go index 2b07782..ee1da62 100644 --- a/plugins/help.go +++ b/plugins/help.go @@ -21,6 +21,9 @@ func (p *Help) Handle(b *bot.Bot, m bot.Message) bool { } if strings.TrimSpace(arg) != "" { for _, x := range b.Plugins { + if m.IsChannel && !b.PluginEnabledForChannel(x.Name(), m.Target) { + continue + } if strings.EqualFold(x.Name(), strings.TrimSpace(arg)) { b.Send(m.ReplyTarget(), ircColor(ircCyan, x.Help())) return true @@ -35,6 +38,9 @@ func (p *Help) Handle(b *bot.Bot, m bot.Message) bool { } var names []string for _, x := range b.Plugins { + if m.IsChannel && !b.PluginEnabledForChannel(x.Name(), m.Target) { + continue + } names = append(names, x.Name()) } sort.Strings(names) diff --git a/plugins/karma.go b/plugins/karma.go index b9ad355..3418004 100644 --- a/plugins/karma.go +++ b/plugins/karma.go @@ -27,7 +27,7 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool { cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix) if !ok { if updates := p.applyTextChanges(m.Text); len(updates) > 0 { - b.Send(m.ReplyTarget(), ircColor(ircCyan, "karma updated: "+strings.Join(updates, ", "))) + b.Send(m.ReplyTarget(), formatKarmaUpdates(updates)) return true } return false @@ -56,11 +56,17 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool { return true } -func (p *Karma) applyTextChanges(text string) []string { +type karmaUpdate struct { + key string + delta int + value int +} + +func (p *Karma) applyTextChanges(text string) []karmaUpdate { if p.db == nil || p.rx == nil { return nil } - updates := make([]string, 0) + updates := make([]karmaUpdate, 0) for _, match := range p.rx.FindAllStringSubmatchIndex(text, -1) { if len(match) < 8 { continue @@ -79,11 +85,35 @@ func (p *Karma) applyTextChanges(text string) []string { if err != nil { continue } - updates = append(updates, fmt.Sprintf("%s=%+d", key, value)) + updates = append(updates, karmaUpdate{key: key, delta: delta, value: value}) } return updates } +func formatKarmaUpdates(updates []karmaUpdate) string { + if len(updates) == 0 { + return "" + } + details := make([]string, 0, len(updates)) + positive, negative := true, true + for _, update := range updates { + if update.delta <= 0 { + positive = false + } + if update.delta >= 0 { + negative = false + } + details = append(details, fmt.Sprintf("%s %+d (total %+d)", update.key, update.delta, update.value)) + } + if positive { + return ircColor(ircGreen, "Karma boost! "+strings.Join(details, ", ")+" ✨🎯🌟💫") + } + if negative { + return ircColor(ircRed, "Karma dip! "+strings.Join(details, ", ")+" 📉🌀💥😬") + } + return ircColor(ircCyan, "Karma update! "+strings.Join(details, ", ")+" ✨📊🔄🌟") +} + func isKarmaWordByte(value byte) bool { return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || diff --git a/plugins/karma_test.go b/plugins/karma_test.go index 2fe1266..2942982 100644 --- a/plugins/karma_test.go +++ b/plugins/karma_test.go @@ -3,6 +3,7 @@ package plugins import ( "path/filepath" "regexp" + "strings" "testing" "github.com/variablenix/GoBot/bot" @@ -26,6 +27,19 @@ func TestKarmaRegex(t *testing.T) { } } +func TestKarmaUpdateMessageIsColorfulAndCompact(t *testing.T) { + message := formatKarmaUpdates([]karmaUpdate{{key: "echo", delta: 1, value: 4}}) + if !strings.Contains(message, "Karma boost! echo") { + t.Fatalf("unexpected karma message: %q", message) + } + if strings.Count(message, "✨") == 0 || strings.Count(message, "🎯") == 0 || strings.Count(message, "🌟") == 0 || strings.Count(message, "💫") == 0 { + t.Fatalf("expected four positive karma emojis: %q", message) + } + if !strings.Contains(message, "\x03") { + t.Fatal("expected IRC color formatting") + } +} + func TestKarmaChangesPersist(t *testing.T) { db, err := storage.Open(filepath.Join(t.TempDir(), "karma.db")) if err != nil {