From 1e4c90822a584ffbb6e5b0f890ad22137ba9a06e Mon Sep 17 00:00:00 2001 From: Jacob Jonsson Date: Sun, 30 Nov 2025 22:52:22 +0100 Subject: [PATCH] chore: make stuff work --- build.zig | 33 ++++++-- src/bot.zig | 235 ++++++++++++++++++++++++++++++++++++++------------- src/main.zig | 114 ++++++++++++++----------- src/root.zig | 2 +- 4 files changed, 267 insertions(+), 117 deletions(-) diff --git a/build.zig b/build.zig index 72f8337..ad0243c 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,12 @@ pub fn build(b: *std.Build) void { // target and optimize options) will be listed when running `zig build --help` // in this directory. + // Dependencies + const zircon = b.dependency("zircon", .{ + .target = target, + .optimize = optimize, + }); + // This creates a module, which represents a collection of source files alongside // some compilation options, such as optimization mode and linked system libraries. // Zig modules are the preferred way of making Zig code available to consumers. @@ -39,6 +45,9 @@ pub fn build(b: *std.Build) void { // Later on we'll use this module as the root module of a test executable // which requires us to specify a target. .target = target, + .imports = &.{ + .{ .name = "zircon", .module = zircon.module("zircon") }, + }, }); // Here we define an executable. An executable needs to have a root module @@ -79,17 +88,11 @@ pub fn build(b: *std.Build) void { // can be extremely useful in case of collisions (which can happen // importing modules from different packages). .{ .name = "zigeru", .module = mod }, + .{ .name = "zircon", .module = zircon.module("zircon") }, }, }), }); - const zircon = b.dependency("zircon", .{ - .target = target, - .optimize = optimize, - }); - - exe.root_module.addImport("zircon", zircon.module("zircon")); - mod.addImport("zircon", zircon.module("zircon")); exe.linkLibC(); // This declares intent for the executable to be installed into the @@ -134,6 +137,21 @@ pub fn build(b: *std.Build) void { // A run step that will run the test executable. const run_mod_tests = b.addRunArtifact(mod_tests); + const bot_tests = b.addTest(.{ + .root_module = b.addModule("bot", .{ + .target = target, + .root_source_file = b.path("src/bot.zig"), + .imports = &.{ + .{ + .name = "zircon", + .module = zircon.module("zircon"), + }, + }, + }), + }); + + const run_bot_tests = b.addRunArtifact(bot_tests); + // Creates an executable that will run `test` blocks from the executable's // root module. Note that test executables only test one module at a time, // hence why we have to create two separate ones. @@ -150,6 +168,7 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run tests"); test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_exe_tests.step); + test_step.dependOn(&run_bot_tests.step); // Just like flags, top level steps are also listed in the `--help` menu. // diff --git a/src/bot.zig b/src/bot.zig index a76e80c..c9267b6 100644 --- a/src/bot.zig +++ b/src/bot.zig @@ -1,62 +1,101 @@ const std = @import("std"); const zircon = @import("zircon"); -pub const Command = union(enum) { - substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool }, - help: void, +/// AdminCommand are commands useful for debugging zigeru, since they +/// are more spammy than others they are separated and only sent to +/// #eru-admin. +pub const AdminCommand = union(enum) { + backlog: struct { history: u16 }, + status: void, - pub fn parse(nick: []const u8, text: []const u8) ?Command { + pub fn parse(text: []const u8) ?AdminCommand { if (text.len < 2) return null; - if (text[0] == 's') { - if (text.len == 1) return null; - const delim = text[1]; - var parts = std.mem.splitScalar(u8, text, delim); - _ = parts.next() orelse return null; // skip 's' - const needle = parts.next() orelse return null; - const replacement = parts.next().?; - const flags = parts.next(); - const all = if (flags == null) false else (std.mem.eql(u8, flags.?, "g")); - return Command{ - .substitute = .{ - .author = nick, - .needle = needle, - .replacement = replacement, - .all = all, - }, + if (std.mem.eql(u8, text, "!status")) { + return .status; + } + + if (text.len > 8 and std.mem.eql(u8, text[0..8], "!backlog")) { + const history = std.fmt.parseInt(u16, text[9..], 10) catch |err| { + std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ text[8..], err }); + return null; }; + return .{ .backlog = .{ .history = history } }; } - - if (std.mem.eql(u8, text, "help")) { - return Command.help; - } - return null; } }; -const HELP_MESSAGE: []const u8 = - \\Welcome to zigeru! - \\ - \\Commands: - \\!help:\tSee this message - \\s/TYPO/CORRECTION/\tCorrect previous message by replacing TYPO with CORRECTION. -; +/// Command represents the commands that ordinary IRC users can use. +pub const Command = union(enum) { + /// `s///` + substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool = false }, + /// !help + help: void, -pub const Error = error{ - OutOfMemory, - NoMessage, + pub fn parse(nick: []const u8, text: []const u8) ?Command { + if (std.mem.eql(u8, text, "!help")) { + return .help; + } + + if (text[0] == 's') { + if (text.len == 1) return null; + const delim = switch (text[1]) { + '/', '|', '#' => text[1], + else => { + return null; + }, + }; + if (std.mem.count(u8, text, &.{delim}) != 3) { + // invalid format, we expect three delimiters + return null; + } + var parts = std.mem.splitScalar(u8, text, delim); + _ = parts.next() orelse return null; + const needle = parts.next().?; + const replacement = parts.next().?; + return .{ + .substitute = .{ + .author = nick, + .needle = needle, + .replacement = replacement, + .all = if (parts.next()) |flags| std.mem.eql(u8, flags, "g") else false, + }, + }; + } + return null; + } }; +const HELP_MESSAGE: []const u8 = "Skicka `s/TYPO/CORRECTION/` för att ersätta TYPO med CORRECTION i ditt senaste meddelande."; + +pub const Error = error{ OutOfMemory, NoMessage, WriteFailed }; + pub const Message = struct { timestamp: i64, + targets: []const u8, author: []const u8, content: []const u8, + + pub fn new_owned(allocator: std.mem.Allocator, timestamp: i64, author: []const u8, targets: []const u8, content: []const u8) Error!Message { + return .{ + .timestamp = timestamp, + .targets = try allocator.dupe(u8, targets), + .author = try allocator.dupe(u8, author), + .content = try allocator.dupe(u8, content), + }; + } + + pub fn deinit(self: Message, allocator: std.mem.Allocator) void { + allocator.free(self.author); + allocator.free(self.content); + allocator.free(self.targets); + } }; pub const Bot = struct { backlog: [1024]?Message, - sent_messages: std.ArrayList(u8), + sent_messages: std.ArrayList([]u8), top: usize, bottom: usize, allocator: std.mem.Allocator, @@ -64,7 +103,7 @@ pub const Bot = struct { pub fn init(allocator: std.mem.Allocator) Error!Bot { return Bot{ .backlog = .{null} ** 1024, - .sent_messages = try std.ArrayList(u8).initCapacity(allocator, 10), + .sent_messages = try std.ArrayList([]u8).initCapacity(allocator, 10), .top = 0, .bottom = 0, .allocator = allocator, @@ -72,28 +111,90 @@ pub const Bot = struct { } pub fn deinit(self: *Bot) void { + for (self.sent_messages.items) |item| { + self.allocator.free(item); + } self.sent_messages.deinit(self.allocator); + + var idx = self.previous_idx(self.top); + while (idx != self.bottom) : (idx = self.previous_idx(idx)) { + if (self.backlog[idx]) |message| { + message.deinit(self.allocator); + } else { + break; + } + } } pub fn execute(self: *Bot, cmd: *const Command, prefix: ?zircon.Prefix, targets: []const u8) Error!zircon.Message { switch (cmd.*) { .substitute => |command| { - const prev_msg = self.previous_message_by_author(command.author) orelse return Error.NoMessage; - const size = std.mem.replacementSize(u8, prev_msg.content, command.needle, command.replacement); - const output = try self.sent_messages.addManyAsSlice(self.allocator, size); - _ = std.mem.replace(u8, prev_msg.content, command.needle, command.replacement, output); - return zircon.Message{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = output } }; + const prev_msg = self.previous_message_by_author(command.author, targets) orelse return Error.NoMessage; + const output = try std.mem.replaceOwned( + u8, + self.allocator, + prev_msg.content, + command.needle, + command.replacement, + ); + defer self.allocator.free(output); + const quoted_output = try std.fmt.allocPrint(self.allocator, "{s}: \"{s}\"", .{ command.author, output }); + try self.sent_messages.append(self.allocator, quoted_output); + return zircon.Message{ + .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, + }; }, .help => { - return zircon.Message{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = HELP_MESSAGE } }; + return zircon.Message{ + .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = HELP_MESSAGE }, + }; + }, + } + } + + pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, prefix: ?zircon.Prefix, targets: []const u8) Error!zircon.Message { + switch (cmd.*) { + .status => { + const msg = try std.fmt.allocPrint( + self.allocator, + "heard messages: {}, sent messages: {}, top: {}, bottom: {}", + .{ self.no_messages(), self.sent_messages.items.len, self.top, self.bottom }, + ); + return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } }; + }, + .backlog => |backlog| { + if (self.top == self.bottom) { + return Error.NoMessage; + } + if (backlog.history > self.no_messages()) { + return Error.NoMessage; + } + const idx = self.previous_idx(self.top - backlog.history); + if (self.backlog[idx]) |message| { + const quoted_output = try std.fmt.allocPrint( + self.allocator, + "backlog {}: author: \"{s}\", content: \"{s}\"", + .{ backlog.history, message.author, message.content }, + ); + try self.sent_messages.append(self.allocator, quoted_output); + return .{ + .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output }, + }; + } else return Error.NoMessage; }, } } pub fn hear(self: *Bot, msg: Message) void { self.backlog[self.top] = msg; - self.top = (self.top + 1) % 1024; - if (self.top == self.bottom) self.bottom = (self.bottom + 1) % self.backlog.len; + self.top = (self.top + 1) % self.backlog.len; + if (self.top == self.bottom) { + self.bottom = (self.bottom + 1) % self.backlog.len; + // free old message + self.allocator.free(self.backlog[self.top].?.author); + self.allocator.free(self.backlog[self.top].?.content); + self.backlog[self.top] = null; + } } pub fn no_messages(self: *Bot) usize { @@ -108,7 +209,7 @@ pub const Bot = struct { if (idx == 0) { return self.backlog.len - 1; } - return idx - 1; + return (idx - 1) % self.backlog.len; } fn previous_message(self: *Bot, comptime pred: *const fn (Message) bool) ?Message { @@ -129,14 +230,14 @@ pub const Bot = struct { return null; } - fn previous_message_by_author(self: *Bot, author: []const u8) ?Message { + fn previous_message_by_author(self: *Bot, author: []const u8, targets: []const u8) ?Message { var idx = self.previous_idx(self.top); while (true) : (idx = self.previous_idx(idx)) { if (self.backlog[idx] == null) { return null; } const message = self.backlog[idx] orelse unreachable; - if (std.mem.eql(u8, message.author, author)) { + if (std.mem.eql(u8, message.author, author) and std.mem.eql(u8, message.targets, targets)) { return message; } if (idx == self.bottom) { @@ -149,7 +250,9 @@ pub const Bot = struct { }; test "hear_wraps" { - var bot = Bot.new(std.testing.allocator); + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); + const testMessage = Message{ .author = "Jassob", .timestamp = 12345, @@ -166,7 +269,8 @@ test "hear_wraps" { } test "previous_message" { - var bot = Bot.new(std.testing.allocator); + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); const callback = struct { fn callback(_: Message) bool { @@ -179,7 +283,8 @@ test "previous_message" { } test "previous_message1" { - var bot = Bot.new(std.testing.allocator); + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); var contents: [10][]const u8 = .{undefined} ** 10; for (0..10) |i| { @@ -207,20 +312,30 @@ test "previous_message1" { } test "execute_substitution_no_previous_message" { - var bot = Bot.new(std.testing.allocator); + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); const cmd = Command{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" } }; - try std.testing.expectError(Error.NoMessage, bot.execute(&cmd)); + try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, null, "#test")); } test "execute_substitution" { - var bot = Bot.new(std.testing.allocator); + var bot = try Bot.init(std.testing.allocator); + defer bot.deinit(); + + // hear original message with typo bot.hear(Message{ .timestamp = 1234, .author = "jassob", .content = "What" }); - const cmd = Command{ .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" } }; - const result = try bot.execute(&cmd); - switch (result) { - .post_message => |message| { - try std.testing.expectEqualDeep(message.content, "what"); - std.testing.allocator.free(message.content); + + // execute substitution + const cmd = Command{ + .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" }, + }; + const response = try bot.execute(&cmd, null, "#test"); + + // expect response matching the correct message + switch (response) { + .PRIVMSG => |message| { + try std.testing.expectEqualDeep(message.text, "jassob: \"what\""); }, + else => unreachable, } } diff --git a/src/main.zig b/src/main.zig index 2ab092c..e9ea224 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,9 +1,13 @@ const std = @import("std"); -const zigeru = @import("zigeru"); + const zircon = @import("zircon"); +const zigeru = @import("zigeru"); const Bot = zigeru.bot.Bot; +const Error = zigeru.bot.Error; const BotCommand = zigeru.bot.Command; +const AdminCommand = zigeru.bot.AdminCommand; +const BotMessage = zigeru.bot.Message; var debug_allocator = std.heap.DebugAllocator(.{}).init; @@ -26,30 +30,23 @@ pub fn main() !void { defer client.deinit(); var bot_adapter = try BotAdapter.init(allocator); - const adapter = bot_adapter.adapter(); - + defer bot_adapter.deinit(); + client.register_message_closure(bot_adapter.closure()); // Connect to the IRC server and perform registration. try client.connect(); try client.register(); try client.join("#eru-tests"); + try client.join("#eru-admin"); - // Enter the main loop that keeps reading incoming IRC messages forever. - // The client loop accepts a LoopConfig struct with two optional fields. - // - // These two fields, .msg_callback and .spawn_thread are callback pointers. - // You set them to custom functions you define to customize the main loop. - // - // .msg_callback lets you answer any received IRC messages with another one. - // - // .spawn_thread lets you tweak if you spawn a thread to run .msg_callback. - client.loop(.{ .msg_callback = adapter.callbackFn }) catch |err| { - std.debug.print("eru exited with error: {}", .{err}); + client.loop(.{}) catch |err| { + std.debug.print("eru exited with error: {}\n", .{err}); + return; }; } pub const Adapter = struct { - ptr: ?*anyopaque, - callbackFn: *const fn (?*anyopaque, zircon.Message) ?zircon.Message, + ptr: *anyopaque, + callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message, }; pub const BotAdapter = struct { @@ -70,47 +67,66 @@ pub const BotAdapter = struct { pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { switch (message) { .PRIVMSG => |msg| { - if (Command.parse(msg.prefix, msg.targets, msg.text)) |command| { - return command.handle(&self.bot); + std.log.debug("received message: nick {?s}, user: {?s}, host: {?s}, targets: {s}, text: {s}", .{ + msg.prefix.?.nick, + msg.prefix.?.user, + msg.prefix.?.host, + msg.targets, + msg.text, + }); + const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown"; + if (BotCommand.parse(nick, msg.text)) |command| { + return self.bot.execute(&command, msg.prefix, msg.targets) catch |err| { + const err_msg = switch (err) { + Error.NoMessage => "no matching message", + Error.OutOfMemory => "out of memory", + Error.WriteFailed => "write failed", + }; + return .{ .PRIVMSG = .{ .prefix = msg.prefix, .targets = "#eru-admin", .text = err_msg } }; + }; } - self.bot.hear(zigeru.bot.Message{ - .author = msg.prefix.?.nick orelse "unknown", - .timestamp = std.time.timestamp(), - .content = msg.text, + if (AdminCommand.parse(msg.text)) |command| { + return self.bot.execute_admin(&command, msg.prefix, "#eru-admin") catch |err| { + const err_msg = switch (err) { + Error.NoMessage => "no matching message", + Error.OutOfMemory => "out of memory", + Error.WriteFailed => "write failed", + }; + return .{ .PRIVMSG = .{ .prefix = msg.prefix, .targets = "#eru-admin", .text = err_msg } }; + }; + } + self.bot.hear(BotMessage.new_owned( + self.allocator, + std.time.timestamp(), + msg.prefix.?.nick orelse "unknown", + msg.targets, + msg.text, + ) catch |err| { + const error_msg = switch (err) { + Error.OutOfMemory => "eru failed to listen to a message with error: no memory", + else => unreachable, + }; + return zircon.Message{ + .PRIVMSG = .{ .targets = "jassob", .text = error_msg }, + }; }); }, - else => {}, + else => { + std.log.debug("received unknown message {}", .{message}); + }, } return null; } - pub fn adapter(self: *BotAdapter) Adapter { + pub fn erased_callback(self: *anyopaque, message: zircon.Message) ?zircon.Message { + const a: *@This() = @ptrCast(@alignCast(self)); + return a.callback(message); + } + + pub fn closure(self: *BotAdapter) zircon.MessageClosure { return .{ .ptr = self, - .callbackFn = self.callback, - }; - } -}; - -pub const Command = struct { - command: BotCommand, - prefix: ?zircon.Prefix, - targets: []const u8, - - pub fn parse(prefix: ?zircon.Prefix, targets: []const u8, text: []const u8) ?Command { - const nick = prefix.?.nick.?; - const command = BotCommand.parse(nick, text) orelse return null; - return .{ - .command = command, - .prefix = prefix, - .targets = targets, - }; - } - - pub fn handle(self: Command, bot: *Bot) ?zircon.Message { - return bot.execute(&self.command, self.prefix, self.targets) catch |err| { - std.debug.print("Failed to handle {}: {}", .{ self, err }); - return null; + .callbackFn = BotAdapter.erased_callback, }; } }; @@ -136,5 +152,5 @@ test "substitute" { }; const response = bot_adapter.callback(cmd_msg); try std.testing.expect(response != null); - try std.testing.expectEqualStrings("hello zig", response.?.PRIVMSG.text); + try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text); } diff --git a/src/root.zig b/src/root.zig index 2f461df..caa6615 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,3 +1,3 @@ //! By convention, root.zig is the root source file when making a library. -const std = @import("std"); + pub const bot = @import("bot.zig");