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