Compare commits

..

18 commits

Author SHA1 Message Date
bd3d7b909a
chore: unexport some bot imports 2026-03-15 15:21:50 +01:00
1addcc7ae3
fixup! chore: rename functions 2026-03-15 15:20:41 +01:00
2e1416eb21
refactor(main): remove one layer of indirection
This commit removes the BotAdapter.erased_callback function, which
served as a generic version of BotAdapter.callback that was legible
for zircon.MessageClosure.

However, it is clearer to document how that mechanism works. Therefore
we remove the BotAdapter.erased_callback and instead makes
BotAdapter.callback match the zircon.MessageClosure.callbackFn
signature.
2026-03-15 15:15:49 +01:00
7d444204ef
docs(main): add comments to functions and types 2026-03-15 15:14:59 +01:00
b72d584996
fixup! refactor: extract modules from bot.zig
This comment was added when this refactoring had already been
performed.
2026-03-15 15:06:56 +01:00
1d44645451
chore: rename functions
This commit updates the function to match the naming conventions used
in Zig, where variables and fields are snake_case, but functions are
camelCase and types are PascalCase.
2026-03-15 15:04:56 +01:00
bd1891521e
refactor: move message dispatch logic to bot
This commit moves the logic that governs what action to take when a
message is heard by the bot from the BotAdapter (which should be a
layer only responsible for translating IRC messages to our internal
representation) to the Bot. This makes it possible to test full
conversations in the bot tests.
2026-03-15 14:35:51 +01:00
49a6b79fd9
refactor(commands): move user command tests
This commit just shuffles the test for user commands closer to the
definition of the user commands.
2026-03-15 13:51:26 +01:00
958c00ce6f
refactor(bot): rename bot.hear to bot.store
Clearer what it actually does (i.e. record a message in the backlog)
and also unlocks that name for a dispatch function where we hear any
message and decide what to do with it. Currently that dispatch logic
lives inside BotAdapter in main.zig and that is not ideal.
2026-03-15 13:50:17 +01:00
4d1f22194c
refactor(bot): introduce response type
The response type holds represents the way a bot can respond to a
message it executes and allows us to move the IRC dependency out of
bot and into only the main module.
2026-03-15 13:47:57 +01:00
4f2b9cbce2
test(bot): record recursive substitution test case
Found while the IRC users tried to find bugs in the code base.

Currently the bot does not "hear" the commands of users, which means
that of the following conversation:

```
<jassob> helo, world!
<jassob> s/helo/hello/
```

