Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ Set `NOTIFICATION_GATEWAY_URL` on Room TBA to `https://<heroku-app>.herokuapp.co

This repo can run **solo on Heroku** (default) or be **imported by a personal multi-bot host** via `@uplbtools/discord-bot/runtime`. See [docs/embeddable-runtime.md](docs/embeddable-runtime.md).

## Central host integration

The production host is [`smmariquit/discord-bot-host`](https://github.com/smmariquit/discord-bot-host), running the other personal Discord bots on one Heroku web dyno. The host calls `createUplbToolsRuntime({ envPrefix: "UPLB_", listen: false })`, so this bot keeps its cron jobs, Discord event handlers, and webhook routes while the host owns the single Heroku `PORT` and process lifecycle.

The Heroku app is funded by a GitHub Student Developer Pack credit, planned through May 2028 as an estimate. Verify the actual credit expiry in Heroku.

## Docs

- [docs/embeddable-runtime.md](docs/embeddable-runtime.md): library export + host embedding
Expand Down
2 changes: 1 addition & 1 deletion docs/discord-server-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Links: https://uplbtools.me · https://github.com/uplbtools · https://room-tba.

Bot commands: `/issue`, `/prs`, `/ci`, `/map`, `/status`. Maintainers: `/triage`. Weekly triage cron posts here (Monday 09:00 UTC).

**This channel receives:** GitHub Actions `workflow_run` failures (except E2E/staging-smoke: those have dedicated handlers), Playwright E2E failure summaries from room-tba CI, and triage cron output.
**This channel receives:** GitHub Actions `workflow_run` failures (except E2E/staging-smoke: those have dedicated handlers), Playwright E2E failure summaries from room-tba CI, triage cron output, and in-app feedback sent from room-tba Settings (`feedback.submitted`).

## #github

Expand Down
21 changes: 21 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type NotificationEvent = {
type:
| "proposal.submitted"
| "proposal.reviewed"
| "feedback.submitted"
| "deploy.succeeded"
| "deploy.failed"
| "release.published"
Expand Down Expand Up @@ -58,6 +59,26 @@ Posted from room-tba admin approve/reject/request-changes routes.

Routes to **`#contributors`** (green approve, red reject, amber needs-changes).

## feedback.submitted payload

