ScuttleBot

Merge pull request #136 from ConflictHQ/feature/119-ircv3-tags feat: IRCv3 leverage — tags, MONITOR, CHATHISTORY, extended bans, +B mode

noreply 2026-04-05 16:31 trunk merge
Commit f64fe5f571b3116c5b75a1c7a4d889758f5b39ef4fd574b95661d9946e3d6705
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120120
PingTimeout: 30 * time.Second,
121121
SSL: false,
122122
})
123123
124124
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
125
+ cl.Cmd.Mode(cl.GetNick(), "+B")
125126
for _, ch := range b.channels {
126127
cl.Cmd.Join(ch)
127128
}
128129
b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
129130
})
130131
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120 PingTimeout: 30 * time.Second,
121 SSL: false,
122 })
123
124 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
125 for _, ch := range b.channels {
126 cl.Cmd.Join(ch)
127 }
128 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
129 })
130
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120 PingTimeout: 30 * time.Second,
121 SSL: false,
122 })
123
124 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
125 cl.Cmd.Mode(cl.GetNick(), "+B")
126 for _, ch := range b.channels {
127 cl.Cmd.Join(ch)
128 }
129 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130 })
131
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
3434
type Message struct {
3535
At time.Time `json:"at"`
3636
Channel string `json:"channel"`
3737
Nick string `json:"nick"`
3838
Text string `json:"text"`
39
+ MsgID string `json:"msgid,omitempty"`
3940
Meta *Meta `json:"meta,omitempty"`
4041
}
4142
4243
// ringBuf is a fixed-capacity circular buffer of Messages.
4344
type ringBuf struct {
@@ -175,10 +176,11 @@
175176
PingTimeout: 30 * time.Second,
176177
SSL: false,
177178
})
178179
179180
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181
+ cl.Cmd.Mode(cl.GetNick(), "+B")
180182
// Check RELAYMSG support from ISUPPORT (RPL_005).
181183
if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
182184
b.relaySep = sep
183185
if b.log != nil {
184186
b.log.Info("bridge: RELAYMSG supported", "separator", sep)
@@ -234,15 +236,20 @@
234236
nick := e.Source.Name
235237
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
236238
nick = acct
237239
}
238240
241
+ var msgID string
242
+ if id, ok := e.Tags.Get("msgid"); ok {
243
+ msgID = id
244
+ }
239245
b.dispatch(Message{
240246
At: e.Timestamp,
241247
Channel: channel,
242248
Nick: nick,
243249
Text: e.Last(),
250
+ MsgID: msgID,
244251
})
245252
})
246253
247254
b.client = c
248255
249256
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
 
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
@@ -175,10 +176,11 @@
175 PingTimeout: 30 * time.Second,
176 SSL: false,
177 })
178
179 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
180 // Check RELAYMSG support from ISUPPORT (RPL_005).
181 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
182 b.relaySep = sep
183 if b.log != nil {
184 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
@@ -234,15 +236,20 @@
234 nick := e.Source.Name
235 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
236 nick = acct
237 }
238
 
 
 
 
239 b.dispatch(Message{
240 At: e.Timestamp,
241 Channel: channel,
242 Nick: nick,
243 Text: e.Last(),
 
244 })
245 })
246
247 b.client = c
248
249
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 MsgID string `json:"msgid,omitempty"`
40 Meta *Meta `json:"meta,omitempty"`
41 }
42
43 // ringBuf is a fixed-capacity circular buffer of Messages.
44 type ringBuf struct {
@@ -175,10 +176,11 @@
176 PingTimeout: 30 * time.Second,
177 SSL: false,
178 })
179
180 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181 cl.Cmd.Mode(cl.GetNick(), "+B")
182 // Check RELAYMSG support from ISUPPORT (RPL_005).
183 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
184 b.relaySep = sep
185 if b.log != nil {
186 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
@@ -234,15 +236,20 @@
236 nick := e.Source.Name
237 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
238 nick = acct
239 }
240
241 var msgID string
242 if id, ok := e.Tags.Get("msgid"); ok {
243 msgID = id
244 }
245 b.dispatch(Message{
246 At: e.Timestamp,
247 Channel: channel,
248 Nick: nick,
249 Text: e.Last(),
250 MsgID: msgID,
251 })
252 })
253
254 b.client = c
255
256
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151151
PingTimeout: 30 * time.Second,
152152
SSL: false,
153153
})
154154
155155
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156
+ cl.Cmd.Mode(cl.GetNick(), "+B")
156157
for _, ch := range b.channels {
157158
cl.Cmd.Join(ch)
158159
}
159160
if b.log != nil {
160161
b.log.Info("herald connected", "channels", b.channels)
161162
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151 PingTimeout: 30 * time.Second,
152 SSL: false,
153 })
154
155 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
156 for _, ch := range b.channels {
157 cl.Cmd.Join(ch)
158 }
159 if b.log != nil {
160 b.log.Info("herald connected", "channels", b.channels)
161
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151 PingTimeout: 30 * time.Second,
152 SSL: false,
153 })
154
155 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156 cl.Cmd.Mode(cl.GetNick(), "+B")
157 for _, ch := range b.channels {
158 cl.Cmd.Join(ch)
159 }
160 if b.log != nil {
161 b.log.Info("herald connected", "channels", b.channels)
162
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
2020
"strings"
2121
"sync"
2222
"time"
2323
2424
"github.com/lrstanley/girc"
25
+
26
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
2527
)
2628
2729
const (
2830
botNick = "oracle"
2931
defaultLimit = 50
@@ -124,10 +126,11 @@
124126
llm LLMProvider
125127
log *slog.Logger
126128
mu sync.Mutex
127129
lastReq map[string]time.Time // nick → last request time
128130
client *girc.Client
131
+ chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
129132
}
130133
131134
// New creates an oracle bot.
132135
func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
133136
return &Bot{
@@ -159,18 +162,26 @@
159162
Name: "scuttlebot oracle",
160163
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
161164
PingDelay: 30 * time.Second,
162165
PingTimeout: 30 * time.Second,
163166
SSL: false,
167
+ SupportedCaps: map[string][]string{
168
+ "draft/chathistory": nil,
169
+ "chathistory": nil,
170
+ },
164171
})
172
+
173
+ b.chFetch = chathistory.New(c)
165174
166175
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
176
+ cl.Cmd.Mode(cl.GetNick(), "+B")
167177
for _, ch := range b.channels {
168178
cl.Cmd.Join(ch)
169179
}
180
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
170181
if b.log != nil {
171
- b.log.Info("oracle connected", "channels", b.channels)
182
+ b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
172183
}
173184
})
174185
175186
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
176187
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +245,12 @@
234245
return
235246
}
236247
b.lastReq[nick] = time.Now()
237248
b.mu.Unlock()
238249
239
- // Fetch history.
240
- entries, err := b.history.Query(req.Channel, req.Limit)
250
+ // Fetch history — prefer CHATHISTORY if available, fall back to store.
251
+ entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
241252
if err != nil {
242253
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
243254
return
244255
}
245256
if len(entries) == 0 {
@@ -263,10 +274,39 @@
263274
if line != "" {
264275
cl.Cmd.Notice(nick, line)
265276
}
266277
}
267278
}
279
+
280
+func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
281
+ if b.chFetch != nil && b.client != nil {
282
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
283
+ if hasCH {
284
+ chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
285
+ defer cancel()
286
+ msgs, err := b.chFetch.Latest(chCtx, channel, limit)
287
+ if err == nil {
288
+ entries := make([]HistoryEntry, len(msgs))
289
+ for i, m := range msgs {
290
+ nick := m.Nick
291
+ if m.Account != "" {
292
+ nick = m.Account
293
+ }
294
+ entries[i] = HistoryEntry{
295
+ Nick: nick,
296
+ Raw: m.Text,
297
+ }
298
+ }
299
+ return entries, nil
300
+ }
301
+ if b.log != nil {
302
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
303
+ }
304
+ }
305
+ }
306
+ return b.history.Query(channel, limit)
307
+}
268308
269309
func buildPrompt(channel string, entries []HistoryEntry) string {
270310
var sb strings.Builder
271311
fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272312
fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273313
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
 
 
25 )
26
27 const (
28 botNick = "oracle"
29 defaultLimit = 50
@@ -124,10 +126,11 @@
124 llm LLMProvider
125 log *slog.Logger
126 mu sync.Mutex
127 lastReq map[string]time.Time // nick → last request time
128 client *girc.Client
 
129 }
130
131 // New creates an oracle bot.
132 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
133 return &Bot{
@@ -159,18 +162,26 @@
159 Name: "scuttlebot oracle",
160 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
161 PingDelay: 30 * time.Second,
162 PingTimeout: 30 * time.Second,
163 SSL: false,
 
 
 
 
164 })
 
 
165
166 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
167 for _, ch := range b.channels {
168 cl.Cmd.Join(ch)
169 }
 
