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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ there.
| [Security](docs/security.md) | Deployment and runtime security guidance |
| [Development and CI](docs/development.md) | Project workflow, tests, CI, CodeQL, and Dependabot |

Owners can send GoBot a private `reload` message to apply reloadable plugin
configuration without dropping the IRC connection. See
[Configuration](docs/configuration.md#owner-only-private-reload) for the
authentication and reload boundaries.

## Project layout

```text
Expand Down
90 changes: 74 additions & 16 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,24 @@ import (
)

type Bot struct {
Config Config
DB *storage.DB
Stats *Stats
Plugins []Plugin
Log *zap.Logger
Queue *Queue
client *irc.Client
mu sync.RWMutex
commandMu sync.Mutex
lastCommands map[string]time.Time
lastWarnings map[string]time.Time
inviteMu sync.Mutex
lastInvites map[string]time.Time
lastInvite time.Time
warmupMu sync.RWMutex
warmupUntil map[string]time.Time
Config Config
DB *storage.DB
Stats *Stats
Plugins []Plugin
Log *zap.Logger
Queue *Queue
client *irc.Client
reloadHandler func(Message)
mu sync.RWMutex
pluginMu sync.RWMutex
commandMu sync.Mutex
lastCommands map[string]time.Time
lastWarnings map[string]time.Time
inviteMu sync.Mutex
lastInvites map[string]time.Time
lastInvite time.Time
warmupMu sync.RWMutex
warmupUntil map[string]time.Time
}

func New(cfg Config, db *storage.DB, plugins []Plugin, log *zap.Logger) *Bot {
Expand All @@ -44,6 +46,38 @@ func NewWithStats(cfg Config, db *storage.DB, plugins []Plugin, log *zap.Logger,
b.Queue = NewQueue(cfg.RateLimit.MessagesPerSecond, cfg.RateLimit.Burst, func(o Outgoing) { b.sendNow(o.Target, o.Text) })
return b
}

// SetReloadHandler installs the owner-only private-message callback used by
// the process to reload plugin configuration in place.
func (b *Bot) SetReloadHandler(handler func(Message)) {
b.mu.Lock()
b.reloadHandler = handler
b.mu.Unlock()
}

// ReloadPlugins applies configuration to active plugins that explicitly
// support runtime reloads. Connection, identity, channel, and owner settings
// remain unchanged until the next process start.
func (b *Bot) ReloadPlugins(configs map[string]PluginConfig) (int, error) {
b.pluginMu.Lock()
defer b.pluginMu.Unlock()
count := 0
var firstErr error
for _, p := range b.Plugins {
reloadable, ok := p.(Reloadable)
if !ok {
continue
}
if err := reloadable.Reload(configs[p.Name()]); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", p.Name(), err)
}
continue
}
count++
}
return count, firstErr
}
func (b *Bot) Send(target, text string) {
if !b.Queue.Enqueue(Outgoing{target, text}) {
b.Stats.dropped.Add(1)
Expand Down Expand Up @@ -428,6 +462,9 @@ func (b *Bot) logIRCEvent(m *irc.Message) {
}
}
func (b *Bot) dispatch(msg Message) {
if b.handlePrivateReload(msg) {
return
}
if msg.Command == "PRIVMSG" && msg.IsChannel && b.channelWarming(msg.Target) {
return
}
Expand All @@ -440,8 +477,10 @@ func (b *Bot) dispatch(msg Message) {
continue
}
consumed := false
b.pluginMu.RLock()
func() {
defer func() {
b.pluginMu.RUnlock()
if r := recover(); r != nil {
b.Log.Error("plugin panic", zap.String("plugin", p.Name()), zap.Any("panic", r))
}
Expand Down Expand Up @@ -469,8 +508,10 @@ func (b *Bot) dispatchEvent(msg Message) {
if !ok {
continue
}
b.pluginMu.RLock()
func() {
defer func() {
b.pluginMu.RUnlock()
if r := recover(); r != nil {
b.Log.Error("plugin event panic", zap.String("plugin", p.Name()), zap.Any("panic", r))
}
Expand All @@ -479,6 +520,23 @@ func (b *Bot) dispatchEvent(msg Message) {
}()
}
}

func (b *Bot) handlePrivateReload(msg Message) bool {
if msg.Command != "PRIVMSG" || msg.IsChannel || !b.IsOwner(msg) {
return false
}
text := strings.ToLower(strings.TrimSpace(msg.Text))
if text != "reload" && text != "!reload" {
return false
}
b.mu.RLock()
handler := b.reloadHandler
b.mu.RUnlock()
if handler != nil {
handler(msg)
}
return true
}
func (b *Bot) Run(ctx context.Context) error {
backoff := 5 * time.Second
for {
Expand Down
41 changes: 41 additions & 0 deletions bot/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,44 @@ func TestValidChannelName(t *testing.T) {
}
}
}

func TestIsOwnerRequiresAuthenticatedAccount(t *testing.T) {
b := &Bot{Config: Config{OwnerAccounts: []string{"Alice"}}}

if !b.IsOwner(Message{Account: "alice"}) {
t.Fatal("expected matching authenticated account to be an owner")
}
if b.IsOwner(Message{Nick: "alice"}) {
t.Fatal("nickname alone must not prove ownership")
}
if b.IsOwner(Message{Account: "*"}) {
t.Fatal("unidentified account must not prove ownership")
}
}

func TestPrivateReloadIsOwnerOnly(t *testing.T) {
called := 0
b := &Bot{
Config: Config{OwnerAccounts: []string{"alice"}},
reloadHandler: func(Message) { called++ },
}

if !b.handlePrivateReload(Message{Command: "PRIVMSG", Account: "alice", Text: "reload"}) {
t.Fatal("expected an authenticated owner's private reload to be handled")
}
if called != 1 {
t.Fatalf("expected reload handler once, got %d calls", called)
}
if b.handlePrivateReload(Message{Command: "PRIVMSG", Account: "guest", Text: "reload"}) {
t.Fatal("unauthenticated account must not trigger reload")
}
if b.handlePrivateReload(Message{Command: "PRIVMSG", Account: "alice", IsChannel: true, Text: "reload"}) {
t.Fatal("channel messages must not trigger private reload")
}
if b.handlePrivateReload(Message{Command: "PRIVMSG", Account: "alice", Text: "reload now"}) {
t.Fatal("reload must require an exact command")
}
if called != 1 {
t.Fatalf("unexpected reload handler calls: %d", called)
}
}
6 changes: 6 additions & 0 deletions bot/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ type Starter interface {
type EventHandler interface {
HandleEvent(*Bot, Message) bool
}

// Reloadable lets a long-running plugin apply configuration changes without
// rebuilding its state or dropping the IRC connection.
type Reloadable interface {
Reload(PluginConfig) error
}
113 changes: 78 additions & 35 deletions cmd/irc-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,10 @@ import (
)

func main() {
viper.SetConfigFile("config.yaml")
viper.AutomaticEnv()
viper.SetEnvPrefix("BOT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
_ = viper.ReadInConfig()
viper.BindEnv("identity.sasl_pass", "BOT_SASL_PASS")
viper.BindEnv("plugins.news.api_key", "BOT_NEWS_API_KEY")
viper.BindEnv("plugins.lastfm.api_key", "BOT_LASTFM_API_KEY")
viper.BindEnv("plugins.github.token", "BOT_GITHUB_TOKEN")
viper.BindEnv("plugins.urltitle.youtube_api_key", "BOT_YOUTUBE_API_KEY")
viper.BindEnv("plugins.ask.ai_rewrite", "BOT_ASK_AI_REWRITE")
viper.BindEnv("plugins.ask.provider", "BOT_ASK_PROVIDER")
viper.BindEnv("plugins.ask.openrouter_api_key", "BOT_OPENROUTER_API_KEY")
viper.BindEnv("plugins.ask.openrouter_model", "BOT_OPENROUTER_MODEL")
viper.BindEnv("plugins.ask.openai_api_key", "BOT_OPENAI_API_KEY")
viper.BindEnv("plugins.ask.openai_model", "BOT_OPENAI_MODEL")
viper.BindEnv("plugins.ask.gemini_api_key", "BOT_GEMINI_API_KEY")
viper.BindEnv("plugins.ask.gemini_model", "BOT_GEMINI_MODEL")
viper.BindEnv("plugins.ask.ollama_url", "BOT_OLLAMA_URL")
viper.BindEnv("plugins.ask.ollama_model", "BOT_OLLAMA_MODEL")
viper.BindEnv("storage.db_path", "BOT_STORAGE_DB_PATH")
viper.BindEnv("stats.listen_address", "BOT_STATS_LISTEN_ADDRESS")

var cfg bot.Config
if err := viper.Unmarshal(&cfg); err != nil {
cfg, err := loadConfig()
if err != nil {
panic(err)
}
if cfg.Storage.DBPath == "" {
cfg.Storage.DBPath = "bot.db"
}
if cfg.CommandPrefix == "" {
cfg.CommandPrefix = "!"
}
if cfg.Stats.ListenAddress == "" {
cfg.Stats.ListenAddress = "127.0.0.1"
}

networks := cfg.Networks
if len(networks) == 0 && cfg.Server.Host != "" {
Expand Down Expand Up @@ -133,13 +101,37 @@ func main() {
}
}
instances = append(instances, instance)
}

var reloadMu sync.Mutex
reload := func(current *bot.Bot, msg bot.Message) {
reloadMu.Lock()
defer reloadMu.Unlock()
updated, err := loadConfig()
if err != nil {
current.Send(msg.ReplyTarget(), "reload failed; configuration was not changed")
log.Warn("configuration reload failed", zap.Error(err))
return
}
count, err := current.ReloadPlugins(updated.Plugins)
if err != nil {
current.Send(msg.ReplyTarget(), "reload failed; configuration was not changed")
log.Warn("configuration reload failed", zap.Error(err))
return
}
current.Send(msg.ReplyTarget(), fmt.Sprintf("configuration reloaded for %d plugin(s); IRC connection unchanged", count))
log.Info("configuration reloaded", zap.Int("plugins", count), zap.String("network", current.Config.NetworkName))
}
for _, instance := range instances {
current := instance
current.SetReloadHandler(func(msg bot.Message) { reload(current, msg) })
wg.Add(1)
go func(networkName string, b *bot.Bot) {
defer wg.Done()
if err := b.Run(ctx); err != nil {
log.Error("bot stopped", zap.String("network", networkName), zap.Error(err))
}
}(network.Name, instance)
}(current.Config.NetworkName, current)
}
wg.Wait()
qctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand All @@ -149,6 +141,57 @@ func main() {
}
}

func loadConfig() (bot.Config, error) {
configureViper()
if err := viper.ReadInConfig(); err != nil {
return bot.Config{}, err
}
var cfg bot.Config
if err := viper.Unmarshal(&cfg); err != nil {
return bot.Config{}, err
}
if cfg.Storage.DBPath == "" {
cfg.Storage.DBPath = "bot.db"
}
if cfg.CommandPrefix == "" {
cfg.CommandPrefix = "!"
}
if cfg.Stats.ListenAddress == "" {
cfg.Stats.ListenAddress = "127.0.0.1"
}
return cfg, nil
}

func configureViper() {
viper.Reset()
viper.SetConfigFile("config.yaml")
viper.AutomaticEnv()
viper.SetEnvPrefix("BOT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
bind := func(key, env string) {
if err := viper.BindEnv(key, env); err != nil {
panic(err)
}
}
bind("identity.sasl_pass", "BOT_SASL_PASS")
bind("plugins.news.api_key", "BOT_NEWS_API_KEY")
bind("plugins.lastfm.api_key", "BOT_LASTFM_API_KEY")
bind("plugins.github.token", "BOT_GITHUB_TOKEN")
bind("plugins.urltitle.youtube_api_key", "BOT_YOUTUBE_API_KEY")
bind("plugins.ask.ai_rewrite", "BOT_ASK_AI_REWRITE")
bind("plugins.ask.provider", "BOT_ASK_PROVIDER")
bind("plugins.ask.openrouter_api_key", "BOT_OPENROUTER_API_KEY")
bind("plugins.ask.openrouter_model", "BOT_OPENROUTER_MODEL")
bind("plugins.ask.openai_api_key", "BOT_OPENAI_API_KEY")
bind("plugins.ask.openai_model", "BOT_OPENAI_MODEL")
bind("plugins.ask.gemini_api_key", "BOT_GEMINI_API_KEY")
bind("plugins.ask.gemini_model", "BOT_GEMINI_MODEL")
bind("plugins.ask.ollama_url", "BOT_OLLAMA_URL")
bind("plugins.ask.ollama_model", "BOT_OLLAMA_MODEL")
bind("storage.db_path", "BOT_STORAGE_DB_PATH")
bind("stats.listen_address", "BOT_STATS_LISTEN_ADDRESS")
}

func newLogger(format string) *zap.Logger {
cfg := zap.NewProductionConfig()
if format != "json" {
Expand Down
19 changes: 19 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ owner_accounts:
GoBot records the IRCv3 `account` tag when the network provides it. There is
intentionally no ownership-claim command.

### Owner-only private reload

An owner can reload file-backed plugin settings without disconnecting GoBot:

```text
/msg GoBot reload
/msg GoBot !reload
```

The sender must be identified to an IRC account listed in `owner_accounts`; a
nickname alone is not accepted. GoBot replies privately after the reload and
keeps the existing IRC connection open.

The reload currently applies settings for active plugins that support runtime
reloads, including `ask`. It does not change the server, nickname, channels,
`owner_accounts`, plugin enable/disable flags, or per-channel plugin
overrides. Those changes require a restart. Environment variables loaded from
`.env` are process settings as well, so changing `.env` requires a restart.

Anyone may invite the bot when invitations are enabled:

```yaml
Expand Down
Loading