From 04298a0aa990e07c5d95c0231451ff1eec4a6573 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:30:41 +0200 Subject: [PATCH] Bring QuakeNet Fishbot nostalgia to Discord --- CHANGELOG.md | 4 + .../theme/components/PxCommandTray.vue | 8 + docs/commands.md | 34 ++++ docs/runbook.md | 2 + src/adapters/discord/fishbot.test.ts | 88 +++++++++ src/features/features.test.ts | 1 + src/features/fishbot/fishbot.test.ts | 168 ++++++++++++++++++ src/features/fishbot/index.ts | 89 ++++++++++ src/features/fishbot/phrases.ts | 118 ++++++++++++ src/features/index.ts | 2 + 10 files changed, 514 insertions(+) create mode 100644 src/adapters/discord/fishbot.test.ts create mode 100644 src/features/fishbot/fishbot.test.ts create mode 100644 src/features/fishbot/index.ts create mode 100644 src/features/fishbot/phrases.ts 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,