Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/.vitepress/theme/components/PxCommandTray.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
34 changes: 34 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,40 @@ Sends a message to Pixel's maintainers, with your Discord name and ID so they ca

</PxCommand>

### 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.

<PxCommand :level="4" sig="/fishbot say [phrase:]" tier="guest" reply="public" autocomplete :options="{ '[phrase:]': 'A classic phrase, such as fish, hello fishbot, spoon or moo?. Start typing for suggestions; leave empty for private help.' }">

Try a classic Fishbot phrase. Matching ignores case and extra spaces, but requires the whole phrase. Fish go m00!

</PxCommand>

<PxCommand :level="4" sig="/fishbot trout user:" tier="guest" reply="public" :options="{ 'user:': 'Pick the person (or bot) to trout. Nobody gets pinged.' }">

Slap someone around a bit with a large trout. Purely theatrical.

</PxCommand>

<PxCommand :level="4" sig="/fishbot feed" tier="guest" reply="public">

Feed Fishbot hundreds and thousands.

</PxCommand>

<PxCommand :level="4" sig="/fishbot pet" tier="guest" reply="public">

Give Fishbot a friendly stroke.

</PxCommand>

<PxCommand :level="4" sig="/fishbot cookie" tier="guest" reply="public">

Sneak a cookie away from Fishbot.

</PxCommand>

## Members

For paid members. Commands marked Friend also work with a friend membership.
Expand Down
2 changes: 2 additions & 0 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):**
Expand Down
88 changes: 88 additions & 0 deletions src/adapters/discord/fishbot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions src/features/features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("buildFeatures", () => {
"closing-time",
"events",
"feedback",
"fishbot",
"ha",
"help",
"info",
Expand Down
168 changes: 168 additions & 0 deletions src/features/fishbot/fishbot.test.ts
Original file line number Diff line number Diff line change
@@ -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 } });
});
});
Loading
Loading