170 if b.log != nil {
171 b.log.Info("oracle connected", "channels", b.channels)
172 }
173 })
174
175 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
176 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +245,12 @@
234 return
235 }
236 b.lastReq[nick] = time.Now()
237 b.mu.Unlock()
238
239 // Fetch history.
240 entries, err := b.history.Query(req.Channel, req.Limit)
241 if err != nil {
242 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
243 return
244 }
245 if len(entries) == 0 {
@@ -263,10 +274,39 @@
263 if line != "" {
264 cl.Cmd.Notice(nick, line)
265 }
266 }
267 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
269 func buildPrompt(channel string, entries []HistoryEntry) string {
270 var sb strings.Builder
271 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/pkg/chathistory"
27 )
28
29 const (
30 botNick = "oracle"
31 defaultLimit = 50
@@ -124,10 +126,11 @@
126 llm LLMProvider
127 log *slog.Logger
128 mu sync.Mutex
129 lastReq map[string]time.Time // nick → last request time
130 client *girc.Client
131 chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
132 }
133
134 // New creates an oracle bot.
135 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
136 return &Bot{
@@ -159,18 +162,26 @@
162 Name: "scuttlebot oracle",
163 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
164 PingDelay: 30 * time.Second,
165 PingTimeout: 30 * time.Second,
166 SSL: false,
167 SupportedCaps: map[string][]string{
168 "draft/chathistory": nil,
169 "chathistory": nil,
170 },
171 })
172
173 b.chFetch = chathistory.New(c)
174
175 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
176 cl.Cmd.Mode(cl.GetNick(), "+B")
177 for _, ch := range b.channels {
178 cl.Cmd.Join(ch)
179 }
180 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
181 if b.log != nil {
182 b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
183 }
184 })
185
186 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
187 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +245,12 @@
245 return
246 }
247 b.lastReq[nick] = time.Now()
248 b.mu.Unlock()
249
250 // Fetch history — prefer CHATHISTORY if available, fall back to store.
251 entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
252 if err != nil {
253 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
254 return
255 }
256 if len(entries) == 0 {
@@ -263,10 +274,39 @@
274 if line != "" {
275 cl.Cmd.Notice(nick, line)
276 }
277 }
278 }
279
280 func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
281 if b.chFetch != nil && b.client != nil {
282 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
283 if hasCH {
284 chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
285 defer cancel()
286 msgs, err := b.chFetch.Latest(chCtx, channel, limit)
287 if err == nil {
288 entries := make([]HistoryEntry, len(msgs))
289 for i, m := range msgs {
290 nick := m.Nick
291 if m.Account != "" {
292 nick = m.Account
293 }
294 entries[i] = HistoryEntry{
295 Nick: nick,
296 Raw: m.Text,
297 }
298 }
299 return entries, nil
300 }
301 if b.log != nil {
302 b.log.Warn("chathistory failed, falling back to store", "err", err)
303 }
304 }
305 }
306 return b.history.Query(channel, limit)
307 }
308
309 func buildPrompt(channel string, entries []HistoryEntry) string {
310 var sb strings.Builder
311 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
312 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
313
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
6464
PingTimeout: 30 * time.Second,
6565
SSL: false,
6666
})
6767
6868
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
69
+ client.Cmd.Mode(client.GetNick(), "+B")
6970
for _, ch := range b.channels {
7071
client.Cmd.Join(ch)
7172
}
7273
b.log.Info("scribe connected and joined channels", "channels", b.channels)
7374
})
7475
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
64 PingTimeout: 30 * time.Second,
65 SSL: false,
66 })
67
68 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
 
