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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions scripts/register-commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
26 changes: 26 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions test/help.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof makeEnv>) => 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<unknown>[] = [];
const ctx = { waitUntil: (p: Promise<unknown>) => 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 };
}
Loading