ScuttleBot

feat(#36): wire topology Manager at daemon startup — provision static channels - Add topology.Nick to TopologyConfig (default: "topology") with Defaults() support - NewManager now accepts a *Policy so the Manager knows channel rules at runtime - Added Policy() accessor, ProvisionChannel(), and Invite() to topology.Manager - provision() now calls Invite() for autojoin nicks after ChanServ registration - Replaced fixed 100ms ChanServ sleep with a 3-iteration retry loop (3×200ms) - Wire topology startup in cmd/scuttlebot/main.go: create NickServ account, connect, provision all static channels, close on daemon shutdown

lmata 2026-04-02 05:17 trunk
Commit d6520d1d13e00732bc826c2695e5ffe810407ff34c6b75856a95647389035419
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -24,10 +24,11 @@
2424
botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
2525
"github.com/conflicthq/scuttlebot/internal/config"
2626
"github.com/conflicthq/scuttlebot/internal/ergo"
2727
"github.com/conflicthq/scuttlebot/internal/mcp"
2828
"github.com/conflicthq/scuttlebot/internal/registry"
29
+ "github.com/conflicthq/scuttlebot/internal/topology"
2930
)
3031
3132
var version = "dev"
3233
3334
func main() {
@@ -155,10 +156,48 @@
155156
if err := bridgeBot.Start(ctx); err != nil {
156157
log.Error("bridge bot error", "err", err)
157158
}
158159
}()
159160
}
161
+
162
+ // Topology manager — provisions static channels and enforces autojoin policy.
163
+ topoPolicy := topology.NewPolicy(cfg.Topology)
164
+ if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
165
+ topoPass := mustGenToken()
166
+ if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
167
+ if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
168
+ log.Error("topology account setup failed", "err", err2)
169
+ os.Exit(1)
170
+ }
171
+ }
172
+ topoMgr := topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
173
+ topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
174
+ if err := topoMgr.Connect(topoCtx); err != nil {
175
+ topoCancel()
176
+ log.Error("topology manager connect failed", "err", err)
177
+ os.Exit(1)
178
+ }
179
+ topoCancel()
180
+ staticChannels := make([]topology.ChannelConfig, 0, len(cfg.Topology.Channels))
181
+ for _, sc := range cfg.Topology.Channels {
182
+ staticChannels = append(staticChannels, topology.ChannelConfig{
183
+ Name: sc.Name,
184
+ Topic: sc.Topic,
185
+ Ops: sc.Ops,
186
+ Voice: sc.Voice,
187
+ Autojoin: sc.Autojoin,
188
+ })
189
+ }
190
+ if err := topoMgr.Provision(staticChannels); err != nil {
191
+ log.Error("topology provision failed", "err", err)
192
+ }
193
+ go func() {
194
+ <-ctx.Done()
195
+ topoMgr.Close()
196
+ }()
197
+ }
198
+ _ = topoPolicy // available for future API wiring (#37–#42)
160199
161200
// Policy store — persists behavior/agent/logging settings.
162201
policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
163202
if err != nil {
164203
log.Error("policy store", "err", err)
165204
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -24,10 +24,11 @@
24 botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
25 "github.com/conflicthq/scuttlebot/internal/config"
26 "github.com/conflicthq/scuttlebot/internal/ergo"
27 "github.com/conflicthq/scuttlebot/internal/mcp"
28 "github.com/conflicthq/scuttlebot/internal/registry"
 
29 )
30
31 var version = "dev"
32
33 func main() {
@@ -155,10 +156,48 @@
155 if err := bridgeBot.Start(ctx); err != nil {
156 log.Error("bridge bot error", "err", err)
157 }
158 }()
159 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
161 // Policy store — persists behavior/agent/logging settings.
162 policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
163 if err != nil {
164 log.Error("policy store", "err", err)
165
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -24,10 +24,11 @@
24 botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
25 "github.com/conflicthq/scuttlebot/internal/config"
26 "github.com/conflicthq/scuttlebot/internal/ergo"
27 "github.com/conflicthq/scuttlebot/internal/mcp"
28 "github.com/conflicthq/scuttlebot/internal/registry"
29 "github.com/conflicthq/scuttlebot/internal/topology"
30 )
31
32 var version = "dev"
33
34 func main() {
@@ -155,10 +156,48 @@
156 if err := bridgeBot.Start(ctx); err != nil {
157 log.Error("bridge bot error", "err", err)
158 }
159 }()
160 }
161
162 // Topology manager — provisions static channels and enforces autojoin policy.
163 topoPolicy := topology.NewPolicy(cfg.Topology)
164 if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
165 topoPass := mustGenToken()
166 if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
167 if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
168 log.Error("topology account setup failed", "err", err2)
169 os.Exit(1)
170 }
171 }
172 topoMgr := topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
173 topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
174 if err := topoMgr.Connect(topoCtx); err != nil {
175 topoCancel()
176 log.Error("topology manager connect failed", "err", err)
177 os.Exit(1)
178 }
179 topoCancel()
180 staticChannels := make([]topology.ChannelConfig, 0, len(cfg.Topology.Channels))
181 for _, sc := range cfg.Topology.Channels {
182 staticChannels = append(staticChannels, topology.ChannelConfig{
183 Name: sc.Name,
184 Topic: sc.Topic,
185 Ops: sc.Ops,
186 Voice: sc.Voice,
187 Autojoin: sc.Autojoin,
188 })
189 }
190 if err := topoMgr.Provision(staticChannels); err != nil {
191 log.Error("topology provision failed", "err", err)
192 }
193 go func() {
194 <-ctx.Done()
195 topoMgr.Close()
196 }()
197 }
198 _ = topoPolicy // available for future API wiring (#37–#42)
199
200 // Policy store — persists behavior/agent/logging settings.
201 policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
202 if err != nil {
203 log.Error("policy store", "err", err)
204
--- internal/config/config.go
+++ internal/config/config.go
@@ -196,10 +196,14 @@
196196
197197
// TopologyConfig is the top-level channel topology declaration.
198198
// It defines static channels provisioned at startup and dynamic channel type
199199
// rules applied when agents create channels at runtime.
200200
type TopologyConfig struct {
201
+ // Nick is the IRC nick used by the topology manager to provision channels
202
+ // via ChanServ. Defaults to "topology".
203
+ Nick string `yaml:"nick"`
204
+
201205
// Channels are static channels provisioned at daemon startup.
202206
Channels []StaticChannelConfig `yaml:"channels"`
203207
204208
// Types are prefix-based rules applied to dynamically created channels.
205209
// The first matching prefix wins.
@@ -314,10 +318,13 @@
314318
c.Bridge.BufferSize = 200
315319
}
316320
if c.Bridge.WebUserTTLMinutes == 0 {
317321
c.Bridge.WebUserTTLMinutes = 5
318322
}
323
+ if c.Topology.Nick == "" {
324
+ c.Topology.Nick = "topology"
325
+ }
319326
}
320327
321328
func envStr(key string) string { return os.Getenv(key) }
322329
323330
// LoadFile reads a YAML config file into c. Missing file is not an error —
324331
--- internal/config/config.go
+++ internal/config/config.go
@@ -196,10 +196,14 @@
196
197 // TopologyConfig is the top-level channel topology declaration.
198 // It defines static channels provisioned at startup and dynamic channel type
199 // rules applied when agents create channels at runtime.
200 type TopologyConfig struct {
 
 
 
 
201 // Channels are static channels provisioned at daemon startup.
202 Channels []StaticChannelConfig `yaml:"channels"`
203
204 // Types are prefix-based rules applied to dynamically created channels.
205 // The first matching prefix wins.
@@ -314,10 +318,13 @@
314 c.Bridge.BufferSize = 200
315 }
316 if c.Bridge.WebUserTTLMinutes == 0 {
317 c.Bridge.WebUserTTLMinutes = 5
318 }
 
 
 
