diff --git a/CHANGELOG.md b/CHANGELOG.md
index a95af2f..02091fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t
## [Unreleased]
+### Added
+
+- `/fishbot` brings a little QuakeNet nostalgia to Discord: classic call-and-response phrases with autocomplete, feeding and petting the fish, cookie theft, and a harmless trout slap using Discord's user picker. Available to everyone with the usual command rate limits and no pings. It only responds to slash commands; no chat-reading permissions or IRC-specific behaviour.
+
## [0.18.0] - 2026-10-19
### Added
diff --git a/docs/.vitepress/theme/components/PxCommandTray.vue b/docs/.vitepress/theme/components/PxCommandTray.vue
index 1d3e4e0..652a8c0 100644
--- a/docs/.vitepress/theme/components/PxCommandTray.vue
+++ b/docs/.vitepress/theme/components/PxCommandTray.vue
@@ -39,6 +39,14 @@ const props = withDefaults(
);
const COMMANDS: readonly Command[] = [
+ {
+ name: "/fishbot say",
+ description: "Try a classic Fishbot phrase (start typing for suggestions)",
+ },
+ { name: "/fishbot trout", description: "Give someone a harmless slap with a large trout" },
+ { name: "/fishbot feed", description: "Feed Fishbot hundreds and thousands" },
+ { name: "/fishbot pet", description: "Give Fishbot a friendly stroke" },
+ { name: "/fishbot cookie", description: "Sneak a cookie away from Fishbot" },
{ name: "/help", description: "List the commands you can use" },
{ name: "/status", description: "Is Pixelbar open right now?" },
{ name: "/events", description: "See what's coming up at Pixelbar" },
diff --git a/docs/commands.md b/docs/commands.md
index 77e940b..103d969 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -53,6 +53,40 @@ Sends a message to Pixel's maintainers, with your Discord name and ID so they ca
+### Fishbot
+
+A little QuakeNet nostalgia. Pixel keeps the original Fishbot replies, including their old-school language. These commands answer publicly; help and unknown phrases are private. Pixel only answers slash commands and does not read the channel's conversation.
+
+
+
+Try a classic Fishbot phrase. Matching ignores case and extra spaces, but requires the whole phrase. Fish go m00!
+
+
+
+
+
+Slap someone around a bit with a large trout. Purely theatrical.
+
+
+
+
+
+Feed Fishbot hundreds and thousands.
+
+
+
+
+
+Give Fishbot a friendly stroke.
+
+
+
+
+
+Sneak a cookie away from Fishbot.
+
+
+
## Members
For paid members. Commands marked Friend also work with a friend membership.
diff --git a/docs/runbook.md b/docs/runbook.md
index 9c8ca0b..dc3c09a 100644
--- a/docs/runbook.md
+++ b/docs/runbook.md
@@ -431,6 +431,8 @@ All of them are gitignored. The share files are **read-only mirrors**: edit the
**After a deploy that adds, removes or renames commands:** a Release registers prod and `just deploy dev` registers dev. Otherwise run `just register dev` or `just register prod` against that environment's guild. Topic changes to `/info` need it too.
+**Fishbot:** `/fishbot` needs only the existing slash-command permissions; leave privileged intents off. After registration, try `/fishbot say phrase:fish` (public reply: “fish go m00!” addressed to the caller) and `/fishbot say` (private help). The feature has no configuration or saved state. It uses the normal dispatcher rate limits and never listens to ordinary messages. Original replies are retained, including old-school insults; the reference is [sjdiego's Fishbot triggers](https://github.com/sjdiego/fishbot/blob/main/triggers.go). IRC actions render as italics. Channel lookup and the obsolete Outwar URL trigger are omitted.
+
**Deploys must stop the old instance before starting the new one,** so two never answer at once. Expect a short gap while it restarts. Overlap during a revision swap is still #11.
**Azure CD ([ADR 0010](adr/0010-ghcr-cd.md), [ADR 0013](adr/0013-release-workflow.md)):**
diff --git a/src/adapters/discord/fishbot.test.ts b/src/adapters/discord/fishbot.test.ts
new file mode 100644
index 0000000..b4fbdd7
--- /dev/null
+++ b/src/adapters/discord/fishbot.test.ts
@@ -0,0 +1,88 @@
+import { ApplicationCommandOptionType, MessageFlags } from "discord.js";
+import { describe, expect, it } from "vitest";
+import { Dispatcher } from "../../core/dispatcher.ts";
+import { IdentityService } from "../../core/identity.ts";
+import { silentLogger } from "../../core/logger.ts";
+import { nullErrorReporter } from "../../core/ports/error-reporter.ts";
+import { RateLimiter } from "../../core/rate-limit.ts";
+import { CommandRegistry } from "../../core/registry.ts";
+import { createFishbotFeature } from "../../features/fishbot/index.ts";
+import { actor, IDS } from "../../testing/fixtures.ts";
+import { parseOptions } from "./args.ts";
+import { toSlashCommand } from "./commands.ts";
+import { renderReply } from "./render.ts";
+
+it("registers Fishbot's autocomplete and real Discord user picker", () => {
+ const definition = createFishbotFeature().commands?.[0];
+ if (!definition) throw new Error("Missing Fishbot command");
+ const slash = toSlashCommand(definition);
+ expect(slash.name).toBe("fishbot");
+ expect(slash.default_member_permissions).toBeUndefined();
+ expect(slash.options).toContainEqual(
+ expect.objectContaining({
+ name: "say",
+ type: ApplicationCommandOptionType.Subcommand,
+ options: [
+ expect.objectContaining({
+ name: "phrase",
+ type: ApplicationCommandOptionType.String,
+ required: false,
+ autocomplete: true,
+ }),
+ ],
+ }),
+ );
+ expect(slash.options).toContainEqual(
+ expect.objectContaining({
+ name: "trout",
+ type: ApplicationCommandOptionType.Subcommand,
+ options: [
+ expect.objectContaining({
+ name: "user",
+ type: ApplicationCommandOptionType.User,
+ required: true,
+ }),
+ ],
+ }),
+ );
+});
+
+describe("Fishbot in Discord", () => {
+ it("parses a nickname, dispatches a trout and renders with pings disabled", async () => {
+ const registry = new CommandRegistry();
+ registry.register(createFishbotFeature());
+ const dispatcher = new Dispatcher({
+ registry,
+ identity: new IdentityService([]),
+ rateLimiter: new RateLimiter({ capacity: 10, refillPerSecond: 0 }),
+ logger: silentLogger,
+ reporter: nullErrorReporter,
+ });
+ const parsed = parseOptions([
+ {
+ name: "trout",
+ type: ApplicationCommandOptionType.Subcommand,
+ options: [
+ {
+ name: "user",
+ type: ApplicationCommandOptionType.User,
+ user: { id: IDS.friend, displayName: "Global name", username: "handle", bot: false },
+ member: { nick: "@everyone `fish`" },
+ },
+ ],
+ },
+ ]);
+ const result = await dispatcher.dispatch({ ...parsed, actor: actor(), command: "fishbot" });
+ const payload = renderReply(result.reply, result.private);
+ expect(payload.content).toContain("`@everyone 'fish'`");
+ expect(payload.allowedMentions).toEqual({ parse: [] });
+ expect(payload.flags).toBeUndefined();
+ const help = await dispatcher.dispatch({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "say",
+ args: {},
+ });
+ expect(renderReply(help.reply, help.private).flags).toBe(MessageFlags.Ephemeral);
+ });
+});
diff --git a/src/features/features.test.ts b/src/features/features.test.ts
index 01d04a5..aa1d3d3 100644
--- a/src/features/features.test.ts
+++ b/src/features/features.test.ts
@@ -95,6 +95,7 @@ describe("buildFeatures", () => {
"closing-time",
"events",
"feedback",
+ "fishbot",
"ha",
"help",
"info",
diff --git a/src/features/fishbot/fishbot.test.ts b/src/features/fishbot/fishbot.test.ts
new file mode 100644
index 0000000..65e7c7e
--- /dev/null
+++ b/src/features/fishbot/fishbot.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from "vitest";
+import { isGroup, isSubgroup } from "../../core/command.ts";
+import { Dispatcher, MESSAGES } from "../../core/dispatcher.ts";
+import { IdentityService } from "../../core/identity.ts";
+import { silentLogger } from "../../core/logger.ts";
+import { nullErrorReporter } from "../../core/ports/error-reporter.ts";
+import { RateLimiter } from "../../core/rate-limit.ts";
+import { CommandRegistry } from "../../core/registry.ts";
+import { actor, context, IDS } from "../../testing/fixtures.ts";
+import { createFishbotFeature } from "./index.ts";
+import { PHRASES } from "./phrases.ts";
+
+function setup(capacity = 100) {
+ const registry = new CommandRegistry();
+ registry.register(createFishbotFeature());
+ return new Dispatcher({
+ registry,
+ identity: new IdentityService([]),
+ rateLimiter: new RateLimiter({ capacity, refillPerSecond: 0, now: () => 0 }),
+ logger: silentLogger,
+ reporter: nullErrorReporter,
+ });
+}
+
+const request = (phrase?: string) => ({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "say",
+ args: phrase === undefined ? {} : { phrase },
+});
+
+describe("Fishbot", () => {
+ it.each(PHRASES)("answers the complete phrase %s for a guest", async (phrase, response) => {
+ const result = await setup().dispatch(request(phrase));
+ expect(result.private).toBe(false);
+ expect(result.reply.text).toBe(response.replaceAll("$nick", "`Test User`"));
+ expect(result.reply.text).not.toContain("$nick");
+ expect(result.reply.text).not.toMatch(/\p{Cc}/u);
+ });
+
+ it("normalizes case and whitespace", async () => {
+ const result = await setup().dispatch(request(" HeLLo Fishbot \n"));
+ expect(result.reply.text).toBe("Hi `Test User`");
+ });
+
+ it.each([
+ undefined,
+ "",
+ "something unknown",
+ "I mentioned fish in a sentence",
+ "fish\x01",
+ "fish $name",
+ ])("privately helps with unmatched input %s without echoing it", async (phrase) => {
+ const result = await setup().dispatch(request(phrase));
+ expect(result.private).toBe(true);
+ expect(result.reply.text).toContain("/fishbot say phrase:fish");
+ expect(result.reply.text).toContain("/fishbot trout user:");
+ });
+
+ it("keeps malicious display names inert, bounded, and out of template replacement", async () => {
+ const result = await setup().dispatch({
+ ...request("hello fishbot"),
+ actor: actor({ displayName: "`**@everyone**\n\x01$name $&" }),
+ });
+ expect(result.reply.text).toBe("Hi `'**@everyone** $name $&`");
+ const long = await setup().dispatch({
+ ...request("fish"),
+ actor: actor({ displayName: "x".repeat(3000) }),
+ });
+ expect(long.reply.text?.length).toBeLessThan(150);
+ });
+
+ it("limits and filters discoverable phrases", async () => {
+ const dispatcher = setup();
+ const suggest = (typed: string) =>
+ dispatcher.suggest({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "say",
+ option: "phrase",
+ typed,
+ args: {},
+ });
+ const all = await suggest("");
+ expect(all).toHaveLength(25);
+ const filtered = await suggest(" BADGER ");
+ expect(filtered).toEqual([
+ {
+ name: PHRASES.find(([phrase]) => phrase.startsWith("badger"))?.[0],
+ value: PHRASES.find(([phrase]) => phrase.startsWith("badger"))?.[0],
+ },
+ ]);
+ for (const entry of all) {
+ expect(entry.name.length).toBeLessThanOrEqual(100);
+ expect((await dispatcher.dispatch(request(String(entry.value)))).private).toBe(false);
+ }
+ expect(await suggest("nonexistent")).toEqual([]);
+ });
+
+ it.each(["feed", "pet", "cookie"])("supports the %s action", async (subcommand) => {
+ const result = await setup().dispatch({
+ actor: actor(),
+ command: "fishbot",
+ subcommand,
+ args: {},
+ });
+ expect(result.private).toBe(false);
+ expect(result.reply.text).toBeTruthy();
+ expect(result.reply.text).not.toMatch(/\p{Cc}/u);
+ });
+
+ it("trouts the resolved user, including bots, without a mention", async () => {
+ const result = await setup().dispatch({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "trout",
+ args: { user: IDS.member },
+ users: { user: { id: IDS.member, displayName: "**Fishbot**", isBot: true } },
+ });
+ expect(result.private).toBe(false);
+ expect(result.reply.text).toBe(
+ "`Test User` slaps `**Fishbot**` around a bit with a large trout. trouted!",
+ );
+ expect(result.reply.text).not.toContain(IDS.member);
+ });
+
+ it("refuses missing or unresolved trout targets", async () => {
+ for (const args of [{}, { user: IDS.member }]) {
+ const result = await setup().dispatch({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "trout",
+ args,
+ });
+ expect(result.private).toBe(true);
+ expect(result.reply.text).not.toContain("trouted!");
+ }
+ });
+
+ it.each([
+ ["hampster", "`Test User`: There is no 'p' in hamster you retard."],
+ ["fish", "`Test User`: fish go m00!"],
+ ["how old are you, fishbot?", "*is older than time itself.*"],
+ ["vinegar with aftershock!", "Ah, a true connoisseur!"],
+ ])("preserves the original reply for %s", async (phrase, expected) => {
+ expect((await setup().dispatch(request(phrase))).reply.text).toBe(expected);
+ });
+
+ it("defends the trout handler against a missing resolved target", async () => {
+ const group = createFishbotFeature().commands?.[0];
+ if (!group || !isGroup(group)) throw new Error("Missing Fishbot group");
+ const trout = group.subcommands.find((sub) => sub.name === "trout");
+ if (!trout || isSubgroup(trout)) throw new Error("Missing trout command");
+ await expect(trout.handler(context())).rejects.toThrow("Choose someone to trout.");
+ });
+
+ it("uses the normal shared command rate limit", async () => {
+ const dispatcher = setup(1);
+ expect((await dispatcher.dispatch(request("fish"))).private).toBe(false);
+ const result = await dispatcher.dispatch({
+ actor: actor(),
+ command: "fishbot",
+ subcommand: "feed",
+ args: {},
+ });
+ expect(result).toEqual({ private: true, reply: { text: MESSAGES.rateLimited, private: true } });
+ });
+});
diff --git a/src/features/fishbot/index.ts b/src/features/fishbot/index.ts
new file mode 100644
index 0000000..ce0e683
--- /dev/null
+++ b/src/features/fishbot/index.ts
@@ -0,0 +1,89 @@
+import { UserFacingError } from "../../core/errors.ts";
+import type { Feature } from "../../core/feature.ts";
+import { inlineCode } from "../../core/format.ts";
+import { normalizePhrase, PHRASES, replyForPhrase } from "./phrases.ts";
+
+export function createFishbotFeature(): Feature {
+ return {
+ name: "fishbot",
+ commands: [
+ {
+ name: "fishbot",
+ description: "QuakeNet nostalgia: fish go m00!",
+ access: { minTier: "guest" },
+ subcommands: [
+ {
+ name: "say",
+ description: "Try a classic Fishbot phrase (start typing for suggestions)",
+ access: { minTier: "guest" },
+ options: [
+ {
+ name: "phrase",
+ description: "Try fish, hello fishbot, spoon or moo?; leave empty for help",
+ type: "string",
+ suggest: async ({ typed }) =>
+ PHRASES.filter(([phrase]) =>
+ normalizePhrase(phrase).includes(normalizePhrase(typed)),
+ )
+ .slice(0, 25)
+ .map(([phrase]) => ({ name: phrase, value: phrase })),
+ },
+ ],
+ handler: async ({ args, principal }) => {
+ const phrase = normalizePhrase(String(args.phrase ?? ""));
+ const response = replyForPhrase(phrase);
+ if (!response)
+ return {
+ private: true,
+ text: "Fish go m00! Try `/fishbot say phrase:fish`, `hello fishbot`, `spoon` or `moo?`. Start typing a phrase for more suggestions.\nYou can also `/fishbot feed`, `/fishbot pet`, `/fishbot cookie` or `/fishbot trout user:`. I only answer commands, not ordinary chat.",
+ };
+ const name = inlineCode(principal.displayName, 80, "friend");
+ return { text: response.replaceAll("$nick", () => name) };
+ },
+ },
+ {
+ name: "trout",
+ description: "Give someone a harmless slap with a large trout",
+ access: { minTier: "guest" },
+ options: [
+ {
+ name: "user",
+ description: "Who gets the trout?",
+ type: "user",
+ required: true,
+ allowBots: true,
+ },
+ ],
+ handler: async ({ users, principal }) => {
+ const target = users.user;
+ if (!target) throw new UserFacingError("Choose someone to trout.");
+ return {
+ text: `${inlineCode(principal.displayName, 80, "Someone")} slaps ${inlineCode(target.displayName, 80, "a friend")} around a bit with a large trout. trouted!`,
+ };
+ },
+ },
+ {
+ name: "feed",
+ description: "Feed Fishbot hundreds and thousands",
+ access: { minTier: "guest" },
+ handler: async () => ({ text: "MEDI.. er.. FISHBOT" }),
+ },
+ {
+ name: "pet",
+ description: "Give Fishbot a friendly stroke",
+ access: { minTier: "guest" },
+ handler: async ({ principal }) => ({
+ text: `*m00s loudly at ${inlineCode(principal.displayName, 80, "a friend")}.*`,
+ }),
+ },
+ {
+ name: "cookie",
+ description: "Sneak a cookie away from Fishbot",
+ access: { minTier: "guest" },
+ handler: async () => ({ text: ":(" }),
+ },
+ ],
+ },
+ ],
+ };
+}
diff --git a/src/features/fishbot/phrases.ts b/src/features/fishbot/phrases.ts
new file mode 100644
index 0000000..6572b11
--- /dev/null
+++ b/src/features/fishbot/phrases.ts
@@ -0,0 +1,118 @@
+/**
+ * Original replies from the QuakeNet Fishbot reimplementation:
+ * https://github.com/sjdiego/fishbot/blob/main/triggers.go
+ * IRC actions become Markdown italics. Channel lookup and the obsolete Outwar
+ * URL trigger are omitted; IRC input actions have dedicated slash subcommands.
+ */
+export const PHRASES = [
+ [
+ "some people are being fangoriously devoured by a gelatinous monster",
+ "Hillary's legs are being digested.",
+ "^some people are being fangoriously devoured by a gelatinous monster$",
+ ],
+ ["hello fishbot", "Hi $nick", "^hello fishbot$"],
+ [
+ "badger badger badger badger badger badger badger badger badger badger badger badger",
+ "mushroom mushroom!",
+ "^badger badger badger badger badger badger badger badger badger badger badger badger$",
+ ],
+ ["snake", "Ah snake a snake! Snake, a snake! Ooooh, it's a snake!", "^snake$"],
+ [
+ "carrots handbags cheese.",
+ "toilets russians planets hamsters weddings poets stalin KUALA LUMPUR! pygmies budgies KUALA LUMPUR!",
+ "^carrots handbags cheese\\.$",
+ ],
+ ["sledgehammer", "sledgehammers go quack!", "^sledgehammer$"],
+ ["vinegar and aftershock.", "Ah, a true connoisseur!", "^vinegar.*?aftershock.$"],
+ [
+ "moo?",
+ "To moo, or not to moo, that is the question. Whether 'tis nobler in the mind to suffer the slings and arrows of outrageous fish...",
+ "^moo\\?$",
+ ],
+ [
+ "herring",
+ "herring(n): Useful device for chopping down tall trees. Also moos (see fish).",
+ "^herring$",
+ ],
+ ["hampster", "$nick: There is no 'p' in hamster you retard.", "^hampster$"],
+ ["ag", "Ag, ag ag ag ag ag AG AG AG!", "^ag$"],
+ ["fishbot owns", "Aye, I do.", "^fishbot owns$"],
+ ["vinegar", "Nope, too sober for vinegar. Try later.", "^vinegar$"],
+ ["martian", "Don't run! We are your friends!", "^martian$"],
+ ["just then, he fell into the sea", "Ooops!", "^just then\\, he fell into the sea$"],
+ ["aftershock", "mmmm, Aftershock.", "^aftershock$"],
+ ["why are you here?", "Same reason. I love candy.", "^why are you here\\?$"],
+ ["spoon", "There is no spoon.", "^spoon$"],
+ ["bounce", "moo", "^bounce$"],
+ ["crack", "Doh, there goes another bench!", "^crack$"],
+ [
+ "you can't just pick people at random!",
+ "I can do anything I like, $nick, I'm eccentric! Rrarrrrrgh! Go!",
+ "^you can\\'t just pick people at random\\!$",
+ ],
+ ["wertle", "moo", "^wertle$"],
+ ["flibble", "plob", "^flibble$"],
+ [
+ "fishbot created splidge",
+ "omg no! Think I could show my face around here if I was responsible for THAT?",
+ "^fishbot created splidge$",
+ ],
+ ["now there's more than one of them?", "A lot more.", "^now there\\'s more than one of them\\?$"],
+ ["I want everything", "Would that include a bullet from this gun?", "^I want everything$"],
+ ["we are getting aggravated.", "Yes, we are.", "^we are getting aggravated\\.$"],
+ ["how old are you, fishbot?", "*is older than time itself.*", "^how old are you, fishbot\\?$"],
+ [
+ "atlantis",
+ "Beware the underwater headquarters of the trout and their bass henchmen. From there they plan their attacks on other continents.",
+ "^atlantis$",
+ ],
+ ["oh god", "fishbot will suffice.", "^oh god$"],
+ ["fishbot", "Yes?", "^fishbot$"],
+ [
+ "what is the matrix?",
+ "No-one can be told what the matrix is. You have to see it for yourself.",
+ "^what is the matrix\\?$",
+ ],
+ ["what do you need?", "Guns. Lots of guns.", "^what do you need\\?$"],
+ ["I know Kungfu", "Show me.", "^I know Kungfu$"],
+ ["cake", "fish", "^cake$"],
+ ["trout go moo", "Aye, that's cos they're fish.", "^trout go moo$"],
+ ["Kangaroo", "The kangaroo is a four winged stinging insect.", "^Kangaroo$"],
+ ["bass", "Beware of the mutant sea bass and their laser cannons!", "^bass$"],
+ ["trout", "Trout are freshwater fish and have underwater weapons.", "^trout$"],
+ [
+ "where do you want to go today?",
+ "anywhere but redmond :(.",
+ "^where do you want to go today\\?$",
+ ],
+ ["fish go moo", "*notes that $nick is truly enlightened.*", "^fish go moo$"],
+ ["cows go moo", "$nick: only when they are impersonating fish.", "^cows go moo$"],
+ ["fish go blubb", "$nick LIES! Fish don't go blubb! fish go m00!", "^fish go blubb$"],
+ ["you know who else moos", "$nick: YA MUM!", "^you know who else moos$"],
+ [
+ "If there's one thing I know for sure, it's that fish don't m00.",
+ "$nick: HERETIC! UNBELIEVER!",
+ "^If there's one thing I know for sure, it\\'s that fish don\\'t m00\\.$",
+ ],
+ [
+ "fishbot: Muahahaha. Ph33r the dark side. :)",
+ "$nick: You smell :P.",
+ "^fishbot: Muahahaha\\. Ph33r the dark side\\. :\\)$",
+ ],
+ ["ammuu?", "$nick: fish go m00 oh yes they do!", "^ammuu\\?$"],
+ ["fish", "$nick: fish go m00!", "^fish$"],
+] as const;
+
+const matchers = PHRASES.map(([phrase, response, pattern]) => ({
+ phrase,
+ response,
+ pattern: new RegExp(pattern, "i"),
+}));
+
+export function normalizePhrase(phrase: string): string {
+ return phrase.trim().toLowerCase().replace(/\s+/g, " ");
+}
+
+export function replyForPhrase(phrase: string): string | undefined {
+ return matchers.find((entry) => entry.pattern.test(phrase))?.response;
+}
diff --git a/src/features/index.ts b/src/features/index.ts
index 326df18..5c49b63 100644
--- a/src/features/index.ts
+++ b/src/features/index.ts
@@ -21,6 +21,7 @@ import { createAdminFeature } from "./admin/index.ts";
import { createClosingTimeFeature } from "./closing-time/index.ts";
import { createEventsFeature } from "./events/index.ts";
import { createFeedbackFeature } from "./feedback/index.ts";
+import { createFishbotFeature } from "./fishbot/index.ts";
import { createHelpFeature } from "./help/index.ts";
import { createHomeFeature } from "./home/index.ts";
import { createInfoFeature } from "./info/index.ts";
@@ -76,6 +77,7 @@ export function buildFeatures(deps: FeatureDeps): Feature[] {
});
return [
createHelpFeature(),
+ createFishbotFeature(),
createPingFeature({ version: deps.version }),
createStatusFeature({
spaceStatus: deps.spaceStatus,