an experimental irc client
at e59dbf3eeede483cfabdc384137106d3e4dbb7f3 1054 lines 34 kB view raw
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}