Merge pull request 'feat: start NEW_FEAT refactor (channel resolver + decision input modules)' (#16) from feat/new-feat-refactor-start into feat/split-tools-and-mention-override
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -44,7 +44,7 @@
|
||||
- supports `--install` / `--uninstall`
|
||||
- uninstall restores all recorded changes
|
||||
- writes install/uninstall records under `~/.openclaw/dirigent-install-records/`
|
||||
- Added discord-control-api with:
|
||||
- Added discord-control-api with: (historical; later migrated into plugin internal Discord REST control)
|
||||
- `channel-private-create` (create private channel for allowlist)
|
||||
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
||||
- `member-list` (guild members list with pagination + optional field projection)
|
||||
|
||||
37
FEAT.md
37
FEAT.md
@@ -120,4 +120,39 @@ Turn management is handled entirely by the plugin. Manual control via slash comm
|
||||
- Private channel create/update with permission overwrites
|
||||
- Member list with pagination + field projection
|
||||
- Guardrails: action validation, id-list limits, response-size limit
|
||||
- Runs as optional companion service (`discord-control-api/`)
|
||||
- (Migrated) Discord control now runs in-plugin via direct Discord REST (no companion service)
|
||||
|
||||
---
|
||||
|
||||
## NEW_FEAT 合并记录(原 NEW_FEAT.md)
|
||||
|
||||
### 背景与目标
|
||||
- 解决 turn 初始化依赖被动观察(`recordChannelAccount`)导致 `currentSpeaker` 空值的问题。
|
||||
- 将 Discord control 从 sidecar 迁移到插件内模块。
|
||||
- 采用 channel 成员缓存(内存 + 本地持久化),避免轮询。
|
||||
|
||||
### 关键实现方向
|
||||
- 统一 channelId 解析链路,避免 `channel=discord` 错位。
|
||||
- `before_model_resolve / before_prompt_build` 与消息 hook 使用一致解析策略。
|
||||
- 清理未使用函数,降低排障噪音。
|
||||
- 模块化重构:`index.ts` 作为 wiring,逻辑拆入 `hooks/core/tools/policy/commands`。
|
||||
|
||||
### Channel 成员缓存
|
||||
- 缓存文件:`~/.openclaw/dirigent-channel-members.json`
|
||||
- 启动加载、运行时原子写盘。
|
||||
- 记录字段包含 `botAccountIds/updatedAt/source/guildId`。
|
||||
- 首次无缓存时允许 bootstrap 拉取,随后走本地缓存。
|
||||
|
||||
### Turn 初始化改造
|
||||
- `ensureTurnOrder(channelId)` 基于缓存中的 botAccountIds 初始化。
|
||||
- 不再仅依赖“已见账号”被动记录。
|
||||
- 提升新频道首条消息场景的稳定性。
|
||||
|
||||
### 权限计算(频道可见成员)
|
||||
- 通过 guild 成员 + roles + channel overwrites 计算 `VIEW_CHANNEL` 可见性。
|
||||
- 用于内部 turn bootstrap,不对外暴露为公共工具。
|
||||
|
||||
### 风险与注意
|
||||
- 权限位计算必须严格按 Discord 规则。
|
||||
- 缓存读写需原子化,防并发损坏。
|
||||
- 通过 `updatedAt/source/guildId` 提高可观测性与排障效率。
|
||||
|
||||
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:
|
||||
cd plugin && npm run check
|
||||
@@ -24,8 +24,3 @@ render-config:
|
||||
package-plugin:
|
||||
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)
|
||||
- `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
|
||||
- `scripts/` — smoke/dev/helper checks
|
||||
- `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,
|
||||
"enableDebugLogs": false,
|
||||
"debugLogChannelIds": [],
|
||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
||||
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||
"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 并透传错误细节。
|
||||
@@ -27,25 +27,22 @@ The script prints JSON for:
|
||||
|
||||
You can merge this snippet manually into your `openclaw.json`.
|
||||
|
||||
## Installer script (with rollback)
|
||||
|
||||
For production-like install with automatic rollback on error (Node-only installer):
|
||||
## Installer script
|
||||
|
||||
```bash
|
||||
node ./scripts/install-dirigent-openclaw.mjs --install
|
||||
node ./scripts/install.mjs --install
|
||||
# optional port override
|
||||
node ./scripts/install.mjs --install --no-reply-port 8787
|
||||
# or wrapper
|
||||
./scripts/install-dirigent-openclaw.sh --install
|
||||
```
|
||||
|
||||
Uninstall (revert all recorded config changes):
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
node ./scripts/install-dirigent-openclaw.mjs --uninstall
|
||||
node ./scripts/install.mjs --uninstall
|
||||
# or wrapper
|
||||
./scripts/install-dirigent-openclaw.sh --uninstall
|
||||
# or specify a record explicitly
|
||||
# RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \
|
||||
# node ./scripts/install-dirigent-openclaw.mjs --uninstall
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
@@ -64,12 +61,10 @@ Environment overrides:
|
||||
|
||||
The script:
|
||||
- writes via `openclaw config set ... --json`
|
||||
- creates config backup first
|
||||
- restores backup automatically if any install step fails
|
||||
- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status`
|
||||
- writes a change record for every install/uninstall:
|
||||
- directory: `~/.openclaw/dirigent-install-records/`
|
||||
- latest pointer: `~/.openclaw/dirigent-install-record-latest.json`
|
||||
- installs plugin + no-reply-api into `~/.openclaw/plugins`
|
||||
- updates `plugins.entries.dirigent` and `models.providers.<no-reply-provider>`
|
||||
- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`)
|
||||
- does not maintain install/uninstall record files
|
||||
|
||||
Policy state semantics:
|
||||
- channel policy file is loaded once into memory on startup
|
||||
|
||||
@@ -51,5 +51,5 @@ This PR delivers two tracks:
|
||||
## Rollback
|
||||
|
||||
- Disable plugin entry or remove plugin path from OpenClaw config
|
||||
- Stop `discord-control-api` process
|
||||
- (Legacy note) `discord-control-api` sidecar has been removed; Discord control is in-plugin now
|
||||
- Keep no-reply API stopped if not needed
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
1. Dirigent 基础静态与脚本测试
|
||||
2. no-reply-api 隔离集成测试
|
||||
3. discord-control-api 功能测试(dryRun + 实操)
|
||||
3. (历史)discord-control-api 功能测试(dryRun + 实操,当前版本已迁移为 in-plugin)
|
||||
|
||||
未覆盖:
|
||||
|
||||
@@ -53,7 +53,7 @@ make check check-rules test-api
|
||||
|
||||
---
|
||||
|
||||
### B. discord-control-api dryRun + 实操测试
|
||||
### B. (历史)discord-control-api dryRun + 实操测试(当前版本已迁移)
|
||||
|
||||
执行内容与结果:
|
||||
|
||||
@@ -113,7 +113,7 @@ make check check-rules test-api
|
||||
|
||||
### 2) 回归测试
|
||||
|
||||
- discord-control-api 引入后,不影响 Dirigent 原有流程
|
||||
- (历史结论)discord-control-api 引入后,不影响 Dirigent 原有流程;现已迁移为 in-plugin 实现
|
||||
- 规则校验脚本在最新代码继续稳定通过
|
||||
|
||||
### 3) 运行与安全校验
|
||||
|
||||
@@ -78,7 +78,7 @@ When current speaker NO_REPLYs, have **that bot** send a brief handoff message i
|
||||
**Challenges:**
|
||||
- Adds visible noise to the channel (could use a convention like a specific emoji reaction)
|
||||
- The no-reply'd bot can't send messages (it was silenced)
|
||||
- Could use the discord-control-api to send as a different bot
|
||||
- Could use in-plugin Discord REST control to send as a different bot (sidecar removed)
|
||||
|
||||
### 6. Timer-Based Retry (Pragmatic)
|
||||
|
||||
@@ -93,7 +93,7 @@ After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new messa
|
||||
**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be:
|
||||
|
||||
1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn:
|
||||
2. Use the discord-control-api to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
|
||||
2. Use in-plugin Discord REST control to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
|
||||
3. This real Discord message triggers OpenClaw to route it to all agents
|
||||
4. The turn manager allows only the (now-current) next speaker to respond
|
||||
5. The next speaker sees the original conversation context in their session history and responds appropriately
|
||||
|
||||
12
package.json
12
package.json
@@ -7,9 +7,9 @@
|
||||
"dist/",
|
||||
"plugin/",
|
||||
"no-reply-api/",
|
||||
"discord-control-api/",
|
||||
|
||||
"docs/",
|
||||
"scripts/install-dirigent-openclaw.mjs",
|
||||
"scripts/install.mjs",
|
||||
"docker-compose.yml",
|
||||
"Makefile",
|
||||
"README.md",
|
||||
@@ -17,10 +17,10 @@
|
||||
"TASKLIST.md"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/",
|
||||
"postinstall": "node scripts/install-dirigent-openclaw.mjs --install",
|
||||
"uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall",
|
||||
"update": "node scripts/install-dirigent-openclaw.mjs --update"
|
||||
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
|
||||
"postinstall": "node scripts/install.mjs --install",
|
||||
"uninstall": "node scripts/install.mjs --uninstall",
|
||||
"update": "node scripts/install.mjs --update"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
|
||||
@@ -39,7 +39,7 @@ Unified optional tool:
|
||||
- `bypassUserIds` (deprecated alias of `humanList`)
|
||||
- `endSymbols` (default ["🔚"])
|
||||
- `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`
|
||||
- `discordControlCallerId`
|
||||
- `enableDebugLogs` (default false)
|
||||
|
||||
73
plugin/channel-resolver.ts
Normal file
73
plugin/channel-resolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
|
||||
const candidates: unknown[] = [
|
||||
ctx.conversationId,
|
||||
ctx.OriginatingTo,
|
||||
event?.to,
|
||||
(event?.metadata as Record<string, unknown>)?.to,
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== "string" || !c.trim()) continue;
|
||||
const s = c.trim();
|
||||
|
||||
if (s.startsWith("channel:")) {
|
||||
const id = s.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (s.startsWith("discord:channel:")) {
|
||||
const id = s.slice("discord:channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (/^\d{15,}$/.test(s)) return s;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromSessionKey(sessionKey?: string): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
const canonical = sessionKey.match(/discord:(?:channel|group):(\d+)/);
|
||||
if (canonical?.[1]) return canonical[1];
|
||||
|
||||
const suffix = sessionKey.match(/:channel:(\d+)$/);
|
||||
if (suffix?.[1]) return suffix[1];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||
const marker = "Conversation info (untrusted metadata):";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return undefined;
|
||||
const tail = text.slice(idx + marker.length);
|
||||
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
if (!m) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromConversationMetadata(conv: Record<string, unknown>): string | undefined {
|
||||
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
|
||||
const id = conv.chat_id.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (typeof conv.conversation_label === "string") {
|
||||
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
|
||||
if (labelMatch?.[1]) return labelMatch[1];
|
||||
}
|
||||
|
||||
if (typeof conv.channel_id === "string" && /^\d+$/.test(conv.channel_id)) {
|
||||
return conv.channel_id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
133
plugin/commands/dirigent-command.ts
Normal file
133
plugin/commands/dirigent-command.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type CommandDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { filePath: string; channelPolicies: Record<string, unknown> };
|
||||
persistPolicies: (api: OpenClawPluginApi) => void;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
};
|
||||
|
||||
export function registerDirigentCommand(deps: CommandDeps): void {
|
||||
const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps;
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent",
|
||||
description: "Dirigent runtime commands",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const args = cmdCtx.args || "";
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const subCmd = parts[0] || "help";
|
||||
|
||||
if (subCmd === "help") {
|
||||
return {
|
||||
text:
|
||||
`Dirigent commands:\n` +
|
||||
`/dirigent status - Show current channel status\n` +
|
||||
`/dirigent turn-status - Show turn-based speaking status\n` +
|
||||
`/dirigent turn-advance - Manually advance turn\n` +
|
||||
`/dirigent turn-reset - Reset turn order\n` +
|
||||
`/dirigent_policy get <discordChannelId>\n` +
|
||||
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
|
||||
`/dirigent_policy delete <discordChannelId>`,
|
||||
};
|
||||
}
|
||||
|
||||
if (subCmd === "status") {
|
||||
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-status") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-advance") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-reset") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
resetTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
|
||||
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
|
||||
},
|
||||
});
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent_policy",
|
||||
description: "Dirigent channel policy CRUD",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const live = getLivePluginConfig(api, baseConfig);
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const args = (cmdCtx.args || "").trim();
|
||||
if (!args) {
|
||||
return {
|
||||
text:
|
||||
"Usage:\n" +
|
||||
"/dirigent_policy get <discordChannelId>\n" +
|
||||
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
|
||||
"/dirigent_policy delete <discordChannelId>",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/);
|
||||
const op = (opRaw || "").toLowerCase();
|
||||
const channelId = (channelIdRaw || "").trim();
|
||||
|
||||
if (!channelId || !/^\d+$/.test(channelId)) {
|
||||
return { text: "channelId is required and must be numeric Discord channel id", isError: true };
|
||||
}
|
||||
|
||||
if (op === "get") {
|
||||
const policy = (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
|
||||
}
|
||||
|
||||
if (op === "delete") {
|
||||
delete (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, deleted: true }) };
|
||||
}
|
||||
|
||||
if (op === "set") {
|
||||
const jsonText = rest.join(" ").trim();
|
||||
if (!jsonText) {
|
||||
return { text: "set requires <policy-json>", isError: true };
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
return { text: `invalid policy-json: ${String(e)}`, isError: true };
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (typeof parsed.listMode === "string") next.listMode = parsed.listMode;
|
||||
if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String);
|
||||
if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String);
|
||||
if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String);
|
||||
|
||||
(policyState.channelPolicies as Record<string, unknown>)[channelId] = next;
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) };
|
||||
}
|
||||
|
||||
return { text: `unsupported op: ${op}. use get|set|delete`, isError: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
141
plugin/core/channel-members.ts
Normal file
141
plugin/core/channel-members.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { buildUserIdToAccountIdMap } from "./identity.js";
|
||||
|
||||
const PERM_VIEW_CHANNEL = 1n << 10n;
|
||||
const PERM_ADMINISTRATOR = 1n << 3n;
|
||||
|
||||
function toBigIntPerm(v: unknown): bigint {
|
||||
if (typeof v === "bigint") return v;
|
||||
if (typeof v === "number") return BigInt(Math.trunc(v));
|
||||
if (typeof v === "string" && v.trim()) {
|
||||
try {
|
||||
return BigInt(v.trim());
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
return 0n;
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: r.ok, status: r.status, json, text };
|
||||
}
|
||||
|
||||
function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bigint>, channelOverwrites: any[]): boolean {
|
||||
const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : [];
|
||||
let perms = guildRoles.get(guildId) || 0n;
|
||||
for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n;
|
||||
|
||||
if ((perms & PERM_ADMINISTRATOR) !== 0n) return true;
|
||||
|
||||
let everyoneAllow = 0n;
|
||||
let everyoneDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) {
|
||||
everyoneAllow = toBigIntPerm(ow?.allow);
|
||||
everyoneDeny = toBigIntPerm(ow?.deny);
|
||||
break;
|
||||
}
|
||||
}
|
||||
perms = (perms & ~everyoneDeny) | everyoneAllow;
|
||||
|
||||
let roleAllow = 0n;
|
||||
let roleDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 0) continue;
|
||||
const id = String(ow?.id || "");
|
||||
if (id !== guildId && roleIds.includes(id)) {
|
||||
roleAllow |= toBigIntPerm(ow?.allow);
|
||||
roleDeny |= toBigIntPerm(ow?.deny);
|
||||
}
|
||||
}
|
||||
perms = (perms & ~roleDeny) | roleAllow;
|
||||
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 1) continue;
|
||||
if (String(ow?.id || "") === String(member?.user?.id || "")) {
|
||||
const allow = toBigIntPerm(ow?.allow);
|
||||
const deny = toBigIntPerm(ow?.deny);
|
||||
perms = (perms & ~deny) | allow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (perms & PERM_VIEW_CHANNEL) !== 0n;
|
||||
}
|
||||
|
||||
function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return rec.token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
|
||||
const token = getAnyDiscordToken(api);
|
||||
if (!token) return [];
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
if (!ch.ok) return [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
if (!guildId) return [];
|
||||
|
||||
const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`);
|
||||
if (!rolesResp.ok) return [];
|
||||
const rolePerms = new Map<string, bigint>();
|
||||
for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) {
|
||||
rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions));
|
||||
}
|
||||
|
||||
const members: any[] = [];
|
||||
let after = "";
|
||||
while (true) {
|
||||
const q = new URLSearchParams({ limit: "1000" });
|
||||
if (after) q.set("after", after);
|
||||
const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`);
|
||||
if (!mResp.ok) return [];
|
||||
const batch = Array.isArray(mResp.json) ? mResp.json : [];
|
||||
members.push(...batch);
|
||||
if (batch.length < 1000) break;
|
||||
after = String(batch[batch.length - 1]?.user?.id || "");
|
||||
if (!after) break;
|
||||
}
|
||||
|
||||
const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : [];
|
||||
const visibleUserIds = members
|
||||
.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites))
|
||||
.map((m) => String(m?.user?.id || ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const userToAccount = buildUserIdToAccountIdMap(api);
|
||||
const out = new Set<string>();
|
||||
for (const uid of visibleUserIds) {
|
||||
const aid = userToAccount.get(uid);
|
||||
if (aid) out.add(aid);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
79
plugin/core/identity.ts
Normal file
79
plugin/core/identity.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDiscordUserIdFromAccount(api: OpenClawPluginApi, accountId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
return match.accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
|
||||
let accountId: string | undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
accountId = match.accountId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accountId) return undefined;
|
||||
|
||||
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
|
||||
const name = (agent?.name as string) || agentId;
|
||||
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
|
||||
|
||||
let identity = `You are ${name} (Discord account: ${accountId}`;
|
||||
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
|
||||
identity += `).`;
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const map = new Map<string, string>();
|
||||
for (const [accountId, acct] of Object.entries(accounts)) {
|
||||
if (typeof acct.token === "string") {
|
||||
const userId = userIdFromToken(acct.token);
|
||||
if (userId) map.set(userId, accountId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
23
plugin/core/live-config.ts
Normal file
23
plugin/core/live-config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const plugins = (root.plugins as Record<string, unknown>) || {};
|
||||
const entries = (plugins.entries as Record<string, unknown>) || {};
|
||||
const entry = (entries.dirigent as Record<string, unknown>) || (entries.whispergate as Record<string, unknown>) || {};
|
||||
const cfg = (entry.config as Record<string, unknown>) || {};
|
||||
if (Object.keys(cfg).length > 0) {
|
||||
return {
|
||||
enableDiscordControlTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
noReplyPort: 8787,
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
...cfg,
|
||||
} as DirigentConfig;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
31
plugin/core/mentions.ts
Normal file
31
plugin/core/mentions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMentionedUserIds(content: string): string[] {
|
||||
const regex = /<@!?(\d+)>/g;
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const id = match[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||
if (!config.moderatorBotToken) return undefined;
|
||||
return userIdFromToken(config.moderatorBotToken);
|
||||
}
|
||||
49
plugin/core/moderator-discord.ts
Normal file
49
plugin/core/moderator-discord.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
export async function sendModeratorMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (msg: string) => void; warn: (msg: string) => void },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`);
|
||||
return false;
|
||||
}
|
||||
logger.info(`dirigent: moderator message sent to channel=${channelId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`dirigent: moderator send error: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
51
plugin/core/no-reply-process.ts
Normal file
51
plugin/core/no-reply-process.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
let noReplyProcess: ChildProcess | null = null;
|
||||
|
||||
export function startNoReplyApi(
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
pluginDir: string,
|
||||
port = 8787,
|
||||
): void {
|
||||
logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`);
|
||||
|
||||
if (noReplyProcess) {
|
||||
logger.info("dirigent: no-reply API already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
|
||||
logger.info(`dirigent: resolved serverPath=${serverPath}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("dirigent: no-reply API server found, spawning process...");
|
||||
|
||||
noReplyProcess = spawn(process.execPath, [serverPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
|
||||
noReplyProcess.on("exit", (code, signal) => {
|
||||
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
|
||||
noReplyProcess = null;
|
||||
});
|
||||
|
||||
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
|
||||
}
|
||||
|
||||
export function stopNoReplyApi(logger: { info: (m: string) => void }): void {
|
||||
if (!noReplyProcess) return;
|
||||
logger.info("dirigent: stopping no-reply API");
|
||||
noReplyProcess.kill("SIGTERM");
|
||||
noReplyProcess = null;
|
||||
}
|
||||
31
plugin/core/session-state.ts
Normal file
31
plugin/core/session-state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Decision } from "../rules.js";
|
||||
|
||||
export type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
export const MAX_SESSION_DECISIONS = 2000;
|
||||
export const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const sessionDecision = new Map<string, DecisionRecord>();
|
||||
export const sessionAllowed = new Map<string, boolean>();
|
||||
export const sessionInjected = new Set<string>();
|
||||
export const sessionChannelId = new Map<string, string>();
|
||||
export const sessionAccountId = new Map<string, string>();
|
||||
export const sessionTurnHandled = new Set<string>();
|
||||
|
||||
export function pruneDecisionMap(now = Date.now()): void {
|
||||
for (const [k, v] of sessionDecision.entries()) {
|
||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
||||
}
|
||||
|
||||
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
|
||||
const keys = sessionDecision.keys();
|
||||
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
|
||||
const k = keys.next();
|
||||
if (k.done) break;
|
||||
sessionDecision.delete(k.value);
|
||||
}
|
||||
}
|
||||
117
plugin/core/turn-bootstrap.ts
Normal file
117
plugin/core/turn-bootstrap.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { initTurnOrder } from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./channel-members.js";
|
||||
|
||||
const channelSeenAccounts = new Map<string, Set<string>>();
|
||||
const channelBootstrapTried = new Set<string>();
|
||||
let cacheLoaded = false;
|
||||
|
||||
function cachePath(api: OpenClawPluginApi): string {
|
||||
return api.resolvePath("~/.openclaw/dirigent-channel-members.json");
|
||||
}
|
||||
|
||||
function loadCache(api: OpenClawPluginApi): void {
|
||||
if (cacheLoaded) return;
|
||||
cacheLoaded = true;
|
||||
const p = cachePath(api);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, { botAccountIds?: string[]; source?: string; guildId?: string; updatedAt?: string }>;
|
||||
for (const [channelId, rec] of Object.entries(parsed || {})) {
|
||||
const ids = Array.isArray(rec?.botAccountIds) ? rec.botAccountIds.filter(Boolean) : [];
|
||||
if (ids.length > 0) channelSeenAccounts.set(channelId, new Set(ids));
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed loading channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inferGuildIdFromChannelId(api: OpenClawPluginApi, channelId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
const chMap = (rec?.channels as Record<string, Record<string, unknown>> | undefined) || undefined;
|
||||
if (!chMap) continue;
|
||||
const direct = chMap[channelId];
|
||||
const prefixed = chMap[`channel:${channelId}`];
|
||||
const found = (direct || prefixed) as Record<string, unknown> | undefined;
|
||||
if (found && typeof found.guildId === "string" && found.guildId.trim()) return found.guildId.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function persistCache(api: OpenClawPluginApi): void {
|
||||
const p = cachePath(api);
|
||||
const out: Record<string, { botAccountIds: string[]; updatedAt: string; source: string; guildId?: string }> = {};
|
||||
for (const [channelId, set] of channelSeenAccounts.entries()) {
|
||||
out[channelId] = {
|
||||
botAccountIds: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "dirigent/turn-bootstrap",
|
||||
guildId: inferGuildIdFromChannelId(api, channelId),
|
||||
};
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, p);
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed persisting channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const b of bindings) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
ids.push(match.accountId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
|
||||
const allBots = new Set(getAllBotAccountIds(api));
|
||||
const seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) return [];
|
||||
return [...seen].filter((id) => allBots.has(id));
|
||||
}
|
||||
|
||||
export function recordChannelAccount(api: OpenClawPluginApi, channelId: string, accountId: string): boolean {
|
||||
loadCache(api);
|
||||
let seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) {
|
||||
seen = new Set();
|
||||
channelSeenAccounts.set(channelId, seen);
|
||||
}
|
||||
if (seen.has(accountId)) return false;
|
||||
seen.add(accountId);
|
||||
persistCache(api);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise<void> {
|
||||
loadCache(api);
|
||||
let botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
|
||||
if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) {
|
||||
channelBootstrapTried.add(channelId);
|
||||
const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]);
|
||||
for (const aid of discovered) recordChannelAccount(api, channelId, aid);
|
||||
botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
}
|
||||
|
||||
if (botAccounts.length > 0) {
|
||||
initTurnOrder(channelId, botAccounts);
|
||||
}
|
||||
}
|
||||
45
plugin/core/utils.ts
Normal file
45
plugin/core/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
export function pickDefined(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||
if (!cfg.enableDebugLogs) return false;
|
||||
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||
if (allow.length === 0) return true;
|
||||
if (!channelId) return true;
|
||||
return allow.includes(channelId);
|
||||
}
|
||||
|
||||
export function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
|
||||
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||
return {
|
||||
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
|
||||
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
|
||||
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
|
||||
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
|
||||
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
|
||||
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
|
||||
from: typeof ctx.from === "string" ? ctx.from : undefined,
|
||||
metaSenderId:
|
||||
typeof meta.senderId === "string"
|
||||
? meta.senderId
|
||||
: typeof meta.sender_id === "string"
|
||||
? meta.sender_id
|
||||
: undefined,
|
||||
metaUserId:
|
||||
typeof meta.userId === "string"
|
||||
? meta.userId
|
||||
: typeof meta.user_id === "string"
|
||||
? meta.user_id
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
37
plugin/decision-input.ts
Normal file
37
plugin/decision-input.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
extractDiscordChannelId,
|
||||
extractDiscordChannelIdFromConversationMetadata,
|
||||
extractDiscordChannelIdFromSessionKey,
|
||||
extractUntrustedConversationInfo,
|
||||
} from "./channel-resolver.js";
|
||||
|
||||
export type DerivedDecisionInput = {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function deriveDecisionInputFromPrompt(params: {
|
||||
prompt: string;
|
||||
messageProvider?: string;
|
||||
sessionKey?: string;
|
||||
ctx?: Record<string, unknown>;
|
||||
event?: Record<string, unknown>;
|
||||
}): DerivedDecisionInput {
|
||||
const { prompt, messageProvider, sessionKey, ctx, event } = params;
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
|
||||
let channelId = extractDiscordChannelId(ctx || {}, event);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
|
||||
|
||||
const senderId =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
185
plugin/hooks/before-message-write.ts
Normal file
185
plugin/hooks/before-message-write.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type BeforeMessageWriteDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("before_message_write", (event, ctx) => {
|
||||
try {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
);
|
||||
|
||||
const key = ctx.sessionKey;
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
let content = "";
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
||||
if (msg) {
|
||||
const role = msg.role as string | undefined;
|
||||
if (role && role !== "assistant") return;
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") content += part;
|
||||
else if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
|
||||
content += (part as Record<string, unknown>).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
content = ((event as Record<string, unknown>).content as string) || "";
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
if (!key || !channelId || !accountId) return;
|
||||
|
||||
const currentTurn = getTurnDebugInfo(channelId);
|
||||
if (currentTurn.currentSpeaker !== accountId) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isEmpty = trimmed.length === 0;
|
||||
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
const wasNoReply = isEmpty || isNoReply;
|
||||
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
api.logger.info(`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`);
|
||||
|
||||
if (wasAllowed === undefined) return;
|
||||
|
||||
if (wasAllowed === false) {
|
||||
sessionAllowed.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
|
||||
if (!nextSpeaker) {
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
} else if (hasEndSymbol) {
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
} else {
|
||||
api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
168
plugin/hooks/before-model-resolve.ts
Normal file
168
plugin/hooks/before-model-resolve.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { checkTurn } from "../turn-manager.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforeModelResolveDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
|
||||
pruneDecisionMap: () => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
} = deps;
|
||||
|
||||
api.on("before_model_resolve", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
|
||||
`promptPreview=${prompt.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||
|
||||
if (derived.channelId) {
|
||||
sessionChannelId.set(key, derived.channelId);
|
||||
}
|
||||
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (resolvedAccountId) {
|
||||
sessionAccountId.set(key, resolvedAccountId);
|
||||
}
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
sessionDecision.set(key, rec);
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_model_resolve recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (derived.channelId) {
|
||||
await ensureTurnOrder(api, derived.channelId);
|
||||
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (accountId) {
|
||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||
if (!turnCheck.allowed) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(
|
||||
`dirigent: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||
);
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
}
|
||||
sessionAllowed.set(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) {
|
||||
if (rec.needsRestore) {
|
||||
sessionDecision.delete(key);
|
||||
return {
|
||||
providerOverride: undefined,
|
||||
modelOverride: undefined,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rec.needsRestore = true;
|
||||
sessionDecision.set(key, rec);
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
const hasConvMarker2 = prompt.includes("Conversation info (untrusted metadata):");
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_NO_REPLY_TRIGGER session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`decision=${rec.decision.reason} ` +
|
||||
`shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` +
|
||||
`hasConvMarker=${hasConvMarker2} promptLen=${prompt.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||
);
|
||||
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
});
|
||||
}
|
||||
134
plugin/hooks/before-prompt-build.ts
Normal file
134
plugin/hooks/before-prompt-build.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforePromptBuildDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionInjected: Set<string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string;
|
||||
buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string;
|
||||
buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string;
|
||||
};
|
||||
|
||||
export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
} = deps;
|
||||
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_prompt_build recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sessionDecision.delete(key);
|
||||
|
||||
if (sessionInjected.has(key)) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
|
||||
|
||||
let identity = "";
|
||||
if (isGroupChat && ctx.agentId) {
|
||||
const idStr = buildAgentIdentity(api, ctx.agentId);
|
||||
if (idStr) identity = idStr + "\n\n";
|
||||
}
|
||||
|
||||
let schedulingInstruction = "";
|
||||
if (isGroupChat) {
|
||||
schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId);
|
||||
}
|
||||
|
||||
sessionInjected.add(key);
|
||||
|
||||
api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||
return { prependContext: identity + instruction + schedulingInstruction };
|
||||
});
|
||||
}
|
||||
115
plugin/hooks/message-received.ts
Normal file
115
plugin/hooks/message-received.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId } from "../channel-resolver.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageReceivedDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
|
||||
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
|
||||
extractMentionedUserIds: (content: string) => string[];
|
||||
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
|
||||
};
|
||||
|
||||
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
} = deps;
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
try {
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
const preChannelId = extractDiscordChannelId(c, e);
|
||||
const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
}
|
||||
|
||||
if (preChannelId) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||
const from =
|
||||
(typeof metadata?.senderId === "string" && metadata.senderId) ||
|
||||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
|
||||
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (moderatorUserId && from === moderatorUserId) {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
|
||||
}
|
||||
} else {
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
const isHuman = humanList.includes(from);
|
||||
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
|
||||
|
||||
if (senderAccountId && senderAccountId !== "default") {
|
||||
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
|
||||
if (isNew) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
|
||||
const mentionedUserIds = extractMentionedUserIds(messageContent);
|
||||
|
||||
if (mentionedUserIds.length > 0) {
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
|
||||
|
||||
if (mentionedAccountIds.length > 0) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
|
||||
if (overrideSet) {
|
||||
api.logger.info(
|
||||
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
|
||||
);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
123
plugin/hooks/message-sent.ts
Normal file
123
plugin/hooks/message-sent.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageSentDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const key = ctx.sessionKey;
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
|
||||
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
|
||||
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
|
||||
`session=${key ?? "undefined"}`,
|
||||
);
|
||||
|
||||
let channelId = extractDiscordChannelId(c, e);
|
||||
if (!channelId && key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!channelId && key) {
|
||||
channelId = extractDiscordChannelIdFromSessionKey(key);
|
||||
}
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isEmpty = trimmed.length === 0;
|
||||
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
const wasNoReply = isEmpty || isNoReply;
|
||||
|
||||
if (key && sessionTurnHandled.has(key)) {
|
||||
sessionTurnHandled.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasNoReply || hasEndSymbol) {
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
|
||||
api.logger.info(
|
||||
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
|
||||
);
|
||||
|
||||
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
1403
plugin/index.ts
1403
plugin/index.ts
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,9 @@
|
||||
"waitIdentifier": { "type": "string", "default": "👤" },
|
||||
"noReplyProvider": { "type": "string" },
|
||||
"noReplyModel": { "type": "string" },
|
||||
"noReplyPort": { "type": "number", "default": 8787 },
|
||||
"enableDiscordControlTool": { "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 },
|
||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"moderatorBotToken": { "type": "string" }
|
||||
|
||||
50
plugin/policy/store.ts
Normal file
50
plugin/policy/store.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { ChannelPolicy, DirigentConfig } from "../rules.js";
|
||||
|
||||
export type PolicyState = {
|
||||
filePath: string;
|
||||
channelPolicies: Record<string, ChannelPolicy>;
|
||||
};
|
||||
|
||||
export const policyState: PolicyState = {
|
||||
filePath: "",
|
||||
channelPolicies: {},
|
||||
};
|
||||
|
||||
export function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
|
||||
}
|
||||
|
||||
export function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig): void {
|
||||
if (policyState.filePath) return;
|
||||
const filePath = resolvePoliciesPath(api, config);
|
||||
policyState.filePath = filePath;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "{}\n", "utf8");
|
||||
policyState.channelPolicies = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
||||
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`);
|
||||
policyState.channelPolicies = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistPolicies(api: OpenClawPluginApi): void {
|
||||
if (!policyState.filePath) throw new Error("policy state not initialized");
|
||||
const dir = path.dirname(policyState.filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${policyState.filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(policyState.channelPolicies, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, policyState.filePath);
|
||||
api.logger.info(`dirigent: policy file updated at ${policyState.filePath}`);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type DirigentConfig = {
|
||||
waitIdentifier?: string;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
noReplyPort?: number;
|
||||
/** Discord bot token for the moderator bot (used for turn handoff messages) */
|
||||
moderatorBotToken?: string;
|
||||
};
|
||||
|
||||
179
plugin/tools/register-tools.ts
Normal file
179
plugin/tools/register-tools.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type DiscordControlAction = "channel-private-create" | "channel-private-update";
|
||||
|
||||
type ToolDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
};
|
||||
|
||||
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
|
||||
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
|
||||
return { accountId, token: accounts[accountId].token as string };
|
||||
}
|
||||
for (const [aid, rec] of Object.entries(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
||||
return { ok: r.ok, status: r.status, text, json };
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerDirigentTools(deps: ToolDeps): void {
|
||||
const { api, baseConfig, pickDefined, getLivePluginConfig } = deps;
|
||||
|
||||
async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
||||
enableDiscordControlTool?: boolean;
|
||||
discordControlAccountId?: string;
|
||||
};
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId);
|
||||
if (!selected) {
|
||||
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true };
|
||||
}
|
||||
const token = selected.token;
|
||||
|
||||
if (action === "channel-private-create") {
|
||||
const guildId = String(params.guildId || "").trim();
|
||||
const name = String(params.name || "").trim();
|
||||
if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true };
|
||||
|
||||
const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : [];
|
||||
const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyEveryoneMask = String(params.denyEveryoneMask || "1024");
|
||||
|
||||
const overwrites: any[] = [
|
||||
{ id: guildId, type: 0, allow: "0", deny: denyEveryoneMask },
|
||||
...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })),
|
||||
...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })),
|
||||
];
|
||||
|
||||
const body = pickDefined({
|
||||
name,
|
||||
type: typeof params.type === "number" ? params.type : 0,
|
||||
parent_id: params.parentId,
|
||||
topic: params.topic,
|
||||
position: params.position,
|
||||
nsfw: params.nsfw,
|
||||
permission_overwrites: overwrites,
|
||||
});
|
||||
|
||||
const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body);
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
|
||||
const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge";
|
||||
const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : [];
|
||||
const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : [];
|
||||
const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyMask = String(params.denyMask || "0");
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true };
|
||||
|
||||
const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0);
|
||||
|
||||
let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || "")));
|
||||
for (const id of addRoleIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 0, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
for (const id of addUserIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 1, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
|
||||
const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next });
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
api.registerTool({
|
||||
name: "discord_channel_create",
|
||||
description: "Create a private Discord channel with specific user/role permissions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accountId: { type: "string" },
|
||||
guildId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "number" },
|
||||
parentId: { type: "string" },
|
||||
topic: { type: "string" },
|
||||
position: { type: "number" },
|
||||
nsfw: { type: "boolean" },
|
||||
allowedUserIds: { type: "array", items: { type: "string" } },
|
||||
allowedRoleIds: { type: "array", items: { type: "string" } },
|
||||
allowMask: { type: "string" },
|
||||
denyEveryoneMask: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
return executeDiscordAction("channel-private-create", params);
|
||||
},
|
||||
}, { optional: false });
|
||||
|
||||
api.registerTool({
|
||||
name: "discord_channel_update",
|
||||
description: "Update permissions on an existing private Discord channel.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accountId: { type: "string" },
|
||||
channelId: { type: "string" },
|
||||
mode: { type: "string", enum: ["merge", "replace"] },
|
||||
addUserIds: { type: "array", items: { type: "string" } },
|
||||
addRoleIds: { type: "array", items: { type: "string" } },
|
||||
removeTargetIds: { type: "array", items: { type: "string" } },
|
||||
allowMask: { type: "string" },
|
||||
denyMask: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
return executeDiscordAction("channel-private-update", params);
|
||||
},
|
||||
}, { optional: false });
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dirigent plugin installer/uninstaller/updater with delta-tracking.
|
||||
*
|
||||
* Usage:
|
||||
* node install-dirigent-openclaw.mjs --install Install (or reinstall) plugin
|
||||
* node install-dirigent-openclaw.mjs --uninstall Remove plugin config & files
|
||||
* node install-dirigent-openclaw.mjs --update Pull latest from git and reinstall
|
||||
*
|
||||
* OpenClaw directory resolution (priority order):
|
||||
* 1. --openclaw-profile-path <path> CLI argument
|
||||
* 2. $OPENCLAW_DIR environment variable
|
||||
* 3. ~/.openclaw (fallback)
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
// ── Arg parsing ───────────────────────────────────────────────────────────
|
||||
const VALID_MODES = ["--install", "--uninstall", "--update"];
|
||||
let modeArg = null;
|
||||
let argOpenClawDir = null;
|
||||
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i];
|
||||
if (VALID_MODES.includes(arg)) {
|
||||
modeArg = arg;
|
||||
} else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) {
|
||||
argOpenClawDir = process.argv[++i];
|
||||
} else if (arg.startsWith("--openclaw-profile-path=")) {
|
||||
argOpenClawDir = arg.split("=").slice(1).join("=");
|
||||
}
|
||||
}
|
||||
|
||||
if (!modeArg) {
|
||||
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall | --update [--openclaw-profile-path <path>]");
|
||||
process.exit(2);
|
||||
}
|
||||
const mode = modeArg.slice(2); // "install" | "uninstall" | "update"
|
||||
|
||||
// ── OpenClaw directory resolution ─────────────────────────────────────────
|
||||
// Priority: --openclaw-profile-path arg > $OPENCLAW_DIR env > ~/.openclaw
|
||||
function resolveOpenClawDir() {
|
||||
// 1. CLI argument
|
||||
if (argOpenClawDir) {
|
||||
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
console.error(`[dirigent] --openclaw-profile-path=${dir} does not exist`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Environment variable
|
||||
if (process.env.OPENCLAW_DIR) {
|
||||
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
console.warn(`[dirigent] OPENCLAW_DIR=${dir} does not exist, falling back...`);
|
||||
}
|
||||
|
||||
// 3. Fallback
|
||||
const fallback = path.join(os.homedir(), ".openclaw");
|
||||
if (fs.existsSync(fallback)) return fallback;
|
||||
|
||||
console.error("[dirigent] cannot resolve OpenClaw directory. Use --openclaw-profile-path or set OPENCLAW_DIR.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const OPENCLAW_DIR = resolveOpenClawDir();
|
||||
console.log(`[dirigent] OpenClaw dir: ${OPENCLAW_DIR}`);
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = process.env;
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
// ── Update mode: git pull then reinstall ──────────────────────────────────
|
||||
const GIT_REPO_URL = env.DIRIGENT_GIT_URL || "https://git.hangman-lab.top/nav/Dirigent.git";
|
||||
const GIT_BRANCH = env.DIRIGENT_GIT_BRANCH || "latest";
|
||||
|
||||
if (mode === "update") {
|
||||
console.log(`[dirigent] updating from ${GIT_REPO_URL} branch=${GIT_BRANCH} ...`);
|
||||
|
||||
// Check if we're in a git repo
|
||||
const gitDir = path.join(REPO_ROOT, ".git");
|
||||
if (!fs.existsSync(gitDir)) {
|
||||
console.error("[dirigent] not a git repo — cannot update. Clone the repo first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fetch and checkout latest
|
||||
try {
|
||||
execFileSync("git", ["fetch", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["checkout", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["pull", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
console.log("[dirigent] source updated successfully");
|
||||
} catch (err) {
|
||||
console.error(`[dirigent] git update failed: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Re-exec as install (the updated script may differ), pass through openclaw dir
|
||||
const updatedScript = path.join(REPO_ROOT, "scripts", "install-dirigent-openclaw.mjs");
|
||||
const installArgs = [updatedScript, "--install", "--openclaw-profile-path", OPENCLAW_DIR];
|
||||
const result = spawnSync(process.execPath, installArgs, {
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// ── Build: copy plugin + no-reply-api to dist ─────────────────────────────
|
||||
const PLUGIN_SRC_DIR = path.resolve(REPO_ROOT, "plugin");
|
||||
const NO_REPLY_API_SRC_DIR = path.resolve(REPO_ROOT, "no-reply-api");
|
||||
const DIST_PLUGIN_DIR = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||
const DIST_NO_REPLY_DIR = path.resolve(REPO_ROOT, "dist", "no-reply-api");
|
||||
|
||||
function syncDir(srcDir, destDir) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
for (const f of fs.readdirSync(srcDir)) {
|
||||
const srcFile = path.join(srcDir, f);
|
||||
if (fs.statSync(srcFile).isFile()) {
|
||||
fs.copyFileSync(srcFile, path.join(destDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Determine plugin install path ─────────────────────────────────────────
|
||||
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
||||
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
||||
const NO_REPLY_INSTALL_DIR = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
|
||||
// ── Config helpers ────────────────────────────────────────────────────────
|
||||
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway";
|
||||
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
|
||||
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
|
||||
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
const LIST_MODE = env.LIST_MODE || "human-list";
|
||||
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
|
||||
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
|
||||
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
|
||||
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
|
||||
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
|
||||
const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️";
|
||||
|
||||
const STATE_DIR = env.STATE_DIR || path.join(OPENCLAW_DIR, "dirigent-install-records");
|
||||
const LATEST_RECORD_LINK = env.LATEST_RECORD_LINK || path.join(OPENCLAW_DIR, "dirigent-install-record-latest.json");
|
||||
|
||||
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
||||
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
|
||||
const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`);
|
||||
|
||||
const PATH_PLUGINS_LOAD = "plugins.load.paths";
|
||||
const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent";
|
||||
const PATH_PROVIDERS = "models.providers";
|
||||
const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`;
|
||||
const PATH_PLUGINS_ALLOW = "plugins.allow";
|
||||
|
||||
function runOpenclaw(args, { allowFail = false } = {}) {
|
||||
try {
|
||||
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
|
||||
} catch (e) {
|
||||
if (allowFail) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
|
||||
if (out == null || out === "" || out === "undefined") return undefined;
|
||||
try {
|
||||
return JSON.parse(out);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) {
|
||||
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
|
||||
}
|
||||
|
||||
function unsetPath(pathKey) {
|
||||
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
|
||||
}
|
||||
|
||||
function writeRecord(modeName, delta) {
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
const rec = {
|
||||
mode: modeName,
|
||||
timestamp: ts,
|
||||
openclawDir: OPENCLAW_DIR,
|
||||
openclawConfigPath: OPENCLAW_CONFIG_PATH,
|
||||
backupPath: BACKUP_PATH,
|
||||
pluginInstallDir: PLUGIN_INSTALL_DIR,
|
||||
delta,
|
||||
};
|
||||
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
|
||||
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
|
||||
return rec;
|
||||
}
|
||||
|
||||
function readRecord(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function findLatestInstallRecord() {
|
||||
if (!fs.existsSync(STATE_DIR)) return "";
|
||||
const files = fs
|
||||
.readdirSync(STATE_DIR)
|
||||
.filter((f) => /^dirigent-\d+\.json$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const f of files) {
|
||||
const p = path.join(STATE_DIR, f);
|
||||
try {
|
||||
const rec = readRecord(p);
|
||||
if (rec?.mode === "install") return p;
|
||||
} catch {
|
||||
// ignore broken records
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
if (v === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// INSTALL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
if (mode === "install") {
|
||||
// Check if already installed - if so, uninstall first
|
||||
const existingRecord = findLatestInstallRecord();
|
||||
if (existingRecord) {
|
||||
console.log("[dirigent] existing installation detected, uninstalling first...");
|
||||
process.env.RECORD_FILE = existingRecord;
|
||||
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
|
||||
env: { ...process.env, OPENCLAW_DIR },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
console.error("[dirigent] reinstall failed during uninstall phase");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("[dirigent] previous installation removed, proceeding with fresh install...");
|
||||
}
|
||||
|
||||
// 1. Build dist
|
||||
console.log("[dirigent] building dist...");
|
||||
syncDir(PLUGIN_SRC_DIR, DIST_PLUGIN_DIR);
|
||||
syncDir(NO_REPLY_API_SRC_DIR, DIST_NO_REPLY_DIR);
|
||||
|
||||
// 2. Copy to plugins dir
|
||||
console.log(`[dirigent] installing plugin to ${PLUGIN_INSTALL_DIR}`);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
syncDir(DIST_PLUGIN_DIR, PLUGIN_INSTALL_DIR);
|
||||
|
||||
// Also install no-reply-api next to plugin (plugin expects ../no-reply-api/)
|
||||
console.log(`[dirigent] installing no-reply-api to ${NO_REPLY_INSTALL_DIR}`);
|
||||
syncDir(DIST_NO_REPLY_DIR, NO_REPLY_INSTALL_DIR);
|
||||
|
||||
// 3. Backup config
|
||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
|
||||
|
||||
// 4. Initialize channel policies file
|
||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||
}
|
||||
|
||||
const delta = { added: {}, replaced: {}, removed: {}, _prev: {} };
|
||||
|
||||
try {
|
||||
// ── plugins.load.paths ────────────────────────────────────────────────
|
||||
const plugins = getJson("plugins") || {};
|
||||
const oldPaths = clone(plugins.load?.paths) || [];
|
||||
const newPaths = clone(oldPaths);
|
||||
if (!newPaths.includes(PLUGIN_INSTALL_DIR)) {
|
||||
newPaths.push(PLUGIN_INSTALL_DIR);
|
||||
delta.added[PATH_PLUGINS_LOAD] = PLUGIN_INSTALL_DIR;
|
||||
}
|
||||
delta._prev[PATH_PLUGINS_LOAD] = oldPaths;
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = newPaths;
|
||||
|
||||
// ── plugins.entries.dirigent ──────────────────────────────────────────
|
||||
const oldEntry = clone(plugins.entries?.dirigent);
|
||||
const newEntry = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
schedulingIdentifier: SCHEDULING_IDENTIFIER,
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
},
|
||||
};
|
||||
if (oldEntry === undefined) {
|
||||
delta.added[PATH_PLUGIN_ENTRY] = newEntry;
|
||||
} else {
|
||||
delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry;
|
||||
}
|
||||
plugins.entries = plugins.entries || {};
|
||||
plugins.entries.dirigent = newEntry;
|
||||
setJson("plugins", plugins);
|
||||
|
||||
// ── models.providers.<providerId> ─────────────────────────────────────
|
||||
const providers = getJson(PATH_PROVIDERS) || {};
|
||||
const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]);
|
||||
const newProvider = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (oldProvider === undefined) {
|
||||
delta.added[PATH_PROVIDER_ENTRY] = newProvider;
|
||||
} else {
|
||||
delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider;
|
||||
}
|
||||
providers[NO_REPLY_PROVIDER_ID] = newProvider;
|
||||
setJson(PATH_PROVIDERS, providers);
|
||||
|
||||
// ── plugins.allow ─────────────────────────────────────────────────────
|
||||
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
|
||||
const oldAllow = clone(allowList);
|
||||
if (!allowList.includes("dirigent")) {
|
||||
allowList.push("dirigent");
|
||||
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
|
||||
delta._prev[PATH_PLUGINS_ALLOW] = oldAllow;
|
||||
setJson(PATH_PLUGINS_ALLOW, allowList);
|
||||
console.log("[dirigent] added 'dirigent' to plugins.allow");
|
||||
}
|
||||
|
||||
writeRecord("install", delta);
|
||||
console.log("[dirigent] install ok (config written)");
|
||||
console.log(`[dirigent] plugin dir: ${PLUGIN_INSTALL_DIR}`);
|
||||
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
||||
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
|
||||
} catch (e) {
|
||||
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
|
||||
console.error(`[dirigent] install failed; rollback complete: ${String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UNINSTALL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
else if (mode === "uninstall") {
|
||||
const recFile = env.RECORD_FILE || findLatestInstallRecord();
|
||||
if (!recFile || !fs.existsSync(recFile)) {
|
||||
console.log("[dirigent] no install record found, nothing to uninstall.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
|
||||
|
||||
const rec = readRecord(recFile);
|
||||
const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
|
||||
const installedPluginDir = rec.pluginInstallDir || PLUGIN_INSTALL_DIR;
|
||||
|
||||
try {
|
||||
// 1. Remove from allow list
|
||||
if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) {
|
||||
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
|
||||
const idx = allowList.indexOf("dirigent");
|
||||
if (idx !== -1) {
|
||||
allowList.splice(idx, 1);
|
||||
setJson(PATH_PLUGINS_ALLOW, allowList);
|
||||
console.log("[dirigent] removed 'dirigent' from plugins.allow");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove plugin entry
|
||||
if (delta.added[PATH_PLUGIN_ENTRY] !== undefined || delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) {
|
||||
unsetPath(PATH_PLUGIN_ENTRY);
|
||||
console.log("[dirigent] removed plugins.entries.dirigent");
|
||||
}
|
||||
|
||||
// 3. Remove plugin path from load paths
|
||||
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
|
||||
const plugins = getJson("plugins") || {};
|
||||
const paths = plugins.load?.paths || [];
|
||||
const pluginPath = delta.added[PATH_PLUGINS_LOAD];
|
||||
const idx = paths.indexOf(pluginPath);
|
||||
if (idx !== -1) {
|
||||
paths.splice(idx, 1);
|
||||
plugins.load.paths = paths;
|
||||
setJson("plugins", plugins);
|
||||
console.log("[dirigent] removed plugin path from plugins.load.paths");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remove provider
|
||||
if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
|
||||
const providers = getJson(PATH_PROVIDERS) || {};
|
||||
delete providers[NO_REPLY_PROVIDER_ID];
|
||||
setJson(PATH_PROVIDERS, providers);
|
||||
console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`);
|
||||
}
|
||||
|
||||
// Handle replaced provider: restore old value
|
||||
if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) {
|
||||
const providers = getJson(PATH_PROVIDERS) || {};
|
||||
providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY];
|
||||
setJson(PATH_PROVIDERS, providers);
|
||||
console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`);
|
||||
}
|
||||
|
||||
// Handle plugins.load.paths restoration
|
||||
if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) {
|
||||
const plugins = getJson("plugins") || {};
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD];
|
||||
setJson("plugins", plugins);
|
||||
console.log("[dirigent] restored previous plugins.load.paths");
|
||||
}
|
||||
|
||||
// 5. Remove installed plugin files
|
||||
if (fs.existsSync(installedPluginDir)) {
|
||||
fs.rmSync(installedPluginDir, { recursive: true, force: true });
|
||||
console.log(`[dirigent] removed plugin dir: ${installedPluginDir}`);
|
||||
}
|
||||
// Also remove no-reply-api dir
|
||||
const noReplyDir = path.join(path.dirname(installedPluginDir), "no-reply-api");
|
||||
if (fs.existsSync(noReplyDir)) {
|
||||
fs.rmSync(noReplyDir, { recursive: true, force: true });
|
||||
console.log(`[dirigent] removed no-reply-api dir: ${noReplyDir}`);
|
||||
}
|
||||
// Backward-compat cleanup for older mistaken install path
|
||||
const legacyNoReplyDir = path.join(path.dirname(installedPluginDir), "dirigent-no-reply-api");
|
||||
if (fs.existsSync(legacyNoReplyDir)) {
|
||||
fs.rmSync(legacyNoReplyDir, { recursive: true, force: true });
|
||||
console.log(`[dirigent] removed legacy no-reply-api dir: ${legacyNoReplyDir}`);
|
||||
}
|
||||
|
||||
writeRecord("uninstall", delta);
|
||||
console.log("[dirigent] uninstall ok");
|
||||
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
||||
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
|
||||
} catch (e) {
|
||||
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
|
||||
console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
279
scripts/install.mjs
Executable file
279
scripts/install.mjs
Executable file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
|
||||
let modeArg = null;
|
||||
let argOpenClawDir = null;
|
||||
let argNoReplyPort = 8787;
|
||||
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i];
|
||||
if (VALID_MODES.has(arg)) {
|
||||
modeArg = arg;
|
||||
} else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) {
|
||||
argOpenClawDir = process.argv[++i];
|
||||
} else if (arg.startsWith("--openclaw-profile-path=")) {
|
||||
argOpenClawDir = arg.split("=").slice(1).join("=");
|
||||
} else if (arg === "--no-reply-port" && i + 1 < process.argv.length) {
|
||||
argNoReplyPort = Number(process.argv[++i]);
|
||||
} else if (arg.startsWith("--no-reply-port=")) {
|
||||
argNoReplyPort = Number(arg.split("=").slice(1).join("="));
|
||||
}
|
||||
}
|
||||
|
||||
if (!modeArg) {
|
||||
fail("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path <path>] [--no-reply-port <port>]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) {
|
||||
fail("invalid --no-reply-port (1-65535)");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const mode = modeArg.slice(2);
|
||||
|
||||
const C = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
};
|
||||
|
||||
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
|
||||
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
|
||||
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
|
||||
function ok(msg) { console.log(color(`\t✓ ${msg}`, "green")); }
|
||||
function warn(msg) { console.log(color(`\t⚠ ${msg}`, "yellow")); }
|
||||
function fail(msg) { console.log(color(`\t✗ ${msg}`, "red")); }
|
||||
|
||||
function resolveOpenClawDir() {
|
||||
if (argOpenClawDir) {
|
||||
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
|
||||
return dir;
|
||||
}
|
||||
if (process.env.OPENCLAW_DIR) {
|
||||
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`);
|
||||
}
|
||||
const fallback = path.join(os.homedir(), ".openclaw");
|
||||
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const OPENCLAW_DIR = resolveOpenClawDir();
|
||||
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
fail(`config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
||||
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
||||
const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api");
|
||||
|
||||
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigentway";
|
||||
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
|
||||
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
|
||||
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`;
|
||||
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
const LIST_MODE = process.env.LIST_MODE || "human-list";
|
||||
const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]";
|
||||
const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]";
|
||||
const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json");
|
||||
const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}";
|
||||
const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["🔚"]';
|
||||
const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "➡️";
|
||||
|
||||
function runOpenclaw(args, allowFail = false) {
|
||||
try {
|
||||
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
|
||||
} catch (e) {
|
||||
if (allowFail) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], true);
|
||||
if (!out || out === "undefined") return undefined;
|
||||
try { return JSON.parse(out); } catch { return undefined; }
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) {
|
||||
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
|
||||
}
|
||||
|
||||
function unsetPath(pathKey) {
|
||||
runOpenclaw(["config", "unset", pathKey], true);
|
||||
}
|
||||
|
||||
function syncDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.cpSync(src, dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function isRegistered() {
|
||||
const entry = getJson("plugins.entries.dirigent");
|
||||
return !!(entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
if (mode === "update") {
|
||||
title("Update");
|
||||
const branch = process.env.DIRIGENT_GIT_BRANCH || "latest";
|
||||
step(1, 2, `update source branch=${branch}`);
|
||||
execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
ok("source updated");
|
||||
|
||||
step(2, 2, "run install after update");
|
||||
const script = path.join(REPO_ROOT, "scripts", "install.mjs");
|
||||
const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)];
|
||||
const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env });
|
||||
process.exit(ret.status ?? 1);
|
||||
}
|
||||
|
||||
if (mode === "install") {
|
||||
title("Install");
|
||||
step(1, 6, `environment: ${OPENCLAW_DIR}`);
|
||||
|
||||
if (isRegistered()) {
|
||||
warn("plugins.entries.dirigent exists; reinstalling in-place");
|
||||
}
|
||||
|
||||
step(2, 6, "build dist assets");
|
||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
|
||||
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||
const distNoReply = path.resolve(REPO_ROOT, "dist", "dirigent", "no-reply-api");
|
||||
syncDirRecursive(pluginSrc, distPlugin);
|
||||
syncDirRecursive(noReplySrc, distNoReply);
|
||||
|
||||
step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
|
||||
syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR);
|
||||
|
||||
// cleanup old layout from previous versions
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) {
|
||||
fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok(`removed legacy path: ${oldTopLevelNoReply}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||
ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||
}
|
||||
|
||||
step(4, 6, "configure plugin entry/path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR);
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = loadPaths;
|
||||
|
||||
plugins.entries = plugins.entries || {};
|
||||
plugins.entries.dirigent = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
schedulingIdentifier: SCHEDULING_IDENTIFIER,
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
noReplyPort: NO_REPLY_PORT,
|
||||
},
|
||||
};
|
||||
setJson("plugins", plugins);
|
||||
|
||||
step(5, 6, "configure no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
providers[NO_REPLY_PROVIDER_ID] = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
setJson("models.providers", providers);
|
||||
|
||||
step(6, 6, "enable plugin in allowlist");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
if (!allow.includes("dirigent")) {
|
||||
allow.push("dirigent");
|
||||
setJson("plugins.allow", allow);
|
||||
}
|
||||
|
||||
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === "uninstall") {
|
||||
title("Uninstall");
|
||||
step(1, 5, `environment: ${OPENCLAW_DIR}`);
|
||||
|
||||
step(2, 5, "remove allowlist + plugin entry");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
const idx = allow.indexOf("dirigent");
|
||||
if (idx >= 0) {
|
||||
allow.splice(idx, 1);
|
||||
setJson("plugins.allow", allow);
|
||||
ok("removed from plugins.allow");
|
||||
}
|
||||
|
||||
unsetPath("plugins.entries.dirigent");
|
||||
ok("removed plugins.entries.dirigent");
|
||||
|
||||
step(3, 5, "remove plugin load path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
|
||||
setJson("plugins", plugins);
|
||||
ok("removed plugin path from plugins.load.paths");
|
||||
|
||||
step(4, 5, "remove no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
delete providers[NO_REPLY_PROVIDER_ID];
|
||||
setJson("models.providers", providers);
|
||||
ok(`removed provider ${NO_REPLY_PROVIDER_ID}`);
|
||||
|
||||
step(5, 5, "remove installed files");
|
||||
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
|
||||
if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true });
|
||||
const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api");
|
||||
if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true });
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok("removed installed files");
|
||||
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -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