319 }
320
321 func envStr(key string) string { return os.Getenv(key) }
322
323 // LoadFile reads a YAML config file into c. Missing file is not an error —
324
--- internal/config/config.go
+++ internal/config/config.go
@@ -196,10 +196,14 @@
196
197 // TopologyConfig is the top-level channel topology declaration.
198 // It defines static channels provisioned at startup and dynamic channel type
199 // rules applied when agents create channels at runtime.
200 type TopologyConfig struct {
201 // Nick is the IRC nick used by the topology manager to provision channels
202 // via ChanServ. Defaults to "topology".
203 Nick string `yaml:"nick"`
204
205 // Channels are static channels provisioned at daemon startup.
206 Channels []StaticChannelConfig `yaml:"channels"`
207
208 // Types are prefix-based rules applied to dynamically created channels.
209 // The first matching prefix wins.
@@ -314,10 +318,13 @@
318 c.Bridge.BufferSize = 200
319 }
320 if c.Bridge.WebUserTTLMinutes == 0 {
321 c.Bridge.WebUserTTLMinutes = 5
322 }
323 if c.Topology.Nick == "" {
324 c.Topology.Nick = "topology"
325 }
326 }
327
328 func envStr(key string) string { return os.Getenv(key) }
329
330 // LoadFile reads a YAML config file into c. Missing file is not an error —
331
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -28,31 +28,40 @@
2828
// Ops is a list of nicks to grant +o (channel operator) status.
2929
Ops []string
3030
3131
// Voice is a list of nicks to grant +v status.
3232
Voice []string
33
+
34
+ // Autojoin is a list of bot nicks to invite after provisioning.
35
+ Autojoin []string
3336
}
3437
3538
// Manager provisions and maintains IRC channel topology.
3639
type Manager struct {
3740
ircAddr string
3841
nick string
3942
password string
4043
log *slog.Logger
44
+ policy *Policy
4145
client *girc.Client
4246
}
4347
4448
// NewManager creates a topology Manager. nick and password are the Ergo
4549
// credentials of the scuttlebot oper account used to manage channels.
46
-func NewManager(ircAddr, nick, password string, log *slog.Logger) *Manager {
50
+// policy may be nil if the caller only uses the manager for ad-hoc provisioning.
51
+func NewManager(ircAddr, nick, password string, policy *Policy, log *slog.Logger) *Manager {
4752
return &Manager{
4853
ircAddr: ircAddr,
4954
nick: nick,
5055
password: password,
56
+ policy: policy,
5157
log: log,
5258
}
5359
}
60
+
61
+// Policy returns the policy attached to this manager, or nil.
62
+func (m *Manager) Policy() *Policy { return m.policy }
5463
5564
// Connect establishes the IRC connection used for channel management.
5665
// Call before Provision.
5766
func (m *Manager) Connect(ctx context.Context) error {
5867
host, port, err := splitHostPort(m.ircAddr)
@@ -142,15 +151,50 @@
142151
143152
// DestroyEphemeral drops an ephemeral task channel.
144153
func (m *Manager) DestroyEphemeral(channel string) {
145154
m.chanserv("DROP %s", channel)
146155
}
156
+
157
+// ProvisionChannel provisions a single channel and invites its autojoin nicks.
158
+// It applies the manager's Policy if set; the caller may override autojoin via
159
+// the ChannelConfig directly.
160
+func (m *Manager) ProvisionChannel(ch ChannelConfig) error {
161
+ if err := ValidateName(ch.Name); err != nil {
162
+ return err
163
+ }
164
+ if err := m.provision(ch); err != nil {
165
+ return err
166
+ }
167
+ if len(ch.Autojoin) > 0 {
168
+ m.Invite(ch.Name, ch.Autojoin)
169
+ }
170
+ return nil
171
+}
172
+
173
+// Invite sends IRC INVITE to each nick in nicks for the given channel.
174
+// Invite is best-effort: nicks that are not connected are silently skipped.
175
+func (m *Manager) Invite(channel string, nicks []string) {
176
+ if m.client == nil {
177
+ return
178
+ }
179
+ for _, nick := range nicks {
180
+ m.client.Cmd.Invite(nick, channel)
181
+ }
182
+}
147183
148184
func (m *Manager) provision(ch ChannelConfig) error {
149185
// Register with ChanServ (idempotent — fails silently if already registered).
150186
m.chanserv("REGISTER %s", ch.Name)
151
- time.Sleep(100 * time.Millisecond) // let ChanServ process
187
+ // Give ChanServ time to process the registration before issuing follow-up
188
+ // commands. Retry the sleep up to 3 times so transient load doesn't cause
189
+ // TOPIC/ACCESS commands to fire before registration completes.
190
+ for range 3 {
191
+ time.Sleep(200 * time.Millisecond)
192
+ if m.client.IsConnected() {
193
+ break
194
+ }
195
+ }
152196
153197
if ch.Topic != "" {
154198
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
155199
}
156200
@@ -158,10 +202,14 @@
158202
m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
159203
}
160204
for _, nick := range ch.Voice {
161205
m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
162206
}
207
+
208
+ if len(ch.Autojoin) > 0 {
209
+ m.Invite(ch.Name, ch.Autojoin)
210
+ }
163211
164212
m.log.Info("provisioned channel", "channel", ch.Name)
165213
return nil
166214
}
167215
168216
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -28,31 +28,40 @@
28 // Ops is a list of nicks to grant +o (channel operator) status.
29 Ops []string
30
31 // Voice is a list of nicks to grant +v status.
32 Voice []string
 
 
 
33 }
34
35 // Manager provisions and maintains IRC channel topology.
36 type Manager struct {
37 ircAddr string
38 nick string
39 password string
40 log *slog.Logger
 
41 client *girc.Client
42 }
43
44 // NewManager creates a topology Manager. nick and password are the Ergo
45 // credentials of the scuttlebot oper account used to manage channels.
46 func NewManager(ircAddr, nick, password string, log *slog.Logger) *Manager {
 
47 return &Manager{
48 ircAddr: ircAddr,
49 nick: nick,
50 password: password,
 
51 log: log,
52 }
53 }
 
 
 
