yaic 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/ralph-qa.md +101 -0
  3. data/.claude/ralph/bin/dump-pid.sh +3 -0
  4. data/.claude/ralph/bin/kill-claude +6 -0
  5. data/.claude/ralph/bin/start-ralph +44 -0
  6. data/.claude/ralph/bin/stop-hook.sh +9 -0
  7. data/.claude/ralph/bin/stop-ralph +17 -0
  8. data/.claude/ralph/prompt.md +218 -0
  9. data/.claude/settings.json +26 -0
  10. data/.gitmodules +3 -0
  11. data/CLAUDE.md +65 -0
  12. data/README.md +106 -17
  13. data/Rakefile +8 -0
  14. data/devenv.nix +1 -0
  15. data/docs/agents/data-model.md +150 -0
  16. data/docs/agents/ralph/features/01-message-parsing.md.done +160 -0
  17. data/docs/agents/ralph/features/01-tcpsocket-refactor.md.done +109 -0
  18. data/docs/agents/ralph/features/02-connection-socket.md.done +138 -0
  19. data/docs/agents/ralph/features/02-simplified-client-api.md.done +306 -0
  20. data/docs/agents/ralph/features/03-registration.md.done +147 -0
  21. data/docs/agents/ralph/features/04-ping-pong.md.done +109 -0
  22. data/docs/agents/ralph/features/05-event-system.md.done +167 -0
  23. data/docs/agents/ralph/features/06-privmsg-notice.md.done +163 -0
  24. data/docs/agents/ralph/features/07-join-part.md.done +190 -0
  25. data/docs/agents/ralph/features/08-quit.md.done +118 -0
  26. data/docs/agents/ralph/features/09-nick-change.md.done +109 -0
  27. data/docs/agents/ralph/features/10-topic.md.done +145 -0
  28. data/docs/agents/ralph/features/11-kick.md.done +122 -0
  29. data/docs/agents/ralph/features/12-names.md.done +124 -0
  30. data/docs/agents/ralph/features/13-mode.md.done +174 -0
  31. data/docs/agents/ralph/features/14-who-whois.md.done +188 -0
  32. data/docs/agents/ralph/features/15-client-api.md.done +180 -0
  33. data/docs/agents/ralph/features/16-ssl-test-infrastructure.md.done +50 -0
  34. data/docs/agents/ralph/features/17-github-actions-ci.md.done +70 -0
  35. data/docs/agents/ralph/features/18-brakeman-security-scanning.md.done +67 -0
  36. data/docs/agents/ralph/features/19-fix-qa.md.done +73 -0
  37. data/docs/agents/ralph/features/20-test-optimization.md.done +70 -0
  38. data/docs/agents/ralph/features/21-test-parallelization.md.done +56 -0
  39. data/docs/agents/ralph/features/22-wait-until-pattern.md.done +90 -0
  40. data/docs/agents/ralph/features/23-ping-test-optimization.md.done +46 -0
  41. data/docs/agents/ralph/features/24-blocking-who-whois.md.done +159 -0
  42. data/docs/agents/ralph/features/25-verbose-mode.md.done +166 -0
  43. data/docs/agents/ralph/plans/test-optimization-plan.md +172 -0
  44. data/docs/agents/ralph/progress.md +731 -0
  45. data/docs/agents/todo.md +5 -0
  46. data/lib/yaic/channel.rb +22 -0
  47. data/lib/yaic/client.rb +821 -0
  48. data/lib/yaic/event.rb +35 -0
  49. data/lib/yaic/message.rb +119 -0
  50. data/lib/yaic/registration.rb +17 -0
  51. data/lib/yaic/socket.rb +120 -0
  52. data/lib/yaic/source.rb +39 -0
  53. data/lib/yaic/version.rb +1 -1
  54. data/lib/yaic/who_result.rb +17 -0
  55. data/lib/yaic/whois_result.rb +20 -0
  56. data/lib/yaic.rb +13 -1
  57. metadata +51 -1
