ScuttleBot

scuttlebot / internal / bots / systembot / systembot.go
Blame History Raw 253 lines
1
// Package systembot implements the systembot — IRC system event logger.
2
//
3
// systembot is the complement to scribe: where scribe owns the agent message
4
// stream (PRIVMSG), systembot owns the system stream:
5
// - NOTICE messages (server announcements, NickServ/ChanServ responses)
6
// - Connection events: JOIN, PART, QUIT, KICK
7
// - Mode changes: MODE
8
//
9
// Every event is written to a Store as a SystemEntry.
10
package systembot
11
12
import (
13
"context"
14
"fmt"
15
"log/slog"
16
"net"
17
"strconv"
18
"strings"
19
"time"
20
21
"github.com/lrstanley/girc"
22
23
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24
)
25
26
const botNick = "systembot"
27
28
// EntryKind classifies a system event.
29
type EntryKind string
30
31
const (
32
KindNotice EntryKind = "notice"
33
KindJoin EntryKind = "join"
34
KindPart EntryKind = "part"
35
KindQuit EntryKind = "quit"
36
KindKick EntryKind = "kick"
37
KindMode EntryKind = "mode"
38
)
39
40
// Entry is a single system event log record.
41
type Entry struct {
42
At time.Time
43
Kind EntryKind
44
Channel string // empty for server-level events (QUIT, server NOTICE)
45
Nick string // who triggered the event; empty for server events
46
Text string // message text, mode string, kick reason, etc.
47
}
48
49
// Store is where system entries are written.
50
type Store interface {
51
Append(Entry) error
52
}
53
54
// Bot is the systembot.
55
type Bot struct {
56
ircAddr string
57
password string
58
channels []string
59
store Store
60
log *slog.Logger
61
client *girc.Client
62
}
63
64
// New creates a systembot.
65
func New(ircAddr, password string, channels []string, store Store, log *slog.Logger) *Bot {
66
return &Bot{
67
ircAddr: ircAddr,
68
password: password,
69
channels: channels,
70
store: store,
71
log: log,
72
}
73
}
74
75
// Name returns the bot's IRC nick.
76
func (b *Bot) Name() string { return botNick }
77
78
// Start connects to IRC and begins logging system events. Blocks until ctx is cancelled.
79
func (b *Bot) Start(ctx context.Context) error {
80
host, port, err := splitHostPort(b.ircAddr)
81
if err != nil {
82
return fmt.Errorf("systembot: parse irc addr: %w", err)
83
}
84
85
c := girc.New(girc.Config{
86
Server: host,
87
Port: port,
88
Nick: botNick,
89
User: botNick,
90
Name: "scuttlebot systembot",
91
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
92
PingDelay: 30 * time.Second,
93
PingTimeout: 30 * time.Second,
94
SSL: false,
95
})
96
97
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
98
cl.Cmd.Mode(cl.GetNick(), "+B")
99
for _, ch := range b.channels {
100
cl.Cmd.Join(ch)
101
}
102
b.log.Info("systembot connected", "channels", b.channels)
103
})
104
105
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
106
if ch := e.Last(); strings.HasPrefix(ch, "#") {
107
cl.Cmd.Join(ch)
108
}
109
})
110
111
// NOTICE — server announcements, NickServ/ChanServ responses.
112
c.Handlers.AddBg(girc.NOTICE, func(_ *girc.Client, e girc.Event) {
113
channel := ""
114
if len(e.Params) > 0 && strings.HasPrefix(e.Params[0], "#") {
115
channel = e.Params[0]
116
}
117
nick := ""
118
if e.Source != nil {
119
nick = e.Source.Name
120
}
121
b.write(Entry{Kind: KindNotice, Channel: channel, Nick: nick, Text: e.Last()})
122
})
123
124
// JOIN
125
c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
126
channel := e.Last()
127
if len(e.Params) > 0 {
128
channel = e.Params[0]
129
}
130
nick := ""
131
if e.Source != nil {
132
nick = e.Source.Name
133
}
134
b.write(Entry{Kind: KindJoin, Channel: channel, Nick: nick})
135
})
136
137
// PART
138
c.Handlers.AddBg(girc.PART, func(_ *girc.Client, e girc.Event) {
139
channel := ""
140
if len(e.Params) > 0 {
141
channel = e.Params[0]
142
}
143
nick := ""
144
if e.Source != nil {
145
nick = e.Source.Name
146
}
147
b.write(Entry{Kind: KindPart, Channel: channel, Nick: nick, Text: e.Last()})
148
})
149
150
// QUIT
151
c.Handlers.AddBg(girc.QUIT, func(_ *girc.Client, e girc.Event) {
152
nick := ""
153
if e.Source != nil {
154
nick = e.Source.Name
155
}
156
b.write(Entry{Kind: KindQuit, Nick: nick, Text: e.Last()})
157
})
158
159
// KICK
160
c.Handlers.AddBg(girc.KICK, func(_ *girc.Client, e girc.Event) {
161
channel := ""
162
if len(e.Params) > 0 {
163
channel = e.Params[0]
164
}
165
kicked := ""
166
if len(e.Params) > 1 {
167
kicked = e.Params[1]
168
}
169
b.write(Entry{Kind: KindKick, Channel: channel, Nick: kicked, Text: e.Last()})
170
})
171
172
// MODE
173
c.Handlers.AddBg(girc.MODE, func(_ *girc.Client, e girc.Event) {
174
channel := ""
175
if len(e.Params) > 0 && strings.HasPrefix(e.Params[0], "#") {
176
channel = e.Params[0]
177
}
178
nick := ""
179
if e.Source != nil {
180
nick = e.Source.Name
181
}
182
b.write(Entry{Kind: KindMode, Channel: channel, Nick: nick, Text: strings.Join(e.Params, " ")})
183
})
184
185
router := cmdparse.NewRouter(botNick)
186
router.Register(cmdparse.Command{
187
Name: "status",
188
Usage: "STATUS",
189
Description: "show connected users and channel counts",
190
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
191
})
192
router.Register(cmdparse.Command{
193
Name: "who",
194
Usage: "WHO [#channel]",
195
Description: "show detailed user list",
196
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
197
})
198
199
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
200
if len(e.Params) < 1 || e.Source == nil {
201
return
202
}
203
// Dispatch commands (DMs and channel messages).
204
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
205
cl.Cmd.Message(reply.Target, reply.Text)
206
return
207
}
208
})
209
210
b.client = c
211
212
errCh := make(chan error, 1)
213
go func() {
214
if err := c.Connect(); err != nil && ctx.Err() == nil {
215
errCh <- err
216
}
217
}()
218
219
select {
220
case <-ctx.Done():
221
c.Close()
222
return nil
223
case err := <-errCh:
224
return fmt.Errorf("systembot: irc connection: %w", err)
225
}
226
}
227
228
// Stop disconnects the bot.
229
func (b *Bot) Stop() {
230
if b.client != nil {
231
b.client.Close()
232
}
233
}
234
235
func (b *Bot) write(e Entry) {
236
e.At = time.Now()
237
if err := b.store.Append(e); err != nil {
238
b.log.Error("systembot: failed to write entry", "kind", e.Kind, "err", err)
239
}
240
}
241
242
func splitHostPort(addr string) (string, int, error) {
243
host, portStr, err := net.SplitHostPort(addr)
244
if err != nil {
245
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
246
}
247
port, err := strconv.Atoi(portStr)
248
if err != nil {
249
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
250
}
251
return host, port, nil
252
}
253

Keyboard Shortcuts

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