an experimental irc client
1const std = @import("std");
2const comlink = @import("comlink.zig");
3const tls = @import("tls");
4const vaxis = @import("vaxis");
5const zeit = @import("zeit");
6const bytepool = @import("pool.zig");
7
8const testing = std.testing;
9
10pub const MessagePool = bytepool.BytePool(max_raw_msg_size * 4);
11pub const Slice = MessagePool.Slice;
12
13const assert = std.debug.assert;
14
15const log = std.log.scoped(.irc);
16
17/// maximum size message we can write
18pub const maximum_message_size = 512;
19
20/// maximum size message we can receive
21const max_raw_msg_size = 512 + 8191; // see modernircdocs
22
23pub const Buffer = union(enum) {
24 client: *Client,
25 channel: *Channel,
26};
27
28pub const Command = enum {
29 RPL_WELCOME, // 001
30 RPL_YOURHOST, // 002
31 RPL_CREATED, // 003
32 RPL_MYINFO, // 004
33 RPL_ISUPPORT, // 005
34
35 RPL_ENDOFWHO, // 315
36 RPL_TOPIC, // 332
37 RPL_WHOREPLY, // 352
38 RPL_NAMREPLY, // 353
39 RPL_WHOSPCRPL, // 354
40 RPL_ENDOFNAMES, // 366
41
42 RPL_LOGGEDIN, // 900
43 RPL_SASLSUCCESS, // 903
44
45 // Named commands
46 AUTHENTICATE,
47 AWAY,
48 BATCH,
49 BOUNCER,
50 CAP,
51 CHATHISTORY,
52 JOIN,
53 MARKREAD,
54 NOTICE,
55 PART,
56 PRIVMSG,
57
58 unknown,
59
60 const map = std.StaticStringMap(Command).initComptime(.{
61 .{ "001", .RPL_WELCOME },
62 .{ "002", .RPL_YOURHOST },
63 .{ "003", .RPL_CREATED },
64 .{ "004", .RPL_MYINFO },
65 .{ "005", .RPL_ISUPPORT },
66
67 .{ "315", .RPL_ENDOFWHO },
68 .{ "332", .RPL_TOPIC },
69 .{ "352", .RPL_WHOREPLY },
70 .{ "353", .RPL_NAMREPLY },
71 .{ "354", .RPL_WHOSPCRPL },
72 .{ "366", .RPL_ENDOFNAMES },
73
74 .{ "900", .RPL_LOGGEDIN },
75 .{ "903", .RPL_SASLSUCCESS },
76
77 .{ "AUTHENTICATE", .AUTHENTICATE },
78 .{ "AWAY", .AWAY },
79 .{ "BATCH", .BATCH },
80 .{ "BOUNCER", .BOUNCER },
81 .{ "CAP", .CAP },
82 .{ "CHATHISTORY", .CHATHISTORY },
83 .{ "JOIN", .JOIN },
84 .{ "MARKREAD", .MARKREAD },
85 .{ "NOTICE", .NOTICE },
86 .{ "PART", .PART },
87 .{ "PRIVMSG", .PRIVMSG },
88 });
89
90 pub fn parse(cmd: []const u8) Command {
91 return map.get(cmd) orelse .unknown;
92 }
93};
94
95pub const Channel = struct {
96 client: *Client,
97 name: []const u8,
98 topic: ?[]const u8 = null,
99 members: std.ArrayList(Member),
100 in_flight: struct {
101 who: bool = false,
102 names: bool = false,
103 } = .{},
104
105 messages: std.ArrayList(Message),
106 history_requested: bool = false,
107 who_requested: bool = false,
108 at_oldest: bool = false,
109 last_read: i64 = 0,
110 has_unread: bool = false,
111 has_unread_highlight: bool = false,
112
113 pub const Member = struct {
114 user: *User,
115
116 /// Highest channel membership prefix (or empty space if no prefix)
117 prefix: u8,
118
119 pub fn compare(_: void, lhs: Member, rhs: Member) bool {
120 return if (lhs.prefix != ' ' and rhs.prefix == ' ')
121 true
122 else if (lhs.prefix == ' ' and rhs.prefix != ' ')
123 false
124 else
125 std.ascii.orderIgnoreCase(lhs.user.nick, rhs.user.nick).compare(.lt);
126 }
127 };
128
129 pub fn deinit(self: *const Channel, alloc: std.mem.Allocator) void {
130 alloc.free(self.name);
131 self.members.deinit();
132 if (self.topic) |topic| {
133 alloc.free(topic);
134 }
135 for (self.messages.items) |msg| {
136 alloc.free(msg.bytes);
137 }
138 self.messages.deinit();
139 }
140
141 pub fn compare(_: void, lhs: Channel, rhs: Channel) bool {
142 return std.ascii.orderIgnoreCase(lhs.name, rhs.name).compare(std.math.CompareOperator.lt);
143 }
144
145 pub fn compareRecentMessages(self: *Channel, lhs: Member, rhs: Member) bool {
146 var l: i64 = 0;
147 var r: i64 = 0;
148 var iter = std.mem.reverseIterator(self.messages.items);
149 while (iter.next()) |msg| {
150 if (msg.source()) |source| {
151 const bang = std.mem.indexOfScalar(u8, source, '!') orelse source.len;
152 const nick = source[0..bang];
153
154 if (l == 0 and msg.time() != null and std.mem.eql(u8, lhs.user.nick, nick)) {
155 l = msg.time().?.unixTimestamp();
156 } else if (r == 0 and msg.time() != null and std.mem.eql(u8, rhs.user.nick, nick))
157 r = msg.time().?.unixTimestamp();
158 }
159 if (l > 0 and r > 0) break;
160 }
161 return l < r;
162 }
163
164 pub fn sortMembers(self: *Channel) void {
165 std.sort.insertion(Member, self.members.items, {}, Member.compare);
166 }
167
168 pub fn addMember(self: *Channel, user: *User, args: struct {
169 prefix: ?u8 = null,
170 sort: bool = true,
171 }) !void {
172 if (args.prefix) |p| {
173 log.debug("adding member: nick={s}, prefix={c}", .{ user.nick, p });
174 }
175 for (self.members.items) |*member| {
176 if (user == member.user) {
177 // Update the prefix for an existing member if the prefix is
178 // known
179 if (args.prefix) |p| member.prefix = p;
180 return;
181 }
182 }
183
184 try self.members.append(.{ .user = user, .prefix = args.prefix orelse ' ' });
185
186 if (args.sort) {
187 self.sortMembers();
188 }
189 }
190
191 pub fn removeMember(self: *Channel, user: *User) void {
192 for (self.members.items, 0..) |member, i| {
193 if (user == member.user) {
194 _ = self.members.orderedRemove(i);
195 return;
196 }
197 }
198 }
199
200 /// issue a MARKREAD command for this channel. The most recent message in the channel will be used as
201 /// the last read time
202 pub fn markRead(self: *Channel) !void {
203 if (!self.has_unread) return;
204
205 self.has_unread = false;
206 self.has_unread_highlight = false;
207 const last_msg = self.messages.getLast();
208 const time_tag = last_msg.getTag("time") orelse return;
209 var write_buf: [128]u8 = undefined;
210 const mark_read = try std.fmt.bufPrint(
211 &write_buf,
212 "MARKREAD {s} timestamp={s}\r\n",
213 .{
214 self.name,
215 time_tag,
216 },
217 );
218 try self.client.queueWrite(mark_read);
219 }
220};
221
222pub const User = struct {
223 nick: []const u8,
224 away: bool = false,
225 color: vaxis.Color = .default,
226
227 pub fn deinit(self: *const User, alloc: std.mem.Allocator) void {
228 alloc.free(self.nick);
229 }
230};
231
232/// an irc message
233pub const Message = struct {
234 bytes: []const u8,
235
236 pub const ParamIterator = struct {
237 params: ?[]const u8,
238 index: usize = 0,
239
240 pub fn next(self: *ParamIterator) ?[]const u8 {
241 const params = self.params orelse return null;
242 if (self.index >= params.len) return null;
243
244 // consume leading whitespace
245 while (self.index < params.len) {
246 if (params[self.index] != ' ') break;
247 self.index += 1;
248 }
249
250 const start = self.index;
251 if (start >= params.len) return null;
252
253 // If our first byte is a ':', we return the rest of the string as a
254 // single param (or the empty string)
255 if (params[start] == ':') {
256 self.index = params.len;
257 if (start == params.len - 1) {
258 return "";
259 }
260 return params[start + 1 ..];
261 }
262
263 // Find the first index of space. If we don't have any, the reset of
264 // the line is the last param
265 self.index = std.mem.indexOfScalarPos(u8, params, self.index, ' ') orelse {
266 defer self.index = params.len;
267 return params[start..];
268 };
269
270 return params[start..self.index];
271 }
272 };
273
274 pub const Tag = struct {
275 key: []const u8,
276 value: []const u8,
277 };
278
279 pub const TagIterator = struct {
280 tags: []const u8,
281 index: usize = 0,
282
283 // tags are a list of key=value pairs delimited by semicolons.
284 // key[=value] [; key[=value]]
285 pub fn next(self: *TagIterator) ?Tag {
286 if (self.index >= self.tags.len) return null;
287
288 // find next delimiter
289 const end = std.mem.indexOfScalarPos(u8, self.tags, self.index, ';') orelse self.tags.len;
290 var kv_delim = std.mem.indexOfScalarPos(u8, self.tags, self.index, '=') orelse end;
291 // it's possible to have tags like this:
292 // @bot;account=botaccount;+typing=active
293 // where the first tag doesn't have a value. Guard against the
294 // kv_delim being past the end position
295 if (kv_delim > end) kv_delim = end;
296
297 defer self.index = end + 1;
298
299 return .{
300 .key = self.tags[self.index..kv_delim],
301 .value = if (end == kv_delim) "" else self.tags[kv_delim + 1 .. end],
302 };
303 }
304 };
305
306 pub fn tagIterator(msg: Message) TagIterator {
307 const src = msg.bytes;
308 if (src[0] != '@') return .{ .tags = "" };
309
310 assert(src.len > 1);
311 const n = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse src.len;
312 return .{ .tags = src[1..n] };
313 }
314
315 pub fn source(msg: Message) ?[]const u8 {
316 const src = msg.bytes;
317 var i: usize = 0;
318
319 // get past tags
320 if (src[0] == '@') {
321 assert(src.len > 1);
322 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return null;
323 }
324
325 // consume whitespace
326 while (i < src.len) : (i += 1) {
327 if (src[i] != ' ') break;
328 }
329
330 // Start of source
331 if (src[i] == ':') {
332 assert(src.len > i);
333 i += 1;
334 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
335 return src[i..end];
336 }
337
338 return null;
339 }
340
341 pub fn command(msg: Message) Command {
342 const src = msg.bytes;
343 var i: usize = 0;
344
345 // get past tags
346 if (src[0] == '@') {
347 assert(src.len > 1);
348 i = std.mem.indexOfScalarPos(u8, src, 1, ' ') orelse return .unknown;
349 }
350 // consume whitespace
351 while (i < src.len) : (i += 1) {
352 if (src[i] != ' ') break;
353 }
354
355 // get past source
356 if (src[i] == ':') {
357 assert(src.len > i);
358 i += 1;
359 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .unknown;
360 }
361 // consume whitespace
362 while (i < src.len) : (i += 1) {
363 if (src[i] != ' ') break;
364 }
365
366 assert(src.len > i);
367 // Find next space
368 const end = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse src.len;
369 return Command.parse(src[i..end]);
370 }
371
372 pub fn paramIterator(msg: Message) ParamIterator {
373 const src = msg.bytes;
374 var i: usize = 0;
375
376 // get past tags
377 if (src[0] == '@') {
378 i = std.mem.indexOfScalarPos(u8, src, 0, ' ') orelse return .{ .params = "" };
379 }
380 // consume whitespace
381 while (i < src.len) : (i += 1) {
382 if (src[i] != ' ') break;
383 }
384
385 // get past source
386 if (src[i] == ':') {
387 assert(src.len > i);
388 i += 1;
389 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
390 }
391 // consume whitespace
392 while (i < src.len) : (i += 1) {
393 if (src[i] != ' ') break;
394 }
395
396 // get past command
397 i = std.mem.indexOfScalarPos(u8, src, i, ' ') orelse return .{ .params = "" };
398
399 assert(src.len > i);
400 return .{ .params = src[i + 1 ..] };
401 }
402
403 /// Returns the value of the tag 'key', if present
404 pub fn getTag(self: Message, key: []const u8) ?[]const u8 {
405 var tag_iter = self.tagIterator();
406 while (tag_iter.next()) |tag| {
407 if (!std.mem.eql(u8, tag.key, key)) continue;
408 return tag.value;
409 }
410 return null;
411 }
412
413 pub fn time(self: Message) ?zeit.Instant {
414 const val = self.getTag("time") orelse return null;
415
416 // Return null if we can't parse the time
417 const instant = zeit.instant(.{
418 .source = .{ .iso8601 = val },
419 .timezone = &zeit.utc,
420 }) catch return null;
421
422 return instant;
423 }
424
425 pub fn localTime(self: Message, tz: *const zeit.TimeZone) ?zeit.Instant {
426 const utc = self.time() orelse return null;
427 return utc.in(tz);
428 }
429
430 pub fn compareTime(_: void, lhs: Message, rhs: Message) bool {
431 const lhs_time = lhs.time() orelse return false;
432 const rhs_time = rhs.time() orelse return false;
433
434 return lhs_time.timestamp < rhs_time.timestamp;
435 }
436};
437
438pub const Client = struct {
439 pub const Config = struct {
440 user: []const u8,
441 nick: []const u8,
442 password: []const u8,
443 real_name: []const u8,
444 server: []const u8,
445 network_id: ?[]const u8 = null,
446 name: ?[]const u8 = null,
447 tls: bool = true,
448 lua_table: i32,
449 };
450
451 pub const Capabilities = struct {
452 @"away-notify": bool = false,
453 batch: bool = false,
454 @"echo-message": bool = false,
455 @"message-tags": bool = false,
456 sasl: bool = false,
457 @"server-time": bool = false,
458
459 @"draft/chathistory": bool = false,
460 @"draft/no-implicit-names": bool = false,
461 @"draft/read-marker": bool = false,
462
463 @"soju.im/bouncer-networks": bool = false,
464 @"soju.im/bouncer-networks-notify": bool = false,
465 };
466
467 /// ISupport are features only advertised via ISUPPORT that we care about
468 pub const ISupport = struct {
469 whox: bool = false,
470 prefix: []const u8 = "",
471 };
472
473 alloc: std.mem.Allocator,
474 app: *comlink.App,
475 client: tls.Connection(std.net.Stream),
476 stream: std.net.Stream,
477 config: Config,
478
479 channels: std.ArrayList(Channel),
480 users: std.StringHashMap(*User),
481
482 should_close: bool = false,
483 status: enum {
484 connected,
485 disconnected,
486 } = .disconnected,
487
488 caps: Capabilities = .{},
489 supports: ISupport = .{},
490
491 batches: std.StringHashMap(*Channel),
492 write_queue: *comlink.WriteQueue,
493
494 thread: ?std.Thread = null,
495
496 pub fn init(alloc: std.mem.Allocator, app: *comlink.App, wq: *comlink.WriteQueue, cfg: Config) !Client {
497 return .{
498 .alloc = alloc,
499 .app = app,
500 .client = undefined,
501 .stream = undefined,
502 .config = cfg,
503 .channels = std.ArrayList(Channel).init(alloc),
504 .users = std.StringHashMap(*User).init(alloc),
505 .batches = std.StringHashMap(*Channel).init(alloc),
506 .write_queue = wq,
507 };
508 }
509
510 pub fn deinit(self: *Client) void {
511 self.should_close = true;
512 if (self.status == .connected) {
513 self.write("PING comlink\r\n") catch |err|
514 log.err("couldn't close tls conn: {}", .{err});
515 if (self.thread) |thread| {
516 thread.detach();
517 self.thread = null;
518 }
519 }
520 // id gets allocated in the main thread. We need to deallocate it here if
521 // we have one
522 if (self.config.network_id) |id| self.alloc.free(id);
523 if (self.config.name) |name| self.alloc.free(name);
524
525 for (self.channels.items) |channel| {
526 channel.deinit(self.alloc);
527 }
528 self.channels.deinit();
529
530 var user_iter = self.users.valueIterator();
531 while (user_iter.next()) |user| {
532 user.*.deinit(self.alloc);
533 self.alloc.destroy(user.*);
534 }
535 self.users.deinit();
536 self.alloc.free(self.supports.prefix);
537 var batches = self.batches;
538 var iter = batches.keyIterator();
539 while (iter.next()) |key| {
540 self.alloc.free(key.*);
541 }
542 batches.deinit();
543 }
544
545 pub fn ack(self: *Client, cap: []const u8) void {
546 const info = @typeInfo(Capabilities);
547 assert(info == .Struct);
548
549 inline for (info.Struct.fields) |field| {
550 if (std.mem.eql(u8, field.name, cap)) {
551 @field(self.caps, field.name) = true;
552 return;
553 }
554 }
555 }
556
557 pub fn read(self: *Client, buf: []u8) !usize {
558 switch (self.config.tls) {
559 true => return self.client.read(buf),
560 false => return self.stream.read(buf),
561 }
562 }
563
564 pub fn readLoop(self: *Client, loop: *comlink.EventLoop) !void {
565 var delay: u64 = 1 * std.time.ns_per_s;
566
567 while (!self.should_close) {
568 self.status = .disconnected;
569 log.debug("reconnecting in {d} seconds...", .{@divFloor(delay, std.time.ns_per_s)});
570 self.connect() catch |err| {
571 log.err("connection error: {}", .{err});
572 self.status = .disconnected;
573 log.debug("disconnected", .{});
574 log.debug("reconnecting in {d} seconds...", .{@divFloor(delay, std.time.ns_per_s)});
575 std.time.sleep(delay);
576 delay = delay * 2;
577 if (delay > std.time.ns_per_min) delay = std.time.ns_per_min;
578 continue;
579 };
580 log.debug("connected", .{});
581 self.status = .connected;
582 delay = 1 * std.time.ns_per_s;
583
584 var buf: [16_384]u8 = undefined;
585
586 // 4x max size. We will almost always be *way* under our maximum size, so we will have a
587 // lot more potential messages than just 4
588 var pool: MessagePool = .{};
589 pool.init();
590
591 errdefer |err| {
592 log.err("client: {s} error: {}", .{ self.config.network_id.?, err });
593 }
594
595 const timeout = std.mem.toBytes(std.posix.timeval{
596 .tv_sec = 5,
597 .tv_usec = 0,
598 });
599
600 const keep_alive: i64 = 10 * std.time.ms_per_s;
601 // max round trip time equal to our timeout
602 const max_rt: i64 = 5 * std.time.ms_per_s;
603 var last_msg: i64 = std.time.milliTimestamp();
604 var start: usize = 0;
605
606 while (true) {
607 const n = self.read(buf[start..]) catch |err| {
608 if (err != error.WouldBlock) break;
609 const now = std.time.milliTimestamp();
610 if (now - last_msg > keep_alive + max_rt) {
611 // reconnect??
612 self.status = .disconnected;
613 loop.postEvent(.redraw);
614 break;
615 }
616 if (now - last_msg > keep_alive) {
617 // send a ping
618 try self.queueWrite("PING comlink\r\n");
619 continue;
620 }
621 continue;
622 };
623 if (self.should_close) return;
624 log.debug("read {d}", .{n});
625 if (n == 0) {
626 self.status = .disconnected;
627 loop.postEvent(.redraw);
628 break;
629 }
630 last_msg = std.time.milliTimestamp();
631 var i: usize = 0;
632 while (std.mem.indexOfPos(u8, buf[0 .. n + start], i, "\r\n")) |idx| {
633 defer i = idx + 2;
634 const buffer = pool.alloc(idx - i);
635 // const line = try self.alloc.dupe(u8, buf[i..idx]);
636 @memcpy(buffer.slice(), buf[i..idx]);
637 assert(std.mem.eql(u8, buf[idx .. idx + 2], "\r\n"));
638 log.debug("[<-{s}] {s}", .{ self.config.name orelse self.config.server, buffer.slice() });
639 loop.postEvent(.{ .irc = .{ .client = self, .msg = buffer } });
640 }
641 if (i != n) {
642 // we had a part of a line read. Copy it to the beginning of the
643 // buffer
644 std.mem.copyForwards(u8, buf[0 .. (n + start) - i], buf[i..(n + start)]);
645 start = (n + start) - i;
646 } else start = 0;
647 try std.posix.setsockopt(
648 self.stream.handle,
649 std.posix.SOL.SOCKET,
650 std.posix.SO.RCVTIMEO,
651 &timeout,
652 );
653 }
654 }
655 }
656
657 /// push a write request into the queue. The request should include the trailing
658 /// '\r\n'. queueWrite will dupe the message and free after processing.
659 pub fn queueWrite(self: *Client, msg: []const u8) !void {
660 self.write_queue.push(.{ .write = .{
661 .client = self,
662 .msg = try self.alloc.dupe(u8, msg),
663 .allocator = self.alloc,
664 } });
665 }
666
667 pub fn write(self: *Client, buf: []const u8) !void {
668 log.debug("[->{s}] {s}", .{ self.config.name orelse self.config.server, buf[0 .. buf.len - 2] });
669 switch (self.config.tls) {
670 true => try self.client.writeAll(buf),
671 false => try self.stream.writeAll(buf),
672 }
673 }
674
675 pub fn connect(self: *Client) !void {
676 if (self.config.tls) {
677 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, 6697);
678 self.client = try tls.client(self.stream, .{
679 .host = self.config.server,
680 .root_ca = self.app.bundle,
681 });
682 } else {
683 self.stream = try std.net.tcpConnectToHost(self.alloc, self.config.server, 6667);
684 }
685
686 var buf: [4096]u8 = undefined;
687
688 try self.queueWrite("CAP LS 302\r\n");
689
690 const cap_names = std.meta.fieldNames(Capabilities);
691 for (cap_names) |cap| {
692 const cap_req = try std.fmt.bufPrint(
693 &buf,
694 "CAP REQ :{s}\r\n",
695 .{cap},
696 );
697 try self.queueWrite(cap_req);
698 }
699
700 const nick = try std.fmt.bufPrint(
701 &buf,
702 "NICK {s}\r\n",
703 .{self.config.nick},
704 );
705 try self.queueWrite(nick);
706
707 const user = try std.fmt.bufPrint(
708 &buf,
709 "USER {s} 0 * {s}\r\n",
710 .{ self.config.user, self.config.real_name },
711 );
712 try self.queueWrite(user);
713 }
714
715 pub fn getOrCreateChannel(self: *Client, name: []const u8) !*Channel {
716 for (self.channels.items) |*channel| {
717 if (caseFold(name, channel.name)) return channel;
718 }
719 const channel: Channel = .{
720 .name = try self.alloc.dupe(u8, name),
721 .members = std.ArrayList(Channel.Member).init(self.alloc),
722 .messages = std.ArrayList(Message).init(self.alloc),
723 .client = self,
724 };
725 try self.channels.append(channel);
726
727 std.sort.insertion(Channel, self.channels.items, {}, Channel.compare);
728 return self.getOrCreateChannel(name);
729 }
730
731 var color_indices = [_]u8{ 1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14 };
732
733 pub fn getOrCreateUser(self: *Client, nick: []const u8) !*User {
734 return self.users.get(nick) orelse {
735 const color_u32 = std.hash.Fnv1a_32.hash(nick);
736 const index = color_u32 % color_indices.len;
737 const color_index = color_indices[index];
738
739 const color: vaxis.Color = .{
740 .index = color_index,
741 };
742 const user = try self.alloc.create(User);
743 user.* = .{
744 .nick = try self.alloc.dupe(u8, nick),
745 .color = color,
746 };
747 try self.users.put(user.nick, user);
748 return user;
749 };
750 }
751
752 pub fn whox(self: *Client, channel: *Channel) !void {
753 channel.who_requested = true;
754 if (channel.name.len > 0 and
755 channel.name[0] != '#')
756 {
757 const other = try self.getOrCreateUser(channel.name);
758 const me = try self.getOrCreateUser(self.config.nick);
759 try channel.addMember(other, .{});
760 try channel.addMember(me, .{});
761 return;
762 }
763 // Only use WHO if we have WHOX and away-notify. Without
764 // WHOX, we can get rate limited on eg. libera. Without
765 // away-notify, our list will become stale
766 if (self.supports.whox and
767 self.caps.@"away-notify" and
768 !channel.in_flight.who)
769 {
770 var write_buf: [64]u8 = undefined;
771 channel.in_flight.who = true;
772 const who = try std.fmt.bufPrint(
773 &write_buf,
774 "WHO {s} %cnf\r\n",
775 .{channel.name},
776 );
777 try self.queueWrite(who);
778 } else {
779 var write_buf: [64]u8 = undefined;
780 channel.in_flight.names = true;
781 const names = try std.fmt.bufPrint(
782 &write_buf,
783 "NAMES {s}\r\n",
784 .{channel.name},
785 );
786 try self.queueWrite(names);
787 }
788 }
789
790 /// fetch the history for the provided channel.
791 pub fn requestHistory(self: *Client, cmd: ChatHistoryCommand, channel: *Channel) !void {
792 if (!self.caps.@"draft/chathistory") return;
793 if (channel.history_requested) return;
794
795 channel.history_requested = true;
796
797 var buf: [128]u8 = undefined;
798 if (channel.messages.items.len == 0) {
799 const hist = try std.fmt.bufPrint(
800 &buf,
801 "CHATHISTORY LATEST {s} * 50\r\n",
802 .{channel.name},
803 );
804 channel.history_requested = true;
805 try self.queueWrite(hist);
806 return;
807 }
808
809 switch (cmd) {
810 .before => {
811 assert(channel.messages.items.len > 0);
812 const first = channel.messages.items[0];
813 const time = first.getTag("time") orelse
814 return error.NoTimeTag;
815 const hist = try std.fmt.bufPrint(
816 &buf,
817 "CHATHISTORY BEFORE {s} timestamp={s} 50\r\n",
818 .{ channel.name, time },
819 );
820 channel.history_requested = true;
821 try self.queueWrite(hist);
822 },
823 .after => {
824 assert(channel.messages.items.len > 0);
825 const last = channel.messages.getLast();
826 const time = last.getTag("time") orelse
827 return error.NoTimeTag;
828 const hist = try std.fmt.bufPrint(
829 &buf,
830 // we request 500 because we have no
831 // idea how long we've been offline
832 "CHATHISTORY AFTER {s} timestamp={s} 500\r\n",
833 .{ channel.name, time },
834 );
835 channel.history_requested = true;
836 try self.queueWrite(hist);
837 },
838 }
839 }
840};
841
842pub fn toVaxisColor(irc: u8) vaxis.Color {
843 return switch (irc) {
844 0 => .default, // white
845 1 => .{ .index = 0 }, // black
846 2 => .{ .index = 4 }, // blue
847 3 => .{ .index = 2 }, // green
848 4 => .{ .index = 1 }, // red
849 5 => .{ .index = 3 }, // brown
850 6 => .{ .index = 5 }, // magenta
851 7 => .{ .index = 11 }, // orange
852 8 => .{ .index = 11 }, // yellow
853 9 => .{ .index = 10 }, // light green
854 10 => .{ .index = 6 }, // cyan
855 11 => .{ .index = 14 }, // light cyan
856 12 => .{ .index = 12 }, // light blue
857 13 => .{ .index = 13 }, // pink
858 14 => .{ .index = 8 }, // grey
859 15 => .{ .index = 7 }, // light grey
860
861 // 16 to 98 are specifically defined
862 16 => .{ .index = 52 },
863 17 => .{ .index = 94 },
864 18 => .{ .index = 100 },
865 19 => .{ .index = 58 },
866 20 => .{ .index = 22 },
867 21 => .{ .index = 29 },
868 22 => .{ .index = 23 },
869 23 => .{ .index = 24 },
870 24 => .{ .index = 17 },
871 25 => .{ .index = 54 },
872 26 => .{ .index = 53 },
873 27 => .{ .index = 89 },
874 28 => .{ .index = 88 },
875 29 => .{ .index = 130 },
876 30 => .{ .index = 142 },
877 31 => .{ .index = 64 },
878 32 => .{ .index = 28 },
879 33 => .{ .index = 35 },
880 34 => .{ .index = 30 },
881 35 => .{ .index = 25 },
882 36 => .{ .index = 18 },
883 37 => .{ .index = 91 },
884 38 => .{ .index = 90 },
885 39 => .{ .index = 125 },
886 // TODO: finish these out https://modern.ircdocs.horse/formatting#color
887
888 99 => .default,
889
890 else => .{ .index = irc },
891 };
892}
893
894const CaseMapAlgo = enum {
895 ascii,
896 rfc1459,
897 rfc1459_strict,
898};
899
900pub fn caseMap(char: u8, algo: CaseMapAlgo) u8 {
901 switch (algo) {
902 .ascii => {
903 switch (char) {
904 'A'...'Z' => return char + 0x20,
905 else => return char,
906 }
907 },
908 .rfc1459 => {
909 switch (char) {
910 'A'...'^' => return char + 0x20,
911 else => return char,
912 }
913 },
914 .rfc1459_strict => {
915 switch (char) {
916 'A'...']' => return char + 0x20,
917 else => return char,
918 }
919 },
920 }
921}
922
923pub fn caseFold(a: []const u8, b: []const u8) bool {
924 if (a.len != b.len) return false;
925 var i: usize = 0;
926 while (i < a.len) {
927 const diff = std.mem.indexOfDiff(u8, a[i..], b[i..]) orelse return true;
928 const a_diff = caseMap(a[diff], .rfc1459);
929 const b_diff = caseMap(b[diff], .rfc1459);
930 if (a_diff != b_diff) return false;
931 i += diff + 1;
932 }
933 return true;
934}
935
936pub const ChatHistoryCommand = enum {
937 before,
938 after,
939};
940
941test "caseFold" {
942 try testing.expect(caseFold("a", "A"));
943 try testing.expect(caseFold("aBcDeFgH", "abcdefgh"));
944}
945
946test "simple message" {
947 const msg: Message = .{ .bytes = "JOIN" };
948 try testing.expect(msg.command() == .JOIN);
949}
950
951test "simple message with extra whitespace" {
952 const msg: Message = .{ .bytes = "JOIN " };
953 try testing.expect(msg.command() == .JOIN);
954}
955
956test "well formed message with tags, source, params" {
957 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
958
959 var tag_iter = msg.tagIterator();
960 const tag = tag_iter.next();
961 try testing.expect(tag != null);
962 try testing.expectEqualStrings("key", tag.?.key);
963 try testing.expectEqualStrings("value", tag.?.value);
964 try testing.expect(tag_iter.next() == null);
965
966 const source = msg.source();
967 try testing.expect(source != null);
968 try testing.expectEqualStrings("example.chat", source.?);
969 try testing.expect(msg.command() == .JOIN);
970
971 var param_iter = msg.paramIterator();
972 const p1 = param_iter.next();
973 const p2 = param_iter.next();
974 try testing.expect(p1 != null);
975 try testing.expect(p2 != null);
976 try testing.expectEqualStrings("abc", p1.?);
977 try testing.expectEqualStrings("def", p2.?);
978
979 try testing.expect(param_iter.next() == null);
980}
981
982test "message with tags, source, params and extra whitespace" {
983 const msg: Message = .{ .bytes = "@key=value :example.chat JOIN abc def" };
984
985 var tag_iter = msg.tagIterator();
986 const tag = tag_iter.next();
987 try testing.expect(tag != null);
988 try testing.expectEqualStrings("key", tag.?.key);
989 try testing.expectEqualStrings("value", tag.?.value);
990 try testing.expect(tag_iter.next() == null);
991
992 const source = msg.source();
993 try testing.expect(source != null);
994 try testing.expectEqualStrings("example.chat", source.?);
995 try testing.expect(msg.command() == .JOIN);
996
997 var param_iter = msg.paramIterator();
998 const p1 = param_iter.next();
999 const p2 = param_iter.next();
1000 try testing.expect(p1 != null);
1001 try testing.expect(p2 != null);
1002 try testing.expectEqualStrings("abc", p1.?);
1003 try testing.expectEqualStrings("def", p2.?);
1004
1005 try testing.expect(param_iter.next() == null);
1006}
1007
1008test "param iterator: simple list" {
1009 var iter: Message.ParamIterator = .{ .params = "a b c" };
1010 var i: usize = 0;
1011 while (iter.next()) |param| {
1012 switch (i) {
1013 0 => try testing.expectEqualStrings("a", param),
1014 1 => try testing.expectEqualStrings("b", param),
1015 2 => try testing.expectEqualStrings("c", param),
1016 else => return error.TooManyParams,
1017 }
1018 i += 1;
1019 }
1020 try testing.expect(i == 3);
1021}
1022
1023test "param iterator: trailing colon" {
1024 var iter: Message.ParamIterator = .{ .params = "* LS :" };
1025 var i: usize = 0;
1026 while (iter.next()) |param| {
1027 switch (i) {
1028 0 => try testing.expectEqualStrings("*", param),
1029 1 => try testing.expectEqualStrings("LS", param),
1030 2 => try testing.expectEqualStrings("", param),
1031 else => return error.TooManyParams,
1032 }
1033 i += 1;
1034 }
1035 try testing.expect(i == 3);
1036}
1037
1038test "param iterator: colon" {
1039 var iter: Message.ParamIterator = .{ .params = "* LS :sasl multi-prefix" };
1040 var i: usize = 0;
1041 while (iter.next()) |param| {
1042 switch (i) {
1043 0 => try testing.expectEqualStrings("*", param),
1044 1 => try testing.expectEqualStrings("LS", param),
1045 2 => try testing.expectEqualStrings("sasl multi-prefix", param),
1046 else => return error.TooManyParams,
1047 }
1048 i += 1;
1049 }
1050 try testing.expect(i == 3);
1051}
1052
1053test "param iterator: colon and leading colon" {
1054 var iter: Message.ParamIterator = .{ .params = "* LS ::)" };
1055 var i: usize = 0;
1056 while (iter.next()) |param| {
1057 switch (i) {
1058 0 => try testing.expectEqualStrings("*", param),
1059 1 => try testing.expectEqualStrings("LS", param),
1060 2 => try testing.expectEqualStrings(":)", param),
1061 else => return error.TooManyParams,
1062 }
1063 i += 1;
1064 }
1065 try testing.expect(i == 3);
1066}