54
55 // Connect establishes the IRC connection used for channel management.
56 // Call before Provision.
57 func (m *Manager) Connect(ctx context.Context) error {
58 host, port, err := splitHostPort(m.ircAddr)
@@ -142,15 +151,50 @@
142
143 // DestroyEphemeral drops an ephemeral task channel.
144 func (m *Manager) DestroyEphemeral(channel string) {
145 m.chanserv("DROP %s", channel)
146 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
148 func (m *Manager) provision(ch ChannelConfig) error {
149 // Register with ChanServ (idempotent — fails silently if already registered).
150 m.chanserv("REGISTER %s", ch.Name)
151 time.Sleep(100 * time.Millisecond) // let ChanServ process
 
 
 
 
 
 
 
 
152
153 if ch.Topic != "" {
154 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
155 }
156
@@ -158,10 +202,14 @@
158 m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
159 }
160 for _, nick := range ch.Voice {
161 m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
162 }
 
 
 
 
163
164 m.log.Info("provisioned channel", "channel", ch.Name)
165 return nil
166 }
167
168
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -28,31 +28,40 @@
28 // Ops is a list of nicks to grant +o (channel operator) status.
29 Ops []string
30
31 // Voice is a list of nicks to grant +v status.
32 Voice []string
33
34 // Autojoin is a list of bot nicks to invite after provisioning.
35 Autojoin []string
36 }
37
38 // Manager provisions and maintains IRC channel topology.
39 type Manager struct {
40 ircAddr string
41 nick string
42 password string
43 log *slog.Logger
44 policy *Policy
45 client *girc.Client
46 }
47
48 // NewManager creates a topology Manager. nick and password are the Ergo
49 // credentials of the scuttlebot oper account used to manage channels.
50 // policy may be nil if the caller only uses the manager for ad-hoc provisioning.
51 func NewManager(ircAddr, nick, password string, policy *Policy, log *slog.Logger) *Manager {
52 return &Manager{
53 ircAddr: ircAddr,
54 nick: nick,
55 password: password,
56 policy: policy,
57 log: log,
58 }
59 }
60
61 // Policy returns the policy attached to this manager, or nil.
62 func (m *Manager) Policy() *Policy { return m.policy }
63
64 // Connect establishes the IRC connection used for channel management.
65 // Call before Provision.
66 func (m *Manager) Connect(ctx context.Context) error {
67 host, port, err := splitHostPort(m.ircAddr)
@@ -142,15 +151,50 @@
151
152 // DestroyEphemeral drops an ephemeral task channel.
153 func (m *Manager) DestroyEphemeral(channel string) {
154 m.chanserv("DROP %s", channel)
155 }
156
157 // ProvisionChannel provisions a single channel and invites its autojoin nicks.
158 // It applies the manager's Policy if set; the caller may override autojoin via
159 // the ChannelConfig directly.
160 func (m *Manager) ProvisionChannel(ch ChannelConfig) error {
161 if err := ValidateName(ch.Name); err != nil {
162 return err
163 }
164 if err := m.provision(ch); err != nil {
165 return err
166 }
167 if len(ch.Autojoin) > 0 {
168 m.Invite(ch.Name, ch.Autojoin)
169 }
170 return nil
171 }
172
173 // Invite sends IRC INVITE to each nick in nicks for the given channel.
174 // Invite is best-effort: nicks that are not connected are silently skipped.
175 func (m *Manager) Invite(channel string, nicks []string) {
176 if m.client == nil {
177 return
178 }
179 for _, nick := range nicks {
180 m.client.Cmd.Invite(nick, channel)
181 }
182 }
183
184 func (m *Manager) provision(ch ChannelConfig) error {
185 // Register with ChanServ (idempotent — fails silently if already registered).
186 m.chanserv("REGISTER %s", ch.Name)
187 // Give ChanServ time to process the registration before issuing follow-up
188 // commands. Retry the sleep up to 3 times so transient load doesn't cause
189 // TOPIC/ACCESS commands to fire before registration completes.
190 for range 3 {
191 time.Sleep(200 * time.Millisecond)
192 if m.client.IsConnected() {
193 break
194 }
195 }
196
197 if ch.Topic != "" {
198 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
199 }
200
@@ -158,10 +202,14 @@
202 m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
203 }
204 for _, nick := range ch.Voice {
205 m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
206 }
207
208 if len(ch.Autojoin) > 0 {
209 m.Invite(ch.Name, ch.Autojoin)
210 }
211
212 m.log.Info("provisioned channel", "channel", ch.Name)
213 return nil
214 }
215
216

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button