an experimental irc client
1const std = @import("std");
2const comlink = @import("comlink.zig");
3const lua = @import("lua.zig");
4const tls = @import("tls");
5const vaxis = @import("vaxis");
6const zeit = @import("zeit");
7const bytepool = @import("pool.zig");
8
9const testing = std.testing;
10const mem = std.mem;
11const vxfw = vaxis.vxfw;
12
13const Allocator = std.mem.Allocator;
14const Base64Encoder = std.base64.standard.Encoder;
15pub const MessagePool = bytepool.BytePool(max_raw_msg_size * 4);
16pub const Slice = MessagePool.Slice;
17
18const assert = std.debug.assert;
19
20const log = std.log.scoped(.irc);
21
22/// maximum size message we can write
23pub const maximum_message_size = 512;
24
25/// maximum size message we can receive
26const max_raw_msg_size = 512 + 8191; // see modernircdocs
27
28pub const Buffer = union(enum) {
29 client: *Client,
30 channel: *Channel,
31};
32
33pub const Event = comlink.IrcEvent;
34
35pub const Command = enum {
36 RPL_WELCOME, // 001
37 RPL_YOURHOST, // 002
38 RPL_CREATED, // 003
39 RPL_MYINFO, // 004
40 RPL_ISUPPORT, // 005
41
42 RPL_ENDOFWHO, // 315
43 RPL_TOPIC, // 332
44 RPL_WHOREPLY, // 352
45 RPL_NAMREPLY, // 353
46 RPL_WHOSPCRPL, // 354
47 RPL_ENDOFNAMES, // 366
48
49 RPL_LOGGEDIN, // 900
50 RPL_SASLSUCCESS, // 903
51
52 // Named commands
53 AUTHENTICATE,
54 AWAY,
55 BATCH,
56 BOUNCER,
57 CAP,
58 CHATHISTORY,
59 JOIN,
60 MARKREAD,
61 NOTICE,
62 PART,
63 PRIVMSG,
64
65 unknown,
66
67 const map = std.StaticStringMap(Command).initComptime(.{
68 .{ "001", .RPL_WELCOME },
69 .{ "002", .RPL_YOURHOST },
70 .{ "003", .RPL_CREATED },
71 .{ "004", .RPL_MYINFO },
72 .{ "005", .RPL_ISUPPORT },
73
74 .{ "315", .RPL_ENDOFWHO },
75 .{ "332", .RPL_TOPIC },
76 .{ "352", .RPL_WHOREPLY },
77 .{ "353", .RPL_NAMREPLY },
78 .{ "354", .RPL_WHOSPCRPL },
79 .{ "366", .RPL_ENDOFNAMES },
80
81 .{ "900", .RPL_LOGGEDIN },
82 .{ "903", .RPL_SASLSUCCESS },
83
84 .{ "AUTHENTICATE", .AUTHENTICATE },
85 .{ "AWAY", .AWAY },
86 .{ "BATCH", .BATCH },
87 .{ "BOUNCER", .BOUNCER },
88 .{ "CAP", .CAP },
89 .{ "CHATHISTORY", .CHATHISTORY },
90 .{ "JOIN", .JOIN },
91 .{ "MARKREAD", .MARKREAD },
92 .{ "NOTICE", .NOTICE },
93 .{ "PART", .PART },
94 .{ "PRIVMSG", .PRIVMSG },
95 });
96
97 pub fn parse(cmd: []const u8) Command {
98 return map.get(cmd) orelse .unknown;
99 }
100};
101
102pub const Channel = struct {
103 client: *Client,
104 name: []const u8,
105 topic: ?[]const u8 = null,
106 members: std.ArrayList(Member),
107 in_flight: struct {
108 who: bool = false,
109 names: bool = false,
110 } = .{},
111
112 messages: std.ArrayList(Message),
113 history_requested: bool = false,
114 who_requested: bool = false,
115 at_oldest: bool = false,
116 last_read: i64 = 0,
117 has_unread: bool = false,
118 has_unread_highlight: bool = false,
119
120 pub const Member = struct {
121 user: *User,
122
123 /// Highest channel membership prefix (or empty space if no prefix)
124 prefix: u8,
125
126 pub fn compare(_: void, lhs: Member, rhs: Member) bool {
127 return if (lhs.prefix != ' ' and rhs.prefix == ' ')
128 true
129 else if (lhs.prefix == ' ' and rhs.prefix != ' ')
130 false
131 else
132 std.ascii.orderIgnoreCase(lhs.user.nick, rhs.user.nick).compare(.lt);
133 }
134 };
135
136 pub fn deinit(self: *const Channel, alloc: std.mem.Allocator) void {
137 alloc.free(self.name);
138 self.members.deinit();
139 if (self.topic) |topic| {
140 alloc.free(topic);
141 }
142 for (self.messages.items) |msg| {
143 alloc.free(msg.bytes);
144 }
145 self.messages.deinit();
146 }
147
148 pub fn compare(_: void, lhs: *Channel, rhs: *Channel) bool {
149 return std.ascii.orderIgnoreCase(lhs.name, rhs.name).compare(std.math.CompareOperator.lt);
150 }
151
152 pub fn compareRecentMessages(self: *Channel, lhs: Member, rhs: Member) bool {
153 var l: i64 = 0;
154 var r: i64 = 0;
155 var iter = std.mem.reverseIterator(self.messages.items);
156 while (iter.next()) |msg| {
157 if (msg.source()) |source| {
158 const bang = std.mem.indexOfScalar(u8, source, '!') orelse source.len;
159 const nick = source[0..bang];
160
161 if (l == 0 and msg.time() != null and std.mem.eql(u8, lhs.user.nick, nick)) {
162 l = msg.time().?.unixTimestamp();
163 } else if (r == 0 and msg.time() != null and std.mem.eql(u8, rhs.user.nick, nick))
164 r = msg.time().?.unixTimestamp();
165 }
166 if (l > 0 and r > 0) break;
167 }
168 return l < r;
169 }
170
171 pub fn sortMembers(self: *Channel) void {
172 std.sort.insertion(Member, self.members.items, {}, Member.compare);
173 }
174
175 pub fn addMember(self: *Channel, user: *User, args: struct {
176 prefix: ?u8 = null,
177 sort: bool = true,
178 }) !void {
179 if (args.prefix) |p| {
180 log.debug("adding member: nick={s}, prefix={c}", .{ user.nick, p });
181 }
182 for (self.members.items) |*member| {
183 if (user == member.user) {
184 // Update the prefix for an existing member if the prefix is
185 // known
186 if (args.prefix) |p| member.prefix = p;
187 return;
188 }
189 }
190
191 try self.members.append(.{ .user = user, .prefix = args.prefix orelse ' ' });
192
193 if (args.sort) {
194 self.sortMembers();
195 }
196 }
197
198 pub fn removeMember(self: *Channel, user: *User) void {
199 for (self.members.items, 0..) |member, i| {
200 if (user == member.user) {
201 _ = self.members.orderedRemove(i);
202 return;
203 }
204 }
205 }
206
207 /// issue a MARKREAD command for this channel. The most recent message in the channel will be used as
208 /// the last read time
209 pub fn markRead(self: *Channel) !void {
210 if (!self.has_unread) return;
211
212 self.has_unread = false;
213 self.has_unread_highlight = false;
214 const last_msg = self.messages.getLast();
215 const time_tag = last_msg.getTag("time") orelse return;
216 try self.client.print(
217 "MARKREAD {s} timestamp={s}\r\n",
218 .{
219 self.name,
220 time_tag,
221 },
222 );
223 }
224};
225
226pub const User = struct {
227 nick: []const u8,
228 away: bool = false,
229 color: vaxis.Color = .default,
230 real_name: ?[]const u8 = null,
231
232 pub fn deinit(self: *const User, alloc: std.mem.Allocator) void {
233 alloc.free(self.nick);
234 if (self.real_name) |realname| alloc.free(realname);
235 }
236};
237
238/// an irc message
239pub const Message = struct {
240 bytes: []const u8,
241
242 pub const ParamIterator = struct {
243 params: ?[]const u8,
244 index: usize = 0,
245
246 pub fn next(self: *ParamIterator) ?[]const u8 {
247 const params = self.params orelse return null;
248 if (self.index >= params.len) return null;
249
250 // consume leading whitespace
251 while (self.index < params.len) {
252 if (params[self.index] != ' ') break;
253 self.index += 1;
254 }
255
256 const start = self.index;
257 if (start >= params.len) return null;
258
259 // If our first byte is a ':', we return the rest of the string as a
260 // single param (or the empty string)
261 if (params[start] == ':') {
262 self.index = params.len;
263 if (start == params.len - 1) {
264 return "";
265 }
266 return params[start + 1 ..];
267 }
268
269 // Find the first index of space. If we don't have any, the reset of
270 // the line is the last param
271 self.index = std.mem.indexOfScalarPos(u8, params, self.index, ' ') orelse {
272 defer self.index = params.len;
273 return params[start..];
274 };
275
276 return params[start..self.index];
277 }
278 };
279
280 pub const Tag = struct {
281 key: []const u8,
282 value: []const u8,
283 };
284
285 pub const TagIterator = struct {
286 tags: []const u8,
287 index: usize = 0,
288
289 // tags are a list of key=value pairs delimited by semicolons.
290 // key[=value] [; key[=value]]
291 pub fn next(self: *TagIterator) ?Tag {
292 if (self.index >= self.tags.len) return null;
293
294 // find next delimiter
295 const end = std.mem.indexOfScalarPos(u8, self.tags, self.index, ';') orelse self.tags.len;
296 var kv_delim = std.mem.indexOfScalarPos(u8, self.tags, self.index, '=') orelse end;
297 // it's possible to have tags like this:
298 // @bot;account=botaccount;+typing=active
299 // where the first tag doesn't have a value. Guard against the
300 // kv_delim being past the end position
301 if (kv_delim > end) kv_delim = end;
302
303 defer self.index = end + 1;
304
305 return .{
306 .key = self.tags[self.index..kv_delim],
307 .value = if (end == kv_delim) "" else self.tags[kv_delim + 1 .. end],
308 };
309 }
310 };
311
312 pub fn tagIterator(msg: Message) TagIterator {
313 const src = msg.bytes;
314 if (src[0] != '@') return .{ .tags = "" };
315
316 assert(src.len > 1);
317 const n = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse src.len;
318 return .{ .tags = src[1..n] };
319 }
320
321 pub fn source(msg: Message) ?[]const u8 {
322 const src = msg.bytes;
323 var i: usize = 0;
324
325 // get past tags
326 if (src[0] == '@') {
327 assert(src.len > 1);
328 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return null;
329 }
330
331 // consume whitespace
332 while (i < src.len) : (i += 1) {
333 if (src[i] != ' ') break;
334 }
335
336 // Start of source
337 if (src[i] == ':') {
338 assert(src.len > i);
339 i += 1;
340 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
341 return src[i..end];
342 }
343
344 return null;
345 }
346
347 pub fn command(msg: Message) Command {
348 const src = msg.bytes;
349 var i: usize = 0;
350
351 // get past tags
352 if (src[0] == '@') {
353 assert(src.len > 1);
354 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return .unknown;
355 }
356 // consume whitespace
357 while (i < src.len) : (i += 1) {
358 if (src[i] != ' ') break;
359 }
360
361 // get past source
362 if (src[i] == ':') {
363 assert(src.len > i);
364 i += 1;
365 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .unknown;
366 }
367 // consume whitespace
368 while (i < src.len) : (i += 1) {
369 if (src[i] != ' ') break;
370 }
371
372 assert(src.len > i);
373 // Find next space
374 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
375 return Command.parse(src[i..end]);
376 }
377
378 pub fn paramIterator(msg: Message) ParamIterator {
379 const src = msg.bytes;
380 var i: usize = 0;
381
382 // get past tags
383 if (src[0] == '@') {
384 i = std.mem.indexOfScalarPos(u8, src, 0, ' ') orelse return .{ .params = "" };
385 }
386 // consume whitespace
387 while (i < src.len) : (i += 1) {
388 if (src[i] != ' ') break;
389 }
390
391 // get past source
392 if (src[i] == ':') {
393 assert(src.len > i);
394 i += 1;
395 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
396 }
397 // consume whitespace
398 while (i < src.len) : (i += 1) {
399 if (src[i] != ' ') break;
400 }
401
402 // get past command
403 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
404
405 assert(src.len > i);
406 return .{ .params = src[i + 1 ..] };
407 }
408
409 /// Returns the value of the tag 'key', if present
410 pub fn getTag(self: Message, key: []const u8) ?[]const u8 {
411 var tag_iter = self.tagIterator();
412 while (tag_iter.next()) |tag| {
413 if (!std.mem.eql(u8, tag.key, key)) continue;
414 return tag.value;
415 }
416 return null;
417 }
418
419 pub fn time(self: Message) ?zeit.Instant {
420 const val = self.getTag("time") orelse return null;
421
422 // Return null if we can't parse the time
423 const instant = zeit.instant(.{
424 .source = .{ .iso8601 = val },
425 .timezone = &zeit.utc,
426 }) catch return null;
427
428 return instant;
429 }
430
431 pub fn localTime(self: Message, tz: *const zeit.TimeZone) ?zeit.Instant {
432 const utc = self.time() orelse return null;
433 return utc.in(tz);
434 }
435
436 pub fn compareTime(_: void, lhs: Message, rhs: Message) bool {
437 const lhs_time = lhs.time() orelse return false;
438 const rhs_time = rhs.time() orelse return false;
439
440 return lhs_time.timestamp_ns < rhs_time.timestamp_ns;
441 }
442};
443
444pub const Client = struct {
445 pub const Config = struct {
446 user: []const u8,
447 nick: []const u8,
448 password: []const u8,
449 real_name: []const u8,
450 server: []const u8,
451 port: ?u16,
452 network_id: ?[]const u8 = null,
453 network_nick: ?[]const u8 = null,
454 name: ?[]const u8 = null,
455 tls: bool = true,
456 lua_table: i32,
457 };
458
459 pub const Capabilities = struct {
460 @"away-notify": bool = false,
461 batch: bool = false,
462 @"echo-message": bool = false,
463 @"message-tags": bool = false,
464 sasl: bool = false,
465 @"server-time": bool = false,
466
467 @"draft/chathistory": bool = false,
468 @"draft/no-implicit-names": bool = false,
469 @"draft/read-marker": bool = false,
470
471 @"soju.im/bouncer-networks": bool = false,
472 @"soju.im/bouncer-networks-notify": bool = false,
473 };
474
475 /// ISupport are features only advertised via ISUPPORT that we care about
476 pub const ISupport = struct {
477 whox: bool = false,
478 prefix: []const u8 = "",
479 };
480
481 alloc: std.mem.Allocator,
482 app: *comlink.App,
483 client: tls.Connection(std.net.Stream),
484 stream: std.net.Stream,
485 config: Config,
486
487 channels: std.ArrayList(*Channel),
488 users: std.StringHashMap(*User),
489
490 should_close: bool = false,
491 status: enum {
492 connected,
493 disconnected,
494 } = .disconnected,
495
496 caps: Capabilities = .{},
497 supports: ISupport = .{},
498
499 batches: std.StringHashMap(*Channel),
500 write_queue: *comlink.WriteQueue,
501
502 thread: ?std.Thread = null,
503
504 redraw: std.atomic.Value(bool),
505 fifo: std.fifo.LinearFifo(Event, .Dynamic),
506 fifo_mutex: std.Thread.Mutex,
507
508 pub fn init(
509 alloc: std.mem.Allocator,
510 app: *comlink.App,
511 wq: *comlink.WriteQueue,
512 cfg: Config,
513 ) !Client {
514 return .{
515 .alloc = alloc,
516 .app = app,
517 .client = undefined,
518 .stream = undefined,
519 .config = cfg,
520 .channels = std.ArrayList(*Channel).init(alloc),
521 .users = std.StringHashMap(*User).init(alloc),
522 .batches = std.StringHashMap(*Channel).init(alloc),
523 .write_queue = wq,
524 .redraw = std.atomic.Value(bool).init(false),
525 .fifo = std.fifo.LinearFifo(Event, .Dynamic).init(alloc),
526 .fifo_mutex = .{},
527 };
528 }
529
530 pub fn deinit(self: *Client) void {
531 self.should_close = true;
532 if (self.status == .connected) {
533 self.write("PING comlink\r\n") catch |err|
534 log.err("couldn't close tls conn: {}", .{err});
535 if (self.thread) |thread| {
536 thread.detach();
537 self.thread = null;
538 }
539 }
540 // id gets allocated in the main thread. We need to deallocate it here if
541 // we have one
542 if (self.config.network_id) |id| self.alloc.free(id);
543 if (self.config.name) |name| self.alloc.free(name);
544
545 if (self.config.network_nick) |nick| self.alloc.free(nick);
546
547 for (self.channels.items) |channel| {
548 channel.deinit(self.alloc);
549 self.alloc.destroy(channel);
550 }
551 self.channels.deinit();
552
553 var user_iter = self.users.valueIterator();
554 while (user_iter.next()) |user| {
555 user.*.deinit(self.alloc);
556 self.alloc.destroy(user.*);
557 }
558 self.users.deinit();
559 self.alloc.free(self.supports.prefix);
560 var batches = self.batches;
561 var iter = batches.keyIterator();
562 while (iter.next()) |key| {
563 self.alloc.free(key.*);
564 }
565 batches.deinit();
566 self.fifo.deinit();
567 }
568
569 pub fn typeErasedNameDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
570 const self: *Client = @ptrCast(@alignCast(ptr));
571 const text: vxfw.Text = .{
572 .text = self.config.name orelse self.config.server,
573 .softwrap = false,
574 };
575 return text.draw(ctx);
576 }
577
578 pub fn typeErasedNameSelectedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
579 const self: *Client = @ptrCast(@alignCast(ptr));
580 const text: vxfw.Text = .{
581 .text = self.config.name orelse self.config.server,
582 .softwrap = false,
583 .style = .{ .reverse = true },
584 };
585 return text.draw(ctx);
586 }
587
588 pub fn drainFifo(self: *Client) void {
589 self.fifo_mutex.lock();
590 defer self.fifo_mutex.unlock();
591 while (self.fifo.readItem()) |item| {
592 self.handleEvent(item) catch |err| {
593 log.err("error: {}", .{err});
594 };
595 }
596 }
597
598 pub fn handleEvent(self: *Client, event: Event) !void {
599 const msg: Message = .{ .bytes = event.msg.slice() };
600 const client = event.client;
601 defer event.msg.deinit();
602 switch (msg.command()) {
603 .unknown => {},
604 .CAP => {
605 // syntax: <client> <ACK/NACK> :caps
606 var iter = msg.paramIterator();
607 _ = iter.next() orelse return; // client
608 const ack_or_nak = iter.next() orelse return;
609 const caps = iter.next() orelse return;
610 var cap_iter = mem.splitScalar(u8, caps, ' ');
611 while (cap_iter.next()) |cap| {
612 if (mem.eql(u8, ack_or_nak, "ACK")) {
613 client.ack(cap);
614 if (mem.eql(u8, cap, "sasl"))
615 try client.queueWrite("AUTHENTICATE PLAIN\r\n");
616 } else if (mem.eql(u8, ack_or_nak, "NAK")) {
617 log.debug("CAP not supported {s}", .{cap});
618 }
619 }
620 },
621 .AUTHENTICATE => {
622 var iter = msg.paramIterator();
623 while (iter.next()) |param| {
624 // A '+' is the continuuation to send our
625 // AUTHENTICATE info
626 if (!mem.eql(u8, param, "+")) continue;
627 var buf: [4096]u8 = undefined;
628 const config = client.config;
629 const sasl = try std.fmt.bufPrint(
630 &buf,
631 "{s}\x00{s}\x00{s}",
632 .{ config.user, config.nick, config.password },
633 );
634
635 // Create a buffer big enough for the base64 encoded string
636 const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len));
637 defer self.alloc.free(b64_buf);
638 const encoded = Base64Encoder.encode(b64_buf, sasl);
639 // Make our message
640 const auth = try std.fmt.bufPrint(
641 &buf,
642 "AUTHENTICATE {s}\r\n",
643 .{encoded},
644 );
645 try client.queueWrite(auth);
646 if (config.network_id) |id| {
647 const bind = try std.fmt.bufPrint(
648 &buf,
649 "BOUNCER BIND {s}\r\n",
650 .{id},
651 );
652 try client.queueWrite(bind);
653 }
654 try client.queueWrite("CAP END\r\n");
655 }
656 },
657 .RPL_WELCOME => {
658 const now = try zeit.instant(.{});
659 var now_buf: [30]u8 = undefined;
660 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339);
661
662 const past = try now.subtract(.{ .days = 7 });
663 var past_buf: [30]u8 = undefined;
664 const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339);
665
666 var buf: [128]u8 = undefined;
667 const targets = try std.fmt.bufPrint(
668 &buf,
669 "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n",
670 .{ now_fmt, past_fmt },
671 );
672 try client.queueWrite(targets);
673 // on_connect callback
674 try lua.onConnect(self.app.lua, client);
675 },
676 .RPL_YOURHOST => {},
677 .RPL_CREATED => {},
678 .RPL_MYINFO => {},
679 .RPL_ISUPPORT => {
680 // syntax: <client> <token>[ <token>] :are supported
681 var iter = msg.paramIterator();
682 _ = iter.next() orelse return; // client
683 while (iter.next()) |token| {
684 if (mem.eql(u8, token, "WHOX"))
685 client.supports.whox = true
686 else if (mem.startsWith(u8, token, "PREFIX")) {
687 const prefix = blk: {
688 const idx = mem.indexOfScalar(u8, token, ')') orelse
689 // default is "@+"
690 break :blk try self.alloc.dupe(u8, "@+");
691 break :blk try self.alloc.dupe(u8, token[idx + 1 ..]);
692 };
693 client.supports.prefix = prefix;
694 }
695 }
696 },
697 .RPL_LOGGEDIN => {},
698 .RPL_TOPIC => {
699 // syntax: <client> <channel> :<topic>
700 var iter = msg.paramIterator();
701 _ = iter.next() orelse return; // client ("*")
702 const channel_name = iter.next() orelse return; // channel
703 const topic = iter.next() orelse return; // topic
704
705 var channel = try client.getOrCreateChannel(channel_name);
706 if (channel.topic) |old_topic| {
707 self.alloc.free(old_topic);
708 }
709 channel.topic = try self.alloc.dupe(u8, topic);
710 },
711 .RPL_SASLSUCCESS => {},
712 .RPL_WHOREPLY => {
713 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
714 var iter = msg.paramIterator();
715 _ = iter.next() orelse return; // client
716 const channel_name = iter.next() orelse return; // channel
717 if (mem.eql(u8, channel_name, "*")) return;
718 _ = iter.next() orelse return; // username
719 _ = iter.next() orelse return; // host
720 _ = iter.next() orelse return; // server
721 const nick = iter.next() orelse return; // nick
722 const flags = iter.next() orelse return; // flags
723
724 const user_ptr = try client.getOrCreateUser(nick);
725 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
726 var channel = try client.getOrCreateChannel(channel_name);
727
728 const prefix = for (flags) |c| {
729 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
730 break c;
731 }
732 } else ' ';
733
734 try channel.addMember(user_ptr, .{ .prefix = prefix });
735 },
736 .RPL_WHOSPCRPL => {
737 // syntax: <client> <channel> <nick> <flags> :<realname>
738 var iter = msg.paramIterator();
739 _ = iter.next() orelse return;
740 const channel_name = iter.next() orelse return; // channel
741 const nick = iter.next() orelse return;
742 const flags = iter.next() orelse return;
743
744 const user_ptr = try client.getOrCreateUser(nick);
745 if (iter.next()) |real_name| {
746 if (user_ptr.real_name) |old_name| {
747 self.alloc.free(old_name);
748 }
749 user_ptr.real_name = try self.alloc.dupe(u8, real_name);
750 }
751 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
752 var channel = try client.getOrCreateChannel(channel_name);
753
754 const prefix = for (flags) |c| {
755 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
756 break c;
757 }
758 } else ' ';
759
760 try channel.addMember(user_ptr, .{ .prefix = prefix });
761 },
762 .RPL_ENDOFWHO => {
763 // syntax: <client> <mask> :End of WHO list
764 var iter = msg.paramIterator();
765 _ = iter.next() orelse return; // client
766 const channel_name = iter.next() orelse return; // channel
767 if (mem.eql(u8, channel_name, "*")) return;
768 var channel = try client.getOrCreateChannel(channel_name);
769 channel.in_flight.who = false;
770 },
771 .RPL_NAMREPLY => {
772 // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>}
773 var iter = msg.paramIterator();
774 _ = iter.next() orelse return; // client
775 _ = iter.next() orelse return; // symbol
776 const channel_name = iter.next() orelse return; // channel
777 const names = iter.next() orelse return;
778 var channel = try client.getOrCreateChannel(channel_name);
779 var name_iter = std.mem.splitScalar(u8, names, ' ');
780 while (name_iter.next()) |name| {
781 const nick, const prefix = for (client.supports.prefix) |ch| {
782 if (name[0] == ch) {
783 break .{ name[1..], name[0] };
784 }
785 } else .{ name, ' ' };
786
787 if (prefix != ' ') {
788 log.debug("HAS PREFIX {s}", .{name});
789 }
790
791 const user_ptr = try client.getOrCreateUser(nick);
792
793 try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false });
794 }
795
796 channel.sortMembers();
797 },
798 .RPL_ENDOFNAMES => {
799 // syntax: <client> <channel> :End of /NAMES list
800 var iter = msg.paramIterator();
801 _ = iter.next() orelse return; // client
802 const channel_name = iter.next() orelse return; // channel
803 var channel = try client.getOrCreateChannel(channel_name);
804 channel.in_flight.names = false;
805 },
806 .BOUNCER => {
807 var iter = msg.paramIterator();
808 while (iter.next()) |param| {
809 if (mem.eql(u8, param, "NETWORK")) {
810 const id = iter.next() orelse continue;
811 const attr = iter.next() orelse continue;
812 // check if we already have this network
813 for (self.app.clients.items, 0..) |cl, i| {
814 if (cl.config.network_id) |net_id| {
815 if (mem.eql(u8, net_id, id)) {
816 if (mem.eql(u8, attr, "*")) {
817 // * means the network was
818 // deleted
819 cl.deinit();
820 _ = self.app.clients.swapRemove(i);
821 }
822 return;
823 }
824 }
825 }
826
827 var cfg = client.config;
828 cfg.network_id = try self.alloc.dupe(u8, id);
829
830 var attr_iter = std.mem.splitScalar(u8, attr, ';');
831 while (attr_iter.next()) |kv| {
832 const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue;
833 const key = kv[0..n];
834 if (mem.eql(u8, key, "name"))
835 cfg.name = try self.alloc.dupe(u8, kv[n + 1 ..])
836 else if (mem.eql(u8, key, "nickname"))
837 cfg.network_nick = try self.alloc.dupe(u8, kv[n + 1 ..]);
838 }
839 try self.app.connect(cfg);
840 }
841 }
842 },
843 .AWAY => {
844 const src = msg.source() orelse return;
845 var iter = msg.paramIterator();
846 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
847 const user = try client.getOrCreateUser(src[0..n]);
848 // If there are any params, the user is away. Otherwise
849 // they are back.
850 user.away = if (iter.next()) |_| true else false;
851 },
852 .BATCH => {
853 var iter = msg.paramIterator();
854 const tag = iter.next() orelse return;
855 switch (tag[0]) {
856 '+' => {
857 const batch_type = iter.next() orelse return;
858 if (mem.eql(u8, batch_type, "chathistory")) {
859 const target = iter.next() orelse return;
860 var channel = try client.getOrCreateChannel(target);
861 channel.at_oldest = true;
862 const duped_tag = try self.alloc.dupe(u8, tag[1..]);
863 try client.batches.put(duped_tag, channel);
864 }
865 },
866 '-' => {
867 const key = client.batches.getKey(tag[1..]) orelse return;
868 var chan = client.batches.get(key) orelse @panic("key should exist here");
869 chan.history_requested = false;
870 _ = client.batches.remove(key);
871 self.alloc.free(key);
872 },
873 else => {},
874 }
875 },
876 .CHATHISTORY => {
877 var iter = msg.paramIterator();
878 const should_targets = iter.next() orelse return;
879 if (!mem.eql(u8, should_targets, "TARGETS")) return;
880 const target = iter.next() orelse return;
881 // we only add direct messages, not more channels
882 assert(target.len > 0);
883 if (target[0] == '#') return;
884
885 var channel = try client.getOrCreateChannel(target);
886 const user_ptr = try client.getOrCreateUser(target);
887 const me_ptr = try client.getOrCreateUser(client.nickname());
888 try channel.addMember(user_ptr, .{});
889 try channel.addMember(me_ptr, .{});
890 // we set who_requested so we don't try to request
891 // who on DMs
892 channel.who_requested = true;
893 var buf: [128]u8 = undefined;
894 const mark_read = try std.fmt.bufPrint(
895 &buf,
896 "MARKREAD {s}\r\n",
897 .{channel.name},
898 );
899 try client.queueWrite(mark_read);
900 try client.requestHistory(.after, channel);
901 },
902 .JOIN => {
903 // get the user
904 const src = msg.source() orelse return;
905 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
906 const user = try client.getOrCreateUser(src[0..n]);
907
908 // get the channel
909 var iter = msg.paramIterator();
910 const target = iter.next() orelse return;
911 var channel = try client.getOrCreateChannel(target);
912
913 // If it's our nick, we request chat history
914 if (mem.eql(u8, user.nick, client.nickname())) {
915 try client.requestHistory(.after, channel);
916 if (self.app.explicit_join) {
917 self.app.selectChannelName(client, target);
918 self.app.explicit_join = false;
919 }
920 } else try channel.addMember(user, .{});
921 },
922 .MARKREAD => {
923 var iter = msg.paramIterator();
924 const target = iter.next() orelse return;
925 const timestamp = iter.next() orelse return;
926 const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse return;
927 const last_read = zeit.instant(.{
928 .source = .{
929 .iso8601 = timestamp[equal + 1 ..],
930 },
931 }) catch |err| {
932 log.err("couldn't convert timestamp: {}", .{err});
933 return;
934 };
935 var channel = try client.getOrCreateChannel(target);
936 channel.last_read = last_read.unixTimestamp();
937 const last_msg = channel.messages.getLastOrNull() orelse return;
938 const time = last_msg.time() orelse return;
939 if (time.unixTimestamp() > channel.last_read)
940 channel.has_unread = true
941 else
942 channel.has_unread = false;
943 },
944 .PART => {
945 // get the user
946 const src = msg.source() orelse return;
947 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
948 const user = try client.getOrCreateUser(src[0..n]);
949
950 // get the channel
951 var iter = msg.paramIterator();
952 const target = iter.next() orelse return;
953
954 if (mem.eql(u8, user.nick, client.nickname())) {
955 for (client.channels.items, 0..) |channel, i| {
956 if (!mem.eql(u8, channel.name, target)) continue;
957 var chan = client.channels.orderedRemove(i);
958 self.app.state.buffers.selected_idx -|= 1;
959 chan.deinit(self.app.alloc);
960 self.alloc.destroy(chan);
961 break;
962 }
963 } else {
964 const channel = try client.getOrCreateChannel(target);
965 channel.removeMember(user);
966 }
967 },
968 .PRIVMSG, .NOTICE => {
969 // syntax: <target> :<message>
970 const msg2: Message = .{
971 .bytes = try self.app.alloc.dupe(u8, msg.bytes),
972 };
973 var iter = msg2.paramIterator();
974 const target = blk: {
975 const tgt = iter.next() orelse return;
976 if (mem.eql(u8, tgt, client.nickname())) {
977 // If the target is us, it likely has our
978 // hostname in it.
979 const source = msg2.source() orelse return;
980 const n = mem.indexOfScalar(u8, source, '!') orelse source.len;
981 break :blk source[0..n];
982 } else break :blk tgt;
983 };
984
985 // We handle batches separately. When we encounter a
986 // PRIVMSG from a batch, we use the original target
987 // from the batch start. We also never notify from a
988 // batched message. Batched messages also require
989 // sorting
990 var tag_iter = msg2.tagIterator();
991 while (tag_iter.next()) |tag| {
992 if (mem.eql(u8, tag.key, "batch")) {
993 const entry = client.batches.getEntry(tag.value) orelse @panic("TODO");
994 var channel = entry.value_ptr.*;
995 try channel.messages.append(msg2);
996 std.sort.insertion(Message, channel.messages.items, {}, Message.compareTime);
997 channel.at_oldest = false;
998 const time = msg2.time() orelse continue;
999 if (time.unixTimestamp() > channel.last_read) {
1000 channel.has_unread = true;
1001 const content = iter.next() orelse continue;
1002 if (std.mem.indexOf(u8, content, client.nickname())) |_| {
1003 channel.has_unread_highlight = true;
1004 }
1005 }
1006 break;
1007 }
1008 } else {
1009 // standard handling
1010 var channel = try client.getOrCreateChannel(target);
1011 try channel.messages.append(msg2);
1012 const content = iter.next() orelse return;
1013 var has_highlight = false;
1014 {
1015 const sender: []const u8 = blk: {
1016 const src = msg2.source() orelse break :blk "";
1017 const l = std.mem.indexOfScalar(u8, src, '!') orelse
1018 std.mem.indexOfScalar(u8, src, '@') orelse
1019 src.len;
1020 break :blk src[0..l];
1021 };
1022 try lua.onMessage(self.app.lua, client, channel.name, sender, content);
1023 }
1024 if (std.mem.indexOf(u8, content, client.nickname())) |_| {
1025 var buf: [64]u8 = undefined;
1026 const title_or_err = if (msg2.source()) |source|
1027 std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, source })
1028 else
1029 std.fmt.bufPrint(&buf, "{s}", .{channel.name});
1030 const title = title_or_err catch title: {
1031 const len = @min(buf.len, channel.name.len);
1032 @memcpy(buf[0..len], channel.name[0..len]);
1033 break :title buf[0..len];
1034 };
1035 _ = title;
1036 // TODO: fix this
1037 // try self.vx.notify(writer, title, content);
1038 has_highlight = true;
1039 }
1040 const time = msg2.time() orelse return;
1041 if (time.unixTimestamp() > channel.last_read) {
1042 channel.has_unread_highlight = has_highlight;
1043 channel.has_unread = true;
1044 }
1045 }
1046
1047 // If we get a message from the current user mark the channel as
1048 // read, since they must have just sent the message.
1049 const sender: []const u8 = blk: {
1050 const src = msg2.source() orelse break :blk "";
1051 const l = std.mem.indexOfScalar(u8, src, '!') orelse
1052 std.mem.indexOfScalar(u8, src, '@') orelse
1053 src.len;
1054 break :blk src[0..l];
1055 };
1056 if (std.mem.eql(u8, sender, client.nickname())) {
1057 self.app.markSelectedChannelRead();
1058 }
1059 },
1060 }
1061 }
1062
1063 pub fn nickname(self: *Client) []const u8 {
1064 return self.config.network_nick orelse self.config.nick;
1065 }
1066
1067 pub fn ack(self: *Client, cap: []const u8) void {
1068 const info = @typeInfo(Capabilities);
1069 assert(info == .Struct);
1070
1071 inline for (info.Struct.fields) |field| {
1072 if (std.mem.eql(u8, field.name, cap)) {
1073 @field(self.caps, field.name) = true;
1074 return;
1075 }
1076 }
1077 }
1078
1079 pub fn read(self: *Client, buf: []u8) !usize {
1080 switch (self.config.tls) {
1081 true => return self.client.read(buf),
1082 false => return self.stream.read(buf),
1083 }
1084 }
1085
1086 pub fn readLoop(self: *Client) !void {
1087 var delay: u64 = 1 * std.time.ns_per_s;
1088
1089 while (!self.should_close) {
1090 self.status = .disconnected;
1091 log.debug("reconnecting in {d} seconds...", .{@divFloor(delay, std.time.ns_per_s)});
1092 self.connect() catch |err| {
1093 log.err("connection error: {}", .{err});
1094 self.status = .disconnected;
1095 log.debug("disconnected", .{});
1096 log.debug("reconnecting in {d} seconds...", .{@divFloor(delay, std.time.ns_per_s)});
1097 std.time.sleep(delay);
1098 delay = delay * 2;
1099 if (delay > std.time.ns_per_min) delay = std.time.ns_per_min;
1100 continue;
1101 };
1102 log.debug("connected", .{});
1103 self.status = .connected;
1104 delay = 1 * std.time.ns_per_s;
1105
1106 var buf: [16_384]u8 = undefined;
1107
1108 // 4x max size. We will almost always be *way* under our maximum size, so we will have a
1109 // lot more potential messages than just 4
1110 var pool: MessagePool = .{};
1111 pool.init();
1112
1113 errdefer |err| {
1114 log.err("client: {s} error: {}", .{ self.config.network_id.?, err });
1115 }
1116
1117 const timeout = std.mem.toBytes(std.posix.timeval{
1118 .tv_sec = 5,
1119 .tv_usec = 0,
1120 });
1121
1122 const keep_alive: i64 = 10 * std.time.ms_per_s;
1123 // max round trip time equal to our timeout
1124 const max_rt: i64 = 5 * std.time.ms_per_s;
1125 var last_msg: i64 = std.time.milliTimestamp();
1126 var start: usize = 0;
1127
1128 while (true) {
1129 try std.posix.setsockopt(
1130 self.stream.handle,
1131 std.posix.SOL.SOCKET,
1132 std.posix.SO.RCVTIMEO,
1133 &timeout,
1134 );
1135 const n = self.read(buf[start..]) catch |err| {
1136 if (err != error.WouldBlock) break;
1137 const now = std.time.milliTimestamp();
1138 if (now - last_msg > keep_alive + max_rt) {
1139 // reconnect??
1140 self.status = .disconnected;
1141 self.redraw.store(true, .unordered);
1142 break;
1143 }
1144 if (now - last_msg > keep_alive) {
1145 // send a ping
1146 try self.queueWrite("PING comlink\r\n");
1147 continue;
1148 }
1149 continue;
1150 };
1151 if (self.should_close) return;
1152 if (n == 0) {
1153 self.status = .disconnected;
1154 self.redraw.store(true, .unordered);
1155 break;
1156 }
1157 last_msg = std.time.milliTimestamp();
1158 var i: usize = 0;
1159 while (std.mem.indexOfPos(u8, buf[0 .. n + start], i, "\r\n")) |idx| {
1160 defer i = idx + 2;
1161 const buffer = pool.alloc(idx - i);
1162 // const line = try self.alloc.dupe(u8, buf[i..idx]);
1163 @memcpy(buffer.slice(), buf[i..idx]);
1164 assert(std.mem.eql(u8, buf[idx .. idx + 2], "\r\n"));
1165 log.debug("[<-{s}] {s}", .{ self.config.name orelse self.config.server, buffer.slice() });
1166 try self.fifo.writeItem(.{ .client = self, .msg = buffer });
1167 }
1168 if (i != n) {
1169 // we had a part of a line read. Copy it to the beginning of the
1170 // buffer
1171 std.mem.copyForwards(u8, buf[0 .. (n + start) - i], buf[i..(n + start)]);
1172 start = (n + start) - i;
1173 } else start = 0;
1174 }
1175 }
1176 }
1177
1178 pub fn print(self: *Client, comptime fmt: []const u8, args: anytype) Allocator.Error!void {
1179 const msg = try std.fmt.allocPrint(self.alloc, fmt, args);
1180 self.write_queue.push(.{ .write = .{
1181 .client = self,
1182 .msg = msg,
1183 } });
1184 }
1185
1186 /// push a write request into the queue. The request should include the trailing
1187 /// '\r\n'. queueWrite will dupe the message and free after processing.
1188 pub fn queueWrite(self: *Client, msg: []const u8) Allocator.Error!void {
1189 self.write_queue.push(.{ .write = .{
1190 .client = self,
1191 .msg = try self.alloc.dupe(u8, msg),
1192 } });
1193 }
1194
1195 pub fn write(self: *Client, buf: []const u8) !void {
1196 log.debug("[->{s}] {s}", .{ self.config.name orelse self.config.server, buf[0 .. buf.len - 2] });
1197 switch (self.config.tls) {
1198 true => try self.client.writeAll(buf),
1199 false => try self.stream.writeAll(buf),
1200 }
1201 }
1202
1203 pub fn connect(self: *Client) !void {
1204 if (self.config.tls) {
1205 const port: u16 = self.config.port orelse 6697;
1206 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, port);
1207 self.client = try tls.client(self.stream, .{
1208 .host = self.config.server,
1209 .root_ca = self.app.bundle,
1210 });
1211 } else {
1212 const port: u16 = self.config.port orelse 6667;
1213 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, port);
1214 }
1215
1216 try self.queueWrite("CAP LS 302\r\n");
1217
1218 const cap_names = std.meta.fieldNames(Capabilities);
1219 for (cap_names) |cap| {
1220 try self.print(
1221 "CAP REQ :{s}\r\n",
1222 .{cap},
1223 );
1224 }
1225
1226 try self.print(
1227 "NICK {s}\r\n",
1228 .{self.config.nick},
1229 );
1230
1231 try self.print(
1232 "USER {s} 0 * {s}\r\n",
1233 .{ self.config.user, self.config.real_name },
1234 );
1235 }
1236
1237 pub fn getOrCreateChannel(self: *Client, name: []const u8) !*Channel {
1238 for (self.channels.items) |channel| {
1239 if (caseFold(name, channel.name)) return channel;
1240 }
1241 const channel = try self.alloc.create(Channel);
1242 channel.* = .{
1243 .name = try self.alloc.dupe(u8, name),
1244 .members = std.ArrayList(Channel.Member).init(self.alloc),
1245 .messages = std.ArrayList(Message).init(self.alloc),
1246 .client = self,
1247 };
1248 try self.channels.append(channel);
1249
1250 std.sort.insertion(*Channel, self.channels.items, {}, Channel.compare);
1251 return channel;
1252 }
1253
1254 var color_indices = [_]u8{ 1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14 };
1255
1256 pub fn getOrCreateUser(self: *Client, nick: []const u8) !*User {
1257 return self.users.get(nick) orelse {
1258 const color_u32 = std.hash.Fnv1a_32.hash(nick);
1259 const index = color_u32 % color_indices.len;
1260 const color_index = color_indices[index];
1261
1262 const color: vaxis.Color = .{
1263 .index = color_index,
1264 };
1265 const user = try self.alloc.create(User);
1266 user.* = .{
1267 .nick = try self.alloc.dupe(u8, nick),
1268 .color = color,
1269 };
1270 try self.users.put(user.nick, user);
1271 return user;
1272 };
1273 }
1274
1275 pub fn whox(self: *Client, channel: *Channel) !void {
1276 channel.who_requested = true;
1277 if (channel.name.len > 0 and
1278 channel.name[0] != '#')
1279 {
1280 const other = try self.getOrCreateUser(channel.name);
1281 const me = try self.getOrCreateUser(self.config.nick);
1282 try channel.addMember(other, .{});
1283 try channel.addMember(me, .{});
1284 return;
1285 }
1286 // Only use WHO if we have WHOX and away-notify. Without
1287 // WHOX, we can get rate limited on eg. libera. Without
1288 // away-notify, our list will become stale
1289 if (self.supports.whox and
1290 self.caps.@"away-notify" and
1291 !channel.in_flight.who)
1292 {
1293 channel.in_flight.who = true;
1294 try self.print(
1295 "WHO {s} %cnfr\r\n",
1296 .{channel.name},
1297 );
1298 } else {
1299 channel.in_flight.names = true;
1300 try self.print(
1301 "NAMES {s}\r\n",
1302 .{channel.name},
1303 );
1304 }
1305 }
1306
1307 /// fetch the history for the provided channel.
1308 pub fn requestHistory(self: *Client, cmd: ChatHistoryCommand, channel: *Channel) !void {
1309 if (!self.caps.@"draft/chathistory") return;
1310 if (channel.history_requested) return;
1311
1312 channel.history_requested = true;
1313
1314 if (channel.messages.items.len == 0) {
1315 try self.print(
1316 "CHATHISTORY LATEST {s} * 50\r\n",
1317 .{channel.name},
1318 );
1319 channel.history_requested = true;
1320 return;
1321 }
1322
1323 switch (cmd) {
1324 .before => {
1325 assert(channel.messages.items.len > 0);
1326 const first = channel.messages.items[0];
1327 const time = first.getTag("time") orelse
1328 return error.NoTimeTag;
1329 try self.print(
1330 "CHATHISTORY BEFORE {s} timestamp={s} 50\r\n",
1331 .{ channel.name, time },
1332 );
1333 channel.history_requested = true;
1334 },
1335 .after => {
1336 assert(channel.messages.items.len > 0);
1337 const last = channel.messages.getLast();
1338 const time = last.getTag("time") orelse
1339 return error.NoTimeTag;
1340 try self.print(
1341 // we request 500 because we have no
1342 // idea how long we've been offline
1343 "CHATHISTORY AFTER {s} timestamp={s} 500\r\n",
1344 .{ channel.name, time },
1345 );
1346 channel.history_requested = true;
1347 },
1348 }
1349 }
1350};
1351
1352pub fn toVaxisColor(irc: u8) vaxis.Color {
1353 return switch (irc) {
1354 0 => .default, // white
1355 1 => .{ .index = 0 }, // black
1356 2 => .{ .index = 4 }, // blue
1357 3 => .{ .index = 2 }, // green
1358 4 => .{ .index = 1 }, // red
1359 5 => .{ .index = 3 }, // brown
1360 6 => .{ .index = 5 }, // magenta
1361 7 => .{ .index = 11 }, // orange
1362 8 => .{ .index = 11 }, // yellow
1363 9 => .{ .index = 10 }, // light green
1364 10 => .{ .index = 6 }, // cyan
1365 11 => .{ .index = 14 }, // light cyan
1366 12 => .{ .index = 12 }, // light blue
1367 13 => .{ .index = 13 }, // pink
1368 14 => .{ .index = 8 }, // grey
1369 15 => .{ .index = 7 }, // light grey
1370
1371 // 16 to 98 are specifically defined
1372 16 => .{ .index = 52 },
1373 17 => .{ .index = 94 },
1374 18 => .{ .index = 100 },
1375 19 => .{ .index = 58 },
1376 20 => .{ .index = 22 },
1377 21 => .{ .index = 29 },
1378 22 => .{ .index = 23 },
1379 23 => .{ .index = 24 },
1380 24 => .{ .index = 17 },
1381 25 => .{ .index = 54 },
1382 26 => .{ .index = 53 },
1383 27 => .{ .index = 89 },
1384 28 => .{ .index = 88 },
1385 29 => .{ .index = 130 },
1386 30 => .{ .index = 142 },
1387 31 => .{ .index = 64 },
1388 32 => .{ .index = 28 },
1389 33 => .{ .index = 35 },
1390 34 => .{ .index = 30 },
1391 35 => .{ .index = 25 },
1392 36 => .{ .index = 18 },
1393 37 => .{ .index = 91 },
1394 38 => .{ .index = 90 },
1395 39 => .{ .index = 125 },
1396 // TODO: finish these out https://modern.ircdocs.horse/formatting#color
1397
1398 99 => .default,
1399
1400 else => .{ .index = irc },
1401 };
1402}
1403
1404const CaseMapAlgo = enum {
1405 ascii,
1406 rfc1459,
1407 rfc1459_strict,
1408};
1409
1410pub fn caseMap(char: u8, algo: CaseMapAlgo) u8 {
1411 switch (algo) {
1412 .ascii => {
1413 switch (char) {
1414 'A'...'Z' => return char + 0x20,
1415 else => return char,
1416 }
1417 },
1418 .rfc1459 => {
1419 switch (char) {
1420 'A'...'^' => return char + 0x20,
1421 else => return char,
1422 }
1423 },
1424 .rfc1459_strict => {
1425 switch (char) {
1426 'A'...']' => return char + 0x20,
1427 else => return char,
1428 }
1429 },
1430 }
1431}
1432
1433pub fn caseFold(a: []const u8, b: []const u8) bool {
1434 if (a.len != b.len) return false;
1435 var i: usize = 0;
1436 while (i < a.len) {
1437 const diff = std.mem.indexOfDiff(u8, a[i..], b[i..]) orelse return true;
1438 const a_diff = caseMap(a[diff], .rfc1459);
1439 const b_diff = caseMap(b[diff], .rfc1459);
1440 if (a_diff != b_diff) return false;
1441 i += diff + 1;
1442 }
1443 return true;
1444}
1445
1446pub const ChatHistoryCommand = enum {
1447 before,
1448 after,
1449};
1450
1451test "caseFold" {
1452 try testing.expect(caseFold("a", "A"));
1453 try testing.expect(caseFold("aBcDeFgH", "abcdefgh"));
1454}
1455
1456test "simple message" {
1457 const msg: Message = .{ .bytes = "JOIN" };
1458 try testing.expect(msg.command() == .JOIN);
1459}
1460
1461test "simple message with extra whitespace" {
1462 const msg: Message = .{ .bytes = "JOIN " };
1463 try testing.expect(msg.command() == .JOIN);
1464}
1465
1466test "well formed message with tags, source, params" {
1467 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
1468
1469 var tag_iter = msg.tagIterator();
1470 const tag = tag_iter.next();
1471 try testing.expect(tag != null);
1472 try testing.expectEqualStrings("key", tag.?.key);
1473 try testing.expectEqualStrings("value", tag.?.value);
1474 try testing.expect(tag_iter.next() == null);
1475
1476 const source = msg.source();
1477 try testing.expect(source != null);
1478 try testing.expectEqualStrings("example.chat", source.?);
1479 try testing.expect(msg.command() == .JOIN);
1480
1481 var param_iter = msg.paramIterator();
1482 const p1 = param_iter.next();
1483 const p2 = param_iter.next();
1484 try testing.expect(p1 != null);
1485 try testing.expect(p2 != null);
1486 try testing.expectEqualStrings("abc", p1.?);
1487 try testing.expectEqualStrings("def", p2.?);
1488
1489 try testing.expect(param_iter.next() == null);
1490}
1491
1492test "message with tags, source, params and extra whitespace" {
1493 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
1494
1495 var tag_iter = msg.tagIterator();
1496 const tag = tag_iter.next();
1497 try testing.expect(tag != null);
1498 try testing.expectEqualStrings("key", tag.?.key);
1499 try testing.expectEqualStrings("value", tag.?.value);
1500 try testing.expect(tag_iter.next() == null);
1501
1502 const source = msg.source();
1503 try testing.expect(source != null);
1504 try testing.expectEqualStrings("example.chat", source.?);
1505 try testing.expect(msg.command() == .JOIN);
1506
1507 var param_iter = msg.paramIterator();
1508 const p1 = param_iter.next();
1509 const p2 = param_iter.next();
1510 try testing.expect(p1 != null);
1511 try testing.expect(p2 != null);
1512 try testing.expectEqualStrings("abc", p1.?);
1513 try testing.expectEqualStrings("def", p2.?);
1514
1515 try testing.expect(param_iter.next() == null);
1516}
1517
1518test "param iterator: simple list" {
1519 var iter: Message.ParamIterator = .{ .params = "a b c" };
1520 var i: usize = 0;
1521 while (iter.next()) |param| {
1522 switch (i) {
1523 0 => try testing.expectEqualStrings("a", param),
1524 1 => try testing.expectEqualStrings("b", param),
1525 2 => try testing.expectEqualStrings("c", param),
1526 else => return error.TooManyParams,
1527 }
1528 i += 1;
1529 }
1530 try testing.expect(i == 3);
1531}
1532
1533test "param iterator: trailing colon" {
1534 var iter: Message.ParamIterator = .{ .params = "* LS :" };
1535 var i: usize = 0;
1536 while (iter.next()) |param| {
1537 switch (i) {
1538 0 => try testing.expectEqualStrings("*", param),
1539 1 => try testing.expectEqualStrings("LS", param),
1540 2 => try testing.expectEqualStrings("", param),
1541 else => return error.TooManyParams,
1542 }
1543 i += 1;
1544 }
1545 try testing.expect(i == 3);
1546}
1547
1548test "param iterator: colon" {
1549 var iter: Message.ParamIterator = .{ .params = "* LS :sasl multi-prefix" };
1550 var i: usize = 0;
1551 while (iter.next()) |param| {
1552 switch (i) {
1553 0 => try testing.expectEqualStrings("*", param),
1554 1 => try testing.expectEqualStrings("LS", param),
1555 2 => try testing.expectEqualStrings("sasl multi-prefix", param),
1556 else => return error.TooManyParams,
1557 }
1558 i += 1;
1559 }
1560 try testing.expect(i == 3);
1561}
1562
1563test "param iterator: colon and leading colon" {
1564 var iter: Message.ParamIterator = .{ .params = "* LS ::)" };
1565 var i: usize = 0;
1566 while (iter.next()) |param| {
1567 switch (i) {
1568 0 => try testing.expectEqualStrings("*", param),
1569 1 => try testing.expectEqualStrings("LS", param),
1570 2 => try testing.expectEqualStrings(":)", param),
1571 else => return error.TooManyParams,
1572 }
1573 i += 1;
1574 }
1575 try testing.expect(i == 3);
1576}