ScuttleBot

scuttlebot / internal / bots / scribe / scribe.go
Blame History Raw 177 lines
1
// Package scribe implements the scribe bot — structured logging for all channel activity.
2
//
3
// scribe joins all configured channels, listens for PRIVMSG, and writes
4
// structured log entries to a Store. Valid JSON envelopes are logged with
5
// their parsed type and ID. Malformed messages are logged as raw entries
6
// without crashing. NOTICE messages are ignored (system/human commentary only).
7
package scribe
8
9
import (
10
"context"
11
"fmt"
12
"log/slog"
13
"net"
14
"strconv"
15
"strings"
16
"time"
17
18
"github.com/lrstanley/girc"
19
20
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
21
"github.com/conflicthq/scuttlebot/pkg/protocol"
22
)
23
24
const botNick = "scribe"
25
26
// Bot is the scribe logging bot.
27
type Bot struct {
28
ircAddr string
29
password string
30
channels []string
31
store Store
32
log *slog.Logger
33
client *girc.Client
34
}
35
36
// New creates a scribe Bot. channels is the list of channels to join and log.
37
func New(ircAddr, password string, channels []string, store Store, log *slog.Logger) *Bot {
38
return &Bot{
39
ircAddr: ircAddr,
40
password: password,
41
channels: channels,
42
store: store,
43
log: log,
44
}
45
}
46
47
// Name returns the bot's IRC nick.
48
func (b *Bot) Name() string { return botNick }
49
50
// Start connects to IRC and begins logging. Blocks until ctx is cancelled.
51
func (b *Bot) Start(ctx context.Context) error {
52
host, port, err := splitHostPort(b.ircAddr)
53
if err != nil {
54
return fmt.Errorf("scribe: parse irc addr: %w", err)
55
}
56
57
c := girc.New(girc.Config{
58
Server: host,
59
Port: port,
60
Nick: botNick,
61
User: botNick,
62
Name: "scuttlebot scribe",
63
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
64
PingDelay: 30 * time.Second,
65
PingTimeout: 30 * time.Second,
66
SSL: false,
67
})
68
69
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
70
client.Cmd.Mode(client.GetNick(), "+B")
71
for _, ch := range b.channels {
72
client.Cmd.Join(ch)
73
}
74
b.log.Info("scribe connected and joined channels", "channels", b.channels)
75
})
76
77
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
78
if ch := e.Last(); strings.HasPrefix(ch, "#") {
79
cl.Cmd.Join(ch)
80
}
81
})
82
83
router := cmdparse.NewRouter(botNick)
84
router.Register(cmdparse.Command{
85
Name: "search",
86
Usage: "SEARCH <term>",
87
Description: "search channel logs",
88
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
89
})
90
router.Register(cmdparse.Command{
91
Name: "stats",
92
Usage: "STATS",
93
Description: "show channel message statistics",
94
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
95
})
96
97
// Log PRIVMSG — the agent message stream.
98
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
99
if len(e.Params) < 1 || e.Source == nil {
100
return
101
}
102
// Dispatch commands (DMs and channel messages).
103
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
104
client.Cmd.Message(reply.Target, reply.Text)
105
return
106
}
107
channel := e.Params[0]
108
if !strings.HasPrefix(channel, "#") {
109
return // non-command DMs ignored
110
}
111
text := e.Last()
112
nick := e.Source.Name
113
b.writeEntry(channel, nick, text)
114
})
115
116
// NOTICE is ignored — system/human commentary, not agent traffic.
117
118
b.client = c
119
120
errCh := make(chan error, 1)
121
go func() {
122
if err := c.Connect(); err != nil && ctx.Err() == nil {
123
errCh <- err
124
}
125
}()
126
127
select {
128
case <-ctx.Done():
129
c.Close()
130
return nil
131
case err := <-errCh:
132
return fmt.Errorf("scribe: irc connection: %w", err)
133
}
134
}
135
136
// Stop disconnects the bot.
137
func (b *Bot) Stop() {
138
if b.client != nil {
139
b.client.Close()
140
}
141
}
142
143
func (b *Bot) writeEntry(channel, nick, text string) {
144
entry := Entry{
145
At: time.Now(),
146
Channel: channel,
147
Nick: nick,
148
Raw: text,
149
}
150
151
env, err := protocol.Unmarshal([]byte(text))
152
if err != nil {
153
// Not a valid envelope — log as raw. This is expected for human messages.
154
entry.Kind = EntryKindRaw
155
} else {
156
entry.Kind = EntryKindEnvelope
157
entry.MessageType = env.Type
158
entry.MessageID = env.ID
159
}
160
161
if err := b.store.Append(entry); err != nil {
162
b.log.Error("scribe: failed to write log entry", "err", err)
163
}
164
}
165
166
func splitHostPort(addr string) (string, int, error) {
167
host, portStr, err := net.SplitHostPort(addr)
168
if err != nil {
169
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
170
}
171
port, err := strconv.Atoi(portStr)
172
if err != nil {
173
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
174
}
175
return host, port, nil
176
}
177

Keyboard Shortcuts

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