Merge pull request 'feat: split dirigent_tools + human @mention override' (#14) from feat/split-tools-and-mention-override into main
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- **Split `dirigent_tools` into 6 individual tools**:
|
||||
- Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list`
|
||||
- Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete`
|
||||
- Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands)
|
||||
- **Human @mention override**: When a `humanList` user @mentions specific agents:
|
||||
- Temporarily overrides the speaking order to only mentioned agents
|
||||
- Agents cycle in their original turn-order position
|
||||
- After all mentioned agents have spoken, original order restores and state goes dormant
|
||||
- Handles edge cases: all NO_REPLY, reset, non-agent mentions
|
||||
- **Wait for human reply**: New `waitIdentifier` (default: `👤`):
|
||||
- Agent ends with `👤` to signal it needs a human response
|
||||
- All agents go silent (routed to no-reply model) until a human speaks
|
||||
- Prompt injection warns agents to use sparingly (only when human is actively participating)
|
||||
- **Installer improvements**:
|
||||
- Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR` → `openclaw` CLI → `~/.openclaw`)
|
||||
- Plugin installed to `$(openclaw_dir)/plugins/dirigent`
|
||||
- New `--update` mode: pulls latest from git `latest` branch and reinstalls
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- **Project renamed from WhisperGate to Dirigent**
|
||||
@@ -24,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)
|
||||
|
||||
158
FEAT.md
Normal file
158
FEAT.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Dirigent — Feature List
|
||||
|
||||
All implemented features across all versions.
|
||||
|
||||
---
|
||||
|
||||
## Core: Rule-Based No-Reply Gate
|
||||
- Deterministic logic in `before_model_resolve` hook decides whether to route to no-reply model
|
||||
- **human-list** mode: humanList senders bypass gate; others need end symbol to pass
|
||||
- **agent-list** mode: agentList senders need end symbol; others bypass
|
||||
- Non-Discord messages skip entirely
|
||||
- DM sessions (no metadata) always bypass
|
||||
- Per-channel policy overrides via JSON file (runtime-updateable)
|
||||
|
||||
## Core: End-Symbol Enforcement
|
||||
- Injects prompt instruction: "Your response MUST end with 🔚"
|
||||
- Gateway keywords (NO_REPLY, HEARTBEAT_OK) exempt from end symbol
|
||||
- Group chats get additional rule: "If not relevant, reply NO_REPLY"
|
||||
- End symbols configurable per-channel via policy
|
||||
|
||||
## Core: No-Reply API
|
||||
- OpenAI-compatible API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
|
||||
- Always returns `NO_REPLY` — used as the override model target
|
||||
- Optional bearer auth (`AUTH_TOKEN`)
|
||||
- Auto-started/stopped with gateway lifecycle
|
||||
|
||||
## Turn-Based Speaking (Multi-Bot)
|
||||
- Only the current speaker is allowed to respond; others forced to no-reply model
|
||||
- Turn order auto-populated from bot accounts seen in each channel
|
||||
- Turn advances on end-symbol (successful speech) or NO_REPLY
|
||||
- Successful speech resets NO_REPLY cycle counter
|
||||
- If all bots NO_REPLY in a cycle → channel goes **dormant**
|
||||
- Dormant reactivation: human message → first in order; bot message → next after sender
|
||||
- Turn timeout: auto-advance after 60s of inactivity
|
||||
- Manual control: `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset`
|
||||
|
||||
## Human @Mention Override *(v0.3.0)*
|
||||
- When a `humanList` user @mentions specific agents (`<@USER_ID>`):
|
||||
- Extracts mentioned Discord user IDs from message content
|
||||
- Maps userIds → accountIds via reverse bot token lookup
|
||||
- Filters to agents in the current turn order
|
||||
- Orders by their position in the current turn order
|
||||
- Temporarily replaces speaking order with only those agents
|
||||
- Cycle: a → b → c → (back to a) → restore original order, go dormant
|
||||
- Edge cases:
|
||||
- All override agents NO_REPLY → restore + dormant
|
||||
- `resetTurn` clears any active override
|
||||
- Human message without mentions → restores override, normal flow
|
||||
- Mentioned users not in turn order → ignored
|
||||
|
||||
## Agent Identity Injection
|
||||
- Group chat prompts include: agent name, Discord accountId, Discord userId
|
||||
- userId resolved from bot token (base64 first segment)
|
||||
|
||||
## Wait for Human Reply *(v0.3.0)*
|
||||
- Configurable wait identifier (default: `👤`)
|
||||
- Agent ends message with `👤` instead of `🔚` when it needs a human to reply
|
||||
- Triggers "waiting for human" state:
|
||||
- All agents routed to no-reply model
|
||||
- Turn manager goes dormant
|
||||
- State clears automatically when a human sends a message
|
||||
- Prompt injection tells agents:
|
||||
- Use wait identifier only when confident the human is actively participating
|
||||
- Do NOT use it speculatively
|
||||
- Works with mention override: wait identifier during override also triggers waiting state
|
||||
|
||||
## Scheduling Identifier
|
||||
- Configurable identifier (default: `➡️`) used for moderator handoff
|
||||
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic scheduling signal)
|
||||
- Agent prompt explains: identifier is meaningless — check chat history, decide whether to reply
|
||||
- If nothing to say → NO_REPLY
|
||||
|
||||
## Moderator Bot Presence
|
||||
- Maintains Discord Gateway WebSocket connection for moderator bot
|
||||
- Shows "online" status with "Moderating" activity
|
||||
- Handles reconnect, resume, heartbeat, invalid session recovery
|
||||
- Singleton guard prevents duplicate connections
|
||||
- Sends handoff messages to trigger next speaker's turn
|
||||
|
||||
## Individual Tools *(v0.3.0)*
|
||||
Six standalone tools (split from former monolithic `dirigent_tools`):
|
||||
|
||||
### Discord Control
|
||||
- **`dirigent_discord_channel_create`** — Create private Discord channel with user/role permissions
|
||||
- **`dirigent_discord_channel_update`** — Update permissions on existing private channel
|
||||
- **`dirigent_discord_member_list`** — List guild members with pagination and field projection
|
||||
|
||||
### Policy Management
|
||||
- **`dirigent_policy_get`** — Get all channel policies
|
||||
- **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols)
|
||||
- **`dirigent_policy_delete`** — Delete a channel policy
|
||||
|
||||
### Turn Management (internal only — not exposed as tools)
|
||||
Turn management is handled entirely by the plugin. Manual control via slash commands:
|
||||
- `/dirigent turn-status` — Show turn state
|
||||
- `/dirigent turn-advance` — Manually advance to next speaker
|
||||
- `/dirigent turn-reset` — Reset turn order (go dormant, clear overrides)
|
||||
|
||||
## Slash Command: `/dirigent`
|
||||
- `status` — Show all channel policies
|
||||
- `turn-status` — Show turn state for current channel
|
||||
- `turn-advance` — Manually advance turn
|
||||
- `turn-reset` — Reset turn order
|
||||
|
||||
## Project Rename (WhisperGate → Dirigent) *(v0.2.0)*
|
||||
- All plugin ids, tool names, config keys, file paths, docs updated
|
||||
- Legacy `whispergate` config key still supported as fallback
|
||||
|
||||
## Installer Script *(updated v0.3.0)*
|
||||
- `scripts/install-dirigent-openclaw.mjs`
|
||||
- `--install` / `--uninstall` / `--update` modes
|
||||
- **Dynamic OpenClaw dir resolution**: `$OPENCLAW_DIR` → `openclaw config get dataDir` → `~/.openclaw`
|
||||
- Builds dist and copies to `$(openclaw_dir)/plugins/dirigent`
|
||||
- `--update`: pulls latest from git `latest` branch, then reinstalls
|
||||
- Auto-reinstall (uninstall + install) if already installed
|
||||
- Backup before changes, rollback on failure
|
||||
- Records stored in `$(openclaw_dir)/dirigent-install-records/`
|
||||
|
||||
## Discord Control API (Sidecar)
|
||||
- Private channel create/update with permission overwrites
|
||||
- Member list with pagination + field projection
|
||||
- Guardrails: action validation, id-list limits, response-size limit
|
||||
- (Migrated) Discord control now runs in-plugin via direct Discord REST (no companion service)
|
||||
|
||||
---
|
||||
|
||||
## NEW_FEAT 合并记录(原 NEW_FEAT.md)
|
||||
|
||||
### 背景与目标
|
||||
- 解决 turn 初始化依赖被动观察(`recordChannelAccount`)导致 `currentSpeaker` 空值的问题。
|
||||
- 将 Discord control 从 sidecar 迁移到插件内模块。
|
||||
- 采用 channel 成员缓存(内存 + 本地持久化),避免轮询。
|
||||
|
||||
### 关键实现方向
|
||||
- 统一 channelId 解析链路,避免 `channel=discord` 错位。
|
||||
- `before_model_resolve / before_prompt_build` 与消息 hook 使用一致解析策略。
|
||||
- 清理未使用函数,降低排障噪音。
|
||||
- 模块化重构:`index.ts` 作为 wiring,逻辑拆入 `hooks/core/tools/policy/commands`。
|
||||
|
||||
### Channel 成员缓存
|
||||
- 缓存文件:`~/.openclaw/dirigent-channel-members.json`
|
||||
- 启动加载、运行时原子写盘。
|
||||
- 记录字段包含 `botAccountIds/updatedAt/source/guildId`。
|
||||
- 首次无缓存时允许 bootstrap 拉取,随后走本地缓存。
|
||||
|
||||
### Turn 初始化改造
|
||||
- `ensureTurnOrder(channelId)` 基于缓存中的 botAccountIds 初始化。
|
||||
- 不再仅依赖“已见账号”被动记录。
|
||||
- 提升新频道首条消息场景的稳定性。
|
||||
|
||||
### 权限计算(频道可见成员)
|
||||
- 通过 guild 成员 + roles + channel overwrites 计算 `VIEW_CHANNEL` 可见性。
|
||||
- 用于内部 turn bootstrap,不对外暴露为公共工具。
|
||||
|
||||
### 风险与注意
|
||||
- 权限位计算必须严格按 Discord 规则。
|
||||
- 缓存读写需原子化,防并发损坏。
|
||||
- 通过 `updatedAt/source/guildId` 提高可观测性与排障效率。
|
||||
7
Makefile
7
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin
|
||||
|
||||
check:
|
||||
cd plugin && npm run check
|
||||
@@ -24,8 +24,3 @@ render-config:
|
||||
package-plugin:
|
||||
node scripts/package-plugin.mjs
|
||||
|
||||
discord-control-up:
|
||||
cd discord-control-api && node server.mjs
|
||||
|
||||
smoke-discord-control:
|
||||
./scripts/smoke-discord-control.sh
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# New Features
|
||||
|
||||
1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。
|
||||
2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。
|
||||
30
README.md
30
README.md
@@ -32,13 +32,17 @@ Dirigent adds deterministic logic **before model selection** and **turn-based sp
|
||||
- **Agent identity injection**
|
||||
- Injects agent name, Discord accountId, and Discord userId into group chat prompts
|
||||
|
||||
- **Human @mention override**
|
||||
- When a `humanList` user @mentions agents, temporarily overrides turn order
|
||||
- Only mentioned agents cycle; original order restores when cycle completes
|
||||
|
||||
- **Per-channel policy runtime**
|
||||
- Policies stored in a standalone JSON file
|
||||
- Update at runtime via `dirigent_tools` (memory first → persist to file)
|
||||
- Update at runtime via `dirigent_policy_set` / `dirigent_policy_delete` tools
|
||||
|
||||
- **Discord control actions (optional)**
|
||||
- Private channel create/update + member list
|
||||
- Unified via `dirigent_tools`
|
||||
- Via `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` tools
|
||||
|
||||
---
|
||||
|
||||
@@ -46,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`)
|
||||
@@ -74,12 +78,21 @@ Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
|
||||
|
||||
## Runtime tools & commands
|
||||
|
||||
### Tool: `dirigent_tools`
|
||||
### Tools (6 individual tools)
|
||||
|
||||
Actions:
|
||||
- `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
- `turn-status`, `turn-advance`, `turn-reset`
|
||||
- `channel-private-create`, `channel-private-update`, `member-list`
|
||||
**Discord control:**
|
||||
- `dirigent_discord_channel_create` — Create private channel
|
||||
- `dirigent_discord_channel_update` — Update channel permissions
|
||||
- `dirigent_discord_member_list` — List guild members
|
||||
|
||||
**Policy management:**
|
||||
- `dirigent_policy_get` — Get all policies
|
||||
- `dirigent_policy_set` — Set/update channel policy
|
||||
- `dirigent_policy_delete` — Delete channel policy
|
||||
|
||||
> Turn management is internal to the plugin (not exposed as tools).
|
||||
|
||||
> See `FEAT.md` for full feature documentation.
|
||||
|
||||
### Slash command (Discord)
|
||||
|
||||
@@ -100,6 +113,7 @@ Common options (see `docs/INTEGRATION.md`):
|
||||
- `humanList`, `agentList`
|
||||
- `endSymbols`
|
||||
- `schedulingIdentifier` (default `➡️`)
|
||||
- `waitIdentifier` (default `👤`) — agent ends with this to pause all agents until human replies
|
||||
- `channelPoliciesFile` (per-channel overrides)
|
||||
- `moderatorBotToken` (handoff messages)
|
||||
- `enableDebugLogs`, `debugLogChannelIds`
|
||||
|
||||
49
TASKLIST.md
49
TASKLIST.md
@@ -39,11 +39,48 @@
|
||||
|
||||
---
|
||||
|
||||
## 6) Split dirigent_tools into Individual Tools ✅
|
||||
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
|
||||
- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`):
|
||||
- `dirigent_discord_channel_create` — Create private Discord channel
|
||||
- `dirigent_discord_channel_update` — Update channel permissions
|
||||
- `dirigent_discord_member_list` — List guild members
|
||||
- `dirigent_policy_get` — Get all channel policies
|
||||
- `dirigent_policy_set` — Set/update a channel policy
|
||||
- `dirigent_policy_delete` — Delete a channel policy
|
||||
- Turn management (status/advance/reset) NOT exposed as tools — purely internal plugin logic, accessible via `/dirigent` slash commands.
|
||||
- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication.
|
||||
- **Done**: All tools registered individually with specific parameter schemas.
|
||||
|
||||
## 7) Human @Mention Override ✅
|
||||
- When a message from a `humanList` user contains `<@USER_ID>` mentions:
|
||||
- Extract mentioned Discord user IDs from the message content.
|
||||
- Map user IDs → accountIds via bot token decoding (reverse of `resolveDiscordUserId`).
|
||||
- Filter to agents in the current turn order.
|
||||
- Order by their position in the current turn order.
|
||||
- Temporarily replace the speaking order with only those agents.
|
||||
- After the cycle returns to the first agent, restore the original order and go dormant.
|
||||
- Edge cases handled:
|
||||
- All override agents NO_REPLY → restore original order, go dormant.
|
||||
- `resetTurn` clears any active override.
|
||||
- New human message without mentions → restores override before normal handling.
|
||||
- Mentioned users not in turn order → ignored, normal flow.
|
||||
- New functions in `turn-manager.ts`: `setMentionOverride()`, `hasMentionOverride()`.
|
||||
- New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`.
|
||||
- **Done**: Override logic integrated in `message_received` handler.
|
||||
|
||||
## 8) Wait for Human Reply ✅
|
||||
- Added configurable `waitIdentifier` (default: `👤`) to config and config schema.
|
||||
- Prompt injection in group chats: tells agents to end with `👤` instead of end symbol when they need a human response. Warns to use sparingly — only when human is actively participating.
|
||||
- Detection in `before_message_write` and `message_sent`: if last char matches wait identifier → `setWaitingForHuman(channelId)`.
|
||||
- Turn manager `waitingForHuman` state:
|
||||
- `checkTurn()` blocks all agents (`reason: "waiting_for_human"`) → routed to no-reply model.
|
||||
- `onNewMessage()` clears `waitingForHuman` on human message → normal flow resumes.
|
||||
- Non-human messages ignored while waiting.
|
||||
- `resetTurn()` also clears waiting state.
|
||||
- **Done**: Full lifecycle implemented across turn-manager, rules, and index.
|
||||
|
||||
---
|
||||
|
||||
## Open Items / Notes
|
||||
- User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed.
|
||||
- **New feature: Human @ list override**
|
||||
- When a message is from a user in `humanList` and contains `<@USER_ID>` mentions:
|
||||
- Detect which agents are @-mentioned (e.g., a, b, c).
|
||||
- Determine their order in the current speaking order list (e.g., a → b → c).
|
||||
- Temporarily replace the speaking order with `[a, b, c]` and cycle a → b → c.
|
||||
- After the cycle returns to **a** again, restore the original speaking order list.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "dirigent-discord-control-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT || 8790);
|
||||
const authToken = process.env.AUTH_TOKEN || "";
|
||||
const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true";
|
||||
const discordToken = process.env.DISCORD_BOT_TOKEN || "";
|
||||
const discordBase = "https://discord.com/api/v10";
|
||||
|
||||
const enabledActions = {
|
||||
channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false",
|
||||
channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false",
|
||||
memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false",
|
||||
};
|
||||
|
||||
const allowedGuildIds = new Set(
|
||||
String(process.env.ALLOWED_GUILD_IDS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const allowedCallerIds = new Set(
|
||||
String(process.env.ALLOWED_CALLER_IDS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const BIT_VIEW_CHANNEL = 1024n;
|
||||
const BIT_SEND_MESSAGES = 2048n;
|
||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
||||
|
||||
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
|
||||
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
|
||||
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function fail(status, code, message, details) {
|
||||
return { status, code, message, details };
|
||||
}
|
||||
|
||||
function readCallerId(req) {
|
||||
const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
}
|
||||
|
||||
function ensureControlAuth(req) {
|
||||
if (requireAuthToken && !authToken) {
|
||||
throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty");
|
||||
}
|
||||
if (!authToken) return;
|
||||
const header = req.headers.authorization || "";
|
||||
if (header !== `Bearer ${authToken}`) {
|
||||
throw fail(401, "unauthorized", "invalid or missing bearer token");
|
||||
}
|
||||
|
||||
if (allowedCallerIds.size > 0) {
|
||||
const callerId = readCallerId(req);
|
||||
if (!callerId || !allowedCallerIds.has(callerId)) {
|
||||
throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDiscordToken() {
|
||||
if (!discordToken) {
|
||||
throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureGuildAllowed(guildId) {
|
||||
if (allowedGuildIds.size === 0) return;
|
||||
if (!allowedGuildIds.has(guildId)) {
|
||||
throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureActionEnabled(action) {
|
||||
if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) {
|
||||
throw fail(403, "action_disabled", "channel-private-create is disabled");
|
||||
}
|
||||
if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) {
|
||||
throw fail(403, "action_disabled", "channel-private-update is disabled");
|
||||
}
|
||||
if (action === "member-list" && !enabledActions.memberList) {
|
||||
throw fail(403, "action_disabled", "member-list is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
async function discordRequest(path, init = {}) {
|
||||
ensureDiscordToken();
|
||||
const headers = {
|
||||
Authorization: `Bot ${discordToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers || {}),
|
||||
};
|
||||
|
||||
const r = await fetch(`${discordBase}${path}`, { ...init, headers });
|
||||
const text = await r.text();
|
||||
let data = text;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {}
|
||||
|
||||
if (!r.ok) {
|
||||
throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toStringMask(v, fallback) {
|
||||
if (v === undefined || v === null || v === "") return String(fallback);
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number") return String(Math.floor(v));
|
||||
if (typeof v === "bigint") return String(v);
|
||||
throw fail(400, "invalid_mask", "invalid permission bit mask");
|
||||
}
|
||||
|
||||
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
|
||||
const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY;
|
||||
const denyDefault = BIT_VIEW_CHANNEL;
|
||||
|
||||
const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault);
|
||||
const targetAllow = toStringMask(allowMask, allowDefault);
|
||||
|
||||
const overwrites = [
|
||||
{
|
||||
id: guildId,
|
||||
type: 0,
|
||||
allow: "0",
|
||||
deny: everyoneDeny,
|
||||
},
|
||||
];
|
||||
|
||||
for (const roleId of allowedRoleIds) {
|
||||
overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
for (const userId of allowedUserIds) {
|
||||
overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
|
||||
return overwrites;
|
||||
}
|
||||
|
||||
function parseFieldList(input) {
|
||||
if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean);
|
||||
if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIdList(value, label) {
|
||||
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
|
||||
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
|
||||
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
|
||||
label,
|
||||
limit: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
size: arr.length,
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function pick(obj, keys) {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
if (k in obj) out[k] = obj[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectMember(member, fields) {
|
||||
if (!fields.length) return member;
|
||||
const base = pick(member, fields);
|
||||
if (fields.some((f) => f.startsWith("user.")) && member?.user) {
|
||||
const userFields = fields
|
||||
.filter((f) => f.startsWith("user."))
|
||||
.map((f) => f.slice(5))
|
||||
.filter(Boolean);
|
||||
base.user = pick(member.user, userFields);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function actionChannelPrivateCreate(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
const name = String(body.name || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
if (!name) throw fail(400, "bad_request", "name is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
type: Number.isInteger(body.type) ? body.type : 0,
|
||||
parent_id: body.parentId || undefined,
|
||||
topic: body.topic || undefined,
|
||||
position: Number.isInteger(body.position) ? body.position : undefined,
|
||||
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
|
||||
permission_overwrites: buildPrivateOverwrites({
|
||||
guildId,
|
||||
allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
|
||||
allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
|
||||
allowMask: body.allowMask,
|
||||
denyEveryoneMask: body.denyEveryoneMask,
|
||||
}),
|
||||
};
|
||||
|
||||
if (body.dryRun === true) {
|
||||
return { ok: true, action: "channel-private-create", dryRun: true, payload };
|
||||
}
|
||||
|
||||
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return { ok: true, action: "channel-private-create", channel };
|
||||
}
|
||||
|
||||
async function actionChannelPrivateUpdate(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
const channelId = String(body.channelId || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
if (!channelId) throw fail(400, "bad_request", "channelId is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
|
||||
const denyMask = toStringMask(body.denyMask, 0n);
|
||||
const mode = String(body.mode || "merge").trim();
|
||||
if (mode !== "merge" && mode !== "replace") {
|
||||
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
|
||||
}
|
||||
|
||||
const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
|
||||
const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
|
||||
const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
|
||||
|
||||
const existing = await discordRequest(`/channels/${channelId}`);
|
||||
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
|
||||
|
||||
let next = [];
|
||||
if (mode === "replace") {
|
||||
// keep @everyone deny if present, otherwise set one
|
||||
const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || {
|
||||
id: guildId,
|
||||
type: 0,
|
||||
allow: "0",
|
||||
deny: String(BIT_VIEW_CHANNEL),
|
||||
};
|
||||
next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) });
|
||||
} else {
|
||||
next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") }));
|
||||
}
|
||||
|
||||
const removeSet = new Set(removeTargetIds);
|
||||
if (removeSet.size > 0) {
|
||||
next = next.filter((o) => !removeSet.has(String(o.id)));
|
||||
}
|
||||
|
||||
const upsert = (id, type) => {
|
||||
const idx = next.findIndex((o) => String(o.id) === String(id));
|
||||
const row = { id: String(id), type, allow: allowMask, deny: denyMask };
|
||||
if (idx >= 0) next[idx] = row;
|
||||
else next.push(row);
|
||||
};
|
||||
|
||||
for (const userId of addUserIds) upsert(userId, 1);
|
||||
for (const roleId of addRoleIds) upsert(roleId, 0);
|
||||
|
||||
const payload = { permission_overwrites: next };
|
||||
|
||||
if (body.dryRun === true) {
|
||||
return { ok: true, action: "channel-private-update", dryRun: true, payload, mode };
|
||||
}
|
||||
|
||||
const channel = await discordRequest(`/channels/${channelId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return { ok: true, action: "channel-private-update", mode, channel };
|
||||
}
|
||||
|
||||
async function actionMemberList(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||
ensureGuildAllowed(guildId);
|
||||
|
||||
const limitRaw = Number(body.limit ?? 100);
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
const after = body.after ? String(body.after) : undefined;
|
||||
const fields = parseFieldList(body.fields);
|
||||
if (fields.length > MAX_MEMBER_FIELDS) {
|
||||
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
|
||||
limit: MAX_MEMBER_FIELDS,
|
||||
size: fields.length,
|
||||
});
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
if (after) qs.set("after", after);
|
||||
|
||||
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
|
||||
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
action: "member-list",
|
||||
guildId,
|
||||
count: Array.isArray(projected) ? projected.length : 0,
|
||||
fields: fields.length ? fields : undefined,
|
||||
members: projected,
|
||||
};
|
||||
|
||||
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
|
||||
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
|
||||
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
|
||||
bytes,
|
||||
limit: MAX_MEMBER_RESPONSE_BYTES,
|
||||
hint: "reduce limit or set fields projection",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleAction(body) {
|
||||
const action = String(body.action || "").trim();
|
||||
if (!action) throw fail(400, "bad_request", "action is required");
|
||||
ensureActionEnabled(action);
|
||||
|
||||
if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
|
||||
if (action === "channel-private-update") return await actionChannelPrivateUpdate(body);
|
||||
if (action === "member-list") return await actionMemberList(body);
|
||||
|
||||
throw fail(400, "unsupported_action", `unsupported action: ${action}`);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
service: "discord-control-api",
|
||||
authRequired: !!authToken || requireAuthToken,
|
||||
actionGates: enabledActions,
|
||||
guildAllowlistEnabled: allowedGuildIds.size > 0,
|
||||
limits: {
|
||||
maxMemberFields: MAX_MEMBER_FIELDS,
|
||||
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
|
||||
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 2_000_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
ensureControlAuth(req);
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
const result = await handleAction(parsed);
|
||||
return sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
return sendJson(res, err?.status || 500, {
|
||||
error: err?.code || "request_failed",
|
||||
message: String(err?.message || err),
|
||||
details: err?.details,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[discord-control-api] listening on :${port}`);
|
||||
});
|
||||
@@ -21,7 +21,6 @@
|
||||
"enableDirigentPolicyTool": true,
|
||||
"enableDebugLogs": false,
|
||||
"debugLogChannelIds": [],
|
||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
||||
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||
"discordControlCallerId": "agent-main"
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# Discord Control API
|
||||
|
||||
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
||||
|
||||
> 现在可以通过 Dirigent 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。
|
||||
> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `dirigent` 或 `discord_control`)。
|
||||
|
||||
1. 创建指定名单可见的私人频道
|
||||
2. 查看 server 成员列表(分页)
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cd discord-control-api
|
||||
export DISCORD_BOT_TOKEN='xxx'
|
||||
# 建议启用
|
||||
export AUTH_TOKEN='strong-token'
|
||||
# optional hard requirement
|
||||
# export REQUIRE_AUTH_TOKEN=true
|
||||
# optional action gates
|
||||
# export ENABLE_CHANNEL_PRIVATE_CREATE=true
|
||||
# export ENABLE_CHANNEL_PRIVATE_UPDATE=true
|
||||
# export ENABLE_MEMBER_LIST=true
|
||||
# optional allowlist
|
||||
# export ALLOWED_GUILD_IDS='123,456'
|
||||
# export ALLOWED_CALLER_IDS='agent-main,agent-admin'
|
||||
# optional limits
|
||||
# export MAX_MEMBER_FIELDS=20
|
||||
# export MAX_MEMBER_RESPONSE_BYTES=500000
|
||||
# export MAX_PRIVATE_MUTATION_TARGETS=200
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Health:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8790/health
|
||||
```
|
||||
|
||||
## Unified action endpoint
|
||||
|
||||
`POST /v1/discord/action`
|
||||
|
||||
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
||||
- Header: `X-OpenClaw-Caller-Id: <id>`(若配置了 `ALLOWED_CALLER_IDS`)
|
||||
- Body: `{ "action": "...", ... }`
|
||||
|
||||
---
|
||||
|
||||
## Action: channel-private-create
|
||||
|
||||
与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channel-private-create",
|
||||
"guildId": "123",
|
||||
"name": "private-room",
|
||||
"type": 0,
|
||||
"parentId": "456",
|
||||
"topic": "secret",
|
||||
"position": 3,
|
||||
"nsfw": false,
|
||||
"allowedUserIds": ["111", "222"],
|
||||
"allowedRoleIds": ["333"],
|
||||
"allowMask": "67648",
|
||||
"denyEveryoneMask": "1024",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。
|
||||
- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。
|
||||
- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。
|
||||
|
||||
---
|
||||
|
||||
## Action: channel-private-update
|
||||
|
||||
对现有频道的白名单/覆盖权限做增删改。
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channel-private-update",
|
||||
"guildId": "123",
|
||||
"channelId": "789",
|
||||
"mode": "merge",
|
||||
"addUserIds": ["111"],
|
||||
"addRoleIds": ["333"],
|
||||
"removeTargetIds": ["222"],
|
||||
"allowMask": "67648",
|
||||
"denyMask": "0",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `mode=merge`:在现有覆盖基础上增删
|
||||
- `mode=replace`:重建覆盖(保留/补上 @everyone deny)
|
||||
|
||||
---
|
||||
|
||||
## Action: member-list
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "member-list",
|
||||
"guildId": "123",
|
||||
"limit": 100,
|
||||
"after": "0",
|
||||
"fields": ["user.id", "user.username", "nick", "roles", "joined_at"]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `limit` 1~1000
|
||||
- `after` 用于分页(Discord snowflake)
|
||||
- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段
|
||||
|
||||
---
|
||||
|
||||
## Curl examples
|
||||
|
||||
示例文件:`docs/EXAMPLES.discord-control.json`
|
||||
|
||||
快速检查脚本:
|
||||
|
||||
```bash
|
||||
./scripts/smoke-discord-control.sh
|
||||
# with env
|
||||
# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
鉴权与内置风格对齐(简化版):
|
||||
- 控制面 token:`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN`
|
||||
- 调用者 allowlist:`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`)
|
||||
- action gate:`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST`
|
||||
- guild allowlist:`ALLOWED_GUILD_IDS`
|
||||
|
||||
- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。
|
||||
- 若无权限,Discord API 会返回 403 并透传错误细节。
|
||||
@@ -27,25 +27,22 @@ The script prints JSON for:
|
||||
|
||||
You can merge this snippet manually into your `openclaw.json`.
|
||||
|
||||
## Installer script (with rollback)
|
||||
|
||||
For production-like install with automatic rollback on error (Node-only installer):
|
||||
## Installer script
|
||||
|
||||
```bash
|
||||
node ./scripts/install-dirigent-openclaw.mjs --install
|
||||
node ./scripts/install.mjs --install
|
||||
# optional port override
|
||||
node ./scripts/install.mjs --install --no-reply-port 8787
|
||||
# or wrapper
|
||||
./scripts/install-dirigent-openclaw.sh --install
|
||||
```
|
||||
|
||||
Uninstall (revert all recorded config changes):
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
node ./scripts/install-dirigent-openclaw.mjs --uninstall
|
||||
node ./scripts/install.mjs --uninstall
|
||||
# or wrapper
|
||||
./scripts/install-dirigent-openclaw.sh --uninstall
|
||||
# or specify a record explicitly
|
||||
# RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \
|
||||
# node ./scripts/install-dirigent-openclaw.mjs --uninstall
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
@@ -64,12 +61,10 @@ Environment overrides:
|
||||
|
||||
The script:
|
||||
- writes via `openclaw config set ... --json`
|
||||
- creates config backup first
|
||||
- restores backup automatically if any install step fails
|
||||
- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status`
|
||||
- writes a change record for every install/uninstall:
|
||||
- directory: `~/.openclaw/dirigent-install-records/`
|
||||
- latest pointer: `~/.openclaw/dirigent-install-record-latest.json`
|
||||
- installs plugin + no-reply-api into `~/.openclaw/plugins`
|
||||
- updates `plugins.entries.dirigent` and `models.providers.<no-reply-provider>`
|
||||
- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`)
|
||||
- does not maintain install/uninstall record files
|
||||
|
||||
Policy state semantics:
|
||||
- channel policy file is loaded once into memory on startup
|
||||
|
||||
@@ -51,5 +51,5 @@ This PR delivers two tracks:
|
||||
## Rollback
|
||||
|
||||
- Disable plugin entry or remove plugin path from OpenClaw config
|
||||
- Stop `discord-control-api` process
|
||||
- (Legacy note) `discord-control-api` sidecar has been removed; Discord control is in-plugin now
|
||||
- Keep no-reply API stopped if not needed
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
1. Dirigent 基础静态与脚本测试
|
||||
2. no-reply-api 隔离集成测试
|
||||
3. discord-control-api 功能测试(dryRun + 实操)
|
||||
3. (历史)discord-control-api 功能测试(dryRun + 实操,当前版本已迁移为 in-plugin)
|
||||
|
||||
未覆盖:
|
||||
|
||||
@@ -53,7 +53,7 @@ make check check-rules test-api
|
||||
|
||||
---
|
||||
|
||||
### B. discord-control-api dryRun + 实操测试
|
||||
### B. (历史)discord-control-api dryRun + 实操测试(当前版本已迁移)
|
||||
|
||||
执行内容与结果:
|
||||
|
||||
@@ -113,7 +113,7 @@ make check check-rules test-api
|
||||
|
||||
### 2) 回归测试
|
||||
|
||||
- discord-control-api 引入后,不影响 Dirigent 原有流程
|
||||
- (历史结论)discord-control-api 引入后,不影响 Dirigent 原有流程;现已迁移为 in-plugin 实现
|
||||
- 规则校验脚本在最新代码继续稳定通过
|
||||
|
||||
### 3) 运行与安全校验
|
||||
|
||||
@@ -78,7 +78,7 @@ When current speaker NO_REPLYs, have **that bot** send a brief handoff message i
|
||||
**Challenges:**
|
||||
- Adds visible noise to the channel (could use a convention like a specific emoji reaction)
|
||||
- The no-reply'd bot can't send messages (it was silenced)
|
||||
- Could use the discord-control-api to send as a different bot
|
||||
- Could use in-plugin Discord REST control to send as a different bot (sidecar removed)
|
||||
|
||||
### 6. Timer-Based Retry (Pragmatic)
|
||||
|
||||
@@ -93,7 +93,7 @@ After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new messa
|
||||
**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be:
|
||||
|
||||
1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn:
|
||||
2. Use the discord-control-api to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
|
||||
2. Use in-plugin Discord REST control to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
|
||||
3. This real Discord message triggers OpenClaw to route it to all agents
|
||||
4. The turn manager allows only the (now-current) next speaker to respond
|
||||
5. The next speaker sees the original conversation context in their session history and responds appropriately
|
||||
|
||||
13
package.json
13
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@hangman-lab/dirigent",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/",
|
||||
"plugin/",
|
||||
"no-reply-api/",
|
||||
"discord-control-api/",
|
||||
|
||||
"docs/",
|
||||
"scripts/install-dirigent-openclaw.mjs",
|
||||
"scripts/install.mjs",
|
||||
"docker-compose.yml",
|
||||
"Makefile",
|
||||
"README.md",
|
||||
@@ -17,9 +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"
|
||||
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
|
||||
"postinstall": "node scripts/install.mjs --install",
|
||||
"uninstall": "node scripts/install.mjs --uninstall",
|
||||
"update": "node scripts/install.mjs --update"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
|
||||
@@ -39,7 +39,7 @@ Unified optional tool:
|
||||
- `bypassUserIds` (deprecated alias of `humanList`)
|
||||
- `endSymbols` (default ["🔚"])
|
||||
- `enableDiscordControlTool` (default true)
|
||||
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
|
||||
- Discord control actions are executed in-plugin via Discord REST API (no `discordControlApiBaseUrl` needed)
|
||||
- `discordControlApiToken`
|
||||
- `discordControlCallerId`
|
||||
- `enableDebugLogs` (default false)
|
||||
|
||||
73
plugin/channel-resolver.ts
Normal file
73
plugin/channel-resolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
|
||||
const candidates: unknown[] = [
|
||||
ctx.conversationId,
|
||||
ctx.OriginatingTo,
|
||||
event?.to,
|
||||
(event?.metadata as Record<string, unknown>)?.to,
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== "string" || !c.trim()) continue;
|
||||
const s = c.trim();
|
||||
|
||||
if (s.startsWith("channel:")) {
|
||||
const id = s.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (s.startsWith("discord:channel:")) {
|
||||
const id = s.slice("discord:channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (/^\d{15,}$/.test(s)) return s;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromSessionKey(sessionKey?: string): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
const canonical = sessionKey.match(/discord:(?:channel|group):(\d+)/);
|
||||
if (canonical?.[1]) return canonical[1];
|
||||
|
||||
const suffix = sessionKey.match(/:channel:(\d+)$/);
|
||||
if (suffix?.[1]) return suffix[1];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||
const marker = "Conversation info (untrusted metadata):";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return undefined;
|
||||
const tail = text.slice(idx + marker.length);
|
||||
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
if (!m) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromConversationMetadata(conv: Record<string, unknown>): string | undefined {
|
||||
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
|
||||
const id = conv.chat_id.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (typeof conv.conversation_label === "string") {
|
||||
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
|
||||
if (labelMatch?.[1]) return labelMatch[1];
|
||||
}
|
||||
|
||||
if (typeof conv.channel_id === "string" && /^\d+$/.test(conv.channel_id)) {
|
||||
return conv.channel_id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
133
plugin/commands/dirigent-command.ts
Normal file
133
plugin/commands/dirigent-command.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type CommandDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { filePath: string; channelPolicies: Record<string, unknown> };
|
||||
persistPolicies: (api: OpenClawPluginApi) => void;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
};
|
||||
|
||||
export function registerDirigentCommand(deps: CommandDeps): void {
|
||||
const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps;
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent",
|
||||
description: "Dirigent runtime commands",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const args = cmdCtx.args || "";
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const subCmd = parts[0] || "help";
|
||||
|
||||
if (subCmd === "help") {
|
||||
return {
|
||||
text:
|
||||
`Dirigent commands:\n` +
|
||||
`/dirigent status - Show current channel status\n` +
|
||||
`/dirigent turn-status - Show turn-based speaking status\n` +
|
||||
`/dirigent turn-advance - Manually advance turn\n` +
|
||||
`/dirigent turn-reset - Reset turn order\n` +
|
||||
`/dirigent_policy get <discordChannelId>\n` +
|
||||
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
|
||||
`/dirigent_policy delete <discordChannelId>`,
|
||||
};
|
||||
}
|
||||
|
||||
if (subCmd === "status") {
|
||||
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-status") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-advance") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-reset") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
resetTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
|
||||
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
|
||||
},
|
||||
});
|
||||
|
||||
api.registerCommand({
|
||||
name: "dirigent_policy",
|
||||
description: "Dirigent channel policy CRUD",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const live = getLivePluginConfig(api, baseConfig);
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const args = (cmdCtx.args || "").trim();
|
||||
if (!args) {
|
||||
return {
|
||||
text:
|
||||
"Usage:\n" +
|
||||
"/dirigent_policy get <discordChannelId>\n" +
|
||||
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
|
||||
"/dirigent_policy delete <discordChannelId>",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/);
|
||||
const op = (opRaw || "").toLowerCase();
|
||||
const channelId = (channelIdRaw || "").trim();
|
||||
|
||||
if (!channelId || !/^\d+$/.test(channelId)) {
|
||||
return { text: "channelId is required and must be numeric Discord channel id", isError: true };
|
||||
}
|
||||
|
||||
if (op === "get") {
|
||||
const policy = (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
|
||||
}
|
||||
|
||||
if (op === "delete") {
|
||||
delete (policyState.channelPolicies as Record<string, unknown>)[channelId];
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, deleted: true }) };
|
||||
}
|
||||
|
||||
if (op === "set") {
|
||||
const jsonText = rest.join(" ").trim();
|
||||
if (!jsonText) {
|
||||
return { text: "set requires <policy-json>", isError: true };
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
return { text: `invalid policy-json: ${String(e)}`, isError: true };
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (typeof parsed.listMode === "string") next.listMode = parsed.listMode;
|
||||
if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String);
|
||||
if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String);
|
||||
if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String);
|
||||
|
||||
(policyState.channelPolicies as Record<string, unknown>)[channelId] = next;
|
||||
persistPolicies(api);
|
||||
return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) };
|
||||
}
|
||||
|
||||
return { text: `unsupported op: ${op}. use get|set|delete`, isError: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
141
plugin/core/channel-members.ts
Normal file
141
plugin/core/channel-members.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { buildUserIdToAccountIdMap } from "./identity.js";
|
||||
|
||||
const PERM_VIEW_CHANNEL = 1n << 10n;
|
||||
const PERM_ADMINISTRATOR = 1n << 3n;
|
||||
|
||||
function toBigIntPerm(v: unknown): bigint {
|
||||
if (typeof v === "bigint") return v;
|
||||
if (typeof v === "number") return BigInt(Math.trunc(v));
|
||||
if (typeof v === "string" && v.trim()) {
|
||||
try {
|
||||
return BigInt(v.trim());
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
return 0n;
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: r.ok, status: r.status, json, text };
|
||||
}
|
||||
|
||||
function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bigint>, channelOverwrites: any[]): boolean {
|
||||
const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : [];
|
||||
let perms = guildRoles.get(guildId) || 0n;
|
||||
for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n;
|
||||
|
||||
if ((perms & PERM_ADMINISTRATOR) !== 0n) return true;
|
||||
|
||||
let everyoneAllow = 0n;
|
||||
let everyoneDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) {
|
||||
everyoneAllow = toBigIntPerm(ow?.allow);
|
||||
everyoneDeny = toBigIntPerm(ow?.deny);
|
||||
break;
|
||||
}
|
||||
}
|
||||
perms = (perms & ~everyoneDeny) | everyoneAllow;
|
||||
|
||||
let roleAllow = 0n;
|
||||
let roleDeny = 0n;
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 0) continue;
|
||||
const id = String(ow?.id || "");
|
||||
if (id !== guildId && roleIds.includes(id)) {
|
||||
roleAllow |= toBigIntPerm(ow?.allow);
|
||||
roleDeny |= toBigIntPerm(ow?.deny);
|
||||
}
|
||||
}
|
||||
perms = (perms & ~roleDeny) | roleAllow;
|
||||
|
||||
for (const ow of channelOverwrites) {
|
||||
if (roleOrMemberType(ow?.type) !== 1) continue;
|
||||
if (String(ow?.id || "") === String(member?.user?.id || "")) {
|
||||
const allow = toBigIntPerm(ow?.allow);
|
||||
const deny = toBigIntPerm(ow?.deny);
|
||||
perms = (perms & ~deny) | allow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (perms & PERM_VIEW_CHANNEL) !== 0n;
|
||||
}
|
||||
|
||||
function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return rec.token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
|
||||
const token = getAnyDiscordToken(api);
|
||||
if (!token) return [];
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
if (!ch.ok) return [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
if (!guildId) return [];
|
||||
|
||||
const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`);
|
||||
if (!rolesResp.ok) return [];
|
||||
const rolePerms = new Map<string, bigint>();
|
||||
for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) {
|
||||
rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions));
|
||||
}
|
||||
|
||||
const members: any[] = [];
|
||||
let after = "";
|
||||
while (true) {
|
||||
const q = new URLSearchParams({ limit: "1000" });
|
||||
if (after) q.set("after", after);
|
||||
const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`);
|
||||
if (!mResp.ok) return [];
|
||||
const batch = Array.isArray(mResp.json) ? mResp.json : [];
|
||||
members.push(...batch);
|
||||
if (batch.length < 1000) break;
|
||||
after = String(batch[batch.length - 1]?.user?.id || "");
|
||||
if (!after) break;
|
||||
}
|
||||
|
||||
const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : [];
|
||||
const visibleUserIds = members
|
||||
.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites))
|
||||
.map((m) => String(m?.user?.id || ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const userToAccount = buildUserIdToAccountIdMap(api);
|
||||
const out = new Set<string>();
|
||||
for (const uid of visibleUserIds) {
|
||||
const aid = userToAccount.get(uid);
|
||||
if (aid) out.add(aid);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
79
plugin/core/identity.ts
Normal file
79
plugin/core/identity.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDiscordUserIdFromAccount(api: OpenClawPluginApi, accountId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
return match.accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
|
||||
let accountId: string | undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
accountId = match.accountId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accountId) return undefined;
|
||||
|
||||
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
|
||||
const name = (agent?.name as string) || agentId;
|
||||
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
|
||||
|
||||
let identity = `You are ${name} (Discord account: ${accountId}`;
|
||||
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
|
||||
identity += `).`;
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const map = new Map<string, string>();
|
||||
for (const [accountId, acct] of Object.entries(accounts)) {
|
||||
if (typeof acct.token === "string") {
|
||||
const userId = userIdFromToken(acct.token);
|
||||
if (userId) map.set(userId, accountId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
23
plugin/core/live-config.ts
Normal file
23
plugin/core/live-config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const plugins = (root.plugins as Record<string, unknown>) || {};
|
||||
const entries = (plugins.entries as Record<string, unknown>) || {};
|
||||
const entry = (entries.dirigent as Record<string, unknown>) || (entries.whispergate as Record<string, unknown>) || {};
|
||||
const cfg = (entry.config as Record<string, unknown>) || {};
|
||||
if (Object.keys(cfg).length > 0) {
|
||||
return {
|
||||
enableDiscordControlTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
noReplyPort: 8787,
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
...cfg,
|
||||
} as DirigentConfig;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
31
plugin/core/mentions.ts
Normal file
31
plugin/core/mentions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMentionedUserIds(content: string): string[] {
|
||||
const regex = /<@!?(\d+)>/g;
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const id = match[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||
if (!config.moderatorBotToken) return undefined;
|
||||
return userIdFromToken(config.moderatorBotToken);
|
||||
}
|
||||
49
plugin/core/moderator-discord.ts
Normal file
49
plugin/core/moderator-discord.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
function userIdFromToken(token: string): string | undefined {
|
||||
try {
|
||||
const segment = token.split(".")[0];
|
||||
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
const acct = accounts[accountId];
|
||||
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
||||
return userIdFromToken(acct.token);
|
||||
}
|
||||
|
||||
export async function sendModeratorMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (msg: string) => void; warn: (msg: string) => void },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`);
|
||||
return false;
|
||||
}
|
||||
logger.info(`dirigent: moderator message sent to channel=${channelId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`dirigent: moderator send error: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
51
plugin/core/no-reply-process.ts
Normal file
51
plugin/core/no-reply-process.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
let noReplyProcess: ChildProcess | null = null;
|
||||
|
||||
export function startNoReplyApi(
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
pluginDir: string,
|
||||
port = 8787,
|
||||
): void {
|
||||
logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`);
|
||||
|
||||
if (noReplyProcess) {
|
||||
logger.info("dirigent: no-reply API already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverPath = path.resolve(pluginDir, "no-reply-api", "server.mjs");
|
||||
logger.info(`dirigent: resolved serverPath=${serverPath}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("dirigent: no-reply API server found, spawning process...");
|
||||
|
||||
noReplyProcess = spawn(process.execPath, [serverPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
|
||||
noReplyProcess.on("exit", (code, signal) => {
|
||||
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
|
||||
noReplyProcess = null;
|
||||
});
|
||||
|
||||
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
|
||||
}
|
||||
|
||||
export function stopNoReplyApi(logger: { info: (m: string) => void }): void {
|
||||
if (!noReplyProcess) return;
|
||||
logger.info("dirigent: stopping no-reply API");
|
||||
noReplyProcess.kill("SIGTERM");
|
||||
noReplyProcess = null;
|
||||
}
|
||||
31
plugin/core/session-state.ts
Normal file
31
plugin/core/session-state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Decision } from "../rules.js";
|
||||
|
||||
export type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
export const MAX_SESSION_DECISIONS = 2000;
|
||||
export const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const sessionDecision = new Map<string, DecisionRecord>();
|
||||
export const sessionAllowed = new Map<string, boolean>();
|
||||
export const sessionInjected = new Set<string>();
|
||||
export const sessionChannelId = new Map<string, string>();
|
||||
export const sessionAccountId = new Map<string, string>();
|
||||
export const sessionTurnHandled = new Set<string>();
|
||||
|
||||
export function pruneDecisionMap(now = Date.now()): void {
|
||||
for (const [k, v] of sessionDecision.entries()) {
|
||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
||||
}
|
||||
|
||||
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
|
||||
const keys = sessionDecision.keys();
|
||||
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
|
||||
const k = keys.next();
|
||||
if (k.done) break;
|
||||
sessionDecision.delete(k.value);
|
||||
}
|
||||
}
|
||||
127
plugin/core/turn-bootstrap.ts
Normal file
127
plugin/core/turn-bootstrap.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { initTurnOrder } from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./channel-members.js";
|
||||
|
||||
const channelSeenAccounts = new Map<string, Set<string>>();
|
||||
const channelBootstrapTried = new Set<string>();
|
||||
let cacheLoaded = false;
|
||||
|
||||
function cachePath(api: OpenClawPluginApi): string {
|
||||
return api.resolvePath("~/.openclaw/dirigent-channel-members.json");
|
||||
}
|
||||
|
||||
function loadCache(api: OpenClawPluginApi): void {
|
||||
if (cacheLoaded) return;
|
||||
cacheLoaded = true;
|
||||
const p = cachePath(api);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, { botAccountIds?: string[]; source?: string; guildId?: string; updatedAt?: string }>;
|
||||
for (const [channelId, rec] of Object.entries(parsed || {})) {
|
||||
const ids = Array.isArray(rec?.botAccountIds) ? rec.botAccountIds.filter(Boolean) : [];
|
||||
if (ids.length > 0) channelSeenAccounts.set(channelId, new Set(ids));
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed loading channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inferGuildIdFromChannelId(api: OpenClawPluginApi, channelId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
for (const rec of Object.values(accounts)) {
|
||||
const chMap = (rec?.channels as Record<string, Record<string, unknown>> | undefined) || undefined;
|
||||
if (!chMap) continue;
|
||||
const direct = chMap[channelId];
|
||||
const prefixed = chMap[`channel:${channelId}`];
|
||||
const found = (direct || prefixed) as Record<string, unknown> | undefined;
|
||||
if (found && typeof found.guildId === "string" && found.guildId.trim()) return found.guildId.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function persistCache(api: OpenClawPluginApi): void {
|
||||
const p = cachePath(api);
|
||||
const out: Record<string, { botAccountIds: string[]; updatedAt: string; source: string; guildId?: string }> = {};
|
||||
for (const [channelId, set] of channelSeenAccounts.entries()) {
|
||||
out[channelId] = {
|
||||
botAccountIds: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "dirigent/turn-bootstrap",
|
||||
guildId: inferGuildIdFromChannelId(api, channelId),
|
||||
};
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, p);
|
||||
} catch (err) {
|
||||
api.logger.warn?.(`dirigent: failed persisting channel member cache: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const b of bindings) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
ids.push(match.accountId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
|
||||
const allBots = new Set(getAllBotAccountIds(api));
|
||||
const seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) return [];
|
||||
return [...seen].filter((id) => allBots.has(id));
|
||||
}
|
||||
|
||||
export function recordChannelAccount(api: OpenClawPluginApi, channelId: string, accountId: string): boolean {
|
||||
loadCache(api);
|
||||
let seen = channelSeenAccounts.get(channelId);
|
||||
if (!seen) {
|
||||
seen = new Set();
|
||||
channelSeenAccounts.set(channelId, seen);
|
||||
}
|
||||
if (seen.has(accountId)) return false;
|
||||
seen.add(accountId);
|
||||
persistCache(api);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise<void> {
|
||||
loadCache(api);
|
||||
let botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder enter channel=${channelId} cached=${JSON.stringify(botAccounts)} bootstrapTried=${channelBootstrapTried.has(channelId)}`,
|
||||
);
|
||||
|
||||
if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) {
|
||||
channelBootstrapTried.add(channelId);
|
||||
const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]);
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder bootstrap-discovered channel=${channelId} discovered=${JSON.stringify(discovered)}`,
|
||||
);
|
||||
for (const aid of discovered) recordChannelAccount(api, channelId, aid);
|
||||
botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
}
|
||||
|
||||
if (botAccounts.length > 0) {
|
||||
api.logger.info(
|
||||
`dirigent: turn-debug ensureTurnOrder initTurnOrder channel=${channelId} members=${JSON.stringify(botAccounts)}`,
|
||||
);
|
||||
initTurnOrder(channelId, botAccounts);
|
||||
}
|
||||
}
|
||||
45
plugin/core/utils.ts
Normal file
45
plugin/core/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
export function pickDefined(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||
if (!cfg.enableDebugLogs) return false;
|
||||
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||
if (allow.length === 0) return true;
|
||||
if (!channelId) return true;
|
||||
return allow.includes(channelId);
|
||||
}
|
||||
|
||||
export function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
|
||||
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||
return {
|
||||
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
|
||||
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
|
||||
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
|
||||
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
|
||||
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
|
||||
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
|
||||
from: typeof ctx.from === "string" ? ctx.from : undefined,
|
||||
metaSenderId:
|
||||
typeof meta.senderId === "string"
|
||||
? meta.senderId
|
||||
: typeof meta.sender_id === "string"
|
||||
? meta.sender_id
|
||||
: undefined,
|
||||
metaUserId:
|
||||
typeof meta.userId === "string"
|
||||
? meta.userId
|
||||
: typeof meta.user_id === "string"
|
||||
? meta.user_id
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
37
plugin/decision-input.ts
Normal file
37
plugin/decision-input.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
extractDiscordChannelId,
|
||||
extractDiscordChannelIdFromConversationMetadata,
|
||||
extractDiscordChannelIdFromSessionKey,
|
||||
extractUntrustedConversationInfo,
|
||||
} from "./channel-resolver.js";
|
||||
|
||||
export type DerivedDecisionInput = {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function deriveDecisionInputFromPrompt(params: {
|
||||
prompt: string;
|
||||
messageProvider?: string;
|
||||
sessionKey?: string;
|
||||
ctx?: Record<string, unknown>;
|
||||
event?: Record<string, unknown>;
|
||||
}): DerivedDecisionInput {
|
||||
const { prompt, messageProvider, sessionKey, ctx, event } = params;
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
|
||||
let channelId = extractDiscordChannelId(ctx || {}, event);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
|
||||
|
||||
const senderId =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
185
plugin/hooks/before-message-write.ts
Normal file
185
plugin/hooks/before-message-write.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type BeforeMessageWriteDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("before_message_write", (event, ctx) => {
|
||||
try {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
);
|
||||
|
||||
const key = ctx.sessionKey;
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
let content = "";
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
||||
if (msg) {
|
||||
const role = msg.role as string | undefined;
|
||||
if (role && role !== "assistant") return;
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") content += part;
|
||||
else if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
|
||||
content += (part as Record<string, unknown>).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
content = ((event as Record<string, unknown>).content as string) || "";
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
if (!key || !channelId || !accountId) return;
|
||||
|
||||
const currentTurn = getTurnDebugInfo(channelId);
|
||||
if (currentTurn.currentSpeaker !== accountId) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isEmpty = trimmed.length === 0;
|
||||
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
const wasNoReply = isEmpty || isNoReply;
|
||||
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
api.logger.info(`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`);
|
||||
|
||||
if (wasAllowed === undefined) return;
|
||||
|
||||
if (wasAllowed === false) {
|
||||
sessionAllowed.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
|
||||
if (!nextSpeaker) {
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
} else if (hasEndSymbol) {
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
} else {
|
||||
api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
168
plugin/hooks/before-model-resolve.ts
Normal file
168
plugin/hooks/before-model-resolve.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { checkTurn } from "../turn-manager.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforeModelResolveDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
|
||||
pruneDecisionMap: () => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
} = deps;
|
||||
|
||||
api.on("before_model_resolve", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
|
||||
`promptPreview=${prompt.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||
|
||||
if (derived.channelId) {
|
||||
sessionChannelId.set(key, derived.channelId);
|
||||
}
|
||||
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (resolvedAccountId) {
|
||||
sessionAccountId.set(key, resolvedAccountId);
|
||||
}
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
sessionDecision.set(key, rec);
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_model_resolve recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (derived.channelId) {
|
||||
await ensureTurnOrder(api, derived.channelId);
|
||||
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (accountId) {
|
||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||
if (!turnCheck.allowed) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(
|
||||
`dirigent: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||
);
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
}
|
||||
sessionAllowed.set(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) {
|
||||
if (rec.needsRestore) {
|
||||
sessionDecision.delete(key);
|
||||
return {
|
||||
providerOverride: undefined,
|
||||
modelOverride: undefined,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rec.needsRestore = true;
|
||||
sessionDecision.set(key, rec);
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
const hasConvMarker2 = prompt.includes("Conversation info (untrusted metadata):");
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_NO_REPLY_TRIGGER session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`decision=${rec.decision.reason} ` +
|
||||
`shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` +
|
||||
`hasConvMarker=${hasConvMarker2} promptLen=${prompt.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||
);
|
||||
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
});
|
||||
}
|
||||
134
plugin/hooks/before-prompt-build.ts
Normal file
134
plugin/hooks/before-prompt-build.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforePromptBuildDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionInjected: Set<string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string;
|
||||
buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string;
|
||||
buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string;
|
||||
};
|
||||
|
||||
export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
} = deps;
|
||||
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_prompt_build recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sessionDecision.delete(key);
|
||||
|
||||
if (sessionInjected.has(key)) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
|
||||
|
||||
let identity = "";
|
||||
if (isGroupChat && ctx.agentId) {
|
||||
const idStr = buildAgentIdentity(api, ctx.agentId);
|
||||
if (idStr) identity = idStr + "\n\n";
|
||||
}
|
||||
|
||||
let schedulingInstruction = "";
|
||||
if (isGroupChat) {
|
||||
schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId);
|
||||
}
|
||||
|
||||
sessionInjected.add(key);
|
||||
|
||||
api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||
return { prependContext: identity + instruction + schedulingInstruction };
|
||||
});
|
||||
}
|
||||
115
plugin/hooks/message-received.ts
Normal file
115
plugin/hooks/message-received.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId } from "../channel-resolver.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageReceivedDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
|
||||
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
|
||||
extractMentionedUserIds: (content: string) => string[];
|
||||
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
|
||||
};
|
||||
|
||||
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
getLivePluginConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
} = deps;
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
try {
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
const preChannelId = extractDiscordChannelId(c, e);
|
||||
const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
}
|
||||
|
||||
if (preChannelId) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||
const from =
|
||||
(typeof metadata?.senderId === "string" && metadata.senderId) ||
|
||||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
|
||||
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (moderatorUserId && from === moderatorUserId) {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
|
||||
}
|
||||
} else {
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
const isHuman = humanList.includes(from);
|
||||
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
|
||||
|
||||
if (senderAccountId && senderAccountId !== "default") {
|
||||
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
|
||||
if (isNew) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
|
||||
const mentionedUserIds = extractMentionedUserIds(messageContent);
|
||||
|
||||
if (mentionedUserIds.length > 0) {
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
|
||||
|
||||
if (mentionedAccountIds.length > 0) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
|
||||
if (overrideSet) {
|
||||
api.logger.info(
|
||||
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
|
||||
);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
123
plugin/hooks/message-sent.ts
Normal file
123
plugin/hooks/message-sent.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageSentDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
getLivePluginConfig,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
} = deps;
|
||||
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const key = ctx.sessionKey;
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
|
||||
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
|
||||
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
|
||||
`session=${key ?? "undefined"}`,
|
||||
);
|
||||
|
||||
let channelId = extractDiscordChannelId(c, e);
|
||||
if (!channelId && key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!channelId && key) {
|
||||
channelId = extractDiscordChannelIdFromSessionKey(key);
|
||||
}
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isEmpty = trimmed.length === 0;
|
||||
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
const wasNoReply = isEmpty || isNoReply;
|
||||
|
||||
if (key && sessionTurnHandled.has(key)) {
|
||||
sessionTurnHandled.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasNoReply || hasEndSymbol) {
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
|
||||
api.logger.info(
|
||||
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
|
||||
);
|
||||
|
||||
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
1248
plugin/index.ts
1248
plugin/index.ts
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "dirigent",
|
||||
"name": "Dirigent",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Rule-based no-reply gate with provider/model override and turn management",
|
||||
"entry": "./index.ts",
|
||||
"configSchema": {
|
||||
@@ -17,13 +17,12 @@
|
||||
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
|
||||
"schedulingIdentifier": { "type": "string", "default": "➡️" },
|
||||
"waitIdentifier": { "type": "string", "default": "👤" },
|
||||
"noReplyProvider": { "type": "string" },
|
||||
"noReplyModel": { "type": "string" },
|
||||
"noReplyPort": { "type": "number", "default": 8787 },
|
||||
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
||||
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
|
||||
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
||||
"discordControlApiToken": { "type": "string" },
|
||||
"discordControlCallerId": { "type": "string" },
|
||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"moderatorBotToken": { "type": "string" }
|
||||
|
||||
50
plugin/policy/store.ts
Normal file
50
plugin/policy/store.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { ChannelPolicy, DirigentConfig } from "../rules.js";
|
||||
|
||||
export type PolicyState = {
|
||||
filePath: string;
|
||||
channelPolicies: Record<string, ChannelPolicy>;
|
||||
};
|
||||
|
||||
export const policyState: PolicyState = {
|
||||
filePath: "",
|
||||
channelPolicies: {},
|
||||
};
|
||||
|
||||
export function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
|
||||
}
|
||||
|
||||
export function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig): void {
|
||||
if (policyState.filePath) return;
|
||||
const filePath = resolvePoliciesPath(api, config);
|
||||
policyState.filePath = filePath;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "{}\n", "utf8");
|
||||
policyState.channelPolicies = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
||||
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`);
|
||||
policyState.channelPolicies = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistPolicies(api: OpenClawPluginApi): void {
|
||||
if (!policyState.filePath) throw new Error("policy state not initialized");
|
||||
const dir = path.dirname(policyState.filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${policyState.filePath}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(policyState.channelPolicies, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tmp, policyState.filePath);
|
||||
api.logger.info(`dirigent: policy file updated at ${policyState.filePath}`);
|
||||
}
|
||||
@@ -10,8 +10,11 @@ export type DirigentConfig = {
|
||||
endSymbols?: string[];
|
||||
/** Scheduling identifier sent by moderator to activate agents (default: ➡️) */
|
||||
schedulingIdentifier?: string;
|
||||
/** Wait identifier: agent ends with this when waiting for a human reply (default: 👤) */
|
||||
waitIdentifier?: string;
|
||||
noReplyProvider: string;
|
||||
noReplyModel: string;
|
||||
noReplyPort?: number;
|
||||
/** Discord bot token for the moderator bot (used for turn handoff messages) */
|
||||
moderatorBotToken?: string;
|
||||
};
|
||||
|
||||
179
plugin/tools/register-tools.ts
Normal file
179
plugin/tools/register-tools.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
|
||||
type DiscordControlAction = "channel-private-create" | "channel-private-update";
|
||||
|
||||
type ToolDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>;
|
||||
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
||||
};
|
||||
|
||||
function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const channels = (root.channels as Record<string, unknown>) || {};
|
||||
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||
|
||||
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
|
||||
return { accountId, token: accounts[accountId].token as string };
|
||||
}
|
||||
for (const [aid, rec] of Object.entries(accounts)) {
|
||||
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> {
|
||||
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
||||
return { ok: r.ok, status: r.status, text, json };
|
||||
}
|
||||
|
||||
function roleOrMemberType(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerDirigentTools(deps: ToolDeps): void {
|
||||
const { api, baseConfig, pickDefined, getLivePluginConfig } = deps;
|
||||
|
||||
async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
||||
enableDiscordControlTool?: boolean;
|
||||
discordControlAccountId?: string;
|
||||
};
|
||||
if (live.enableDiscordControlTool === false) {
|
||||
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId);
|
||||
if (!selected) {
|
||||
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true };
|
||||
}
|
||||
const token = selected.token;
|
||||
|
||||
if (action === "channel-private-create") {
|
||||
const guildId = String(params.guildId || "").trim();
|
||||
const name = String(params.name || "").trim();
|
||||
if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true };
|
||||
|
||||
const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : [];
|
||||
const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyEveryoneMask = String(params.denyEveryoneMask || "1024");
|
||||
|
||||
const overwrites: any[] = [
|
||||
{ id: guildId, type: 0, allow: "0", deny: denyEveryoneMask },
|
||||
...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })),
|
||||
...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })),
|
||||
];
|
||||
|
||||
const body = pickDefined({
|
||||
name,
|
||||
type: typeof params.type === "number" ? params.type : 0,
|
||||
parent_id: params.parentId,
|
||||
topic: params.topic,
|
||||
position: params.position,
|
||||
nsfw: params.nsfw,
|
||||
permission_overwrites: overwrites,
|
||||
});
|
||||
|
||||
const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body);
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
|
||||
const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge";
|
||||
const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : [];
|
||||
const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : [];
|
||||
const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : [];
|
||||
const allowMask = String(params.allowMask || "1024");
|
||||
const denyMask = String(params.denyMask || "0");
|
||||
|
||||
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
||||
if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true };
|
||||
|
||||
const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : [];
|
||||
const guildId = String(ch.json?.guild_id || "");
|
||||
const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0);
|
||||
|
||||
let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || "")));
|
||||
for (const id of addRoleIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 0, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
for (const id of addUserIds) {
|
||||
next = next.filter((x: any) => String(x?.id || "") !== id);
|
||||
next.push({ id, type: 1, allow: allowMask, deny: denyMask });
|
||||
}
|
||||
|
||||
const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next });
|
||||
if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] };
|
||||
}
|
||||
|
||||
api.registerTool({
|
||||
name: "discord_channel_create",
|
||||
description: "Create a private Discord channel with specific user/role permissions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accountId: { type: "string" },
|
||||
guildId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "number" },
|
||||
parentId: { type: "string" },
|
||||
topic: { type: "string" },
|
||||
position: { type: "number" },
|
||||
nsfw: { type: "boolean" },
|
||||
allowedUserIds: { type: "array", items: { type: "string" } },
|
||||
allowedRoleIds: { type: "array", items: { type: "string" } },
|
||||
allowMask: { type: "string" },
|
||||
denyEveryoneMask: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
return executeDiscordAction("channel-private-create", params);
|
||||
},
|
||||
}, { optional: false });
|
||||
|
||||
api.registerTool({
|
||||
name: "discord_channel_update",
|
||||
description: "Update permissions on an existing private Discord channel.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accountId: { type: "string" },
|
||||
channelId: { type: "string" },
|
||||
mode: { type: "string", enum: ["merge", "replace"] },
|
||||
addUserIds: { type: "array", items: { type: "string" } },
|
||||
addRoleIds: { type: "array", items: { type: "string" } },
|
||||
removeTargetIds: { type: "array", items: { type: "string" } },
|
||||
allowMask: { type: "string" },
|
||||
denyMask: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
return executeDiscordAction("channel-private-update", params);
|
||||
},
|
||||
}, { optional: false });
|
||||
}
|
||||
@@ -20,6 +20,14 @@ export type ChannelTurnState = {
|
||||
noRepliedThisCycle: Set<string>;
|
||||
/** Timestamp of last state change */
|
||||
lastChangedAt: number;
|
||||
// ── Mention override state ──
|
||||
/** Original turn order saved when override is active */
|
||||
savedTurnOrder?: string[];
|
||||
/** First agent in override cycle; used to detect cycle completion */
|
||||
overrideFirstAgent?: string;
|
||||
// ── Wait-for-human state ──
|
||||
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
|
||||
waitingForHuman: boolean;
|
||||
};
|
||||
|
||||
const channelTurns = new Map<string, ChannelTurnState>();
|
||||
@@ -47,19 +55,66 @@ function shuffleArray<T>(arr: T[]): T[] {
|
||||
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
|
||||
const existing = channelTurns.get(channelId);
|
||||
if (existing) {
|
||||
// Check if membership changed
|
||||
const oldSet = new Set(existing.turnOrder);
|
||||
// Compare membership against base order.
|
||||
// If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison.
|
||||
const baseOrder = existing.savedTurnOrder || existing.turnOrder;
|
||||
const oldSet = new Set(baseOrder);
|
||||
const newSet = new Set(botAccountIds);
|
||||
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
|
||||
if (same) return; // no change
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` +
|
||||
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` +
|
||||
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
|
||||
// Mention override active: update only the saved base order.
|
||||
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
|
||||
if (existing.savedTurnOrder) {
|
||||
existing.savedTurnOrder = nextOrder;
|
||||
existing.lastChangedAt = Date.now();
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
|
||||
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
|
||||
`keptCurrent=${existing.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-mention flow: preserve previous behavior (re-init to dormant).
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: shuffleArray(botAccountIds),
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +130,11 @@ export function checkTurn(channelId: string, accountId: string): {
|
||||
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
|
||||
}
|
||||
|
||||
// Waiting for human → block all agents
|
||||
if (state.waitingForHuman) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
|
||||
}
|
||||
|
||||
// Not in turn order (human or unknown) → always allowed
|
||||
if (!state.turnOrder.includes(accountId)) {
|
||||
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
|
||||
@@ -107,6 +167,8 @@ export function checkTurn(channelId: string, accountId: string): {
|
||||
* Called when a new message arrives in the channel.
|
||||
* Handles reactivation from dormant state and human-triggered resets.
|
||||
*
|
||||
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
|
||||
*
|
||||
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
|
||||
* @param isHuman - whether the sender is in the humanList
|
||||
*/
|
||||
@@ -115,13 +177,20 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
|
||||
if (!state || state.turnOrder.length === 0) return;
|
||||
|
||||
if (isHuman) {
|
||||
// Human message: activate, start from first in order
|
||||
// Human message: clear wait-for-human, restore original order if overridden, activate from first
|
||||
state.waitingForHuman = false;
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.waitingForHuman) {
|
||||
// Waiting for human — ignore non-human messages
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentSpeaker !== null) {
|
||||
// Already active, no change needed from incoming message
|
||||
return;
|
||||
@@ -141,6 +210,97 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original turn order if an override is active.
|
||||
*/
|
||||
function restoreOriginalOrder(state: ChannelTurnState): void {
|
||||
if (state.savedTurnOrder) {
|
||||
state.turnOrder = state.savedTurnOrder;
|
||||
state.savedTurnOrder = undefined;
|
||||
state.overrideFirstAgent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a temporary mention override for the turn order.
|
||||
* When a human @mentions specific agents, only those agents speak (in their
|
||||
* relative order from the current turn order). After the cycle returns to the
|
||||
* first agent, the original order is restored.
|
||||
*
|
||||
* @param channelId - Discord channel ID
|
||||
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
|
||||
* their position in the current turn order
|
||||
* @returns true if override was set, false if no valid agents
|
||||
*/
|
||||
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || mentionedAccountIds.length === 0) return false;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
|
||||
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
|
||||
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
// Restore any existing override first
|
||||
restoreOriginalOrder(state);
|
||||
|
||||
// Filter to agents actually in the turn order
|
||||
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
|
||||
if (validIds.length === 0) {
|
||||
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Order by their position in the current turn order
|
||||
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
|
||||
|
||||
// Save original and apply override
|
||||
state.savedTurnOrder = [...state.turnOrder];
|
||||
state.turnOrder = validIds;
|
||||
state.overrideFirstAgent = validIds[0];
|
||||
state.currentSpeaker = validIds[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
|
||||
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
|
||||
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mention override is currently active.
|
||||
*/
|
||||
export function hasMentionOverride(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.savedTurnOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the channel to "waiting for human" state.
|
||||
* All agents will be routed to no-reply until a human sends a message.
|
||||
*/
|
||||
export function setWaitingForHuman(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return;
|
||||
state.waitingForHuman = true;
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the channel is waiting for a human reply.
|
||||
*/
|
||||
export function isWaitingForHuman(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.waitingForHuman;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
||||
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
||||
@@ -157,6 +317,8 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
|
||||
// Check if ALL agents have NO_REPLY'd this cycle
|
||||
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
||||
if (allNoReplied) {
|
||||
// If override active, restore original order before going dormant
|
||||
restoreOriginalOrder(state);
|
||||
// Go dormant
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
@@ -168,7 +330,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply:
|
||||
state.noRepliedThisCycle = new Set();
|
||||
}
|
||||
|
||||
return advanceTurn(channelId);
|
||||
const next = advanceTurn(channelId);
|
||||
|
||||
// Check if override cycle completed (returned to first agent)
|
||||
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null; // go dormant after override cycle completes
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,8 +382,10 @@ export function advanceTurn(channelId: string): string | null {
|
||||
export function resetTurn(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (state) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.waitingForHuman = false;
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -229,5 +404,9 @@ export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
waitingForHuman: state.waitingForHuman,
|
||||
hasOverride: !!state.savedTurnOrder,
|
||||
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||
savedTurnOrder: state.savedTurnOrder || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dirigent plugin installer/uninstaller with delta-tracking.
|
||||
* Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const modeArg = process.argv[2];
|
||||
if (modeArg !== "--install" && modeArg !== "--uninstall") {
|
||||
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall");
|
||||
process.exit(2);
|
||||
}
|
||||
const mode = modeArg === "--install" ? "install" : "uninstall";
|
||||
|
||||
const env = process.env;
|
||||
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
// Ensure dist/dirigent exists (handles git clone without npm prepare)
|
||||
const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent");
|
||||
const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin");
|
||||
const NO_REPLY_API_SRC_DIR = path.resolve(__dirname, "..", "no-reply-api");
|
||||
const NO_REPLY_API_DIST_DIR = path.resolve(__dirname, "..", "dist", "no-reply-api");
|
||||
|
||||
if (mode === "install") {
|
||||
// Copy plugin files to dist/dirigent
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
console.log("[dirigent] dist/dirigent/ not found, syncing from plugin/...");
|
||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||
for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) {
|
||||
fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Copy no-reply-api files to dist/no-reply-api
|
||||
if (!fs.existsSync(NO_REPLY_API_DIST_DIR)) {
|
||||
console.log("[dirigent] dist/no-reply-api/ not found, syncing from no-reply-api/...");
|
||||
fs.mkdirSync(NO_REPLY_API_DIST_DIR, { recursive: true });
|
||||
for (const f of fs.readdirSync(NO_REPLY_API_SRC_DIR)) {
|
||||
fs.copyFileSync(path.join(NO_REPLY_API_SRC_DIR, f), path.join(NO_REPLY_API_DIST_DIR, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR;
|
||||
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 || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
|
||||
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 || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir());
|
||||
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
|
||||
|
||||
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,
|
||||
openclawConfigPath: OPENCLAW_CONFIG_PATH,
|
||||
backupPath: BACKUP_PATH,
|
||||
delta, // { added: {...}, replaced: {...}, removed: {...} }
|
||||
};
|
||||
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 "";
|
||||
}
|
||||
|
||||
// Deep clone
|
||||
function clone(v) {
|
||||
if (v === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
// Check if two values are deeply equal
|
||||
function deepEqual(a, b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// INSTALL (with auto-reinstall if already installed)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
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;
|
||||
// Re-exec ourselves in uninstall mode
|
||||
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
|
||||
env: process.env,
|
||||
stdio: ["inherit", "inherit", "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...");
|
||||
}
|
||||
|
||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
|
||||
|
||||
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: {} };
|
||||
|
||||
try {
|
||||
// ── plugins.load.paths ────────────────────────────────────────────────
|
||||
const plugins = getJson("plugins") || {};
|
||||
const oldPaths = clone(plugins.load?.paths) || [];
|
||||
const newPaths = clone(oldPaths);
|
||||
const pathIndex = newPaths.indexOf(PLUGIN_PATH);
|
||||
if (pathIndex === -1) {
|
||||
newPaths.push(PLUGIN_PATH);
|
||||
delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path
|
||||
} else {
|
||||
// already present, no change
|
||||
}
|
||||
// save old paths for potential future rollback of this specific change
|
||||
delta._prev = delta._prev || {};
|
||||
delta._prev[PATH_PLUGINS_LOAD] = oldPaths;
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = newPaths;
|
||||
|
||||
// ── plugins.entries.dirigent ──────────────────────────────────────────
|
||||
const oldEntry = clone(plugins.entries?.dirigent);
|
||||
const newEntry = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
schedulingIdentifier: SCHEDULING_IDENTIFIER,
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
},
|
||||
};
|
||||
if (oldEntry === undefined) {
|
||||
delta.added[PATH_PLUGIN_ENTRY] = newEntry;
|
||||
} else {
|
||||
delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry;
|
||||
}
|
||||
plugins.entries = plugins.entries || {};
|
||||
plugins.entries.dirigent = newEntry;
|
||||
setJson("plugins", plugins);
|
||||
|
||||
// ── models.providers.<providerId> ─────────────────────────────────────
|
||||
const providers = getJson(PATH_PROVIDERS) || {};
|
||||
const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]);
|
||||
const newProvider = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (oldProvider === undefined) {
|
||||
delta.added[PATH_PROVIDER_ENTRY] = newProvider;
|
||||
} else {
|
||||
delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider;
|
||||
}
|
||||
providers[NO_REPLY_PROVIDER_ID] = newProvider;
|
||||
setJson(PATH_PROVIDERS, providers);
|
||||
|
||||
// ── plugins.allow ─────────────────────────────────────────────────────
|
||||
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
|
||||
const oldAllow = clone(allowList);
|
||||
if (!allowList.includes("dirigent")) {
|
||||
allowList.push("dirigent");
|
||||
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
|
||||
delta._prev = delta._prev || {};
|
||||
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] 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 {
|
||||
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: {} };
|
||||
|
||||
try {
|
||||
// ── IMPORTANT: Order matters for OpenClaw config validation ────────────
|
||||
// 1. First remove from allow (before deleting entry, otherwise validation fails)
|
||||
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. Then remove 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. Then remove plugin path (after entry is gone)
|
||||
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
|
||||
const plugins = getJson("plugins") || {};
|
||||
const paths = plugins.load?.paths || [];
|
||||
const idx = paths.indexOf(PLUGIN_PATH);
|
||||
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. Finally 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 it was replaced, not added)
|
||||
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");
|
||||
}
|
||||
|
||||
writeRecord("uninstall", delta);
|
||||
console.log("[dirigent] uninstall ok");
|
||||
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
||||
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
|
||||
} catch (e) {
|
||||
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
|
||||
console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
279
scripts/install.mjs
Executable file
279
scripts/install.mjs
Executable file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
|
||||
let modeArg = null;
|
||||
let argOpenClawDir = null;
|
||||
let argNoReplyPort = 8787;
|
||||
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i];
|
||||
if (VALID_MODES.has(arg)) {
|
||||
modeArg = arg;
|
||||
} else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) {
|
||||
argOpenClawDir = process.argv[++i];
|
||||
} else if (arg.startsWith("--openclaw-profile-path=")) {
|
||||
argOpenClawDir = arg.split("=").slice(1).join("=");
|
||||
} else if (arg === "--no-reply-port" && i + 1 < process.argv.length) {
|
||||
argNoReplyPort = Number(process.argv[++i]);
|
||||
} else if (arg.startsWith("--no-reply-port=")) {
|
||||
argNoReplyPort = Number(arg.split("=").slice(1).join("="));
|
||||
}
|
||||
}
|
||||
|
||||
if (!modeArg) {
|
||||
fail("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path <path>] [--no-reply-port <port>]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) {
|
||||
fail("invalid --no-reply-port (1-65535)");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const mode = modeArg.slice(2);
|
||||
|
||||
const C = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
};
|
||||
|
||||
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
|
||||
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
|
||||
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
|
||||
function ok(msg) { console.log(color(`\t✓ ${msg}`, "green")); }
|
||||
function warn(msg) { console.log(color(`\t⚠ ${msg}`, "yellow")); }
|
||||
function fail(msg) { console.log(color(`\t✗ ${msg}`, "red")); }
|
||||
|
||||
function resolveOpenClawDir() {
|
||||
if (argOpenClawDir) {
|
||||
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
|
||||
return dir;
|
||||
}
|
||||
if (process.env.OPENCLAW_DIR) {
|
||||
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`);
|
||||
}
|
||||
const fallback = path.join(os.homedir(), ".openclaw");
|
||||
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const OPENCLAW_DIR = resolveOpenClawDir();
|
||||
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
fail(`config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
||||
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
||||
const NO_REPLY_INSTALL_DIR = path.join(PLUGIN_INSTALL_DIR, "no-reply-api");
|
||||
|
||||
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigentway";
|
||||
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
|
||||
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
|
||||
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`;
|
||||
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||
const LIST_MODE = process.env.LIST_MODE || "human-list";
|
||||
const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]";
|
||||
const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]";
|
||||
const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json");
|
||||
const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}";
|
||||
const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["🔚"]';
|
||||
const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "➡️";
|
||||
|
||||
function runOpenclaw(args, allowFail = false) {
|
||||
try {
|
||||
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
|
||||
} catch (e) {
|
||||
if (allowFail) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function getJson(pathKey) {
|
||||
const out = runOpenclaw(["config", "get", pathKey, "--json"], true);
|
||||
if (!out || out === "undefined") return undefined;
|
||||
try { return JSON.parse(out); } catch { return undefined; }
|
||||
}
|
||||
|
||||
function setJson(pathKey, value) {
|
||||
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
|
||||
}
|
||||
|
||||
function unsetPath(pathKey) {
|
||||
runOpenclaw(["config", "unset", pathKey], true);
|
||||
}
|
||||
|
||||
function syncDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.cpSync(src, dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function isRegistered() {
|
||||
const entry = getJson("plugins.entries.dirigent");
|
||||
return !!(entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
if (mode === "update") {
|
||||
title("Update");
|
||||
const branch = process.env.DIRIGENT_GIT_BRANCH || "latest";
|
||||
step(1, 2, `update source branch=${branch}`);
|
||||
execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
ok("source updated");
|
||||
|
||||
step(2, 2, "run install after update");
|
||||
const script = path.join(REPO_ROOT, "scripts", "install.mjs");
|
||||
const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)];
|
||||
const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env });
|
||||
process.exit(ret.status ?? 1);
|
||||
}
|
||||
|
||||
if (mode === "install") {
|
||||
title("Install");
|
||||
step(1, 6, `environment: ${OPENCLAW_DIR}`);
|
||||
|
||||
if (isRegistered()) {
|
||||
warn("plugins.entries.dirigent exists; reinstalling in-place");
|
||||
}
|
||||
|
||||
step(2, 6, "build dist assets");
|
||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
|
||||
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||
const distNoReply = path.resolve(REPO_ROOT, "dist", "dirigent", "no-reply-api");
|
||||
syncDirRecursive(pluginSrc, distPlugin);
|
||||
syncDirRecursive(noReplySrc, distNoReply);
|
||||
|
||||
step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
|
||||
syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR);
|
||||
|
||||
// cleanup old layout from previous versions
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) {
|
||||
fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok(`removed legacy path: ${oldTopLevelNoReply}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||
ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||
}
|
||||
|
||||
step(4, 6, "configure plugin entry/path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR);
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = loadPaths;
|
||||
|
||||
plugins.entries = plugins.entries || {};
|
||||
plugins.entries.dirigent = {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
discordOnly: true,
|
||||
listMode: LIST_MODE,
|
||||
humanList: JSON.parse(HUMAN_LIST_JSON),
|
||||
agentList: JSON.parse(AGENT_LIST_JSON),
|
||||
channelPoliciesFile: CHANNEL_POLICIES_FILE,
|
||||
endSymbols: JSON.parse(END_SYMBOLS_JSON),
|
||||
schedulingIdentifier: SCHEDULING_IDENTIFIER,
|
||||
noReplyProvider: NO_REPLY_PROVIDER_ID,
|
||||
noReplyModel: NO_REPLY_MODEL_ID,
|
||||
noReplyPort: NO_REPLY_PORT,
|
||||
},
|
||||
};
|
||||
setJson("plugins", plugins);
|
||||
|
||||
step(5, 6, "configure no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
providers[NO_REPLY_PROVIDER_ID] = {
|
||||
baseUrl: NO_REPLY_BASE_URL,
|
||||
apiKey: NO_REPLY_API_KEY,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NO_REPLY_MODEL_ID,
|
||||
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
setJson("models.providers", providers);
|
||||
|
||||
step(6, 6, "enable plugin in allowlist");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
if (!allow.includes("dirigent")) {
|
||||
allow.push("dirigent");
|
||||
setJson("plugins.allow", allow);
|
||||
}
|
||||
|
||||
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === "uninstall") {
|
||||
title("Uninstall");
|
||||
step(1, 5, `environment: ${OPENCLAW_DIR}`);
|
||||
|
||||
step(2, 5, "remove allowlist + plugin entry");
|
||||
const allow = getJson("plugins.allow") || [];
|
||||
const idx = allow.indexOf("dirigent");
|
||||
if (idx >= 0) {
|
||||
allow.splice(idx, 1);
|
||||
setJson("plugins.allow", allow);
|
||||
ok("removed from plugins.allow");
|
||||
}
|
||||
|
||||
unsetPath("plugins.entries.dirigent");
|
||||
ok("removed plugins.entries.dirigent");
|
||||
|
||||
step(3, 5, "remove plugin load path");
|
||||
const plugins = getJson("plugins") || {};
|
||||
const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : [];
|
||||
plugins.load = plugins.load || {};
|
||||
plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR);
|
||||
setJson("plugins", plugins);
|
||||
ok("removed plugin path from plugins.load.paths");
|
||||
|
||||
step(4, 5, "remove no-reply provider");
|
||||
const providers = getJson("models.providers") || {};
|
||||
delete providers[NO_REPLY_PROVIDER_ID];
|
||||
setJson("models.providers", providers);
|
||||
ok(`removed provider ${NO_REPLY_PROVIDER_ID}`);
|
||||
|
||||
step(5, 5, "remove installed files");
|
||||
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
|
||||
if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true });
|
||||
const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api");
|
||||
if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true });
|
||||
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
|
||||
if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
|
||||
ok("removed installed files");
|
||||
|
||||
console.log("↻ restart gateway: openclaw gateway restart");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8790}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||
CALLER_ID="${CALLER_ID:-}"
|
||||
|
||||
AUTH_HEADER=()
|
||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
fi
|
||||
|
||||
CALLER_HEADER=()
|
||||
if [[ -n "$CALLER_ID" ]]; then
|
||||
CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}")
|
||||
fi
|
||||
|
||||
echo "[1] health"
|
||||
curl -sS "${BASE_URL}/health" | sed -n '1,20p'
|
||||
|
||||
if [[ -z "${GUILD_ID:-}" ]]; then
|
||||
echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[2] dry-run private create"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \
|
||||
| sed -n '1,80p'
|
||||
|
||||
if [[ -n "${CHANNEL_ID:-}" ]]; then
|
||||
echo "[3] dry-run private update"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \
|
||||
| sed -n '1,100p'
|
||||
fi
|
||||
|
||||
echo "[4] member-list (limit=1)"
|
||||
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
"${CALLER_HEADER[@]}" \
|
||||
-d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \
|
||||
| sed -n '1,120p'
|
||||
|
||||
echo "smoke-discord-control: done"
|
||||
Reference in New Issue
Block a user