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.
This commit is contained in:
Jacob Jonsson 2026-03-15 13:47:57 +01:00
parent 4f2b9cbce2
commit 4d1f22194c
Signed by: Jassob
GPG key ID: 7E30B9B047F7202E
3 changed files with 50 additions and 54 deletions

View file

@ -152,12 +152,7 @@ pub fn build(b: *std.Build) !void {
})),
// Our bot tests needs zircon module import,
try testRunWithImports(b, target, "bot", &.{
.{
.name = "zircon",
.module = zircon.module("zircon"),
},
}),
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.

View file

@ -1,5 +1,4 @@
const std = @import("std");
const zircon = @import("zircon");
const zigeru = @import("root.zig");
const Buffer = zigeru.buffer.Buffer;
@ -10,6 +9,10 @@ const HELP_MESSAGE: []const u8 = "Send `s/TYPO/CORRECTION/` to replace TYPO with
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,
@ -39,6 +42,19 @@ 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),
outbox: Buffer([]u8, 1024),
@ -78,12 +94,7 @@ pub const Bot = struct {
self.backlog.deinit();
}
pub fn execute(
self: *Bot,
cmd: *const UserCommand,
prefix: ?zircon.Prefix,
targets: []const u8,
) Error!zircon.Message {
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(
@ -107,28 +118,21 @@ pub const Bot = struct {
.{ command.author, output },
);
self.outbox.append(quoted_output);
return zircon.Message{ .PRIVMSG = .{
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(
@ -136,11 +140,11 @@ pub const Bot = struct {
"heard messages: {}, sent messages: {}",
.{ 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.len() == 0) {
@ -157,18 +161,18 @@ pub const Bot = struct {
);
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 } };
},
}
}
// hear a message and store it in backlog, potentially overwriting oldest 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);
}
@ -252,11 +256,7 @@ test "execute substitution no previous message" {
.needle = "What",
.replacement = "what",
} };
try std.testing.expectError(Error.NoMessage, bot.execute(
&cmd,
null,
"#test",
));
try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test"));
}
test "execute substitution" {
@ -270,11 +270,11 @@ test "execute substitution" {
// execute substitution
const cmd = UserCommand.init_substitute("jassob", "What", "what", false);
const response = try bot.execute(&cmd, null, "#test");
const response = try bot.execute(&cmd, "#test");
// expect response matching the correct message
switch (response) {
.PRIVMSG => |message| {
.privmsg => |message| {
try std.testing.expectEqualDeep(message.text, "jassob: \"what\"");
},
else => unreachable,
@ -291,11 +291,7 @@ test "execute substitution with no matching needle" {
// execute substitution
const cmd = UserCommand.init_substitute("jassob", "something else", "weird", false);
try std.testing.expectError(Error.NoMessage, bot.execute(
&cmd,
null,
"#test",
));
try std.testing.expectError(Error.NoMessage, bot.execute(&cmd, "#test"));
}
test "recursive substitutions does not cause issues" {
@ -308,12 +304,8 @@ test "recursive substitutions does not cause issues" {
// execute substitution
const cmd = UserCommand.init_substitute("jassob", "original", "something else", false);
switch (try bot.execute(
&cmd,
null,
"#test",
)) {
.PRIVMSG => |message| {
switch (try bot.execute(&cmd, "#test")) {
.privmsg => |message| {
try std.testing.expectEqualDeep("jassob: \"something else\"", message.text);
},
else => unreachable,
@ -321,9 +313,5 @@ test "recursive substitutions does not cause issues" {
// execute second substitution
const cmd2 = UserCommand.init_substitute("jassob", "s/original/something else/", "something else", false);
try std.testing.expectError(Error.NoMessage, bot.execute(
&cmd2,
null,
"#test",
));
try std.testing.expectError(Error.NoMessage, bot.execute(&cmd2, "#test"));
}

View file

@ -6,6 +6,7 @@ const Error = zigeru.bot.Error;
const UserCommand = zigeru.commands.UserCommand;
const AdminCommand = zigeru.commands.AdminCommand;
const BotMessage = zigeru.bot.Message;
const BotResponse = zigeru.bot.Response;
const zircon = @import("zircon");
var debug_allocator = std.heap.DebugAllocator(.{}).init;
@ -72,10 +73,10 @@ pub const BotAdapter = struct {
);
const nick = if (msg.prefix) |prefix| if (prefix.nick) |nick| nick else "unknown" else "unknown";
if (UserCommand.parse(nick, msg.text)) |cmd| {
return self.bot.execute(&cmd, msg.prefix, msg.targets) catch |err| return report_error(err);
return toIRC(self.bot.execute(&cmd, 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);
return toIRC(self.bot.execute_admin(&cmd, "#eru-admin") catch |err| return report_error(err));
}
const bot_msg = BotMessage.init_owned(
self.allocator,
@ -120,6 +121,18 @@ pub const BotAdapter = struct {
}
};
/// toIRC converts a bot response and converts it to a IRC message.
fn toIRC(response: BotResponse) 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 },
},
}
}
test "substitute" {
var bot_adapter = try BotAdapter.init(std.testing.allocator);
defer bot_adapter.deinit();