ScuttleBot

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

Keyboard Shortcuts

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