ScuttleBot

feat: channel topology provisioning via ChanServ (internal/topology)

lmata 2026-03-31 04:57 trunk
Commit b895d50d0f391a2bb99eb71c9fb2e4b391628eaa46699e44a500529a64be82ba
M go.mod
+2
--- go.mod
+++ go.mod
@@ -1,5 +1,7 @@
11
module github.com/conflicthq/scuttlebot
22
33
go 1.26.1
44
55
require github.com/oklog/ulid/v2 v2.1.1
6
+
7
+require github.com/lrstanley/girc v1.1.1
68
--- go.mod
+++ go.mod
@@ -1,5 +1,7 @@
1 module github.com/conflicthq/scuttlebot
2
3 go 1.26.1
4
5 require github.com/oklog/ulid/v2 v2.1.1
 
 
6
--- go.mod
+++ go.mod
@@ -1,5 +1,7 @@
1 module github.com/conflicthq/scuttlebot
2
3 go 1.26.1
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 require github.com/lrstanley/girc v1.1.1
8
M go.sum
+2
--- go.sum
+++ go.sum
@@ -1,3 +1,5 @@
1
+github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
2
+github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
13
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
24
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
35
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
46
--- go.sum
+++ go.sum
@@ -1,3 +1,5 @@
 
 
1 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
2 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
3 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
4
--- go.sum
+++ go.sum
@@ -1,3 +1,5 @@
1 github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
2 github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
3 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
4 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
5 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
6
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -1,1 +1,197 @@
1
+// Package topology manages IRC channel provisioning.
2
+//
3
+// The Manager connects to Ergo as a privileged oper account and provisions
4
+// channels via ChanServ: registration, topics, and access lists (ops/voice).
5
+// Users define topology in scuttlebot config; this package creates and
6
+// maintains it in Ergo.
17
package topology
8
+
9
+import (
10
+ "context"
11
+ "fmt"
12
+ "log/slog"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/lrstanley/girc"
17
+)
18
+
19
+// ChannelConfig describes a channel to provision.
20
+type ChannelConfig struct {
21
+ // Name is the full channel name including the # prefix.
22
+ // Convention: #fleet, #project.{name}, #project.{name}.{topic}
23
+ Name string
24
+
25
+ // Topic is the initial channel topic (shared state header).
26
+ Topic string
27
+
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)
59
+ if err != nil {
60
+ return fmt.Errorf("topology: parse irc addr: %w", err)
61
+ }
62
+
63
+ c := girc.New(girc.Config{
64
+ Server: host,
65
+ Port: port,
66
+ Nick: m.nick,
67
+ User: "scuttlebot",
68
+ Name: "scuttlebot topology manager",
69
+ SASL: &girc.SASLPlain{User: m.nick, Pass: m.password},
70
+ SSL: false,
71
+ })
72
+
73
+ connected := make(chan struct{})
74
+ c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
75
+ close(connected)
76
+ })
77
+
78
+ go func() {
79
+ if err := c.Connect(); err != nil {
80
+ m.log.Error("topology irc connection error", "err", err)
81
+ }
82
+ }()
83
+
84
+ select {
85
+ case <-connected:
86
+ m.client = c
87
+ return nil
88
+ case <-ctx.Done():
89
+ c.Close()
90
+ return ctx.Err()
91
+ case <-time.After(30 * time.Second):
92
+ c.Close()
93
+ return fmt.Errorf("topology: timed out connecting to IRC")
94
+ }
95
+}
96
+
97
+// Close disconnects from IRC.
98
+func (m *Manager) Close() {
99
+ if m.client != nil {
100
+ m.client.Close()
101
+ }
102
+}
103
+
104
+// Provision creates and configures a set of channels. It is idempotent —
105
+// calling it multiple times with the same config is safe.
106
+func (m *Manager) Provision(channels []ChannelConfig) error {
107
+ if m.client == nil {
108
+ return fmt.Errorf("topology: not connected — call Connect first")
109
+ }
110
+ for _, ch := range channels {
111
+ if err := ValidateName(ch.Name); err != nil {
112
+ return err
113
+ }
114
+ if err := m.provision(ch); err != nil {
115
+ return err
116
+ }
117
+ }
118
+ return nil
119
+}
120
+
121
+// SetTopic updates the topic on an existing channel.
122
+func (m *Manager) SetTopic(channel, topic string) error {
123
+ if m.client == nil {
124
+ return fmt.Errorf("topology: not connected")
125
+ }
126
+ m.chanserv("TOPIC %s %s", channel, topic)
127
+ return nil
128
+}
129
+
130
+// ProvisionEphemeral creates a short-lived task channel.
131
+// Convention: #task.{id}
132
+func (m *Manager) ProvisionEphemeral(id string) (string, error) {
133
+ name := "#task." + id
134
+ if err := ValidateName(name); err != nil {
135
+ return "", err
136
+ }
137
+ if err := m.provision(ChannelConfig{Name: name}); err != nil {
138
+ return "", err
139
+ }
140
+ return name, nil
141
+}
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
+
157
+ for _, nick := range ch.Ops {
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
+func (m *Manager) chanserv(format string, args ...any) {
169
+ msg := fmt.Sprintf(format, args...)
170
+ m.client.Cmd.Message("ChanServ", msg)
171
+}
172
+
173
+// ValidateName checks that a channel name follows scuttlebot conventions.
174
+func ValidateName(name string) error {
175
+ if !strings.HasPrefix(name, "#") {
176
+ return fmt.Errorf("topology: channel name must start with #: %q", name)
177
+ }
178
+ if len(name) < 2 {
179
+ return fmt.Errorf("topology: channel name too short: %q", name)
180
+ }
181
+ if strings.Contains(name, " ") {
182
+ return fmt.Errorf("topology: channel name must not contain spaces: %q", name)
183
+ }
184
+ return nil
185
+}
186
+
187
+func splitHostPort(addr string) (string, int, error) {
188
+ parts := strings.SplitN(addr, ":", 2)
189
+ if len(parts) != 2 {
190
+ return "", 0, fmt.Errorf("invalid address %q (expected host:port)", addr)
191
+ }
192
+ var port int
193
+ if _, err := fmt.Sscan(parts[1], &port); err != nil {
194
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
195
+ }
196
+ return parts[0], port, nil
197
+}
2198
3199
ADDED internal/topology/topology_test.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -1,1 +1,197 @@
 
 
 
 
 
 
1 package topology
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED internal/topology/topology_test.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -1,1 +1,197 @@
1 // Package topology manages IRC channel provisioning.
2 //
3 // The Manager connects to Ergo as a privileged oper account and provisions
4 // channels via ChanServ: registration, topics, and access lists (ops/voice).
5 // Users define topology in scuttlebot config; this package creates and
6 // maintains it in Ergo.
7 package topology
8
9 import (
10 "context"
11 "fmt"
12 "log/slog"
13 "strings"
14 "time"
15
16 "github.com/lrstanley/girc"
17 )
18
19 // ChannelConfig describes a channel to provision.
20 type ChannelConfig struct {
21 // Name is the full channel name including the # prefix.
22 // Convention: #fleet, #project.{name}, #project.{name}.{topic}
23 Name string
24
25 // Topic is the initial channel topic (shared state header).
26 Topic string
27
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)
59 if err != nil {
60 return fmt.Errorf("topology: parse irc addr: %w", err)
61 }
62
63 c := girc.New(girc.Config{
64 Server: host,
65 Port: port,
66 Nick: m.nick,
67 User: "scuttlebot",
68 Name: "scuttlebot topology manager",
69 SASL: &girc.SASLPlain{User: m.nick, Pass: m.password},
70 SSL: false,
71 })
72
73 connected := make(chan struct{})
74 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
75 close(connected)
76 })
77
78 go func() {
79 if err := c.Connect(); err != nil {
80 m.log.Error("topology irc connection error", "err", err)
81 }
82 }()
83
84 select {
85 case <-connected:
86 m.client = c
87 return nil
88 case <-ctx.Done():
89 c.Close()
90 return ctx.Err()
91 case <-time.After(30 * time.Second):
92 c.Close()
93 return fmt.Errorf("topology: timed out connecting to IRC")
94 }
95 }
96
97 // Close disconnects from IRC.
98 func (m *Manager) Close() {
99 if m.client != nil {
100 m.client.Close()
101 }
102 }
103
104 // Provision creates and configures a set of channels. It is idempotent —
105 // calling it multiple times with the same config is safe.
106 func (m *Manager) Provision(channels []ChannelConfig) error {
107 if m.client == nil {
108 return fmt.Errorf("topology: not connected — call Connect first")
109 }
110 for _, ch := range channels {
111 if err := ValidateName(ch.Name); err != nil {
112 return err
113 }
114 if err := m.provision(ch); err != nil {
115 return err
116 }
117 }
118 return nil
119 }
120
121 // SetTopic updates the topic on an existing channel.
122 func (m *Manager) SetTopic(channel, topic string) error {
123 if m.client == nil {
124 return fmt.Errorf("topology: not connected")
125 }
126 m.chanserv("TOPIC %s %s", channel, topic)
127 return nil
128 }
129
130 // ProvisionEphemeral creates a short-lived task channel.
131 // Convention: #task.{id}
132 func (m *Manager) ProvisionEphemeral(id string) (string, error) {
133 name := "#task." + id
134 if err := ValidateName(name); err != nil {
135 return "", err
136 }
137 if err := m.provision(ChannelConfig{Name: name}); err != nil {
138 return "", err
139 }
140 return name, nil
141 }
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
157 for _, nick := range ch.Ops {
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 func (m *Manager) chanserv(format string, args ...any) {
169 msg := fmt.Sprintf(format, args...)
170 m.client.Cmd.Message("ChanServ", msg)
171 }
172
173 // ValidateName checks that a channel name follows scuttlebot conventions.
174 func ValidateName(name string) error {
175 if !strings.HasPrefix(name, "#") {
176 return fmt.Errorf("topology: channel name must start with #: %q", name)
177 }
178 if len(name) < 2 {
179 return fmt.Errorf("topology: channel name too short: %q", name)
180 }
181 if strings.Contains(name, " ") {
182 return fmt.Errorf("topology: channel name must not contain spaces: %q", name)
183 }
184 return nil
185 }
186
187 func splitHostPort(addr string) (string, int, error) {
188 parts := strings.SplitN(addr, ":", 2)
189 if len(parts) != 2 {
190 return "", 0, fmt.Errorf("invalid address %q (expected host:port)", addr)
191 }
192 var port int
193 if _, err := fmt.Sscan(parts[1], &port); err != nil {
194 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
195 }
196 return parts[0], port, nil
197 }
198
199 DDED internal/topology/topology_test.go
--- a/internal/topology/topology_test.go
+++ b/internal/topology/topology_test.go
@@ -0,0 +1,35 @@
1
+package topology_test
2
+
3
+import (
4
+ "testing"
5
+
6
+ "github.com/conflicthq/scuttlebot/internal/topology"
7
+)
8
+
9
+func TestValidateName(t *testing.T) {
10
+ cases := []struct {
11
+ name string
12
+ channel string
13
+ wantErr bool
14
+ }{
15
+ {"valid fleet", "#fleet", false},
16
+ {"valid project", "#project.myapp", false},
17
+ {"valid subtopic", "#project.myapp.tasks.backend", false},
18
+ {"valid task", "#task.01HX123", false},
19
+ {"missing prefix", "fleet", true},
20
+ {"empty after prefix", "#", true},
21
+ {"contains space", "#my channel", true},
22
+ }
23
+
24
+ for _, tc := range cases {
25
+ t.Run(tc.name, func(t *testing.T) {
26
+ err := topology.ValidateName(tc.channel)
27
+ if tc.wantErr && err == nil {
28
+ t.Errorf("ValidateName(%q): expected error, got nil", tc.channel)
29
+ }
30
+ if !tc.wantErr && err != nil {
31
+ t.Errorf("ValidateName(%q): unexpected error: %v", tc.channel, err)
32
+ }
33
+ })
34
+ }
35
+}
--- a/internal/topology/topology_test.go
+++ b/internal/topology/topology_test.go
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/topology/topology_test.go
+++ b/internal/topology/topology_test.go
@@ -0,0 +1,35 @@
1 package topology_test
2
3 import (
4 "testing"
5
6 "github.com/conflicthq/scuttlebot/internal/topology"
7 )
8
9 func TestValidateName(t *testing.T) {
10 cases := []struct {
11 name string
12 channel string
13 wantErr bool
14 }{
15 {"valid fleet", "#fleet", false},
16 {"valid project", "#project.myapp", false},
17 {"valid subtopic", "#project.myapp.tasks.backend", false},
18 {"valid task", "#task.01HX123", false},
19 {"missing prefix", "fleet", true},
20 {"empty after prefix", "#", true},
21 {"contains space", "#my channel", true},
22 }
23
24 for _, tc := range cases {
25 t.Run(tc.name, func(t *testing.T) {
26 err := topology.ValidateName(tc.channel)
27 if tc.wantErr && err == nil {
28 t.Errorf("ValidateName(%q): expected error, got nil", tc.channel)
29 }
30 if !tc.wantErr && err != nil {
31 t.Errorf("ValidateName(%q): unexpected error: %v", tc.channel, err)
32 }
33 })
34 }
35 }

Keyboard Shortcuts

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