Compare commits

..

2 commits

Author SHA1 Message Date
4970b1b1f6
wip: started a refactoring 2026-03-07 15:57:32 +01:00
75ccf913ac
refactor(bot.zig): deinit all backlog slots
Previously we deinited backwards from the most recently heard message
until we reach a null-slot.

Now this code has been simplified to walk the backlog and deinit any
found messages.
2026-01-04 23:54:26 +01:00
8 changed files with 323 additions and 552 deletions

View file

@ -5,31 +5,6 @@ zigeru is a IRC bot which implements the following commands:
- `s/OLD/NEW/` -- posts the previous message by the user, where every - `s/OLD/NEW/` -- posts the previous message by the user, where every
occurrence of OLD is replaced by NEW. occurrence of OLD is replaced by NEW.
```
00:04 <jassob> hello, world
00:05 <jassob> s/world/IRC/
00:05 <@eru> jassob: "hello, IRC"
```
- `!help` -- post a usage string as a response.
```
00:05 <jassob> !help
00:05 <@eru> Send `s/TYPO/CORRECTION/` to replace TYPO with CORRECTION in your last message.
```
- `!join #CHANNEL` -- make zigeru join #CHANNEL.
```
# in #eru-test2
00:06 <jassob> !join #eru-test3
# in #eru-test3
00:06 --> jassob (~u@6wh6sdzhnfjx4.dtek.se) has joined #eru-test3
00:06 -- Channel #eru-test3: 2 nicks (0 owners, 0 admins, 1 op, 0 halfops, 0 voiced, 1 regular)
00:06 -- Channel created on ons, 11 mar 2026 00:06:44
```
## Getting started ## Getting started
To enter into a development shell with all the tools needed for To enter into a development shell with all the tools needed for
@ -45,4 +20,7 @@ To run the binary: `zig build run`.
To build the binary: `zig build`. To build the binary: `zig build`.
To build the binary statically: `zig build -Dtarget=x86_64-linux-musl` ## TO DO
- [ ] Add IRC support
- [ ] Add TLS support

105
build.zig
View file