@@ -0,0 +1,821 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yaic
4
+ class Client
5
+ STALE_TIMEOUT = 180
6
+ DEFAULT_CONNECT_TIMEOUT = 30
7
+ DEFAULT_OPERATION_TIMEOUT = 10
8
+
9
+ EVENT_MAP = {
10
+ "PRIVMSG" => :message,
11
+ "NOTICE" => :notice,
12
+ "JOIN" => :join,
13
+ "PART" => :part,
14
+ "QUIT" => :quit,
15
+ "KICK" => :kick,
16
+ "NICK" => :nick,
17
+ "TOPIC" => :topic,
18
+ "MODE" => :mode
19
+ }.freeze
20
+
21
+ PREFIX_MODES = {
22
+ "@" => :op,
23
+ "+" => :voice,
24
+ "%" => :halfop,
25
+ "~" => :owner,
26
+ "&" => :admin
27
+ }.freeze
28
+
29
+ attr_reader :server
30
+
31
+ def state
32
+ @monitor.synchronize { @state }
33
+ end
34
+
35
+ def isupport
36
+ @monitor.synchronize { @isupport.dup }
37
+ end
38
+
39
+ def last_received_at
40
+ @monitor.synchronize { @last_received_at }
41
+ end
42
+
43
+ def channels
44
+ @monitor.synchronize { @channels.dup }
45
+ end
46
+
47
+ def initialize(port:, host: nil, nick: nil, user: nil, realname: nil, password: nil, ssl: false, server: nil, nickname: nil, username: nil, verbose: false)
48
+ @server = server || host
49
+ @port = port
50
+ @nick = nickname || nick
51
+ @user = username || user || @nick
52
+ @realname = realname || @nick
53
+ @password = password
54
+ @ssl = ssl
55
+ @verbose = verbose
56
+
57
+ @socket = nil
58
+ @state = :disconnected
59
+ @isupport = {}
60
+ @nick_attempts = 0
61
+ @last_received_at = nil
62
+ @handlers = {}
63
+ @channels = {}
64
+ @pending_names = {}
65
+ @pending_whois = {}
66
+ @pending_whois_complete = {}
67
+ @pending_who_results = {}
68
+ @pending_who_complete = {}
69
+ @read_thread = nil
70
+ @monitor = Monitor.new
71
+ end
72
+
73
+ def connect(timeout: DEFAULT_CONNECT_TIMEOUT)
74
+ log "Connecting to #{@server}:#{@port}#{" (SSL)" if @ssl}..."
75
+ sock = @monitor.synchronize do
76
+ @socket ||= Socket.new(@server, @port, ssl: @ssl)
77
+ end
78
+ sock.connect
79
+ send_registration
80
+ set_state(:registering)
81
+ start_read_loop
82
+ wait_until(timeout: timeout) { connected? }
83
+ log "Connected"
84
+ end
85
+
86
+ def connected?
87
+ @monitor.synchronize { @state == :connected }
88
+ end
89
+
90
+ def disconnect
91
+ socket&.disconnect
92
+ set_state(:disconnected)
93
+ @read_thread&.join(1)
94
+ end
95
+
96
+ def handle_message(message)
97
+ @monitor.synchronize { @last_received_at = Time.now }
98
+
99
+ case message.command
100
+ when "PING"
101
+ handle_ping(message)
102
+ when "001"
103
+ handle_rpl_welcome(message)
104
+ when "005"
105
+ handle_rpl_isupport(message)
106
+ when "433"
107
+ handle_err_nicknameinuse(message)
108
+ when "JOIN"
109
+ handle_join(message)
110
+ when "PART"
111
+ handle_part(message)
112
+ when "QUIT"
113
+ handle_quit(message)
114
+ when "NICK"
115
+ handle_nick(message)
116
+ when "KICK"
117
+ handle_kick(message)
118
+ when "TOPIC"
119
+ handle_topic(message)
120
+ when "332"
121
+ handle_rpl_topic(message)
122
+ when "333"
123
+ handle_rpl_topicwhotime(message)
124
+ when "353"
125
+ handle_rpl_namreply(message)
126
+ when "366"
127
+ handle_rpl_endofnames(message)
128
+ when "MODE"
129
+ handle_mode(message)
130
+ when "352"
131
+ handle_rpl_whoreply(message)
132
+ when "315"
133
+ handle_rpl_endofwho(message)
134
+ when "311"
135
+ handle_rpl_whoisuser(message)
136
+ when "319"
137
+ handle_rpl_whoischannels(message)
138
+ when "312"
139
+ handle_rpl_whoisserver(message)
140
+ when "317"
141
+ handle_rpl_whoisidle(message)
142
+ when "330"
143
+ handle_rpl_whoisaccount(message)
144
+ when "301"
145
+ handle_rpl_away(message)
146
+ when "318"
147
+ handle_rpl_endofwhois(message)
148
+ end
149
+
150
+ emit_events(message)
151
+ end
152
+
153
+ def connection_stale?
154
+ @monitor.synchronize do
155
+ return false if @last_received_at.nil?
156
+ Time.now - @last_received_at > STALE_TIMEOUT
157
+ end
158
+ end
159
+
160
+ def on(event_type, &block)
161
+ @monitor.synchronize do
162
+ @handlers[event_type] ||= []
163
+ @handlers[event_type] << block
164
+ end
165
+ self
166
+ end
167
+
168
+ def off(event_type)
169
+ @monitor.synchronize do
170
+ @handlers.delete(event_type)
171
+ end
172
+ self
173
+ end
174
+
175
+ def privmsg(target, text)
176
+ message = Message.new(command: "PRIVMSG", params: [target, text])
177
+ socket.write(message.to_s)
178
+ end
179
+
180
+ alias_method :msg, :privmsg
181
+
182
+ def notice(target, text)
183
+ message = Message.new(command: "NOTICE", params: [target, text])
184
+ socket.write(message.to_s)
185
+ end
186
+
187
+ def join(channel, key = nil, timeout: DEFAULT_OPERATION_TIMEOUT)
188
+ log "Joining #{channel}..."
189
+ params = key ? [channel, key] : [channel]
190
+ message = Message.new(command: "JOIN", params: params)
191
+ socket.write(message.to_s)
192
+ wait_until(timeout: timeout) { channel_joined?(channel) }
193
+ log "Joined #{channel}"
194
+ end
195
+
196
+ def part(channel, reason = nil, timeout: DEFAULT_OPERATION_TIMEOUT)
197
+ log "Parting #{channel}..."
198
+ params = reason ? [channel, reason] : [channel]
199
+ message = Message.new(command: "PART", params: params)
200
+ socket.write(message.to_s)
201
+ wait_until(timeout: timeout) { !channel_joined?(channel) }
202
+ log "Parted #{channel}"
203
+ end
204
+
205
+ def quit(reason = nil)
206
+ params = reason ? [reason] : []
207
+ message = Message.new(command: "QUIT", params: params)
208
+ socket.write(message.to_s)
209
+ @monitor.synchronize { @channels.clear }
210
+ set_state(:disconnected)
211
+ @read_thread&.join(5)
212
+ socket&.disconnect
213
+ emit(:disconnect, nil)
214
+ log "Disconnected"
215
+ end
216
+
217
+ def nick(new_nick = nil, timeout: DEFAULT_OPERATION_TIMEOUT)
218
+ return @monitor.synchronize { @nick } if new_nick.nil?
219
+
220
+ old_nick = @monitor.synchronize { @nick }
221
+ message = Message.new(command: "NICK", params: [new_nick])
222
+ socket.write(message.to_s)
223
+ wait_until(timeout: timeout) { @monitor.synchronize { @nick } != old_nick }
224
+ end
225
+
226
+ def topic(channel, new_topic = nil)
227
+ params = new_topic.nil? ? [channel] : [channel, new_topic]
228
+ message = Message.new(command: "TOPIC", params: params)
229
+ socket.write(message.to_s)
230
+ end
231
+
232
+ def kick(channel, nick, reason = nil)
233
+ params = reason ? [channel, nick, reason] : [channel, nick]
234
+ message = Message.new(command: "KICK", params: params)
235
+ socket.write(message.to_s)
236
+ end
237
+
238
+ def names(channel)
239
+ message = Message.new(command: "NAMES", params: [channel])
240
+ socket.write(message.to_s)
241
+ end
242
+
243
+ def mode(target, modes = nil, *args)
244
+ params = [target]
245
+ params << modes if modes
246
+ params.concat(args) unless args.empty?
247
+ message = Message.new(command: "MODE", params: params)
248
+ socket.write(message.to_s)
249
+ end
250
+
251
+ def who(mask, timeout: DEFAULT_OPERATION_TIMEOUT)
252
+ log "Sending WHO #{mask}..."
253
+ @monitor.synchronize do
254
+ @pending_who_results[mask] = []
255
+ @pending_who_complete[mask] = false
256
+ end
257
+
258
+ message = Message.new(command: "WHO", params: [mask])
259
+ socket.write(message.to_s)
260
+
261
+ wait_until(timeout: timeout) { @monitor.synchronize { @pending_who_complete[mask] } }
262
+
263
+ @monitor.synchronize do
264
+ results = @pending_who_results.delete(mask)
265
+ log "WHO complete (#{results.size} results)"
266
+ results
267
+ end
268
+ ensure
269
+ @monitor.synchronize { @pending_who_complete.delete(mask) }
270
+ end
271
+
272
+ def whois(nick, timeout: DEFAULT_OPERATION_TIMEOUT)
273
+ log "Sending WHOIS #{nick}..."
274
+ @monitor.synchronize { @pending_whois_complete[nick] = false }
275
+
276
+ message = Message.new(command: "WHOIS", params: [nick])
277
+ socket.write(message.to_s)
278
+
279
+ wait_until(timeout: timeout) { @monitor.synchronize { @pending_whois_complete[nick] } }
280
+
281
+ @monitor.synchronize do
282
+ result = @pending_whois.delete(nick)
283
+ log "WHOIS complete"
284
+ result
285
+ end
286
+ ensure
287
+ @monitor.synchronize { @pending_whois_complete.delete(nick) }
288
+ end
289
+
290
+ def raw(command)
291
+ socket.write(command)
292
+ end
293
+
294
+ private
295
+
296
+ def socket
297
+ @monitor.synchronize { @socket }
298
+ end
299
+
300
+ def log(message)
301
+ return unless @verbose
302
+ warn "[YAIC] #{message}"
303
+ end
304
+
305
+ def set_state(new_state)
306
+ @monitor.synchronize { @state = new_state }
307
+ end
308
+
309
+ def channel_joined?(channel)
310
+ @monitor.synchronize { @channels.key?(channel) }
311
+ end
312
+
313
+ def wait_until(timeout:)
314
+ deadline = Time.now + timeout
315
+ until yield
316
+ raise Yaic::TimeoutError, "Operation timed out after #{timeout} seconds" if Time.now > deadline
317
+ sleep 0.01
318
+ end
319
+ end
320
+
321
+ def start_read_loop
322
+ @read_thread = Thread.new do
323
+ loop do
324
+ break if @monitor.synchronize { @state } == :disconnected
325
+ process_incoming
326
+ end
327
+ end
328
+ end
329
+
330
+ def process_incoming
331
+ raw = socket.read
332
+ if raw
333
+ message = Message.parse(raw)
334
+ handle_message(message) if message
335
+ else
336
+ sleep 0.001
337
+ end
338
+ rescue => e
339
+ emit(:error, nil, exception: e)
340
+ end
341
+
342
+ def send_registration
343
+ sock = socket
344
+ if @password
345
+ sock.write(Registration.pass_message(@password).to_s)
346
+ end
347
+ sock.write(Registration.nick_message(@nick).to_s)
348
+ sock.write(Registration.user_message(@user, @realname).to_s)
349
+ end
350
+
351
+ def handle_rpl_welcome(message)
352
+ @monitor.synchronize do
353
+ @nick = message.params[0] if message.params[0]
354
+ end
355
+ set_state(:connected)
356
+ end
357
+
358
+ def handle_rpl_isupport(message)
359
+ @monitor.synchronize do
360
+ message.params[1..-2].each do |param|
361
+ next unless param
362
+
363
+ if param.include?("=")
364
+ key, value = param.split("=", 2)
365
+ @isupport[key] = value
366
+ else
367
+ @isupport[param] = true
368
+ end
369
+ end
370
+ end
371
+ end
372
+
373
+ def handle_err_nicknameinuse(_message)
374
+ return unless @monitor.synchronize { @state } == :registering
375
+
376
+ @monitor.synchronize do
377
+ @nick_attempts += 1
378
+ new_nick = "#{@nick.sub(/_+$/, "")}#{"_" * @nick_attempts}"
379
+ @nick = new_nick
380
+ socket.write(Registration.nick_message(@nick).to_s)
381
+ end
382
+ end
383
+
384
+ def handle_ping(message)
385
+ token = message.params[0]
386
+ pong = Message.new(command: "PONG", params: [token])
387
+ socket.write(pong.to_s)
388
+ end
389
+
390
+ def handle_join(message)
391
+ channel_name = message.params[0]
392
+ joiner_nick = message.source&.nick
393
+ return unless joiner_nick && channel_name
394
+
395
+ @monitor.synchronize do
396
+ if joiner_nick == @nick
397
+ @channels[channel_name] = Channel.new(channel_name)
398
+ elsif (channel = @channels[channel_name])
399
+ channel.users[joiner_nick] = Set.new
400
+ end
401
+ end
402
+ end
403
+
404
+ def handle_part(message)
405
+ channel_name = message.params[0]
406
+ parter_nick = message.source&.nick
407
+ return unless parter_nick && channel_name
408
+
409
+ @monitor.synchronize do
410
+ if parter_nick == @nick
411
+ @channels.delete(channel_name)
412
+ else
413
+ channel = @channels[channel_name]
414
+ channel&.users&.delete(parter_nick)
415
+ end
416
+ end
417
+ end
418
+
419
+ def handle_quit(message)
420
+ quitter_nick = message.source&.nick
421
+ return unless quitter_nick
422
+
423
+ @monitor.synchronize do
424
+ @channels.each_value do |channel|
425
+ channel.users.delete(quitter_nick)
426
+ end
427
+ end
428
+ end
429
+
430
+ def handle_nick(message)
431
+ old_nick = message.source&.nick
432
+ new_nick = message.params[0]
433
+ return unless old_nick && new_nick
434
+
435
+ @monitor.synchronize do
436
+ if old_nick == @nick
437
+ @nick = new_nick
438
+ end
439
+
440
+ @channels.each_value do |channel|
441
+ if channel.users.key?(old_nick)
442
+ user_data = channel.users.delete(old_nick)
443
+ channel.users[new_nick] = user_data
444
+ end
445
+ end
446
+ end
447
+ end
448
+
449
+ def handle_kick(message)
450
+ channel_name = message.params[0]
451
+ kicked_nick = message.params[1]
452
+ return unless channel_name && kicked_nick
453
+
454
+ @monitor.synchronize do
455
+ if kicked_nick == @nick
456
+ @channels.delete(channel_name)
457
+ else
458
+ channel = @channels[channel_name]
459
+ channel&.users&.delete(kicked_nick)
460
+ end
461
+ end
462
+ end
463
+
464
+ def handle_topic(message)
465
+ channel_name = message.params[0]
466
+ topic_text = message.params[1]
467
+ setter_nick = message.source&.nick
468
+ return unless channel_name
469
+
470
+ @monitor.synchronize do
471
+ channel = @channels[channel_name]
472
+ channel&.set_topic(topic_text, setter_nick)
473
+ end
474
+ end
475
+
476
+ def handle_rpl_topic(message)
477
+ channel_name = message.params[1]
478
+ topic_text = message.params[2]
479
+ return unless channel_name
480
+
481
+ @monitor.synchronize do
482
+ channel = @channels[channel_name]
483
+ channel&.set_topic(topic_text)
484
+ end
485
+ end
486
+
487
+ def handle_rpl_topicwhotime(message)
488
+ channel_name = message.params[1]
489
+ setter = message.params[2]
490
+ time_str = message.params[3]
491
+ return unless channel_name && setter && time_str
492
+
493
+ @monitor.synchronize do
494
+ channel = @channels[channel_name]
495
+ channel&.set_topic(channel&.topic, setter, Time.at(time_str.to_i))
496
+ end
497
+ end
498
+
499
+ def handle_rpl_namreply(message)
500
+ channel_name = message.params[2]
501
+ users_str = message.params[3]
502
+ return unless channel_name && users_str
503
+
504
+ @monitor.synchronize do
505
+ @pending_names[channel_name] ||= {}
506
+
507
+ users_str.split.each do |user_entry|
508
+ nick, modes = parse_user_with_prefix(user_entry)
509
+ @pending_names[channel_name][nick] = modes
510
+ end
511
+ end
512
+ end
513
+
514
+ def handle_rpl_endofnames(message)
515
+ channel_name = message.params[1]
516
+ return unless channel_name
517
+
518
+ pending = nil
519
+ @monitor.synchronize do
520
+ channel = @channels[channel_name]
521
+ pending = @pending_names.delete(channel_name) || {}
522
+
523
+ if channel
524
+ pending.each do |nick, modes|
525
+ channel.users[nick] = modes
526
+ end
527
+ end
528
+ end
529
+
530
+ emit(:names, message, channel: channel_name, users: pending)
531
+ end
532
+
533
+ def handle_mode(message)
534
+ target = message.params[0]
535
+ return unless target
536
+
537
+ modes_str = message.params[1]
538
+ return unless modes_str
539
+
540
+ @monitor.synchronize do
541
+ channel = @channels[target]
542
+ return unless channel
543
+
544
+ params = message.params[2..] || []
545
+ param_idx = 0
546
+
547
+ adding = true
548
+ modes_str.each_char do |char|
549
+ case char
550
+ when "+"
551
+ adding = true
552
+ when "-"
553
+ adding = false
554
+ when "o", "v", "h", "a", "q"
555
+ nick = params[param_idx]
556
+ param_idx += 1
557
+ apply_user_mode(channel, nick, char, adding) if nick
558
+ when "k"
559
+ if adding
560
+ channel.modes[:key] = params[param_idx]
561
+ param_idx += 1
562
+ else
563
+ channel.modes.delete(:key)
564
+ end
565
+ when "l"
566
+ if adding
567
+ channel.modes[:limit] = params[param_idx].to_i
568
+ param_idx += 1
569
+ else
570
+ channel.modes.delete(:limit)
571
+ end
572
+ when "m"
573
+ channel.modes[:moderated] = adding ? true : nil
574
+ channel.modes.delete(:moderated) unless adding
575
+ when "i"
576
+ channel.modes[:invite_only] = adding ? true : nil
577
+ channel.modes.delete(:invite_only) unless adding
578
+ when "t"
579
+ channel.modes[:topic_protected] = adding ? true : nil
580
+ channel.modes.delete(:topic_protected) unless adding
581
+ when "n"
582
+ channel.modes[:no_external] = adding ? true : nil
583
+ channel.modes.delete(:no_external) unless adding
584
+ when "s"
585
+ channel.modes[:secret] = adding ? true : nil
586
+ channel.modes.delete(:secret) unless adding
587
+ when "p"
588
+ channel.modes[:private] = adding ? true : nil
589
+ channel.modes.delete(:private) unless adding
590
+ when "b"
591
+ param_idx += 1
592
+ end
593
+ end
594
+ end
595
+ end
596
+
597
+ def handle_rpl_whoreply(message)
598
+ channel = message.params[1]
599
+ user = message.params[2]
600
+ host = message.params[3]
601
+ server = message.params[4]
602
+ nick = message.params[5]
603
+ flags = message.params[6]
604
+ hopcount_realname = message.params[7]
605
+
606
+ away = flags&.include?("G") || false
607
+ realname = hopcount_realname&.sub(/^\d+\s*/, "") || ""
608
+
609
+ @monitor.synchronize do
610
+ @pending_who_results.each_key do |mask|
611
+ if who_reply_matches_mask?(mask, channel, nick)
612
+ result = WhoResult.new(
613
+ channel: channel,
614
+ user: user,
615
+ host: host,
616
+ server: server,
617
+ nick: nick,
618
+ away: away,
619
+ realname: realname
620
+ )
621
+ @pending_who_results[mask] << result
622
+ end
623
+ end
624
+ end
625
+
626
+ emit(:who, message, channel: channel, user: user, host: host, server: server,
627
+ nick: nick, away: away, realname: realname)
628
+ end
629
+
630
+ def handle_rpl_endofwho(message)
631
+ mask = message.params[1]
632
+ @monitor.synchronize do
633
+ @pending_who_complete[mask] = true if @pending_who_complete.key?(mask)
634
+ end
635
+ end
636
+
637
+ def who_reply_matches_mask?(mask, channel, nick)
638
+ if mask.start_with?("#")
639
+ channel.casecmp?(mask)
640
+ else
641
+ nick.casecmp?(mask)
642
+ end
643
+ end
644
+
645
+ def handle_rpl_whoisuser(message)
646
+ nick = message.params[1]
647
+ user = message.params[2]
648
+ host = message.params[3]
649
+ realname = message.params[5]
650
+
651
+ @monitor.synchronize do
652
+ @pending_whois[nick] = WhoisResult.new(nick: nick)
653
+ @pending_whois[nick].user = user
654
+ @pending_whois[nick].host = host
655
+ @pending_whois[nick].realname = realname
656
+ end
657
+ end
658
+
659
+ def handle_rpl_whoischannels(message)
660
+ nick = message.params[1]
661
+ channels_str = message.params[2]
662
+
663
+ @monitor.synchronize do
664
+ return unless @pending_whois[nick] && channels_str
665
+
666
+ channels_str.split.each do |chan|
667
+ channel = chan.gsub(/^[@+%~&]+/, "")
668
+ @pending_whois[nick].channels << channel
669
+ end
670
+ end
671
+ end
672
+
673
+ def handle_rpl_whoisserver(message)
674
+ nick = message.params[1]
675
+ server = message.params[2]
676
+
677
+ @monitor.synchronize do
678
+ return unless @pending_whois[nick]
679
+ @pending_whois[nick].server = server
680
+ end
681
+ end
682
+
683
+ def handle_rpl_whoisidle(message)
684
+ nick = message.params[1]
685
+ idle = message.params[2]&.to_i
686
+ signon = message.params[3]&.to_i
687
+
688
+ @monitor.synchronize do
689
+ return unless @pending_whois[nick]
690
+ @pending_whois[nick].idle = idle
691
+ @pending_whois[nick].signon = signon ? Time.at(signon) : nil
692
+ end
693
+ end
694
+
695
+ def handle_rpl_whoisaccount(message)
696
+ nick = message.params[1]
697
+ account = message.params[2]
698
+
699
+ @monitor.synchronize do
700
+ return unless @pending_whois[nick]
701
+ @pending_whois[nick].account = account
702
+ end
703
+ end
704
+
705
+ def handle_rpl_away(message)
706
+ nick = message.params[1]
707
+ away_msg = message.params[2]
708
+
709
+ @monitor.synchronize do
710
+ return unless @pending_whois[nick]
711
+ @pending_whois[nick].away = away_msg
712
+ end
713
+ end
714
+
715
+ def handle_rpl_endofwhois(message)
716
+ nick = message.params[1]
717
+ result = nil
718
+ @monitor.synchronize do
719
+ @pending_whois_complete[nick] = true if @pending_whois_complete.key?(nick)
720
+ result = @pending_whois[nick]
721
+ end
722
+ emit(:whois, message, result: result)
723
+ end
724
+
725
+ def apply_user_mode(channel, nick, mode_char, adding)
726
+ return unless channel.users.key?(nick)
727
+
728
+ mode_sym = case mode_char
729
+ when "o" then :op
730
+ when "v" then :voice
731
+ when "h" then :halfop
732
+ when "a" then :admin
733
+ when "q" then :owner
734
+ end
735
+ return unless mode_sym
736
+
737
+ if adding
738
+ channel.users[nick] << mode_sym
739
+ else
740
+ channel.users[nick].delete(mode_sym)
741
+ end
742
+ end
743
+
744
+ def parse_user_with_prefix(user_entry)
745
+ modes = Set.new
746
+ nick = user_entry
747
+
748
+ while nick.length > 0 && PREFIX_MODES.key?(nick[0])
749
+ modes << PREFIX_MODES[nick[0]]
750
+ nick = nick[1..]
751
+ end
752
+
753
+ [nick, modes]
754
+ end
755
+
756
+ def emit_events(message)
757
+ emit(:raw, message, message: message)
758
+
759
+ event_type = determine_event_type(message)
760
+ return unless event_type
761
+
762
+ attributes = build_event_attributes(event_type, message)
763
+ emit(event_type, message, **attributes)
764
+ end
765
+
766
+ def emit(event_type, message, **attributes)
767
+ handlers = @monitor.synchronize { @handlers[event_type]&.dup }
768
+ return unless handlers
769
+
770
+ event = Event.new(type: event_type, message: message, **attributes)
771
+ handlers.each do |handler|
772
+ handler.call(event)
773
+ rescue => e
774
+ warn "Event handler error: #{e.message}"
775
+ end
776
+ end
777
+
778
+ def determine_event_type(message)
779
+ return EVENT_MAP[message.command] if EVENT_MAP.key?(message.command)
780
+
781
+ if message.command == "001"
782
+ :connect
783
+ elsif message.command == "332"
784
+ :topic
785
+ elsif message.command.match?(/\A[45]\d\d\z/)
786
+ :error
787
+ end
788
+ end
789
+
790
+ def build_event_attributes(event_type, message)
791
+ case event_type
792
+ when :connect
793
+ {server: message.source&.raw}
794
+ when :message, :notice
795
+ {source: message.source, target: message.params[0], text: message.params[1]}
796
+ when :join
797
+ {channel: message.params[0], user: message.source}
798
+ when :part
799
+ {channel: message.params[0], user: message.source, reason: message.params[1]}
800
+ when :quit
801
+ {user: message.source, reason: message.params[0]}
802
+ when :kick
803
+ {channel: message.params[0], user: message.params[1], by: message.source, reason: message.params[2]}
804
+ when :nick
805
+ {old_nick: message.source&.nick, new_nick: message.params[0]}
806
+ when :topic
807
+ if message.command == "332"
808
+ {channel: message.params[1], topic: message.params[2], setter: nil}
809
+ else
810
+ {channel: message.params[0], topic: message.params[1], setter: message.source}
811
+ end
812
+ when :mode
813
+ {target: message.params[0], modes: message.params[1], args: message.params[2..]}
814
+ when :error
815
+ {numeric: message.command.to_i, message: message.params.last}
816
+ else
817
+ {}
818
+ end
819
+ end
820
+ end
821
+ end