Posted from room-tba `POST /api/feedback` (in-app feedback box, [room-tba#881](https://github.com/uplbtools/room-tba/issues/881)).

| Field | Type | Notes |
| ---------- | --------------- | ----------------------------------------- |
| feedbackId | number | row id in the room-tba `feedback` table |
| message | string | free text, submitter-written |
| contact | string \| null | optional, whatever the submitter typed |
| screen | string \| null | same-site path only, query string stripped |
| appVersion | string \| null | |
| wasOnline | boolean \| null | |

Routes to **`#development`**. No IP address is sent.

`/api/feedback` is public and unauthenticated, so `message` and `contact` are untrusted:
the bot parses the payload with `feedbackSubmittedPayloadSchema` and renders text through
`safeUserText` (markdown and masked links escaped, `@` broken with a zero-width space,
truncated to fit the embed). Mentions are additionally denied on every Discord send.

## CI E2E payloads (`ci.e2e.*`, `ci.staging-*`)

Posted from room-tba `discord-notify-e2e.yml`. Routes to `#development` except `ci.staging-smoke.failed` → `#deploys`.
Expand Down
14 changes: 13 additions & 1 deletion src/notifications/delivery/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { config } from "../../config.js";
import { BOT_FOOTER, ROOM_TBA_BASE } from "../../constants.js";
import { log } from "../../log.js";
import { channelIdForCiEvent, ciE2eEmbed } from "../ci-embeds.js";
import { feedbackSubmittedEmbed } from "../feedback-embed.js";
import { githubActivityEmbed } from "../github-embeds.js";
import { channelIdForGithubRoute, githubDiscordRoute } from "../github-routing.js";
import type { TestInventoryPayload } from "../test-inventory.js";
Expand All @@ -14,6 +15,7 @@ import type {
ProposalReviewOutcome,
ProposalSubmittedPayload,
} from "../types.js";
import { feedbackSubmittedPayloadSchema } from "../types.js";

const seenKeys = new Map<string, number>();
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -44,7 +46,9 @@ async function sendToChannel(
log("warn", `Channel ${channelId} is not a guild text channel`);
return;
}
await (channel as TextChannel).send(content);
// No notification we deliver is ever meant to ping, and some carry untrusted
// text (feedback, proposals), so deny mentions for every path at the sender.
await (channel as TextChannel).send({ ...content, allowedMentions: { parse: [] } });
}

function proposalSubmittedEmbed(payload: ProposalSubmittedPayload): EmbedBuilder {
Expand Down Expand Up @@ -127,6 +131,14 @@ export async function deliverToDiscord(
});
break;
}
case "feedback.submitted": {
// Parsed, not cast: the body came from a public unauthenticated endpoint.
const payload = feedbackSubmittedPayloadSchema.parse(event.payload);
await sendToChannel(client, config.channelDevelopmentId, {
embeds: [feedbackSubmittedEmbed(payload, event.occurredAt)],
});
break;
}
case "deploy.succeeded":
case "deploy.failed": {
const isProd =
Expand Down
151 changes: 151 additions & 0 deletions src/notifications/feedback-embed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test";
import {
feedbackSubmittedEmbed,
safeInlineText,
safeUserText,
} from "./feedback-embed.js";
import { feedbackSubmittedPayloadSchema } from "./types.js";

const OCCURRED_AT = "2026-08-04T09:00:00.000Z";

const validPayload = {
feedbackId: 12,
message: "The 3D buildings do not load on my phone.",
contact: "juan@up.edu.ph",
screen: "/map",
appVersion: "2026.08.01",
wasOnline: true,
};

describe("feedbackSubmittedPayloadSchema", () => {
test("accepts the payload room-tba emits", () => {
expect(feedbackSubmittedPayloadSchema.safeParse(validPayload).success).toBe(true);
});

test("accepts null optional context", () => {
const result = feedbackSubmittedPayloadSchema.safeParse({
...validPayload,
contact: null,
screen: null,
appVersion: null,
wasOnline: null,
});
expect(result.success).toBe(true);
});

test("rejects a missing message", () => {
const { message: _message, ...rest } = validPayload;
expect(feedbackSubmittedPayloadSchema.safeParse(rest).success).toBe(false);
});

test("rejects an empty message", () => {
const result = feedbackSubmittedPayloadSchema.safeParse({
...validPayload,
message: "",
});
expect(result.success).toBe(false);
});

test("rejects wrong field types", () => {
const result = feedbackSubmittedPayloadSchema.safeParse({
...validPayload,
feedbackId: "12",
wasOnline: "yes",
});
expect(result.success).toBe(false);
});
});

describe("safeUserText", () => {
test("neutralises everyone, here, and role mentions", () => {
const rendered = safeUserText("@everyone @here <@&123> <@456>");
expect(rendered).not.toContain("@everyone");
expect(rendered).not.toContain("@here");
expect(rendered).not.toContain("<@&123>");
expect(rendered).not.toContain("<@456>");
// Text is still readable, just un-pingable.
expect(rendered).toContain("everyone");
});

test("escapes markdown and masked links", () => {
const rendered = safeUserText("**bold** [click](https://evil.example)");
expect(rendered).toContain("\\*\\*bold\\*\\*");
// Escaped bracket: Discord renders the URL plainly instead of hiding it.
expect(rendered).toContain("\\[click]");
});

test("truncates over-long text instead of throwing", () => {
const rendered = safeUserText("a".repeat(5000), 1024);
expect(rendered.length).toBe(1024);
expect(rendered.endsWith("…")).toBe(true);
});

test("does not leave a dangling escape before the ellipsis", () => {
const rendered = safeUserText("*".repeat(5000), 100);
expect(rendered.endsWith("\\…")).toBe(false);
expect(rendered.endsWith("…")).toBe(true);
});

test("stays fast on a hostile body (escapeMarkdown is quadratic)", () => {
const started = performance.now();
safeUserText("@everyone *".repeat(5000), 3900);
expect(performance.now() - started).toBeLessThan(1000);
});

test("leaves a readable app version unescaped", () => {
expect(safeInlineText("2026.08.01")).toBe("2026.08.01");
expect(safeInlineText("/map")).toBe("/map");
});

test("still defuses markdown and mentions in a context value", () => {
expect(safeInlineText("/map\n# @everyone")).not.toContain("@everyone");
expect(safeInlineText("/map\n# huge")).not.toContain("\n");
});
});

describe("feedbackSubmittedEmbed", () => {
test("renders message, contact, and context", () => {
const data = feedbackSubmittedEmbed(validPayload, OCCURRED_AT).toJSON();
expect(data.title).toBe("New in-app feedback");
expect(data.description).toContain("3D buildings");
const field = (name: string) => data.fields?.find((f) => f.name === name)?.value;
expect(field("Screen")).toBe("/map");
expect(field("App version")).toBe("2026.08.01");
expect(field("Online")).toBe("Yes");
expect(field("Feedback ID")).toBe("12");
expect(field("Contact")).toContain("juan");
});

test("omits contact when absent and dashes missing context", () => {
const data = feedbackSubmittedEmbed(
{
...validPayload,
contact: null,
screen: null,
appVersion: null,
wasOnline: null,
},
OCCURRED_AT,
).toJSON();
expect(data.fields?.some((f) => f.name === "Contact")).toBe(false);
expect(data.fields?.find((f) => f.name === "Online")?.value).toBe("—");
expect(data.fields?.find((f) => f.name === "Screen")?.value).toBe("—");
});

test("keeps a hostile oversized body inside Discord's limits", () => {
const data = feedbackSubmittedEmbed(
{ ...validPayload, message: "@everyone *".repeat(2000) },
OCCURRED_AT,
).toJSON();
expect(data.description?.length).toBeLessThanOrEqual(4096);
expect(data.description).not.toContain("@everyone");
});

test("marks offline submissions", () => {
const data = feedbackSubmittedEmbed(
{ ...validPayload, wasOnline: false },
OCCURRED_AT,
).toJSON();
expect(data.fields?.find((f) => f.name === "Online")?.value).toBe("No");
});
});
112 changes: 112 additions & 0 deletions src/notifications/feedback-embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { EmbedBuilder, escapeMarkdown } from "discord.js";
import { BOT_FOOTER } from "../constants.js";
import type { FeedbackSubmittedPayload } from "./types.js";

/** Discord caps embed descriptions at 4096; leave headroom for escape chars. */
const MESSAGE_MAX = 3900;
/** Discord caps embed field values at 1024. */
const FIELD_MAX = 1024;
/**
* Cut raw text before escaping: `escapeMarkdown` is quadratic (22k chars is
* roughly 7s of blocked event loop) and escaping only ever grows text, so a
* longer body loses its tail either way.
*
* ponytail: mirrors room-tba's own 2000-char cap. Raise both together if that
* cap moves, or the tail of a long message quietly disappears here.
*/
const RAW_MAX = 2000;

function neutralise(escaped: string, max: number, clipped: boolean): string {
// Zero-width space after every `@`: nothing a submitter writes can ping
// @everyone, @here, or a role, and the text still reads normally.
const safe = escaped.replaceAll("@", "@\u200b");
// `clipped` forces the ellipsis even when escaping did not push the text past
// `max`: the raw body was already cut, and that has to stay visible.
if (!clipped && safe.length <= max) return safe;
// A trailing backslash would escape the ellipsis instead of rendering it.
return `${safe.slice(0, max - 1).replace(/\\+$/, "")}…`;
}

/**
* Render untrusted free text (the message body, the contact line).
*
* room-tba `/api/feedback` is public and unauthenticated, so anything here can
* be hostile: escape markdown — masked links included, or a submission could
* render a fake link into the team channel — neutralise mentions, and truncate
* rather than let Discord reject the whole message.
*/
export function safeUserText(raw: string, max = FIELD_MAX): string {
const limit = Math.min(max, RAW_MAX);
return neutralise(
escapeMarkdown(raw.slice(0, limit), {
heading: true,
bulletedList: true,
numberedList: true,
maskedLink: true,
}),
max,
raw.length > limit,
);
}

/**
* Render a single-token context value (screen path, app version).
*
* Whitespace is collapsed first, so no line start is left for heading or list
* markdown to fire from — which also spares readable values the escaping that
* would otherwise render an app version as `2026\.08.01`.
*/
export function safeInlineText(raw: string): string {
return neutralise(
escapeMarkdown(raw.slice(0, RAW_MAX).replace(/\s+/g, " ").trim(), {
maskedLink: true,
}),
FIELD_MAX,
raw.length > RAW_MAX,
);
}

function optionalField(value: string | null): string {
const trimmed = value?.trim();
return trimmed ? safeInlineText(trimmed) : "—";
}

export function feedbackSubmittedEmbed(
payload: FeedbackSubmittedPayload,
occurredAt: string,
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(0x0369a1)
.setTitle("New in-app feedback")
.setDescription(safeUserText(payload.message, MESSAGE_MAX))
.addFields(
{ name: "Screen", value: optionalField(payload.screen), inline: true },
{
name: "App version",
value: optionalField(payload.appVersion),
inline: true,
},
{
name: "Online",
value: payload.wasOnline === null ? "—" : payload.wasOnline ? "Yes" : "No",
inline: true,
},
{
name: "Feedback ID",
value: String(payload.feedbackId),
inline: true,
},
)
.setTimestamp(new Date(occurredAt))
.setFooter({ text: BOT_FOOTER });

if (payload.contact?.trim()) {
embed.addFields({
name: "Contact",
value: safeUserText(payload.contact.trim()),
inline: true,
});
}

return embed;
}
19 changes: 19 additions & 0 deletions src/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ describe("notificationEventSchema", () => {
expect(result.success).toBe(true);
});

test("accepts feedback.submitted envelope", () => {
const result = notificationEventSchema.safeParse({
schemaVersion: 1,
type: "feedback.submitted",
source: "room-tba",
occurredAt: new Date().toISOString(),
idempotencyKey: "feedback:12:submitted",
payload: {
feedbackId: 12,
message: "Rooms in CAS are missing.",
contact: null,
screen: "/map",
appVersion: "2026.08.01",
wasOnline: true,
},
});
expect(result.success).toBe(true);
});

test("rejects wrong schema version", () => {
const result = notificationEventSchema.safeParse({
schemaVersion: 2,
Expand Down
Loading
Loading