an experimental irc client
at ea3cb48fd35f9d1b787e8e018a0562d7048b801c 1066 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 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}