feat: split dirigent_tools + human @mention override #14
7
Makefile
7
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control
|
.PHONY: check check-rules test-api up down smoke render-config package-plugin
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cd plugin && npm run check
|
cd plugin && npm run check
|
||||||
@@ -24,8 +24,3 @@ render-config:
|
|||||||
package-plugin:
|
package-plugin:
|
||||||
node scripts/package-plugin.mjs
|
node scripts/package-plugin.mjs
|
||||||
|
|
||||||
discord-control-up:
|
|
||||||
cd discord-control-api && node server.mjs
|
|
||||||
|
|
||||||
smoke-discord-control:
|
|
||||||
./scripts/smoke-discord-control.sh
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Dirigent adds deterministic logic **before model selection** and **turn-based sp
|
|||||||
|
|
||||||
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
|
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
|
||||||
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
|
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
|
||||||
- `discord-control-api/` — Discord admin extension API (private channels + member list)
|
- Discord admin actions are now handled in-plugin via direct Discord REST API calls (no sidecar service)
|
||||||
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
|
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
|
||||||
- `scripts/` — smoke/dev/helper checks
|
- `scripts/` — smoke/dev/helper checks
|
||||||
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
|
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dirigent-discord-control-api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.mjs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 8790);
|
|
||||||
const authToken = process.env.AUTH_TOKEN || "";
|
|
||||||
const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true";
|
|
||||||
const discordToken = process.env.DISCORD_BOT_TOKEN || "";
|
|
||||||
const discordBase = "https://discord.com/api/v10";
|
|
||||||
|
|
||||||
const enabledActions = {
|
|
||||||
channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false",
|
|
||||||
channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false",
|
|
||||||
memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false",
|
|
||||||
};
|
|
||||||
|
|
||||||
const allowedGuildIds = new Set(
|
|
||||||
String(process.env.ALLOWED_GUILD_IDS || "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allowedCallerIds = new Set(
|
|
||||||
String(process.env.ALLOWED_CALLER_IDS || "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
|
|
||||||
const BIT_VIEW_CHANNEL = 1024n;
|
|
||||||
const BIT_SEND_MESSAGES = 2048n;
|
|
||||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
|
||||||
|
|
||||||
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
|
|
||||||
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
|
|
||||||
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
|
|
||||||
|
|
||||||
function sendJson(res, status, payload) {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(status, code, message, details) {
|
|
||||||
return { status, code, message, details };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCallerId(req) {
|
|
||||||
const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"];
|
|
||||||
return typeof v === "string" ? v.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureControlAuth(req) {
|
|
||||||
if (requireAuthToken && !authToken) {
|
|
||||||
throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty");
|
|
||||||
}
|
|
||||||
if (!authToken) return;
|
|
||||||
const header = req.headers.authorization || "";
|
|
||||||
if (header !== `Bearer ${authToken}`) {
|
|
||||||
throw fail(401, "unauthorized", "invalid or missing bearer token");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowedCallerIds.size > 0) {
|
|
||||||
const callerId = readCallerId(req);
|
|
||||||
if (!callerId || !allowedCallerIds.has(callerId)) {
|
|
||||||
throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDiscordToken() {
|
|
||||||
if (!discordToken) {
|
|
||||||
throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureGuildAllowed(guildId) {
|
|
||||||
if (allowedGuildIds.size === 0) return;
|
|
||||||
if (!allowedGuildIds.has(guildId)) {
|
|
||||||
throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureActionEnabled(action) {
|
|
||||||
if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) {
|
|
||||||
throw fail(403, "action_disabled", "channel-private-create is disabled");
|
|
||||||
}
|
|
||||||
if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) {
|
|
||||||
throw fail(403, "action_disabled", "channel-private-update is disabled");
|
|
||||||
}
|
|
||||||
if (action === "member-list" && !enabledActions.memberList) {
|
|
||||||
throw fail(403, "action_disabled", "member-list is disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discordRequest(path, init = {}) {
|
|
||||||
ensureDiscordToken();
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bot ${discordToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(init.headers || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const r = await fetch(`${discordBase}${path}`, { ...init, headers });
|
|
||||||
const text = await r.text();
|
|
||||||
let data = text;
|
|
||||||
try {
|
|
||||||
data = text ? JSON.parse(text) : {};
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toStringMask(v, fallback) {
|
|
||||||
if (v === undefined || v === null || v === "") return String(fallback);
|
|
||||||
if (typeof v === "string") return v;
|
|
||||||
if (typeof v === "number") return String(Math.floor(v));
|
|
||||||
if (typeof v === "bigint") return String(v);
|
|
||||||
throw fail(400, "invalid_mask", "invalid permission bit mask");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
|
|
||||||
const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY;
|
|
||||||
const denyDefault = BIT_VIEW_CHANNEL;
|
|
||||||
|
|
||||||
const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault);
|
|
||||||
const targetAllow = toStringMask(allowMask, allowDefault);
|
|
||||||
|
|
||||||
const overwrites = [
|
|
||||||
{
|
|
||||||
id: guildId,
|
|
||||||
type: 0,
|
|
||||||
allow: "0",
|
|
||||||
deny: everyoneDeny,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const roleId of allowedRoleIds) {
|
|
||||||
overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" });
|
|
||||||
}
|
|
||||||
for (const userId of allowedUserIds) {
|
|
||||||
overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return overwrites;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFieldList(input) {
|
|
||||||
if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean);
|
|
||||||
if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIdList(value, label) {
|
|
||||||
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
|
|
||||||
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
|
|
||||||
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
|
|
||||||
label,
|
|
||||||
limit: MAX_PRIVATE_MUTATION_TARGETS,
|
|
||||||
size: arr.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(obj, keys) {
|
|
||||||
if (!obj || typeof obj !== "object") return obj;
|
|
||||||
const out = {};
|
|
||||||
for (const k of keys) {
|
|
||||||
if (k in obj) out[k] = obj[k];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectMember(member, fields) {
|
|
||||||
if (!fields.length) return member;
|
|
||||||
const base = pick(member, fields);
|
|
||||||
if (fields.some((f) => f.startsWith("user.")) && member?.user) {
|
|
||||||
const userFields = fields
|
|
||||||
.filter((f) => f.startsWith("user."))
|
|
||||||
.map((f) => f.slice(5))
|
|
||||||
.filter(Boolean);
|
|
||||||
base.user = pick(member.user, userFields);
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function actionChannelPrivateCreate(body) {
|
|
||||||
const guildId = String(body.guildId || "").trim();
|
|
||||||
const name = String(body.name || "").trim();
|
|
||||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
|
||||||
if (!name) throw fail(400, "bad_request", "name is required");
|
|
||||||
ensureGuildAllowed(guildId);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name,
|
|
||||||
type: Number.isInteger(body.type) ? body.type : 0,
|
|
||||||
parent_id: body.parentId || undefined,
|
|
||||||
topic: body.topic || undefined,
|
|
||||||
position: Number.isInteger(body.position) ? body.position : undefined,
|
|
||||||
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
|
|
||||||
permission_overwrites: buildPrivateOverwrites({
|
|
||||||
guildId,
|
|
||||||
allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
|
|
||||||
allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
|
|
||||||
allowMask: body.allowMask,
|
|
||||||
denyEveryoneMask: body.denyEveryoneMask,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.dryRun === true) {
|
|
||||||
return { ok: true, action: "channel-private-create", dryRun: true, payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true, action: "channel-private-create", channel };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function actionChannelPrivateUpdate(body) {
|
|
||||||
const guildId = String(body.guildId || "").trim();
|
|
||||||
const channelId = String(body.channelId || "").trim();
|
|
||||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
|
||||||
if (!channelId) throw fail(400, "bad_request", "channelId is required");
|
|
||||||
ensureGuildAllowed(guildId);
|
|
||||||
|
|
||||||
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
|
|
||||||
const denyMask = toStringMask(body.denyMask, 0n);
|
|
||||||
const mode = String(body.mode || "merge").trim();
|
|
||||||
if (mode !== "merge" && mode !== "replace") {
|
|
||||||
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
|
|
||||||
}
|
|
||||||
|
|
||||||
const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
|
|
||||||
const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
|
|
||||||
const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
|
|
||||||
|
|
||||||
const existing = await discordRequest(`/channels/${channelId}`);
|
|
||||||
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
|
|
||||||
|
|
||||||
let next = [];
|
|
||||||
if (mode === "replace") {
|
|
||||||
// keep @everyone deny if present, otherwise set one
|
|
||||||
const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || {
|
|
||||||
id: guildId,
|
|
||||||
type: 0,
|
|
||||||
allow: "0",
|
|
||||||
deny: String(BIT_VIEW_CHANNEL),
|
|
||||||
};
|
|
||||||
next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) });
|
|
||||||
} else {
|
|
||||||
next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSet = new Set(removeTargetIds);
|
|
||||||
if (removeSet.size > 0) {
|
|
||||||
next = next.filter((o) => !removeSet.has(String(o.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsert = (id, type) => {
|
|
||||||
const idx = next.findIndex((o) => String(o.id) === String(id));
|
|
||||||
const row = { id: String(id), type, allow: allowMask, deny: denyMask };
|
|
||||||
if (idx >= 0) next[idx] = row;
|
|
||||||
else next.push(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const userId of addUserIds) upsert(userId, 1);
|
|
||||||
for (const roleId of addRoleIds) upsert(roleId, 0);
|
|
||||||
|
|
||||||
const payload = { permission_overwrites: next };
|
|
||||||
|
|
||||||
if (body.dryRun === true) {
|
|
||||||
return { ok: true, action: "channel-private-update", dryRun: true, payload, mode };
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await discordRequest(`/channels/${channelId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true, action: "channel-private-update", mode, channel };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function actionMemberList(body) {
|
|
||||||
const guildId = String(body.guildId || "").trim();
|
|
||||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
|
||||||
ensureGuildAllowed(guildId);
|
|
||||||
|
|
||||||
const limitRaw = Number(body.limit ?? 100);
|
|
||||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
|
||||||
const after = body.after ? String(body.after) : undefined;
|
|
||||||
const fields = parseFieldList(body.fields);
|
|
||||||
if (fields.length > MAX_MEMBER_FIELDS) {
|
|
||||||
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
|
|
||||||
limit: MAX_MEMBER_FIELDS,
|
|
||||||
size: fields.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
qs.set("limit", String(limit));
|
|
||||||
if (after) qs.set("after", after);
|
|
||||||
|
|
||||||
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
|
|
||||||
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
ok: true,
|
|
||||||
action: "member-list",
|
|
||||||
guildId,
|
|
||||||
count: Array.isArray(projected) ? projected.length : 0,
|
|
||||||
fields: fields.length ? fields : undefined,
|
|
||||||
members: projected,
|
|
||||||
};
|
|
||||||
|
|
||||||
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
|
|
||||||
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
|
|
||||||
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
|
|
||||||
bytes,
|
|
||||||
limit: MAX_MEMBER_RESPONSE_BYTES,
|
|
||||||
hint: "reduce limit or set fields projection",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAction(body) {
|
|
||||||
const action = String(body.action || "").trim();
|
|
||||||
if (!action) throw fail(400, "bad_request", "action is required");
|
|
||||||
ensureActionEnabled(action);
|
|
||||||
|
|
||||||
if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
|
|
||||||
if (action === "channel-private-update") return await actionChannelPrivateUpdate(body);
|
|
||||||
if (action === "member-list") return await actionMemberList(body);
|
|
||||||
|
|
||||||
throw fail(400, "unsupported_action", `unsupported action: ${action}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
|
||||||
return sendJson(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
service: "discord-control-api",
|
|
||||||
authRequired: !!authToken || requireAuthToken,
|
|
||||||
actionGates: enabledActions,
|
|
||||||
guildAllowlistEnabled: allowedGuildIds.size > 0,
|
|
||||||
limits: {
|
|
||||||
maxMemberFields: MAX_MEMBER_FIELDS,
|
|
||||||
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
|
|
||||||
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
|
|
||||||
return sendJson(res, 404, { error: "not_found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = "";
|
|
||||||
req.on("data", (chunk) => {
|
|
||||||
body += chunk;
|
|
||||||
if (body.length > 2_000_000) req.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on("end", async () => {
|
|
||||||
try {
|
|
||||||
ensureControlAuth(req);
|
|
||||||
const parsed = body ? JSON.parse(body) : {};
|
|
||||||
const result = await handleAction(parsed);
|
|
||||||
return sendJson(res, 200, result);
|
|
||||||
} catch (err) {
|
|
||||||
return sendJson(res, err?.status || 500, {
|
|
||||||
error: err?.code || "request_failed",
|
|
||||||
message: String(err?.message || err),
|
|
||||||
details: err?.details,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`[discord-control-api] listening on :${port}`);
|
|
||||||
});
|
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
"enableDirigentPolicyTool": true,
|
"enableDirigentPolicyTool": true,
|
||||||
"enableDebugLogs": false,
|
"enableDebugLogs": false,
|
||||||
"debugLogChannelIds": [],
|
"debugLogChannelIds": [],
|
||||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
|
||||||
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||||
"discordControlCallerId": "agent-main"
|
"discordControlCallerId": "agent-main"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
# Discord Control API
|
|
||||||
|
|
||||||
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
|
||||||
|
|
||||||
> 现在可以通过 Dirigent 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。
|
|
||||||
> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `dirigent` 或 `discord_control`)。
|
|
||||||
|
|
||||||
1. 创建指定名单可见的私人频道
|
|
||||||
2. 查看 server 成员列表(分页)
|
|
||||||
|
|
||||||
## Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd discord-control-api
|
|
||||||
export DISCORD_BOT_TOKEN='xxx'
|
|
||||||
# 建议启用
|
|
||||||
export AUTH_TOKEN='strong-token'
|
|
||||||
# optional hard requirement
|
|
||||||
# export REQUIRE_AUTH_TOKEN=true
|
|
||||||
# optional action gates
|
|
||||||
# export ENABLE_CHANNEL_PRIVATE_CREATE=true
|
|
||||||
# export ENABLE_CHANNEL_PRIVATE_UPDATE=true
|
|
||||||
# export ENABLE_MEMBER_LIST=true
|
|
||||||
# optional allowlist
|
|
||||||
# export ALLOWED_GUILD_IDS='123,456'
|
|
||||||
# export ALLOWED_CALLER_IDS='agent-main,agent-admin'
|
|
||||||
# optional limits
|
|
||||||
# export MAX_MEMBER_FIELDS=20
|
|
||||||
# export MAX_MEMBER_RESPONSE_BYTES=500000
|
|
||||||
# export MAX_PRIVATE_MUTATION_TARGETS=200
|
|
||||||
node server.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Health:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS http://127.0.0.1:8790/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Unified action endpoint
|
|
||||||
|
|
||||||
`POST /v1/discord/action`
|
|
||||||
|
|
||||||
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
|
||||||
- Header: `X-OpenClaw-Caller-Id: <id>`(若配置了 `ALLOWED_CALLER_IDS`)
|
|
||||||
- Body: `{ "action": "...", ... }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action: channel-private-create
|
|
||||||
|
|
||||||
与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "channel-private-create",
|
|
||||||
"guildId": "123",
|
|
||||||
"name": "private-room",
|
|
||||||
"type": 0,
|
|
||||||
"parentId": "456",
|
|
||||||
"topic": "secret",
|
|
||||||
"position": 3,
|
|
||||||
"nsfw": false,
|
|
||||||
"allowedUserIds": ["111", "222"],
|
|
||||||
"allowedRoleIds": ["333"],
|
|
||||||
"allowMask": "67648",
|
|
||||||
"denyEveryoneMask": "1024",
|
|
||||||
"dryRun": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。
|
|
||||||
- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。
|
|
||||||
- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action: channel-private-update
|
|
||||||
|
|
||||||
对现有频道的白名单/覆盖权限做增删改。
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "channel-private-update",
|
|
||||||
"guildId": "123",
|
|
||||||
"channelId": "789",
|
|
||||||
"mode": "merge",
|
|
||||||
"addUserIds": ["111"],
|
|
||||||
"addRoleIds": ["333"],
|
|
||||||
"removeTargetIds": ["222"],
|
|
||||||
"allowMask": "67648",
|
|
||||||
"denyMask": "0",
|
|
||||||
"dryRun": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- `mode=merge`:在现有覆盖基础上增删
|
|
||||||
- `mode=replace`:重建覆盖(保留/补上 @everyone deny)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action: member-list
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "member-list",
|
|
||||||
"guildId": "123",
|
|
||||||
"limit": 100,
|
|
||||||
"after": "0",
|
|
||||||
"fields": ["user.id", "user.username", "nick", "roles", "joined_at"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- `limit` 1~1000
|
|
||||||
- `after` 用于分页(Discord snowflake)
|
|
||||||
- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Curl examples
|
|
||||||
|
|
||||||
示例文件:`docs/EXAMPLES.discord-control.json`
|
|
||||||
|
|
||||||
快速检查脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/smoke-discord-control.sh
|
|
||||||
# with env
|
|
||||||
# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
鉴权与内置风格对齐(简化版):
|
|
||||||
- 控制面 token:`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN`
|
|
||||||
- 调用者 allowlist:`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`)
|
|
||||||
- action gate:`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST`
|
|
||||||
- guild allowlist:`ALLOWED_GUILD_IDS`
|
|
||||||
|
|
||||||
- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。
|
|
||||||
- 若无权限,Discord API 会返回 403 并透传错误细节。
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"dist/",
|
"dist/",
|
||||||
"plugin/",
|
"plugin/",
|
||||||
"no-reply-api/",
|
"no-reply-api/",
|
||||||
"discord-control-api/",
|
|
||||||
"docs/",
|
"docs/",
|
||||||
"scripts/install-dirigent-openclaw.mjs",
|
"scripts/install-dirigent-openclaw.mjs",
|
||||||
"docker-compose.yml",
|
"docker-compose.yml",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Unified optional tool:
|
|||||||
- `bypassUserIds` (deprecated alias of `humanList`)
|
- `bypassUserIds` (deprecated alias of `humanList`)
|
||||||
- `endSymbols` (default ["🔚"])
|
- `endSymbols` (default ["🔚"])
|
||||||
- `enableDiscordControlTool` (default true)
|
- `enableDiscordControlTool` (default true)
|
||||||
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
|
- Discord control actions are executed in-plugin via Discord REST API (no `discordControlApiBaseUrl` needed)
|
||||||
- `discordControlApiToken`
|
- `discordControlApiToken`
|
||||||
- `discordControlCallerId`
|
- `discordControlCallerId`
|
||||||
- `enableDebugLogs` (default false)
|
- `enableDebugLogs` (default false)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentCo
|
|||||||
return {
|
return {
|
||||||
enableDiscordControlTool: true,
|
enableDiscordControlTool: true,
|
||||||
enableDirigentPolicyTool: true,
|
enableDirigentPolicyTool: true,
|
||||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
|
||||||
enableDebugLogs: false,
|
enableDebugLogs: false,
|
||||||
debugLogChannelIds: [],
|
debugLogChannelIds: [],
|
||||||
schedulingIdentifier: "➡️",
|
schedulingIdentifier: "➡️",
|
||||||
|
|||||||
@@ -56,15 +56,11 @@ export default {
|
|||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
enableDiscordControlTool: true,
|
enableDiscordControlTool: true,
|
||||||
enableDirigentPolicyTool: true,
|
enableDirigentPolicyTool: true,
|
||||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
|
||||||
schedulingIdentifier: "➡️",
|
schedulingIdentifier: "➡️",
|
||||||
waitIdentifier: "👤",
|
waitIdentifier: "👤",
|
||||||
...(api.pluginConfig || {}),
|
...(api.pluginConfig || {}),
|
||||||
} as DirigentConfig & {
|
} as DirigentConfig & {
|
||||||
enableDiscordControlTool: boolean;
|
enableDiscordControlTool: boolean;
|
||||||
discordControlApiBaseUrl: string;
|
|
||||||
discordControlApiToken?: string;
|
|
||||||
discordControlCallerId?: string;
|
|
||||||
enableDirigentPolicyTool: boolean;
|
enableDirigentPolicyTool: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,6 @@
|
|||||||
"noReplyModel": { "type": "string" },
|
"noReplyModel": { "type": "string" },
|
||||||
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
||||||
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
|
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
|
||||||
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
|
||||||
"discordControlApiToken": { "type": "string" },
|
|
||||||
"discordControlCallerId": { "type": "string" },
|
|
||||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
"enableDebugLogs": { "type": "boolean", "default": false },
|
||||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"moderatorBotToken": { "type": "string" }
|
"moderatorBotToken": { "type": "string" }
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8790}"
|
|
||||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
|
||||||
CALLER_ID="${CALLER_ID:-}"
|
|
||||||
|
|
||||||
AUTH_HEADER=()
|
|
||||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
||||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
CALLER_HEADER=()
|
|
||||||
if [[ -n "$CALLER_ID" ]]; then
|
|
||||||
CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[1] health"
|
|
||||||
curl -sS "${BASE_URL}/health" | sed -n '1,20p'
|
|
||||||
|
|
||||||
if [[ -z "${GUILD_ID:-}" ]]; then
|
|
||||||
echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[2] dry-run private create"
|
|
||||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
"${AUTH_HEADER[@]}" \
|
|
||||||
"${CALLER_HEADER[@]}" \
|
|
||||||
-d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \
|
|
||||||
| sed -n '1,80p'
|
|
||||||
|
|
||||||
if [[ -n "${CHANNEL_ID:-}" ]]; then
|
|
||||||
echo "[3] dry-run private update"
|
|
||||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
"${AUTH_HEADER[@]}" \
|
|
||||||
"${CALLER_HEADER[@]}" \
|
|
||||||
-d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \
|
|
||||||
| sed -n '1,100p'
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[4] member-list (limit=1)"
|
|
||||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
"${AUTH_HEADER[@]}" \
|
|
||||||
"${CALLER_HEADER[@]}" \
|
|
||||||
-d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \
|
|
||||||
| sed -n '1,120p'
|
|
||||||
|
|
||||||
echo "smoke-discord-control: done"
|
|
||||||
Reference in New Issue
Block a user