- /discord/connected: Discord's OAuth redirect target. Stashes code/state
in sessionStorage, strips them from the URL (replaceState), no-referrer
(metadata + response header), sends a signed-out user to
/login?next=/discord/connected and resumes, access_denied = "Nothing
changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
finding 2). Also needed for signed-in users: drb_session is
SameSite=Strict, so it is not sent on the cross-site redirect back from
discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
picker (automatic / the bot for a system / the org's own bots); the
/drb link code moves to a collapsed secondary option and opens itself
when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
/tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
navigates to a discord.com /oauth2/authorize URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
// Discord radio channels + radio-bot pool — shapes returned by c2-core
|
|
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
|
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
|
|
|
export interface RadioBotLease {
|
|
node_id: string;
|
|
lease_id?: string;
|
|
since?: string;
|
|
streams?: string[];
|
|
releasing?: boolean;
|
|
}
|
|
|
|
/** One bot_tokens doc as GET /tokens returns it — token string masked server-side. */
|
|
export interface RadioBot {
|
|
token_id: string;
|
|
name: string;
|
|
token: string;
|
|
org_id?: string | null; // null = platform pool, else that org's BYO bot
|
|
valid?: boolean | null; // null/undefined = never checked (Discord unreachable)
|
|
guild_ids?: string[] | null; // null = membership unknown
|
|
check_error?: string | null;
|
|
validated_at?: string | null;
|
|
application_id?: string | null;
|
|
bot_name?: string | null;
|
|
invite_url?: string | null;
|
|
lease?: RadioBotLease | null;
|
|
in_use?: boolean;
|
|
assigned_node_id?: string | null;
|
|
preferred_for_system_id?: string | null;
|
|
/** "Connect a Discord server" is on for this bot: its OAuth client secret is
|
|
* set server-side (server-26#225 S6). The secret itself is never sent. */
|
|
oauth_enabled?: boolean | null;
|
|
}
|
|
|
|
export interface RadioBotAddResult {
|
|
token_id: string;
|
|
name: string;
|
|
valid: boolean | null;
|
|
guild_ids: string[] | null;
|
|
check_error: string | null;
|
|
invite_url: string | null;
|
|
}
|
|
|
|
export interface DiscordStreamActual {
|
|
state?: string;
|
|
error?: string | null;
|
|
/** The node's reason code on error: login_failed, connect_failed, not_in_guild, bad_channel, no_audio. */
|
|
code?: string | null;
|
|
since?: string | null;
|
|
at?: string | null;
|
|
}
|
|
|
|
export interface DiscordStream {
|
|
stream_id: string;
|
|
guild_id: string;
|
|
channel_id: string;
|
|
channel_name?: string | null;
|
|
guild_name?: string | null;
|
|
org_id?: string | null;
|
|
system_id?: string;
|
|
system_name?: string | null;
|
|
mode?: "persistent" | "adhoc";
|
|
desired?: "on" | "off";
|
|
node_id?: string | null;
|
|
node_name?: string | null;
|
|
node_status?: string | null;
|
|
token_id?: string | null;
|
|
token_name?: string | null;
|
|
actual?: DiscordStreamActual | null;
|
|
stop_reason?: string | null;
|
|
updated_at?: string | null;
|
|
created_at?: string | null;
|
|
/** POST /discord/streams: this system was already on in that channel — nothing was replaced. */
|
|
already_on?: boolean;
|
|
}
|
|
|
|
export interface DiscordGuild {
|
|
guild_id: string;
|
|
guild_name: string | null;
|
|
linked: boolean;
|
|
org_id: string | null;
|
|
linked_at: string | null;
|
|
acting_org_id: string | null;
|
|
acting_org_name: string | null;
|
|
live_streams: number;
|
|
}
|
|
|
|
export interface DiscordAdminOverview {
|
|
guilds: DiscordGuild[];
|
|
streams: DiscordStream[];
|
|
orgs: { org_id: string; name: string }[];
|
|
node_names: Record<string, string>;
|
|
systems: { system_id: string; name: string | null; org_id: string | null }[];
|
|
limits: {
|
|
guild_max_streams: number;
|
|
node_max_streams: number;
|
|
unlinked_guild_org_id: string | null;
|
|
unlinked_guild_org_name: string | null;
|
|
/** Connect stops picking a DRB pool bot at this many servers (owner ruling Q3). */
|
|
pool_bot_guild_cap?: number;
|
|
};
|
|
}
|
|
|
|
/** Discord's hard limit for an unverified bot. */
|
|
export const DISCORD_UNVERIFIED_GUILD_LIMIT = 100;
|
|
/** settings.discord_pool_bot_guild_cap's default — used when the overview doesn't send it. */
|
|
export const POOL_BOT_GUILD_CAP = 90;
|
|
|
|
/** A desired-on channel that has not reached "connected" for this long turns red. */
|
|
export const STUCK_AFTER_MS = 2 * 60 * 1000;
|
|
|
|
export type Health = "ok" | "warn" | "bad" | "off";
|
|
|
|
export function streamHealth(s: DiscordStream, now = Date.now()): { health: Health; label: string } {
|
|
const state = s.actual?.state ?? "pending";
|
|
if (s.desired !== "on") {
|
|
return { health: "off", label: state === "stopping" ? "off → stopping" : "off" };
|
|
}
|
|
if (state === "connected") return { health: "ok", label: "on → connected" };
|
|
if (state === "error" || state === "node_offline") return { health: "bad", label: `on → ${state.replace("_", " ")}` };
|
|
const since = s.actual?.since ? Date.parse(s.actual.since) : NaN;
|
|
if (!Number.isNaN(since) && now - since > STUCK_AFTER_MS) {
|
|
const mins = Math.round((now - since) / 60000);
|
|
return { health: "bad", label: `on → ${state} for ${mins} min` };
|
|
}
|
|
return { health: "warn", label: `on → ${state.replace("_", " ")}` };
|
|
}
|
|
|
|
/** The pool bot's invite link pinned to one server (same params c2-core's invite_url(app, guild) adds). */
|
|
export function inviteForGuild(inviteUrl: string | null | undefined, guildId: string): string | null {
|
|
if (!inviteUrl) return null;
|
|
return `${inviteUrl}&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true`;
|
|
}
|
|
|
|
export function guildLabel(g: { guild_id: string; guild_name?: string | null }): string {
|
|
return g.guild_name || `server ${shortId(g.guild_id)}`;
|
|
}
|
|
|
|
/** 1152…4402 — enough to tell snowflakes apart without a 19-digit column. */
|
|
export function shortId(id: string | null | undefined): string {
|
|
if (!id) return "—";
|
|
return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id;
|
|
}
|
|
|
|
export const SNOWFLAKE = /^\d{15,21}$/;
|
|
|
|
/**
|
|
* c2api's request() throws `C2 API error <status>: <body>`. Discord routes
|
|
* answer with `{detail, code, invite: [{name, invite_url}]}` — pull those out
|
|
* so "no bot in this server" can render its invite links.
|
|
*/
|
|
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
|
|
const raw = err instanceof Error ? err.message : String(err);
|
|
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
|
if (m) {
|
|
try {
|
|
const body = JSON.parse(m[2]);
|
|
const detail = typeof body?.detail === "string" ? body.detail : Array.isArray(body?.detail) ? body.detail.map((d: { msg?: string }) => d.msg).join("; ") : null;
|
|
return { message: detail || raw, invite: Array.isArray(body?.invite) ? body.invite : [] };
|
|
} catch {
|
|
return { message: m[2] || raw, invite: [] };
|
|
}
|
|
}
|
|
return { message: raw, invite: [] };
|
|
}
|
|
|
|
export function fmtWhen(iso: string | null | undefined): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "—";
|
|
const sameDay = d.toDateString() === new Date().toDateString();
|
|
return sameDay
|
|
? d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
|
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
}
|