Files
Dirigent/plugin/web/control-page.ts
hzhang b9cbb7e895 fix: change control page routes from gateway to plugin auth
auth: "gateway" requires Bearer token in Authorization header,
which browser direct navigation never sends (no session cookies).
auth: "plugin" allows unauthenticated access on loopback, which
is sufficient since gateway is bound to 127.0.0.1 only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 01:15:29 +01:00

295 lines
13 KiB
TypeScript

import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js";
import { scanPaddedCell } from "../core/padded-cell.js";
import path from "node:path";
import os from "node:os";
const SWITCHABLE_MODES: ChannelMode[] = ["none", "chat", "report"];
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
}
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function modeBadge(mode: ChannelMode): string {
const colors: Record<ChannelMode, string> = {
none: "#888", chat: "#5865f2", report: "#57f287",
work: "#fee75c", discussion: "#eb459e",
};
return `<span style="background:${colors[mode]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">${escapeHtml(mode)}</span>`;
}
function buildPage(content: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dirigent</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:24px}
h1{font-size:1.6rem;margin-bottom:4px;color:#fff}
.subtitle{color:#888;font-size:0.85rem;margin-bottom:24px}
h2{font-size:1.1rem;margin:24px 0 12px;color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}
table{width:100%;border-collapse:collapse;margin-bottom:16px;font-size:0.9rem}
th{text-align:left;padding:8px 12px;background:#252540;color:#aaa;font-weight:600;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
td{padding:8px 12px;border-top:1px solid #2a2a4a}
tr:hover td{background:#1e1e3a}
input,select{background:#252540;border:1px solid #444;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:0.9rem}
input:focus,select:focus{outline:none;border-color:#5865f2}
button{background:#5865f2;color:#fff;border:none;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}
button:hover{background:#4752c4}
button.danger{background:#ed4245}
button.danger:hover{background:#c03537}
button.secondary{background:#36393f}
button.secondary:hover{background:#2f3136}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.guild-section{background:#16213e;border:1px solid #2a2a4a;border-radius:8px;margin-bottom:16px;overflow:hidden}
.guild-header{padding:12px 16px;background:#1f2d50;display:flex;align-items:center;gap:10px;font-weight:600}
.guild-name{font-size:1rem;color:#fff}
.guild-id{font-size:0.75rem;color:#888;font-family:monospace}
.msg{padding:8px 12px;border-radius:4px;margin:8px 0;font-size:0.85rem}
.msg.ok{background:#1a4a2a;border:1px solid #2d7a3a;color:#57f287}
.msg.err{background:#4a1a1a;border:1px solid #7a2d2d;color:#ed4245}
.spinner{display:none}
</style>
</head>
<body>
<h1>Dirigent</h1>
<p class="subtitle">OpenClaw multi-agent turn management</p>
${content}
<script>
async function apiCall(endpoint, method, body) {
const resp = await fetch(endpoint, {
method: method || 'GET',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
return resp.json();
}
function showMsg(el, text, isErr) {
el.className = 'msg ' + (isErr ? 'err' : 'ok');
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
</script>
</body>
</html>`;
}
export function registerControlPage(deps: {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
openclawDir: string;
hasPaddedCell: () => boolean;
}): void {
const { api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell } = deps;
// ── Main page ──────────────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent",
auth: "plugin",
match: "exact",
handler: async (_req, res) => {
const entries = identityRegistry.list();
const paddedCellBtn = hasPaddedCell()
? `<button class="secondary" onclick="rescanPaddedCell()">Re-scan padded-cell</button>`
: "";
// Build identity table rows
const identityRows = entries.map((e) => html`
<tr data-agent-id="${escapeHtml(e.agentId)}">
<td><code>${escapeHtml(e.discordUserId)}</code></td>
<td>${escapeHtml(e.agentId)}</td>
<td>${escapeHtml(e.agentName)}</td>
<td><button class="danger" onclick="removeIdentity('${escapeHtml(e.agentId)}')">Remove</button></td>
</tr>`).join("");
// Build guild sections
let guildHtml = "<p style='color:#888'>Loading guilds…</p>";
if (moderatorBotToken) {
try {
const guilds = await fetchAdminGuilds(moderatorBotToken);
if (guilds.length === 0) {
guildHtml = "<p style='color:#888'>No guilds with admin permissions found.</p>";
} else {
guildHtml = "";
for (const guild of guilds) {
const channels = await fetchGuildChannels(moderatorBotToken, guild.id);
const channelRows = channels.map((ch) => {
const mode = channelStore.getMode(ch.id);
const locked = LOCKED_MODES.has(mode);
const dropdown = locked
? modeBadge(mode)
: `<select onchange="setMode('${escapeHtml(ch.id)}', this.value)">
${SWITCHABLE_MODES.map((m) => `<option value="${m}"${m === mode ? " selected" : ""}>${m}</option>`).join("")}
</select>`;
return html`<tr>
<td><code style="font-size:0.8rem">${escapeHtml(ch.id)}</code></td>
<td>#${escapeHtml(ch.name)}</td>
<td>${dropdown}</td>
</tr>`;
}).join("");
guildHtml += html`
<div class="guild-section">
<div class="guild-header">
<span class="guild-name">${escapeHtml(guild.name)}</span>
<span class="guild-id">${escapeHtml(guild.id)}</span>
</div>
<table>
<thead><tr><th>Channel ID</th><th>Name</th><th>Mode</th></tr></thead>
<tbody>${channelRows}</tbody>
</table>
</div>`;
}
}
} catch (err) {
guildHtml = `<p style="color:#ed4245">Failed to load guilds: ${escapeHtml(String(err))}</p>`;
}
} else {
guildHtml = "<p style='color:#888'>moderatorBotToken not configured — cannot list guilds.</p>";
}
const content = html`
<h2>Identity Registry</h2>
<div id="identity-msg" class="msg" style="display:none"></div>
<table>
<thead><tr><th>Discord User ID</th><th>Agent ID</th><th>Agent Name</th><th></th></tr></thead>
<tbody id="identity-tbody">${identityRows}</tbody>
</table>
<div class="row">
<input id="new-discord-id" placeholder="Discord user ID" style="width:200px">
<input id="new-agent-id" placeholder="Agent ID" style="width:160px">
<input id="new-agent-name" placeholder="Agent name (optional)" style="width:180px">
<button onclick="addIdentity()">Add</button>
${paddedCellBtn}
</div>
<h2>Guild &amp; Channel Configuration</h2>
<div id="channel-msg" class="msg" style="display:none"></div>
${guildHtml}
<script>
async function addIdentity() {
const discordUserId = document.getElementById('new-discord-id').value.trim();
const agentId = document.getElementById('new-agent-id').value.trim();
const agentName = document.getElementById('new-agent-name').value.trim();
if (!discordUserId || !agentId) return alert('Discord user ID and Agent ID are required');
const r = await apiCall('/dirigent/api/identity', 'POST', { discordUserId, agentId, agentName: agentName || agentId });
showMsg(document.getElementById('identity-msg'), r.ok ? 'Added.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function removeIdentity(agentId) {
if (!confirm('Remove identity for ' + agentId + '?')) return;
const r = await apiCall('/dirigent/api/identity/' + encodeURIComponent(agentId), 'DELETE');
showMsg(document.getElementById('identity-msg'), r.ok ? 'Removed.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function setMode(channelId, mode) {
const r = await apiCall('/dirigent/api/channel-mode', 'POST', { channelId, mode });
showMsg(document.getElementById('channel-msg'), r.ok ? 'Mode updated.' : r.error, !r.ok);
}
async function rescanPaddedCell() {
const r = await apiCall('/dirigent/api/rescan-padded-cell', 'POST');
showMsg(document.getElementById('identity-msg'), r.ok ? ('Scanned: ' + r.count + ' entries.') : r.error, !r.ok);
if (r.ok) location.reload();
}
</script>`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPage(content));
},
});
// ── API: add identity ──────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { discordUserId, agentId, agentName } = JSON.parse(body);
if (!discordUserId || !agentId) throw new Error("discordUserId and agentId required");
identityRegistry.upsert({ discordUserId, agentId, agentName: agentName ?? agentId });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: remove identity ───────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity/",
auth: "plugin",
match: "prefix",
handler: (req, res) => {
if (req.method !== "DELETE") { res.writeHead(405); res.end(); return; }
const agentId = decodeURIComponent((req.url ?? "").replace("/dirigent/api/identity/", ""));
const removed = identityRegistry.remove(agentId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: removed, error: removed ? undefined : "Not found" }));
},
});
// ── API: set channel mode ──────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/channel-mode",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { channelId, mode } = JSON.parse(body) as { channelId: string; mode: ChannelMode };
if (!channelId || !mode) throw new Error("channelId and mode required");
if (LOCKED_MODES.has(mode)) throw new Error(`Mode "${mode}" is locked to creation tools`);
channelStore.setMode(channelId, mode);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: rescan padded-cell ────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/rescan-padded-cell",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: count >= 0, count, error: count < 0 ? "padded-cell not detected" : undefined }));
},
});
}