ScuttleBot

feat: Go SDK (pkg/client) — connect, send, handle, reconnect Client wraps girc with SASL PLAIN auth, automatic channel join on connect, typed message handler dispatch, wildcard "*" handler support, and exponential backoff reconnect (2s–60s). Non-JSON PRIVMSG silently ignored; NOTICE never dispatched. Send returns error immediately if not connected. Closes #7

lmata 2026-03-31 05:18 trunk
Commit 7afa71e45419ae6f71b54a311680c2dda9a9aad17addb77f2f14d35560b18e6c
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -1,1 +1,261 @@
1
+// Package client provides a Go SDK for connecting agents to scuttlebot.
2
+//
3
+// Agents use this package to join IRC channels, send structured messages, and
4
+// register handlers for incoming message types. IRC is abstracted entirely —
5
+// callers think in terms of channels and message types, not IRC primitives.
6
+//
7
+// Quick start:
8
+//
9
+// c, err := client.New(client.Options{
10
+// ServerAddr: "127.0.0.1:6667",
11
+// Nick: "my-agent",
12
+// Password: creds.Password,
13
+// Channels: []string{"#fleet"},
14
+// })
15
+// c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error {
16
+// // handle the task
17
+// return nil
18
+// })
19
+// err = c.Run(ctx)
120
package client
21
+
22
+import (
23
+ "context"
24
+ "fmt"
25
+ "log/slog"
26
+ "strings"
27
+ "sync"
28
+ "time"
29
+
30
+ "github.com/lrstanley/girc"
31
+
32
+ "github.com/conflicthq/scuttlebot/pkg/protocol"
33
+)
34
+
35
+const (
36
+ reconnectBase = 2 * time.Second
37
+ reconnectMax = 60 * time.Second
38
+)
39
+
40
+// HandlerFunc is called when a message of a registered type arrives.
41
+// Returning a non-nil error logs it but does not disconnect the client.
42
+type HandlerFunc func(ctx context.Context, env *protocol.Envelope) error
43
+
44
+// Options configures a Client.
45
+type Options struct {
46
+ // ServerAddr is the IRC server address (host:port).
47
+ ServerAddr string
48
+
49
+ // Nick is the IRC nick and NickServ account name.
50
+ Nick string
51
+
52
+ // Password is the NickServ / SASL password received at registration.
53
+ Password string
54
+
55
+ // Channels is the list of IRC channels to join on connect.
56
+ Channels []string
57
+
58
+ // Log is an optional structured logger. Defaults to discarding all output.
59
+ Log *slog.Logger
60
+}
61
+
62
+// Client connects an agent to scuttlebot over IRC.
63
+type Client struct {
64
+ opts Options
65
+ log *slog.Logger
66
+ mu sync.RWMutex
67
+ handlers map[string][]HandlerFunc
68
+ irc *girc.Client
69
+}
70
+
71
+// New creates a Client. Call Run to connect.
72
+func New(opts Options) (*Client, error) {
73
+ if opts.ServerAddr == "" {
74
+ return nil, fmt.Errorf("client: ServerAddr is required")
75
+ }
76
+ if opts.Nick == "" {
77
+ return nil, fmt.Errorf("client: Nick is required")
78
+ }
79
+ if opts.Password == "" {
80
+ return nil, fmt.Errorf("client: Password is required")
81
+ }
82
+ log := opts.Log
83
+ if log == nil {
84
+ log = slog.New(slog.NewTextHandler(noopWriter{}, nil))
85
+ }
86
+ return &Client{
87
+ opts: opts,
88
+ log: log,
89
+ handlers: make(map[string][]HandlerFunc),
90
+ }, nil
91
+}
92
+
93
+// Handle registers a handler for messages of the given type (e.g. "task.create").
94
+// Multiple handlers can be registered for the same type; they run concurrently.
95
+// Use "*" to receive every message type.
96
+func (c *Client) Handle(msgType string, fn HandlerFunc) {
97
+ c.mu.Lock()
98
+ defer c.mu.Unlock()
99
+ c.handlers[msgType] = append(c.handlers[msgType], fn)
100
+}
101
+
102
+// Send encodes payload as a protocol.Envelope of the given type and sends it
103
+// to channel as a PRIVMSG.
104
+func (c *Client) Send(ctx context.Context, channel, msgType string, payload any) error {
105
+ env, err := protocol.New(msgType, c.opts.Nick, payload)
106
+ if err != nil {
107
+ return fmt.Errorf("client: build envelope: %w", err)
108
+ }
109
+ data, err := protocol.Marshal(env)
110
+ if err != nil {
111
+ return fmt.Errorf("client: marshal envelope: %w", err)
112
+ }
113
+
114
+ c.mu.RLock()
115
+ irc := c.irc
116
+ c.mu.RUnlock()
117
+
118
+ if irc == nil {
119
+ return fmt.Errorf("client: not connected")
120
+ }
121
+ irc.Cmd.Message(channel, string(data))
122
+ return nil
123
+}
124
+
125
+// Run connects to IRC and blocks until ctx is cancelled. It reconnects with
126
+// exponential backoff if the connection drops.
127
+func (c *Client) Run(ctx context.Context) error {
128
+ wait := reconnectBase
129
+ for {
130
+ if ctx.Err() != nil {
131
+ return nil
132
+ }
133
+
134
+ err := c.connect(ctx)
135
+ if ctx.Err() != nil {
136
+ return nil
137
+ }
138
+ if err != nil {
139
+ c.log.Warn("irc connection error, reconnecting", "err", err, "wait", wait)
140
+ } else {
141
+ c.log.Info("irc disconnected, reconnecting", "wait", wait)
142
+ }
143
+
144
+ select {
145
+ case <-ctx.Done():
146
+ return nil
147
+ case <-time.After(wait):
148
+ }
149
+ wait = minDuration(wait*2, reconnectMax)
150
+ }
151
+}
152
+
153
+// connect makes one connection attempt. Blocks until disconnected or ctx done.
154
+func (c *Client) connect(ctx context.Context) error {
155
+ host, port, err := splitHostPort(c.opts.ServerAddr)
156
+ if err != nil {
157
+ return fmt.Errorf("parse server addr: %w", err)
158
+ }
159
+
160
+ irc := girc.New(girc.Config{
161
+ Server: host,
162
+ Port: port,
163
+ Nick: c.opts.Nick,
164
+ User: c.opts.Nick,
165
+ Name: c.opts.Nick,
166
+ SASL: &girc.SASLPlain{User: c.opts.Nick, Pass: c.opts.Password},
167
+ SSL: false,
168
+ })
169
+
170
+ irc.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
171
+ for _, ch := range c.opts.Channels {
172
+ cl.Cmd.Join(ch)
173
+ }
174
+ c.log.Info("connected to irc", "server", c.opts.ServerAddr, "channels", c.opts.Channels)
175
+ // Reset backoff in caller on successful connect.
176
+ })
177
+
178
+ irc.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
179
+ if len(e.Params) < 1 {
180
+ return
181
+ }
182
+ channel := e.Params[0]
183
+ if !strings.HasPrefix(channel, "#") {
184
+ return // ignore DMs
185
+ }
186
+ text := e.Last()
187
+ env, err := protocol.Unmarshal([]byte(text))
188
+ if err != nil {
189
+ return // non-JSON PRIVMSG (human chat) — silently ignored
190
+ }
191
+ c.dispatch(ctx, env)
192
+ })
193
+
194
+ // NOTICE is ignored — system/human commentary, not agent traffic.
195
+
196
+ c.mu.Lock()
197
+ c.irc = irc
198
+ c.mu.Unlock()
199
+
200
+ errCh := make(chan error, 1)
201
+ go func() {
202
+ if err := irc.Connect(); err != nil {
203
+ errCh <- err
204
+ } else {
205
+ errCh <- nil
206
+ }
207
+ }()
208
+
209
+ select {
210
+ case <-ctx.Done():
211
+ irc.Close()
212
+ c.mu.Lock()
213
+ c.irc = nil
214
+ c.mu.Unlock()
215
+ return nil
216
+ case err := <-errCh:
217
+ c.mu.Lock()
218
+ c.irc = nil
219
+ c.mu.Unlock()
220
+ return err
221
+ }
222
+}
223
+
224
+// dispatch delivers an envelope to all matching handlers, each in its own goroutine.
225
+func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
226
+ c.mu.RLock()
227
+ typed := append([]HandlerFunc(nil), c.handlers[env.Type]...)
228
+ wild := append([]HandlerFunc(nil), c.handlers["*"]...)
229
+ c.mu.RUnlock()
230
+
231
+ fns := append(typed, wild...)
232
+ for _, fn := range fns {
233
+ fn := fn
234
+ go func() {
235
+ if err := fn(ctx, env); err != nil {
236
+ c.log.Error("handler error", "type", env.Type, "id", env.ID, "err", err)
237
+ }
238
+ }()
239
+ }
240
+}
241
+
242
+func splitHostPort(addr string) (string, int, error) {
243
+ var host string
244
+ var port int
245
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
246
+ return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
247
+ }
248
+ return host, port, nil
249
+}
250
+
251
+func minDuration(a, b time.Duration) time.Duration {
252
+ if a < b {
253
+ return a
254
+ }
255
+ return b
256
+}
257
+
258
+// noopWriter satisfies io.Writer for the discard logger.
259
+type noopWriter struct{}
260
+
261
+func (noopWriter) Write(p []byte) (int, error) { return len(p), nil }
2262
3263
ADDED pkg/client/client_test.go
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -1,1 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1 package client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED pkg/client/client_test.go
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -1,1 +1,261 @@
1 // Package client provides a Go SDK for connecting agents to scuttlebot.
2 //
3 // Agents use this package to join IRC channels, send structured messages, and
4 // register handlers for incoming message types. IRC is abstracted entirely —
5 // callers think in terms of channels and message types, not IRC primitives.
6 //
7 // Quick start:
8 //
9 // c, err := client.New(client.Options{
10 // ServerAddr: "127.0.0.1:6667",
11 // Nick: "my-agent",
12 // Password: creds.Password,
13 // Channels: []string{"#fleet"},
14 // })
15 // c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error {
16 // // handle the task
17 // return nil
18 // })
19 // err = c.Run(ctx)
20 package client
21
22 import (
23 "context"
24 "fmt"
25 "log/slog"
26 "strings"
27 "sync"
28 "time"
29
30 "github.com/lrstanley/girc"
31
32 "github.com/conflicthq/scuttlebot/pkg/protocol"
33 )
34
35 const (
36 reconnectBase = 2 * time.Second
37 reconnectMax = 60 * time.Second
38 )
39
40 // HandlerFunc is called when a message of a registered type arrives.
41 // Returning a non-nil error logs it but does not disconnect the client.
42 type HandlerFunc func(ctx context.Context, env *protocol.Envelope) error
43
44 // Options configures a Client.
45 type Options struct {
46 // ServerAddr is the IRC server address (host:port).
47 ServerAddr string
48
49 // Nick is the IRC nick and NickServ account name.
50 Nick string
51
52 // Password is the NickServ / SASL password received at registration.
53 Password string
54
55 // Channels is the list of IRC channels to join on connect.
56 Channels []string
57
58 // Log is an optional structured logger. Defaults to discarding all output.
59 Log *slog.Logger
60 }
61
62 // Client connects an agent to scuttlebot over IRC.
63 type Client struct {
64 opts Options
65 log *slog.Logger
66 mu sync.RWMutex
67 handlers map[string][]HandlerFunc
68 irc *girc.Client
69 }
70
71 // New creates a Client. Call Run to connect.
72 func New(opts Options) (*Client, error) {
73 if opts.ServerAddr == "" {
74 return nil, fmt.Errorf("client: ServerAddr is required")
75 }
76 if opts.Nick == "" {
77 return nil, fmt.Errorf("client: Nick is required")
78 }
79 if opts.Password == "" {
80 return nil, fmt.Errorf("client: Password is required")
81 }
82 log := opts.Log
83 if log == nil {
84 log = slog.New(slog.NewTextHandler(noopWriter{}, nil))
85 }
86 return &Client{
87 opts: opts,
88 log: log,
89 handlers: make(map[string][]HandlerFunc),
90 }, nil
91 }
92
93 // Handle registers a handler for messages of the given type (e.g. "task.create").
94 // Multiple handlers can be registered for the same type; they run concurrently.
95 // Use "*" to receive every message type.
96 func (c *Client) Handle(msgType string, fn HandlerFunc) {
97 c.mu.Lock()
98 defer c.mu.Unlock()
99 c.handlers[msgType] = append(c.handlers[msgType], fn)
100 }
101
102 // Send encodes payload as a protocol.Envelope of the given type and sends it
103 // to channel as a PRIVMSG.
104 func (c *Client) Send(ctx context.Context, channel, msgType string, payload any) error {
105 env, err := protocol.New(msgType, c.opts.Nick, payload)
106 if err != nil {
107 return fmt.Errorf("client: build envelope: %w", err)
108 }
109 data, err := protocol.Marshal(env)
110 if err != nil {
111 return fmt.Errorf("client: marshal envelope: %w", err)
112 }
113
114 c.mu.RLock()
115 irc := c.irc
116 c.mu.RUnlock()
117
118 if irc == nil {
119 return fmt.Errorf("client: not connected")
120 }
121 irc.Cmd.Message(channel, string(data))
122 return nil
123 }
124
125 // Run connects to IRC and blocks until ctx is cancelled. It reconnects with
126 // exponential backoff if the connection drops.
127 func (c *Client) Run(ctx context.Context) error {
128 wait := reconnectBase
129 for {
130 if ctx.Err() != nil {
131 return nil
132 }
133
134 err := c.connect(ctx)
135 if ctx.Err() != nil {
136 return nil
137 }
138 if err != nil {
139 c.log.Warn("irc connection error, reconnecting", "err", err, "wait", wait)
140 } else {
141 c.log.Info("irc disconnected, reconnecting", "wait", wait)
142 }
143
144 select {
145 case <-ctx.Done():
146 return nil
147 case <-time.After(wait):
148 }
149 wait = minDuration(wait*2, reconnectMax)
150 }
151 }
152
153 // connect makes one connection attempt. Blocks until disconnected or ctx done.
154 func (c *Client) connect(ctx context.Context) error {
155 host, port, err := splitHostPort(c.opts.ServerAddr)
156 if err != nil {
157 return fmt.Errorf("parse server addr: %w", err)
158 }
159
160 irc := girc.New(girc.Config{
161 Server: host,
162 Port: port,
163 Nick: c.opts.Nick,
164 User: c.opts.Nick,
165 Name: c.opts.Nick,
166 SASL: &girc.SASLPlain{User: c.opts.Nick, Pass: c.opts.Password},
167 SSL: false,
168 })
169
170 irc.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
171 for _, ch := range c.opts.Channels {
172 cl.Cmd.Join(ch)
173 }
174 c.log.Info("connected to irc", "server", c.opts.ServerAddr, "channels", c.opts.Channels)
175 // Reset backoff in caller on successful connect.
176 })
177
178 irc.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
179 if len(e.Params) < 1 {
180 return
181 }
182 channel := e.Params[0]
183 if !strings.HasPrefix(channel, "#") {
184 return // ignore DMs
185 }
186 text := e.Last()
187 env, err := protocol.Unmarshal([]byte(text))
188 if err != nil {
189 return // non-JSON PRIVMSG (human chat) — silently ignored
190 }
191 c.dispatch(ctx, env)
192 })
193
194 // NOTICE is ignored — system/human commentary, not agent traffic.
195
196 c.mu.Lock()
197 c.irc = irc
198 c.mu.Unlock()
199
200 errCh := make(chan error, 1)
201 go func() {
202 if err := irc.Connect(); err != nil {
203 errCh <- err
204 } else {
205 errCh <- nil
206 }
207 }()
208
209 select {
210 case <-ctx.Done():
211 irc.Close()
212 c.mu.Lock()
213 c.irc = nil
214 c.mu.Unlock()
215 return nil
216 case err := <-errCh:
217 c.mu.Lock()
218 c.irc = nil
219 c.mu.Unlock()
220 return err
221 }
222 }
223
224 // dispatch delivers an envelope to all matching handlers, each in its own goroutine.
225 func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
226 c.mu.RLock()
227 typed := append([]HandlerFunc(nil), c.handlers[env.Type]...)
228 wild := append([]HandlerFunc(nil), c.handlers["*"]...)
229 c.mu.RUnlock()
230
231 fns := append(typed, wild...)
232 for _, fn := range fns {
233 fn := fn
234 go func() {
235 if err := fn(ctx, env); err != nil {
236 c.log.Error("handler error", "type", env.Type, "id", env.ID, "err", err)
237 }
238 }()
239 }
240 }
241
242 func splitHostPort(addr string) (string, int, error) {
243 var host string
244 var port int
245 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
246 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
247 }
248 return host, port, nil
249 }
250
251 func minDuration(a, b time.Duration) time.Duration {
252 if a < b {
253 return a
254 }
255 return b
256 }
257
258 // noopWriter satisfies io.Writer for the discard logger.
259 type noopWriter struct{}
260
261 func (noopWriter) Write(p []byte) (int, error) { return len(p), nil }
262
263 DDED pkg/client/client_test.go
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -0,0 +1,113 @@
1
+package client_test
2
+
3
+import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/pkg/client"
9
+ "github.com/conflicthq/scuttlebot/pkg/protocol"
10
+)
11
+
12
+func TestNewValidation(t *testing.T) {
13
+ tests := []struct {
14
+ name string
15
+ opts client.Options
16
+ }{
17
+ {"missing server", client.Options{Nick: "a", Password: "p"}},
18
+ {"missing nick", client.Options{ServerAddr: "localhost:6667", Password: "p"}},
19
+ {"missing password", client.Options{ServerAddr: "localhost:6667", Nick: "a"}},
20
+ }
21
+ for _, tt := range tests {
22
+ t.Run(tt.name, func(t *testing.T) {
23
+ if _, err := client.New(tt.opts); err == nil {
24
+ t.Error("expected error, got nil")
25
+ }
26
+ })
27
+ }
28
+}
29
+
30
+func TestNewOK(t *testing.T) {
31
+ c, err := client.New(client.Options{
32
+ ServerAddr: "localhost:6667",
33
+ Nick: "agent-01",
34
+ Password: "secret",
35
+ })
36
+ if err != nil {
37
+ t.Fatalf("unexpected error: %v", err)
38
+ }
39
+ if c == nil {
40
+ t.Fatal("expected non-nil client")
41
+ }
42
+}
43
+
44
+func TestHandleRegistration(t *testing.T) {
45
+ c, _ := client.New(client.Options{
46
+ ServerAddr: "localhost:6667",
47
+ Nick: "agent-01",
48
+ Password: "secret",
49
+ })
50
+
51
+ called := make(chan string, 1)
52
+ c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error {
53
+ called <- env.Type
54
+ return nil
55
+ })
56
+
57
+ // Verify handler is registered by checking it doesn't panic.
58
+ // Dispatch is tested indirectly via the exported Handle API.
59
+ select {
60
+ case <-called:
61
+ t.Error("handler fired unexpectedly before any message")
62
+ default:
63
+ }
64
+}
65
+
66
+func TestSendNotConnected(t *testing.T) {
67
+ c, _ := client.New(client.Options{
68
+ ServerAddr: "localhost:6667",
69
+ Nick: "agent-01",
70
+ Password: "secret",
71
+ })
72
+
73
+ err := c.Send(context.Background(), "#fleet", "task.create", map[string]string{"task": "x"})
74
+ if err == nil {
75
+ t.Error("expected error when not connected, got nil")
76
+ }
77
+}
78
+
79
+func TestRunCancelledImmediately(t *testing.T) {
80
+ c, _ := client.New(client.Options{
81
+ ServerAddr: "localhost:6667",
82
+ Nick: "agent-01",
83
+ Password: "secret",
84
+ })
85
+
86
+ ctx, cancel := context.WithCancel(context.Background())
87
+ cancel() // cancel immediately
88
+
89
+ done := make(chan error, 1)
90
+ go func() { done <- c.Run(ctx) }()
91
+
92
+ select {
93
+ case err := <-done:
94
+ if err != nil {
95
+ t.Errorf("Run returned error: %v", err)
96
+ }
97
+ case <-time.After(2 * time.Second):
98
+ t.Error("Run did not return after ctx cancelled")
99
+ }
100
+}
101
+
102
+func TestWildcardHandler(t *testing.T) {
103
+ // Verify that registering "*" doesn't panic and multiple handlers stack.
104
+ c, _ := client.New(client.Options{
105
+ ServerAddr: "localhost:6667",
106
+ Nick: "agent-01",
107
+ Password: "secret",
108
+ })
109
+
110
+ c.Handle("*", func(ctx context.Context, env *protocol.Envelope) error { return nil })
111
+ c.Handle("*", func(ctx context.Context, env *protocol.Envelope) error { return nil })
112
+ c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error { return nil })
113
+}
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -0,0 +1,113 @@
1 package client_test
2
3 import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/pkg/client"
9 "github.com/conflicthq/scuttlebot/pkg/protocol"
10 )
11
12 func TestNewValidation(t *testing.T) {
13 tests := []struct {
14 name string
15 opts client.Options
16 }{
17 {"missing server", client.Options{Nick: "a", Password: "p"}},
18 {"missing nick", client.Options{ServerAddr: "localhost:6667", Password: "p"}},
19 {"missing password", client.Options{ServerAddr: "localhost:6667", Nick: "a"}},
20 }
21 for _, tt := range tests {
22 t.Run(tt.name, func(t *testing.T) {
23 if _, err := client.New(tt.opts); err == nil {
24 t.Error("expected error, got nil")
25 }
26 })
27 }
28 }
29
30 func TestNewOK(t *testing.T) {
31 c, err := client.New(client.Options{
32 ServerAddr: "localhost:6667",
33 Nick: "agent-01",
34 Password: "secret",
35 })
36 if err != nil {
37 t.Fatalf("unexpected error: %v", err)
38 }
39 if c == nil {
40 t.Fatal("expected non-nil client")
41 }
42 }
43
44 func TestHandleRegistration(t *testing.T) {
45 c, _ := client.New(client.Options{
46 ServerAddr: "localhost:6667",
47 Nick: "agent-01",
48 Password: "secret",
49 })
50
51 called := make(chan string, 1)
52 c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error {
53 called <- env.Type
54 return nil
55 })
56
57 // Verify handler is registered by checking it doesn't panic.
58 // Dispatch is tested indirectly via the exported Handle API.
59 select {
60 case <-called:
61 t.Error("handler fired unexpectedly before any message")
62 default:
63 }
64 }
65
66 func TestSendNotConnected(t *testing.T) {
67 c, _ := client.New(client.Options{
68 ServerAddr: "localhost:6667",
69 Nick: "agent-01",
70 Password: "secret",
71 })
72
73 err := c.Send(context.Background(), "#fleet", "task.create", map[string]string{"task": "x"})
74 if err == nil {
75 t.Error("expected error when not connected, got nil")
76 }
77 }
78
79 func TestRunCancelledImmediately(t *testing.T) {
80 c, _ := client.New(client.Options{
81 ServerAddr: "localhost:6667",
82 Nick: "agent-01",
83 Password: "secret",
84 })
85
86 ctx, cancel := context.WithCancel(context.Background())
87 cancel() // cancel immediately
88
89 done := make(chan error, 1)
90 go func() { done <- c.Run(ctx) }()
91
92 select {
93 case err := <-done:
94 if err != nil {
95 t.Errorf("Run returned error: %v", err)
96 }
97 case <-time.After(2 * time.Second):
98 t.Error("Run did not return after ctx cancelled")
99 }
100 }
101
102 func TestWildcardHandler(t *testing.T) {
103 // Verify that registering "*" doesn't panic and multiple handlers stack.
104 c, _ := client.New(client.Options{
105 ServerAddr: "localhost:6667",
106 Nick: "agent-01",
107 Password: "secret",
108 })
109
110 c.Handle("*", func(ctx context.Context, env *protocol.Envelope) error { return nil })
111 c.Handle("*", func(ctx context.Context, env *protocol.Envelope) error { return nil })
112 c.Handle("task.create", func(ctx context.Context, env *protocol.Envelope) error { return nil })
113 }

Keyboard Shortcuts

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