"use client";
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import {
DISCORD_UNVERIFIED_GUILD_LIMIT,
POOL_BOT_GUILD_CAP,
type DiscordAdminOverview,
type RadioBot,
type RadioBotAddResult,
fmtWhen,
guildLabel,
inviteForGuild,
parseC2Error,
shortId,
} from "@/lib/discord";
import { Button } from "@/components/ui/Button";
import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth";
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord
// admin page's "Radio bots" tab and by /tokens. Every action here is a real
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh,
// DELETE /tokens/{id}, POST /tokens/flush, PUT /tokens/{id}/oauth.
function Checks({ t }: { t: RadioBot }) {
if (t.valid === false) {
return (
Token rejected by Discord
{t.check_error &&
{t.check_error}
}
);
}
if (t.valid) {
return (
Token valid
checked {fmtWhen(t.validated_at)}
{t.check_error &&
last check failed: {t.check_error}
}
);
}
return (
Not checked yet
{t.check_error &&
{t.check_error}
}
);
}
/** "N of 100 servers" — Discord caps an unverified bot at 100; Connect stops
* picking a pool bot at the cap (owner ruling Q3), so a full one needs a sibling. */
function ServerCount({ t, cap }: { t: RadioBot; cap: number }) {
if (t.guild_ids == null) return null;
const n = t.guild_ids.length;
const limit = t.server_cap ?? cap;
const full = t.org_id == null && n >= limit;
return (
= DISCORD_UNVERIFIED_GUILD_LIMIT ? "text-sev-moderate" : "text-ink-muted"}`}>
{n} of {DISCORD_UNVERIFIED_GUILD_LIMIT} servers
{full && ` · full at ${limit}: Connect skips it, add another pool bot`}
);
}
/**
* The bot app's OAuth client secret, which turns on "Connect a Discord server"
* for it (server-26#225 S6/S7, owner ruling Q1: per-bot OAuth). Write-only:
* c2-core checks it with Discord and stores it server-side; this form never
* reads it back, and the field is cleared as soon as it is sent.
*/
function ConnectSetting({ t, onChanged }: { t: RadioBot; onChanged: () => Promise | void }) {
const [open, setOpen] = useState(false);
const [secret, setSecret] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const on = !!t.oauth_enabled;
async function save(e: React.FormEvent) {
e.preventDefault();
const value = secret.trim();
setSecret("");
if (!value) return;
setSaving(true);
setError(null);
try {
await setBotOAuthSecret(t.token_id, value);
setOpen(false);
await onChanged();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
async function turnOff() {
if (!confirm(`Turn off Connect for ${t.name}? Its stored client secret is deleted. Plain invite links keep working.`)) return;
setSaving(true);
setError(null);
try {
await clearBotOAuthSecret(t.token_id);
await onChanged();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
);
}
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
if (t.guild_ids == null) return unknown — refresh;
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g]));
const members = t.guild_ids;
// Servers this bot could serve (its own org's for a BYO bot, every one for the pool) but is not in.
const missing = (overview?.guilds ?? []).filter(
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id),
);
const cap = overview?.limits.pool_bot_guild_cap ?? POOL_BOT_GUILD_CAP;
if (!members.length && !missing.length) return no servers;
return (
Added {added.name}
{added.valid ? ` · token valid · in ${added.guild_ids?.length ?? 0} server(s)` : " · Discord could not be reached, membership unknown"}
{added.check_error ? ` (${added.check_error})` : ""}.
{added.invite_url && (
Invite it to a server
)}
)}
{error &&
{error}
}
{bots.length === 0 ? (
No radio bots yet. Add one to let nodes carry radio into Discord voice channels.