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
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};