@ -1,16 +1,12 @@
const std = @import("std"); const std = @import("std");
const Import = std.Build.Module.Import;
const ResolvedTarget = std.Build.ResolvedTarget;
const Run = std.Build.Step.Run;
// Although this function looks imperative, it does not perform the build // Although this function looks imperative, it does not perform the build
// directly and instead it mutates the build graph (`b`) that will be then // directly and instead it mutates the build graph (`b`) that will be then
// executed by an external runner. The functions in `std.Build` implement a DSL // executed by an external runner. The functions in `std.Build` implement a DSL
// for defining build steps and express dependencies between them, allowing the // for defining build steps and express dependencies between them, allowing the
// build runner to parallelize the build automatically (and the cache system to // build runner to parallelize the build automatically (and the cache system to
// know when a step doesn't need to be re-run). // know when a step doesn't need to be re-run).
pub fn build(b: *std.Build) !void { pub fn build(b: *std.Build) void {
// Standard target options allow the person running `zig build` to choose // Standard target options allow the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which // what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options // means any target is allowed, and the default is native. Other options
@ -110,7 +106,7 @@ pub fn build(b: *std.Build) !void {
// This will evaluate the `run` step rather than the default step. // This will evaluate the `run` step rather than the default step.
// For a top level step to actually do something, it must depend on other // For a top level step to actually do something, it must depend on other
// steps (e.g. a Run step, as we will see in a moment). // steps (e.g. a Run step, as we will see in a moment).
const runStep = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");
// This creates a RunArtifact step in the build graph. A RunArtifact step // This creates a RunArtifact step in the build graph. A RunArtifact step
// invokes an executable compiled by Zig. Steps will only be executed by the // invokes an executable compiled by Zig. Steps will only be executed by the
@ -118,51 +114,69 @@ pub fn build(b: *std.Build) !void {
// or if another step depends on it, so it's up to you to define when and // or if another step depends on it, so it's up to you to define when and
// how this Run step will be executed. In our case we want to run it when // how this Run step will be executed. In our case we want to run it when
// the user runs `zig build run`, so we create a dependency link. // the user runs `zig build run`, so we create a dependency link.
const runCmd = b.addRunArtifact(exe); const run_cmd = b.addRunArtifact(exe);
runStep.dependOn(&runCmd.step); run_step.dependOn(&run_cmd.step);
// By making the run step depend on the default step, it will be run from the // By making the run step depend on the default step, it will be run from the
// installation directory rather than directly from within the cache directory. // installation directory rather than directly from within the cache directory.
runCmd.step.dependOn(b.getInstallStep()); run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build // This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc` // command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| { if (b.args) |args| {
runCmd.addArgs(args); run_cmd.addArgs(args);
} }
// A top level step for running all tests. dependOn can be called multiple
// times and since the two run steps do not depend on one another, this will
// make the two of them run in parallel.
const testStep = b.step("test", "Run tests");
const testRunArtifacts: [6]*Run = .{
// Creates an executable that will run `test` blocks from the provided module. // Creates an executable that will run `test` blocks from the provided module.
// Here `mod` needs to define a target, which is why earlier we made sure to // Here `mod` needs to define a target, which is why earlier we made sure to
// set the releative field. // set the releative field.
b.addRunArtifact(b.addTest(.{ const run_mod_tests = b.addRunArtifact(b.addTest(.{
.root_module = mod, .root_module = mod,
})), }));
const run_bot_tests = b.addRunArtifact(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_parser_tests = b.addRunArtifact(b.addTest(.{
.root_module = b.addModule("parser", .{
.target = target,
.root_source_file = b.path("src/parser.zig"),
}),
}));
const run_buffer_tests = b.addRunArtifact(b.addTest(.{
.root_module = b.addModule("buffer", .{
.target = target,
.root_source_file = b.path("src/buffer.zig"),
}),
}));
// 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.
b.addRunArtifact(b.addTest(.{ const run_exe_tests = b.addRunArtifact(b.addTest(.{
.root_module = exe.root_module, .root_module = exe.root_module,
})), }));
// Our bot tests needs zircon module import, // A top level step for running all tests. dependOn can be called multiple
try testRun(b, target, "bot"), // times and since the two run steps do not depend on one another, this will
// Our module tests for each module we want to add. If // make the two of them run in parallel.
// breaking out new functionality to a module, remember to const test_step = b.step("test", "Run tests");
// bump the length of the array above. test_step.dependOn(&run_mod_tests.step);
try testRun(b, target, "buffer"), test_step.dependOn(&run_exe_tests.step);
try testRun(b, target, "parser"), test_step.dependOn(&run_parser_tests.step);
try testRun(b, target, "commands"), test_step.dependOn(&run_bot_tests.step);
}; test_step.dependOn(&run_buffer_tests.step);
for (testRunArtifacts) |test_run_artifact| {
testStep.dependOn(&test_run_artifact.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.
// //
@ -176,30 +190,3 @@ pub fn build(b: *std.Build) !void {
// Lastly, the Zig build system is relatively simple and self-contained, // Lastly, the Zig build system is relatively simple and self-contained,
// and reading its source code will allow you to master it. // and reading its source code will allow you to master it.
} }
// Creates a Run reference that can be depended on when creating a
// test step.
//
// Assumes there exists a file called "src/$name.zig" that contains
// tests.
fn testRun(b: *std.Build, target: ?ResolvedTarget, name: []const u8) error{OutOfMemory}!*Run {
return testRunWithImports(b, target, name, &.{});
}
// Creates a Run reference that can be depended on when creating a
// test step, for modules with external imports.
//
// Assumes there exists a file called "src/$name.zig" that contains
// tests.
fn testRunWithImports(b: *std.Build, target: ?ResolvedTarget, name: []const u8, imports: []const Import) error{OutOfMemory}!*Run {
return b.addRunArtifact(b.addTest(.{
.root_module = b.addModule(
name,
.{
.target = target,
.root_source_file = b.path(try std.mem.concat(b.allocator, u8, &.{ "src/", name, ".zig" })),
.imports = imports,
},
),
}));
}

View file

@ -1,25 +1,92 @@
const std = @import("std"); const std = @import("std");
const zircon = @import("zircon");
const zigeru = @import("root.zig"); const Buffer = @import("buffer.zig").Buffer;
const Buffer = zigeru.buffer.Buffer; const Parser = @import("parser.zig").Parser;
const UserCommand = zigeru.commands.UserCommand;
const AdminCommand = zigeru.commands.AdminCommand; /// 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,
join: struct { channel: []const u8 },
err: struct { message: []const u8 },
pub fn parse(text: []const u8) ?AdminCommand {
const original = Parser.init(text);
if (original.consume_char('!')) |command| {
if (command.consume_str("status")) |_| {
return .status;
}
if (command.consume_str("join").?.consume_char(' ')) |join| {
if (join.rest[0] != '#') {
return .{ .err = .{ .message = "channels must start with \"#\"" } };
}
return .{ .join = .{ .channel = join.rest } };
}
if (command.consume_str("backlog")) |backlog| {
const history = std.fmt.parseInt(u16, backlog.rest, 10) catch |err| {
std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ backlog.rest, err });
return null;
};
return .{ .backlog = .{ .history = history } };
}
std.log.debug("unknown command: \"{s}\"", .{command.rest});
}
return null;
}
};
/// 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 fn parse(nick: []const u8, text: []const u8) ?Command {
const original = Parser.init(text);
if (original.consume_str("!help")) |_| {
return .help;
}
if (original.consume_char('s')) |substitute| {
const delim_parser, const delim = substitute.take_char();
if (std.ascii.isAlphanumeric(delim)) {
std.log.debug("parsing substitute command: delimiter cannot be a whitespace: \"{s}\"", .{text});
return null;
}
const typo_parser, const typo = delim_parser.take_until_char(delim);
const correction_parser, const correction = typo_parser.consume_char(delim).?.take_until_char(delim);
if (correction_parser.consume_char(delim) == null) {
std.log.debug("parsing substitute command: missing an ending '/' in \"{s}\"", .{text});
return null;
}
return .{
.substitute = .{
.author = nick,
.needle = typo,
.replacement = correction,
.all = false,
},
};
}
return null;
}
};
const HELP_MESSAGE: []const u8 = "Send `s/TYPO/CORRECTION/` to replace TYPO with CORRECTION in your last message."; const HELP_MESSAGE: []const u8 = "Send `s/TYPO/CORRECTION/` to replace TYPO with CORRECTION in your last message.";
pub const Error = error{ OutOfMemory, NoMessage, WriteFailed }; pub const Error = error{ OutOfMemory, NoMessage, WriteFailed };
/// Message represents a message received by the bot.
///
/// It is what we store in the backlog and what the substitutions are
/// run on.
pub const Message = struct { pub const Message = struct {
timestamp: i64, timestamp: i64,
targets: []const u8, targets: []const u8,
author: []const u8, author: []const u8,
content: []const u8, content: []const u8,
pub fn initOwned( pub fn init_owned(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
timestamp: i64, timestamp: i64,
author: []const u8, author: []const u8,
@ -42,81 +109,47 @@ pub const Message = struct {
} }
}; };
/// Responses from hearing a Message.
///
/// Response represents the kind of responses the bot can make when
/// hearing a message.
///
/// Responses can be both administrative (i.e. a join-request for a
/// channel), or actual responses to a message (e.g. the result of a
/// substitution).
pub const Response = union(enum) {
join: struct { channels: []const u8 },
privmsg: struct { targets: []const u8, text: []const u8 },
};
pub const Bot = struct { pub const Bot = struct {
backlog: Buffer(*const Message, 1024), backlog: Buffer(*const Message, 1024),
outbox: Buffer([]u8, 1024), sent_messages: Buffer([]u8, 1024),
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
// deinit function for backlog messages fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void {
fn deinitBacklogSlot(allocator: std.mem.Allocator, item: *const Message) void {
item.deinit(allocator); item.deinit(allocator);
} }
// deinit function for outbox messages. fn deinit_sent_message(allocator: std.mem.Allocator, item: []u8) void {
fn deinitSentMessage(allocator: std.mem.Allocator, item: []u8) void {
allocator.free(item); allocator.free(item);
} }
// init initializes a Bot with fixed size backlog and outbox
// buffers.
//
// Both buffers own their contents and deinits any slots that gets
// overwritten.
pub fn init(allocator: std.mem.Allocator) Error!Bot { pub fn init(allocator: std.mem.Allocator) Error!Bot {
var bot = Bot{ var bot = Bot{
.allocator = allocator, .allocator = allocator,
.backlog = undefined, .backlog = undefined,
.outbox = undefined, .sent_messages = undefined,
}; };
bot.backlog = .initWithClosure(allocator, &Bot.deinitBacklogSlot); bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot);
bot.outbox = .initWithClosure(allocator, &Bot.deinitSentMessage); bot.sent_messages = .init_with_closure(allocator, &Bot.deinit_sent_message);
return bot; return bot;
} }
// deinits self's buffers and their contents.
//
// NOTE: does not deinit self.
pub fn deinit(self: *Bot) void { pub fn deinit(self: *Bot) void {
self.outbox.deinit(); self.sent_messages.deinit();
self.backlog.deinit(); self.backlog.deinit();
} }
pub fn hear(self: *Bot, message: *const Message) ?Error!Response { pub fn execute(
// Store the message to keep track of the allocation self: *Bot,
defer self.store(message); cmd: *const Command,
prefix: ?zircon.Prefix,
if (UserCommand.parse(message.author, message.content)) |cmd| { targets: []const u8,
return self.execute(&cmd, message.targets); ) Error!zircon.Message {
}
if (AdminCommand.parse(message.content)) |cmd| {
return self.execute_admin(&cmd, "#eru-admin");
}
return null;
}
pub fn execute(self: *Bot, cmd: *const UserCommand, targets: []const u8) Error!Response {
switch (cmd.*) { switch (cmd.*) {
.substitute => |command| { .substitute => |command| {
const prev_msg = self.previous_message_by_author( const prev_msg = self.previous_message_by_author(
command.author, command.author,
targets, targets,
) orelse return Error.NoMessage; ) orelse return Error.NoMessage;
if (std.mem.count(u8, prev_msg.content, command.needle) == 0) {
return Error.NoMessage;
}
const output = try std.mem.replaceOwned( const output = try std.mem.replaceOwned(
u8, u8,
self.allocator, self.allocator,
@ -130,67 +163,77 @@ pub const Bot = struct {
"{s}: \"{s}\"", "{s}: \"{s}\"",
.{ command.author, output }, .{ command.author, output },
); );
self.outbox.append(quoted_output); self.sent_messages.append(quoted_output);
return .{ .privmsg = .{ return zircon.Message{ .PRIVMSG = .{
.targets = targets, .targets = targets,
.prefix = prefix,
.text = quoted_output, .text = quoted_output,
} }; } };
}, },
.help => { .help => {
return .{ .privmsg = .{ return zircon.Message{ .PRIVMSG = .{
.targets = targets, .targets = targets,
.prefix = prefix,
.text = HELP_MESSAGE, .text = HELP_MESSAGE,
} }; } };
}, },
} }
} }
pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, targets: []const u8) Error!Response { pub fn execute_admin(
self: *Bot,
cmd: *const AdminCommand,
prefix: ?zircon.Prefix,
targets: []const u8,
) Error!zircon.Message {
switch (cmd.*) { switch (cmd.*) {
.status => { .status => {
const msg = try std.fmt.allocPrint( const msg = try std.fmt.allocPrint(
self.allocator, self.allocator,
"heard messages: {}, sent messages: {}", "heard messages: {}, sent messages: {}",
.{ self.backlog.len(), self.outbox.len() }, .{ self.no_messages(), self.sent_messages.items.len },
); );
return .{ .privmsg = .{ .targets = targets, .text = msg } }; return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } };
}, },
.join => |msg| { .join => |msg| {
std.log.debug("received join request: channel \"{s}\"", .{msg.channel}); std.log.debug("received join request: channel \"{s}\"", .{msg.channel});
return .{ .join = .{ .channels = msg.channel } }; return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } };
}, },
.backlog => |backlog| { .backlog => |backlog| {
if (self.backlog.len() == 0) { if (self.backlog.insertions() == 0) {
return Error.NoMessage; return Error.NoMessage;
} }
if (backlog.history >= self.backlog.len()) { if (backlog.history > self.no_messages()) {
return Error.NoMessage; return Error.NoMessage;
} }
if (self.backlog.getBackwards(backlog.history)) |message| { if (self.backlog.get_backwards(backlog.history)) |message| {
const quoted_output = try std.fmt.allocPrint( const quoted_output = try std.fmt.allocPrint(
self.allocator, self.allocator,
"backlog {}: author: \"{s}\", content: \"{s}\"", "backlog {}: author: \"{s}\", content: \"{s}\"",
.{ backlog.history, message.author, message.content }, .{ backlog.history, message.author, message.content },
); );
self.outbox.append(quoted_output); self.sent_messages.append(quoted_output);
return .{ return .{
.privmsg = .{ .targets = targets, .text = quoted_output }, .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output },
}; };
} else return Error.NoMessage; } else return Error.NoMessage;
}, },
.err => |err| { .err => |err| {
return .{ .privmsg = .{ .targets = targets, .text = err.message } }; return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = err.message } };
}, },
} }
} }
// store a message in backlog, potentially overwriting oldest message. pub fn hear(self: *Bot, msg: *const Message) void {
pub fn store(self: *Bot, msg: *const Message) void {
self.backlog.append(msg); self.backlog.append(msg);
} }
pub fn no_messages(self: *Bot) usize {
return self.backlog.len();
}
fn previous_message_by_author(self: *Bot, author: []const u8, targets: []const u8) ?*const Message { fn previous_message_by_author(self: *Bot, author: []const u8, targets: []const u8) ?*const Message {
var iter = self.backlog.iterateReverse(); var iter = self.backlog.iterate_reverse();
while (iter.prev()) |message| { while (iter.prev()) |message| {
if (std.mem.eql(u8, message.author, author) and if (std.mem.eql(u8, message.author, author) and
std.mem.eql(u8, message.targets, targets)) std.mem.eql(u8, message.targets, targets))
@ -202,19 +245,15 @@ pub const Bot = struct {
} }
}; };
fn newTestMessage(allocator: std.mem.Allocator, content: []const u8) !*Message { test "deiniting an owned Message leaks no memory" {
return try Message.initOwned( const allocator = std.testing.allocator;
const testMessage = try Message.init_owned(
allocator, allocator,
12345, 12345,
"jassob", "jassob",
"#test", "#test",
content, "All your codebase are belong to us.\n",
); );
}
test "deiniting an owned Message leaks no memory" {
const allocator = std.testing.allocator;
const testMessage = try newTestMessage(allocator, "test");
testMessage.deinit(allocator); testMessage.deinit(allocator);
} }
@ -228,10 +267,17 @@ test "hear and deinit has no leaks" {
var bot = try Bot.init(allocator); var bot = try Bot.init(allocator);
defer bot.deinit(); defer bot.deinit();
const testMessage = try newTestMessage(allocator, "test"); const testMessage = try Message.init_owned(
try std.testing.expectEqual(null, bot.hear(testMessage)); allocator,
12345,
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
try std.testing.expectEqual(0, bot.backlog.top); try std.testing.expectEqual(0, bot.backlog.top);
try std.testing.expectEqual(1, bot.no_messages());
} }
test "a few hears and deinit has no leaks" { test "a few hears and deinit has no leaks" {
@ -239,12 +285,19 @@ test "a few hears and deinit has no leaks" {
var bot = try Bot.init(allocator); var bot = try Bot.init(allocator);
defer bot.deinit(); defer bot.deinit();
for (0..2) |_| { for (0..2) |i| {
const testMessage = try newTestMessage(std.testing.allocator, "test"); const testMessage = try Message.init_owned(
_ = bot.hear(testMessage); std.testing.allocator,
@intCast(i),
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
} }
try std.testing.expectEqual(1, bot.backlog.top); try std.testing.expectEqual(1, bot.backlog.top);
try std.testing.expect(bot.no_messages() == 2);
} }
test "hear wraps" { test "hear wraps" {
@ -252,86 +305,69 @@ test "hear wraps" {
defer bot.deinit(); defer bot.deinit();
for (0..1025) |_| { for (0..1025) |_| {
const testMessage = try newTestMessage(std.testing.allocator, "test"); const testMessage = try Message.init_owned(
_ = bot.hear(testMessage); std.testing.allocator,
12345,
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
} }
try std.testing.expectEqual(0, bot.backlog.top); try std.testing.expectEqual(0, bot.backlog.top);
try std.testing.expectEqual(1, bot.backlog.bottom()); try std.testing.expectEqual(1, bot.backlog.bottom());
try std.testing.expectEqual(1024, bot.backlog.len()); try std.testing.expectEqual(1024, bot.no_messages());
} }
test "execute substitution no previous message" { test "execute_substitution_no_previous_message" {
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,
null,
"#test",
));
}
test "execute_substitution" {
var bot = try Bot.init(std.testing.allocator); var bot = try Bot.init(std.testing.allocator);
defer bot.deinit(); defer bot.deinit();
const substitution = try newTestMessage(std.testing.allocator, "s/What/what/");
try std.testing.expectError(Error.NoMessage, bot.hear(substitution).?);
}
test "execute substitution" {
const allocator = std.testing.allocator;
var bot = try Bot.init(allocator);
defer bot.deinit();
// hear original message with typo // hear original message with typo
const msg = try newTestMessage(allocator, "What"); const msg = try Message.init_owned(
try std.testing.expectEqual(null, bot.hear(msg)); std.testing.allocator,
1234,
"jassob",
"#test",
"What",
);
bot.hear(msg);
// execute substitution // execute substitution
const sub = try newTestMessage(allocator, "s/What/what/"); const cmd = Command{
const response = try bot.hear(sub).?; .substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" },
};
const response = try bot.execute(&cmd, null, "#test");
// expect response matching the correct message // expect response matching the correct message
switch (response) { switch (response) {
.privmsg => |message| { .PRIVMSG => |message| {
try std.testing.expectEqualDeep(message.text, "jassob: \"what\""); try std.testing.expectEqualDeep(message.text, "jassob: \"what\"");
}, },
else => unreachable, else => unreachable,
} }
} }
test "execute substitution with no matching needle" { test "parse_botcommands" {
const allocator = std.testing.allocator; const cmd = AdminCommand.parse("!join badchannel") orelse unreachable;
var bot = try Bot.init(allocator); try std.testing.expectEqual(
defer bot.deinit(); AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } },
cmd,
// hear original message
const msg = try newTestMessage(allocator, "original");
try std.testing.expectEqual(null, bot.hear(msg));
// execute substitution
const sub = try newTestMessage(allocator, "s/something else/weird/");
try std.testing.expectError(Error.NoMessage, bot.hear(sub).?);
}
test "recursive substitutions does not cause issues" {
const allocator = std.testing.allocator;
var bot = try Bot.init(allocator);
defer bot.deinit();
// hear original message
const msg = try newTestMessage(allocator, "original");
try std.testing.expectEqual(null, bot.hear(msg));
// execute substitution
const sub = try newTestMessage(allocator, "s/original/something else/");
switch (try bot.hear(sub).?) {
.privmsg => |message| {
try std.testing.expectEqualDeep("jassob: \"something else\"", message.text);
},
else => unreachable,
}
// execute second substitution
const sub2 = try newTestMessage(
allocator,
"s|s/original/something else/|something else|",
); );
switch (try bot.hear(sub2).?) {
.privmsg => |message| {
try std.testing.expectEqualDeep("jassob: \"something else\"", message.text);
},
else => unreachable,
}
} }

View file

@ -6,6 +6,11 @@ const std = @import("std");
// automatically deinited when it is overwritten by supplying a custom // automatically deinited when it is overwritten by supplying a custom
// ItemDeinitClosure, using .init_with_closure. // ItemDeinitClosure, using .init_with_closure.
pub fn Buffer(comptime T: type, comptime length: usize) type { pub fn Buffer(comptime T: type, comptime length: usize) type {
// TODO(jassob): Refactor buffer to use a top and len instead of
// top and bottom.
//
// Idea: bottom is always 0 if len != length and otherwise it is
// always top + 1.
return struct { return struct {
items: [length]?T, items: [length]?T,
top: usize, top: usize,
@ -23,7 +28,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
}; };
} }
pub fn initWithClosure( pub fn init_with_closure(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void, deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void,
) Buffer(T, length) { ) Buffer(T, length) {
@ -33,12 +38,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
return buf; return buf;
} }
fn canDeinit(self: *const Buffer(T, length)) bool { fn can_deinit(self: *const Buffer(T, length)) bool {
return self.allocator != null and self.deinit_func != null; return self.allocator != null and self.deinit_func != null;
} }
fn deinitItem(self: *Buffer(T, length), item: T) void { fn deinit_item(self: *Buffer(T, length), item: T) void {
if (!self.canDeinit()) { if (!self.can_deinit()) {
// Nothing to do // Nothing to do
return; return;
} }
@ -66,7 +71,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
} }
if (self.insertions >= length) { if (self.insertions >= length) {
// free old bottom item // free old bottom item
self.deinitItem(self.items[self.top].?); self.deinit_item(self.items[self.top].?);
self.items[self.top] = null; self.items[self.top] = null;
} }
self.items[self.top] = item; self.items[self.top] = item;
@ -86,14 +91,14 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
pub fn get(self: *const Buffer(T, length), index: usize) ?T { pub fn get(self: *const Buffer(T, length), index: usize) ?T {
const idx = (self.bottom() + index) % self.items.len; const idx = (self.bottom() + index) % self.items.len;
if (index > self.insertions) { if (index > self.insertions()) {
return null; return null;
} }
return self.items[idx]; return self.items[idx];
} }
pub fn getBackwards(self: *const Buffer(T, length), offset: usize) ?T { pub fn get_backwards(self: *const Buffer(T, length), offset: usize) ?T {
if (offset >= self.insertions) { if (offset >= self.insertions()) {
return null; return null;
} }
return self.items[(self.top - offset) % self.items.len]; return self.items[(self.top - offset) % self.items.len];
@ -107,7 +112,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
}; };
} }
pub fn iterateReverse(self: *const Buffer(T, length)) BufferIterator(T, length) { pub fn iterate_reverse(self: *const Buffer(T, length)) BufferIterator(T, length) {
return .{ return .{
.buffer = self, .buffer = self,
.index = self.top, .index = self.top,
@ -116,12 +121,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
} }
pub fn deinit(self: *Buffer(T, length)) void { pub fn deinit(self: *Buffer(T, length)) void {
if (!self.canDeinit()) { if (!self.can_deinit()) {
return; return;
} }
for (self.items, 0..) |item, idx| { for (self.items, 0..) |item, idx| {
if (item != null) { if (item != null) {
self.deinitItem(item.?); self.deinit_item(item.?);
self.items[idx] = null; self.items[idx] = null;
} }
} }
@ -232,7 +237,7 @@ test "deiniting allocating buffer does not leak" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// create buffer with closure // create buffer with closure
var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice);
defer buffer.deinit(); defer buffer.deinit();
// add less elements than length // add less elements than length
@ -248,7 +253,7 @@ test "wrapping allocating buffer does not leak" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// create buffer with closure // create buffer with closure
var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice); var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice);
defer buffer.deinit(); defer buffer.deinit();
// add an element more than length // add an element more than length

View file

@ -1,144 +0,0 @@
const std = @import("std");
const Parser = @import("root.zig").parser.Parser;
/// UserCommand represents the commands that ordinary IRC users can use.
pub const UserCommand = 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 fn init_substitute(author: []const u8, needle: []const u8, replacement: []const u8, all: bool) UserCommand {
return .{ .substitute = .{
.author = author,
.needle = needle,
.replacement = replacement,
.all = all,
} };
}
pub fn parse(nick: []const u8, text: []const u8) ?UserCommand {
const original = Parser.init(text);
if (original.consume_str("!help")) |_| {
return .help;
}
if (original.consume_char('s')) |substitute| {
const log_prefix = "parsing substitute command";
var parser = substitute;
parser, const delim = parser.take_char();
if (std.ascii.isAlphanumeric(delim)) {
std.log.debug("{s}: delimiter cannot be a whitespace: \"{s}\"", .{ log_prefix, text });
return null;
}
var result = parser.take_until_char(delim);
if (result == null) {
std.log.debug(
"{s}: cannot find typo, expecting a message on the form 's{}TYPO{}CORRECTION{}', but got {s}",
.{ log_prefix, delim, delim, delim, text },
);
return null;
}
parser, const typo = result.?;
result = parser.consume_char(delim).?.take_until_char(delim);
if (result == null) {
std.log.debug("{s}: missing an ending '/' in \"{s}\"", .{ log_prefix, text });
return null;
}
parser, const correction = result.?;
return .init_substitute(nick, typo, correction, false);
}
return null;
}
};
test "can parse !help successful" {
try std.testing.expectEqual(
UserCommand.help,
UserCommand.parse("jassob", "!help"),
);
}
test "can parse s/hello/world/ successful" {
try std.testing.expectEqualDeep(
UserCommand{ .substitute = .{
.author = "jassob",
.needle = "hello",
.replacement = "world",
.all = false,
} },
UserCommand.parse("jassob", "s/hello/world/"),
);
}
test "can parse s/hello/world and report failure" {
try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world"));
}
test "can parse s/hello|world| and report failure" {
try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "s/hello/world"));
}
test "correctly ignores non-messages when trying to parse" {
try std.testing.expectEqualDeep(null, UserCommand.parse("jassob", "Hello, world"));
}
/// 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,
join: struct { channel: []const u8 },
err: struct { message: []const u8 },
pub fn parse(text: []const u8) ?AdminCommand {
const original = Parser.init(text);
if (original.consume_char('!')) |command| {
if (command.consume_str("status")) |_| {
return .status;
}
if (command.consume_str("join")) |join| {
if (join.consume_space()) |channel| {
if (channel.rest[0] != '#') {
return .{ .err = .{ .message = "channels must start with \"#\"" } };
}
return .{ .join = .{ .channel = join.rest } };
}
}
if (command.consume_str("backlog")) |backlog| {
if (backlog.consume_space()) |history| {
const historyOffset = std.fmt.parseInt(u16, history.rest, 10) catch |err| {
std.debug.print("failed to parse int ('{s}') with error: {}\n", .{ history.rest, err });
return null;
};
return .{ .backlog = .{ .history = historyOffset } };
}
}
std.log.debug("unknown command: \"{s}\"", .{command.rest});
}
return null;
}
};
test "parse admin commands" {
const cmd = AdminCommand.parse("!join badchannel") orelse unreachable;
try std.testing.expectEqual(
AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } },
cmd,
);
}
test "parse backlog admin commands" {
const cmd = AdminCommand.parse("!backlog 1") orelse unreachable;
try std.testing.expectEqual(
AdminCommand{ .backlog = .{ .history = 1 } },
cmd,
);
}
test "parse unknown admin commands" {
const cmd = AdminCommand.parse("!history 1");
try std.testing.expectEqual(null, cmd);
}

