ScuttleBot

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

Keyboard Shortcuts

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