Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
29 changes: 17 additions & 12 deletions bot/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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{}

Expand Down
25 changes: 25 additions & 0 deletions bot/plugin_override_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
3 changes: 2 additions & 1 deletion cmd/irc-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
12 changes: 8 additions & 4 deletions docs/games.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
28 changes: 26 additions & 2 deletions plugins/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
31 changes: 29 additions & 2 deletions plugins/duckhunt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 ""
Expand Down
15 changes: 14 additions & 1 deletion plugins/duckhunt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions plugins/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading