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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Pixel is a helpful assistant bot for members and visitors of the [Pixelbar](http

Pixel starts on Discord. Its core doesn't depend on any platform, so other platforms (interactive ones like Telegram, outbound-only ones like Mastodon) can be added later as separate adapters.

> **Status:** phase 1 in progress. The core, the access lists and the Discord adapter work, with `/help`, `/ping`, `/status` (live from SpaceAPI), `/whoami` and `/admin status`, plus open/closed announcements in Discord, `/events` (from the server's scheduled events) and `/info` (short answers about Pixelbar). See [`docs/architecture.md`](docs/architecture.md) and [`docs/identity-and-access.md`](docs/identity-and-access.md). To hide admin commands from other people in Discord, see [`docs/discord-command-visibility.md`](docs/discord-command-visibility.md).
> **Status:** phase 1 in progress. The core, the access lists and the Discord adapter work, with `/help`, `/ping`, `/status` (live from SpaceAPI), `/whoami` and `/admin` (`status`, `reload`, `set-level`, `whois`), plus open/closed announcements in Discord, `/events` (from the server's scheduled events) and `/info` (short answers about Pixelbar). See [`docs/architecture.md`](docs/architecture.md) and [`docs/identity-and-access.md`](docs/identity-and-access.md). To hide admin commands from other people in Discord, see [`docs/discord-command-visibility.md`](docs/discord-command-visibility.md).

## Stack

Expand Down
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ The [spaceapi.io directory](https://api.spaceapi.io/openapi.json) was considered
| `help` | `/help` | guest | ✅ | Lists only the commands the caller can use |
| `ping` | `/ping` | guest | ✅ | Version |
| `whoami` | `/whoami` | guest | ✅ | Private reply: your ID and tier |
| `admin` | `/admin status` | admin | ✅ | Private reply: version, uptime, access-list counts (no IDs). `/admin` is a group, and more subcommands follow |
| `admin` | `/admin status` | admin | ✅ | Private reply: version, uptime, access-list counts (no IDs) |
| `admin` | `/admin reload` | admin | ✅ | Re-reads `admins.yaml` and `members.yaml` after hand edits. Keeps the old data if they're now invalid |
| `admin` | `/admin set-level user: level: [reason:]` | admin | ✅ | Makes someone `member`, `friend` or `guest`. Private reply with before and after. Refuses admins and bots, and says so when nothing would change |
| `admin` | `/admin whois user:` | admin | ✅ | Private. Level, where it comes from (admins file, members file, not listed), capabilities and the note. Lookups are logged |
| `status` | `/status` | guest | ✅ | Public. A "Checking…" box, then a live answer: open (green) or closed (red), and how long (if Pixel saw the change) |
| `status` | background: announce changes | n/a | ✅ | Posts to the live and/or timeline channels (see below) |
| `events` | `/events` | guest | ✅ | Public. What's on now, then the next events (5 at most), with when, how soon, where and how often it repeats |
Expand Down
1 change: 1 addition & 0 deletions docs/identity-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ If any step fails, both the file and the view are unchanged, and the caller gets

- **Admins are never written here.** Changing an admin's entry is refused.
- **Every change is audited**: an `access.changed` log event with who did it (ID, name, handle), who it was done to (ID), and the tier and capabilities before and after. Notes are never logged. The change also becomes a Sentry breadcrumb, so error reports show recent access changes. Sentry is not the audit record; a dedicated admin audit log is planned (#31).
- **Admin commands** use the store: `/admin set-level user: level: [reason:]` sets someone to `member`, `friend` or `guest`, and `/admin whois user:` shows what Pixel knows. Both are admin-only and private, act on the immutable user ID, refuse admins and bots, and say so when nothing would change. `guest` keeps the entry and capabilities; removing someone completely means editing the file by hand, then `/admin reload`. The optional reason goes into the audit log only (200 characters at most). Text from the file, such as a note, is shown as a code span, and names are escaped, so none of it can render as formatting, a link or a mention.
- **Hand edits made while the bot runs** are picked up by the next change, or by `/admin reload` (admins only), which re-reads both files and keeps the old data if they are now invalid. Restarting also works. In local development, `just dev` restarts automatically when the files change.

### Request flow
Expand Down
8 changes: 8 additions & 0 deletions src/core/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ export const TIERS = ["guest", "friend", "member", "admin"] as const;

export type Tier = (typeof TIERS)[number];

/** How a tier is shown to people. */
export const TIER_LABELS: Record<Tier, string> = {
guest: "Guest",
friend: "Friend of Pixelbar",
member: "Pixelbar member",
admin: "Pixel admin",
};

export type ChatContext = "dm" | "group";

export type Platform = "discord";
Expand Down
40 changes: 39 additions & 1 deletion src/core/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { escapeMarkdown, formatUntil, isValidTimeZone } from "./format.ts";
import { escapeMarkdown, formatUntil, inlineCode, isValidTimeZone } from "./format.ts";

const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
Expand Down Expand Up @@ -57,3 +57,41 @@ describe("escapeMarkdown", () => {
expect(escapeMarkdown(" one\n\ntwo \t three ")).toBe("one two three");
});
});

describe("inlineCode", () => {
it("shows text as a code span", () => {
expect(inlineCode("paid yearly")).toBe("`paid yearly`");
});

it.each([
["a masked link", "[click](https://evil.example)"],
["an everyone mention", "@everyone <@123> <#456>"],
["a heading, list and quote", "# big\n- item\n> quote"],
["formatting", "**bold** __under__ ~~strike~~ ||spoiler||"],
["a custom emoji and timestamp", "<:wave:123> <t:1700000000:R>"],
])("keeps %s inert inside the code span", (_label, text) => {
const out = inlineCode(text);
expect(out.startsWith("`")).toBe(true);
expect(out.endsWith("`")).toBe(true);
expect(out.slice(1, -1)).not.toContain("`");
expect(out).not.toMatch(/\n/);
});

it("can't break out of the span with backticks", () => {
expect(inlineCode("a`b```c")).toBe("`a'b'''c`");
});

it("turns control, invisible and bidi characters and line breaks into spaces", () => {
expect(inlineCode("a\u0000b\u200bc\u202ed\n\re\u2028f")).toBe("`a b c d e f`");
});

it("cuts long text by characters, not code units", () => {
expect(inlineCode("😀".repeat(10), 5)).toBe(`\`${"😀".repeat(4)}…\``);
expect(inlineCode("abcde", 5)).toBe("`abcde`");
});

it("falls back when nothing is left", () => {
expect(inlineCode(" \u200b \n")).toBe("(empty)");
expect(inlineCode("", 10, "none")).toBe("none");
});
});
18 changes: 18 additions & 0 deletions src/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,21 @@ export function escapeMarkdown(text: string): string {
.trim()
.replace(/[\\*_~`|<>[\]]/g, "\\$&");
}

/**
* Shows text written by someone else as a code span, so nothing in it can act
* as formatting, a link, a mention, a heading or a list. Control and invisible
* characters become spaces, backticks become apostrophes, and long text is cut
* to `max` characters. Returns `fallback` when nothing is left.
*/
export function inlineCode(text: string, max = 200, fallback = "(empty)"): string {
const clean = text
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/`/g, "'");
if (clean === "") return fallback;
const chars = [...clean];
const shown = chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : clean;
return `\`${shown}\``;
}
7 changes: 5 additions & 2 deletions src/core/ports/access-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ export type AccessView = {

export type AccessChange =
/** Sets (or creates) someone's tier. Keeps their note and capabilities unless `note` is given. */
| { kind: "set-tier"; id: string; tier: MemberTier; note?: string }
| { kind: "set-tier"; id: string; tier: MemberTier; note?: string; reason?: string }
/** Replaces someone's capabilities. They must already have an entry. */
| { kind: "set-capabilities"; id: string; capabilities: readonly string[] };
| { kind: "set-capabilities"; id: string; capabilities: readonly string[]; reason?: string };

/** Longest reason accepted. It goes into the audit log, not the file. */
export const MAX_REASON_LENGTH = 200;

export type AccessChangeResult = {
before: MemberRecord | null;
Expand Down
4 changes: 3 additions & 1 deletion src/features/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Feature } from "../../core/feature.ts";
import { formatDuration } from "../../core/format.ts";
import type { AccessStore } from "../../core/ports/access-store.ts";
import { createMemberSubcommands } from "./members.ts";

export type AdminDeps = {
version: string;
startedAt: Date;
access: Pick<AccessStore, "view" | "reload">;
access: Pick<AccessStore, "view" | "apply" | "reload">;
now?: () => Date;
};

Expand Down Expand Up @@ -64,6 +65,7 @@ export function createAdminFeature(deps: AdminDeps): Feature {
};
},
},
...createMemberSubcommands(deps.access),
],
},
],
Expand Down
Loading
Loading