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