69 for _, ch := range b.channels {
70 client.Cmd.Join(ch)
71 }
72 b.log.Info("scribe connected and joined channels", "channels", b.channels)
73 })
74
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
64 PingTimeout: 30 * time.Second,
65 SSL: false,
66 })
67
68 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
69 client.Cmd.Mode(client.GetNick(), "+B")
70 for _, ch := range b.channels {
71 client.Cmd.Join(ch)
72 }
73 b.log.Info("scribe connected and joined channels", "channels", b.channels)
74 })
75
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
2121
"time"
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
26
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
2627
)
2728
2829
const (
2930
botNick = "scroll"
3031
defaultLimit = 50
@@ -38,11 +39,12 @@
3839
password string
3940
channels []string
4041
store scribe.Store
4142
log *slog.Logger
4243
client *girc.Client
43
- rateLimit sync.Map // nick → last request time
44
+ history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
45
+ rateLimit sync.Map // nick → last request time
4446
}
4547
4648
// New creates a scroll Bot backed by the given scribe Store.
4749
func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
4850
return &Bot{
@@ -72,17 +74,26 @@
7274
Name: "scuttlebot scroll",
7375
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
7476
PingDelay: 30 * time.Second,
7577
PingTimeout: 30 * time.Second,
7678
SSL: false,
79
+ SupportedCaps: map[string][]string{
80
+ "draft/chathistory": nil,
81
+ "chathistory": nil,
82
+ },
7783
})
84
+
85
+ // Register CHATHISTORY batch handlers before connecting.
86
+ b.history = chathistory.New(c)
7887
7988
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
89
+ cl.Cmd.Mode(cl.GetNick(), "+B")
8090
for _, ch := range b.channels {
8191
cl.Cmd.Join(ch)
8292
}
83
- b.log.Info("scroll connected", "channels", b.channels)
93
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
94
+ b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
8495
})
8596
8697
// Only respond to DMs — ignore anything in a channel.
8798
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
8899
if len(e.Params) < 1 {
@@ -133,11 +144,11 @@
133144
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134145
client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
135146
return
136147
}
137148
138
- entries, err := b.store.Query(req.Channel, req.Limit)
149
+ entries, err := b.fetchHistory(req)
139150
if err != nil {
140151
client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
141152
return
142153
}
143154
@@ -151,10 +162,40 @@
151162
line, _ := json.Marshal(e)
152163
client.Cmd.Notice(nick, string(line))
153164
}
154165
client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
155166
}
167
+
168
+// fetchHistory tries CHATHISTORY first, falls back to scribe store.
169
+func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
170
+ if b.history != nil && b.client != nil {
171
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
172
+ if hasCH {
173
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
174
+ defer cancel()
175
+ msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
176
+ if err == nil {
177
+ entries := make([]scribe.Entry, len(msgs))
178
+ for i, m := range msgs {
179
+ entries[i] = scribe.Entry{
180
+ At: m.At,
181
+ Channel: req.Channel,
182
+ Nick: m.Nick,
183
+ Kind: scribe.EntryKindRaw,
184
+ Raw: m.Text,
185
+ }
186
+ if m.Account != "" {
187
+ entries[i].Nick = m.Account
188
+ }
189
+ }
190
+ return entries, nil
191
+ }
192
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
193
+ }
194
+ }
195
+ return b.store.Query(req.Channel, req.Limit)
196
+}
156197
157198
func (b *Bot) checkRateLimit(nick string) bool {
158199
now := time.Now()
159200
if last, ok := b.rateLimit.Load(nick); ok {
160201
if now.Sub(last.(time.Time)) < rateLimitWindow {
161202
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
 
26 )
27
28 const (
29 botNick = "scroll"
30 defaultLimit = 50
@@ -38,11 +39,12 @@
38 password string
39 channels []string
40 store scribe.Store
41 log *slog.Logger
42 client *girc.Client
43 rateLimit sync.Map // nick → last request time
 
44 }
45
46 // New creates a scroll Bot backed by the given scribe Store.
47 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
48 return &Bot{
@@ -72,17 +74,26 @@
72 Name: "scuttlebot scroll",
73 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
74 PingDelay: 30 * time.Second,
75 PingTimeout: 30 * time.Second,
76 SSL: false,
 
 
 
 
77 })
 
 
 
78
79 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
 
80 for _, ch := range b.channels {
81 cl.Cmd.Join(ch)
82 }
83 b.log.Info("scroll connected", "channels", b.channels)
 
84 })
85
86 // Only respond to DMs — ignore anything in a channel.
87 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88 if len(e.Params) < 1 {
@@ -133,11 +144,11 @@
133 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
135 return
136 }
137
138 entries, err := b.store.Query(req.Channel, req.Limit)
139 if err != nil {
140 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
141 return
142 }
143
@@ -151,10 +162,40 @@
151 line, _ := json.Marshal(e)
152 client.Cmd.Notice(nick, string(line))
153 }
154 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
155 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
157 func (b *Bot) checkRateLimit(nick string) bool {
158 now := time.Now()
159 if last, ok := b.rateLimit.Load(nick); ok {
160 if now.Sub(last.(time.Time)) < rateLimitWindow {
161
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
26 "github.com/conflicthq/scuttlebot/pkg/chathistory"
27 )
28
29 const (
30 botNick = "scroll"
31 defaultLimit = 50
@@ -38,11 +39,12 @@
39 password string
40 channels []string
41 store scribe.Store
42 log *slog.Logger
43 client *girc.Client
44 history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
45 rateLimit sync.Map // nick → last request time
46 }
47
48 // New creates a scroll Bot backed by the given scribe Store.
49 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
50 return &Bot{
@@ -72,17 +74,26 @@
74 Name: "scuttlebot scroll",
75 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
76 PingDelay: 30 * time.Second,
77 PingTimeout: 30 * time.Second,
78 SSL: false,
79 SupportedCaps: map[string][]string{
80 "draft/chathistory": nil,
81 "chathistory": nil,
82 },
83 })
84
85 // Register CHATHISTORY batch handlers before connecting.
86 b.history = chathistory.New(c)
87
88 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
89 cl.Cmd.Mode(cl.GetNick(), "+B")
90 for _, ch := range b.channels {
91 cl.Cmd.Join(ch)
92 }
93 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
94 b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
95 })
96
97 // Only respond to DMs — ignore anything in a channel.
98 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
99 if len(e.Params) < 1 {
@@ -133,11 +144,11 @@
144 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
145 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
146 return
147 }
148
149 entries, err := b.fetchHistory(req)
150 if err != nil {
151 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
152 return
153 }
154
@@ -151,10 +162,40 @@
162 line, _ := json.Marshal(e)
163 client.Cmd.Notice(nick, string(line))
164 }
165 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
166 }
167
168 // fetchHistory tries CHATHISTORY first, falls back to scribe store.
169 func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
170 if b.history != nil && b.client != nil {
171 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
172 if hasCH {
173 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
174 defer cancel()
175 msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
176 if err == nil {
177 entries := make([]scribe.Entry, len(msgs))
178 for i, m := range msgs {
179 entries[i] = scribe.Entry{
180 At: m.At,
181 Channel: req.Channel,
182 Nick: m.Nick,
183 Kind: scribe.EntryKindRaw,
184 Raw: m.Text,
185 }
186 if m.Account != "" {
187 entries[i].Nick = m.Account
188 }
189 }
190 return entries, nil
191 }
192 b.log.Warn("chathistory failed, falling back to store", "err", err)
193 }
194 }
195 return b.store.Query(req.Channel, req.Limit)
196 }
197
198 func (b *Bot) checkRateLimit(nick string) bool {
199 now := time.Now()
200 if last, ok := b.rateLimit.Load(nick); ok {
201 if now.Sub(last.(time.Time)) < rateLimitWindow {
202
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146146
PingDelay: 30 * time.Second,
147147
PingTimeout: 30 * time.Second,
148148
})
149149
150150
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
151
+ cl.Cmd.Mode(cl.GetNick(), "+B")
151152
for _, ch := range b.cfg.Channels {
152153
cl.Cmd.Join(ch)
153154
}
154155
cl.Cmd.Join(b.cfg.ModChannel)
155156
if b.log != nil {
156157
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146 PingDelay: 30 * time.Second,
147 PingTimeout: 30 * time.Second,
148 })
149
150 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
151 for _, ch := range b.cfg.Channels {
152 cl.Cmd.Join(ch)
153 }
154 cl.Cmd.Join(b.cfg.ModChannel)
155 if b.log != nil {
156
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146 PingDelay: 30 * time.Second,
147 PingTimeout: 30 * time.Second,
148 })
149
150 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
151 cl.Cmd.Mode(cl.GetNick(), "+B")
152 for _, ch := range b.cfg.Channels {
153 cl.Cmd.Join(ch)
154 }
155 cl.Cmd.Join(b.cfg.ModChannel)
156 if b.log != nil {
157
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
4848
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
4949
JoinPartWindow time.Duration
5050
5151
// Channels is the list of channels to join on connect.
5252
Channels []string
53
+
54
+ // MonitorNicks is the list of nicks to track via IRC MONITOR.
55
+ // Snitch will alert when a monitored nick goes offline unexpectedly.
56
+ MonitorNicks []string
5357
}
5458
5559
func (c *Config) setDefaults() {
5660
if c.Nick == "" {
5761
c.Nick = defaultNick
@@ -135,18 +139,45 @@
135139
PingDelay: 30 * time.Second,
136140
PingTimeout: 30 * time.Second,
137141
})
138142
139143
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
144
+ cl.Cmd.Mode(cl.GetNick(), "+B")
140145
for _, ch := range b.cfg.Channels {
141146
cl.Cmd.Join(ch)
142147
}
143148
if b.cfg.AlertChannel != "" {
144149
cl.Cmd.Join(b.cfg.AlertChannel)
145150
}
151
+ if len(b.cfg.MonitorNicks) > 0 {
152
+ cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
153
+ }
146154
if b.log != nil {
147
- b.log.Info("snitch connected", "channels", b.cfg.Channels)
155
+ b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
156
+ }
157
+ })
158
+
159
+ // away-notify: track agents going idle or returning.
160
+ c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
161
+ if e.Source == nil {
162
+ return
163
+ }
164
+ nick := e.Source.Name
165
+ reason := e.Last()
166
+ if reason != "" {
167
+ b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
168
+ }
169
+ })
170
+
171
+ c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
172
+ nicks := e.Last()
173
+ for _, nick := range strings.Split(nicks, ",") {
174
+ nick = strings.TrimSpace(nick)
175
+ if nick == "" {
176
+ continue
177
+ }
178
+ b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
148179
}
149180
})
150181
151182
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
152183
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
202233
func (b *Bot) JoinChannel(channel string) {
203234
if b.client != nil {
204235
b.client.Cmd.Join(channel)
205236
}
206237
}
238
+
239
+// MonitorAdd adds nicks to the MONITOR list at runtime.
240
+func (b *Bot) MonitorAdd(nicks ...string) {
241
+ if b.client != nil && len(nicks) > 0 {
242
+ b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
243
+ }
244
+}
245
+
246
+// MonitorRemove removes nicks from the MONITOR list at runtime.
247
+func (b *Bot) MonitorRemove(nicks ...string) {
248
+ if b.client != nil && len(nicks) > 0 {
249
+ b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
250
+ }
251
+}
207252
208253
func (b *Bot) window(channel, nick string) *nickWindow {
209254
if b.windows[channel] == nil {
210255
b.windows[channel] = make(map[string]*nickWindow)
211256
}
212257
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
50
51 // Channels is the list of channels to join on connect.
52 Channels []string
 
 
 
 
53 }
54
55 func (c *Config) setDefaults() {
56 if c.Nick == "" {
57 c.Nick = defaultNick
@@ -135,18 +139,45 @@
135 PingDelay: 30 * time.Second,
136 PingTimeout: 30 * time.Second,
137 })
138
139 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
140 for _, ch := range b.cfg.Channels {
141 cl.Cmd.Join(ch)
142 }
143 if b.cfg.AlertChannel != "" {
144 cl.Cmd.Join(b.cfg.AlertChannel)
145 }
 
 
 
