diff --git a/CHANGELOG.md b/CHANGELOG.md index 967baf4..64c8e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/FEAT.md b/FEAT.md index 76b2f6e..56b0fd2 100644 --- a/FEAT.md +++ b/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` 提高可观测性与排障效率。 diff --git a/Makefile b/Makefile index 5f3893c..8bc67c5 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 24c3fdc..dfcf810 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/discord-control-api/package.json b/discord-control-api/package.json deleted file mode 100644 index ef1f8c6..0000000 --- a/discord-control-api/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "dirigent-discord-control-api", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "node server.mjs" - } -} diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs deleted file mode 100644 index 27a21db..0000000 --- a/discord-control-api/server.mjs +++ /dev/null @@ -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}`); -}); diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index f2c75ff..84e3a80 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -21,7 +21,6 @@ "enableDirigentPolicyTool": true, "enableDebugLogs": false, "debugLogChannelIds": [], - "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", "discordControlCallerId": "agent-main" } diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md deleted file mode 100644 index de9119e..0000000 --- a/docs/DISCORD_CONTROL.md +++ /dev/null @@ -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 `(若配置) -- Header: `X-OpenClaw-Caller-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 并透传错误细节。 diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e04cbb5..6eb7378 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -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.` +- 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 diff --git a/docs/PR_SUMMARY.md b/docs/PR_SUMMARY.md index 64be235..f8c8502 100644 --- a/docs/PR_SUMMARY.md +++ b/docs/PR_SUMMARY.md @@ -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 diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 53c0635..3c192d0 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -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) 运行与安全校验 diff --git a/docs/TURN-WAKEUP-PROBLEM.md b/docs/TURN-WAKEUP-PROBLEM.md index d9c42ea..f78eb72 100644 --- a/docs/TURN-WAKEUP-PROBLEM.md +++ b/docs/TURN-WAKEUP-PROBLEM.md @@ -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 diff --git a/package.json b/package.json index 8ce1891..cc009cf 100644 --- a/package.json +++ b/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", diff --git a/plugin/README.md b/plugin/README.md index cd6592d..add6333 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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) diff --git a/plugin/channel-resolver.ts b/plugin/channel-resolver.ts new file mode 100644 index 0000000..c795b1b --- /dev/null +++ b/plugin/channel-resolver.ts @@ -0,0 +1,73 @@ +export function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { + const candidates: unknown[] = [ + ctx.conversationId, + ctx.OriginatingTo, + event?.to, + (event?.metadata as Record)?.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 | 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) : undefined; + } catch { + return undefined; + } +} + +export function extractDiscordChannelIdFromConversationMetadata(conv: Record): 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; +} diff --git a/plugin/commands/dirigent-command.ts b/plugin/commands/dirigent-command.ts new file mode 100644 index 0000000..7232816 --- /dev/null +++ b/plugin/commands/dirigent-command.ts @@ -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 }; + 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 \n` + + `/dirigent_policy set \n` + + `/dirigent_policy delete `, + }; + } + + 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 \n" + + "/dirigent_policy set \n" + + "/dirigent_policy delete ", + 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)[channelId]; + return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) }; + } + + if (op === "delete") { + delete (policyState.channelPolicies as Record)[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 ", isError: true }; + } + let parsed: Record; + try { + parsed = JSON.parse(jsonText); + } catch (e) { + return { text: `invalid policy-json: ${String(e)}`, isError: true }; + } + + const next: Record = {}; + 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)[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 }; + }, + }); +} diff --git a/plugin/core/channel-members.ts b/plugin/core/channel-members.ts new file mode 100644 index 0000000..fab4e50 --- /dev/null +++ b/plugin/core/channel-members.ts @@ -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, 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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + 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 { + 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(); + 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(); + for (const uid of visibleUserIds) { + const aid = userToAccount.get(uid); + if (aid) out.add(aid); + } + return [...out]; +} diff --git a/plugin/core/identity.ts b/plugin/core/identity.ts new file mode 100644 index 0000000..6b35d2b --- /dev/null +++ b/plugin/core/identity.ts @@ -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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + 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) || {}; + const bindings = root.bindings as Array> | undefined; + if (!Array.isArray(bindings)) return undefined; + for (const b of bindings) { + if (b.agentId === agentId) { + const match = b.match as Record | 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) || {}; + const bindings = root.bindings as Array> | undefined; + const agents = ((root.agents as Record)?.list as Array>) || []; + 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 | undefined; + if (match?.channel === "discord" && typeof match.accountId === "string") { + accountId = match.accountId; + break; + } + } + } + if (!accountId) return undefined; + + const agent = agents.find((a: Record) => 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 { + const root = (api.config as Record) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + const map = new Map(); + 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; +} diff --git a/plugin/core/live-config.ts b/plugin/core/live-config.ts new file mode 100644 index 0000000..8da04af --- /dev/null +++ b/plugin/core/live-config.ts @@ -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) || {}; + const plugins = (root.plugins as Record) || {}; + const entries = (plugins.entries as Record) || {}; + const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; + const cfg = (entry.config as Record) || {}; + if (Object.keys(cfg).length > 0) { + return { + enableDiscordControlTool: true, + enableDirigentPolicyTool: true, + enableDebugLogs: false, + debugLogChannelIds: [], + noReplyPort: 8787, + schedulingIdentifier: "➡️", + waitIdentifier: "👤", + ...cfg, + } as DirigentConfig; + } + return fallback; +} diff --git a/plugin/core/mentions.ts b/plugin/core/mentions.ts new file mode 100644 index 0000000..6fa041e --- /dev/null +++ b/plugin/core/mentions.ts @@ -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(); + 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); +} diff --git a/plugin/core/moderator-discord.ts b/plugin/core/moderator-discord.ts new file mode 100644 index 0000000..4bca80d --- /dev/null +++ b/plugin/core/moderator-discord.ts @@ -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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + 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 { + 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; + } +} diff --git a/plugin/core/no-reply-process.ts b/plugin/core/no-reply-process.ts new file mode 100644 index 0000000..ce43217 --- /dev/null +++ b/plugin/core/no-reply-process.ts @@ -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; +} diff --git a/plugin/core/session-state.ts b/plugin/core/session-state.ts new file mode 100644 index 0000000..108bef7 --- /dev/null +++ b/plugin/core/session-state.ts @@ -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(); +export const sessionAllowed = new Map(); +export const sessionInjected = new Set(); +export const sessionChannelId = new Map(); +export const sessionAccountId = new Map(); +export const sessionTurnHandled = new Set(); + +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); + } +} diff --git a/plugin/core/turn-bootstrap.ts b/plugin/core/turn-bootstrap.ts new file mode 100644 index 0000000..d561cf6 --- /dev/null +++ b/plugin/core/turn-bootstrap.ts @@ -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>(); +const channelBootstrapTried = new Set(); +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; + 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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + for (const rec of Object.values(accounts)) { + const chMap = (rec?.channels as Record> | undefined) || undefined; + if (!chMap) continue; + const direct = chMap[channelId]; + const prefixed = chMap[`channel:${channelId}`]; + const found = (direct || prefixed) as Record | 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 = {}; + 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) || {}; + const bindings = root.bindings as Array> | undefined; + if (!Array.isArray(bindings)) return []; + const ids: string[] = []; + for (const b of bindings) { + const match = b.match as Record | 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 { + 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); + } +} diff --git a/plugin/core/utils.ts b/plugin/core/utils.ts new file mode 100644 index 0000000..a38760a --- /dev/null +++ b/plugin/core/utils.ts @@ -0,0 +1,45 @@ +type DebugConfig = { + enableDebugLogs?: boolean; + debugLogChannelIds?: string[]; +}; + +export function pickDefined(input: Record): Record { + const out: Record = {}; + 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, event: Record) { + const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; + 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, + }; +} diff --git a/plugin/decision-input.ts b/plugin/decision-input.ts new file mode 100644 index 0000000..dc176e5 --- /dev/null +++ b/plugin/decision-input.ts @@ -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; +}; + +export function deriveDecisionInputFromPrompt(params: { + prompt: string; + messageProvider?: string; + sessionKey?: string; + ctx?: Record; + event?: Record; +}): 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 }; +} diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts new file mode 100644 index 0000000..faee8ba --- /dev/null +++ b/plugin/hooks/before-message-write.ts @@ -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 }; + sessionAllowed: Map; + sessionChannelId: Map; + sessionAccountId: Map; + sessionTurnHandled: Set; + 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; + resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined; + sendModeratorMessage: ( + botToken: string, + channelId: string, + content: string, + logger: { info: (m: string) => void; warn: (m: string) => void }, + ) => Promise; +}; + +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).message as Record | 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).text === "string") { + content += (part as Record).text; + } + } + } + } + if (!content) { + content = ((event as Record).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); + + 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)}`); + } + }); +} diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts new file mode 100644 index 0000000..0b551f6 --- /dev/null +++ b/plugin/hooks/before-model-resolve.ts @@ -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; + sessionAllowed: Map; + sessionChannelId: Map; + sessionAccountId: Map; + policyState: { channelPolicies: Record }; + 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; +}; + +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).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, + event: event as Record, + }); + + 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, + 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).sender_id ?? "")} ` + + `convSender=${String((derived.conv as Record).sender ?? "")} ` + + `convChannelId=${String((derived.conv as Record).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).sender_id ?? "")} ` + + `convSender=${String((derived.conv as Record).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, + }; + }); +} diff --git a/plugin/hooks/before-prompt-build.ts b/plugin/hooks/before-prompt-build.ts new file mode 100644 index 0000000..2c8331e --- /dev/null +++ b/plugin/hooks/before-prompt-build.ts @@ -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; + sessionInjected: Set; + policyState: { channelPolicies: Record }; + 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).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt({ + prompt, + messageProvider: ctx.messageProvider, + sessionKey: key, + ctx: ctx as Record, + event: event as Record, + }); + + const decision = evaluateDecision({ + config: live, + channel: derived.channel, + channelId: derived.channelId, + channelPolicies: policyState.channelPolicies as Record, + 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).sender_id ?? "")} ` + + `convSender=${String((derived.conv as Record).sender ?? "")} ` + + `convChannelId=${String((derived.conv as Record).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).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt({ + prompt, + messageProvider: ctx.messageProvider, + sessionKey: key, + ctx: ctx as Record, + event: event as Record, + }); + const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record); + 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 }; + }); +} diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts new file mode 100644 index 0000000..3629a15 --- /dev/null +++ b/plugin/hooks/message-received.ts @@ -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, event: Record) => Record; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; + getModeratorUserId: (cfg: DirigentConfig) => string | undefined; + recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean; + extractMentionedUserIds: (content: string) => string[]; + buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map; +}; + +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; + const e = (event || {}) as Record; + 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).metadata as Record | undefined; + const from = + (typeof metadata?.senderId === "string" && metadata.senderId) || + (typeof (e as Record).from === "string" ? ((e as Record).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).content as string) || ((e as Record).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)}`); + } + }); +} diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts new file mode 100644 index 0000000..2c87284 --- /dev/null +++ b/plugin/hooks/message-sent.ts @@ -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 }; + sessionChannelId: Map; + sessionAccountId: Map; + sessionTurnHandled: Set; + 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; +}; + +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; + const e = (event || {}) as Record; + + 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); + + 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)}`); + } + }); +} diff --git a/plugin/index.ts b/plugin/index.ts index f6a4779..3ef862f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,83 +1,39 @@ import fs from "node:fs"; import path from "node:path"; -import { spawn, type ChildProcess } from "node:child_process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; -import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride, setWaitingForHuman, isWaitingForHuman } from "./turn-manager.js"; +import type { DirigentConfig } from "./rules.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; - -// ── No-Reply API child process lifecycle ────────────────────────────── -let noReplyProcess: ChildProcess | null = null; - -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})`); -} - -function stopNoReplyApi(logger: { info: (m: string) => void }): void { - if (!noReplyProcess) return; - logger.info("dirigent: stopping no-reply API"); - noReplyProcess.kill("SIGTERM"); - noReplyProcess = null; -} - -type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; - -type DecisionRecord = { - decision: Decision; - createdAt: number; - needsRestore?: boolean; -}; - -type PolicyState = { - filePath: string; - channelPolicies: Record; -}; +import { registerMessageReceivedHook } from "./hooks/message-received.js"; +import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; +import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js"; +import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js"; +import { registerMessageSentHook } from "./hooks/message-sent.js"; +import { registerDirigentCommand } from "./commands/dirigent-command.js"; +import { registerDirigentTools } from "./tools/register-tools.js"; +import { getLivePluginConfig } from "./core/live-config.js"; +import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js"; +import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js"; +import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js"; +import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js"; +import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js"; +import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js"; +import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; +import { + DECISION_TTL_MS, + pruneDecisionMap, + sessionAccountId, + sessionAllowed, + sessionChannelId, + sessionDecision, + sessionInjected, + sessionTurnHandled, +} from "./core/session-state.js"; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; -const sessionDecision = new Map(); -const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) -const sessionInjected = new Set(); // Track which sessions have already injected the end marker -const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping -const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping -const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write -const MAX_SESSION_DECISIONS = 2000; -const DECISION_TTL_MS = 5 * 60 * 1000; - function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`; @@ -92,445 +48,6 @@ function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): str return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`; } -const policyState: PolicyState = { - filePath: "", - channelPolicies: {}, -}; - -function normalizeChannel(ctx: Record): string { - const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); - } - return ""; -} - -/** - * Extract the actual Discord channel ID from a conversationId or "to" field. - * OpenClaw uses format "channel:" for Discord conversations. - * Also tries event.to and event.metadata.to as fallbacks. - */ -function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { - const candidates: unknown[] = [ - ctx.conversationId, - event?.to, - (event?.metadata as Record)?.to, - ]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) { - const s = c.trim(); - // Handle "channel:123456" format - if (s.startsWith("channel:")) { - const id = s.slice("channel:".length); - if (/^\d+$/.test(id)) return id; - } - // Handle "discord:channel:123456" format - if (s.startsWith("discord:channel:")) { - const id = s.slice("discord:channel:".length); - if (/^\d+$/.test(id)) return id; - } - // If it's a raw snowflake (all digits), use directly - if (/^\d{15,}$/.test(s)) return s; - } - } - return undefined; -} - -function normalizeSender(event: Record, ctx: Record): string | undefined { - const direct = [ctx.senderId, ctx.from, event.from]; - for (const v of direct) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - const meta = (event.metadata || ctx.metadata) as Record | undefined; - if (!meta) return undefined; - const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; - for (const v of metaCandidates) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - return undefined; -} - -function extractUntrustedConversationInfo(text: string): Record | 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) : undefined; - } catch { - return undefined; - } -} - -function deriveDecisionInputFromPrompt( - prompt: string, - messageProvider?: string, - channelIdFromCtx?: string, -): { - channel: string; - channelId?: string; - senderId?: string; - content: string; - conv: Record; -} { - const conv = extractUntrustedConversationInfo(prompt) || {}; - const channel = (messageProvider || "").toLowerCase(); - - // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id - let channelId = channelIdFromCtx; - if (!channelId) { - // Try chat_id field (format "channel:123456") - if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) { - channelId = conv.chat_id.slice("channel:".length); - } - // Try conversation_label (format "Guild #name channel id:123456") - if (!channelId && typeof conv.conversation_label === "string") { - const labelMatch = conv.conversation_label.match(/channel id:(\d+)/); - if (labelMatch) channelId = labelMatch[1]; - } - // Try channel_id field directly - if (!channelId && typeof conv.channel_id === "string" && conv.channel_id) { - channelId = conv.channel_id; - } - } - - const senderId = - (typeof conv.sender_id === "string" && conv.sender_id) || - (typeof conv.sender === "string" && conv.sender) || - undefined; - - return { channel, channelId, senderId, content: prompt, conv }; -} - -function pruneDecisionMap(now = Date.now()) { - 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); - } -} - - -function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig { - const root = (api.config as Record) || {}; - const plugins = (root.plugins as Record) || {}; - const entries = (plugins.entries as Record) || {}; - // Support both "dirigent" and legacy "whispergate" config keys - const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; - const cfg = (entry.config as Record) || {}; - if (Object.keys(cfg).length > 0) { - // Merge with defaults to ensure optional fields have values - return { - enableDiscordControlTool: true, - enableDirigentPolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", - enableDebugLogs: false, - debugLogChannelIds: [], - schedulingIdentifier: "➡️", - waitIdentifier: "👤", - ...cfg, - } as DirigentConfig; - } - return fallback; -} - -function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string { - return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json"); -} - -function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig) { - 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; - policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; - } catch (err) { - api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`); - policyState.channelPolicies = {}; - } -} - -/** Resolve agentId → Discord accountId from config bindings */ -function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return undefined; - for (const b of bindings) { - if (b.agentId === agentId) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - return match.accountId; - } - } - } - return undefined; -} - -/** - * Get all Discord bot accountIds from config bindings. - */ -function getAllBotAccountIds(api: OpenClawPluginApi): string[] { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return []; - const ids: string[] = []; - for (const b of bindings) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - ids.push(match.accountId); - } - } - return ids; -} - -/** - * Track which bot accountIds have been seen in each channel via message_received. - * Key: channelId, Value: Set of accountIds seen. - */ -const channelSeenAccounts = new Map>(); - -/** - * Record a bot accountId seen in a channel. - * Returns true if this is a new account for this channel (turn order should be updated). - */ -function recordChannelAccount(channelId: string, accountId: string): boolean { - let seen = channelSeenAccounts.get(channelId); - if (!seen) { - seen = new Set(); - channelSeenAccounts.set(channelId, seen); - } - if (seen.has(accountId)) return false; - seen.add(accountId); - return true; -} - -/** - * Get the list of bot accountIds seen in a channel. - * Only returns accounts that are also in the global bindings (actual bots). - */ -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)); -} - -/** - * Ensure turn order is initialized for a channel. - * Uses only bot accounts that have been seen in this channel. - */ -function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { - const botAccounts = getChannelBotAccountIds(api, channelId); - if (botAccounts.length > 0) { - initTurnOrder(channelId, botAccounts); - } -} - -/** - * Build agent identity string for injection into group chat prompts. - * Includes agent name, Discord accountId, and Discord userId. - */ -function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - const agents = ((root.agents as Record)?.list as Array>) || []; - if (!Array.isArray(bindings)) return undefined; - - // Find accountId for this agent - let accountId: string | undefined; - for (const b of bindings) { - if (b.agentId === agentId) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - accountId = match.accountId; - break; - } - } - } - if (!accountId) return undefined; - - // Find agent name - const agent = agents.find((a: Record) => a.id === agentId); - const name = (agent?.name as string) || agentId; - - // Resolve Discord userId from bot token - const discordUserId = resolveDiscordUserId(api, accountId); - - let identity = `You are ${name} (Discord account: ${accountId}`; - if (discordUserId) { - identity += `, Discord userId: ${discordUserId}`; - } - identity += `).`; - - return identity; -} - -// --- Moderator bot helpers --- - -/** Extract Discord user ID from a bot token (base64-encoded in first segment) */ -function userIdFromToken(token: string): string | undefined { - try { - const segment = token.split(".")[0]; - // Add padding - const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); - return Buffer.from(padded, "base64").toString("utf8"); - } catch { - return undefined; - } -} - -/** Resolve accountId → Discord user ID by reading the account's bot token from config */ -function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined { - const root = (api.config as Record) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - const acct = accounts[accountId]; - if (!acct?.token || typeof acct.token !== "string") return undefined; - return userIdFromToken(acct.token); -} - -/** - * Build a reverse map: Discord userId → accountId for all configured Discord accounts. - */ -function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map { - const root = (api.config as Record) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - const map = new Map(); - 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; -} - -/** - * Extract Discord @mention user IDs from message content. - * Matches <@USER_ID> and <@!USER_ID> patterns. - * Returns user IDs in the order they appear. - */ -function extractMentionedUserIds(content: string): string[] { - const regex = /<@!?(\d+)>/g; - const ids: string[] = []; - const seen = new Set(); - let match; - while ((match = regex.exec(content)) !== null) { - const id = match[1]; - if (!seen.has(id)) { - seen.add(id); - ids.push(id); - } - } - return ids; -} - -/** Get the moderator bot's Discord user ID from its token */ -function getModeratorUserId(config: DirigentConfig): string | undefined { - if (!config.moderatorBotToken) return undefined; - return userIdFromToken(config.moderatorBotToken); -} - -/** Send a message as the moderator bot via Discord REST API */ -async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise { - 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; - } -} - -function persistPolicies(api: OpenClawPluginApi): void { - const filePath = policyState.filePath; - if (!filePath) throw new Error("policy file path not initialized"); - const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; - const tmp = `${filePath}.tmp`; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(tmp, before, "utf8"); - fs.renameSync(tmp, filePath); - api.logger.info(`dirigent: policy file persisted: ${filePath}`); -} - -function pickDefined(input: Record) { - const out: Record = {}; - for (const [k, v] of Object.entries(input)) { - if (v !== undefined) out[k] = v; - } - return out; -} - -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); -} - -function debugCtxSummary(ctx: Record, event: Record) { - const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; - 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, - }; -} - export default { id: "dirigent", name: "Dirigent", @@ -539,15 +56,12 @@ export default { const baseConfig = { enableDiscordControlTool: true, enableDirigentPolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", schedulingIdentifier: "➡️", waitIdentifier: "👤", + noReplyPort: 8787, ...(api.pluginConfig || {}), } as DirigentConfig & { enableDiscordControlTool: boolean; - discordControlApiBaseUrl: string; - discordControlApiToken?: string; - discordControlCallerId?: string; enableDirigentPolicyTool: boolean; }; @@ -563,22 +77,21 @@ export default { api.on("gateway_start", () => { api.logger.info(`dirigent: gateway_start event received`); + const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + // Check no-reply-api server file exists - const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); + const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs"); api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`); - // Additional debug: list what's in the parent directory - const parentDir = path.resolve(pluginDir, ".."); + // Additional debug: list what's in the plugin directory try { - const entries = fs.readdirSync(parentDir); - api.logger.info(`dirigent: parent dir (${parentDir}) entries: ${JSON.stringify(entries)}`); + const entries = fs.readdirSync(pluginDir); + api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`); } catch (e) { - api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`); + api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`); } - startNoReplyApi(api.logger, pluginDir); - - const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); if (live.moderatorBotToken) { @@ -596,793 +109,101 @@ export default { api.logger.info("dirigent: gateway stopping, services shut down"); }); - // ── Helper: execute Discord control API action ── - async function executeDiscordAction(action: DiscordControlAction, params: Record) { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - enableDiscordControlTool?: boolean; - }; - if (live.enableDiscordControlTool === false) { - return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; - } - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action }); - const headers: Record = { "Content-Type": "application/json" }; - if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; - if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; - const r = await fetch(`${baseUrl}/v1/discord/action`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const text = await r.text(); - if (!r.ok) { - return { content: [{ type: "text", text: `discord action failed (${r.status}): ${text}` }], isError: true }; - } - return { content: [{ type: "text", text }] }; - } - - // ── Discord control tools ── - - api.registerTool( - { - name: "dirigent_discord_channel_create", - description: "Create a private Discord channel with specific user/role permissions.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - 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) { - return executeDiscordAction("channel-private-create", params); - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_discord_channel_update", - description: "Update permissions on an existing private Discord channel.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - 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) { - return executeDiscordAction("channel-private-update", params); - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_discord_member_list", - description: "List members of a Discord guild.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - guildId: { type: "string" }, - limit: { type: "number" }, - after: { type: "string" }, - fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, - }, - required: [], - }, - async execute(_id: string, params: Record) { - return executeDiscordAction("member-list", params); - }, - }, - { optional: false }, - ); - - // ── Policy tools ── - - api.registerTool( - { - name: "dirigent_policy_get", - description: "Get all Dirigent channel policies.", - parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, - async execute() { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; - if (live.enableDirigentPolicyTool === false) { - return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; - } - ensurePolicyStateLoaded(api, live); - return { - content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], - }; - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_policy_set", - description: "Set or update a Dirigent channel policy.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - channelId: { type: "string" }, - listMode: { type: "string", enum: ["human-list", "agent-list"] }, - humanList: { type: "array", items: { type: "string" } }, - agentList: { type: "array", items: { type: "string" } }, - endSymbols: { type: "array", items: { type: "string" } }, - }, - required: ["channelId"], - }, - async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; - if (live.enableDirigentPolicyTool === false) { - return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; - } - ensurePolicyStateLoaded(api, live); - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - const next: ChannelPolicy = { - listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, - humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, - agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, - endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, - }; - policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_policy_delete", - description: "Delete a Dirigent channel policy.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - channelId: { type: "string" }, - }, - required: ["channelId"], - }, - async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; - if (live.enableDirigentPolicyTool === false) { - return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; - } - ensurePolicyStateLoaded(api, live); - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - delete policyState.channelPolicies[channelId]; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - }, - }, - { optional: false }, - ); + // Register tools + registerDirigentTools({ + api, + baseConfig: baseConfig as DirigentConfig, + pickDefined, + getLivePluginConfig, + }); // Turn management is handled internally by the plugin (not exposed as tools). // Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control. - api.on("message_received", async (event, ctx) => { - try { - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake. - // Extract the real Discord channel ID from conversationId or event.to. - 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))}`); - } - - // Turn management on message received - if (preChannelId) { - ensureTurnOrder(api, preChannelId); - // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. - // The actual sender ID is in event.metadata.senderId. - const metadata = (e as Record).metadata as Record | undefined; - const from = (typeof metadata?.senderId === "string" && metadata.senderId) - || (typeof (e as Record).from === "string" ? (e as Record).from as string : ""); - - // Ignore moderator bot messages — they don't affect turn state - const moderatorUserId = getModeratorUserId(livePre); - if (moderatorUserId && from === moderatorUserId) { - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); - } - // Don't call onNewMessage — moderator messages are transparent to turn logic - } else { - const humanList = livePre.humanList || livePre.bypassUserIds || []; - const isHuman = humanList.includes(from); - const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; - - // Track which bot accounts are present in this channel - if (senderAccountId && senderAccountId !== "default") { - const isNew = recordChannelAccount(preChannelId, senderAccountId); - if (isNew) { - // Re-initialize turn order with updated channel membership - ensureTurnOrder(api, preChannelId); - api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); - } - } - - // Human @mention override: when a human mentions specific agents, - // temporarily override the turn order to only those agents. - if (isHuman) { - const messageContent = (e as Record).content as string - || (e as Record).text as string - || ""; - const mentionedUserIds = extractMentionedUserIds(messageContent); - - if (mentionedUserIds.length > 0) { - // Build reverse map: userId → accountId - const userIdMap = buildUserIdToAccountIdMap(api); - // Exclude moderator bot from mention targets - const mentionedAccountIds = mentionedUserIds - .map(uid => userIdMap.get(uid)) - .filter((aid): aid is string => !!aid); - - if (mentionedAccountIds.length > 0) { - 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))}`); - } - // Skip normal onNewMessage — override already set currentSpeaker - } else { - // No valid agents in mentions, fall through to normal handling - onNewMessage(preChannelId, senderAccountId, isHuman); - } - } else { - // Mentioned users aren't agents, normal human message - onNewMessage(preChannelId, senderAccountId, isHuman); - } - } else { - // No mentions, normal human message - 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)}`); - } + registerMessageReceivedHook({ + api, + baseConfig: baseConfig as DirigentConfig, + getLivePluginConfig, + shouldDebugLog, + debugCtxSummary, + ensureTurnOrder, + getModeratorUserId, + recordChannelAccount, + extractMentionedUserIds, + buildUserIdToAccountIdMap, }); - 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).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, ctx.messageProvider, ctx.channelId); - // Fallback: extract channelId from sessionKey (format "agent::discord:channel:") - if (!derived.channelId && key) { - const skMatch = key.match(/:channel:(\d+)$/); - if (skMatch) derived.channelId = skMatch[1]; - } - // Only proceed if: discord channel AND prompt contains untrusted metadata - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; - - // Always save channelId and accountId mappings for use in later hooks - 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, - 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).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } - - // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result. - // This ensures only the current speaker can respond even for human messages. - if (derived.channelId) { - ensureTurnOrder(api, derived.channelId); - const accountId = resolveAccountId(api, ctx.agentId || ""); - if (accountId) { - const turnCheck = checkTurn(derived.channelId, accountId); - if (!turnCheck.allowed) { - // Forced no-reply - record this session as not allowed to speak - 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, - }; - } - // Allowed to speak - record this session as allowed - 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 prompt = ((event as Record).prompt as string) || ""; - const hasConvMarker = 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).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `decision=${rec.decision.reason} ` + - `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + - `hasConvMarker=${hasConvMarker} 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, - }; + registerBeforeModelResolveHook({ + api, + baseConfig: baseConfig as DirigentConfig, + sessionDecision, + sessionAllowed, + sessionChannelId, + sessionAccountId, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveAccountId, + pruneDecisionMap, + shouldDebugLog, + ensureTurnOrder, }); - 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).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies, - 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).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } - - sessionDecision.delete(key); - - // Only inject once per session (one-time injection) - 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; - } - - // Resolve end symbols from config/policy for dynamic instruction - const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); - 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); - - // Inject agent identity for group chats (includes userId now) - let identity = ""; - if (isGroupChat && ctx.agentId) { - const idStr = buildAgentIdentity(api, ctx.agentId); - if (idStr) identity = idStr + "\n\n"; - } - - // Add scheduling identifier instruction for group chats - let schedulingInstruction = ""; - if (isGroupChat) { - schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); - } - - // Mark session as injected (one-time injection) - 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 }; + registerBeforePromptBuildHook({ + api, + baseConfig: baseConfig as DirigentConfig, + sessionDecision, + sessionInjected, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + buildEndMarkerInstruction, + buildSchedulingIdentifierInstruction, + buildAgentIdentity, }); // Register slash commands for Discord - api.registerCommand({ - name: "dirigent", - description: "Dirigent channel policy management", - 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` }; - } - - 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 }; - }, + registerDirigentCommand({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + persistPolicies, + ensurePolicyStateLoaded, + getLivePluginConfig, }); // Handle NO_REPLY detection before message write - 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 ?? {}))}`, - ); - - let 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).message as Record | 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).text === "string") { - content += (part as Record).text; - } - } - } - } - if (!content) { - content = ((event as Record).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); - - 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])}`, - ); - - // Wait identifier: agent wants a human reply → all agents go silent - 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; - } - - 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; - } - - // Trigger moderator handoff message using scheduling identifier format - 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) { - 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)}`); - } + registerBeforeMessageWriteHook({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + ensureTurnOrder, + resolveDiscordUserId, + sendModeratorMessage, }); // Turn advance: when an agent sends a message, check if it signals end of turn - api.on("message_sent", async (event, ctx) => { - try { - const key = ctx.sessionKey; - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - - 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) { - const skMatch = key.match(/:channel:(\d+)$/); - if (skMatch) channelId = skMatch[1]; - } - 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); - - 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; - - // Skip if turn was already advanced in before_message_write - 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; - } - - // Wait identifier detection (fallback if not caught in before_message_write) - 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}`, - ); - // Moderator handoff using scheduling identifier format - if (wasNoReply && nextSpeaker && live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const schedulingId = live.schedulingIdentifier || "➡️"; - const handoffMsg = `<@${nextUserId}>${schedulingId}`; - 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)}`); - } + registerMessageSentHook({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + resolveDiscordUserId, + sendModeratorMessage, }); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index b901262..ba0c07f 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -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" } diff --git a/plugin/policy/store.ts b/plugin/policy/store.ts new file mode 100644 index 0000000..1b4479e --- /dev/null +++ b/plugin/policy/store.ts @@ -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; +}; + +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; + 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}`); +} diff --git a/plugin/rules.ts b/plugin/rules.ts index aa1dd64..3750562 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -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; }; diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts new file mode 100644 index 0000000..4180ada --- /dev/null +++ b/plugin/tools/register-tools.ts @@ -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) => Record; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; +}; + +function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null { + const root = (api.config as Record) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + + 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) { + 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) { + 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) { + return executeDiscordAction("channel-private-update", params); + }, + }, { optional: false }); +} diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs deleted file mode 100755 index 7669627..0000000 --- a/scripts/install-dirigent-openclaw.mjs +++ /dev/null @@ -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 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 ]"); - 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. ───────────────────────────────────── - 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); - } -} diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100755 index 0000000..4f70595 --- /dev/null +++ b/scripts/install.mjs @@ -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 ] [--no-reply-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); +} diff --git a/scripts/smoke-discord-control.sh b/scripts/smoke-discord-control.sh deleted file mode 100755 index 613be5c..0000000 --- a/scripts/smoke-discord-control.sh +++ /dev/null @@ -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"