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