ScuttleBot

scuttlebot / pkg / ircagent / ircagent.go
Blame History Raw 522 lines
1
package ircagent
2
3
import (
4
"bytes"
5
"context"
6
"encoding/json"
7
"fmt"
8
"io"
9
"log/slog"
10
"net"
11
"net/http"
12
"strconv"
13
"strings"
14
"sync"
15
"time"
16
17
"github.com/conflicthq/scuttlebot/internal/llm"
18
"github.com/lrstanley/girc"
19
)
20
21
const (
22
defaultHistoryLen = 20
23
defaultTypingDelay = 400 * time.Millisecond
24
defaultErrorJoiner = " - "
25
defaultGatewayTimout = 60 * time.Second
26
)
27
28
var defaultActivityPrefixes = []string{"claude-", "codex-", "gemini-"}
29
30
// DefaultActivityPrefixes returns the default set of nick prefixes treated as
31
// status/activity senders rather than chat participants.
32
func DefaultActivityPrefixes() []string {
33
return append([]string(nil), defaultActivityPrefixes...)
34
}
35
36
// Config configures the shared IRC agent runtime.
37
type Config struct {
38
IRCAddr string
39
Nick string
40
Pass string
41
Channels []string
42
SystemPrompt string
43
Logger *slog.Logger
44
HistoryLen int
45
TypingDelay time.Duration
46
ErrorJoiner string
47
ActivityPrefixes []string
48
Direct *DirectConfig
49
Gateway *GatewayConfig
50
}
51
52
// DirectConfig configures direct provider mode.
53
type DirectConfig struct {
54
Backend string
55
APIKey string
56
Model string
57
}
58
59
// GatewayConfig configures scuttlebot gateway mode.
60
type GatewayConfig struct {
61
APIURL string
62
Token string
63
Backend string
64
HTTPClient *http.Client
65
}
66
67
type historyEntry struct {
68
role string
69
nick string
70
content string
71
}
72
73
type completer interface {
74
complete(ctx context.Context, prompt string) (string, error)
75
}
76
77
type directCompleter struct {
78
provider llm.Provider
79
}
80
81
func (d *directCompleter) complete(ctx context.Context, prompt string) (string, error) {
82
return d.provider.Summarize(ctx, prompt)
83
}
84
85
type gatewayCompleter struct {
86
apiURL string
87
token string
88
backend string
89
http *http.Client
90
}
91
92
func (g *gatewayCompleter) complete(ctx context.Context, prompt string) (string, error) {
93
body, _ := json.Marshal(map[string]string{"backend": g.backend, "prompt": prompt})
94
req, err := http.NewRequestWithContext(ctx, "POST", g.apiURL+"/v1/llm/complete", bytes.NewReader(body))
95
if err != nil {
96
return "", err
97
}
98
req.Header.Set("Content-Type", "application/json")
99
req.Header.Set("Authorization", "Bearer "+g.token)
100
101
resp, err := g.http.Do(req)
102
if err != nil {
103
return "", fmt.Errorf("gateway request: %w", err)
104
}
105
defer resp.Body.Close()
106
107
data, _ := io.ReadAll(resp.Body)
108
if resp.StatusCode != http.StatusOK {
109
return "", fmt.Errorf("gateway error %d: %s", resp.StatusCode, string(data))
110
}
111
112
var result struct {
113
Text string `json:"text"`
114
}
115
if err := json.Unmarshal(data, &result); err != nil {
116
return "", fmt.Errorf("gateway parse: %w", err)
117
}
118
return result.Text, nil
119
}
120
121
type agent struct {
122
cfg Config
123
llm completer
124
log *slog.Logger
125
irc *girc.Client
126
mu sync.Mutex
127
history map[string][]historyEntry
128
}
129
130
// Run starts the IRC agent and blocks until the context is canceled or the IRC
131
// connection fails.
132
func Run(ctx context.Context, cfg Config) error {
133
cfg = withDefaults(cfg)
134
if err := validateConfig(cfg); err != nil {
135
return err
136
}
137
138
llmClient, err := buildCompleter(cfg)
139
if err != nil {
140
return err
141
}
142
143
a := &agent{
144
cfg: cfg,
145
llm: llmClient,
146
log: cfg.Logger,
147
history: make(map[string][]historyEntry),
148
}
149
return a.run(ctx)
150
}
151
152
// SplitCSV trims and splits comma-separated channel strings.
153
func SplitCSV(s string) []string {
154
var out []string
155
for _, part := range strings.Split(s, ",") {
156
if part = strings.TrimSpace(part); part != "" {
157
out = append(out, part)
158
}
159
}
160
return out
161
}
162
163
func withDefaults(cfg Config) Config {
164
if cfg.Logger == nil {
165
cfg.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
166
}
167
if cfg.HistoryLen <= 0 {
168
cfg.HistoryLen = defaultHistoryLen
169
}
170
if cfg.TypingDelay <= 0 {
171
cfg.TypingDelay = defaultTypingDelay
172
}
173
if cfg.ErrorJoiner == "" {
174
cfg.ErrorJoiner = defaultErrorJoiner
175
}
176
if len(cfg.ActivityPrefixes) == 0 {
177
cfg.ActivityPrefixes = append([]string(nil), defaultActivityPrefixes...)
178
}
179
if len(cfg.Channels) == 0 {
180
cfg.Channels = []string{"#general"}
181
}
182
return cfg
183
}
184
185
func validateConfig(cfg Config) error {
186
switch {
187
case cfg.IRCAddr == "":
188
return fmt.Errorf("irc address is required")
189
case cfg.Nick == "":
190
return fmt.Errorf("nick is required")
191
case cfg.Pass == "":
192
return fmt.Errorf("pass is required")
193
case cfg.SystemPrompt == "":
194
return fmt.Errorf("system prompt is required")
195
}
196
return nil
197
}
198
199
func buildCompleter(cfg Config) (completer, error) {
200
gatewayConfigured := cfg.Gateway != nil && cfg.Gateway.Token != ""
201
directConfigured := cfg.Direct != nil && cfg.Direct.APIKey != ""
202
203
if gatewayConfigured && !directConfigured {
204
if cfg.Gateway.APIURL == "" {
205
return nil, fmt.Errorf("gateway api url is required")
206
}
207
if cfg.Gateway.Backend == "" {
208
return nil, fmt.Errorf("gateway backend is required")
209
}
210
httpClient := cfg.Gateway.HTTPClient
211
if httpClient == nil {
212
httpClient = &http.Client{Timeout: defaultGatewayTimout}
213
}
214
cfg.Logger.Info("mode: gateway", "api-url", cfg.Gateway.APIURL, "backend", cfg.Gateway.Backend)
215
return &gatewayCompleter{
216
apiURL: cfg.Gateway.APIURL,
217
token: cfg.Gateway.Token,
218
backend: cfg.Gateway.Backend,
219
http: httpClient,
220
}, nil
221
}
222
223
if directConfigured {
224
if cfg.Direct.Backend == "" {
225
return nil, fmt.Errorf("direct backend is required")
226
}
227
cfg.Logger.Info("mode: direct", "backend", cfg.Direct.Backend, "model", cfg.Direct.Model)
228
provider, err := llm.New(llm.BackendConfig{
229
Backend: cfg.Direct.Backend,
230
APIKey: cfg.Direct.APIKey,
231
Model: cfg.Direct.Model,
232
})
233
if err != nil {
234
return nil, fmt.Errorf("build provider: %w", err)
235
}
236
return &directCompleter{provider: provider}, nil
237
}
238
239
return nil, fmt.Errorf("set gateway token or direct api key")
240
}
241
242
func (a *agent) run(ctx context.Context) error {
243
host, port, err := splitHostPort(a.cfg.IRCAddr)
244
if err != nil {
245
return err
246
}
247
248
client := girc.New(girc.Config{
249
Server: host,
250
Port: port,
251
Nick: a.cfg.Nick,
252
User: a.cfg.Nick,
253
Name: a.cfg.Nick + " (AI agent)",
254
SASL: &girc.SASLPlain{User: a.cfg.Nick, Pass: a.cfg.Pass},
255
})
256
257
client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
258
a.log.Info("connected", "server", a.cfg.IRCAddr)
259
for _, ch := range a.cfg.Channels {
260
cl.Cmd.Join(ch)
261
}
262
})
263
264
client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
265
if len(e.Params) < 1 || e.Source == nil {
266
return
267
}
268
269
target := e.Params[0]
270
senderNick := e.Source.Name
271
text := strings.TrimSpace(e.Last())
272
if senderNick == a.cfg.Nick {
273
return
274
}
275
276
// RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277
if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278
if idx := strings.Index(senderNick, sep); idx != -1 {
279
senderNick = senderNick[:idx]
280
}
281
}
282
// Fallback: parse legacy [nick] prefix from bridge bot.
283
if strings.HasPrefix(text, "[") {
284
if end := strings.Index(text, "] "); end != -1 {
285
senderNick = text[1:end]
286
text = text[end+2:]
287
}
288
}
289
290
isDM := !strings.HasPrefix(target, "#")
291
isMentioned := MentionsNick(text, a.cfg.Nick)
292
isActivityPost := HasAnyPrefix(senderNick, a.cfg.ActivityPrefixes)
293
294
convKey := target
295
if isDM {
296
convKey = senderNick
297
}
298
a.appendHistory(convKey, "user", senderNick, text)
299
300
if isActivityPost {
301
return
302
}
303
if !isDM && !isMentioned {
304
return
305
}
306
307
cleaned := TrimAddressedText(text, a.cfg.Nick)
308
309
a.mu.Lock()
310
history := a.history[convKey]
311
if len(history) > 0 {
312
history[len(history)-1].content = cleaned
313
a.history[convKey] = history
314
}
315
a.mu.Unlock()
316
317
replyTo := target
318
if isDM {
319
replyTo = senderNick
320
}
321
go a.respond(ctx, cl, convKey, replyTo, senderNick, isDM)
322
})
323
324
a.irc = client
325
326
errCh := make(chan error, 1)
327
go func() {
328
if err := client.Connect(); err != nil && ctx.Err() == nil {
329
errCh <- err
330
}
331
}()
332
333
select {
334
case <-ctx.Done():
335
client.Close()
336
return nil
337
case err := <-errCh:
338
return fmt.Errorf("irc: %w", err)
339
}
340
}
341
342
func (a *agent) respond(ctx context.Context, cl *girc.Client, convKey, replyTo, senderNick string, isDM bool) {
343
prompt := a.buildPrompt(convKey)
344
time.Sleep(a.cfg.TypingDelay)
345
346
reply, err := a.llm.complete(ctx, prompt)
347
if err != nil {
348
a.log.Error("llm error", "err", err)
349
cl.Cmd.Message(replyTo, senderNick+": sorry, something went wrong"+a.cfg.ErrorJoiner+err.Error())
350
return
351
}
352
353
reply = strings.TrimSpace(reply)
354
a.appendHistory(convKey, "assistant", a.cfg.Nick, reply)
355
356
prefix := ""
357
if !isDM && senderNick != "" {
358
prefix = senderNick + ": "
359
}
360
for i, line := range strings.Split(reply, "\n") {
361
line = strings.TrimSpace(line)
362
if line == "" {
363
continue
364
}
365
if i == 0 {
366
line = prefix + line
367
}
368
cl.Cmd.Message(replyTo, line)
369
}
370
}
371
372
func (a *agent) buildPrompt(convKey string) string {
373
a.mu.Lock()
374
history := append([]historyEntry(nil), a.history[convKey]...)
375
a.mu.Unlock()
376
377
var sb strings.Builder
378
sb.WriteString(a.cfg.SystemPrompt)
379
sb.WriteString("\n\nConversation history:\n")
380
for _, entry := range history {
381
role := "User"
382
if entry.role == "assistant" {
383
role = "Assistant"
384
}
385
fmt.Fprintf(&sb, "[%s] %s: %s\n", role, entry.nick, entry.content)
386
}
387
sb.WriteString("\nRespond to the last user message. Be concise.")
388
return sb.String()
389
}
390
391
func (a *agent) appendHistory(convKey, role, nick, content string) {
392
a.mu.Lock()
393
defer a.mu.Unlock()
394
395
history := a.history[convKey]
396
history = append(history, historyEntry{role: role, nick: nick, content: content})
397
if len(history) > a.cfg.HistoryLen {
398
history = history[len(history)-a.cfg.HistoryLen:]
399
}
400
a.history[convKey] = history
401
}
402
403
// MentionsNick reports whether text contains a standalone mention of nick.
404
func MentionsNick(text, nick string) bool {
405
lower := strings.ToLower(text)
406
needle := strings.ToLower(nick)
407
start := 0
408
409
for {
410
idx := strings.Index(lower[start:], needle)
411
if idx < 0 {
412
return false
413
}
414
idx += start
415
416
before := idx == 0 || !isMentionAdjacent(lower[idx-1])
417
after := idx+len(needle) >= len(lower) || !isMentionAdjacent(lower[idx+len(needle)])
418
if before && after {
419
return true
420
}
421
422
start = idx + 1
423
}
424
}
425
426
// MatchesGroupMention checks if text contains a group mention that applies
427
// to an agent with the given nick and type. Supported patterns:
428
//
429
// - @all — matches every agent
430
// - @worker, @observer, @orchestrator, @operator — matches by agent type
431
// - @prefix-* — matches agents whose nick starts with prefix- (e.g. @claude-* matches claude-kohakku-abc)
432
func MatchesGroupMention(text, nick, agentType string) bool {
433
lower := strings.ToLower(text)
434
435
// @all
436
if containsWord(lower, "@all") {
437
return true
438
}
439
440
// @role — e.g. @worker, @observer
441
if agentType != "" && containsWord(lower, "@"+strings.ToLower(agentType)) {
442
return true
443
}
444
445
// @prefix-* patterns — find all @word-* tokens in the text.
446
for i := 0; i < len(lower); i++ {
447
if lower[i] != '@' {
448
continue
449
}
450
// Extract the token after @.
451
j := i + 1
452
for j < len(lower) && (isAlNum(lower[j]) || lower[j] == '*') {
453
j++
454
}
455
token := lower[i+1 : j]
456
if !strings.HasSuffix(token, "*") || len(token) < 2 {
457
continue
458
}
459
prefix := token[:len(token)-1] // remove the *
460
if strings.HasPrefix(strings.ToLower(nick), prefix) {
461
return true
462
}
463
}
464
465
return false
466
}
467
468
func containsWord(text, word string) bool {
469
idx := strings.Index(text, word)
470
if idx < 0 {
471
return false
472
}
473
end := idx + len(word)
474
before := idx == 0 || !isAlNum(text[idx-1])
475
after := end >= len(text) || !isAlNum(text[end])
476
return before && after
477
}
478
479
// TrimAddressedText removes an initial nick address from text when present.
480
func TrimAddressedText(text, nick string) string {
481
cleaned := text
482
lower := strings.ToLower(text)
483
if idx := strings.Index(lower, strings.ToLower(nick)); idx != -1 {
484
after := strings.TrimSpace(text[idx+len(nick):])
485
after = strings.TrimLeft(after, ":, ")
486
if after != "" {
487
cleaned = after
488
}
489
}
490
return cleaned
491
}
492
493
func isAlNum(c byte) bool {
494
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
495
}
496
497
func isMentionAdjacent(c byte) bool {
498
return isAlNum(c) || c == '.' || c == '/' || c == '\\'
499
}
500
501
// HasAnyPrefix reports whether s starts with any prefix in prefixes.
502
func HasAnyPrefix(s string, prefixes []string) bool {
503
for _, prefix := range prefixes {
504
if strings.HasPrefix(s, prefix) {
505
return true
506
}
507
}
508
return false
509
}
510
511
func splitHostPort(addr string) (string, int, error) {
512
host, portStr, err := net.SplitHostPort(addr)
513
if err != nil {
514
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
515
}
516
port, err := strconv.Atoi(portStr)
517
if err != nil {
518
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
519
}
520
return host, port, nil
521
}
522

Keyboard Shortcuts

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