146 if b.log != nil {
147 b.log.Info("snitch connected", "channels", b.cfg.Channels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148 }
149 })
150
151 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
152 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
202 func (b *Bot) JoinChannel(channel string) {
203 if b.client != nil {
204 b.client.Cmd.Join(channel)
205 }
206 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
208 func (b *Bot) window(channel, nick string) *nickWindow {
209 if b.windows[channel] == nil {
210 b.windows[channel] = make(map[string]*nickWindow)
211 }
212
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
50
51 // Channels is the list of channels to join on connect.
52 Channels []string
53
54 // MonitorNicks is the list of nicks to track via IRC MONITOR.
55 // Snitch will alert when a monitored nick goes offline unexpectedly.
56 MonitorNicks []string
57 }
58
59 func (c *Config) setDefaults() {
60 if c.Nick == "" {
61 c.Nick = defaultNick
@@ -135,18 +139,45 @@
139 PingDelay: 30 * time.Second,
140 PingTimeout: 30 * time.Second,
141 })
142
143 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
144 cl.Cmd.Mode(cl.GetNick(), "+B")
145 for _, ch := range b.cfg.Channels {
146 cl.Cmd.Join(ch)
147 }
148 if b.cfg.AlertChannel != "" {
149 cl.Cmd.Join(b.cfg.AlertChannel)
150 }
151 if len(b.cfg.MonitorNicks) > 0 {
152 cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
153 }
154 if b.log != nil {
155 b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
156 }
157 })
158
159 // away-notify: track agents going idle or returning.
160 c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
161 if e.Source == nil {
162 return
163 }
164 nick := e.Source.Name
165 reason := e.Last()
166 if reason != "" {
167 b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
168 }
169 })
170
171 c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
172 nicks := e.Last()
173 for _, nick := range strings.Split(nicks, ",") {
174 nick = strings.TrimSpace(nick)
175 if nick == "" {
176 continue
177 }
178 b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
179 }
180 })
181
182 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
183 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
233 func (b *Bot) JoinChannel(channel string) {
234 if b.client != nil {
235 b.client.Cmd.Join(channel)
236 }
237 }
238
239 // MonitorAdd adds nicks to the MONITOR list at runtime.
240 func (b *Bot) MonitorAdd(nicks ...string) {
241 if b.client != nil && len(nicks) > 0 {
242 b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
243 }
244 }
245
246 // MonitorRemove removes nicks from the MONITOR list at runtime.
247 func (b *Bot) MonitorRemove(nicks ...string) {
248 if b.client != nil && len(nicks) > 0 {
249 b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
250 }
251 }
252
253 func (b *Bot) window(channel, nick string) *nickWindow {
254 if b.windows[channel] == nil {
255 b.windows[channel] = make(map[string]*nickWindow)
256 }
257
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130130
PingDelay: 30 * time.Second,
131131
PingTimeout: 30 * time.Second,
132132
})
133133
134134
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
135
+ cl.Cmd.Mode(cl.GetNick(), "+B")
135136
for _, ch := range b.cfg.Channels {
136137
cl.Cmd.Join(ch)
137138
}
138139
cl.Cmd.Join(b.cfg.ModChannel)
139140
if b.log != nil {
140141
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130 PingDelay: 30 * time.Second,
131 PingTimeout: 30 * time.Second,
132 })
133
134 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
135 for _, ch := range b.cfg.Channels {
136 cl.Cmd.Join(ch)
137 }
138 cl.Cmd.Join(b.cfg.ModChannel)
139 if b.log != nil {
140
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130 PingDelay: 30 * time.Second,
131 PingTimeout: 30 * time.Second,
132 })
133
134 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
135 cl.Cmd.Mode(cl.GetNick(), "+B")
136 for _, ch := range b.cfg.Channels {
137 cl.Cmd.Join(ch)
138 }
139 cl.Cmd.Join(b.cfg.ModChannel)
140 if b.log != nil {
141
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
9191
PingTimeout: 30 * time.Second,
9292
SSL: false,
9393
})
9494
9595
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
96
+ cl.Cmd.Mode(cl.GetNick(), "+B")
9697
for _, ch := range b.channels {
9798
cl.Cmd.Join(ch)
9899
}
99100
b.log.Info("systembot connected", "channels", b.channels)
100101
})
101102
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
91 PingTimeout: 30 * time.Second,
92 SSL: false,
93 })
94
95 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
96 for _, ch := range b.channels {
97 cl.Cmd.Join(ch)
98 }
99 b.log.Info("systembot connected", "channels", b.channels)
100 })
101
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
91 PingTimeout: 30 * time.Second,
92 SSL: false,
93 })
94
95 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
96 cl.Cmd.Mode(cl.GetNick(), "+B")
97 for _, ch := range b.channels {
98 cl.Cmd.Join(ch)
99 }
100 b.log.Info("systembot connected", "channels", b.channels)
101 })
102
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199199
PingTimeout: 30 * time.Second,
200200
SSL: false,
201201
})
202202
203203
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
204
+ cl.Cmd.Mode(cl.GetNick(), "+B")
204205
for _, ch := range b.initChannels {
205206
cl.Cmd.Join(ch)
206207
}
207208
for ch := range b.channelConfigs {
208209
cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
309310
switch action {
310311
case ActionWarn:
311312
cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
312313
case ActionMute:
313314
cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
314
- cl.Cmd.Mode(channel, "+q", nick)
315
+ // Use extended ban m: to mute — agent stays in channel but cannot speak.
316
+ mask := "m:" + nick + "!*@*"
317
+ cl.Cmd.Mode(channel, "+b", mask)
318
+ // Remove mute after cooldown so the agent can recover.
319
+ cs := b.channelStateFor(channel)
320
+ go func() {
321
+ time.Sleep(cs.cfg.CoolDown)
322
+ cl.Cmd.Mode(channel, "-b", mask)
323
+ }()
315324
case ActionKick:
316325
cl.Cmd.Kick(channel, nick, "warden: "+reason)
317326
}
318327
}
319328
320329
321330
ADDED pkg/chathistory/chathistory.go
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199 PingTimeout: 30 * time.Second,
200 SSL: false,
201 })
202
203 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
204 for _, ch := range b.initChannels {
205 cl.Cmd.Join(ch)
206 }
207 for ch := range b.channelConfigs {
208 cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
309 switch action {
310 case ActionWarn:
311 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
312 case ActionMute:
313 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
314 cl.Cmd.Mode(channel, "+q", nick)
 
 
 
 
 
 
 
 
315 case ActionKick:
316 cl.Cmd.Kick(channel, nick, "warden: "+reason)
317 }
318 }
319
320
321 DDED pkg/chathistory/chathistory.go
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199 PingTimeout: 30 * time.Second,
200 SSL: false,
201 })
202
203 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
204 cl.Cmd.Mode(cl.GetNick(), "+B")
205 for _, ch := range b.initChannels {
206 cl.Cmd.Join(ch)
207 }
208 for ch := range b.channelConfigs {
209 cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
310 switch action {
311 case ActionWarn:
312 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
313 case ActionMute:
314 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
315 // Use extended ban m: to mute — agent stays in channel but cannot speak.
316 mask := "m:" + nick + "!*@*"
317 cl.Cmd.Mode(channel, "+b", mask)
318 // Remove mute after cooldown so the agent can recover.
319 cs := b.channelStateFor(channel)
320 go func() {
321 time.Sleep(cs.cfg.CoolDown)
322 cl.Cmd.Mode(channel, "-b", mask)
323 }()
324 case ActionKick:
325 cl.Cmd.Kick(channel, nick, "warden: "+reason)
326 }
327 }
328
329
330 DDED pkg/chathistory/chathistory.go
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1
+// Package chathistory provides a synchronous wrapper around the IRCv3
2
+// CHATHISTORY extension for use with girc clients.
3
+//
4
+// Usage:
5
+//
6
+// fetcher := chathistory.New(client)
7
+// msgs, err := fetcher.Latest(ctx, "#channel", 50)
8
+package chathistory
9
+
10
+import (
11
+ "context"
12
+ "fmt"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "github.com/lrstanley/girc"
18
+)
19
+
20
+// Message is a single message returned by a CHATHISTORY query.
21
+type Message struct {
22
+ At time.Time
23
+ Nick string
24
+ Account string
25
+ Text string
26
+ MsgID string
27
+}
28
+
29
+// Fetcher sends CHATHISTORY commands and collects the batched responses.
30
+type Fetcher struct {
31
+ client *girc.Client
32
+
33
+ mu sync.Mutex
34
+ batches map[string]*batch // batchRef → accumulator
35
+ waiters map[string]chan []Message // channel → result (one waiter per channel)
36
+ handlers bool
37
+}
38
+
39
+type batch struct {
40
+ channel string
41
+ msgs []Message
42
+}
43
+
44
+// New creates a Fetcher and registers the necessary BATCH handlers on the
45
+// client. The client's Config.SupportedCaps should include
46
+// "draft/chathistory" (or "chathistory") so the capability is negotiated.
47
+func New(client *girc.Client) *Fetcher {
48
+ f := &Fetcher{
49
+ client: client,
50
+ batches: make(map[string]*batch),
51
+ waiters: make(map[string]chan []Message),
52
+ }
53
+ f.registerHandlers()
54
+ return f
55
+}
56
+
57
+func (f *Fetcher) registerHandlers() {
58
+ f.mu.Lock()
59
+ defer f.mu.Unlock()
60
+ if f.handlers {
61
+ return
62
+ }
63
+ f.handlers = true
64
+
65
+ // BATCH open/close.
66
+ f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67
+ if len(e.Params) < 1 {
68
+ return
69
+ }
70
+ raw := e.Params[0]
71
+ if strings.HasPrefix(raw, "+") {
72
+ ref := raw[1:]
73
+ if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74
+ ch := ""
75
+ if len(e.Params) >= 3 {
76
+ ch = e.Params[2]
77
+ }
78
+ f.mu.Lock()
79
+ f.batches[ref] = &batch{channel: ch}
80
+ f.mu.Unlock()
81
+ }
82
+ } else if strings.HasPrefix(raw, "-") {
83
+ ref := raw[1:]
84
+ f.mu.Lock()
85
+ b, ok := f.batches[ref]
86
+ if ok {
87
+ delete(f.batches, ref)
88
+ if w, wok := f.waiters[b.channel]; wok {
89
+ delete(f.waiters, b.channel)
90
+ f.mu.Unlock()
91
+ w <- b.msgs
92
+ return
93
+ }
94
+ }
95
+ f.mu.Unlock()
96
+ }
97
+ })
98
+
99
+ // Collect PRIVMSGs tagged with a tracked batch ref.
100
+ f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101
+ batchRef, ok := e.Tags.Get("batch")
102
+ if !ok || batchRef == "" {
103
+ return
104
+ }
105
+
106
+ f.mu.Lock()
107
+ b, tracked := f.batches[batchRef]
108
+ if !tracked {
109
+ f.mu.Unlock()
110
+ return
111
+ }
112
+
113
+ nick := ""
114
+ if e.Source != nil {
115
+ nick = e.Source.Name
116
+ }
117
+ acct, _ := e.Tags.Get("account")
118
+ msgID, _ := e.Tags.Get("msgid")
119
+
120
+ b.msgs = append(b.msgs, Message{
121
+ At: e.Timestamp,
122
+ Nick: nick,
123
+ Account: acct,
124
+ Text: e.Last(),
125
+ MsgID: msgID,
126
+ })
127
+ f.mu.Unlock()
128
+ })
129
+}
130
+
131
+// Latest fetches the N most recent messages from a channel using
132
+// CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133
+func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134
+ result := make(chan []Message, 1)
135
+
136
+ f.mu.Lock()
137
+ f.waiters[channel] = result
138
+ f.mu.Unlock()
139
+
140
+ if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141
+ f.mu.Lock()
142
+ delete(f.waiters, channel)
143
+ f.mu.Unlock()
144
+ return nil, fmt.Errorf("chathistory: send: %w", err)
145
+ }
146
+
147
+ select {
148
+ case msgs := <-result:
149
+ return msgs, nil
150
+ case <-ctx.Done():
151
+ f.mu.Lock()
152
+ delete(f.waiters, channel)
153
+ f.mu.Unlock()
154
+ return nil, ctx.Err()
155
+ }
156
+}
157
+
158
+// Before fetches up to count messages before the given timestamp.
159
+func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160
+ result := make(chan []Message, 1)
161
+
162
+ f.mu.Lock()
163
+ f.waiters[channel] = result
164
+ f.mu.Unlock()
165
+
166
+ ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167
+ if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168
+ f.mu.Lock()
169
+ delete(f.waiters, channel)
170
+ f.mu.Unlock()
171
+ return nil, fmt.Errorf("chathistory: send: %w", err)
172
+ }
173
+
174
+ select {
175
+ case msgs := <-result:
176
+ return msgs, nil
177
+ case <-ctx.Done():
178
+ f.mu.Lock()
179
+ delete(f.waiters, channel)
180
+ f.mu.Unlock()
181
+ return nil, ctx.Err()
182
+ }
183
+}
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1 // Package chathistory provides a synchronous wrapper around the IRCv3
2 // CHATHISTORY extension for use with girc clients.
3 //
4 // Usage:
5 //
6 // fetcher := chathistory.New(client)
7 // msgs, err := fetcher.Latest(ctx, "#channel", 50)
8 package chathistory
9
10 import (
11 "context"
12 "fmt"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/lrstanley/girc"
18 )
19
20 // Message is a single message returned by a CHATHISTORY query.
21 type Message struct {
22 At time.Time
23 Nick string
24 Account string
25 Text string
26 MsgID string
27 }
28
29 // Fetcher sends CHATHISTORY commands and collects the batched responses.
30 type Fetcher struct {
31 client *girc.Client
32
33 mu sync.Mutex
34 batches map[string]*batch // batchRef → accumulator
35 waiters map[string]chan []Message // channel → result (one waiter per channel)
36 handlers bool
37 }
38
39 type batch struct {
40 channel string
41 msgs []Message
42 }
43
44 // New creates a Fetcher and registers the necessary BATCH handlers on the
45 // client. The client's Config.SupportedCaps should include
46 // "draft/chathistory" (or "chathistory") so the capability is negotiated.
47 func New(client *girc.Client) *Fetcher {
48 f := &Fetcher{
49 client: client,
50 batches: make(map[string]*batch),
51 waiters: make(map[string]chan []Message),
52 }
53 f.registerHandlers()
54 return f
55 }
56
57 func (f *Fetcher) registerHandlers() {
58 f.mu.Lock()
59 defer f.mu.Unlock()
60 if f.handlers {
61 return
62 }
63 f.handlers = true
64
65 // BATCH open/close.
66 f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67 if len(e.Params) < 1 {
68 return
69 }
70 raw := e.Params[0]
71 if strings.HasPrefix(raw, "+") {
72 ref := raw[1:]
73 if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74 ch := ""
75 if len(e.Params) >= 3 {
76 ch = e.Params[2]
77 }
78 f.mu.Lock()
79 f.batches[ref] = &batch{channel: ch}
80 f.mu.Unlock()
81 }
82 } else if strings.HasPrefix(raw, "-") {
83 ref := raw[1:]
84 f.mu.Lock()
85 b, ok := f.batches[ref]
86 if ok {
87 delete(f.batches, ref)
88 if w, wok := f.waiters[b.channel]; wok {
89 delete(f.waiters, b.channel)
90 f.mu.Unlock()
91 w <- b.msgs
92 return
93 }
94 }
95 f.mu.Unlock()
96 }
97 })
98
99 // Collect PRIVMSGs tagged with a tracked batch ref.
100 f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101 batchRef, ok := e.Tags.Get("batch")
102 if !ok || batchRef == "" {
103 return
104 }
105
106 f.mu.Lock()
107 b, tracked := f.batches[batchRef]
108 if !tracked {
109 f.mu.Unlock()
110 return
111 }
112
113 nick := ""
114 if e.Source != nil {
115 nick = e.Source.Name
116 }
117 acct, _ := e.Tags.Get("account")
118 msgID, _ := e.Tags.Get("msgid")
119
120 b.msgs = append(b.msgs, Message{
121 At: e.Timestamp,
122 Nick: nick,
123 Account: acct,
124 Text: e.Last(),
125 MsgID: msgID,
126 })
127 f.mu.Unlock()
128 })
129 }
130
131 // Latest fetches the N most recent messages from a channel using
132 // CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133 func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134 result := make(chan []Message, 1)
135
136 f.mu.Lock()
137 f.waiters[channel] = result
138 f.mu.Unlock()
139
140 if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141 f.mu.Lock()
142 delete(f.waiters, channel)
143 f.mu.Unlock()
144 return nil, fmt.Errorf("chathistory: send: %w", err)
145 }
146
147 select {
148 case msgs := <-result:
149 return msgs, nil
150 case <-ctx.Done():
151 f.mu.Lock()
152 delete(f.waiters, channel)
153 f.mu.Unlock()
154 return nil, ctx.Err()
155 }
156 }
157
158 // Before fetches up to count messages before the given timestamp.
159 func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160 result := make(chan []Message, 1)
161
162 f.mu.Lock()
163 f.waiters[channel] = result
164 f.mu.Unlock()
165
166 ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167 if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168 f.mu.Lock()
169 delete(f.waiters, channel)
170 f.mu.Unlock()
171 return nil, fmt.Errorf("chathistory: send: %w", err)
172 }
173
174 select {
175 case msgs := <-result:
176 return msgs, nil
177 case <-ctx.Done():
178 f.mu.Lock()
179 delete(f.waiters, channel)
180 f.mu.Unlock()
181 return nil, ctx.Err()
182 }
183 }
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -134,11 +134,15 @@
134134
}
135135
target := normalizeChannel(e.Params[0])
136136
if !c.hasChannel(target) {
137137
return
138138
}
139
+ // Prefer account-tag (IRCv3) over source nick.
139140
sender := e.Source.Name
141
+ if acct, ok := e.Tags.Get("account"); ok && acct != "" {
142
+ sender = acct
143
+ }
140144
text := strings.TrimSpace(e.Last())
141145
// RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
142146
if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
143147
if idx := strings.Index(sender, sep); idx != -1 {
144148
sender = sender[:idx]
@@ -149,11 +153,20 @@
149153
if end := strings.Index(text, "] "); end != -1 {
150154
sender = text[1:end]
151155
text = strings.TrimSpace(text[end+2:])
152156
}
153157
}
154
- c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
158
+ // Use server-time when available; fall back to local clock.
159
+ at := e.Timestamp
160
+ if at.IsZero() {
161
+ at = time.Now()
162
+ }
163
+ var msgID string
164
+ if id, ok := e.Tags.Get("msgid"); ok {
165
+ msgID = id
166
+ }
167
+ c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
155168
})
156169
157170
c.mu.Lock()
158171
c.client = client
159172
c.mu.Unlock()
160173
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -134,11 +134,15 @@
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
 
