chore: make stuff work

This commit is contained in:
Jacob Jonsson 2025-11-30 22:52:22 +01:00
parent 936bf470c7
commit 1e4c90822a
Signed by: Jassob
GPG key ID: 7E30B9B047F7202E
4 changed files with 267 additions and 117 deletions

View file

@ -21,6 +21,12 @@ pub fn build(b: *std.Build) void {
// target and optimize options) will be listed when running `zig build --help` // target and optimize options) will be listed when running `zig build --help`
// in this directory. // 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 // This creates a module, which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries. // some compilation options, such as optimization mode and linked system libraries.
// Zig modules are the preferred way of making Zig code available to consumers. // 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 // Later on we'll use this module as the root module of a test executable
// which requires us to specify a target. // which requires us to specify a target.
.target = target, .target = target,
.imports = &.{
.{ .name = "zircon", .module = zircon.module("zircon") },
},
}); });
// Here we define an executable. An executable needs to have a root module // 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 // can be extremely useful in case of collisions (which can happen
// importing modules from different packages). // importing modules from different packages).
.{ .name = "zigeru", .module = mod }, .{ .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(); exe.linkLibC();
// This declares intent for the executable to be installed into the // 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. // A run step that will run the test executable.
const run_mod_tests = b.addRunArtifact(mod_tests); 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 // 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, // root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones. // 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"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_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. // Just like flags, top level steps are also listed in the `--help` menu.
// //

View file

@ -1,62 +1,101 @@
const std = @import("std"); const std = @import("std");
const zircon = @import("zircon"); const zircon = @import("zircon");
pub const Command = union(enum) { /// AdminCommand are commands useful for debugging zigeru, since they
substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool }, /// are more spammy than others they are separated and only sent to
help: void, /// #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.len < 2) return null;
if (text[0] == 's') { if (std.mem.eql(u8, text, "!status")) {
if (text.len == 1) return null; return .status;
const delim = text[1]; }
var parts = std.mem.splitScalar(u8, text, delim);
_ = parts.next() orelse return null; // skip 's' if (text.len > 8 and std.mem.eql(u8, text[0..8], "!backlog")) {
const needle = parts.next() orelse return null; const history = std.fmt.parseInt(u16, text[9..], 10) catch |err| {
const replacement = parts.next().?; std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ text[8..], err });
const flags = parts.next(); return null;
const all = if (flags == null) false else (std.mem.eql(u8, flags.?, "g"));
return Command{
.substitute = .{
.author = nick,
.needle = needle,
.replacement = replacement,
.all = all,
},
}; };
return .{ .backlog = .{ .history = history } };
} }
if (std.mem.eql(u8, text, "help")) {
return Command.help;
}
return null; return null;
} }
}; };
const HELP_MESSAGE: []const u8 = /// Command represents the commands that ordinary IRC users can use.
\\Welcome to zigeru! pub const Command = union(enum) {
\\ /// `s/<old-word>/<new-word>/`
\\Commands: substitute: struct { author: []const u8, needle: []const u8, replacement: []const u8, all: bool = false },
\\!help:\tSee this message /// !help
\\s/TYPO/CORRECTION/\tCorrect previous message by replacing TYPO with CORRECTION. help: void,
;
pub const Error = error{ pub fn parse(nick: []const u8, text: []const u8) ?Command {
OutOfMemory, if (std.mem.eql(u8, text, "!help")) {
NoMessage, 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 { pub const Message = struct {
timestamp: i64, timestamp: i64,
targets: []const u8,
author: []const u8, author: []const u8,
content: []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 { pub const Bot = struct {
backlog: [1024]?Message, backlog: [1024]?Message,
sent_messages: std.ArrayList(u8), sent_messages: std.ArrayList([]u8),
top: usize, top: usize,
bottom: usize, bottom: usize,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -64,7 +103,7 @@ pub const Bot = struct {
pub fn init(allocator: std.mem.Allocator) Error!Bot { pub fn init(allocator: std.mem.Allocator) Error!Bot {
return Bot{ return Bot{
.backlog = .{null} ** 1024, .backlog = .{null} ** 1024,
.sent_messages = try std.ArrayList(u8).initCapacity(allocator, 10), .sent_messages = try std.ArrayList([]u8).initCapacity(allocator, 10),
.top = 0, .top = 0,
.bottom = 0, .bottom = 0,
.allocator = allocator, .allocator = allocator,
@ -72,28 +111,90 @@ pub const Bot = struct {
} }
pub fn deinit(self: *Bot) void { pub fn deinit(self: *Bot) void {
for (self.sent_messages.items) |item| {
self.allocator.free(item);
}
self.sent_messages.deinit(self.allocator); 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 { pub fn execute(self: *Bot, cmd: *const Command, prefix: ?zircon.Prefix, targets: []const u8) Error!zircon.Message {
switch (cmd.*) { switch (cmd.*) {
.substitute => |command| { .substitute => |command| {
const prev_msg = self.previous_message_by_author(command.author) orelse return Error.NoMessage; const prev_msg = self.previous_message_by_author(command.author, targets) orelse return Error.NoMessage;
const size = std.mem.replacementSize(u8, prev_msg.content, command.needle, command.replacement); const output = try std.mem.replaceOwned(
const output = try self.sent_messages.addManyAsSlice(self.allocator, size); u8,
_ = std.mem.replace(u8, prev_msg.content, command.needle, command.replacement, output); self.allocator,
return zircon.Message{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = output } }; 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 => { .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 { pub fn hear(self: *Bot, msg: Message) void {
self.backlog[self.top] = msg; self.backlog[self.top] = msg;
self.top = (self.top + 1) % 1024; self.top = (self.top + 1) % self.backlog.len;
if (self.top == self.bottom) self.bottom = (self.bottom + 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 { pub fn no_messages(self: *Bot) usize {
@ -108,7 +209,7 @@ pub const Bot = struct {
if (idx == 0) { if (idx == 0) {
return self.backlog.len - 1; 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 { fn previous_message(self: *Bot, comptime pred: *const fn (Message) bool) ?Message {
@ -129,14 +230,14 @@ pub const Bot = struct {
return null; 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); var idx = self.previous_idx(self.top);
while (true) : (idx = self.previous_idx(idx)) { while (true) : (idx = self.previous_idx(idx)) {
if (self.backlog[idx] == null) { if (self.backlog[idx] == null) {
return null; return null;
} }
const message = self.backlog[idx] orelse unreachable; 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; return message;
} }
if (idx == self.bottom) { if (idx == self.bottom) {
@ -149,7 +250,9 @@ pub const Bot = struct {
}; };
test "hear_wraps" { test "hear_wraps" {
var bot = Bot.new(std.testing.allocator); var bot = try Bot.init(std.testing.allocator);
defer bot.deinit();
const testMessage = Message{ const testMessage = Message{
.author = "Jassob", .author = "Jassob",
.timestamp = 12345, .timestamp = 12345,
@ -166,7 +269,8 @@ test "hear_wraps" {
} }
test "previous_message" { test "previous_message" {
var bot = Bot.new(std.testing.allocator); var bot = try Bot.init(std.testing.allocator);
defer bot.deinit();
const callback = struct { const callback = struct {
fn callback(_: Message) bool { fn callback(_: Message) bool {
@ -179,7 +283,8 @@ test "previous_message" {
} }
test "previous_message1" { 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; var contents: [10][]const u8 = .{undefined} ** 10;
for (0..10) |i| { for (0..10) |i| {
@ -207,20 +312,30 @@ test "previous_message1" {
} }
test "execute_substitution_no_previous_message" { 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" } }; 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" { 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" }); 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); // execute substitution
switch (result) { const cmd = Command{
.post_message => |message| { .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" },
try std.testing.expectEqualDeep(message.content, "what"); };
std.testing.allocator.free(message.content); 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,
} }
} }

View file

@ -1,9 +1,13 @@
const std = @import("std"); const std = @import("std");
const zigeru = @import("zigeru");
const zircon = @import("zircon"); const zircon = @import("zircon");
const zigeru = @import("zigeru");
const Bot = zigeru.bot.Bot; const Bot = zigeru.bot.Bot;
const Error = zigeru.bot.Error;
const BotCommand = zigeru.bot.Command; const BotCommand = zigeru.bot.Command;
const AdminCommand = zigeru.bot.AdminCommand;
const BotMessage = zigeru.bot.Message;
var debug_allocator = std.heap.DebugAllocator(.{}).init; var debug_allocator = std.heap.DebugAllocator(.{}).init;
@ -26,30 +30,23 @@ pub fn main() !void {
defer client.deinit(); defer client.deinit();
var bot_adapter = try BotAdapter.init(allocator); 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. // Connect to the IRC server and perform registration.
try client.connect(); try client.connect();
try client.register(); try client.register();
try client.join("#eru-tests"); try client.join("#eru-tests");
try client.join("#eru-admin");
// Enter the main loop that keeps reading incoming IRC messages forever. client.loop(.{}) catch |err| {
// The client loop accepts a LoopConfig struct with two optional fields. std.debug.print("eru exited with error: {}\n", .{err});
// return;
// 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});
}; };
} }
pub const Adapter = struct { pub const Adapter = struct {
ptr: ?*anyopaque, ptr: *anyopaque,
callbackFn: *const fn (?*anyopaque, zircon.Message) ?zircon.Message, callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message,
}; };
pub const BotAdapter = struct { pub const BotAdapter = struct {
@ -70,47 +67,66 @@ pub const BotAdapter = struct {
pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message { pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message {
switch (message) { switch (message) {
.PRIVMSG => |msg| { .PRIVMSG => |msg| {
if (Command.parse(msg.prefix, msg.targets, msg.text)) |command| { std.log.debug("received message: nick {?s}, user: {?s}, host: {?s}, targets: {s}, text: {s}", .{
return command.handle(&self.bot); 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{ if (AdminCommand.parse(msg.text)) |command| {
.author = msg.prefix.?.nick orelse "unknown", return self.bot.execute_admin(&command, msg.prefix, "#eru-admin") catch |err| {
.timestamp = std.time.timestamp(), const err_msg = switch (err) {
.content = msg.text, 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; 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 .{ return .{
.ptr = self, .ptr = self,
.callbackFn = self.callback, .callbackFn = BotAdapter.erased_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;
}; };
} }
}; };
@ -136,5 +152,5 @@ test "substitute" {
}; };
const response = bot_adapter.callback(cmd_msg); const response = bot_adapter.callback(cmd_msg);
try std.testing.expect(response != null); 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);
} }

View file

@ -1,3 +1,3 @@
//! By convention, root.zig is the root source file when making a library. //! By convention, root.zig is the root source file when making a library.
const std = @import("std");
pub const bot = @import("bot.zig"); pub const bot = @import("bot.zig");