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 EventLoop = comlink.EventLoop;
9const Lua = ziglua.Lua;
10
11const assert = std.debug.assert;
12
13/// lua constant for the REGISTRYINDEX table
14const registry_index = ziglua.registry_index;
15
16/// global key for the app userdata pointer in the registry
17const app_key = "comlink.app";
18
19/// global key for the loop userdata pointer
20const loop_key = "comlink.loop";
21
22/// active client key. This gets replaced with the client context during callbacks
23const client_key = "comlink.client";
24
25pub fn init(app: *App, lua: *Lua, loop: *comlink.EventLoop) !void {
26 // load standard libraries
27 lua.openLibs();
28
29 _ = try lua.getGlobal("package"); // [package]
30 _ = lua.getField(1, "preload"); // [package, preload]
31 lua.pushFunction(ziglua.wrap(Comlink.preloader)); // [package, preload, function]
32 lua.setField(2, "comlink"); // [package, preload]
33 lua.pop(1); // [package]
34 _ = lua.getField(1, "path"); // [package, string]
35 const package_path = try lua.toString(2);
36 lua.pop(1); // [package]
37
38 // set package.path
39 {
40 var buf: [std.posix.PATH_MAX]u8 = undefined;
41 var fba = std.heap.FixedBufferAllocator.init(&buf);
42 const alloc = fba.allocator();
43 const prefix = blk: {
44 if (app.env.get("XDG_CONFIG_HOME")) |cfg|
45 break :blk try std.fs.path.join(alloc, &.{ cfg, "comlink" });
46 if (app.env.get("HOME")) |home|
47 break :blk try std.fs.path.join(alloc, &.{ home, ".config/comlink" });
48 return error.NoConfigFile;
49 };
50 const base = try std.fs.path.join(app.alloc, &.{ prefix, "?.lua" });
51 defer app.alloc.free(base);
52 const one = try std.fs.path.join(app.alloc, &.{ prefix, "lua/?.lua" });
53 defer app.alloc.free(one);
54 const two = try std.fs.path.join(app.alloc, &.{ prefix, "lua/?/init.lua" });
55 defer app.alloc.free(two);
56 const new_pkg_path = try std.mem.join(app.alloc, ";", &.{ package_path, base, one, two });
57 _ = lua.pushString(new_pkg_path); // [package, string]
58 lua.setField(1, "path"); // [package];
59 defer app.alloc.free(new_pkg_path);
60 }
61
62 // empty the stack
63 lua.pop(1); // []
64
65 // keep a reference to our app in the lua state
66 lua.pushLightUserdata(app); // [userdata]
67 lua.setField(registry_index, app_key); // []
68 // keep a reference to our loop in the lua state
69 lua.pushLightUserdata(loop); // [userdata]
70 lua.setField(registry_index, loop_key); // []
71
72 // load config
73 var buf: [std.posix.PATH_MAX]u8 = undefined;
74 var fba = std.heap.FixedBufferAllocator.init(&buf);
75 const alloc = fba.allocator();
76 const path = blk: {
77 if (app.env.get("XDG_CONFIG_HOME")) |cfg|
78 break :blk try std.fs.path.joinZ(alloc, &.{ cfg, "comlink/init.lua" });
79 if (app.env.get("HOME")) |home|
80 break :blk try std.fs.path.joinZ(alloc, &.{ home, ".config/comlink/init.lua" });
81 unreachable;
82 };
83
84 switch (ziglua.lang) {
85 .luajit, .lua51 => lua.loadFile(path) catch return error.LuaError,
86 else => lua.loadFile(path, .binary_text) catch return error.LuaError,
87 }
88 lua.protectedCall(0, ziglua.mult_return, 0) catch return error.LuaError;
89}
90
91/// retrieves the *App lightuserdata from the registry index
92fn getApp(lua: *Lua) *App {
93 const lua_type = lua.getField(registry_index, app_key); // [userdata]
94 assert(lua_type == .light_userdata); // set by comlink as a lightuserdata
95 const app = lua.toUserdata(App, -1) catch unreachable; // already asserted
96 lua.pop(1); // []
97 // as lightuserdata
98 return app;
99}
100
101/// retrieves the *Loop lightuserdata from the registry index
102fn getLoop(lua: *Lua) *EventLoop {
103 const lua_type = lua.getField(registry_index, loop_key); // [userdata]
104 assert(lua_type == .light_userdata); // set by comlink as a lightuserdata
105 const loop = lua.toUserdata(comlink.EventLoop, -1) catch unreachable; // already asserted
106 // as lightuserdata
107 return loop;
108}
109
110fn getClient(lua: *Lua) *irc.Client {
111 const lua_type = lua.getField(registry_index, client_key); // [userdata]
112 assert(lua_type == .light_userdata); // set by comlink as a lightuserdata
113 const client = lua.toUserdata(irc.Client, -1) catch unreachable; // already asserted
114 // as lightuserdata
115 return client;
116}
117
118/// The on_connect event is emitted when we complete registration and receive a RPL_WELCOME message
119pub fn onConnect(lua: *Lua, client: *irc.Client) !void {
120 defer lua.setTop(0); // []
121 lua.pushLightUserdata(client); // [light_userdata]
122 lua.setField(registry_index, client_key); // []
123
124 Client.getTable(lua, client.config.lua_table); // [table]
125 const lua_type = lua.getField(1, "on_connect"); // [table, type]
126 switch (lua_type) {
127 .function => {
128 // Push the table to the top since it is our argument to the function
129 lua.pushValue(1); // [table, function, table]
130 lua.protectedCall(1, 0, 0) catch return error.LuaError; // [table]
131 // clear the stack
132 lua.pop(1); // []
133 },
134 else => {},
135 }
136}
137
138pub fn onMessage(lua: *Lua, client: *irc.Client, channel: []const u8, sender: []const u8, msg: []const u8) !void {
139 defer lua.setTop(0); // []
140 Client.getTable(lua, client.config.lua_table); // [table]
141 const lua_type = lua.getField(1, "on_message"); // [table, type]
142 switch (lua_type) {
143 .function => {
144 // Push the table to the top since it is our argument to the function
145 _ = lua.pushString(channel); // [function,string]
146 _ = lua.pushString(sender); // [function,string,string]
147 _ = lua.pushString(msg); // [function,string,string,string]
148 lua.protectedCall(3, 0, 0) catch return error.LuaError;
149 },
150 else => {},
151 }
152}
153
154pub fn execFn(lua: *Lua, func: i32) !void {
155 const lua_type = lua.rawGetIndex(registry_index, func); // [function]
156 switch (lua_type) {
157 .function => lua.protectedCall(0, 0, 0) catch return error.LuaError,
158 else => lua.raiseErrorStr("not a function", .{}),
159 }
160}
161
162pub fn execUserCommand(lua: *Lua, cmdline: []const u8, func: i32) !void {
163 defer lua.setTop(0); // []
164 const lua_type = lua.rawGetIndex(registry_index, func); // [function]
165 _ = lua.pushString(cmdline); // [function, string]
166
167 switch (lua_type) {
168 .function => lua.protectedCall(1, 0, 0) catch |err| {
169 const msg = lua.toString(-1) catch {
170 std.log.err("{}", .{err});
171 return error.LuaError;
172 };
173 std.log.err("{s}", .{msg});
174 },
175 else => lua.raiseErrorStr("not a function", .{}),
176 }
177}
178
179/// Comlink function namespace
180const Comlink = struct {
181 /// loads our "comlink" library
182 pub fn preloader(lua: *Lua) i32 {
183 const fns = [_]ziglua.FnReg{
184 .{ .name = "bind", .func = ziglua.wrap(bind) },
185 .{ .name = "connect", .func = ziglua.wrap(connect) },
186 .{ .name = "log", .func = ziglua.wrap(log) },
187 .{ .name = "notify", .func = ziglua.wrap(notify) },
188 .{ .name = "add_command", .func = ziglua.wrap(addCommand) },
189 .{ .name = "selected_channel", .func = ziglua.wrap(Comlink.selectedChannel) },
190 };
191 lua.newLibTable(&fns); // [table]
192 lua.setFuncs(&fns, 0); // [table]
193 return 1;
194 }
195
196 /// creates a keybind. Accepts one or two string.
197 ///
198 /// The first string is the key binding. The second string is the optional
199 /// action. If nil, the key is unbound (if a binding exists). Otherwise, the
200 /// provided key is bound to the provided action.
201 fn bind(lua: *Lua) i32 {
202 const app = getApp(lua);
203 lua.argCheck(lua.isString(1), 1, "expected a string");
204 lua.argCheck(lua.isString(2) or lua.isNil(2) or lua.isFunction(2), 2, "expected a string, a function, or nil");
205
206 // [string {string,function,nil}]
207 const key_str = lua.toString(1) catch unreachable;
208
209 var codepoint: ?u21 = null;
210 var mods: vaxis.Key.Modifiers = .{};
211
212 var iter = std.mem.splitScalar(u8, key_str, '+');
213 while (iter.next()) |key_txt| {
214 const last = iter.peek() == null;
215 if (last) {
216 codepoint = vaxis.Key.name_map.get(key_txt) orelse
217 std.unicode.utf8Decode(key_txt) catch {
218 lua.raiseErrorStr("invalid utf8 or more than one codepoint", .{});
219 };
220 }
221 if (std.mem.eql(u8, "shift", key_txt))
222 mods.shift = true
223 else if (std.mem.eql(u8, "alt", key_txt))
224 mods.alt = true
225 else if (std.mem.eql(u8, "ctrl", key_txt))
226 mods.ctrl = true
227 else if (std.mem.eql(u8, "super", key_txt))
228 mods.super = true
229 else if (std.mem.eql(u8, "hyper", key_txt))
230 mods.hyper = true
231 else if (std.mem.eql(u8, "meta", key_txt))
232 mods.meta = true;
233 }
234
235 const cp = codepoint orelse lua.raiseErrorStr("invalid keybind", .{});
236
237 const cmd: comlink.Command = switch (lua.typeOf(2)) {
238 .string => blk: {
239 const cmd_str = lua.toString(2) catch unreachable;
240 const cmd = comlink.Command.fromString(cmd_str) orelse
241 lua.raiseErrorStr("unknown command", .{});
242 break :blk cmd;
243 },
244 .function => blk: {
245 const ref = lua.ref(registry_index) catch
246 lua.raiseErrorStr("couldn't ref keybind function", .{});
247 const cmd: comlink.Command = .{ .lua_function = ref };
248 break :blk cmd;
249 },
250 .nil => {
251 // remove the keybind
252 for (app.binds.items, 0..) |item, i| {
253 if (item.key.matches(cp, mods)) {
254 _ = app.binds.swapRemove(i);
255 break;
256 }
257 }
258 return 0;
259 },
260 else => unreachable,
261 };
262
263 // replace an existing bind if we have one
264 for (app.binds.items) |*item| {
265 if (item.key.matches(cp, mods)) {
266 item.command = cmd;
267 break;
268 }
269 } else {
270 // otherwise add a new bind
271 app.binds.append(.{
272 .key = .{ .codepoint = cp, .mods = mods },
273 .command = cmd,
274 }) catch lua.raiseErrorStr("out of memory", .{});
275 }
276 return 0;
277 }
278
279 /// connects to a client. Accepts a table
280 fn connect(lua: *Lua) i32 {
281 lua.argCheck(lua.isTable(1), 1, "expected a table");
282
283 // [table]
284 var lua_type = lua.getField(1, "user"); // [table,string]
285 lua.argCheck(lua_type == .string, 1, "expected a string for field 'user'");
286 const user = lua.toString(-1) catch unreachable; // [table]
287
288 lua_type = lua.getField(1, "nick"); // [table,string]
289 lua.argCheck(lua_type == .string, 1, "expected a string for field 'nick'");
290 const nick = lua.toString(-1) catch unreachable; // [table]
291
292 lua_type = lua.getField(1, "password"); // [table, string]
293 lua.argCheck(lua_type == .string, 1, "expected a string for field 'password'");
294 const password = lua.toString(-1) catch unreachable; // [table]
295
296 lua_type = lua.getField(1, "real_name"); // [table, string]
297 lua.argCheck(lua_type == .string, 1, "expected a string for field 'real_name'");
298 const real_name = lua.toString(-1) catch unreachable; // [table]
299
300 lua_type = lua.getField(1, "server"); // [table, string]
301 lua.argCheck(lua_type == .string, 1, "expected a string for field 'server'");
302 const server = lua.toString(-1) catch unreachable; // [table]
303
304 lua_type = lua.getField(1, "tls"); // [table, boolean|nil]
305 const tls: bool = switch (lua_type) {
306 .nil => blk: {
307 lua.pop(1); // [table]
308 break :blk true;
309 },
310 .boolean => lua.toBoolean(-1), // [table]
311 else => lua.raiseErrorStr("expected a boolean for field 'tls'", .{}),
312 };
313
314 lua.pop(1); // []
315
316 Client.initTable(lua); // [table]
317 const table_ref = lua.ref(registry_index) catch {
318 lua.raiseErrorStr("couldn't ref client table", .{});
319 };
320
321 const cfg: irc.Client.Config = .{
322 .server = server,
323 .user = user,
324 .nick = nick,
325 .password = password,
326 .real_name = real_name,
327 .tls = tls,
328 .lua_table = table_ref,
329 };
330
331 const loop = getLoop(lua); // []
332 loop.postEvent(.{ .connect = cfg });
333
334 // put the table back on the stack
335 Client.getTable(lua, table_ref); // [table]
336 return 1; // []
337 }
338
339 fn log(lua: *Lua) i32 {
340 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string]
341 const msg = lua.toString(1) catch unreachable; // []
342 std.log.scoped(.lua).info("{s}", .{msg});
343 return 0;
344 }
345
346 /// System notification. Takes two strings: title, body
347 fn notify(lua: *Lua) i32 {
348 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string, string]
349 lua.argCheck(lua.isString(2), 2, "expected a string"); // [string, string]
350 const app = getApp(lua);
351 const title = lua.toString(1) catch { // [string, string]
352 lua.raiseErrorStr("couldn't write notification", .{});
353 };
354 const body = lua.toString(2) catch { // [string, string]
355 lua.raiseErrorStr("couldn't write notification", .{});
356 };
357 lua.pop(2); // []
358 app.vx.notify(app.tty.anyWriter(), title, body) catch
359 lua.raiseErrorStr("couldn't write notification", .{});
360 return 0;
361 }
362
363 /// Add a user command to the command list
364 fn addCommand(lua: *Lua) i32 {
365 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string, function]
366 lua.argCheck(lua.isFunction(2), 2, "expected a function"); // [string, function]
367 const ref = lua.ref(registry_index) catch lua.raiseErrorStr("couldn't ref function", .{});
368 const cmd = lua.toString(1) catch unreachable;
369 comlink.Command.user_commands.put(cmd, ref) catch lua.raiseErrorStr("out of memory", .{});
370 return 0;
371 }
372
373 fn selectedChannel(lua: *Lua) i32 {
374 const app = getApp(lua);
375 if (app.selectedBuffer()) |buf| {
376 switch (buf) {
377 .client => {},
378 .channel => |chan| {
379 Channel.initTable(lua, chan); // [table]
380 return 1;
381 },
382 }
383 }
384 lua.pushNil(); // [nil]
385 return 1;
386 }
387};
388
389const Channel = struct {
390 fn initTable(lua: *Lua, channel: *irc.Channel) void {
391 const fns = [_]ziglua.FnReg{
392 .{ .name = "send_msg", .func = ziglua.wrap(Channel.sendMsg) },
393 .{ .name = "name", .func = ziglua.wrap(Channel.name) },
394 };
395 lua.newLibTable(&fns); // [table]
396 lua.setFuncs(&fns, 0); // [table]
397
398 lua.pushLightUserdata(channel); // [table, lightuserdata]
399 lua.setField(1, "_ptr"); // [table]
400 }
401
402 fn sendMsg(lua: *Lua) i32 {
403 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table]
404 lua.argCheck(lua.isString(2), 2, "expected a string"); // [table,string]
405 const msg = lua.toString(2) catch unreachable;
406 lua.pop(1); // [table]
407 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata]
408 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata");
409 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable;
410 lua.pop(1); // [table]
411
412 if (msg.len > 0 and msg[0] == '/') {
413 const app = getApp(lua);
414 app.handleCommand(lua, .{ .channel = channel }, msg) catch
415 lua.raiseErrorStr("couldn't handle command", .{});
416 return 0;
417 }
418
419 var buf: [1024]u8 = undefined;
420 const msg_final = std.fmt.bufPrint(
421 &buf,
422 "PRIVMSG {s} :{s}\r\n",
423 .{ channel.name, msg },
424 ) catch lua.raiseErrorStr("out of memory", .{});
425 channel.client.queueWrite(msg_final) catch lua.raiseErrorStr("out of memory", .{});
426 return 0;
427 }
428
429 fn name(lua: *Lua) i32 {
430 lua.argCheck(lua.isTable(1), 1, "expected a table"); // [table]
431 const lua_type = lua.getField(1, "_ptr"); // [table, lightuserdata]
432 lua.argCheck(lua_type == .light_userdata, 2, "expected lightuserdata");
433 const channel = lua.toUserdata(irc.Channel, 2) catch unreachable;
434 lua.pop(2); // []
435 _ = lua.pushString(channel.name); // [string]
436 return 1;
437 }
438};
439
440/// Client function namespace
441const Client = struct {
442 /// initialize a table for a client and pushes it on the stack
443 fn initTable(lua: *Lua) void {
444 const fns = [_]ziglua.FnReg{
445 .{ .name = "join", .func = ziglua.wrap(Client.join) },
446 .{ .name = "name", .func = ziglua.wrap(Client.name) },
447 };
448 lua.newLibTable(&fns); // [table]
449 lua.setFuncs(&fns, 0); // [table]
450
451 lua.pushNil(); // [table, nil]
452 lua.setField(1, "on_connect"); // [table]
453 }
454
455 /// retrieve a client table and push it on the stack
456 fn getTable(lua: *Lua, i: i32) void {
457 const lua_type = lua.rawGetIndex(registry_index, i); // [table]
458 if (lua_type != .table)
459 lua.raiseErrorStr("couldn't get client table", .{});
460 }
461
462 /// exectute a join command
463 fn join(lua: *Lua) i32 {
464 const client = getClient(lua);
465 lua.argCheck(lua.isString(1), 1, "expected a string"); // [string]
466 const channel = lua.toString(1) catch unreachable; // []
467 assert(channel.len < 120); // channel name too long
468 var buf: [128]u8 = undefined;
469
470 const msg = std.fmt.bufPrint(
471 &buf,
472 "JOIN {s}\r\n",
473 .{channel},
474 ) catch lua.raiseErrorStr("channel name too long", .{});
475
476 client.queueWrite(msg) catch lua.raiseErrorStr("couldn't queue write", .{});
477
478 return 0;
479 }
480
481 fn name(lua: *Lua) i32 {
482 const client = getClient(lua); // []
483 _ = lua.pushString(client.config.name orelse ""); // [string]
484 return 1; // []
485 }
486};