139 sender := e.Source.Name
 
 
 
140 text := strings.TrimSpace(e.Last())
141 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
142 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
143 if idx := strings.Index(sender, sep); idx != -1 {
144 sender = sender[:idx]
@@ -149,11 +153,20 @@
149 if end := strings.Index(text, "] "); end != -1 {
150 sender = text[1:end]
151 text = strings.TrimSpace(text[end+2:])
152 }
153 }
154 c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
 
 
 
 
 
 
 
 
 
155 })
156
157 c.mu.Lock()
158 c.client = client
159 c.mu.Unlock()
160
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -134,11 +134,15 @@
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
139 // Prefer account-tag (IRCv3) over source nick.
140 sender := e.Source.Name
141 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
142 sender = acct
143 }
144 text := strings.TrimSpace(e.Last())
145 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
146 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
147 if idx := strings.Index(sender, sep); idx != -1 {
148 sender = sender[:idx]
@@ -149,11 +153,20 @@
153 if end := strings.Index(text, "] "); end != -1 {
154 sender = text[1:end]
155 text = strings.TrimSpace(text[end+2:])
156 }
157 }
158 // Use server-time when available; fall back to local clock.
159 at := e.Timestamp
160 if at.IsZero() {
161 at = time.Now()
162 }
163 var msgID string
164 if id, ok := e.Tags.Get("msgid"); ok {
165 msgID = id
166 }
167 c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
168 })
169
170 c.mu.Lock()
171 c.client = client
172 c.mu.Unlock()
173
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -42,10 +42,11 @@
4242
type Message struct {
4343
At time.Time
4444
Channel string
4545
Nick string
4646
Text string
47
+ MsgID string
4748
}
4849
4950
type Connector interface {
5051
Connect(ctx context.Context) error
5152
Post(ctx context.Context, text string) error
5253
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -42,10 +42,11 @@
42 type Message struct {
43 At time.Time
44 Channel string
45 Nick string
46 Text string
 
47 }
48
49 type Connector interface {
50 Connect(ctx context.Context) error
51 Post(ctx context.Context, text string) error
52
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -42,10 +42,11 @@
42 type Message struct {
43 At time.Time
44 Channel string
45 Nick string
46 Text string
47 MsgID string
48 }
49
50 type Connector interface {
51 Connect(ctx context.Context) error
52 Post(ctx context.Context, text string) error
53

Keyboard Shortcuts

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