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