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