View file

@ -1,10 +1,11 @@
const std = @import("std"); const std = @import("std");
const zigeru = @import("zigeru"); const zigeru = @import("zigeru");
const bot = zigeru.bot;
const Bot = zigeru.bot.Bot; const Bot = zigeru.bot.Bot;
const UserCommand = zigeru.commands.UserCommand; const Error = zigeru.bot.Error;
const AdminCommand = zigeru.commands.AdminCommand; const BotCommand = zigeru.bot.Command;
const AdminCommand = zigeru.bot.AdminCommand;
const BotMessage = zigeru.bot.Message;
const zircon = @import("zircon"); const zircon = @import("zircon");
var debug_allocator = std.heap.DebugAllocator(.{}).init; var debug_allocator = std.heap.DebugAllocator(.{}).init;
@ -42,16 +43,11 @@ pub fn main() !void {
}; };
} }
/// BotAdapter is the closure that we register in zircon as the pub const Adapter = struct {
/// message callback. ptr: *anyopaque,
/// callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message,
/// Whenever a message is received by the zircon client it will invoke };
/// BotAdapter.callback (through some indirection) with the received
/// message.
///
/// The main responsibility of BotAdapter is to serve as the
/// translation layer between our own internal types and the zircon
/// IRC types.
pub const BotAdapter = struct { pub const BotAdapter = struct {
bot: Bot, bot: Bot,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -67,48 +63,29 @@ pub const BotAdapter = struct {
self.bot.deinit(); self.bot.deinit();
} }
/// callback gets called for every message that we receive. pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message {
///
/// This is where we can extend the bot to support more types of
/// messages if needed.
///
/// See
/// - https://modern.ircdocs.horse/, for what kinds of messages exists in the IRC protocol documentation,
/// - https://github.com/Jassob/zircon/blob/main/src/message.zig, for zircon documentation.
///
/// NOTE: This function does not have a self-parameter, this is
/// because this function is called as a "generic" function
/// that is parameterized in the pointer argument (otherwise
/// zircon library would not be able to be reused).
///
/// That's why the first thing we do is perform some casting
/// magic to convert our pointer back to a BotAdapter
/// pointer. This is safe as long as we know that we
/// registered a BotAdapter closure and there are no other
/// callbacks registered.
pub fn callback(ptr: *anyopaque, message: zircon.Message) ?zircon.Message {
const self: *@This() = @ptrCast(@alignCast(ptr));
switch (message) { switch (message) {
.PRIVMSG => |msg| { .PRIVMSG => |msg| {
const nick = nickFromPrefix(msg.prefix); std.log.debug(
"received message: nick {?s}, user: {?s}, host: {?s}, targets: {s}, text: {s}",
// create message .{ msg.prefix.?.nick, msg.prefix.?.user, msg.prefix.?.host, msg.targets, msg.text },
const bot_message = bot.Message.initOwned( );
const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown";
if (BotCommand.parse(nick, msg.text)) |cmd| {
return self.bot.execute(&cmd, msg.prefix, msg.targets) catch |err| return report_error(err);
}
if (AdminCommand.parse(msg.text)) |cmd| {
return self.bot.execute_admin(&cmd, msg.prefix, "#eru-admin") catch |err| return report_error(err);
}
const bot_msg = BotMessage.init_owned(
self.allocator, self.allocator,
std.time.timestamp(), std.time.timestamp(),
nick, nick,
msg.targets, msg.targets,
msg.text, msg.text,
) catch |err| return reportError(err); ) catch |err| return report_error(err);
self.bot.hear(&bot_msg);
// send message to bot
const response = self.bot.hear(bot_message) orelse {
return null; return null;
} catch |err| {
return reportError(err);
};
return toIRC(response);
}, },
.JOIN => |msg| { .JOIN => |msg| {
std.log.debug("received join message: channels {s}", .{msg.channels}); std.log.debug("received join message: channels {s}", .{msg.channels});
@ -121,77 +98,50 @@ pub const BotAdapter = struct {
} }
} }
/// report errors as private message to admin channel. fn report_error(err: Error) zircon.Message {
fn reportError(err: bot.Error) zircon.Message {
const err_msg = switch (err) { const err_msg = switch (err) {
bot.Error.NoMessage => "no matching message", Error.NoMessage => "no matching message",
bot.Error.OutOfMemory => "out of memory", Error.OutOfMemory => "out of memory",
bot.Error.WriteFailed => "write failed", Error.WriteFailed => "write failed",
}; };
return .{ .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = err_msg } }; return .{ .PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = err_msg } };
} }
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 { pub fn closure(self: *BotAdapter) zircon.MessageClosure {
return .{ return .{
.ptr = self, .ptr = self,
.callbackFn = BotAdapter.callback, .callbackFn = BotAdapter.erased_callback,
}; };
} }
}; };
/// nickFromPrefix returns a nick if it exists in the prefix or // test "substitute" {
/// "unknown" otherwise. // var bot_adapter = try BotAdapter.init(std.testing.allocator);
fn nickFromPrefix(prefix: ?zircon.Prefix) []const u8 { // defer bot_adapter.deinit();
if (prefix == null or prefix.?.nick == null) { // const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" };
return "unknown"; // const msg = zircon.Message{
} // .PRIVMSG = .{
return prefix.?.nick.?; // .prefix = prefix,
} // .targets = "#test",
// .text = "hello world",
/// toIRC converts a bot response and converts it to a IRC message. // },
fn toIRC(response: bot.Response) zircon.Message { // };
switch (response) { // if (bot_adapter.callback(msg)) |_| {
.join => |join| return .{ // @panic("unexpected response");
.JOIN = .{ .prefix = null, .channels = join.channels }, // }
}, // const cmd_msg = zircon.Message{
.privmsg => |msg| return .{ // .PRIVMSG = .{
.PRIVMSG = .{ .prefix = null, .targets = msg.targets, .text = msg.text }, // .prefix = prefix,
}, // .targets = "#test",
} // .text = "s/world/zig/",
} // },
// };
fn priv_msg(text: []const u8) zircon.Message { // const response = bot_adapter.callback(cmd_msg);
return .{ .PRIVMSG = .{ // try std.testing.expect(response != null);
.prefix = .{ .nick = "jassob", .host = "localhost", .user = "jassob" }, // try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text);
.targets = "#test", // }
.text = text,
} };
}
test "substitute" {
var adapter = try BotAdapter.init(std.testing.allocator);
defer adapter.deinit();
if (BotAdapter.callback(&adapter, priv_msg("hello world"))) |_| {
@panic("unexpected response");
}
const response = BotAdapter.callback(&adapter, priv_msg("s/world/zig/"));
try std.testing.expect(response != null);
try std.testing.expectEqualStrings(
"jassob: \"hello zig\"",
response.?.PRIVMSG.text,
);
}
test "get empty backlog message" {
var bot_adapter = try BotAdapter.init(std.testing.allocator);
defer bot_adapter.deinit();
const msg = zircon.Message{
.PRIVMSG = .{ .prefix = null, .targets = "#eru-admin", .text = "!backlog 0" },
};
try std.testing.expectEqualDeep(
"no matching message",
BotAdapter.callback(&bot_adapter, msg).?.PRIVMSG.text,
);
}

