diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..66b647b --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,172 @@ +// Cmd-K (or `/`-triggered) command palette. Shows the catalog filtered to +// commands the current member can run, grouped by tier. Selecting a row +// inserts its usage template into the message composer; the inline +// parser in the composer handles actual execution on send. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { ALL_COMMANDS, fetchCatalog, type Tier } from "../lib/commands"; + +interface Props { + open: boolean; + serverId: string | undefined; + onClose(): void; + /** Called with the slash-prefixed string to insert into the composer. */ + onPick(insertion: string): void; +} + +interface Row { + name: string; + tier: Tier; + description: string; + usage: string; + allowed: boolean; +} + +const TIER_ORDER: Tier[] = ["user", "mod", "admin", "owner"]; +const TIER_LABEL: Record = { + user: "Member", + mod: "Moderator", + admin: "Admin", + owner: "Owner", +}; + +export function CommandPalette({ open, serverId, onClose, onPick }: Props) { + const [filter, setFilter] = useState(""); + const [rows, setRows] = useState([]); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + if (!open) return; + setFilter(""); + setCursor(0); + inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open || !serverId) return; + let cancelled = false; + (async () => { + try { + const cat = await fetchCatalog(serverId); + if (cancelled) return; + // Merge server-side rows (with `allowed`) with client-only rows + // so cosmetic and read commands show up too. Client-only ones + // are always allowed. + const serverNames = new Set(cat.commands.map(c => c.name)); + const clientOnly: Row[] = ALL_COMMANDS + .filter(c => !serverNames.has(c.name)) + .map(c => ({ + name: c.name, + tier: c.tier, + description: c.description, + usage: c.usage, + allowed: true, + })); + const all = [...cat.commands, ...clientOnly]; + all.sort((a, b) => { + const t = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier); + if (t !== 0) return t; + return a.name.localeCompare(b.name); + }); + setRows(all); + } catch { + // If the catalog endpoint fails (offline, denied, etc), fall back + // to the static client list with everything marked allowed so the + // palette is still useful. + const fallback: Row[] = ALL_COMMANDS.map(c => ({ + name: c.name, + tier: c.tier, + description: c.description, + usage: c.usage, + allowed: true, + })); + setRows(fallback); + } + })(); + return () => { + cancelled = true; + }; + }, [open, serverId]); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return rows; + return rows.filter(r => + r.name.toLowerCase().includes(q) || + r.description.toLowerCase().includes(q), + ); + }, [rows, filter]); + + useEffect(() => { + if (cursor >= filtered.length) setCursor(Math.max(0, filtered.length - 1)); + }, [filtered.length, cursor]); + + if (!open) return null; + + function pick(r: Row) { + if (!r.allowed) return; + onPick(r.usage); + onClose(); + } + + return ( +
+
e.stopPropagation()}> + setFilter(e.target.value)} + onKeyDown={e => { + if (e.key === "Escape") { + onClose(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setCursor(c => Math.min(filtered.length - 1, c + 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setCursor(c => Math.max(0, c - 1)); + } else if (e.key === "Enter") { + const r = filtered[cursor]; + if (r) pick(r); + } + }} + /> +
    + {filtered.length === 0 && ( +
  • No matching commands.
  • + )} + {filtered.map((r, i) => ( +
  • setCursor(i)} + onClick={() => pick(r)} + aria-disabled={!r.allowed} + > +
    + /{r.name} + {TIER_LABEL[r.tier]} +
    +
    {r.usage}
    +
    {r.description}
    +
  • + ))} +
+
+ ↑↓ to move + ↵ to insert + esc to close +
+
+
+ ); +} diff --git a/apps/web/src/lib/commands.ts b/apps/web/src/lib/commands.ts new file mode 100644 index 0000000..9f72b64 --- /dev/null +++ b/apps/web/src/lib/commands.ts @@ -0,0 +1,479 @@ +// Slash command catalog and parser. Mirrors the server-side dispatcher in +// crates/tempest-api/src/commands.rs plus a few client-only cosmetic +// commands. The parser turns "/cmd args..." text into a structured +// payload for POST /servers/:id/commands; cosmetic commands get rewritten +// inline and sent as normal messages. + +import { apiFetch } from "../api/client"; + +export type Tier = "user" | "mod" | "admin" | "owner"; + +export interface ServerCommand { + name: string; + tier: Tier; + description: string; + usage: string; + serverSide: true; + parse(rest: string, ctx: ParseCtx): { args: Record } | { error: string }; +} + +export interface CosmeticCommand { + name: string; + tier: "user"; + description: string; + usage: string; + serverSide: false; + rewrite(rest: string): string; +} + +export interface ClientReadCommand { + name: string; + tier: "user"; + description: string; + usage: string; + serverSide: false; + // Returns a system message string to render inline; may also fetch. + run(rest: string, ctx: ParseCtx): Promise; +} + +export type Command = ServerCommand | CosmeticCommand | ClientReadCommand; + +export interface ParseCtx { + resolveUserId(token: string): string | undefined; + myUserId: string; +} + +// ---- helpers ---- + +function splitArgs(rest: string): string[] { + return rest.trim().split(/\s+/).filter(Boolean); +} + +function pickUser(token: string | undefined, ctx: ParseCtx): string | undefined { + if (!token) return undefined; + const cleaned = token.replace(/^<@!?/, "").replace(/>$/, "").replace(/^@/, ""); + // Accept raw snowflakes too. + if (/^\d+$/.test(cleaned)) return cleaned; + return ctx.resolveUserId(cleaned); +} + +function parseDurationSecs(token: string | undefined): number | undefined { + if (!token) return undefined; + // Accept 30s, 5m, 2h, 1d, 1h30m, plain seconds. + if (/^\d+$/.test(token)) { + const n = parseInt(token, 10); + return Number.isFinite(n) ? n : undefined; + } + let total = 0; + const re = /(\d+)([smhd])/g; + let m: RegExpExecArray | null; + let matched = false; + while ((m = re.exec(token)) !== null) { + matched = true; + const n = parseInt(m[1]!, 10); + const unit = m[2]!; + const mul = unit === "s" ? 1 : unit === "m" ? 60 : unit === "h" ? 3600 : 86400; + total += n * mul; + } + return matched && total > 0 ? total : undefined; +} + +// ---- catalog ---- + +export const COSMETIC: CosmeticCommand[] = [ + { + name: "me", + tier: "user", + description: "Send a /me action message.", + usage: "/me ", + serverSide: false, + rewrite: rest => `*${rest.trim()}*`, + }, + { + name: "shrug", + tier: "user", + description: "Append the shrug emoticon to a message.", + usage: "/shrug [text]", + serverSide: false, + rewrite: rest => `${rest.trim()} ¯\\_(ツ)_/¯`.trim(), + }, + { + name: "tableflip", + tier: "user", + description: "Flip a table.", + usage: "/tableflip [text]", + serverSide: false, + rewrite: rest => `${rest.trim()} (╯°□°)╯︵ ┻━┻`.trim(), + }, + { + name: "unflip", + tier: "user", + description: "Restore order.", + usage: "/unflip [text]", + serverSide: false, + rewrite: rest => `${rest.trim()} ┬─┬ノ( º _ ºノ)`.trim(), + }, +]; + +export const CLIENT_READS: ClientReadCommand[] = [ + { + name: "whois", + tier: "user", + description: "Look up a member of this server.", + usage: "/whois @user", + serverSide: false, + async run(rest, ctx) { + const tokens = splitArgs(rest); + const target = pickUser(tokens[0], ctx); + if (!target) return "whois: pass a member like /whois @handle"; + try { + const r = await apiFetch<{ display_name: string; handle: string; nickname?: string; is_owner: boolean }>( + `/users/${target}`, + ); + const tier = r.is_owner ? "Owner" : "Member"; + const nickPart = r.nickname ? ` (nick: ${r.nickname})` : ""; + return `${r.display_name} @${r.handle}${nickPart} — ${tier}`; + } catch { + return "whois: user not found"; + } + }, + }, + { + name: "help", + tier: "user", + description: "Show available slash commands.", + usage: "/help [command]", + serverSide: false, + async run(rest) { + const tokens = splitArgs(rest); + const all = [...COSMETIC, ...CLIENT_READS, ...SERVER_COMMANDS]; + if (tokens.length === 0) { + const groups: Record = { user: [], mod: [], admin: [], owner: [] }; + for (const c of all) { + groups[c.tier]!.push(`/${c.name}`); + } + return [ + "Slash commands:", + `Member: ${groups.user.join(", ")}`, + `Mod: ${groups.mod.join(", ") || "(none)"}`, + `Admin: ${groups.admin.join(", ") || "(none)"}`, + `Owner: ${groups.owner.join(", ") || "(none)"}`, + "Type / for usage.", + ].join("\n"); + } + const name = tokens[0]!.replace(/^\//, ""); + const c = all.find(x => x.name === name); + if (!c) return `help: no command named /${name}`; + return `${c.usage} — ${c.description}`; + }, + }, +]; + +export const SERVER_COMMANDS: ServerCommand[] = [ + { + name: "nick", + tier: "user", + description: "Set your nickname in this server.", + usage: "/nick [name]", + serverSide: true, + parse(rest) { + const trimmed = rest.trim(); + return { args: { nickname: trimmed.length === 0 ? null : trimmed } }; + }, + }, + { + name: "ban", + tier: "mod", + description: "Ban a member.", + usage: "/ban @user [reason]", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "ban: first argument must be a member" }; + const reason = tokens.slice(1).join(" ") || undefined; + return { args: { user_id: user, reason } }; + }, + }, + { + name: "unban", + tier: "mod", + description: "Lift a ban.", + usage: "/unban ", + serverSide: true, + parse(rest) { + const tokens = splitArgs(rest); + if (!tokens[0] || !/^\d+$/.test(tokens[0])) return { error: "unban: pass the user id" }; + return { args: { user_id: tokens[0] } }; + }, + }, + { + name: "kick", + tier: "mod", + description: "Kick a member.", + usage: "/kick @user [reason]", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "kick: first argument must be a member" }; + const reason = tokens.slice(1).join(" ") || undefined; + return { args: { user_id: user, reason } }; + }, + }, + { + name: "timeout", + tier: "mod", + description: "Timeout a member for a duration.", + usage: "/timeout @user [reason]", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "timeout: first argument must be a member" }; + const dur = parseDurationSecs(tokens[1]); + if (dur === undefined) return { error: "timeout: second argument must be a duration like 10m or 1h30m" }; + const reason = tokens.slice(2).join(" ") || undefined; + return { args: { user_id: user, duration_secs: dur, reason } }; + }, + }, + { + name: "untimeout", + tier: "mod", + description: "Clear a member's timeout.", + usage: "/untimeout @user", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "untimeout: first argument must be a member" }; + return { args: { user_id: user } }; + }, + }, + { + name: "purge", + tier: "mod", + description: "Bulk delete the most recent N messages, optionally from one user.", + usage: "/purge [@user]", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const n = tokens[0] ? parseInt(tokens[0], 10) : NaN; + if (!Number.isFinite(n) || n < 1 || n > 200) return { error: "purge: n must be 1..=200" }; + const user = tokens[1] ? pickUser(tokens[1], ctx) : undefined; + return { args: { count: n, user_id: user } }; + }, + }, + { + name: "slowmode", + tier: "mod", + description: "Set this channel's slowmode.", + usage: "/slowmode ", + serverSide: true, + parse(rest) { + const tokens = splitArgs(rest); + const s = tokens[0] ? parseDurationSecs(tokens[0]) : undefined; + if (s === undefined && tokens[0] !== "0") return { error: "slowmode: pass a duration or 0 to disable" }; + return { args: { seconds: s ?? 0 } }; + }, + }, + { + name: "lock", + tier: "mod", + description: "Lock this channel.", + usage: "/lock [reason]", + serverSide: true, + parse(rest) { + const reason = rest.trim() || undefined; + return { args: { reason } }; + }, + }, + { + name: "unlock", + tier: "mod", + description: "Unlock this channel.", + usage: "/unlock", + serverSide: true, + parse() { + return { args: {} }; + }, + }, + { + name: "role-assign", + tier: "admin", + description: "Assign a role to a member.", + usage: "/role-assign @user ", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "role-assign: first argument must be a member" }; + const role = tokens[1]; + if (!role || !/^\d+$/.test(role)) return { error: "role-assign: second argument must be a role id" }; + return { args: { user_id: user, role_id: role } }; + }, + }, + { + name: "role-unassign", + tier: "admin", + description: "Remove a role from a member.", + usage: "/role-unassign @user ", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "role-unassign: first argument must be a member" }; + const role = tokens[1]; + if (!role || !/^\d+$/.test(role)) return { error: "role-unassign: second argument must be a role id" }; + return { args: { user_id: user, role_id: role } }; + }, + }, + { + name: "announce", + tier: "admin", + description: "Post a server announcement in this channel.", + usage: "/announce ", + serverSide: true, + parse(rest) { + const text = rest.trim(); + if (text.length === 0) return { error: "announce: pass the text to broadcast" }; + return { args: { text } }; + }, + }, + { + name: "audit-search", + tier: "admin", + description: "Search recent audit events.", + usage: "/audit-search [event_type] [actor_user_id]", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const args: Record = { limit: 20 }; + if (tokens[0]) args.event_type = tokens[0]; + if (tokens[1]) { + const u = pickUser(tokens[1], ctx); + if (u) args.actor_user_id = u; + } + return { args }; + }, + }, + { + name: "server-rename", + tier: "admin", + description: "Rename this server.", + usage: "/server-rename ", + serverSide: true, + parse(rest) { + const name = rest.trim(); + if (name.length === 0 || name.length > 80) return { error: "server-rename: name must be 1-80 chars" }; + return { args: { name } }; + }, + }, + { + name: "transfer-owner", + tier: "owner", + description: "Transfer ownership of this server.", + usage: "/transfer-owner @user", + serverSide: true, + parse(rest, ctx) { + const tokens = splitArgs(rest); + const user = pickUser(tokens[0], ctx); + if (!user) return { error: "transfer-owner: first argument must be a member" }; + return { args: { user_id: user } }; + }, + }, + { + name: "server-delete", + tier: "owner", + description: "Delete this server. Requires confirm=true.", + usage: "/server-delete confirm", + serverSide: true, + parse(rest) { + if (rest.trim().toLowerCase() !== "confirm") { + return { error: "server-delete: type /server-delete confirm to actually delete" }; + } + return { args: { confirm: true } }; + }, + }, +]; + +export const ALL_COMMANDS: Command[] = [ + ...COSMETIC, + ...CLIENT_READS, + ...SERVER_COMMANDS, +]; + +export function lookup(name: string): Command | undefined { + return ALL_COMMANDS.find(c => c.name === name); +} + +// ---- entry points ---- + +export interface ParseResult { + /** Cosmetic command that produced text to send as a normal message. */ + rewriteToMessage?: string; + /** Server-side dispatch ready to send. */ + serverDispatch?: { name: string; args: Record }; + /** Client-only command that produces a system reply string. */ + clientRead?: () => Promise; + /** Parse error to surface to the user. */ + error?: string; +} + +const SLASH_RE = /^\/([a-z][a-z0-9-]*)\s*(.*)$/s; + +export function parseSlash(input: string, ctx: ParseCtx): ParseResult | undefined { + const m = SLASH_RE.exec(input.trim()); + if (!m) return undefined; + const name = m[1]!; + const rest = m[2] ?? ""; + const cmd = lookup(name); + if (!cmd) return { error: `Unknown command: /${name}. Type /help for the list.` }; + if (!cmd.serverSide) { + if ("rewrite" in cmd) { + return { rewriteToMessage: cmd.rewrite(rest) }; + } + if ("run" in cmd) { + const run = cmd.run; + return { clientRead: () => run(rest, ctx) }; + } + } else { + const r = cmd.parse(rest, ctx); + if ("error" in r) return { error: r.error }; + return { serverDispatch: { name: cmd.name, args: r.args } }; + } + return { error: `internal: command /${name} has no handler` }; +} + +export interface DispatchResult { + ok: boolean; + message: string; + data?: unknown; +} + +export async function dispatchCommand( + serverId: string, + channelId: string | undefined, + payload: { name: string; args: Record }, +): Promise { + return apiFetch(`/servers/${serverId}/commands`, { + method: "POST", + body: { ...payload, channel_id: channelId }, + }); +} + +export interface CatalogOut { + tier: Tier; + is_owner: boolean; + commands: Array<{ + name: string; + tier: Tier; + description: string; + usage: string; + allowed: boolean; + }>; +} + +export async function fetchCatalog(serverId: string): Promise { + return apiFetch(`/servers/${serverId}/commands`); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8ad6723..831f55e 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2113,3 +2113,76 @@ hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; } .profile-banner { height: 90px; } .pending-chip { max-width: 160px; } } + +/* ---- Command palette ---- */ + +.cmdp-backdrop { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; align-items: flex-start; justify-content: center; + padding: 12vh 16px 16px; + z-index: 80; +} +.cmdp { + width: min(560px, 100%); + background: var(--bg-elev2, #1a1d24); + border: 1px solid var(--border, #2a2f38); + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); + overflow: hidden; + display: flex; flex-direction: column; +} +.cmdp-input { + width: 100%; + background: transparent; + border: 0; + border-bottom: 1px solid var(--border, #2a2f38); + padding: 14px 16px; + font-size: 16px; + color: inherit; + outline: none; +} +.cmdp-list { + list-style: none; margin: 0; padding: 4px 0; + max-height: 50vh; overflow-y: auto; +} +.cmdp-empty { padding: 12px 16px; } +.cmdp-row { + padding: 8px 16px; + cursor: pointer; + display: flex; flex-direction: column; gap: 2px; + border-left: 3px solid transparent; +} +.cmdp-row.is-active { background: rgba(120, 140, 220, 0.12); border-left-color: var(--accent, #6f8aff); } +.cmdp-row.is-disabled { opacity: 0.45; cursor: not-allowed; } +.cmdp-row-head { display: flex; gap: 8px; align-items: baseline; } +.cmdp-name { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-weight: 600; } +.cmdp-tier { + font-size: 11px; padding: 1px 6px; border-radius: 999px; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 600; +} +.cmdp-tier-user { background: rgba(120, 140, 220, 0.15); color: #8da4ff; } +.cmdp-tier-mod { background: rgba(79, 143, 237, 0.18); color: #6fa9ff; } +.cmdp-tier-admin { background: rgba(230, 71, 71, 0.18); color: #ff8a8a; } +.cmdp-tier-owner { background: rgba(230, 142, 61, 0.18); color: #ffb070; } +.cmdp-usage { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; } +.cmdp-desc { font-size: 12px; } +.cmdp-foot { + display: flex; gap: 16px; padding: 8px 16px; + border-top: 1px solid var(--border, #2a2f38); +} + +/* ---- Command status banner above the composer ---- */ + +.cmd-status { + margin: 0 8px 6px; + padding: 8px 10px 8px 12px; + border-radius: 8px; + display: flex; align-items: flex-start; gap: 8px; + font-size: 13px; + cursor: pointer; +} +.cmd-status-ok { background: rgba(60, 160, 90, 0.14); border: 1px solid rgba(60, 160, 90, 0.35); color: #b6e7c4; } +.cmd-status-err { background: rgba(220, 70, 70, 0.14); border: 1px solid rgba(220, 70, 70, 0.35); color: #ffb3b3; } +.cmd-status-text { margin: 0; flex: 1; white-space: pre-wrap; word-break: break-word; font: inherit; color: inherit; } + diff --git a/apps/web/src/views/chat.tsx b/apps/web/src/views/chat.tsx index a692a0a..ef812e2 100644 --- a/apps/web/src/views/chat.tsx +++ b/apps/web/src/views/chat.tsx @@ -19,6 +19,8 @@ import { usePrefsStore } from "../store/prefs"; import { DmsView } from "./dms"; import { type CustomEmoji, emojiUrl } from "../lib/emojis"; import { EmojiPicker } from "../components/EmojiPicker"; +import { CommandPalette } from "../components/CommandPalette"; +import { dispatchCommand, parseSlash, type ParseCtx } from "../lib/commands"; interface Server { id: string; name: string; owner_id: string; icon_url: string | null; banner_url: string | null } type ChannelKind = "text" | "category" | "thread" | "announcement"; @@ -370,6 +372,7 @@ export function ChatShell() { c.id === activeChannel)?.name} + serverId={activeServer} memberMap={memberMap} roles={roleList} meId={me.data?.id} @@ -549,6 +552,7 @@ async function createServer(qc: ReturnType) { function ChannelView({ channelId, channelName, + serverId, memberMap, roles, meId, @@ -558,6 +562,7 @@ function ChannelView({ }: { channelId?: string; channelName?: string; + serverId?: string; memberMap: Map; roles: Role[]; meId?: string; @@ -584,6 +589,45 @@ function ChannelView({ const [reactPicker, setReactPicker] = useState<{ messageId: string; anchor: HTMLElement } | undefined>(); const reactAnchorRef = useRef(null); useEffect(() => { reactAnchorRef.current = reactPicker?.anchor ?? null; }, [reactPicker]); + const [paletteOpen, setPaletteOpen] = useState(false); + const [cmdStatus, setCmdStatus] = useState<{ ok: boolean; text: string } | undefined>(); + const cmdStatusTimer = useRef(undefined); + function flashCmdStatus(ok: boolean, text: string) { + setCmdStatus({ ok, text }); + if (cmdStatusTimer.current) window.clearTimeout(cmdStatusTimer.current); + cmdStatusTimer.current = window.setTimeout(() => setCmdStatus(undefined), 6000); + } + useEffect(() => () => { + if (cmdStatusTimer.current) window.clearTimeout(cmdStatusTimer.current); + }, []); + + // Cmd/Ctrl+K opens the command palette. Limited to when this view is + // focused so it doesn't clash with the browser's own bookmark menu in + // text fields outside the chat. + useEffect(() => { + function onKey(e: KeyboardEvent) { + const k = e.key.toLowerCase(); + if ((e.metaKey || e.ctrlKey) && k === "k") { + e.preventDefault(); + setPaletteOpen(true); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const cmdCtx: ParseCtx = useMemo(() => ({ + myUserId: meId ?? "", + resolveUserId(token: string) { + const t = token.replace(/^@/, "").toLowerCase(); + for (const m of memberMap.values()) { + if (m.handle.toLowerCase() === t) return m.user_id; + if (m.display_name.toLowerCase() === t) return m.user_id; + if (m.nickname && m.nickname.toLowerCase() === t) return m.user_id; + } + return undefined; + }, + }), [memberMap, meId]); async function pickFiles(files: FileList | null) { if (!files || files.length === 0) return; @@ -630,6 +674,70 @@ function ChannelView({ const content = draft; const attachments = pending.length > 0 ? pending : undefined; const replyToId = replyTo?.id; + + // Slash commands: take over before the regular send path. Cosmetic + // commands (/me, /shrug, ...) rewrite the content and continue as + // normal messages. Server-side commands (/ban, /kick, ...) hit the + // dispatcher and surface the result as a status banner. Client-only + // reads (/whois, /help) render their reply locally. + const trimmed = content.trim(); + if (trimmed.startsWith("/") && pending.length === 0) { + const parsed = parseSlash(trimmed, cmdCtx); + if (parsed) { + if (parsed.error) { + flashCmdStatus(false, parsed.error); + return; + } + if (parsed.clientRead) { + setDraft(""); + try { + const reply = await parsed.clientRead(); + flashCmdStatus(true, reply); + } catch (e) { + flashCmdStatus(false, errMsg(e, "command failed")); + } + return; + } + if (parsed.serverDispatch) { + if (!serverId) { + flashCmdStatus(false, "no server selected"); + return; + } + setDraft(""); + try { + const r = await dispatchCommand(serverId, channelId, parsed.serverDispatch); + flashCmdStatus(r.ok, r.message); + } catch (e) { + flashCmdStatus(false, errMsg(e, `/${parsed.serverDispatch.name} failed`)); + } + return; + } + if (parsed.rewriteToMessage !== undefined) { + // Fall through to the normal send path with the rewritten body. + setDraft(""); + setPending([]); + setReplyTo(undefined); + try { + const body: Record = { content: parsed.rewriteToMessage }; + if (replyToId) body.reply_to = replyToId; + const created = await apiFetch(`/channels/${channelId}/messages`, { + method: "POST", + body, + }); + qc.setQueryData(["messages", channelId], (prev) => { + if (!prev) return [created]; + if (prev.some(x => x.id === created.id)) return prev; + return [created, ...prev]; + }); + } catch (e) { + alert(errMsg(e, "send failed")); + setDraft(content); + } + return; + } + } + } + setDraft(""); setPending([]); setReplyTo(undefined); @@ -758,6 +866,17 @@ function ChannelView({ })}
+ {cmdStatus && ( +
setCmdStatus(undefined)} + > +
{cmdStatus.text}
+ +
+ )} {replyTo && (
Replying to {authorLabel(replyTo.author_id)} @@ -794,9 +913,18 @@ function ChannelView({ style={{ display: "none" }} onChange={e => void pickFiles(e.target.files)} /> - setDraft(e.target.value)} - onKeyDown={e => { if (e.key === "Enter") void send(); }} /> + onKeyDown={e => { + if (e.key === "Enter") void send(); + else if (e.key === "/" && draft.length === 0) { + // Lightweight discovery: opening the palette when + // the user types `/` into an empty composer makes + // commands findable without a separate keybind. + e.preventDefault(); + setPaletteOpen(true); + } + }} />
+ setPaletteOpen(false)} + onPick={(usage) => { + setDraft(usage + " "); + setPaletteOpen(false); + // Defer focus until after render so the input exists. + setTimeout(() => inputRef.current?.focus(), 0); + }} + /> setEmojiOpen(false)} diff --git a/crates/tempest-api/src/commands.rs b/crates/tempest-api/src/commands.rs new file mode 100644 index 0000000..5546d34 --- /dev/null +++ b/crates/tempest-api/src/commands.rs @@ -0,0 +1,1022 @@ +//! Slash command catalog and dispatcher. +//! +//! The catalog enumerates every server-side command. The dispatcher +//! resolves the actor's permission mask once, runs the per-command +//! access check, executes the action against tempest_db, publishes +//! gateway events when relevant, and writes audit rows. A single +//! `command.invoked` audit row is written for every dispatch (success +//! or denied) so the audit log carries a clean "who ran what slash +//! command" feed alongside the existing fine-grained events. + +use crate::error::ApiResult; +use crate::events::{publish, EventEnvelope}; +use crate::state::AppState; +use crate::middleware::perms::effective_mask; +use serde::{Deserialize, Serialize}; +use tempest_core::ids::Snowflake; +use tempest_core::Error; +use tempest_perms::{Permission, PermissionMask, Tier}; +use tempest_protocol::opcodes::{Event, MessageCreate, MessageDelete}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Access { + Permission(Permission), + AnyOf(&'static [Permission]), + Admin, + Owner, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct CommandSpec { + pub name: &'static str, + pub tier: Tier, + pub access: Access, + pub description: &'static str, + pub usage: &'static str, +} + +pub const CATALOG: &[CommandSpec] = &[ + CommandSpec { + name: "nick", + tier: Tier::User, + access: Access::Permission(Permission::ChangeOwnNickname), + description: "Set your nickname in this server.", + usage: "/nick [name]", + }, + CommandSpec { + name: "ban", + tier: Tier::Mod, + access: Access::Permission(Permission::BanMembers), + description: "Ban a member.", + usage: "/ban @user [reason]", + }, + CommandSpec { + name: "unban", + tier: Tier::Mod, + access: Access::Permission(Permission::BanMembers), + description: "Lift a ban.", + usage: "/unban ", + }, + CommandSpec { + name: "kick", + tier: Tier::Mod, + access: Access::Permission(Permission::KickMembers), + description: "Kick a member.", + usage: "/kick @user [reason]", + }, + CommandSpec { + name: "timeout", + tier: Tier::Mod, + access: Access::Permission(Permission::TimeoutMembers), + description: "Timeout a member for a duration.", + usage: "/timeout @user [reason]", + }, + CommandSpec { + name: "untimeout", + tier: Tier::Mod, + access: Access::Permission(Permission::TimeoutMembers), + description: "Clear a member's timeout.", + usage: "/untimeout @user", + }, + CommandSpec { + name: "purge", + tier: Tier::Mod, + access: Access::Permission(Permission::ManageMessages), + description: "Bulk delete the most recent N messages, optionally from one user.", + usage: "/purge [@user]", + }, + CommandSpec { + name: "slowmode", + tier: Tier::Mod, + access: Access::Permission(Permission::SetSlowmode), + description: "Set per-user send cooldown for this channel.", + usage: "/slowmode ", + }, + CommandSpec { + name: "lock", + tier: Tier::Mod, + access: Access::Permission(Permission::ManageChannels), + description: "Lock this channel so non-mods cannot send.", + usage: "/lock [reason]", + }, + CommandSpec { + name: "unlock", + tier: Tier::Mod, + access: Access::Permission(Permission::ManageChannels), + description: "Unlock this channel.", + usage: "/unlock", + }, + CommandSpec { + name: "role-assign", + tier: Tier::Admin, + access: Access::Permission(Permission::AssignRolesBelow), + description: "Assign a role to a member.", + usage: "/role-assign @user ", + }, + CommandSpec { + name: "role-unassign", + tier: Tier::Admin, + access: Access::Permission(Permission::AssignRolesBelow), + description: "Remove a role from a member.", + usage: "/role-unassign @user ", + }, + CommandSpec { + name: "announce", + tier: Tier::Admin, + access: Access::Admin, + description: "Post a server-wide announcement in this channel.", + usage: "/announce ", + }, + CommandSpec { + name: "audit-search", + tier: Tier::Admin, + access: Access::Permission(Permission::ViewAuditLog), + description: "Read the most recent audit events, optionally filtered.", + usage: "/audit-search [event_type] [actor_user_id]", + }, + CommandSpec { + name: "server-rename", + tier: Tier::Admin, + access: Access::Permission(Permission::ManageGuild), + description: "Rename this server.", + usage: "/server-rename ", + }, + CommandSpec { + name: "transfer-owner", + tier: Tier::Owner, + access: Access::Owner, + description: "Transfer ownership of this server.", + usage: "/transfer-owner @user", + }, + CommandSpec { + name: "server-delete", + tier: Tier::Owner, + access: Access::Owner, + description: "Permanently delete this server. Requires confirm=true.", + usage: "/server-delete confirm", + }, +]; + +pub fn lookup(name: &str) -> Option<&'static CommandSpec> { + CATALOG.iter().find(|c| c.name == name) +} + +#[derive(Debug, Deserialize)] +pub struct DispatchReq { + pub name: String, + pub args: serde_json::Value, + pub channel_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct DispatchResp { + pub ok: bool, + pub message: String, + #[serde(default, skip_serializing_if = "is_null")] + pub data: serde_json::Value, +} + +fn is_null(v: &serde_json::Value) -> bool { + v.is_null() +} + +fn check_access(access: Access, mask: PermissionMask, is_owner: bool) -> bool { + if is_owner { + return true; + } + match access { + Access::Permission(p) => mask.has(p), + Access::AnyOf(ps) => ps.iter().any(|p| mask.has(*p)), + Access::Admin => Tier::ADMIN_BITS.iter().any(|p| mask.has(*p)), + Access::Owner => false, + } +} + +/// Public re-export of the access check for the routes layer. +pub fn check_access_public(access: Access, mask: PermissionMask, is_owner: bool) -> bool { + check_access(access, mask, is_owner) +} + +pub async fn dispatch( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + req: DispatchReq, +) -> ApiResult { + let spec = lookup(&req.name).ok_or_else(|| { + Error::InvalidInput(format!("unknown command: {}", req.name)) + })?; + + if !tempest_db::members::is_member(&state.db, server_id, actor).await? { + record_invoked(state, server_id, actor, spec, false, "not_a_member").await; + return Err(Error::Forbidden("not a member".into()).into()); + } + + let scope = req + .channel_id + .or_else(|| { + // Pick any channel for permission resolution if the caller did + // not pass one. This mirrors what the audit list does. + None + }); + let scope_channel = match scope { + Some(c) => c, + None => first_channel_or_server(state, server_id).await?, + }; + let mask = effective_mask(state, actor, server_id, scope_channel, None).await?; + let is_owner = tempest_db::servers::is_owner(&state.db, server_id, actor).await?; + + if !check_access(spec.access, mask, is_owner) { + record_invoked(state, server_id, actor, spec, false, "permission_denied").await; + return Err(Error::Forbidden(format!("missing permission for /{}", spec.name)).into()); + } + + let outcome = match spec.name { + "nick" => cmd_nick(state, actor, server_id, &req.args).await, + "ban" => cmd_ban(state, actor, server_id, &req.args).await, + "unban" => cmd_unban(state, actor, server_id, &req.args).await, + "kick" => cmd_kick(state, actor, server_id, &req.args).await, + "timeout" => cmd_timeout(state, actor, server_id, &req.args).await, + "untimeout" => cmd_untimeout(state, actor, server_id, &req.args).await, + "purge" => cmd_purge(state, actor, server_id, scope_channel, &req.args).await, + "slowmode" => cmd_slowmode(state, actor, server_id, scope_channel, &req.args).await, + "lock" => cmd_lock(state, actor, server_id, scope_channel, true, &req.args).await, + "unlock" => cmd_lock(state, actor, server_id, scope_channel, false, &req.args).await, + "role-assign" => cmd_role_assign(state, actor, server_id, &req.args, true).await, + "role-unassign" => cmd_role_assign(state, actor, server_id, &req.args, false).await, + "announce" => cmd_announce(state, actor, server_id, scope_channel, &req.args).await, + "audit-search" => cmd_audit_search(state, actor, server_id, &req.args).await, + "server-rename" => cmd_server_rename(state, actor, server_id, &req.args).await, + "transfer-owner" => cmd_transfer_owner(state, actor, server_id, &req.args).await, + "server-delete" => cmd_server_delete(state, actor, server_id, &req.args).await, + _ => Err(Error::InvalidInput("command not implemented".into())), + }; + + let ok = outcome.is_ok(); + let denied_reason = match &outcome { + Ok(_) => None, + Err(e) => Some(e.to_string()), + }; + record_invoked( + state, + server_id, + actor, + spec, + ok, + denied_reason.as_deref().unwrap_or(""), + ) + .await; + + let resp = outcome?; + Ok(resp) +} + +async fn record_invoked( + state: &AppState, + server_id: Snowflake, + actor: Snowflake, + spec: &CommandSpec, + ok: bool, + note: &str, +) { + let meta = serde_json::json!({ + "name": spec.name, + "tier": spec.tier.name(), + "ok": ok, + "note": if note.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(note.to_string()) }, + }); + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "command.invoked", + Some("command"), + None, + None, + None, + Some(&meta), + ) + .await; +} + +async fn first_channel_or_server( + state: &AppState, + server_id: Snowflake, +) -> ApiResult { + let row: Option<(Snowflake,)> = sqlx::query_as( + r#"SELECT id FROM channels WHERE server_id = $1 ORDER BY position ASC, id ASC LIMIT 1"#, + ) + .bind(server_id) + .fetch_optional(&state.db) + .await + .map_err(Error::Database)?; + Ok(row.map(|(c,)| c).unwrap_or(server_id)) +} + +// ---- argument extractors ---- + +fn arg_str<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, Error> { + args.get(key) + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::InvalidInput(format!("arg `{key}` is required"))) +} + +fn arg_str_opt<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args.get(key).and_then(|v| v.as_str()) +} + +fn arg_id(args: &serde_json::Value, key: &str) -> Result { + arg_str(args, key)? + .parse::() + .map_err(|_| Error::InvalidInput(format!("arg `{key}` must be a snowflake"))) +} + +fn arg_id_opt(args: &serde_json::Value, key: &str) -> Option { + arg_str_opt(args, key).and_then(|s| s.parse::().ok()) +} + +fn arg_int(args: &serde_json::Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_i64()) + .ok_or_else(|| Error::InvalidInput(format!("arg `{key}` must be an integer"))) +} + +fn arg_bool(args: &serde_json::Value, key: &str) -> bool { + args.get(key).and_then(|v| v.as_bool()).unwrap_or(false) +} + +// ---- per-command implementations ---- + +async fn cmd_nick( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let nick = arg_str_opt(args, "nickname").map(|s| s.trim()); + let nick = nick.filter(|s| !s.is_empty()); + if let Some(n) = nick { + if n.len() > 32 { + return Err(Error::InvalidInput("nickname > 32 chars".into())); + } + } + let prior: Option<(Option,)> = sqlx::query_as( + r#"SELECT nickname FROM members WHERE server_id = $1 AND user_id = $2"#, + ) + .bind(server_id) + .bind(actor) + .fetch_optional(&state.db) + .await + .map_err(Error::Database)?; + tempest_db::members::set_nickname(&state.db, server_id, actor, nick).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.nickname_changed", + Some("user"), + Some(actor), + prior.map(|(n,)| serde_json::json!({ "nickname": n })).as_ref(), + Some(&serde_json::json!({ "nickname": nick })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: match nick { + Some(n) => format!("Nickname set to {n}."), + None => "Nickname cleared.".into(), + }, + data: serde_json::Value::Null, + }) +} + +async fn cmd_ban( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let target = arg_id(args, "user_id")?; + let reason = arg_str_opt(args, "reason").map(|s| s.to_string()); + if tempest_db::servers::is_owner(&state.db, server_id, target).await? { + return Err(Error::Forbidden("cannot ban owner".into())); + } + if target == actor { + return Err(Error::InvalidInput("cannot ban yourself".into())); + } + tempest_db::members::ban(&state.db, server_id, target, actor, reason.as_deref()).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.banned", + Some("user"), + Some(target), + None, + Some(&serde_json::json!({ "reason": reason })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Banned {}.", target.0), + data: serde_json::Value::Null, + }) +} + +async fn cmd_unban( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let target = arg_id(args, "user_id")?; + tempest_db::members::unban(&state.db, server_id, target).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.unbanned", + Some("user"), + Some(target), + None, + None, + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Unbanned {}.", target.0), + data: serde_json::Value::Null, + }) +} + +async fn cmd_kick( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let target = arg_id(args, "user_id")?; + let reason = arg_str_opt(args, "reason").map(|s| s.to_string()); + if tempest_db::servers::is_owner(&state.db, server_id, target).await? { + return Err(Error::Forbidden("cannot kick owner".into())); + } + if target == actor { + return Err(Error::InvalidInput("cannot kick yourself".into())); + } + tempest_db::members::remove(&state.db, server_id, target).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.kicked", + Some("user"), + Some(target), + None, + Some(&serde_json::json!({ "reason": reason })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Kicked {}.", target.0), + data: serde_json::Value::Null, + }) +} + +async fn cmd_timeout( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let target = arg_id(args, "user_id")?; + let duration_secs = arg_int(args, "duration_secs")?; + if duration_secs <= 0 || duration_secs > 60 * 60 * 24 * 28 { + return Err(Error::InvalidInput( + "duration_secs must be 1..=2419200 (28 days)".into(), + )); + } + let reason = arg_str_opt(args, "reason").map(|s| s.to_string()); + if tempest_db::servers::is_owner(&state.db, server_id, target).await? { + return Err(Error::Forbidden("cannot timeout owner".into())); + } + let until = time::OffsetDateTime::now_utc() + time::Duration::seconds(duration_secs); + tempest_db::members::set_timeout(&state.db, server_id, target, Some(until)).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.timeout_set", + Some("user"), + Some(target), + None, + Some(&serde_json::json!({ "until": until, "reason": reason })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Timed out {} until {}.", target.0, until), + data: serde_json::json!({ "until": until }), + }) +} + +async fn cmd_untimeout( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let target = arg_id(args, "user_id")?; + tempest_db::members::set_timeout(&state.db, server_id, target, None).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "member.timeout_cleared", + Some("user"), + Some(target), + None, + None, + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Cleared timeout for {}.", target.0), + data: serde_json::Value::Null, + }) +} + +async fn cmd_purge( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + channel_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let count = arg_int(args, "count")?; + if !(1..=200).contains(&count) { + return Err(Error::InvalidInput("count must be 1..=200".into())); + } + let target = arg_id_opt(args, "user_id"); + let deleted = tempest_db::messages::bulk_soft_delete(&state.db, channel_id, target, count).await?; + for id in &deleted { + publish( + &state.redis, + EventEnvelope::for_channel(channel_id, Event::MessageDelete(MessageDelete { + id: *id, + channel_id, + })), + ) + .await; + } + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "messages.bulk_deleted", + Some("channel"), + Some(channel_id), + None, + None, + Some(&serde_json::json!({ + "deleted": deleted.len(), + "filter_user_id": target, + })), + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Deleted {} messages.", deleted.len()), + data: serde_json::json!({ "deleted": deleted.len() }), + }) +} + +async fn cmd_slowmode( + state: &AppState, + actor: Snowflake, + _server_id: Snowflake, + channel_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let seconds = arg_int(args, "seconds")?; + if !(0..=21600).contains(&seconds) { + return Err(Error::InvalidInput("seconds must be 0..=21600 (6h)".into())); + } + let prior = tempest_db::channels::get(&state.db, channel_id) + .await? + .map(|c| c.slowmode_seconds); + tempest_db::channels::set_slowmode(&state.db, channel_id, seconds as i32).await?; + crate::audit_log::record( + state, + tempest_db::channels::get(&state.db, channel_id) + .await? + .map(|c| c.server_id), + Some(actor), + None, + "channel.slowmode_set", + Some("channel"), + Some(channel_id), + prior.map(|s| serde_json::json!({ "slowmode_seconds": s })).as_ref(), + Some(&serde_json::json!({ "slowmode_seconds": seconds })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: if seconds == 0 { + "Slowmode off.".into() + } else { + format!("Slowmode set to {seconds}s.") + }, + data: serde_json::Value::Null, + }) +} + +async fn cmd_lock( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + channel_id: Snowflake, + locked: bool, + args: &serde_json::Value, +) -> Result { + let reason = arg_str_opt(args, "reason").map(|s| s.to_string()); + tempest_db::channels::set_locked(&state.db, channel_id, locked, actor, reason.as_deref()).await?; + let event = if locked { "channel.locked" } else { "channel.unlocked" }; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + event, + Some("channel"), + Some(channel_id), + None, + None, + Some(&serde_json::json!({ "reason": reason })), + ) + .await; + Ok(DispatchResp { + ok: true, + message: if locked { "Channel locked.".into() } else { "Channel unlocked.".into() }, + data: serde_json::Value::Null, + }) +} + +async fn cmd_role_assign( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, + assign: bool, +) -> Result { + let target = arg_id(args, "user_id")?; + let role = arg_id(args, "role_id")?; + let role_belongs: Option<(bool,)> = sqlx::query_as( + r#"SELECT (server_id = $2) FROM roles WHERE id = $1"#, + ) + .bind(role) + .bind(server_id) + .fetch_optional(&state.db) + .await + .map_err(Error::Database)?; + if !role_belongs.map(|(b,)| b).unwrap_or(false) { + return Err(Error::NotFound("role not in this server".into())); + } + if assign { + sqlx::query( + r#"INSERT INTO member_roles (server_id, user_id, role_id, assigned_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (server_id, user_id, role_id) DO NOTHING"#, + ) + .bind(server_id) + .bind(target) + .bind(role) + .bind(actor) + .execute(&state.db) + .await + .map_err(Error::Database)?; + } else { + sqlx::query( + r#"DELETE FROM member_roles + WHERE server_id = $1 AND user_id = $2 AND role_id = $3"#, + ) + .bind(server_id) + .bind(target) + .bind(role) + .execute(&state.db) + .await + .map_err(Error::Database)?; + } + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + if assign { "role.assigned" } else { "role.unassigned" }, + Some("user"), + Some(target), + None, + Some(&serde_json::json!({ "role_id": role })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: if assign { + format!("Assigned role {} to {}.", role.0, target.0) + } else { + format!("Removed role {} from {}.", role.0, target.0) + }, + data: serde_json::Value::Null, + }) +} + +async fn cmd_announce( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + channel_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let text = arg_str(args, "text")?.trim().to_string(); + if text.is_empty() { + return Err(Error::InvalidInput("announce text is empty".into())); + } + if text.len() > 2000 { + return Err(Error::InvalidInput("announce text > 2000 chars".into())); + } + let id = state.snowflake.next(); + // flag bit 4 = SERVER_ANNOUNCEMENT (rendered with the megaphone style). + let body = format!("📣 **Announcement**\n{text}"); + let msg = sqlx::query_as::<_, tempest_core::model::Message>( + r#"INSERT INTO messages + (id, channel_id, author_id, content, flags) + VALUES ($1, $2, $3, $4, 4) + RETURNING id, channel_id, author_id, content, edited_at, deleted_at, + flags, reply_to, attachments_json, embeds_json"#, + ) + .bind(id) + .bind(channel_id) + .bind(actor) + .bind(&body) + .fetch_one(&state.db) + .await + .map_err(Error::Database)?; + publish( + &state.redis, + EventEnvelope::for_channel(channel_id, Event::MessageCreate(MessageCreate { + id: msg.id, + channel_id: msg.channel_id, + server_id: Some(server_id), + author_id: msg.author_id, + content: msg.content.clone(), + reply_to: msg.reply_to, + created_at: time::OffsetDateTime::from_unix_timestamp( + (msg.id.timestamp_ms() / 1000) as i64, + ).unwrap_or(time::OffsetDateTime::now_utc()), + flags: msg.flags, + attachments: msg.attachments_json.clone(), + })), + ) + .await; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "server.announcement", + Some("channel"), + Some(channel_id), + None, + None, + Some(&serde_json::json!({ "message_id": msg.id, "preview": text.chars().take(120).collect::() })), + ) + .await; + Ok(DispatchResp { + ok: true, + message: "Announcement posted.".into(), + data: serde_json::json!({ "message_id": msg.id }), + }) +} + +async fn cmd_audit_search( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let event_type = arg_str_opt(args, "event_type").map(|s| s.to_string()); + let actor_filter = arg_id_opt(args, "actor_user_id"); + let limit = arg_int(args, "limit").unwrap_or(20).clamp(1, 100); + + let rows: Vec = { + let raw = tempest_db::audit::list_for_server(&state.db, server_id, None, limit * 2).await?; + raw.into_iter() + .filter(|r| event_type.as_deref().map_or(true, |et| r.event_type == et)) + .filter(|r| actor_filter.map_or(true, |a| r.actor_user_id == Some(a))) + .take(limit as usize) + .map(|r| crate::routes::audit::AuditOut { + id: r.id, + server_id: r.server_id, + actor_user_id: r.actor_user_id, + actor_bot_id: r.actor_bot_id, + event_type: r.event_type, + target_kind: r.target_kind, + target_id: r.target_id, + before_json: r.before_json, + after_json: r.after_json, + metadata_json: r.metadata_json, + created_at: r.created_at, + actor_label: None, + target_label: None, + }) + .collect() + }; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "audit.queried", + None, + None, + None, + None, + Some(&serde_json::json!({ + "event_type": event_type, + "actor_user_id": actor_filter, + "limit": limit, + "matches": rows.len(), + })), + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Found {} audit row(s).", rows.len()), + data: serde_json::to_value(&rows).unwrap_or(serde_json::Value::Null), + }) +} + +async fn cmd_server_rename( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let name = arg_str(args, "name")?.trim().to_string(); + if name.is_empty() || name.len() > 80 { + return Err(Error::InvalidInput("name must be 1-80 chars".into())); + } + let prior = tempest_db::servers::get(&state.db, server_id) + .await? + .map(|s| s.name); + tempest_db::servers::rename(&state.db, server_id, &name).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "server.renamed", + Some("server"), + Some(server_id), + prior.map(|n| serde_json::json!({ "name": n })).as_ref(), + Some(&serde_json::json!({ "name": name })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Server renamed to {name}."), + data: serde_json::Value::Null, + }) +} + +async fn cmd_transfer_owner( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + let new_owner = arg_id(args, "user_id")?; + if new_owner == actor { + return Err(Error::InvalidInput("you are already the owner".into())); + } + if !tempest_db::members::is_member(&state.db, server_id, new_owner).await? { + return Err(Error::InvalidInput("target is not a member".into())); + } + let prev = tempest_db::servers::transfer_ownership(&state.db, server_id, new_owner).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "server.ownership_transferred", + Some("user"), + Some(new_owner), + Some(&serde_json::json!({ "owner_id": prev })), + Some(&serde_json::json!({ "owner_id": new_owner })), + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: format!("Ownership transferred to {}.", new_owner.0), + data: serde_json::Value::Null, + }) +} + +async fn cmd_server_delete( + state: &AppState, + actor: Snowflake, + server_id: Snowflake, + args: &serde_json::Value, +) -> Result { + if !arg_bool(args, "confirm") { + return Err(Error::InvalidInput( + "pass confirm=true to actually delete the server".into(), + )); + } + tempest_db::servers::delete(&state.db, server_id).await?; + crate::audit_log::record( + state, + Some(server_id), + Some(actor), + None, + "server.deleted", + Some("server"), + Some(server_id), + None, + None, + None, + ) + .await; + Ok(DispatchResp { + ok: true, + message: "Server deleted.".into(), + data: serde_json::Value::Null, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_names_are_unique() { + let mut names = std::collections::HashSet::new(); + for spec in CATALOG { + assert!(names.insert(spec.name), "duplicate command name: {}", spec.name); + } + } + + #[test] + fn owner_bypass_grants_all() { + for spec in CATALOG { + assert!(check_access(spec.access, PermissionMask::EMPTY, true)); + } + } + + #[test] + fn empty_mask_denies_non_owner() { + for spec in CATALOG { + assert!(!check_access(spec.access, PermissionMask::EMPTY, false)); + } + } + + #[test] + fn admin_access_accepts_administrator_or_manage_guild() { + let admin_only_specs: Vec<_> = CATALOG.iter().filter(|s| matches!(s.access, Access::Admin)).collect(); + assert!(!admin_only_specs.is_empty()); + for spec in admin_only_specs { + let m1 = PermissionMask::EMPTY.with(Permission::Administrator); + let m2 = PermissionMask::EMPTY.with(Permission::ManageGuild); + assert!(check_access(spec.access, m1, false)); + assert!(check_access(spec.access, m2, false)); + } + } + + #[test] + fn permission_specific_specs_check_their_bit() { + for spec in CATALOG { + if let Access::Permission(p) = spec.access { + let m = PermissionMask::EMPTY.with(p); + assert!(check_access(spec.access, m, false), "{}", spec.name); + } + } + } +} diff --git a/crates/tempest-api/src/lib.rs b/crates/tempest-api/src/lib.rs index 2393a41..f7412a8 100644 --- a/crates/tempest-api/src/lib.rs +++ b/crates/tempest-api/src/lib.rs @@ -1,4 +1,5 @@ pub mod audit_log; +pub mod commands; pub mod config; pub mod embeds; pub mod error; diff --git a/crates/tempest-api/src/main.rs b/crates/tempest-api/src/main.rs index a6131e6..7bfe6e6 100644 --- a/crates/tempest-api/src/main.rs +++ b/crates/tempest-api/src/main.rs @@ -102,6 +102,7 @@ async fn main() -> anyhow::Result<()> { .merge(routes::emojis::router()) .merge(routes::friends::router()) .merge(routes::audit::router()) + .merge(routes::commands::router()) .merge(routes::devices::router()) .merge(routes::passkeys::router()) .merge(routes::search::router()) diff --git a/crates/tempest-api/src/routes/commands.rs b/crates/tempest-api/src/routes/commands.rs new file mode 100644 index 0000000..2bc23c5 --- /dev/null +++ b/crates/tempest-api/src/routes/commands.rs @@ -0,0 +1,98 @@ +//! Slash command HTTP surface. +//! +//! GET /servers/:server_id/commands catalog visible to the caller +//! POST /servers/:server_id/commands run a command +//! +//! The catalog endpoint also reports the caller's tier (User / Mod / +//! Admin / Owner) so the client UI can group commands and gray out +//! anything the caller cannot run. The dispatch endpoint enforces the +//! same access checks server-side; the tier flag is purely cosmetic. + +use crate::commands::{dispatch, DispatchReq, DispatchResp, CATALOG}; +use crate::error::ApiResult; +use crate::middleware::AuthCtx; +use crate::middleware::perms::effective_mask; +use crate::state::AppState; +use axum::extract::{Extension, Path, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; +use tempest_core::ids::Snowflake; +use tempest_core::Error; +use tempest_perms::Tier; + +pub fn router() -> Router { + Router::new() + .route("/servers/:server_id/commands", get(catalog).post(invoke)) +} + +#[derive(Debug, Serialize)] +pub struct CommandView { + pub name: &'static str, + pub tier: &'static str, + pub description: &'static str, + pub usage: &'static str, + pub allowed: bool, +} + +#[derive(Debug, Serialize)] +pub struct CatalogOut { + pub tier: &'static str, + pub is_owner: bool, + pub commands: Vec, +} + +async fn catalog( + State(state): State, + Extension(ctx): Extension, + Path(server_id): Path, +) -> ApiResult> { + if !tempest_db::members::is_member(&state.db, server_id, ctx.user_id).await? { + return Err(Error::Forbidden("not a member".into()).into()); + } + let scope = first_channel_or_server(&state, server_id).await?; + let mask = effective_mask(&state, ctx.user_id, server_id, scope, None).await?; + let is_owner = tempest_db::servers::is_owner(&state.db, server_id, ctx.user_id).await?; + let tier = Tier::compute(mask, is_owner); + + let commands = CATALOG + .iter() + .map(|spec| CommandView { + name: spec.name, + tier: spec.tier.name(), + description: spec.description, + usage: spec.usage, + allowed: crate::commands::check_access_public(spec.access, mask, is_owner), + }) + .collect(); + + Ok(Json(CatalogOut { + tier: tier.name(), + is_owner, + commands, + })) +} + +async fn invoke( + State(state): State, + Extension(ctx): Extension, + Path(server_id): Path, + Json(req): Json, +) -> ApiResult> { + let resp = dispatch(&state, ctx.user_id, server_id, req).await?; + Ok(Json(resp)) +} + +async fn first_channel_or_server( + state: &AppState, + server_id: Snowflake, +) -> ApiResult { + let row: Option<(Snowflake,)> = sqlx::query_as( + r#"SELECT id FROM channels WHERE server_id = $1 ORDER BY position ASC, id ASC LIMIT 1"#, + ) + .bind(server_id) + .fetch_optional(&state.db) + .await + .map_err(Error::Database)?; + Ok(row.map(|(c,)| c).unwrap_or(server_id)) +} diff --git a/crates/tempest-api/src/routes/messages.rs b/crates/tempest-api/src/routes/messages.rs index 949f018..291bd55 100644 --- a/crates/tempest-api/src/routes/messages.rs +++ b/crates/tempest-api/src/routes/messages.rs @@ -114,6 +114,19 @@ async fn send( .await? .ok_or(Error::NotFound("channel".into()))?; require_permission(&state, ctx.user_id, ch.server_id, channel_id, None, Permission::SendMessages).await?; + if let Some(lock) = tempest_db::channels::lock_state(&state.db, channel_id).await? { + if lock.locked { + // Anyone with ManageChannels (the same bit needed to lock it) + // can still post in a locked channel; everyone else is told to + // wait. The client surfaces lock_reason from a separate read. + let mask = crate::middleware::perms::effective_mask( + &state, ctx.user_id, ch.server_id, channel_id, None, + ).await?; + if !mask.has(Permission::ManageChannels) { + return Err(Error::Forbidden("channel is locked".into()).into()); + } + } + } let has_attachments = match req.attachments.as_ref() { Some(serde_json::Value::Array(a)) => !a.is_empty(), _ => false, diff --git a/crates/tempest-api/src/routes/mod.rs b/crates/tempest-api/src/routes/mod.rs index 2a21c63..6a1c0d1 100644 --- a/crates/tempest-api/src/routes/mod.rs +++ b/crates/tempest-api/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod audit; pub mod bots; pub mod channels; +pub mod commands; pub mod devices; pub mod direct; pub mod dms; diff --git a/crates/tempest-db/src/channels.rs b/crates/tempest-db/src/channels.rs index de0386e..aac1c5b 100644 --- a/crates/tempest-db/src/channels.rs +++ b/crates/tempest-db/src/channels.rs @@ -74,6 +74,56 @@ pub async fn set_slowmode(db: &Db, id: Snowflake, seconds: i32) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct LockState { + pub locked: bool, + pub locked_by: Option, + pub locked_at: Option, + pub lock_reason: Option, +} + +pub async fn lock_state(db: &Db, id: Snowflake) -> Result> { + let row = sqlx::query_as::<_, LockState>( + r#"SELECT locked, locked_by, locked_at, lock_reason + FROM channels WHERE id = $1"#, + ) + .bind(id) + .fetch_optional(db) + .await?; + Ok(row) +} + +pub async fn set_locked( + db: &Db, + id: Snowflake, + locked: bool, + actor: Snowflake, + reason: Option<&str>, +) -> Result<()> { + if locked { + sqlx::query( + r#"UPDATE channels + SET locked = true, locked_by = $2, locked_at = now(), lock_reason = $3 + WHERE id = $1"#, + ) + .bind(id) + .bind(actor) + .bind(reason) + .execute(db) + .await?; + } else { + sqlx::query( + r#"UPDATE channels + SET locked = false, locked_by = NULL, locked_at = NULL, lock_reason = NULL + WHERE id = $1"#, + ) + .bind(id) + .execute(db) + .await?; + } + Ok(()) +} + pub async fn delete(db: &Db, id: Snowflake) -> Result<()> { sqlx::query(r#"DELETE FROM channels WHERE id = $1"#) .bind(id) diff --git a/crates/tempest-db/src/messages.rs b/crates/tempest-db/src/messages.rs index 83e9c66..cc8d8b2 100644 --- a/crates/tempest-db/src/messages.rs +++ b/crates/tempest-db/src/messages.rs @@ -144,6 +144,54 @@ pub async fn soft_delete(db: &Db, message_id: Snowflake) -> Result { Ok(res.rows_affected() == 1) } +/// Bulk soft-delete the most recent `limit` messages in `channel_id`, +/// optionally narrowed to one author. Returns the ids that were +/// actually tombstoned (skipping ones already deleted) so the caller +/// can publish gateway events. `limit` is clamped to 1..=200. +pub async fn bulk_soft_delete( + db: &Db, + channel_id: Snowflake, + author_id: Option, + limit: i64, +) -> Result> { + let limit = limit.clamp(1, 200); + let rows: Vec<(Snowflake,)> = if let Some(uid) = author_id { + sqlx::query_as( + r#"WITH targets AS ( + SELECT id FROM messages + WHERE channel_id = $1 AND author_id = $2 AND deleted_at IS NULL + ORDER BY id DESC LIMIT $3 + ) + UPDATE messages + SET deleted_at = now(), content = '' + WHERE id IN (SELECT id FROM targets) + RETURNING id"#, + ) + .bind(channel_id) + .bind(uid) + .bind(limit) + .fetch_all(db) + .await? + } else { + sqlx::query_as( + r#"WITH targets AS ( + SELECT id FROM messages + WHERE channel_id = $1 AND deleted_at IS NULL + ORDER BY id DESC LIMIT $2 + ) + UPDATE messages + SET deleted_at = now(), content = '' + WHERE id IN (SELECT id FROM targets) + RETURNING id"#, + ) + .bind(channel_id) + .bind(limit) + .fetch_all(db) + .await? + }; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + pub async fn add_reaction( db: &Db, message_id: Snowflake, diff --git a/crates/tempest-db/src/servers.rs b/crates/tempest-db/src/servers.rs index 09f4681..ea7dad4 100644 --- a/crates/tempest-db/src/servers.rs +++ b/crates/tempest-db/src/servers.rs @@ -230,3 +230,29 @@ pub async fn is_owner(db: &Db, server_id: Snowflake, user_id: Snowflake) -> Resu .await?; Ok(row.map(|(b,)| b).unwrap_or(false)) } + +/// Atomically transfer ownership of `server_id` from the current owner to +/// `new_owner`. The new owner must already be a member; the API layer is +/// expected to assign them the Owner role separately if it wants the +/// owner badge to render. Returns the previous owner id. +pub async fn transfer_ownership( + db: &Db, + server_id: Snowflake, + new_owner: Snowflake, +) -> Result { + let mut tx = db.begin().await?; + let prev: (Snowflake,) = sqlx::query_as( + r#"SELECT owner_id FROM servers + WHERE id = $1 AND deleted_at IS NULL FOR UPDATE"#, + ) + .bind(server_id) + .fetch_one(&mut *tx) + .await?; + sqlx::query(r#"UPDATE servers SET owner_id = $2 WHERE id = $1"#) + .bind(server_id) + .bind(new_owner) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(prev.0) +} diff --git a/crates/tempest-perms/src/lib.rs b/crates/tempest-perms/src/lib.rs index baa7182..4f97ab7 100644 --- a/crates/tempest-perms/src/lib.rs +++ b/crates/tempest-perms/src/lib.rs @@ -11,7 +11,9 @@ pub mod bits; pub mod mask; pub mod resolve; +pub mod tier; pub use bits::Permission; pub use mask::PermissionMask; pub use resolve::{Override, RoleGrant, ScopeOverrides, resolve}; +pub use tier::Tier; diff --git a/crates/tempest-perms/src/tier.rs b/crates/tempest-perms/src/tier.rs new file mode 100644 index 0000000..c24e895 --- /dev/null +++ b/crates/tempest-perms/src/tier.rs @@ -0,0 +1,143 @@ +//! Coarse access tiers (User / Mod / Admin / Owner) computed from the +//! 80-bit permission mask. Tiers are display + grouping labels; every +//! permission decision still goes through specific bit checks. Used by +//! the slash command catalog and the audit log to attribute who ran +//! what at which level. + +use crate::bits::Permission; +use crate::mask::PermissionMask; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Tier { + User, + Mod, + Admin, + Owner, +} + +impl Tier { + /// Bits that promote a member to Mod tier when any one is present in + /// the resolved mask. + pub const MOD_BITS: &'static [Permission] = &[ + Permission::KickMembers, + Permission::BanMembers, + Permission::TimeoutMembers, + Permission::ManageMessages, + Permission::ManageThreads, + ]; + + /// Bits that promote a member to Admin tier. Administrator alone is + /// enough; ManageGuild is also accepted because servers commonly + /// hand it out as a near-admin scope. + pub const ADMIN_BITS: &'static [Permission] = &[ + Permission::Administrator, + Permission::ManageGuild, + ]; + + pub fn compute(mask: PermissionMask, is_owner: bool) -> Self { + if is_owner { + return Tier::Owner; + } + if Self::ADMIN_BITS.iter().any(|p| mask.has(*p)) { + return Tier::Admin; + } + if Self::MOD_BITS.iter().any(|p| mask.has(*p)) { + return Tier::Mod; + } + Tier::User + } + + pub fn label(self) -> &'static str { + match self { + Tier::Owner => "Owner", + Tier::Admin => "Admin", + Tier::Mod => "Moderator", + Tier::User => "Member", + } + } + + pub fn name(self) -> &'static str { + match self { + Tier::Owner => "owner", + Tier::Admin => "admin", + Tier::Mod => "mod", + Tier::User => "user", + } + } + + /// Tier ordering for "actor must outrank target" checks. Owner > Admin + /// > Mod > User. Equal tiers do NOT satisfy outranking. + pub fn rank(self) -> u8 { + match self { + Tier::User => 0, + Tier::Mod => 1, + Tier::Admin => 2, + Tier::Owner => 3, + } + } + + pub fn outranks(self, other: Tier) -> bool { + self.rank() > other.rank() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mask_of(perms: &[Permission]) -> PermissionMask { + let mut m = PermissionMask::EMPTY; + for p in perms { + m = m.with(*p); + } + m + } + + #[test] + fn owner_flag_wins_even_with_no_bits() { + assert_eq!(Tier::compute(PermissionMask::EMPTY, true), Tier::Owner); + } + + #[test] + fn admin_bit_promotes_to_admin() { + let m = mask_of(&[Permission::Administrator]); + assert_eq!(Tier::compute(m, false), Tier::Admin); + } + + #[test] + fn manage_guild_promotes_to_admin() { + let m = mask_of(&[Permission::ManageGuild]); + assert_eq!(Tier::compute(m, false), Tier::Admin); + } + + #[test] + fn mod_bits_promote_to_mod() { + for p in Tier::MOD_BITS { + let m = mask_of(&[*p]); + assert_eq!(Tier::compute(m, false), Tier::Mod, "bit {:?}", p); + } + } + + #[test] + fn admin_bits_outrank_mod_bits() { + let m = mask_of(&[Permission::Administrator, Permission::BanMembers]); + assert_eq!(Tier::compute(m, false), Tier::Admin); + } + + #[test] + fn plain_member_is_user_tier() { + let m = mask_of(&[Permission::ViewChannel, Permission::SendMessages]); + assert_eq!(Tier::compute(m, false), Tier::User); + } + + #[test] + fn ranks_are_strictly_ordered() { + assert!(Tier::Owner.outranks(Tier::Admin)); + assert!(Tier::Admin.outranks(Tier::Mod)); + assert!(Tier::Mod.outranks(Tier::User)); + assert!(!Tier::Mod.outranks(Tier::Mod)); + assert!(!Tier::User.outranks(Tier::Mod)); + } +} diff --git a/migrations/0012_channel_locks.sql b/migrations/0012_channel_locks.sql new file mode 100644 index 0000000..bd4f7e5 --- /dev/null +++ b/migrations/0012_channel_locks.sql @@ -0,0 +1,11 @@ +-- Channel-level lock state for the /lock and /unlock slash commands. +-- A locked channel rejects new messages from anyone without +-- ManageChannels (the same bit that can lock it). Distinct from a +-- read-only channel: locking is reversible by any mod and is logged +-- as a discrete audit event with a reason. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS locked_by bigint DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS locked_at timestamptz DEFAULT NULL, + ADD COLUMN IF NOT EXISTS lock_reason text DEFAULT NULL;