refactor: remove turn tools, rename discord tools, rewrite installer
- Remove turn management tools (turn-status/advance/reset) — internal only, accessible via /dirigent slash commands - Rename discord tools: dirigent_discord_channel_create, dirigent_discord_channel_update, dirigent_discord_member_list - Rewrite install script: - Dynamic OpenClaw dir resolution (OPENCLAW_DIR env → openclaw CLI → ~/.openclaw) - Plugin installed to $(openclaw_dir)/plugins/dirigent - New --update mode: git pull from latest branch + reinstall - Cleaner uninstall: removes installed plugin files - Update docs (FEAT.md, README.md, CHANGELOG.md, TASKLIST.md)
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
- **Split `dirigent_tools` into individual tools**: Each action is now a separate tool with focused parameters:
|
- **Split `dirigent_tools` into 6 individual tools**:
|
||||||
- `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` (Discord control)
|
- Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list`
|
||||||
- `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` (policy management)
|
- Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete`
|
||||||
- `dirigent_turn_status`, `dirigent_turn_advance`, `dirigent_turn_reset` (turn management)
|
- Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands)
|
||||||
- **Human @mention override**: When a `humanList` user @mentions specific agents:
|
- **Human @mention override**: When a `humanList` user @mentions specific agents:
|
||||||
- Temporarily overrides the speaking order to only mentioned agents
|
- Temporarily overrides the speaking order to only mentioned agents
|
||||||
- Agents cycle in their original turn-order position
|
- Agents cycle in their original turn-order position
|
||||||
- After all mentioned agents have spoken, original order restores and state goes dormant
|
- After all mentioned agents have spoken, original order restores and state goes dormant
|
||||||
- Handles edge cases: all NO_REPLY, reset, non-agent mentions
|
- Handles edge cases: all NO_REPLY, reset, non-agent mentions
|
||||||
|
- **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
|
## 0.2.0
|
||||||
|
|
||||||
|
|||||||
26
FEAT.md
26
FEAT.md
@@ -66,22 +66,23 @@ All implemented features across all versions.
|
|||||||
- Sends handoff messages to trigger next speaker's turn
|
- Sends handoff messages to trigger next speaker's turn
|
||||||
|
|
||||||
## Individual Tools *(v0.3.0)*
|
## Individual Tools *(v0.3.0)*
|
||||||
Nine standalone tools (split from former monolithic `dirigent_tools`):
|
Six standalone tools (split from former monolithic `dirigent_tools`):
|
||||||
|
|
||||||
### Discord Control
|
### Discord Control
|
||||||
- **`dirigent_channel_create`** — Create private Discord channel with user/role permissions
|
- **`dirigent_discord_channel_create`** — Create private Discord channel with user/role permissions
|
||||||
- **`dirigent_channel_update`** — Update permissions on existing private channel
|
- **`dirigent_discord_channel_update`** — Update permissions on existing private channel
|
||||||
- **`dirigent_member_list`** — List guild members with pagination and field projection
|
- **`dirigent_discord_member_list`** — List guild members with pagination and field projection
|
||||||
|
|
||||||
### Policy Management
|
### Policy Management
|
||||||
- **`dirigent_policy_get`** — Get all channel policies
|
- **`dirigent_policy_get`** — Get all channel policies
|
||||||
- **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols)
|
- **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols)
|
||||||
- **`dirigent_policy_delete`** — Delete a channel policy
|
- **`dirigent_policy_delete`** — Delete a channel policy
|
||||||
|
|
||||||
### Turn Management
|
### Turn Management (internal only — not exposed as tools)
|
||||||
- **`dirigent_turn_status`** — Show turn state (order, current speaker, dormant status, override info)
|
Turn management is handled entirely by the plugin. Manual control via slash commands:
|
||||||
- **`dirigent_turn_advance`** — Manually advance to next speaker
|
- `/dirigent turn-status` — Show turn state
|
||||||
- **`dirigent_turn_reset`** — Reset turn order (go dormant, clear overrides)
|
- `/dirigent turn-advance` — Manually advance to next speaker
|
||||||
|
- `/dirigent turn-reset` — Reset turn order (go dormant, clear overrides)
|
||||||
|
|
||||||
## Slash Command: `/dirigent`
|
## Slash Command: `/dirigent`
|
||||||
- `status` — Show all channel policies
|
- `status` — Show all channel policies
|
||||||
@@ -93,12 +94,15 @@ Nine standalone tools (split from former monolithic `dirigent_tools`):
|
|||||||
- All plugin ids, tool names, config keys, file paths, docs updated
|
- All plugin ids, tool names, config keys, file paths, docs updated
|
||||||
- Legacy `whispergate` config key still supported as fallback
|
- Legacy `whispergate` config key still supported as fallback
|
||||||
|
|
||||||
## Installer Script
|
## Installer Script *(updated v0.3.0)*
|
||||||
- `scripts/install-dirigent-openclaw.mjs`
|
- `scripts/install-dirigent-openclaw.mjs`
|
||||||
- `--install` / `--uninstall` with delta-tracking
|
- `--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
|
- Auto-reinstall (uninstall + install) if already installed
|
||||||
- Backup before changes, rollback on failure
|
- Backup before changes, rollback on failure
|
||||||
- Records stored in `~/.openclaw/dirigent-install-records/`
|
- Records stored in `$(openclaw_dir)/dirigent-install-records/`
|
||||||
|
|
||||||
## Discord Control API (Sidecar)
|
## Discord Control API (Sidecar)
|
||||||
- Private channel create/update with permission overwrites
|
- Private channel create/update with permission overwrites
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -78,22 +78,19 @@ Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
|
|||||||
|
|
||||||
## Runtime tools & commands
|
## Runtime tools & commands
|
||||||
|
|
||||||
### Tools (9 individual tools)
|
### Tools (6 individual tools)
|
||||||
|
|
||||||
**Discord control:**
|
**Discord control:**
|
||||||
- `dirigent_channel_create` — Create private channel
|
- `dirigent_discord_channel_create` — Create private channel
|
||||||
- `dirigent_channel_update` — Update channel permissions
|
- `dirigent_discord_channel_update` — Update channel permissions
|
||||||
- `dirigent_member_list` — List guild members
|
- `dirigent_discord_member_list` — List guild members
|
||||||
|
|
||||||
**Policy management:**
|
**Policy management:**
|
||||||
- `dirigent_policy_get` — Get all policies
|
- `dirigent_policy_get` — Get all policies
|
||||||
- `dirigent_policy_set` — Set/update channel policy
|
- `dirigent_policy_set` — Set/update channel policy
|
||||||
- `dirigent_policy_delete` — Delete channel policy
|
- `dirigent_policy_delete` — Delete channel policy
|
||||||
|
|
||||||
**Turn management:**
|
> Turn management is internal to the plugin (not exposed as tools).
|
||||||
- `dirigent_turn_status` — Show turn state
|
|
||||||
- `dirigent_turn_advance` — Advance turn
|
|
||||||
- `dirigent_turn_reset` — Reset turn order
|
|
||||||
|
|
||||||
> See `FEAT.md` for full feature documentation.
|
> See `FEAT.md` for full feature documentation.
|
||||||
|
|
||||||
|
|||||||
12
TASKLIST.md
12
TASKLIST.md
@@ -41,16 +41,14 @@
|
|||||||
|
|
||||||
## 6) Split dirigent_tools into Individual Tools ✅
|
## 6) Split dirigent_tools into Individual Tools ✅
|
||||||
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
|
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
|
||||||
- **After**: 9 individual tools, each with focused parameters:
|
- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`):
|
||||||
- `dirigent_channel_create` — Create private Discord channel
|
- `dirigent_discord_channel_create` — Create private Discord channel
|
||||||
- `dirigent_channel_update` — Update channel permissions
|
- `dirigent_discord_channel_update` — Update channel permissions
|
||||||
- `dirigent_member_list` — List guild members
|
- `dirigent_discord_member_list` — List guild members
|
||||||
- `dirigent_policy_get` — Get all channel policies
|
- `dirigent_policy_get` — Get all channel policies
|
||||||
- `dirigent_policy_set` — Set/update a channel policy
|
- `dirigent_policy_set` — Set/update a channel policy
|
||||||
- `dirigent_policy_delete` — Delete a channel policy
|
- `dirigent_policy_delete` — Delete a channel policy
|
||||||
- `dirigent_turn_status` — Show turn status
|
- Turn management (status/advance/reset) NOT exposed as tools — purely internal plugin logic, accessible via `/dirigent` slash commands.
|
||||||
- `dirigent_turn_advance` — Manually advance turn
|
|
||||||
- `dirigent_turn_reset` — Reset turn order
|
|
||||||
- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication.
|
- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication.
|
||||||
- **Done**: All tools registered individually with specific parameter schemas.
|
- **Done**: All tools registered individually with specific parameter schemas.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/",
|
"prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/",
|
||||||
"postinstall": "node scripts/install-dirigent-openclaw.mjs --install",
|
"postinstall": "node scripts/install-dirigent-openclaw.mjs --install",
|
||||||
"uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall"
|
"uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall",
|
||||||
|
"update": "node scripts/install-dirigent-openclaw.mjs --update"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openclaw",
|
"openclaw",
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export default {
|
|||||||
|
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "dirigent_channel_create",
|
name: "dirigent_discord_channel_create",
|
||||||
description: "Create a private Discord channel with specific user/role permissions.",
|
description: "Create a private Discord channel with specific user/role permissions.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -654,7 +654,7 @@ export default {
|
|||||||
|
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "dirigent_channel_update",
|
name: "dirigent_discord_channel_update",
|
||||||
description: "Update permissions on an existing private Discord channel.",
|
description: "Update permissions on an existing private Discord channel.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -679,7 +679,7 @@ export default {
|
|||||||
|
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "dirigent_member_list",
|
name: "dirigent_discord_member_list",
|
||||||
description: "List members of a Discord guild.",
|
description: "List members of a Discord guild.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -798,72 +798,8 @@ export default {
|
|||||||
{ optional: false },
|
{ optional: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Turn management tools ──
|
// Turn management is handled internally by the plugin (not exposed as tools).
|
||||||
|
// Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control.
|
||||||
api.registerTool(
|
|
||||||
{
|
|
||||||
name: "dirigent_turn_status",
|
|
||||||
description: "Show turn-based speaking status for a channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
channelId: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["channelId"],
|
|
||||||
},
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
|
||||||
const channelId = String(params.channelId || "").trim();
|
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ optional: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
api.registerTool(
|
|
||||||
{
|
|
||||||
name: "dirigent_turn_advance",
|
|
||||||
description: "Manually advance the speaking turn in a channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
channelId: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["channelId"],
|
|
||||||
},
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
|
||||||
const channelId = String(params.channelId || "").trim();
|
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
|
||||||
const next = advanceTurn(channelId);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ optional: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
api.registerTool(
|
|
||||||
{
|
|
||||||
name: "dirigent_turn_reset",
|
|
||||||
description: "Reset turn order for a channel (go dormant).",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
channelId: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["channelId"],
|
|
||||||
},
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
|
||||||
const channelId = String(params.channelId || "").trim();
|
|
||||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
|
||||||
resetTurn(channelId);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ optional: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
api.on("message_received", async (event, ctx) => {
|
api.on("message_received", async (event, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,51 +1,143 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Dirigent plugin installer/uninstaller with delta-tracking.
|
* Dirigent plugin installer/uninstaller/updater with delta-tracking.
|
||||||
* Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys.
|
*
|
||||||
|
* Usage:
|
||||||
|
* node install-dirigent-openclaw.mjs --install Install (or reinstall) plugin
|
||||||
|
* node install-dirigent-openclaw.mjs --uninstall Remove plugin config & files
|
||||||
|
* node install-dirigent-openclaw.mjs --update Pull latest from git and reinstall
|
||||||
|
*
|
||||||
|
* OpenClaw directory resolution (in order):
|
||||||
|
* 1. $OPENCLAW_DIR environment variable
|
||||||
|
* 2. `openclaw config get dataDir` command output
|
||||||
|
* 3. ~/.openclaw (fallback)
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
// ── Mode parsing ──────────────────────────────────────────────────────────
|
||||||
|
const VALID_MODES = ["--install", "--uninstall", "--update"];
|
||||||
const modeArg = process.argv[2];
|
const modeArg = process.argv[2];
|
||||||
if (modeArg !== "--install" && modeArg !== "--uninstall") {
|
if (!VALID_MODES.includes(modeArg)) {
|
||||||
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall");
|
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall | --update");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const mode = modeArg === "--install" ? "install" : "uninstall";
|
const mode = modeArg.slice(2); // "install" | "uninstall" | "update"
|
||||||
|
|
||||||
|
// ── OpenClaw directory resolution ─────────────────────────────────────────
|
||||||
|
function resolveOpenClawDir() {
|
||||||
|
// 1. Explicit env var
|
||||||
|
if (process.env.OPENCLAW_DIR) {
|
||||||
|
const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir());
|
||||||
|
if (fs.existsSync(dir)) return dir;
|
||||||
|
console.warn(`[dirigent] OPENCLAW_DIR=${dir} does not exist, trying other methods...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ask openclaw CLI
|
||||||
|
try {
|
||||||
|
const out = execFileSync("openclaw", ["config", "get", "dataDir"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
if (out && fs.existsSync(out)) return out;
|
||||||
|
} catch {
|
||||||
|
// openclaw not found or command failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try openclaw config file location to infer dir
|
||||||
|
try {
|
||||||
|
const out = execFileSync("openclaw", ["config", "path"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
if (out) {
|
||||||
|
const dir = path.dirname(out);
|
||||||
|
if (fs.existsSync(dir)) return dir;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback
|
||||||
|
const fallback = path.join(os.homedir(), ".openclaw");
|
||||||
|
if (fs.existsSync(fallback)) return fallback;
|
||||||
|
|
||||||
|
console.error("[dirigent] cannot resolve OpenClaw directory. Set OPENCLAW_DIR or ensure openclaw CLI is available.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCLAW_DIR = resolveOpenClawDir();
|
||||||
|
console.log(`[dirigent] OpenClaw dir: ${OPENCLAW_DIR}`);
|
||||||
|
|
||||||
|
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json");
|
||||||
|
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||||
|
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const env = process.env;
|
const 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);
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
// Ensure dist/dirigent exists (handles git clone without npm prepare)
|
// ── Update mode: git pull then reinstall ──────────────────────────────────
|
||||||
const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent");
|
const GIT_REPO_URL = env.DIRIGENT_GIT_URL || "https://git.hangman-lab.top/nav/Dirigent.git";
|
||||||
const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin");
|
const GIT_BRANCH = env.DIRIGENT_GIT_BRANCH || "latest";
|
||||||
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") {
|
if (mode === "update") {
|
||||||
// Copy plugin files to dist/dirigent
|
console.log(`[dirigent] updating from ${GIT_REPO_URL} branch=${GIT_BRANCH} ...`);
|
||||||
if (!fs.existsSync(DIST_DIR)) {
|
|
||||||
console.log("[dirigent] dist/dirigent/ not found, syncing from plugin/...");
|
// Check if we're in a git repo
|
||||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
const gitDir = path.join(REPO_ROOT, ".git");
|
||||||
for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) {
|
if (!fs.existsSync(gitDir)) {
|
||||||
fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f));
|
console.error("[dirigent] not a git repo — cannot update. Clone the repo first.");
|
||||||
}
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy no-reply-api files to dist/no-reply-api
|
// Fetch and checkout latest
|
||||||
if (!fs.existsSync(NO_REPLY_API_DIST_DIR)) {
|
try {
|
||||||
console.log("[dirigent] dist/no-reply-api/ not found, syncing from no-reply-api/...");
|
execFileSync("git", ["fetch", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||||
fs.mkdirSync(NO_REPLY_API_DIST_DIR, { recursive: true });
|
execFileSync("git", ["checkout", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||||
for (const f of fs.readdirSync(NO_REPLY_API_SRC_DIR)) {
|
execFileSync("git", ["pull", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||||
fs.copyFileSync(path.join(NO_REPLY_API_SRC_DIR, f), path.join(NO_REPLY_API_DIST_DIR, f));
|
console.log("[dirigent] source updated successfully");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[dirigent] git update failed: ${String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-exec as install (the updated script may differ)
|
||||||
|
const updatedScript = path.join(REPO_ROOT, "scripts", "install-dirigent-openclaw.mjs");
|
||||||
|
const result = spawnSync(process.execPath, [updatedScript, "--install"], {
|
||||||
|
env: { ...process.env, OPENCLAW_DIR },
|
||||||
|
stdio: "inherit",
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
});
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build: copy plugin + no-reply-api to dist ─────────────────────────────
|
||||||
|
const PLUGIN_SRC_DIR = path.resolve(REPO_ROOT, "plugin");
|
||||||
|
const NO_REPLY_API_SRC_DIR = path.resolve(REPO_ROOT, "no-reply-api");
|
||||||
|
const DIST_PLUGIN_DIR = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||||
|
const DIST_NO_REPLY_DIR = path.resolve(REPO_ROOT, "dist", "no-reply-api");
|
||||||
|
|
||||||
|
function syncDir(srcDir, destDir) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
for (const f of fs.readdirSync(srcDir)) {
|
||||||
|
const srcFile = path.join(srcDir, f);
|
||||||
|
if (fs.statSync(srcFile).isFile()) {
|
||||||
|
fs.copyFileSync(srcFile, path.join(destDir, f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR;
|
// ── Determine plugin install path ─────────────────────────────────────────
|
||||||
|
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
|
||||||
|
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
|
||||||
|
const NO_REPLY_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent-no-reply-api");
|
||||||
|
|
||||||
|
// ── Config helpers ────────────────────────────────────────────────────────
|
||||||
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway";
|
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_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_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
|
||||||
@@ -53,13 +145,13 @@ const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
|
|||||||
const LIST_MODE = env.LIST_MODE || "human-list";
|
const LIST_MODE = env.LIST_MODE || "human-list";
|
||||||
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
|
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
|
||||||
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
|
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_FILE = (env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
|
||||||
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
|
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
|
||||||
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
|
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
|
||||||
const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️";
|
const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️";
|
||||||
|
|
||||||
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir());
|
const STATE_DIR = env.STATE_DIR || path.join(OPENCLAW_DIR, "dirigent-install-records");
|
||||||
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
|
const LATEST_RECORD_LINK = env.LATEST_RECORD_LINK || path.join(OPENCLAW_DIR, "dirigent-install-record-latest.json");
|
||||||
|
|
||||||
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
||||||
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
|
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
|
||||||
@@ -103,9 +195,11 @@ function writeRecord(modeName, delta) {
|
|||||||
const rec = {
|
const rec = {
|
||||||
mode: modeName,
|
mode: modeName,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
|
openclawDir: OPENCLAW_DIR,
|
||||||
openclawConfigPath: OPENCLAW_CONFIG_PATH,
|
openclawConfigPath: OPENCLAW_CONFIG_PATH,
|
||||||
backupPath: BACKUP_PATH,
|
backupPath: BACKUP_PATH,
|
||||||
delta, // { added: {...}, replaced: {...}, removed: {...} }
|
pluginInstallDir: PLUGIN_INSTALL_DIR,
|
||||||
|
delta,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
|
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
|
||||||
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
|
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
|
||||||
@@ -135,24 +229,13 @@ function findLatestInstallRecord() {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep clone
|
|
||||||
function clone(v) {
|
function clone(v) {
|
||||||
if (v === undefined) return undefined;
|
if (v === undefined) return undefined;
|
||||||
return JSON.parse(JSON.stringify(v));
|
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)
|
// INSTALL
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
if (mode === "install") {
|
if (mode === "install") {
|
||||||
// Check if already installed - if so, uninstall first
|
// Check if already installed - if so, uninstall first
|
||||||
@@ -160,10 +243,9 @@ if (mode === "install") {
|
|||||||
if (existingRecord) {
|
if (existingRecord) {
|
||||||
console.log("[dirigent] existing installation detected, uninstalling first...");
|
console.log("[dirigent] existing installation detected, uninstalling first...");
|
||||||
process.env.RECORD_FILE = existingRecord;
|
process.env.RECORD_FILE = existingRecord;
|
||||||
// Re-exec ourselves in uninstall mode
|
|
||||||
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
|
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
|
||||||
env: process.env,
|
env: { ...process.env, OPENCLAW_DIR },
|
||||||
stdio: ["inherit", "inherit", "inherit"],
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
console.error("[dirigent] reinstall failed during uninstall phase");
|
console.error("[dirigent] reinstall failed during uninstall phase");
|
||||||
@@ -172,31 +254,42 @@ if (mode === "install") {
|
|||||||
console.log("[dirigent] previous installation removed, proceeding with fresh install...");
|
console.log("[dirigent] previous installation removed, proceeding with fresh install...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Build dist
|
||||||
|
console.log("[dirigent] building dist...");
|
||||||
|
syncDir(PLUGIN_SRC_DIR, DIST_PLUGIN_DIR);
|
||||||
|
syncDir(NO_REPLY_API_SRC_DIR, DIST_NO_REPLY_DIR);
|
||||||
|
|
||||||
|
// 2. Copy to plugins dir
|
||||||
|
console.log(`[dirigent] installing plugin to ${PLUGIN_INSTALL_DIR}`);
|
||||||
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||||
|
syncDir(DIST_PLUGIN_DIR, PLUGIN_INSTALL_DIR);
|
||||||
|
|
||||||
|
// Also install no-reply-api next to plugin (plugin expects ../no-reply-api/)
|
||||||
|
console.log(`[dirigent] installing no-reply-api to ${NO_REPLY_INSTALL_DIR}`);
|
||||||
|
syncDir(DIST_NO_REPLY_DIR, NO_REPLY_INSTALL_DIR);
|
||||||
|
|
||||||
|
// 3. Backup config
|
||||||
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
|
||||||
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
|
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
|
||||||
|
|
||||||
|
// 4. Initialize channel policies file
|
||||||
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
|
||||||
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
|
||||||
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
|
||||||
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = { added: {}, replaced: {}, removed: {} };
|
const delta = { added: {}, replaced: {}, removed: {}, _prev: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── plugins.load.paths ────────────────────────────────────────────────
|
// ── plugins.load.paths ────────────────────────────────────────────────
|
||||||
const plugins = getJson("plugins") || {};
|
const plugins = getJson("plugins") || {};
|
||||||
const oldPaths = clone(plugins.load?.paths) || [];
|
const oldPaths = clone(plugins.load?.paths) || [];
|
||||||
const newPaths = clone(oldPaths);
|
const newPaths = clone(oldPaths);
|
||||||
const pathIndex = newPaths.indexOf(PLUGIN_PATH);
|
if (!newPaths.includes(PLUGIN_INSTALL_DIR)) {
|
||||||
if (pathIndex === -1) {
|
newPaths.push(PLUGIN_INSTALL_DIR);
|
||||||
newPaths.push(PLUGIN_PATH);
|
delta.added[PATH_PLUGINS_LOAD] = PLUGIN_INSTALL_DIR;
|
||||||
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;
|
delta._prev[PATH_PLUGINS_LOAD] = oldPaths;
|
||||||
plugins.load = plugins.load || {};
|
plugins.load = plugins.load || {};
|
||||||
plugins.load.paths = newPaths;
|
plugins.load.paths = newPaths;
|
||||||
@@ -260,7 +353,6 @@ if (mode === "install") {
|
|||||||
if (!allowList.includes("dirigent")) {
|
if (!allowList.includes("dirigent")) {
|
||||||
allowList.push("dirigent");
|
allowList.push("dirigent");
|
||||||
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
|
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
|
||||||
delta._prev = delta._prev || {};
|
|
||||||
delta._prev[PATH_PLUGINS_ALLOW] = oldAllow;
|
delta._prev[PATH_PLUGINS_ALLOW] = oldAllow;
|
||||||
setJson(PATH_PLUGINS_ALLOW, allowList);
|
setJson(PATH_PLUGINS_ALLOW, allowList);
|
||||||
console.log("[dirigent] added 'dirigent' to plugins.allow");
|
console.log("[dirigent] added 'dirigent' to plugins.allow");
|
||||||
@@ -268,6 +360,7 @@ if (mode === "install") {
|
|||||||
|
|
||||||
writeRecord("install", delta);
|
writeRecord("install", delta);
|
||||||
console.log("[dirigent] install ok (config written)");
|
console.log("[dirigent] install ok (config written)");
|
||||||
|
console.log(`[dirigent] plugin dir: ${PLUGIN_INSTALL_DIR}`);
|
||||||
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
||||||
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
|
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -280,7 +373,7 @@ if (mode === "install") {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// UNINSTALL
|
// UNINSTALL
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
else {
|
else if (mode === "uninstall") {
|
||||||
const recFile = env.RECORD_FILE || findLatestInstallRecord();
|
const recFile = env.RECORD_FILE || findLatestInstallRecord();
|
||||||
if (!recFile || !fs.existsSync(recFile)) {
|
if (!recFile || !fs.existsSync(recFile)) {
|
||||||
console.log("[dirigent] no install record found, nothing to uninstall.");
|
console.log("[dirigent] no install record found, nothing to uninstall.");
|
||||||
@@ -292,10 +385,10 @@ else {
|
|||||||
|
|
||||||
const rec = readRecord(recFile);
|
const rec = readRecord(recFile);
|
||||||
const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
|
const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
|
||||||
|
const installedPluginDir = rec.pluginInstallDir || PLUGIN_INSTALL_DIR;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── IMPORTANT: Order matters for OpenClaw config validation ────────────
|
// 1. Remove from allow list
|
||||||
// 1. First remove from allow (before deleting entry, otherwise validation fails)
|
|
||||||
if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) {
|
if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) {
|
||||||
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
|
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
|
||||||
const idx = allowList.indexOf("dirigent");
|
const idx = allowList.indexOf("dirigent");
|
||||||
@@ -306,17 +399,18 @@ else {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Then remove entry
|
// 2. Remove plugin entry
|
||||||
if (delta.added[PATH_PLUGIN_ENTRY] !== undefined || delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) {
|
if (delta.added[PATH_PLUGIN_ENTRY] !== undefined || delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) {
|
||||||
unsetPath(PATH_PLUGIN_ENTRY);
|
unsetPath(PATH_PLUGIN_ENTRY);
|
||||||
console.log("[dirigent] removed plugins.entries.dirigent");
|
console.log("[dirigent] removed plugins.entries.dirigent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Then remove plugin path (after entry is gone)
|
// 3. Remove plugin path from load paths
|
||||||
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
|
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
|
||||||
const plugins = getJson("plugins") || {};
|
const plugins = getJson("plugins") || {};
|
||||||
const paths = plugins.load?.paths || [];
|
const paths = plugins.load?.paths || [];
|
||||||
const idx = paths.indexOf(PLUGIN_PATH);
|
const pluginPath = delta.added[PATH_PLUGINS_LOAD];
|
||||||
|
const idx = paths.indexOf(pluginPath);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
paths.splice(idx, 1);
|
paths.splice(idx, 1);
|
||||||
plugins.load.paths = paths;
|
plugins.load.paths = paths;
|
||||||
@@ -325,7 +419,7 @@ else {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Finally remove provider
|
// 4. Remove provider
|
||||||
if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
|
if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
|
||||||
const providers = getJson(PATH_PROVIDERS) || {};
|
const providers = getJson(PATH_PROVIDERS) || {};
|
||||||
delete providers[NO_REPLY_PROVIDER_ID];
|
delete providers[NO_REPLY_PROVIDER_ID];
|
||||||
@@ -333,7 +427,7 @@ else {
|
|||||||
console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`);
|
console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Handle REPLACED provider: restore old value ───────────────────────
|
// Handle replaced provider: restore old value
|
||||||
if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) {
|
if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) {
|
||||||
const providers = getJson(PATH_PROVIDERS) || {};
|
const providers = getJson(PATH_PROVIDERS) || {};
|
||||||
providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY];
|
providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY];
|
||||||
@@ -341,7 +435,7 @@ else {
|
|||||||
console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`);
|
console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle plugins.load.paths restoration (if it was replaced, not added)
|
// Handle plugins.load.paths restoration
|
||||||
if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) {
|
if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) {
|
||||||
const plugins = getJson("plugins") || {};
|
const plugins = getJson("plugins") || {};
|
||||||
plugins.load = plugins.load || {};
|
plugins.load = plugins.load || {};
|
||||||
@@ -350,6 +444,18 @@ else {
|
|||||||
console.log("[dirigent] restored previous plugins.load.paths");
|
console.log("[dirigent] restored previous plugins.load.paths");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Remove installed plugin files
|
||||||
|
if (fs.existsSync(installedPluginDir)) {
|
||||||
|
fs.rmSync(installedPluginDir, { recursive: true, force: true });
|
||||||
|
console.log(`[dirigent] removed plugin dir: ${installedPluginDir}`);
|
||||||
|
}
|
||||||
|
// Also remove no-reply-api dir
|
||||||
|
const noReplyDir = path.join(path.dirname(installedPluginDir), "dirigent-no-reply-api");
|
||||||
|
if (fs.existsSync(noReplyDir)) {
|
||||||
|
fs.rmSync(noReplyDir, { recursive: true, force: true });
|
||||||
|
console.log(`[dirigent] removed no-reply-api dir: ${noReplyDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
writeRecord("uninstall", delta);
|
writeRecord("uninstall", delta);
|
||||||
console.log("[dirigent] uninstall ok");
|
console.log("[dirigent] uninstall ok");
|
||||||
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
console.log(`[dirigent] record: ${RECORD_PATH}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user