View file

@ -1,15 +1,14 @@
const std = @import("std"); const std = @import("std");
pub fn init(s: []const u8) Parser {
return .init(s);
}
pub const Parser = struct { pub const Parser = struct {
original: []const u8, original: []const u8,
rest: []const u8, rest: []const u8,
end_idx: usize, end_idx: usize,
// Initializes a Parser for s. pub fn seek(self: *const Parser, skip: usize) Parser {
return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip };
}
pub fn init(s: []const u8) Parser { pub fn init(s: []const u8) Parser {
return .{ return .{
.original = s, .original = s,
@ -18,25 +17,6 @@ pub const Parser = struct {
}; };
} }
// Seek the parser window of the text forward skip bytes and return a new Parser.
pub fn seek(self: *const Parser, skip: usize) Parser {
return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip };
}
// Attempts to consume at least one whitespace character from the input text.
pub fn consume_space(self: *const Parser) ?Parser {
if (!std.ascii.isWhitespace(self.rest[0])) {
return null;
}
for (self.rest[1..], 1..) |c, idx| {
if (!std.ascii.isWhitespace(c)) {
return self.seek(idx);
}
}
return self.seek(self.rest.len);
}
// Attempts to consume a character c.
pub fn consume_char(self: *const Parser, c: u8) ?Parser { pub fn consume_char(self: *const Parser, c: u8) ?Parser {
if (self.rest[0] != c) { if (self.rest[0] != c) {
return null; return null;
@ -44,7 +24,6 @@ pub const Parser = struct {
return self.seek(1); return self.seek(1);
} }
// Attempts to consume a string s.
pub fn consume_str(self: *const Parser, s: []const u8) ?Parser { pub fn consume_str(self: *const Parser, s: []const u8) ?Parser {
const len = s.len; const len = s.len;
if (self.rest.len < len) { if (self.rest.len < len) {
@ -56,34 +35,16 @@ pub const Parser = struct {
return self.seek(len); return self.seek(len);
} }
// Finds the next occurrence of c (idx) in the current parser pub fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } {
// window and extracts it. const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable;
//
// Returns a new parser window that starts after idx and the
// extracted byte slice.
pub fn take_until_char(self: *const Parser, c: u8) ?struct { Parser, []const u8 } {
if (std.mem.indexOfScalar(u8, self.rest, c)) |idx| {
return .{ self.seek(idx), self.rest[0..idx] }; return .{ self.seek(idx), self.rest[0..idx] };
} }
return null;
}
// Take the current character and advance the parser one step.
pub fn take_char(self: *const Parser) struct { Parser, u8 } { pub fn take_char(self: *const Parser) struct { Parser, u8 } {
return .{ self.seek(1), self.rest[0] }; return .{ self.seek(1), self.rest[0] };
} }
// Return the currently accepted text.
pub fn parsed(self: *const Parser) []const u8 { pub fn parsed(self: *const Parser) []const u8 {
return self.original[0..self.end_idx]; return self.original[0..self.end_idx];
} }
}; };
test "parser can skip whitespace" {
var parser = init("Hello, World");
parser = parser.consume_str("Hello,").?;
parser = parser.consume_space().?;
parser = parser.consume_str("World").?;
try std.testing.expectEqual("Hello, World", parser.parsed());
}

View file

@ -2,5 +2,3 @@
pub const bot = @import("bot.zig"); pub const bot = @import("bot.zig");
pub const buffer = @import("buffer.zig"); pub const buffer = @import("buffer.zig");
pub const parser = @import("parser.zig");
pub const commands = @import("commands.zig");