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