ScuttleBot

feat: CHATHISTORY support for scroll and oracle (#106) Add pkg/chathistory — synchronous wrapper around IRCv3 CHATHISTORY BATCH protocol for girc clients. Scroll and oracle now negotiate draft/chathistory cap, try CHATHISTORY LATEST first for recent messages, and fall back to scribe store. Scribe remains the long-term archival path.

lmata 2026-04-05 04:02 trunk
Commit 1f324c2904df4b227e45fb6500dcd61517a4da3ff3cd0951e60b146d8d5ace23
--- 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,19 +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) {
167176
cl.Cmd.Mode(cl.GetNick(), "+B")
168177
for _, ch := range b.channels {
169178
cl.Cmd.Join(ch)
170179
}
180
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
171181
if b.log != nil {
172
- b.log.Info("oracle connected", "channels", b.channels)
182
+ b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
173183
}
174184
})
175185
176186
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
177187
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -235,12 +245,12 @@
235245
return
236246
}
237247
b.lastReq[nick] = time.Now()
238248
b.mu.Unlock()
239249
240
- // Fetch history.
241
- 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)
242252
if err != nil {
243253
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
244254
return
245255
}
246256
if len(entries) == 0 {
@@ -264,10 +274,39 @@
264274
if line != "" {
265275
cl.Cmd.Notice(nick, line)
266276
}
267277
}
268278
}
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
+}
269308
270309
func buildPrompt(channel string, entries []HistoryEntry) string {
271310
var sb strings.Builder
272311
fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
273312
fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
274313
--- 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,19 +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 cl.Cmd.Mode(cl.GetNick(), "+B")
168 for _, ch := range b.channels {
169 cl.Cmd.Join(ch)
170 }
 
171 if b.log != nil {
172 b.log.Info("oracle connected", "channels", b.channels)
173 }
174 })
175
176 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
177 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -235,12 +245,12 @@
235 return
236 }
237 b.lastReq[nick] = time.Now()
238 b.mu.Unlock()
239
240 // Fetch history.
241 entries, err := b.history.Query(req.Channel, req.Limit)
242 if err != nil {
243 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
244 return
245 }
246 if len(entries) == 0 {
@@ -264,10 +274,39 @@
264 if line != "" {
265 cl.Cmd.Notice(nick, line)
266 }
267 }
268 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
270 func buildPrompt(channel string, entries []HistoryEntry) string {
271 var sb strings.Builder
272 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
273 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
274
--- 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,19 +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, "#") {
@@ -235,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 {
@@ -264,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/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,18 +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) {
8089
cl.Cmd.Mode(cl.GetNick(), "+B")
8190
for _, ch := range b.channels {
8291
cl.Cmd.Join(ch)
8392
}
84
- 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)
8595
})
8696
8797
// Only respond to DMs — ignore anything in a channel.
8898
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
8999
if len(e.Params) < 1 {
@@ -134,11 +144,11 @@
134144
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
135145
client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
136146
return
137147
}
138148
139
- entries, err := b.store.Query(req.Channel, req.Limit)
149
+ entries, err := b.fetchHistory(req)
140150
if err != nil {
141151
client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
142152
return
143153
}
144154
@@ -152,10 +162,40 @@
152162
line, _ := json.Marshal(e)
153163
client.Cmd.Notice(nick, string(line))
154164
}
155165
client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
156166
}
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
+}
157197
158198
func (b *Bot) checkRateLimit(nick string) bool {
159199
now := time.Now()
160200
if last, ok := b.rateLimit.Load(nick); ok {
161201
if now.Sub(last.(time.Time)) < rateLimitWindow {
162202
163203
ADDED pkg/chathistory/chathistory.go
--- 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,18 +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 cl.Cmd.Mode(cl.GetNick(), "+B")
81 for _, ch := range b.channels {
82 cl.Cmd.Join(ch)
83 }
84 b.log.Info("scroll connected", "channels", b.channels)
 
85 })
86
87 // Only respond to DMs — ignore anything in a channel.
88 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
89 if len(e.Params) < 1 {
@@ -134,11 +144,11 @@
134 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
135 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
136 return
137 }
138
139 entries, err := b.store.Query(req.Channel, req.Limit)
140 if err != nil {
141 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
142 return
143 }
144
@@ -152,10 +162,40 @@
152 line, _ := json.Marshal(e)
153 client.Cmd.Notice(nick, string(line))
154 }
155 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
156 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
158 func (b *Bot) checkRateLimit(nick string) bool {
159 now := time.Now()
160 if last, ok := b.rateLimit.Load(nick); ok {
161 if now.Sub(last.(time.Time)) < rateLimitWindow {
162
163 DDED pkg/chathistory/chathistory.go
--- 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,18 +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 {
@@ -134,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
@@ -152,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
203 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 }

Keyboard Shortcuts

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