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