an experimental irc client
at main 23 kB view raw
1const std = @import("std"); 2const comlink = @import("comlink.zig"); 3const vaxis = @import("vaxis"); 4const ziglua = @import("ziglua"); 5 6const irc = comlink.irc; 7const App = comlink.App; 8const Lua = ziglua.Lua; 9 10const assert = std.debug.assert; 11 12/// lua constant for the REGISTRYINDEX table 13const registry_index = ziglua.registry_index; 14 15/// global key for the app userdata pointer in the registry 16const app_key = "comlink.app"; 17 18/// active client key. This gets replaced with the client context during callbacks 19const client_key = "comlink.client"; 20 21pub fn init(app: *App) !void { 22 const lua = app.lua; 23 // load standard libraries 24 lua.openLibs(); 25 26 _ = try lua.getGlobal("package"); // [package] 27 _ = lua.getField(1, "preload"); // [package, preload] 28 lua.pushFunction(ziglua.wrap(Comlink.preloader)); // [package, preload, function] 29 lua.setField(2, "comlink"); // [package, preload] 30 lua.pop(1); // [package] 31 _ = lua.getField(1, "path"); // [package, string] 32 const package_path = try lua.toString(2); 33 lua.pop(1); // [package] 34 35 // set package.path 36 { 37 var buf: [std.posix.PATH_MAX]u8 = undefined; 38 var fba = std.heap.FixedBufferAllocator.init(&buf); 39 const alloc = fba.allocator(); 40 const prefix = blk: { 41 if (app.env.get("XDG_CONFIG_HOME")) |cfg| 42 break :blk try std.fs.path.join(alloc, &.{ cfg, "comlink" }); 43 if (app.env.get("HOME")) |home| 44 break :blk try std.fs.path.join(alloc, &.{ home, ".config/comlink" }); 45 return error.NoConfigFile; 46 }; 47 const base = try std.fs.path.join(app.alloc, &.{ prefix, "?.lua" }); 48 defer app.alloc.free(base); 49 const one = try std.fs.path.join(app.alloc, &.{ prefix, "lua/?.lua" }); 50 defer app.alloc.free(one); 51 const two = try std.fs.path.join(app.alloc, &.{ prefix, "lua/?/init.lua" }); 52 defer app.alloc.free(two); 53 const new_pkg_path = try std.mem.join(app.alloc, ";", &.{ package_path, base, one, two }); 54 _ = lua.pushString(new_pkg_path); // [package, string] 55 lua.setField(1, "path"); // [package]; 56 defer app.alloc.free(new_pkg_path); 57 } 58 59 // empty the stack 60 lua.pop(1); // [] 61 62 // keep a reference to our app in the lua state 63 lua.pushLightUserdata(app); // [userdata] 64 lua.setField(registry_index, app_key); // [] 65 66 // load config 67 var buf: [std.posix.PATH_MAX]u8 = undefined; 68 var fba = std.heap.FixedBufferAllocator.init(&buf); 69 const alloc = fba.allocator(); 70 const path = blk: { 71 if (app.env.get("XDG_CONFIG_HOME")) |cfg| 72 break :blk try std.fs.path.joinZ(alloc, &.{ cfg, "comlink/init.lua" }); 73 if (app.env.get("HOME")) |home| 74 break :blk try std.fs.path.joinZ(alloc, &.{ home, ".config/comlink/init.lua" }); 75 unreachable; 76 }; 77 78 switch (ziglua.lang) { 79 .luajit, .lua51 => lua.loadFile(path) catch return error.LuaError, 80 else => lua.loadFile(path, .binary_text) catch return error.LuaError, 81 } 82 lua.protectedCall(.{ 83 .args = 0, 84 .results = ziglua.mult_return, 85 .msg_handler = 0, 86 }) catch return error.LuaError; 87} 88 89/// retrieves the *App lightuserdata from the registry index 90fn getApp(lua: *Lua) *App { 91 const lua_type = lua.getField(registry_index, app_key); // [userdata] 92 assert(lua_type == .light_userdata); // set by comlink as a lightuserdata 93 const app = lua.toUserdata(App, -1) catch unreachable; // already asserted 94 lua.pop(1); // [] 95 // as lightuserdata 96 return app; 97} 98 99fn getClient(lua: *Lua) *irc.Client { 100 const lua_type = lua.getField(registry_index, client_key); // [userdata] 101 assert(lua_type == .light_userdata); // set by comlink as a lightuserdata 102 const client = lua.toUserdata(irc.Client, -1) catch unreachable; // already asserted 103 // as lightuserdata 104 return client; 105} 106 107/// The on_connect event is emitted when we complete registration and receive a RPL_WELCOME message 108pub fn onConnect(lua: *Lua, client: *irc.Client) !void { 109 defer lua.setTop(0); // [] 110 lua.pushLightUserdata(client); // [light_userdata] 111 lua.setField(registry_index, client_key); // [] 112 113 Client.getTable(lua, client.config.lua_table); // [table] 114 const lua_type = lua.getField(1, "on_connect"); // [table, type] 115 switch (lua_type) { 116 .function => { 117 // Push the table to the top since it is our argument to the function 118 lua.pushValue(1); // [table, function, table] 119 lua.protectedCall(.{ 120 .args = 1, 121 .results = 0, 122 .msg_handler = 0, 123 }) catch return error.LuaError; // [table] 124 // clear the stack 125 lua.pop(1); // [] 126 }, 127 else => {}, 128 } 129} 130 131pub fn onMessage(lua: *Lua, client: *irc.Client, channel: []const u8, sender: []const u8, msg: []const u8) !void { 132 defer lua.setTop(0); // [] 133 Client.getTable(lua, client.config.lua_table); // [table] 134 const lua_type = lua.getField(1, "on_message"); // [table, type] 135 switch (lua_type) { 136 .function => { 137 // Push the table to the top since it is our argument to the function 138 _ = lua.pushString(channel); // [function,string] 139 _ = lua.pushString(sender); // [function,string,string] 140 _ = lua.pushString(msg); // [function,string,string,string] 141 lua.protectedCall(.{ 142 .args = 3, 143 .results = 0, 144 .msg_handler = 0, 145 }) catch return error.LuaError; // [function,string,string,string] 146 }, 147 else => {}, 148 } 149} 150 151pub fn execFn(lua: *Lua, func: i32) !void { 152 const lua_type = lua.rawGetIndex(registry_index, func); // [function] 153 switch (lua_type) { 154 .function => lua.protectedCall(.{ 155 .args = 0, 156 .results = 0, 157 .msg_handler = 0, 158 }) catch return error.LuaError, 159 else => lua.raiseErrorStr("not a function", .{}), 160 } 161} 162 163pub fn execUserCommand(lua: *Lua, cmdline: []const u8, func: i32) !void { 164 defer lua.setTop(0); // [] 165 const lua_type = lua.rawGetIndex(registry_index, func); // [function] 166 _ = lua.pushString(cmdline); // [function, string] 167 168 switch (lua_type) { 169 .function => lua.protectedCall(.{ 170 .args = 1, 171 .results = 0, 172 .msg_handler = 0, 173 }) catch |err| { 174 const msg = lua.toString(-1) catch { 175 std.log.err("{}", .{err}); 176 return error.LuaError; 177 }; 178 std.log.err("{s}", .{msg}); 179 }, 180 else => lua.raiseErrorStr("not a function", .{}), 181 } 182} 183 184/// Comlink function namespace 185const Comlink = struct { 186 /// loads our "comlink" library 187 pub fn preloader(lua: *Lua) i32 { 188 const fns = [_]ziglua.FnReg{ 189 .{ .name = "bind", .func = ziglua.wrap(bind) }, 190 .{ .name = "setup", .func = ziglua.wrap(setup) }, 191 .{ .name = "connect", .func = ziglua.wrap(connect) }, 192 .{ .name = "log", .func = ziglua.wrap(log) }, 193 .{ .name = "notify", .func = ziglua.wrap(notify) }, 194 .{ .name = "add_command", .func = ziglua.wrap(addCommand) }, 195 .{ .name = "selected_channel", .func = ziglua.wrap(Comlink.selectedChannel) }, 196 }; 197 lua.newLibTable(&fns); // [table] 198 lua.setFuncs(&fns, 0); // [table] 199 return 1; 200 } 201 202 /// Sets global configuration 203 fn setup(lua: *Lua) i32 { 204 defer lua.pop(1); // [] 205 lua.argCheck(lua.isTable(1), 1, "expected a table"); 206 // [table] 207 const app = getApp(lua); 208 const fields = std.meta.fieldNames(comlink.Config); 209 for (fields) |field| { 210 defer lua.pop(1); // [table] 211 const lua_type = lua.getField(1, field); // [table,type] 212 if (lua_type == .nil) { 213 // The field wasn't present 214 continue; 215 } 216 const expected_type = comlink.Config.fieldToLuaType(field); 217 if (lua_type != expected_type) { 218 std.log.warn("unexpected type: {}, expected {}", .{ lua_type, expected_type }); 219 continue; 220 } 221 222 const field_enum = std.meta.stringToEnum(comlink.Config.Fields(), field) orelse continue; 223 switch (field_enum) { 224 .markread_on_focus => app.config.markread_on_focus = lua.toBoolean(-1), 225 } 226 } 227 return 0; 228 } 229 230 /// creates a keybind. Accepts one or two string. 231 /// 232 /// The first string is the key binding. The second string is the optional 233 /// action. If nil, the key is unbound (if a binding exists). Otherwise, the 234 /// provided key is bound to the provided action. 235 fn bind(lua: *Lua) i32 { 236 const app = getApp(lua); 237 lua.argCheck(lua.isString(1), 1, "expected a string"); 238 lua.argCheck(lua.isString(2) or lua.isNil(2) or lua.isFunction(2), 2, "expected a string, a function, or nil"); 239 240 // [string {string,function,nil}] 241 const key_str = lua.toString(1) catch unreachable; 242 243 var codepoint: ?u21 = null; 244 var mods: vaxis.Key.Modifiers = .{}; 245 246 var iter = std.mem.splitScalar(u8, key_str, '+'); 247 while (iter.next()) |key_txt| { 248 const last = iter.peek() == null; 249 if (last) { 250 codepoint = vaxis.Key.name_map.get(key_txt) orelse 251 std.unicode.utf8Decode(key_txt) catch { 252 lua.raiseErrorStr("invalid utf8 or more than one codepoint", .{}); 253 }; 254 } 255 if (std.mem.eql(u8, "shift", key_txt)) 256 mods.shift = true 257 else if (std.mem.eql(u8, "alt", key_txt)) 258 mods.alt = true 259 else if (std.mem.eql(u8, "ctrl", key_txt)) 260 mods.ctrl = true 261 else if (std.mem.eql(u8, "super", key_txt)) 262 mods.super = true 263 else if (std.mem.eql(u8, "hyper", key_txt)) 264 mods.hyper = true 265 else if (std.mem.eql(u8, "meta", key_txt)) 266 mods.meta = true; 267 } 268 269 const cp = codepoint orelse lua.raiseErrorStr("invalid keybind", .{}); 270 271 const cmd: comlink.Command = switch (lua.typeOf(2)) { 272 .string => blk: { 273 const cmd_str = lua.toString(2) catch unreachable; 274 const cmd = comlink.Command.fromString(cmd_str) orelse 275 lua.raiseErrorStr("unknown command", .{}); 276 break :blk cmd; 277 }, 278 .function => blk: { 279 const ref = lua.ref(registry_index) catch 280 lua.raiseErrorStr("couldn't ref keybind function", .{}); 281 const cmd: comlink.Command = .{ .lua_function = ref }; 282 break :blk cmd; 283 }, 284 .nil => { 285 // remove the keybind 286 for (app.binds.items, 0..) |item, i| { 287 if (item.key.matches(cp, mods)) { 288 _ = app.binds.swapRemove(i); 289 break; 290 } 291 } 292 return 0; 293 }, 294 else => unreachable, 295 }; 296 297 // replace an existing bind if we have one 298 for (app.binds.items) |*item| { 299 if (item.key.matches(cp, mods)) { 300 item.command = cmd; 301 break; 302 } 303 } else { 304 // otherwise add a new bind 305 app.binds.append(.{ 306 .key = .{ .codepoint = cp, .mods = mods }, 307 .command = cmd, 308 }) catch lua.raiseErrorStr("out of memory", .{}); 309 } 310 return 0; 311 } 312 313 /// connects to a client. Accepts a table 314 fn connect(lua: *Lua) i32 { 315 lua.argCheck(lua.isTable(1), 1, "expected a table"); 316 317 // [table] 318 var lua_type = lua.getField(1, "nick"); // [table,string] 319 lua.argCheck(lua_type == .string, 1, "expected a string for field 'nick'"); 320 const nick = lua.toString(-1) catch unreachable; 321 lua.pop(1); // [table] 322 323 lua_type = lua.getField(1, "user"); // [table,string] 324 const user: []const u8 = switch (lua_type) { 325 .nil => blk: { 326 lua.pop(1); // [table] 327 break :blk nick; 328 }, 329 .string => blk: { 330 const val = lua.toString(-1) catch ""; 331 lua.pop(1); // [table] 332 break :blk val; 333 }, 334 else => lua.raiseErrorStr("expected a string for field 'user'", .{}), 335 }; 336 337 lua_type = lua.getField(1, "password"); // [table, string] 338 lua.argCheck(lua_type == .string, 1, "expected a string for field 'password'"); 339 const password = lua.toString(-1) catch unreachable; 340 lua.pop(1); // [table] 341 342 lua_type = lua.getField(1, "real_name"); // [table, string] 343 const real_name: []const u8 = switch (lua_type) { 344 .nil => blk: { 345 lua.pop(1); // [table] 346 break :blk nick; 347 }, 348 .string => blk: { 349 const val = lua.toString(-1) catch ""; 350 lua.pop(1); // [table] 351 break :blk val; 352 }, 353 else => lua.raiseErrorStr("expected a string for field 'real_name'", .{}), 354 }; 355 356 lua_type = lua.getField(1, "server"); // [table, string] 357 lua.argCheck(lua_type == .string, 1, "expected a string for field 'server'"); 358 const server = lua.toString(-1) catch unreachable; // [table] 359 lua.pop(1); // [table] 360 361 lua_type = lua.getField(1, "tls"); // [table, boolean|nil] 362 const tls: bool = switch (lua_type) { 363 .nil => blk: { 364 lua.pop(1); // [table] 365 break :blk true; 366 }, 367 .boolean => blk: { 368 const val = lua.toBoolean(-1); 369 lua.pop(1); // [table] 370 break :blk val; 371 }, 372 else => lua.raiseErrorStr("expected a boolean for field 'tls'", .{}), 373 }; 374 375 lua_type = lua.getField(1, "port"); // [table, int|nil] 376 lua.argCheck(lua_type == .nil or lua_type == .number, 1, "expected a number or nil"); 377 const port: ?u16 = switch (lua_type) { 378 .nil => blk: { 379 lua.pop(1); // [table] 380 break :blk null; 381 }, 382 .number => blk: { 383 const val = lua.toNumber(-1) catch unreachable; 384 lua.pop(1); // [table] 385 break :blk @intFromFloat(val); 386 }, 387 else => lua.raiseErrorStr("expected a boolean for field 'tls'", .{}), 388 }; 389 390 Client.initTable(lua); // [table] 391 const table_ref = lua.ref(registry_index) catch { 392 lua.raiseErrorStr("couldn't ref client table", .{}); 393 }; 394 395 const app = getApp(lua); 396 const gpa = app.alloc; 397 398 const cfg: irc.Client.Config = .{ 399 .server = gpa.dupe(u8, server) catch lua.raiseErrorStr("out of memory", .{}), 400 .user = gpa.dupe(u8, user) catch lua.raiseErrorStr("out of memory", .{}), 401 .nick = gpa.dupe(u8, nick) catch lua.raiseErrorStr("out of memory", .{}), 402 .password = gpa.dupe(u8, password) catch lua.raiseErrorStr("out of memory", .{}), 403 .real_name = gpa.dupe(u8, real_name) catch lua.raiseErrorStr("out of memory", .{}), 404 .tls = tls, 405 .lua_table = table_ref, 406 .port = port, 407 }; 408 409 app.connect(cfg) catch { 410 lua.raiseErrorStr("couldn't connect", .{}); 411 }; 412 413 // put the table back on the stack 414 Client.getTable(lua, table_ref); // [table] 415 return 1; // [] 416 } 417 418 fn log(lua: *Lua) i32 { 419 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string] 420 const msg = lua.toString(1) catch unreachable; // [] 421 std.log.scoped(.lua).info("{s}", .{msg}); 422 return 0; 423 } 424 425 /// System notification. Takes two strings: title, body 426 fn notify(lua: *Lua) i32 { 427 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string, string] 428 lua.argCheck(lua.isString(2), 2, "expected a string"); // [string, string] 429 const app = getApp(lua); 430 const title = lua.toString(1) catch { // [string, string] 431 lua.raiseErrorStr("couldn't write notification", .{}); 432 }; 433 const body = lua.toString(2) catch { // [string, string] 434 lua.raiseErrorStr("couldn't write notification", .{}); 435 }; 436 lua.pop(2); // [] 437 if (app.ctx) |ctx| { 438 ctx.sendNotification(title, body) catch { 439 lua.raiseErrorStr("couldn't write notification", .{}); 440 }; 441 } 442 return 0; 443 } 444 445 /// Add a user command to the command list 446 fn addCommand(lua: *Lua) i32 { 447 assert(lua.getTop() == 2); 448 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string, function] 449 lua.argCheck(lua.isFunction(2), 2, "expected a function"); // [string, function] 450 const ref = lua.ref(registry_index) catch lua.raiseErrorStr("couldn't ref function", .{}); // [string] 451 const cmd = lua.toString(1) catch unreachable; 452 453 // ref the string so we don't garbage collect it 454 _ = lua.ref(registry_index) catch lua.raiseErrorStr("couldn't ref command name", .{}); // [] 455 comlink.Command.user_commands.put(cmd, ref) catch lua.raiseErrorStr("out of memory", .{}); 456 return 0; 457 } 458 459 fn selectedChannel(lua: *Lua) i32 { 460 const app = getApp(lua); 461 if (app.selectedBuffer()) |buf| { 462 switch (buf) { 463 .client => {}, 464 .channel => |chan| { 465 Channel.initTable(lua, chan); // [table] 466 return 1; 467 }, 468 } 469 } 470 lua.pushNil(); // [nil] 471 return 1; 472 } 473}; 474 475const Channel = struct { 476 fn initTable(lua: *Lua, channel: *irc.Channel) void { 477 const fns = [_]ziglua.FnReg{ 478 .{ .name = "send_msg", .func = ziglua.wrap(Channel.sendMsg) }, 479 .{ .name = "insert_text", .func = ziglua.wrap(Channel.insertText) }, 480 .{ .name = "name", .func = ziglua.wrap(Channel.name) }, 481 .{ .name = "mark_read", .func = ziglua.wrap(Channel.markRead) }, 482 }; 483 lua.newLibTable(&fns); // [table] 484 lua.setFuncs(&fns, 0); // [table] 485 486 lua.pushLightUserdata(channel); // [table, lightuserdata] 487 lua.setField(1, "_ptr"); // [table] 488 } 489 490 fn sendMsg(lua: *Lua) i32 { 491 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table] 492 lua.argCheck(lua.isString(2), 2, "expected a string"); // [table,string] 493 const msg = lua.toString(2) catch unreachable; 494 lua.pop(1); // [table] 495 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata] 496 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata"); 497 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable; 498 lua.pop(1); // [table] 499 500 if (msg.len > 0 and msg[0] == '/') { 501 const app = getApp(lua); 502 app.handleCommand(.{ .channel = channel }, msg) catch 503 lua.raiseErrorStr("couldn't handle command", .{}); 504 return 0; 505 } 506 507 var buf: [1024]u8 = undefined; 508 const msg_final = std.fmt.bufPrint( 509 &buf, 510 "PRIVMSG {s} :{s}\r\n", 511 .{ channel.name, msg }, 512 ) catch lua.raiseErrorStr("out of memory", .{}); 513 channel.client.queueWrite(msg_final) catch lua.raiseErrorStr("out of memory", .{}); 514 return 0; 515 } 516 517 fn insertText(lua: *Lua) i32 { 518 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table] 519 lua.argCheck(lua.isString(2), 2, "expected a string"); // [table,string] 520 const msg = lua.toString(2) catch unreachable; 521 lua.pop(1); // [table] 522 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata] 523 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata"); 524 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable; 525 lua.pop(1); // [] 526 527 channel.text_field.insertSliceAtCursor(msg) catch { 528 lua.raiseErrorStr("couldn't insert text", .{}); 529 }; 530 return 0; 531 } 532 533 fn name(lua: *Lua) i32 { 534 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table] 535 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata] 536 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata"); 537 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable; 538 lua.pop(2); // [] 539 _ = lua.pushString(channel.name); // [string] 540 return 1; 541 } 542 543 fn markRead(lua: *Lua) i32 { 544 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table] 545 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata] 546 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata"); 547 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable; 548 channel.last_read_indicator = channel.last_read; 549 lua.pop(2); // [] 550 return 0; 551 } 552}; 553 554/// Client function namespace 555const Client = struct { 556 /// initialize a table for a client and pushes it on the stack 557 fn initTable(lua: *Lua) void { 558 const fns = [_]ziglua.FnReg{ 559 .{ .name = "join", .func = ziglua.wrap(Client.join) }, 560 .{ .name = "name", .func = ziglua.wrap(Client.name) }, 561 }; 562 lua.newLibTable(&fns); // [table] 563 lua.setFuncs(&fns, 0); // [table] 564 565 lua.pushNil(); // [table, nil] 566 lua.setField(1, "on_connect"); // [table] 567 } 568 569 /// retrieve a client table and push it on the stack 570 fn getTable(lua: *Lua, i: i32) void { 571 const lua_type = lua.rawGetIndex(registry_index, i); // [table] 572 if (lua_type != .table) 573 lua.raiseErrorStr("couldn't get client table", .{}); 574 } 575 576 /// exectute a join command 577 fn join(lua: *Lua) i32 { 578 const client = getClient(lua); 579 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string] 580 const channel = lua.toString(1) catch unreachable; // [] 581 assert(channel.len < 120); // channel name too long 582 var buf: [128]u8 = undefined; 583 584 const msg = std.fmt.bufPrint( 585 &buf, 586 "JOIN {s}\r\n", 587 .{channel}, 588 ) catch lua.raiseErrorStr("channel name too long", .{}); 589 590 client.queueWrite(msg) catch lua.raiseErrorStr("couldn't queue write", .{}); 591 592 return 0; 593 } 594 595 fn name(lua: *Lua) i32 { 596 const client = getClient(lua); // [] 597 _ = lua.pushString(client.config.name orelse ""); // [string] 598 return 1; // [] 599 } 600};