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`
// 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.
//

View file

@ -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/<old-word>/<new-word>/`
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,
}
}

View file

@ -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);
}

View file

@ -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");