From 0ac0eb416ba16bb415abe1e3a49254b3c7b01b8e Mon Sep 17 00:00:00 2001 From: bhushan4work Date: Mon, 12 Oct 2026 17:38:34 +0530 Subject: [PATCH] feat: add a /help command --- README.md | 1 + scripts/register-commands.mjs | 1 + src/commands.ts | 5 ++- src/messages.ts | 26 +++++++++++++ test/help.test.ts | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 test/help.test.ts diff --git a/README.md b/README.md index fb3290e..2c05cb2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ The bot behind the open source channels on the [CODERCOPS Discord server](https: - **Releases** go to `#releases` with their notes. - **Starter issues** (`good first issue` and `help wanted`) go to `#starter-issues`. Each issue gets one message, and the bot edits it when the issue is claimed, freed up or closed, so the channel always shows what's really available. - **Merged PRs** get a shoutout in `#merged-prs`, with the author's count in the running program. The merge that takes someone to the program's minimum says they now qualify. +- **`/help`** lists every command with a line saying what it does, and points at this repo for bug reports and ideas. - **`/issues`** lists the open starter issues nobody has claimed yet. **`/leaderboard`** shows who has the most merged PRs in a contributor program. - A **weekly leaderboard** is posted while a program is running, and the final results once it ends. - Every program has a public page, for example [CODERCOPS October 2026](https://oss.codercops.com/leaderboard/2026-10), with a JSON version next to it. diff --git a/scripts/register-commands.mjs b/scripts/register-commands.mjs index 928c0ad..aecd350 100644 --- a/scripts/register-commands.mjs +++ b/scripts/register-commands.mjs @@ -20,6 +20,7 @@ const repos = REPOS.split(',').map((r) => r.trim()); const longestRoadmap = Math.max(...ROADMAPS.map((r) => r.days)); const commands = [ + { name: 'help', description: 'Show the commands and what they do' }, { name: 'issues', description: 'List open starter issues nobody has claimed yet', diff --git a/src/commands.ts b/src/commands.ts index b0f084b..9ec9fc4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,7 +6,7 @@ import type { MessagePayload } from "./discord"; import { countMergedPrs, GithubError, listStarterIssues } from "./github"; import { getLeaderboard } from "./leaderboard"; import { getLink, linkedMembers, removeLink } from "./links"; -import { checkinReply, leaderboardMessage, linkPrompt, meReply, starterIssuesReply, streaksMessage } from "./messages"; +import { checkinReply, helpReply, leaderboardMessage, linkPrompt, meReply, starterIssuesReply, streaksMessage } from "./messages"; import { authorizeUrl, linkingEnabled, signState } from "./oauth"; import { grantEarnedRoles, isMaintainer, removeTierRoles } from "./roles"; import { currentProgram, findProgram, leaderboardUrl, programStatus } from "./programs"; @@ -34,6 +34,9 @@ export async function runCommand( const option = (name: string) => data.options?.find((o) => o.name === name)?.value; try { switch (data.name) { + case "help": { + return helpReply(linkingEnabled(env)); + } case "checkin": { if (!user) return { content: "Run this inside the server, not in a DM." }; const day = option("day"); diff --git a/src/messages.ts b/src/messages.ts index bb73b92..e7e5c21 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -301,6 +301,32 @@ export function linkPrompt(url: string, currentLogin?: string): MessagePayload { return { content: lines.join("\n"), components: [row(linkButton("Link my GitHub", url))] }; } +// ---- /help ---- + +/** + * The private reply to /help: every command the member can run right now, one + * line each. The link commands only exist while linking is switched on, so + * they're left out otherwise. + */ +export function helpReply(linking: boolean): MessagePayload { + const lines = [ + "`/help` — Show the commands and what they do.", + "`/issues` — List open starter issues nobody has claimed yet.", + "`/leaderboard` — Show who has the most merged PRs in a contributor program.", + "`/checkin` — Log today on a learning roadmap and keep your streak alive.", + "`/streaks` — Show the learning streaks for every roadmap.", + ]; + if (linking) { + lines.push( + "`/link` — Link your GitHub account to your Discord account.", + "`/unlink` — Remove the link between your GitHub and Discord accounts.", + "`/me` — Show your merged PRs and where you stand." + ); + } + lines.push("", "Spotted a bug or have an idea? Open an issue at https://github.com/codercops/discord-bot"); + return { content: lines.join("\n") }; +} + /** The private reply to /me. */ export function meReply(input: { login: string; diff --git a/test/help.test.ts b/test/help.test.ts new file mode 100644 index 0000000..1f69a88 --- /dev/null +++ b/test/help.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { runCommand } from "../src/commands"; +import { getConfig } from "../src/config"; +import worker from "../src/index"; +import { helpReply } from "../src/messages"; +import { makeEnv, mockFetch } from "./helpers"; + +const now = new Date("2026-10-12T10:00:00Z"); +const linkingEnv = () => makeEnv({ LINKING: "on", DISCORD_CLIENT_SECRET: "client-secret" }); +const run = (env: ReturnType) => runCommand({ name: "help" }, { id: "42", name: "Ada" }, env, getConfig(env), now); + +/** The "/command" — description lines of a help reply. */ +function commandsIn(content: string | undefined): { name: string; description: string }[] { + return (content ?? "") + .split("\n") + .flatMap((line) => { + const match = line.match(/^`(\S+)` — (.+)$/); + return match ? [{ name: match[1]!, description: match[2]! }] : []; + }); +} + +describe("/help", () => { + it("lists every command with a one line description and ends with the repo link", async () => { + const reply = await run(linkingEnv()); + const listed = commandsIn(reply.content); + expect(listed.map((c) => c.name)).toEqual(["/help", "/issues", "/leaderboard", "/checkin", "/streaks", "/link", "/unlink", "/me"]); + for (const { name, description } of listed) { + expect(description.length, name).toBeGreaterThan(10); + expect(description.length, name).toBeLessThan(100); + } + expect(reply.content).toContain("bug"); + expect(reply.content?.endsWith("https://github.com/codercops/discord-bot")).toBe(true); + }); + + it("leaves the link commands out while linking is off", async () => { + const listed = commandsIn((await run(makeEnv())).content); + expect(listed.map((c) => c.name)).toEqual(["/help", "/issues", "/leaderboard", "/checkin", "/streaks"]); + expect(helpReply(false).content).not.toContain("/link"); + expect(helpReply(false).content?.endsWith("https://github.com/codercops/discord-bot")).toBe(true); + }); + + it("answers only the person who ran it, like every other command", async () => { + const fetched = mockFetch(); + const { request, publicKey } = await signedHelpInteraction(); + const pending: Promise[] = []; + const ctx = { waitUntil: (p: Promise) => void pending.push(p), passThroughOnException() {} } as unknown as ExecutionContext; + const res = await worker.fetch(request, makeEnv({ DISCORD_PUBLIC_KEY: publicKey }), ctx); + // Deferred as ephemeral, so only the runner ever sees the reply. + expect(await res.json()).toEqual({ type: 5, data: { flags: 64 } }); + await Promise.all(pending); + const reply = fetched.discord()[0]; + expect(reply?.url).toBe("https://discord.com/api/v10/webhooks/111/interaction-token/messages/@original"); + expect(reply?.body.content).toContain("`/issues`"); + expect(fetched.calls.some((c) => c.url.includes("/channels/"))).toBe(false); + }); +}); + +const toHex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); + +/** A signed POST /interactions for `/help`, the way Discord sends it. */ +async function signedHelpInteraction() { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as CryptoKeyPair; + const publicKey = toHex((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); + const text = JSON.stringify({ type: 2, token: "interaction-token", data: { name: "help" } }); + const signature = toHex(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, new TextEncoder().encode("1700000000" + text))); + const request = new Request("https://bot.example/interactions", { + method: "POST", + headers: { "X-Signature-Ed25519": signature, "X-Signature-Timestamp": "1700000000" }, + body: text, + }); + return { request, publicKey }; +}