the only heard messages in the backlog would be the first ("helo,
world!"), the substitution would be parsed and executed, but not added
to the backlog and hence not possible to update.
2026-03-15 12:40:08 +01:00
b0f0daa19d
refactor(commands): add a substitute constructor
This commit recuces some repetitiveness from creating substitution
literals in tests.
2026-03-14 11:43:26 +01:00
a05229f72d
refactor(bot): add newTestMessage helper
This commit reduces the repetitions in the tests when we create new
messages. It hardcodes a bunch of fields that are currently not used,
but might be eventually.
2026-03-14 11:40:47 +01:00
6796a62a5f
fix(bot): don't substitute if there is no typo
This commit fixes an issue where a substitution command replays a
message because there was no needle to replace.
2026-03-11 01:35:37 +01:00
8b15398196
fix(commands): substitute parsing bug
There was a bug in the parsing logic that caused a substitution
command like `s/typo/correction` to crash the bot.

Correct command is of course `s/typo/correction/`, but now it at least
shouldn't crash.
2026-03-11 01:19:33 +01:00
4e11cc9ea1
fix: make admin command parsing more robust
There was a bug in how we parsed admin commands, apparently we never
tested if we could parse `!backlog X` or unknown admin commands.

This commit also make updates to the backlog command construction to
make sure that we don't try to access messages in the backlog that
don't exist.
2026-03-11 00:57:04 +01:00
d237ba9e8a
docs: update README.md 2026-03-11 00:09:28 +01:00
e1e1938359
refactor: extract modules from bot.zig
This commit creates a bunch of new modules that contain code and tests
for various concepts/implementations that used to exist inside
bot.zig.

Notable amongst these are:
- buffer.zig, which contain the circular buffer containing both
  backlog and outbox messages.
- parser.zig, which contain the parser used to parse commands from IRC
  messages.
2026-03-11 00:01:05 +01:00
8 changed files with 550 additions and 321 deletions

View file

@ -5,6 +5,31 @@ zigeru is a IRC bot which implements the following commands:
- `s/OLD/NEW/` -- posts the previous message by the user, where every
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
To enter into a development shell with all the tools needed for
@ -20,7 +45,4 @@ To run the binary: `zig build run`.
To build the binary: `zig build`.
## TO DO
- [ ] Add IRC support
- [ ] Add TLS support
To build the binary statically: `zig build -Dtarget=x86_64-linux-musl`

119
build.zig
View file

@ -1,12 +1,16 @@
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
// 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
// for defining build steps and express dependencies between them, allowing the
// build runner to parallelize the build automatically (and the cache system to
// 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
// 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
@ -106,7 +110,7 @@ pub fn build(b: *std.Build) void {
// 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
// steps (e.g. a Run step, as we will see in a moment).
const run_step = b.step("run", "Run the app");
const runStep = b.step("run", "Run the app");
// 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
@ -114,69 +118,51 @@ pub fn build(b: *std.Build) void {
// 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
// the user runs `zig build run`, so we create a dependency link.
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
const runCmd = b.addRunArtifact(exe);
runStep.dependOn(&runCmd.step);
// 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.
run_cmd.step.dependOn(b.getInstallStep());
runCmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
runCmd.addArgs(args);
}
// 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
// set the releative field.
const run_mod_tests = b.addRunArtifact(b.addTest(.{
.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
// root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones.
const run_exe_tests = b.addRunArtifact(b.addTest(.{
.root_module = exe.root_module,
}));
// 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 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_parser_tests.step);
test_step.dependOn(&run_bot_tests.step);
test_step.dependOn(&run_buffer_tests.step);
const testStep = b.step("test", "Run tests");
const testRunArtifacts: [6]*Run = .{
// 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
// set the releative field.
b.addRunArtifact(b.addTest(.{
.root_module = mod,
})),
// 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.
b.addRunArtifact(b.addTest(.{
.root_module = exe.root_module,
})),
// Our bot tests needs zircon module import,
try testRun(b, target, "bot"),
// Our module tests for each module we want to add. If
// breaking out new functionality to a module, remember to
// bump the length of the array above.
try testRun(b, target, "buffer"),
try testRun(b, target, "parser"),
try testRun(b, target, "commands"),
};
for (testRunArtifacts) |test_run_artifact| {
testStep.dependOn(&test_run_artifact.step);
}
// Just like flags, top level steps are also listed in the `--help` menu.
//
@ -190,3 +176,30 @@ pub fn build(b: *std.Build) void {
// Lastly, the Zig build system is relatively simple and self-contained,
// 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,92 +1,25 @@
const std = @import("std");
const zircon = @import("zircon");
const Buffer = @import("buffer.zig").Buffer;
const Parser = @import("parser.zig").Parser;
/// 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 zigeru = @import("root.zig");
const Buffer = zigeru.buffer.Buffer;
const UserCommand = zigeru.commands.UserCommand;
const AdminCommand = zigeru.commands.AdminCommand;
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 };
/// 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 {
timestamp: i64,
targets: []const u8,
author: []const u8,
content: []const u8,
pub fn init_owned(
pub fn initOwned(
allocator: std.mem.Allocator,
timestamp: i64,
author: []const u8,
@ -109,47 +42,81 @@ 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 {
backlog: Buffer(*const Message, 1024),
sent_messages: Buffer([]u8, 1024),
outbox: Buffer([]u8, 1024),
allocator: std.mem.Allocator,
fn deinit_backlog_slot(allocator: std.mem.Allocator, item: *const Message) void {
// deinit function for backlog messages
fn deinitBacklogSlot(allocator: std.mem.Allocator, item: *const Message) void {
item.deinit(allocator);
}
fn deinit_sent_message(allocator: std.mem.Allocator, item: []u8) void {
// deinit function for outbox messages.
fn deinitSentMessage(allocator: std.mem.Allocator, item: []u8) void {
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 {
var bot = Bot{
.allocator = allocator,
.backlog = undefined,
.sent_messages = undefined,
.outbox = undefined,
};
bot.backlog = .init_with_closure(allocator, &Bot.deinit_backlog_slot);
bot.sent_messages = .init_with_closure(allocator, &Bot.deinit_sent_message);
bot.backlog = .initWithClosure(allocator, &Bot.deinitBacklogSlot);
bot.outbox = .initWithClosure(allocator, &Bot.deinitSentMessage);
return bot;
}
// deinits self's buffers and their contents.
//
// NOTE: does not deinit self.
pub fn deinit(self: *Bot) void {
self.sent_messages.deinit();
self.outbox.deinit();
self.backlog.deinit();
}
pub fn execute(
self: *Bot,
cmd: *const Command,
prefix: ?zircon.Prefix,
targets: []const u8,
) Error!zircon.Message {
pub fn hear(self: *Bot, message: *const Message) ?Error!Response {
// Store the message to keep track of the allocation
defer self.store(message);
if (UserCommand.parse(message.author, message.content)) |cmd| {
return self.execute(&cmd, message.targets);
}
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.*) {
.substitute => |command| {
const prev_msg = self.previous_message_by_author(
command.author,
targets,
) orelse return Error.NoMessage;
if (std.mem.count(u8, prev_msg.content, command.needle) == 0) {
return Error.NoMessage;
}
const output = try std.mem.replaceOwned(
u8,
self.allocator,
@ -163,77 +130,67 @@ pub const Bot = struct {
"{s}: \"{s}\"",
.{ command.author, output },
);
self.sent_messages.append(quoted_output);
return zircon.Message{ .PRIVMSG = .{
self.outbox.append(quoted_output);
return .{ .privmsg = .{
.targets = targets,
.prefix = prefix,
.text = quoted_output,
} };
},
.help => {
return zircon.Message{ .PRIVMSG = .{
return .{ .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 {
pub fn execute_admin(self: *Bot, cmd: *const AdminCommand, targets: []const u8) Error!Response {
switch (cmd.*) {
.status => {
const msg = try std.fmt.allocPrint(
self.allocator,
"heard messages: {}, sent messages: {}",
.{ self.no_messages(), self.sent_messages.items.len },
.{ self.backlog.len(), self.outbox.len() },
);
return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = msg } };
return .{ .privmsg = .{ .targets = targets, .text = msg } };
},
.join => |msg| {
std.log.debug("received join request: channel \"{s}\"", .{msg.channel});
return .{ .JOIN = .{ .prefix = prefix, .channels = msg.channel } };
return .{ .join = .{ .channels = msg.channel } };
},
.backlog => |backlog| {
if (self.backlog.insertions() == 0) {
if (self.backlog.len() == 0) {
return Error.NoMessage;
}
if (backlog.history > self.no_messages()) {
if (backlog.history >= self.backlog.len()) {
return Error.NoMessage;
}
if (self.backlog.get_backwards(backlog.history)) |message| {
if (self.backlog.getBackwards(backlog.history)) |message| {
const quoted_output = try std.fmt.allocPrint(
self.allocator,
"backlog {}: author: \"{s}\", content: \"{s}\"",
.{ backlog.history, message.author, message.content },
);
self.sent_messages.append(quoted_output);
self.outbox.append(quoted_output);
return .{
.PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = quoted_output },
.privmsg = .{ .targets = targets, .text = quoted_output },
};
} else return Error.NoMessage;
},
.err => |err| {
return .{ .PRIVMSG = .{ .targets = targets, .prefix = prefix, .text = err.message } };
return .{ .privmsg = .{ .targets = targets, .text = err.message } };
},
}
}
pub fn hear(self: *Bot, msg: *const Message) void {
// store a message in backlog, potentially overwriting oldest message.
pub fn store(self: *Bot, msg: *const Message) void {
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 {
var iter = self.backlog.iterate_reverse();
var iter = self.backlog.iterateReverse();
while (iter.prev()) |message| {
if (std.mem.eql(u8, message.author, author) and
std.mem.eql(u8, message.targets, targets))
@ -245,15 +202,19 @@ pub const Bot = struct {
}
};
test "deiniting an owned Message leaks no memory" {
const allocator = std.testing.allocator;
const testMessage = try Message.init_owned(
fn newTestMessage(allocator: std.mem.Allocator, content: []const u8) !*Message {
return try Message.initOwned(
allocator,
12345,
"jassob",
"#test",
"All your codebase are belong to us.\n",
content,
);
}
test "deiniting an owned Message leaks no memory" {
const allocator = std.testing.allocator;
const testMessage = try newTestMessage(allocator, "test");
testMessage.deinit(allocator);
}
@ -267,17 +228,10 @@ test "hear and deinit has no leaks" {
var bot = try Bot.init(allocator);
defer bot.deinit();
const testMessage = try Message.init_owned(
allocator,
12345,
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
const testMessage = try newTestMessage(allocator, "test");
try std.testing.expectEqual(null, bot.hear(testMessage));
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" {
@ -285,19 +239,12 @@ test "a few hears and deinit has no leaks" {
var bot = try Bot.init(allocator);
defer bot.deinit();
for (0..2) |i| {
const testMessage = try Message.init_owned(
std.testing.allocator,
@intCast(i),
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
for (0..2) |_| {
const testMessage = try newTestMessage(std.testing.allocator, "test");
_ = bot.hear(testMessage);
}
try std.testing.expectEqual(1, bot.backlog.top);
try std.testing.expect(bot.no_messages() == 2);
}
test "hear wraps" {
@ -305,69 +252,86 @@ test "hear wraps" {
defer bot.deinit();
for (0..1025) |_| {
const testMessage = try Message.init_owned(
std.testing.allocator,
12345,
"jassob",
"#test",
"All your codebase are belong to us.\n",
);
bot.hear(testMessage);
const testMessage = try newTestMessage(std.testing.allocator, "test");
_ = bot.hear(testMessage);
}
try std.testing.expectEqual(0, bot.backlog.top);
try std.testing.expectEqual(1, bot.backlog.bottom());
try std.testing.expectEqual(1024, bot.no_messages());
try std.testing.expectEqual(1024, bot.backlog.len());
}
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",
));
const substitution = try newTestMessage(std.testing.allocator, "s/What/what/");
try std.testing.expectError(Error.NoMessage, bot.hear(substitution).?);
}
test "execute_substitution" {
var bot = try Bot.init(std.testing.allocator);
test "execute substitution" {
const allocator = std.testing.allocator;
var bot = try Bot.init(allocator);
defer bot.deinit();
// hear original message with typo
const msg = try Message.init_owned(
std.testing.allocator,
1234,
"jassob",
"#test",
"What",
);
bot.hear(msg);
const msg = try newTestMessage(allocator, "What");
try std.testing.expectEqual(null, bot.hear(msg));
// execute substitution
const cmd = Command{
.substitute = .{ .author = "jassob", .needle = "What", .replacement = "what" },
};
const response = try bot.execute(&cmd, null, "#test");
const sub = try newTestMessage(allocator, "s/What/what/");
const response = try bot.hear(sub).?;
// expect response matching the correct message
switch (response) {
.PRIVMSG => |message| {
.privmsg => |message| {
try std.testing.expectEqualDeep(message.text, "jassob: \"what\"");
},
else => unreachable,
}
}
test "parse_botcommands" {
const cmd = AdminCommand.parse("!join badchannel") orelse unreachable;
try std.testing.expectEqual(
AdminCommand{ .err = .{ .message = "channels must start with \"#\"" } },
cmd,
);
test "execute substitution with no matching needle" {
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/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,11 +6,6 @@ const std = @import("std");
// automatically deinited when it is overwritten by supplying a custom
// ItemDeinitClosure, using .init_with_closure.
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 {
items: [length]?T,
top: usize,
@ -28,7 +23,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
};
}
pub fn init_with_closure(
pub fn initWithClosure(
allocator: std.mem.Allocator,
deinit_func: *const fn (allocator: std.mem.Allocator, item: T) void,
) Buffer(T, length) {
@ -38,12 +33,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
return buf;
}
fn can_deinit(self: *const Buffer(T, length)) bool {
fn canDeinit(self: *const Buffer(T, length)) bool {
return self.allocator != null and self.deinit_func != null;
}
fn deinit_item(self: *Buffer(T, length), item: T) void {
if (!self.can_deinit()) {
fn deinitItem(self: *Buffer(T, length), item: T) void {
if (!self.canDeinit()) {
// Nothing to do
return;
}
@ -71,7 +66,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
}
if (self.insertions >= length) {
// free old bottom item
self.deinit_item(self.items[self.top].?);
self.deinitItem(self.items[self.top].?);
self.items[self.top] = null;
}
self.items[self.top] = item;
@ -91,14 +86,14 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
pub fn get(self: *const Buffer(T, length), index: usize) ?T {
const idx = (self.bottom() + index) % self.items.len;
if (index > self.insertions()) {
if (index > self.insertions) {
return null;
}
return self.items[idx];
}
pub fn get_backwards(self: *const Buffer(T, length), offset: usize) ?T {
if (offset >= self.insertions()) {
pub fn getBackwards(self: *const Buffer(T, length), offset: usize) ?T {
if (offset >= self.insertions) {
return null;
}
return self.items[(self.top - offset) % self.items.len];
@ -112,7 +107,7 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
};
}
pub fn iterate_reverse(self: *const Buffer(T, length)) BufferIterator(T, length) {
pub fn iterateReverse(self: *const Buffer(T, length)) BufferIterator(T, length) {
return .{
.buffer = self,
.index = self.top,
@ -121,12 +116,12 @@ pub fn Buffer(comptime T: type, comptime length: usize) type {
}
pub fn deinit(self: *Buffer(T, length)) void {
if (!self.can_deinit()) {
if (!self.canDeinit()) {
return;
}
for (self.items, 0..) |item, idx| {
if (item != null) {
self.deinit_item(item.?);
self.deinitItem(item.?);
self.items[idx] = null;
}
}
@ -237,7 +232,7 @@ test "deiniting allocating buffer does not leak" {
const allocator = std.testing.allocator;
// create buffer with closure
var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice);
var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice);
defer buffer.deinit();
// add less elements than length
@ -253,7 +248,7 @@ test "wrapping allocating buffer does not leak" {
const allocator = std.testing.allocator;
// create buffer with closure
var buffer = Buffer([]u8, 3).init_with_closure(allocator, &deinit_byte_slice);
var buffer = Buffer([]u8, 3).initWithClosure(allocator, &deinit_byte_slice);
defer buffer.deinit();
// add an element more than length

144
src/commands.zig Normal file
View file

@ -0,0 +1,144 @@
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,11 +1,10 @@
const std = @import("std");
const zigeru = @import("zigeru");
const bot = zigeru.bot;
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;
const UserCommand = zigeru.commands.UserCommand;
const AdminCommand = zigeru.commands.AdminCommand;
const zircon = @import("zircon");
var debug_allocator = std.heap.DebugAllocator(.{}).init;
@ -43,11 +42,16 @@ pub fn main() !void {
};
}
pub const Adapter = struct {
ptr: *anyopaque,
callbackFn: *const fn (*anyopaque, zircon.Message) ?zircon.Message,
};
/// BotAdapter is the closure that we register in zircon as the
/// message callback.
///
/// 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 {
bot: Bot,
allocator: std.mem.Allocator,
@ -63,29 +67,48 @@ pub const BotAdapter = struct {
self.bot.deinit();
}
pub fn callback(self: *BotAdapter, message: zircon.Message) ?zircon.Message {
/// callback gets called for every message that we receive.
///
/// 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) {
.PRIVMSG => |msg| {
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)) |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(
const nick = nickFromPrefix(msg.prefix);
// create message
const bot_message = bot.Message.initOwned(
self.allocator,
std.time.timestamp(),
nick,
msg.targets,
msg.text,
) catch |err| return report_error(err);
self.bot.hear(&bot_msg);
return null;
) catch |err| return reportError(err);
// send message to bot
const response = self.bot.hear(bot_message) orelse {
return null;
} catch |err| {
return reportError(err);
};
return toIRC(response);
},
.JOIN => |msg| {
std.log.debug("received join message: channels {s}", .{msg.channels});
@ -98,50 +121,77 @@ pub const BotAdapter = struct {
}
}
fn report_error(err: Error) zircon.Message {
/// report errors as private message to admin channel.
fn reportError(err: bot.Error) zircon.Message {
const err_msg = switch (err) {
Error.NoMessage => "no matching message",
Error.OutOfMemory => "out of memory",
Error.WriteFailed => "write failed",
bot.Error.NoMessage => "no matching message",
bot.Error.OutOfMemory => "out of memory",
bot.Error.WriteFailed => "write failed",
};
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 {
return .{
.ptr = self,
.callbackFn = BotAdapter.erased_callback,
.callbackFn = BotAdapter.callback,
};
}
};
// test "substitute" {
// var bot_adapter = try BotAdapter.init(std.testing.allocator);
// defer bot_adapter.deinit();
// const prefix = zircon.Prefix{ .nick = "jassob", .user = "jassob", .host = "localhost" };
// const msg = zircon.Message{
// .PRIVMSG = .{
// .prefix = prefix,
// .targets = "#test",
// .text = "hello world",
// },
// };
// if (bot_adapter.callback(msg)) |_| {
// @panic("unexpected response");
// }
// const cmd_msg = zircon.Message{
// .PRIVMSG = .{
// .prefix = prefix,
// .targets = "#test",
// .text = "s/world/zig/",
// },
// };
// const response = bot_adapter.callback(cmd_msg);
// try std.testing.expect(response != null);
// try std.testing.expectEqualStrings("jassob: \"hello zig\"", response.?.PRIVMSG.text);
// }
/// nickFromPrefix returns a nick if it exists in the prefix or
/// "unknown" otherwise.
fn nickFromPrefix(prefix: ?zircon.Prefix) []const u8 {
if (prefix == null or prefix.?.nick == null) {
return "unknown";
}
return prefix.?.nick.?;
}
/// toIRC converts a bot response and converts it to a IRC message.
fn toIRC(response: bot.Response) zircon.Message {
switch (response) {
.join => |join| return .{
.JOIN = .{ .prefix = null, .channels = join.channels },
},
.privmsg => |msg| return .{
.PRIVMSG = .{ .prefix = null, .targets = msg.targets, .text = msg.text },
},
}
}
fn priv_msg(text: []const u8) zircon.Message {
return .{ .PRIVMSG = .{
.prefix = .{ .nick = "jassob", .host = "localhost", .user = "jassob" },
.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,14 +1,15 @@
const std = @import("std");
pub fn init(s: []const u8) Parser {
return .init(s);
}
pub const Parser = struct {
original: []const u8,
rest: []const u8,
end_idx: usize,
pub fn seek(self: *const Parser, skip: usize) Parser {
return .{ .original = self.original, .rest = self.rest[skip..], .end_idx = self.end_idx + skip };
}
// Initializes a Parser for s.
pub fn init(s: []const u8) Parser {
return .{
.original = s,
@ -17,6 +18,25 @@ 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 {
if (self.rest[0] != c) {
return null;
@ -24,6 +44,7 @@ pub const Parser = struct {
return self.seek(1);
}
// Attempts to consume a string s.
pub fn consume_str(self: *const Parser, s: []const u8) ?Parser {
const len = s.len;
if (self.rest.len < len) {
@ -35,16 +56,34 @@ pub const Parser = struct {
return self.seek(len);
}
pub fn take_until_char(self: *const Parser, c: u8) struct { Parser, []const u8 } {
const idx = std.mem.indexOfScalar(u8, self.rest, c) orelse unreachable;
return .{ self.seek(idx), self.rest[0..idx] };
// Finds the next occurrence of c (idx) in the current parser
// window and extracts it.
//
// 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 null;
}
// Take the current character and advance the parser one step.
pub fn take_char(self: *const Parser) struct { Parser, u8 } {
return .{ self.seek(1), self.rest[0] };
}
// Return the currently accepted text.
pub fn parsed(self: *const Parser) []const u8 {
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,3 +2,5 @@
pub const bot = @import("bot.zig");
pub const buffer = @import("buffer.zig");
pub const parser = @import("parser.zig");
pub const commands = @import("commands.zig");