ScuttleBot

fix: add oper SAMODE for reliable channel mode assignment Topology manager now OPERs up on connect and uses SAMODE alongside ChanServ AMODE. SAMODE works regardless of channel founder status, fixing the issue where bots got +v instead of +o because topology wasn't the channel founder. Added oper block to Ergo config template with server-admin class (samode + chanreg capabilities). Uses APIToken as oper password.

lmata 2026-04-05 21:48 trunk
Commit a408eeee93d1faed84379f277dc00c2a08990058d250ac0ebd5401a9b5470dfa
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -201,11 +201,11 @@
201201
if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
202202
log.Error("topology account setup failed", "err", err2)
203203
os.Exit(1)
204204
}
205205
}
206
- topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
206
+ topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, cfg.Ergo.APIToken, topoPolicy, log)
207207
topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
208208
if err := topoMgr.Connect(topoCtx); err != nil {
209209
topoCancel()
210210
log.Error("topology manager connect failed", "err", err)
211211
os.Exit(1)
212212
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -201,11 +201,11 @@
201 if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
202 log.Error("topology account setup failed", "err", err2)
203 os.Exit(1)
204 }
205 }
206 topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
207 topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
208 if err := topoMgr.Connect(topoCtx); err != nil {
209 topoCancel()
210 log.Error("topology manager connect failed", "err", err)
211 os.Exit(1)
212
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -201,11 +201,11 @@
201 if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
202 log.Error("topology account setup failed", "err", err2)
203 os.Exit(1)
204 }
205 }
206 topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, cfg.Ergo.APIToken, topoPolicy, log)
207 topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
208 if err := topoMgr.Connect(topoCtx); err != nil {
209 topoCancel()
210 log.Error("topology manager connect failed", "err", err)
211 os.Exit(1)
212
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -83,10 +83,23 @@
8383
unregistered-channels: false
8484
registered-channels: opt-in
8585
direct-messages: opt-in
8686
{{- end}}
8787
88
+opers:
89
+ scuttlebot:
90
+ class: server-admin
91
+ whois-line: is a scuttlebot system operator
92
+ password: "{{.APIToken}}"
93
+
94
+oper-classes:
95
+ server-admin:
96
+ title: Server Admin
97
+ capabilities:
98
+ - samode
99
+ - chanreg
100
+
88101
api:
89102
enabled: true
90103
listener: "{{.APIAddr}}"
91104
bearer-tokens:
92105
- "{{.APIToken}}"
93106
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -83,10 +83,23 @@
83 unregistered-channels: false
84 registered-channels: opt-in
85 direct-messages: opt-in
86 {{- end}}
87
 
 
 
 
 
 
 
 
 
 
 
 
 
88 api:
89 enabled: true
90 listener: "{{.APIAddr}}"
91 bearer-tokens:
92 - "{{.APIToken}}"
93
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -83,10 +83,23 @@
83 unregistered-channels: false
84 registered-channels: opt-in
85 direct-messages: opt-in
86 {{- end}}
87
88 opers:
89 scuttlebot:
90 class: server-admin
91 whois-line: is a scuttlebot system operator
92 password: "{{.APIToken}}"
93
94 oper-classes:
95 server-admin:
96 title: Server Admin
97 capabilities:
98 - samode
99 - chanreg
100
101 api:
102 enabled: true
103 listener: "{{.APIAddr}}"
104 bearer-tokens:
105 - "{{.APIToken}}"
106
--- internal/topology/reaper_test.go
+++ internal/topology/reaper_test.go
@@ -41,11 +41,11 @@
4141
Prefix: "sprint.",
4242
},
4343
},
4444
})
4545
log := slog.New(slog.NewTextHandler(io.Discard, nil))
46
- m := NewManager("localhost:6667", "topology", "pass", pol, log)
46
+ m := NewManager("localhost:6667", "topology", "pass", "", pol, log)
4747
4848
// Simulate that channels were provisioned at different times.
4949
m.mu.Lock()
5050
m.channels["#task.old"] = channelRecord{name: "#task.old", provisionedAt: time.Now().Add(-80 * time.Hour)}
5151
m.channels["#task.fresh"] = channelRecord{name: "#task.fresh", provisionedAt: time.Now().Add(-10 * time.Hour)}
5252
--- internal/topology/reaper_test.go
+++ internal/topology/reaper_test.go
@@ -41,11 +41,11 @@
41 Prefix: "sprint.",
42 },
43 },
44 })
45 log := slog.New(slog.NewTextHandler(io.Discard, nil))
46 m := NewManager("localhost:6667", "topology", "pass", pol, log)
47
48 // Simulate that channels were provisioned at different times.
49 m.mu.Lock()
50 m.channels["#task.old"] = channelRecord{name: "#task.old", provisionedAt: time.Now().Add(-80 * time.Hour)}
51 m.channels["#task.fresh"] = channelRecord{name: "#task.fresh", provisionedAt: time.Now().Add(-10 * time.Hour)}
52
--- internal/topology/reaper_test.go
+++ internal/topology/reaper_test.go
@@ -41,11 +41,11 @@
41 Prefix: "sprint.",
42 },
43 },
44 })
45 log := slog.New(slog.NewTextHandler(io.Discard, nil))
46 m := NewManager("localhost:6667", "topology", "pass", "", pol, log)
47
48 // Simulate that channels were provisioned at different times.
49 m.mu.Lock()
50 m.channels["#task.old"] = channelRecord{name: "#task.old", provisionedAt: time.Now().Add(-80 * time.Hour)}
51 m.channels["#task.fresh"] = channelRecord{name: "#task.fresh", provisionedAt: time.Now().Add(-10 * time.Hour)}
52
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -48,29 +48,31 @@
4848
provisionedAt time.Time
4949
}
5050
5151
// Manager provisions and maintains IRC channel topology.
5252
type Manager struct {
53
- ircAddr string
54
- nick string
55
- password string
56
- log *slog.Logger
57
- policy *Policy
58
- client *girc.Client
53
+ ircAddr string
54
+ nick string
55
+ password string
56
+ operPass string // oper password for SAMODE access
57
+ log *slog.Logger
58
+ policy *Policy
59
+ client *girc.Client
5960
6061
mu sync.Mutex
6162
channels map[string]channelRecord // channel name → record
6263
}
6364
6465
// NewManager creates a topology Manager. nick and password are the Ergo
6566
// credentials of the scuttlebot oper account used to manage channels.
6667
// policy may be nil if the caller only uses the manager for ad-hoc provisioning.
67
-func NewManager(ircAddr, nick, password string, policy *Policy, log *slog.Logger) *Manager {
68
+func NewManager(ircAddr, nick, password, operPass string, policy *Policy, log *slog.Logger) *Manager {
6869
return &Manager{
6970
ircAddr: ircAddr,
7071
nick: nick,
7172
password: password,
73
+ operPass: operPass,
7274
policy: policy,
7375
log: log,
7476
channels: make(map[string]channelRecord),
7577
}
7678
}
@@ -96,10 +98,14 @@
9698
SSL: false,
9799
})
98100
99101
connected := make(chan struct{})
100102
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
103
+ // OPER up for SAMODE access.
104
+ if m.operPass != "" {
105
+ client.Cmd.SendRawf("OPER scuttlebot %s", m.operPass)
106
+ }
101107
close(connected)
102108
})
103109
104110
go func() {
105111
if err := c.Connect(); err != nil {
@@ -213,16 +219,23 @@
213219
214220
if ch.Topic != "" {
215221
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
216222
}
217223
218
- // Use AMODE for persistent auto-mode on join (survives reconnects).
224
+ // Set persistent auto-modes. Use ChanServ AMODE when possible,
225
+ // and SAMODE (oper) as immediate fallback.
219226
for _, nick := range ch.Ops {
220227
m.chanserv("AMODE %s +o %s", ch.Name, nick)
228
+ if m.operPass != "" {
229
+ m.client.Cmd.SendRawf("SAMODE %s +o %s", ch.Name, nick)
230
+ }
221231
}
222232
for _, nick := range ch.Voice {
223233
m.chanserv("AMODE %s +v %s", ch.Name, nick)
234
+ if m.operPass != "" {
235
+ m.client.Cmd.SendRawf("SAMODE %s +v %s", ch.Name, nick)
236
+ }
224237
}
225238
226239
// Apply channel modes (e.g. +m for moderated).
227240
for _, mode := range ch.Modes {
228241
m.client.Cmd.Mode(ch.Name, mode)
@@ -296,12 +309,18 @@
296309
return
297310
}
298311
switch strings.ToUpper(level) {
299312
case "OP":
300313
m.chanserv("AMODE %s +o %s", channel, nick)
314
+ if m.operPass != "" && m.client != nil {
315
+ m.client.Cmd.SendRawf("SAMODE %s +o %s", channel, nick)
316
+ }
301317
case "VOICE":
302318
m.chanserv("AMODE %s +v %s", channel, nick)
319
+ if m.operPass != "" && m.client != nil {
320
+ m.client.Cmd.SendRawf("SAMODE %s +v %s", channel, nick)
321
+ }
303322
default:
304323
m.log.Warn("unknown access level", "level", level)
305324
return
306325
}
307326
m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
308327
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -48,29 +48,31 @@
48 provisionedAt time.Time
49 }
50
51 // Manager provisions and maintains IRC channel topology.
52 type Manager struct {
53 ircAddr string
54 nick string
55 password string
56 log *slog.Logger
57 policy *Policy
58 client *girc.Client
 
59
60 mu sync.Mutex
61 channels map[string]channelRecord // channel name → record
62 }
63
64 // NewManager creates a topology Manager. nick and password are the Ergo
65 // credentials of the scuttlebot oper account used to manage channels.
66 // policy may be nil if the caller only uses the manager for ad-hoc provisioning.
67 func NewManager(ircAddr, nick, password string, policy *Policy, log *slog.Logger) *Manager {
68 return &Manager{
69 ircAddr: ircAddr,
70 nick: nick,
71 password: password,
 
72 policy: policy,
73 log: log,
74 channels: make(map[string]channelRecord),
75 }
76 }
@@ -96,10 +98,14 @@
96 SSL: false,
97 })
98
99 connected := make(chan struct{})
100 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
 
 
 
 
101 close(connected)
102 })
103
104 go func() {
105 if err := c.Connect(); err != nil {
@@ -213,16 +219,23 @@
213
214 if ch.Topic != "" {
215 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
216 }
217
218 // Use AMODE for persistent auto-mode on join (survives reconnects).
 
219 for _, nick := range ch.Ops {
220 m.chanserv("AMODE %s +o %s", ch.Name, nick)
 
 
 
221 }
222 for _, nick := range ch.Voice {
223 m.chanserv("AMODE %s +v %s", ch.Name, nick)
 
 
 
224 }
225
226 // Apply channel modes (e.g. +m for moderated).
227 for _, mode := range ch.Modes {
228 m.client.Cmd.Mode(ch.Name, mode)
@@ -296,12 +309,18 @@
296 return
297 }
298 switch strings.ToUpper(level) {
299 case "OP":
300 m.chanserv("AMODE %s +o %s", channel, nick)
 
 
 
301 case "VOICE":
302 m.chanserv("AMODE %s +v %s", channel, nick)
 
 
 
303 default:
304 m.log.Warn("unknown access level", "level", level)
305 return
306 }
307 m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
308
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -48,29 +48,31 @@
48 provisionedAt time.Time
49 }
50
51 // Manager provisions and maintains IRC channel topology.
52 type Manager struct {
53 ircAddr string
54 nick string
55 password string
56 operPass string // oper password for SAMODE access
57 log *slog.Logger
58 policy *Policy
59 client *girc.Client
60
61 mu sync.Mutex
62 channels map[string]channelRecord // channel name → record
63 }
64
65 // NewManager creates a topology Manager. nick and password are the Ergo
66 // credentials of the scuttlebot oper account used to manage channels.
67 // policy may be nil if the caller only uses the manager for ad-hoc provisioning.
68 func NewManager(ircAddr, nick, password, operPass string, policy *Policy, log *slog.Logger) *Manager {
69 return &Manager{
70 ircAddr: ircAddr,
71 nick: nick,
72 password: password,
73 operPass: operPass,
74 policy: policy,
75 log: log,
76 channels: make(map[string]channelRecord),
77 }
78 }
@@ -96,10 +98,14 @@
98 SSL: false,
99 })
100
101 connected := make(chan struct{})
102 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
103 // OPER up for SAMODE access.
104 if m.operPass != "" {
105 client.Cmd.SendRawf("OPER scuttlebot %s", m.operPass)
106 }
107 close(connected)
108 })
109
110 go func() {
111 if err := c.Connect(); err != nil {
@@ -213,16 +219,23 @@
219
220 if ch.Topic != "" {
221 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
222 }
223
224 // Set persistent auto-modes. Use ChanServ AMODE when possible,
225 // and SAMODE (oper) as immediate fallback.
226 for _, nick := range ch.Ops {
227 m.chanserv("AMODE %s +o %s", ch.Name, nick)
228 if m.operPass != "" {
229 m.client.Cmd.SendRawf("SAMODE %s +o %s", ch.Name, nick)
230 }
231 }
232 for _, nick := range ch.Voice {
233 m.chanserv("AMODE %s +v %s", ch.Name, nick)
234 if m.operPass != "" {
235 m.client.Cmd.SendRawf("SAMODE %s +v %s", ch.Name, nick)
236 }
237 }
238
239 // Apply channel modes (e.g. +m for moderated).
240 for _, mode := range ch.Modes {
241 m.client.Cmd.Mode(ch.Name, mode)
@@ -296,12 +309,18 @@
309 return
310 }
311 switch strings.ToUpper(level) {
312 case "OP":
313 m.chanserv("AMODE %s +o %s", channel, nick)
314 if m.operPass != "" && m.client != nil {
315 m.client.Cmd.SendRawf("SAMODE %s +o %s", channel, nick)
316 }
317 case "VOICE":
318 m.chanserv("AMODE %s +v %s", channel, nick)
319 if m.operPass != "" && m.client != nil {
320 m.client.Cmd.SendRawf("SAMODE %s +v %s", channel, nick)
321 }
322 default:
323 m.log.Warn("unknown access level", "level", level)
324 return
325 }
326 m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
327

Keyboard Shortcuts

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