an experimental irc client
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};