ScuttleBot

scuttlebot / pkg / client / discovery.go
Blame History Raw 390 lines
1
package client
2
3
import (
4
"context"
5
"fmt"
6
"strings"
7
"sync"
8
"time"
9
10
"github.com/lrstanley/girc"
11
)
12
13
// DiscoveryOptions configures the caching behaviour for discovery calls.
14
type DiscoveryOptions struct {
15
// CacheTTL is how long results are cached before re-querying Ergo.
16
// Default: 30s. Set to 0 to disable caching.
17
CacheTTL time.Duration
18
}
19
20
// ChannelSummary is a channel returned by ListChannels.
21
type ChannelSummary struct {
22
Name string
23
MemberCount int
24
Topic string
25
}
26
27
// Member is an entry in the channel member list.
28
type Member struct {
29
Nick string
30
IsOp bool
31
IsVoice bool
32
}
33
34
// TopicInfo is the result of GetTopic.
35
type TopicInfo struct {
36
Channel string
37
Topic string
38
SetBy string
39
SetAt time.Time
40
}
41
42
// WhoIsInfo is the result of WhoIs.
43
type WhoIsInfo struct {
44
Nick string
45
User string
46
Host string
47
RealName string
48
Channels []string
49
Account string // NickServ account name, if identified
50
}
51
52
// discoveryCacheEntry wraps a result with an expiry.
53
type discoveryCacheEntry struct {
54
value any
55
expiry time.Time
56
}
57
58
// discoveryCache is a simple TTL cache keyed by string.
59
type discoveryCache struct {
60
mu sync.Mutex
61
entries map[string]discoveryCacheEntry
62
ttl time.Duration
63
}
64
65
func newDiscoveryCache(ttl time.Duration) *discoveryCache {
66
return &discoveryCache{entries: make(map[string]discoveryCacheEntry), ttl: ttl}
67
}
68
69
func (c *discoveryCache) get(key string) (any, bool) {
70
if c.ttl == 0 {
71
return nil, false
72
}
73
c.mu.Lock()
74
defer c.mu.Unlock()
75
e, ok := c.entries[key]
76
if !ok || time.Now().After(e.expiry) {
77
return nil, false
78
}
79
return e.value, true
80
}
81
82
func (c *discoveryCache) set(key string, value any) {
83
if c.ttl == 0 {
84
return
85
}
86
c.mu.Lock()
87
defer c.mu.Unlock()
88
c.entries[key] = discoveryCacheEntry{value: value, expiry: time.Now().Add(c.ttl)}
89
}
90
91
// Discovery wraps a connected Client with typed IRC discovery methods.
92
// Results are cached to avoid flooding Ergo.
93
//
94
// Typical usage:
95
//
96
// d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 30 * time.Second})
97
// channels, err := d.ListChannels(ctx)
98
type Discovery struct {
99
client *Client
100
cache *discoveryCache
101
}
102
103
// NewDiscovery creates a Discovery using the given (connected) Client.
104
func NewDiscovery(c *Client, opts DiscoveryOptions) *Discovery {
105
ttl := opts.CacheTTL
106
if ttl == 0 {
107
ttl = 30 * time.Second
108
}
109
return &Discovery{client: c, cache: newDiscoveryCache(ttl)}
110
}
111
112
// ListChannels returns all public channels on the server.
113
func (d *Discovery) ListChannels(ctx context.Context) ([]ChannelSummary, error) {
114
const cacheKey = "list_channels"
115
if v, ok := d.cache.get(cacheKey); ok {
116
return v.([]ChannelSummary), nil
117
}
118
119
irc := d.ircClient()
120
if irc == nil {
121
return nil, fmt.Errorf("discovery: not connected")
122
}
123
124
type item struct {
125
name string
126
count int
127
topic string
128
}
129
var (
130
mu sync.Mutex
131
results []item
132
done = make(chan struct{})
133
)
134
135
listID := irc.Handlers.AddBg(girc.LIST, func(_ *girc.Client, e girc.Event) {
136
// RPL_LIST: params are [me, channel, count, topic]
137
if len(e.Params) < 3 {
138
return
139
}
140
var count int
141
_, _ = fmt.Sscanf(e.Params[2], "%d", &count)
142
mu.Lock()
143
results = append(results, item{e.Params[1], count, e.Last()})
144
mu.Unlock()
145
})
146
147
endID := irc.Handlers.AddBg(girc.RPL_LISTEND, func(_ *girc.Client, _ girc.Event) {
148
select {
149
case done <- struct{}{}:
150
default:
151
}
152
})
153
154
defer func() {
155
irc.Handlers.Remove(listID)
156
irc.Handlers.Remove(endID)
157
}()
158
159
irc.Cmd.List()
160
161
select {
162
case <-ctx.Done():
163
return nil, ctx.Err()
164
case <-done:
165
}
166
167
mu.Lock()
168
defer mu.Unlock()
169
out := make([]ChannelSummary, len(results))
170
for i, r := range results {
171
out[i] = ChannelSummary{Name: r.name, MemberCount: r.count, Topic: r.topic}
172
}
173
d.cache.set(cacheKey, out)
174
return out, nil
175
}
176
177
// ChannelMembers returns the current member list for a channel.
178
func (d *Discovery) ChannelMembers(ctx context.Context, channel string) ([]Member, error) {
179
cacheKey := "members:" + channel
180
if v, ok := d.cache.get(cacheKey); ok {
181
return v.([]Member), nil
182
}
183
184
irc := d.ircClient()
185
if irc == nil {
186
return nil, fmt.Errorf("discovery: not connected")
187
}
188
189
var (
190
mu sync.Mutex
191
members []Member
192
done = make(chan struct{})
193
)
194
195
namesID := irc.Handlers.AddBg(girc.RPL_NAMREPLY, func(_ *girc.Client, e girc.Event) {
196
// params: [me, mode, channel, names...]
197
if len(e.Params) < 3 {
198
return
199
}
200
// last param is space-separated nicks with optional @/+ prefix
201
for _, n := range strings.Fields(e.Last()) {
202
m := Member{Nick: n}
203
if strings.HasPrefix(n, "@") {
204
m.Nick = n[1:]
205
m.IsOp = true
206
} else if strings.HasPrefix(n, "+") {
207
m.Nick = n[1:]
208
m.IsVoice = true
209
}
210
mu.Lock()
211
members = append(members, m)
212
mu.Unlock()
213
}
214
})
215
216
endID := irc.Handlers.AddBg(girc.RPL_ENDOFNAMES, func(_ *girc.Client, e girc.Event) {
217
if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
218
select {
219
case done <- struct{}{}:
220
default:
221
}
222
}
223
})
224
225
defer func() {
226
irc.Handlers.Remove(namesID)
227
irc.Handlers.Remove(endID)
228
}()
229
230
_ = irc.Cmd.SendRaw("NAMES " + channel)
231
232
select {
233
case <-ctx.Done():
234
return nil, ctx.Err()
235
case <-done:
236
}
237
238
mu.Lock()
239
defer mu.Unlock()
240
d.cache.set(cacheKey, members)
241
return members, nil
242
}
243
244
// GetTopic returns the topic for a channel.
245
func (d *Discovery) GetTopic(ctx context.Context, channel string) (TopicInfo, error) {
246
cacheKey := "topic:" + channel
247
if v, ok := d.cache.get(cacheKey); ok {
248
return v.(TopicInfo), nil
249
}
250
251
irc := d.ircClient()
252
if irc == nil {
253
return TopicInfo{}, fmt.Errorf("discovery: not connected")
254
}
255
256
result := make(chan TopicInfo, 1)
257
258
topicID := irc.Handlers.AddBg(girc.RPL_TOPIC, func(_ *girc.Client, e girc.Event) {
259
if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
260
select {
261
case result <- TopicInfo{Channel: channel, Topic: e.Last()}:
262
default:
263
}
264
}
265
})
266
267
noTopicID := irc.Handlers.AddBg(girc.RPL_NOTOPIC, func(_ *girc.Client, e girc.Event) {
268
if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
269
select {
270
case result <- TopicInfo{Channel: channel}:
271
default:
272
}
273
}
274
})
275
276
defer func() {
277
irc.Handlers.Remove(topicID)
278
irc.Handlers.Remove(noTopicID)
279
}()
280
281
_ = irc.Cmd.SendRaw("TOPIC " + channel)
282
283
select {
284
case <-ctx.Done():
285
return TopicInfo{}, ctx.Err()
286
case info := <-result:
287
d.cache.set(cacheKey, info)
288
return info, nil
289
}
290
}
291
292
// WhoIs returns identity information for a nick.
293
func (d *Discovery) WhoIs(ctx context.Context, nick string) (WhoIsInfo, error) {
294
cacheKey := "whois:" + nick
295
if v, ok := d.cache.get(cacheKey); ok {
296
return v.(WhoIsInfo), nil
297
}
298
299
irc := d.ircClient()
300
if irc == nil {
301
return WhoIsInfo{}, fmt.Errorf("discovery: not connected")
302
}
303
304
var (
305
mu sync.Mutex
306
info WhoIsInfo
307
done = make(chan struct{})
308
)
309
info.Nick = nick
310
311
// RPL_WHOISUSER (311): nick, user, host, *, realname
312
userID := irc.Handlers.AddBg(girc.RPL_WHOISUSER, func(_ *girc.Client, e girc.Event) {
313
if len(e.Params) < 4 || !strings.EqualFold(e.Params[1], nick) {
314
return
315
}
316
mu.Lock()
317
info.User = e.Params[2]
318
info.Host = e.Params[3]
319
info.RealName = e.Last()
320
mu.Unlock()
321
})
322
323
// RPL_WHOISCHANNELS (319): nick, channels
324
chansID := irc.Handlers.AddBg(girc.RPL_WHOISCHANNELS, func(_ *girc.Client, e girc.Event) {
325
if len(e.Params) < 2 || !strings.EqualFold(e.Params[1], nick) {
326
return
327
}
328
mu.Lock()
329
for _, ch := range strings.Fields(e.Last()) {
330
// Strip mode prefixes (@, +) from channel names.
331
info.Channels = append(info.Channels, strings.TrimLeft(ch, "@+~&%"))
332
}
333
mu.Unlock()
334
})
335
336
// RPL_WHOISACCOUNT (330): nick, account, "is logged in as"
337
acctID := irc.Handlers.AddBg(girc.RPL_WHOISACCOUNT, func(_ *girc.Client, e girc.Event) {
338
if len(e.Params) < 3 || !strings.EqualFold(e.Params[1], nick) {
339
return
340
}
341
mu.Lock()
342
info.Account = e.Params[2]
343
mu.Unlock()
344
})
345
346
// RPL_ENDOFWHOIS (318)
347
endID := irc.Handlers.AddBg(girc.RPL_ENDOFWHOIS, func(_ *girc.Client, e girc.Event) {
348
if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], nick) {
349
select {
350
case done <- struct{}{}:
351
default:
352
}
353
}
354
})
355
356
defer func() {
357
irc.Handlers.Remove(userID)
358
irc.Handlers.Remove(chansID)
359
irc.Handlers.Remove(acctID)
360
irc.Handlers.Remove(endID)
361
}()
362
363
irc.Cmd.Whois(nick)
364
365
select {
366
case <-ctx.Done():
367
return WhoIsInfo{}, ctx.Err()
368
case <-done:
369
}
370
371
mu.Lock()
372
defer mu.Unlock()
373
d.cache.set(cacheKey, info)
374
return info, nil
375
}
376
377
// Invalidate removes a specific entry from the cache (e.g. on channel join/part events).
378
// key forms: "list_channels", "members:#fleet", "topic:#fleet", "whois:nick"
379
func (d *Discovery) Invalidate(key string) {
380
d.cache.mu.Lock()
381
delete(d.cache.entries, key)
382
d.cache.mu.Unlock()
383
}
384
385
func (d *Discovery) ircClient() *girc.Client {
386
d.client.mu.RLock()
387
defer d.client.mu.RUnlock()
388
return d.client.irc
389
}
390

Keyboard Shortcuts

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