|
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
|
|