refactor #22
20
Makefile
20
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin
|
.PHONY: check check-rules check-files smoke install
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cd plugin && npm run check
|
cd plugin && npm run check
|
||||||
@@ -6,21 +6,11 @@ check:
|
|||||||
check-rules:
|
check-rules:
|
||||||
node scripts/validate-rules.mjs
|
node scripts/validate-rules.mjs
|
||||||
|
|
||||||
test-api:
|
check-files:
|
||||||
node scripts/test-no-reply-api.mjs
|
node scripts/check-plugin-files.mjs
|
||||||
|
|
||||||
up:
|
|
||||||
./scripts/dev-up.sh
|
|
||||||
|
|
||||||
down:
|
|
||||||
./scripts/dev-down.sh
|
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
./scripts/smoke-no-reply-api.sh
|
./scripts/smoke-no-reply-api.sh
|
||||||
|
|
||||||
render-config:
|
install:
|
||||||
node scripts/render-openclaw-config.mjs
|
node scripts/install.mjs --install
|
||||||
|
|
||||||
package-plugin:
|
|
||||||
node scripts/package-plugin.mjs
|
|
||||||
|
|
||||||
|
|||||||
189
README.md
189
README.md
@@ -1,144 +1,117 @@
|
|||||||
# Dirigent
|
# Dirigent
|
||||||
|
|
||||||
Rule-based no-reply gate + turn manager for OpenClaw (Discord).
|
Turn-management and moderation plugin for OpenClaw (Discord).
|
||||||
|
|
||||||
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
|
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
|
Dirigent adds deterministic routing and **turn-based speaking** for multi-agent Discord channels:
|
||||||
|
|
||||||
- **Rule gate (before_model_resolve)**
|
- **Rule gate (`before_model_resolve`)**
|
||||||
1. Non-Discord → skip
|
- Non-speaker agents → routed to no-reply model (silent turn)
|
||||||
2. Sender in bypass list / human list → skip
|
- Dormant channels → all agents suppressed until a human message wakes them
|
||||||
3. Message ends with configured end symbol → skip
|
- Configurable per-channel mode: `chat`, `work`, `discussion`, `report`, `none`
|
||||||
4. Otherwise → route to no-reply model/provider
|
|
||||||
|
|
||||||
- **End-symbol enforcement**
|
- **Turn management**
|
||||||
- Injects instruction: `Your response MUST end with 🔚…`
|
- Only the current speaker responds; others are silenced
|
||||||
- In group chats, also injects: "If not relevant, reply NO_REPLY"
|
- Turn advances when the current speaker ends with a configured symbol or returns `NO_REPLY`
|
||||||
|
- Full round of silence → channel enters **dormant** state
|
||||||
|
- Human message → wakes dormant channel, triggers first speaker
|
||||||
|
|
||||||
- **Scheduling identifier (moderator handoff)**
|
- **Moderator bot sidecar**
|
||||||
- Configurable identifier (default: `➡️`) used by the moderator bot
|
- Dedicated Discord Gateway connection (separate bot token) for real-time message push
|
||||||
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic, just a scheduling signal)
|
- Sends schedule-trigger messages (`<@USER_ID>➡️`) to signal speaker turns
|
||||||
- Agent receives instruction explaining the identifier is meaningless — check chat history and decide
|
- Notifies the plugin via HTTP callback on new messages (wake/interrupt)
|
||||||
|
|
||||||
- **Turn-based speaking (multi-bot)**
|
- **Discussion mode**
|
||||||
- Only the current speaker is allowed to respond
|
- Agents can initiate a structured discussion via `create-discussion-channel` tool
|
||||||
- Others are forced to no-reply
|
- Initiator calls `discussion-complete` to conclude; summary is posted to the callback channel
|
||||||
- Turn advances on **end-symbol** or **NO_REPLY**
|
|
||||||
- If all bots NO_REPLY, channel becomes **dormant** until a new human message
|
|
||||||
|
|
||||||
- **Agent identity injection**
|
- **Channel management tools**
|
||||||
- Injects agent name, Discord accountId, and Discord userId into group chat prompts
|
- `create-chat-channel`, `create-work-channel`, `create-report-channel` — create typed channels
|
||||||
|
- `create-discussion-channel`, `discussion-complete` — discussion lifecycle
|
||||||
- **Human @mention override**
|
- `dirigent-register` — register an agent identity
|
||||||
- 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_policy_set` / `dirigent_policy_delete` tools
|
|
||||||
|
|
||||||
- **Discord control actions (optional)**
|
|
||||||
- Private channel create/update + member list
|
|
||||||
- Via `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` tools
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
|
```
|
||||||
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
|
plugin/ OpenClaw plugin (hooks, tools, commands, web UI)
|
||||||
- Discord admin actions are now handled in-plugin via direct Discord REST API calls (no sidecar service)
|
core/ Channel store, identity registry, moderator REST helpers
|
||||||
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
|
hooks/ before_model_resolve, agent_end, message_received
|
||||||
- `scripts/` — smoke/dev/helper checks
|
tools/ Agent-facing tools
|
||||||
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
|
commands/ Slash commands
|
||||||
- `CHANGELOG.md` — milestone summary
|
web/ Control page + Dirigent API (HTTP routes)
|
||||||
|
services/ Sidecar process — spawned automatically by the plugin
|
||||||
|
main.mjs Unified entry point, routes /no-reply/* and /moderator/*
|
||||||
|
no-reply-api/ OpenAI-compatible server that always returns NO_REPLY
|
||||||
|
moderator/ Discord Gateway client + HTTP control endpoints
|
||||||
|
scripts/ Dev helpers
|
||||||
|
docs/ Architecture and integration notes
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start (no Docker)
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd no-reply-api
|
node scripts/install.mjs --install
|
||||||
node server.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then render config snippet:
|
This copies `plugin/` and `services/` into the OpenClaw plugin directory and registers skills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidecar
|
||||||
|
|
||||||
|
The sidecar (`services/main.mjs`) is spawned automatically when openclaw-gateway starts. It exposes:
|
||||||
|
|
||||||
|
| Path prefix | Description |
|
||||||
|
|---|---|
|
||||||
|
| `/no-reply/*` | No-reply model API (`/v1/chat/completions`, `/v1/responses`) |
|
||||||
|
| `/moderator/*` | Moderator bot control (`/send`, `/create-channel`, `/me`, …) |
|
||||||
|
| `/health` | Combined health check |
|
||||||
|
|
||||||
|
Port is configured via `sideCarPort` (default `8787`).
|
||||||
|
|
||||||
|
Smoke-test after gateway start:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/render-openclaw-config.mjs
|
make smoke
|
||||||
```
|
# or:
|
||||||
|
./scripts/smoke-no-reply-api.sh
|
||||||
See `docs/RUN_MODES.md` for Docker mode.
|
|
||||||
Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Runtime tools & commands
|
|
||||||
|
|
||||||
### Tools (6 individual tools)
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
```
|
|
||||||
/dirigent status
|
|
||||||
/dirigent turn-status
|
|
||||||
/dirigent turn-advance
|
|
||||||
/dirigent turn-reset
|
|
||||||
/dirigent turn-shuffling
|
|
||||||
/dirigent turn-shuffling on
|
|
||||||
/dirigent turn-shuffling off
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Config highlights
|
## Plugin config
|
||||||
|
|
||||||
Common options (see `docs/INTEGRATION.md`):
|
Key options (in `openclaw.json` under `plugins.entries.dirigent.config`):
|
||||||
|
|
||||||
- `listMode`: `human-list` or `agent-list`
|
| Key | Default | Description |
|
||||||
- `humanList`, `agentList`
|
|---|---|---|
|
||||||
- `endSymbols`
|
| `moderatorBotToken` | — | Discord bot token for the moderator/sidecar bot |
|
||||||
- `schedulingIdentifier` (default `➡️`)
|
| `scheduleIdentifier` | `➡️` | Symbol appended to schedule-trigger mentions |
|
||||||
- `waitIdentifier` (default `👤`) — agent ends with this to pause all agents until human replies
|
| `listMode` | `human-list` | `human-list` or `agent-list` |
|
||||||
- `channelPoliciesFile` (per-channel overrides)
|
| `humanList` | `[]` | Discord user IDs treated as humans (bypass turn gate) |
|
||||||
- `moderatorBotToken` (handoff messages)
|
| `agentList` | `[]` | Discord user IDs treated as agents (when `listMode=agent-list`) |
|
||||||
- `multiMessageStartMarker` (default `↗️`)
|
| `noReplyProvider` | `dirigent` | Provider ID for the no-reply model |
|
||||||
- `multiMessageEndMarker` (default `↙️`)
|
| `noReplyModel` | `no-reply` | Model ID for the no-reply model |
|
||||||
- `multiMessagePromptMarker` (default `⤵️`)
|
| `sideCarPort` | `8787` | Port the sidecar listens on |
|
||||||
- `enableDebugLogs`, `debugLogChannelIds`
|
| `debugMode` | `false` | Enable verbose debug logging |
|
||||||
|
| `debugLogChannelIds` | `[]` | Channel IDs that receive debug log messages |
|
||||||
Shuffle mode does not currently have a global config key. It is a per-channel runtime toggle, defaults to off, and is controlled with `/dirigent turn-shuffling ...`.
|
| `channelPoliciesFile` | `~/.openclaw/dirigent-channel-policies.json` | Per-channel policy overrides |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development plan (incremental commits)
|
## Dev commands
|
||||||
|
|
||||||
- [x] Task 1: project docs + structure
|
```bash
|
||||||
- [x] Task 2: no-reply API MVP
|
make check # TypeScript check (plugin/)
|
||||||
- [x] Task 3: plugin MVP with rule chain
|
make check-rules # Validate rule-case fixtures
|
||||||
- [x] Task 4: sample config + quick verification scripts
|
make check-files # Verify required files exist
|
||||||
- [x] Task 5: plugin rule extraction + hardening
|
make smoke # Smoke-test no-reply endpoint (sidecar must be running)
|
||||||
- [x] Task 6: containerization + compose
|
make install # Install plugin + sidecar into OpenClaw
|
||||||
- [x] Task 7: plugin usage notes
|
```
|
||||||
- [x] Task 8: sender normalization + TTL + one-shot decision
|
|
||||||
- [x] Task 9: auth-aware no-reply API
|
|
||||||
- [x] Task 10: smoke test helpers
|
|
||||||
- [x] Task 11: plugin structure checker
|
|
||||||
- [x] Task 12: rollout checklist
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
services:
|
|
||||||
dirigent-no-reply-api:
|
|
||||||
build:
|
|
||||||
context: ./no-reply-api
|
|
||||||
container_name: dirigent-no-reply-api
|
|
||||||
ports:
|
|
||||||
- "8787:8787"
|
|
||||||
environment:
|
|
||||||
- PORT=8787
|
|
||||||
- NO_REPLY_MODEL=dirigent-no-reply-v1
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json ./
|
|
||||||
COPY server.mjs ./
|
|
||||||
EXPOSE 8787
|
|
||||||
ENV PORT=8787
|
|
||||||
CMD ["node", "server.mjs"]
|
|
||||||
12
no-reply-api/package-lock.json
generated
12
no-reply-api/package-lock.json
generated
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dirigent-no-reply-api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "dirigent-no-reply-api",
|
|
||||||
"version": "0.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dirigent-no-reply-api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.mjs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 8787);
|
|
||||||
const modelName = process.env.NO_REPLY_MODEL || "no-reply";
|
|
||||||
const authToken = process.env.AUTH_TOKEN || "";
|
|
||||||
|
|
||||||
function sendJson(res, status, payload) {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthorized(req) {
|
|
||||||
if (!authToken) return true;
|
|
||||||
const header = req.headers.authorization || "";
|
|
||||||
return header === `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function noReplyChatCompletion(reqBody) {
|
|
||||||
return {
|
|
||||||
id: `chatcmpl_dirigent_${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: reqBody?.model || modelName,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: { role: "assistant", content: "NO_REPLY" },
|
|
||||||
finish_reason: "stop"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function noReplyResponses(reqBody) {
|
|
||||||
return {
|
|
||||||
id: `resp_dirigent_${Date.now()}`,
|
|
||||||
object: "response",
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
model: reqBody?.model || modelName,
|
|
||||||
output: [
|
|
||||||
{
|
|
||||||
type: "message",
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "output_text", text: "NO_REPLY" }]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listModels() {
|
|
||||||
return {
|
|
||||||
object: "list",
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: modelName,
|
|
||||||
object: "model",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
owned_by: "dirigent"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
|
||||||
return sendJson(res, 200, { ok: true, service: "dirigent-no-reply-api", model: modelName });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && req.url === "/v1/models") {
|
|
||||||
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
||||||
return sendJson(res, 200, listModels());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return sendJson(res, 404, { error: "not_found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthorized(req)) {
|
|
||||||
return sendJson(res, 401, { error: "unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = "";
|
|
||||||
req.on("data", (chunk) => {
|
|
||||||
body += chunk;
|
|
||||||
if (body.length > 1_000_000) req.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on("end", () => {
|
|
||||||
let parsed = {};
|
|
||||||
try {
|
|
||||||
parsed = body ? JSON.parse(body) : {};
|
|
||||||
} catch {
|
|
||||||
return sendJson(res, 400, { error: "invalid_json" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === "/v1/chat/completions") {
|
|
||||||
return sendJson(res, 200, noReplyChatCompletion(parsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === "/v1/responses") {
|
|
||||||
return sendJson(res, 200, noReplyResponses(parsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sendJson(res, 404, { error: "not_found" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`[dirigent-no-reply-api] listening on :${port}`);
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"plugin/",
|
"plugin/",
|
||||||
"no-reply-api/",
|
"services/",
|
||||||
|
|
||||||
"docs/",
|
"docs/",
|
||||||
"scripts/install.mjs",
|
"scripts/install.mjs",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"TASKLIST.md"
|
"TASKLIST.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
|
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/ && cp -r services dist/dirigent/services",
|
||||||
"test": "node --test --experimental-strip-types test/**/*.test.ts",
|
"test": "node --test --experimental-strip-types test/**/*.test.ts",
|
||||||
"postinstall": "node scripts/install.mjs --install",
|
"postinstall": "node scripts/install.mjs --install",
|
||||||
"uninstall": "node scripts/install.mjs --uninstall",
|
"uninstall": "node scripts/install.mjs --uninstall",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
119
plugin/core/sidecar-process.ts
Normal file
119
plugin/core/sidecar-process.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
|
let noReplyProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
const LOCK_FILE = path.join(os.tmpdir(), "dirigent-sidecar.lock");
|
||||||
|
|
||||||
|
function readLock(): { pid: number } | null {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(LOCK_FILE, "utf8").trim();
|
||||||
|
return { pid: Number(raw) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLock(pid: number): void {
|
||||||
|
try { fs.writeFileSync(LOCK_FILE, String(pid)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLock(): void {
|
||||||
|
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockHeld(): boolean {
|
||||||
|
const lock = readLock();
|
||||||
|
if (!lock) return false;
|
||||||
|
try {
|
||||||
|
process.kill(lock.pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSideCar(
|
||||||
|
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||||
|
pluginDir: string,
|
||||||
|
port = 8787,
|
||||||
|
moderatorToken?: string,
|
||||||
|
pluginApiToken?: string,
|
||||||
|
gatewayPort?: number,
|
||||||
|
debugMode?: boolean,
|
||||||
|
): void {
|
||||||
|
logger.info(`dirigent: startSideCar called, pluginDir=${pluginDir}`);
|
||||||
|
|
||||||
|
if (noReplyProcess) {
|
||||||
|
logger.info("dirigent: no-reply API already running (local ref), skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLockHeld()) {
|
||||||
|
logger.info("dirigent: no-reply API already running (lock file), skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/main.mjs lives alongside the plugin directory in the distribution
|
||||||
|
const serverPath = path.resolve(pluginDir, "services", "main.mjs");
|
||||||
|
logger.info(`dirigent: resolved serverPath=${serverPath}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
logger.warn(`dirigent: services/main.mjs not found at ${serverPath}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("dirigent: services/main.mjs found, spawning process...");
|
||||||
|
|
||||||
|
// Build plugin API URL from gateway port, or use a default
|
||||||
|
const pluginApiUrl = gatewayPort
|
||||||
|
? `http://127.0.0.1:${gatewayPort}`
|
||||||
|
: "http://127.0.0.1:18789";
|
||||||
|
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
SERVICES_PORT: String(port),
|
||||||
|
PLUGIN_API_URL: pluginApiUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (moderatorToken) {
|
||||||
|
env.MODERATOR_TOKEN = moderatorToken;
|
||||||
|
}
|
||||||
|
if (pluginApiToken) {
|
||||||
|
env.PLUGIN_API_TOKEN = pluginApiToken;
|
||||||
|
}
|
||||||
|
if (debugMode !== undefined) {
|
||||||
|
env.DEBUG_MODE = debugMode ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
noReplyProcess = spawn(process.execPath, [serverPath], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (noReplyProcess.pid) {
|
||||||
|
writeLock(noReplyProcess.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: services: ${d.toString().trim()}`));
|
||||||
|
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: services: ${d.toString().trim()}`));
|
||||||
|
|
||||||
|
noReplyProcess.on("exit", (code, signal) => {
|
||||||
|
logger.info(`dirigent: services exited (code=${code}, signal=${signal})`);
|
||||||
|
clearLock();
|
||||||
|
noReplyProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`dirigent: services started (pid=${noReplyProcess.pid}, port=${port})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSideCar(logger: { info: (m: string) => void }): void {
|
||||||
|
if (!noReplyProcess) return;
|
||||||
|
logger.info("dirigent: stopping sidecar");
|
||||||
|
noReplyProcess.kill("SIGTERM");
|
||||||
|
noReplyProcess = null;
|
||||||
|
clearLock();
|
||||||
|
}
|
||||||
@@ -176,8 +176,6 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTurnPending(channelId, agentId);
|
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
||||||
);
|
);
|
||||||
@@ -186,6 +184,10 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
// Real turn: wait for Discord delivery before triggering next speaker.
|
// Real turn: wait for Discord delivery before triggering next speaker.
|
||||||
// Anchor was set in before_model_resolve just before the LLM call, so any
|
// Anchor was set in before_model_resolve just before the LLM call, so any
|
||||||
// message from the agent after the anchor must be from this turn.
|
// message from the agent after the anchor must be from this turn.
|
||||||
|
// NOTE: clearTurnPending is intentionally deferred until after pollForTailMatch
|
||||||
|
// returns. While waiting, isTurnPending remains true so that any re-trigger of
|
||||||
|
// this agent is correctly treated as a self-wakeup (suppressed), preventing it
|
||||||
|
// from starting a second real turn during the tail-match window.
|
||||||
const identity = identityRegistry.findByAgentId(agentId);
|
const identity = identityRegistry.findByAgentId(agentId);
|
||||||
if (identity && moderatorBotToken) {
|
if (identity && moderatorBotToken) {
|
||||||
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
||||||
@@ -202,6 +204,7 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
if (isDormant(channelId)) {
|
if (isDormant(channelId)) {
|
||||||
// Channel is dormant: a new external message woke it — restart from first speaker
|
// Channel is dormant: a new external message woke it — restart from first speaker
|
||||||
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
|
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
|
||||||
|
clearTurnPending(channelId, agentId);
|
||||||
const first = wakeFromDormant(channelId);
|
const first = wakeFromDormant(channelId);
|
||||||
if (first) await triggerNextSpeaker(channelId, first);
|
if (first) await triggerNextSpeaker(channelId, first);
|
||||||
return;
|
return;
|
||||||
@@ -243,6 +246,12 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
advancingChannels.delete(channelId);
|
advancingChannels.delete(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear turn pending AFTER advanceSpeaker completes. This ensures isTurnPending
|
||||||
|
// remains true during the async rebuildFn window at cycle boundaries, preventing
|
||||||
|
// re-triggers from starting a second real turn while currentIndex is still at the
|
||||||
|
// outgoing speaker's position.
|
||||||
|
clearTurnPending(channelId, agentId);
|
||||||
|
|
||||||
if (enteredDormant) {
|
if (enteredDormant) {
|
||||||
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
||||||
if (mode === "discussion") {
|
if (mode === "discussion") {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ChannelStore } from "../core/channel-store.js";
|
|||||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||||
import { parseDiscordChannelId } from "./before-model-resolve.js";
|
import { parseDiscordChannelId } from "./before-model-resolve.js";
|
||||||
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||||
import { sendAndDelete, sendModeratorMessage, userIdFromBotToken } from "../core/moderator-discord.js";
|
import { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
|
||||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||||
import type { InterruptFn } from "./agent-end.js";
|
import type { InterruptFn } from "./agent-end.js";
|
||||||
|
|
||||||
@@ -14,24 +14,17 @@ type Deps = {
|
|||||||
moderatorBotToken: string | undefined;
|
moderatorBotToken: string | undefined;
|
||||||
scheduleIdentifier: string;
|
scheduleIdentifier: string;
|
||||||
interruptTailMatch: InterruptFn;
|
interruptTailMatch: InterruptFn;
|
||||||
|
debugMode: boolean;
|
||||||
|
/**
|
||||||
|
* When true, the moderator service handles wake-from-dormant and
|
||||||
|
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
|
||||||
|
* initialization in that case.
|
||||||
|
*/
|
||||||
|
moderatorHandlesMessages?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Process-level dedup for concluded-discussion auto-replies.
|
|
||||||
* Multiple agent VM contexts all fire message_received for the same incoming message;
|
|
||||||
* only the first should send the "This discussion is closed" reply.
|
|
||||||
* Keyed on channelId:messageId; evicted after 500 entries.
|
|
||||||
*/
|
|
||||||
const _CONCLUDED_REPLY_DEDUP_KEY = "_dirigentConcludedReplyDedup";
|
|
||||||
if (!(globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY]) {
|
|
||||||
(globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] = new Set<string>();
|
|
||||||
}
|
|
||||||
const concludedReplyDedup: Set<string> = (globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] as Set<string>;
|
|
||||||
|
|
||||||
export function registerMessageReceivedHook(deps: Deps): void {
|
export function registerMessageReceivedHook(deps: Deps): void {
|
||||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
|
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
|
||||||
// Derive the moderator bot's own Discord user ID so we can skip self-messages
|
|
||||||
// from waking dormant channels (idle reminders must not re-trigger the cycle).
|
|
||||||
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
|
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
|
||||||
|
|
||||||
api.on("message_received", async (event, ctx) => {
|
api.on("message_received", async (event, ctx) => {
|
||||||
@@ -42,23 +35,19 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
// Extract Discord channel ID from ctx or event metadata
|
// Extract Discord channel ID from ctx or event metadata
|
||||||
let channelId: string | undefined;
|
let channelId: string | undefined;
|
||||||
|
|
||||||
// ctx.channelId may be bare "1234567890" or "channel:1234567890"
|
|
||||||
if (typeof c.channelId === "string") {
|
if (typeof c.channelId === "string") {
|
||||||
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
|
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
|
||||||
if (bare) channelId = bare;
|
if (bare) channelId = bare;
|
||||||
}
|
}
|
||||||
// fallback: sessionKey (per-session api instances)
|
|
||||||
if (!channelId && typeof c.sessionKey === "string") {
|
if (!channelId && typeof c.sessionKey === "string") {
|
||||||
channelId = parseDiscordChannelId(c.sessionKey);
|
channelId = parseDiscordChannelId(c.sessionKey);
|
||||||
}
|
}
|
||||||
// fallback: metadata.to / originatingTo = "channel:1234567890"
|
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
const metadata = e.metadata as Record<string, unknown> | undefined;
|
const metadata = e.metadata as Record<string, unknown> | undefined;
|
||||||
const to = String(metadata?.to ?? metadata?.originatingTo ?? "");
|
const to = String(metadata?.to ?? metadata?.originatingTo ?? "");
|
||||||
const toMatch = to.match(/:(\d+)$/);
|
const toMatch = to.match(/:(\d+)$/);
|
||||||
if (toMatch) channelId = toMatch[1];
|
if (toMatch) channelId = toMatch[1];
|
||||||
}
|
}
|
||||||
// fallback: conversation_info
|
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
const metadata = e.metadata as Record<string, unknown> | undefined;
|
const metadata = e.metadata as Record<string, unknown> | undefined;
|
||||||
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
|
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
|
||||||
@@ -69,45 +58,12 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
|
|
||||||
const mode = channelStore.getMode(channelId);
|
const mode = channelStore.getMode(channelId);
|
||||||
|
|
||||||
// dead: suppress routing entirely (OpenClaw handles no-route automatically,
|
|
||||||
// but we handle archived auto-reply here)
|
|
||||||
if (mode === "report") return;
|
if (mode === "report") return;
|
||||||
|
|
||||||
// archived: auto-reply via moderator (deduped — only one agent instance should reply)
|
|
||||||
if (mode === "discussion") {
|
|
||||||
const rec = channelStore.getRecord(channelId);
|
|
||||||
if (rec.discussion?.concluded && moderatorBotToken) {
|
|
||||||
const metadata = e.metadata as Record<string, unknown> | undefined;
|
|
||||||
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
|
|
||||||
const incomingMsgId = String(
|
|
||||||
convInfo?.message_id ??
|
|
||||||
metadata?.message_id ??
|
|
||||||
metadata?.messageId ??
|
|
||||||
e.id ?? "",
|
|
||||||
);
|
|
||||||
const dedupKey = `${channelId}:${incomingMsgId}`;
|
|
||||||
if (!concludedReplyDedup.has(dedupKey)) {
|
|
||||||
concludedReplyDedup.add(dedupKey);
|
|
||||||
if (concludedReplyDedup.size > 500) {
|
|
||||||
const oldest = concludedReplyDedup.values().next().value;
|
|
||||||
if (oldest) concludedReplyDedup.delete(oldest);
|
|
||||||
}
|
|
||||||
await sendModeratorMessage(
|
|
||||||
moderatorBotToken, channelId,
|
|
||||||
"This discussion is closed and no longer active.",
|
|
||||||
api.logger,
|
|
||||||
).catch(() => undefined);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "none" || mode === "work") return;
|
if (mode === "none" || mode === "work") return;
|
||||||
|
|
||||||
// chat / discussion (active): initialize speaker list on first message if needed
|
// ── Speaker-list initialization (always runs, even with moderator service) ──
|
||||||
const initializingChannels = getInitializingChannels();
|
const initializingChannels = getInitializingChannels();
|
||||||
if (!hasSpeakers(channelId) && moderatorBotToken) {
|
if (!hasSpeakers(channelId) && moderatorBotToken) {
|
||||||
// Guard against concurrent initialization from multiple VM contexts
|
|
||||||
if (initializingChannels.has(channelId)) {
|
if (initializingChannels.has(channelId)) {
|
||||||
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
|
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
|
||||||
return;
|
return;
|
||||||
@@ -126,7 +82,7 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
setSpeakerList(channelId, speakers);
|
setSpeakerList(channelId, speakers);
|
||||||
const first = speakers[0];
|
const first = speakers[0];
|
||||||
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
|
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
|
||||||
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,8 +90,8 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chat / discussion (active): check if this is an external message
|
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
|
||||||
// that should interrupt an in-progress tail-match or wake dormant
|
if (moderatorHandlesMessages) return;
|
||||||
|
|
||||||
const senderId = String(
|
const senderId = String(
|
||||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||||
@@ -143,7 +99,6 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
e.from ?? "",
|
e.from ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Identify the sender: is it the current speaker's Discord account?
|
|
||||||
const currentSpeakerIsThisSender = (() => {
|
const currentSpeakerIsThisSender = (() => {
|
||||||
if (!senderId) return false;
|
if (!senderId) return false;
|
||||||
const entry = identityRegistry.findByDiscordUserId(senderId);
|
const entry = identityRegistry.findByDiscordUserId(senderId);
|
||||||
@@ -152,17 +107,16 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
if (!currentSpeakerIsThisSender) {
|
if (!currentSpeakerIsThisSender) {
|
||||||
// Non-current-speaker posted — interrupt any tail-match in progress
|
if (senderId !== moderatorBotUserId) {
|
||||||
interruptTailMatch(channelId);
|
interruptTailMatch(channelId);
|
||||||
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Wake from dormant if needed — but ignore the moderator bot's own messages
|
|
||||||
// (e.g. idle reminder) to prevent it from immediately re-waking the channel.
|
|
||||||
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
|
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
|
||||||
const first = wakeFromDormant(channelId);
|
const first = wakeFromDormant(channelId);
|
||||||
if (first) {
|
if (first) {
|
||||||
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
|
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
|
||||||
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
|
||||||
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
|
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
plugin/index.ts
145
plugin/index.ts
@@ -4,8 +4,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
import { IdentityRegistry } from "./core/identity-registry.js";
|
import { IdentityRegistry } from "./core/identity-registry.js";
|
||||||
import { ChannelStore } from "./core/channel-store.js";
|
import { ChannelStore } from "./core/channel-store.js";
|
||||||
import { scanPaddedCell } from "./core/padded-cell.js";
|
import { scanPaddedCell } from "./core/padded-cell.js";
|
||||||
import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
|
import { startSideCar, stopSideCar } from "./core/sidecar-process.js";
|
||||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
|
||||||
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
||||||
import { registerAgentEndHook } from "./hooks/agent-end.js";
|
import { registerAgentEndHook } from "./hooks/agent-end.js";
|
||||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||||
@@ -13,33 +12,44 @@ import { registerDirigentTools } from "./tools/register-tools.js";
|
|||||||
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
|
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
|
||||||
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
|
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
|
||||||
import { registerControlPage } from "./web/control-page.js";
|
import { registerControlPage } from "./web/control-page.js";
|
||||||
import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js";
|
import { registerDirigentApi } from "./web/dirigent-api.js";
|
||||||
import { setSpeakerList } from "./turn-manager.js";
|
import { sendModeratorMessage, sendScheduleTrigger, getBotUserIdFromToken } from "./core/moderator-discord.js";
|
||||||
|
import { setSpeakerList, isCurrentSpeaker, isDormant, wakeFromDormant } from "./turn-manager.js";
|
||||||
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
||||||
|
|
||||||
type PluginConfig = {
|
type PluginConfig = {
|
||||||
moderatorBotToken?: string;
|
moderatorBotToken?: string;
|
||||||
noReplyProvider?: string;
|
|
||||||
noReplyModel?: string;
|
|
||||||
noReplyPort?: number;
|
|
||||||
scheduleIdentifier?: string;
|
scheduleIdentifier?: string;
|
||||||
identityFilePath?: string;
|
identityFilePath?: string;
|
||||||
channelStoreFilePath?: string;
|
channelStoreFilePath?: string;
|
||||||
|
debugMode?: boolean;
|
||||||
|
noReplyProvider?: string;
|
||||||
|
noReplyModel?: string;
|
||||||
|
sideCarPort?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
||||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||||
return {
|
return {
|
||||||
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
||||||
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
|
||||||
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
|
||||||
noReplyPort: Number(cfg.noReplyPort ?? 8787),
|
|
||||||
scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
|
scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
|
||||||
identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
|
identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
|
||||||
channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
|
channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
|
||||||
|
debugMode: cfg.debugMode ?? false,
|
||||||
|
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
||||||
|
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
||||||
|
sideCarPort: cfg.sideCarPort ?? 8787,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGatewayPort(api: OpenClawPluginApi): number {
|
||||||
|
try {
|
||||||
|
return ((api.config as Record<string, unknown>)?.gateway as Record<string, unknown>)?.port as number ?? 18789;
|
||||||
|
} catch {
|
||||||
|
return 18789;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
|
* Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
|
||||||
* when the gateway process starts/stops, not per agent session. We guard these on
|
* when the gateway process starts/stops, not per agent session. We guard these on
|
||||||
@@ -76,6 +86,10 @@ export default {
|
|||||||
const identityRegistry = new IdentityRegistry(config.identityFilePath);
|
const identityRegistry = new IdentityRegistry(config.identityFilePath);
|
||||||
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
||||||
|
|
||||||
|
const moderatorBotToken = config.moderatorBotToken || undefined;
|
||||||
|
const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined;
|
||||||
|
const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`;
|
||||||
|
|
||||||
let paddedCellDetected = false;
|
let paddedCellDetected = false;
|
||||||
|
|
||||||
function hasPaddedCell(): boolean {
|
function hasPaddedCell(): boolean {
|
||||||
@@ -94,24 +108,27 @@ export default {
|
|||||||
if (!isGatewayLifecycleRegistered()) {
|
if (!isGatewayLifecycleRegistered()) {
|
||||||
markGatewayLifecycleRegistered();
|
markGatewayLifecycleRegistered();
|
||||||
|
|
||||||
api.on("gateway_start", () => {
|
const gatewayPort = getGatewayPort(api);
|
||||||
const live = normalizeConfig(api);
|
|
||||||
|
|
||||||
startNoReplyApi(api.logger, pluginDir, live.noReplyPort);
|
// Start unified services (no-reply API + moderator bot)
|
||||||
|
startSideCar(
|
||||||
|
api.logger,
|
||||||
|
pluginDir,
|
||||||
|
config.sideCarPort,
|
||||||
|
moderatorBotToken,
|
||||||
|
undefined, // pluginApiToken — gateway handles auth for plugin routes
|
||||||
|
gatewayPort,
|
||||||
|
config.debugMode,
|
||||||
|
);
|
||||||
|
|
||||||
if (live.moderatorBotToken) {
|
if (!moderatorBotToken) {
|
||||||
startModeratorPresence(live.moderatorBotToken, api.logger);
|
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||||
api.logger.info("dirigent: moderator bot presence started");
|
}
|
||||||
} else {
|
|
||||||
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
tryAutoScanPaddedCell();
|
tryAutoScanPaddedCell();
|
||||||
});
|
|
||||||
|
|
||||||
api.on("gateway_stop", () => {
|
api.on("gateway_stop", () => {
|
||||||
stopNoReplyApi(api.logger);
|
stopSideCar(api.logger);
|
||||||
stopModeratorPresence();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,18 +137,20 @@ export default {
|
|||||||
api,
|
api,
|
||||||
channelStore,
|
channelStore,
|
||||||
identityRegistry,
|
identityRegistry,
|
||||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
moderatorBotToken,
|
||||||
noReplyModel: config.noReplyModel,
|
|
||||||
noReplyProvider: config.noReplyProvider,
|
|
||||||
scheduleIdentifier: config.scheduleIdentifier,
|
scheduleIdentifier: config.scheduleIdentifier,
|
||||||
|
debugMode: config.debugMode,
|
||||||
|
noReplyProvider: config.noReplyProvider,
|
||||||
|
noReplyModel: config.noReplyModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const interruptTailMatch = registerAgentEndHook({
|
const interruptTailMatch = registerAgentEndHook({
|
||||||
api,
|
api,
|
||||||
channelStore,
|
channelStore,
|
||||||
identityRegistry,
|
identityRegistry,
|
||||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
moderatorBotToken,
|
||||||
scheduleIdentifier: config.scheduleIdentifier,
|
scheduleIdentifier: config.scheduleIdentifier,
|
||||||
|
debugMode: config.debugMode,
|
||||||
onDiscussionDormant: async (channelId: string) => {
|
onDiscussionDormant: async (channelId: string) => {
|
||||||
const live = normalizeConfig(api);
|
const live = normalizeConfig(api);
|
||||||
if (!live.moderatorBotToken) return;
|
if (!live.moderatorBotToken) return;
|
||||||
@@ -148,13 +167,74 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup)
|
||||||
registerMessageReceivedHook({
|
registerMessageReceivedHook({
|
||||||
api,
|
api,
|
||||||
channelStore,
|
channelStore,
|
||||||
identityRegistry,
|
identityRegistry,
|
||||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
moderatorBotToken,
|
||||||
scheduleIdentifier: config.scheduleIdentifier,
|
scheduleIdentifier: config.scheduleIdentifier,
|
||||||
interruptTailMatch,
|
interruptTailMatch,
|
||||||
|
debugMode: config.debugMode,
|
||||||
|
// When moderator service is active it handles wake/interrupt via HTTP callback;
|
||||||
|
// message_received only needs to run speaker-list initialization.
|
||||||
|
moderatorHandlesMessages: !!moderatorBotToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dirigent API (moderator service → plugin callbacks) ───────────────
|
||||||
|
registerDirigentApi({
|
||||||
|
api,
|
||||||
|
channelStore,
|
||||||
|
moderatorBotUserId,
|
||||||
|
scheduleIdentifier: config.scheduleIdentifier,
|
||||||
|
moderatorServiceUrl,
|
||||||
|
moderatorServiceToken: undefined,
|
||||||
|
debugMode: config.debugMode,
|
||||||
|
onNewMessage: async ({ channelId, senderId }) => {
|
||||||
|
const mode = channelStore.getMode(channelId);
|
||||||
|
|
||||||
|
// Modes where agents don't participate
|
||||||
|
if (mode === "none" || mode === "work" || mode === "report") return;
|
||||||
|
|
||||||
|
// Skip messages from the moderator bot itself (schedule triggers, etc.)
|
||||||
|
if (senderId === moderatorBotUserId) return;
|
||||||
|
|
||||||
|
// Concluded discussion: send "closed" reply via moderator service
|
||||||
|
if (mode === "discussion") {
|
||||||
|
const rec = channelStore.getRecord(channelId);
|
||||||
|
if (rec.discussion?.concluded && moderatorBotToken) {
|
||||||
|
await sendModeratorMessage(
|
||||||
|
moderatorBotToken,
|
||||||
|
channelId,
|
||||||
|
"This discussion is closed and no longer active.",
|
||||||
|
api.logger,
|
||||||
|
).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify sender — is it the current speaker?
|
||||||
|
const senderEntry = identityRegistry.findByDiscordUserId(senderId);
|
||||||
|
const currentSpeakerIsThisSender = senderEntry
|
||||||
|
? isCurrentSpeaker(channelId, senderEntry.agentId)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!currentSpeakerIsThisSender) {
|
||||||
|
// Non-current-speaker: interrupt any ongoing tail-match poll
|
||||||
|
interruptTailMatch(channelId);
|
||||||
|
api.logger.info(`dirigent: moderator-callback interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
||||||
|
|
||||||
|
// Wake from dormant if needed
|
||||||
|
if (isDormant(channelId) && moderatorBotToken) {
|
||||||
|
const first = wakeFromDormant(channelId);
|
||||||
|
if (first) {
|
||||||
|
const msg = `<@${first.discordUserId}>${config.scheduleIdentifier}`;
|
||||||
|
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, config.debugMode);
|
||||||
|
api.logger.info(`dirigent: moderator-callback woke dormant channel=${channelId} first=${first.agentId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tools ──────────────────────────────────────────────────────────────
|
// ── Tools ──────────────────────────────────────────────────────────────
|
||||||
@@ -162,9 +242,9 @@ export default {
|
|||||||
api,
|
api,
|
||||||
channelStore,
|
channelStore,
|
||||||
identityRegistry,
|
identityRegistry,
|
||||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
moderatorBotToken,
|
||||||
scheduleIdentifier: config.scheduleIdentifier,
|
scheduleIdentifier: config.scheduleIdentifier,
|
||||||
onDiscussionCreate: async ({ channelId, guildId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
|
onDiscussionCreate: async ({ channelId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
|
||||||
const live = normalizeConfig(api);
|
const live = normalizeConfig(api);
|
||||||
if (!live.moderatorBotToken) return;
|
if (!live.moderatorBotToken) return;
|
||||||
|
|
||||||
@@ -184,11 +264,12 @@ export default {
|
|||||||
if (speakers.length > 0) {
|
if (speakers.length > 0) {
|
||||||
setSpeakerList(channelId, speakers);
|
setSpeakerList(channelId, speakers);
|
||||||
const first = speakers[0];
|
const first = speakers[0];
|
||||||
await sendAndDelete(
|
await sendScheduleTrigger(
|
||||||
live.moderatorBotToken,
|
live.moderatorBotToken,
|
||||||
channelId,
|
channelId,
|
||||||
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
||||||
api.logger,
|
api.logger,
|
||||||
|
live.debugMode,
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -203,7 +284,7 @@ export default {
|
|||||||
api,
|
api,
|
||||||
channelStore,
|
channelStore,
|
||||||
identityRegistry,
|
identityRegistry,
|
||||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
moderatorBotToken,
|
||||||
openclawDir,
|
openclawDir,
|
||||||
hasPaddedCell,
|
hasPaddedCell,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal Discord Gateway connection to keep the moderator bot "online".
|
|
||||||
* Uses Node.js built-in WebSocket (Node 22+).
|
|
||||||
*
|
|
||||||
* IMPORTANT: Only ONE instance should exist per bot token.
|
|
||||||
* Uses a singleton guard to prevent multiple connections.
|
|
||||||
*/
|
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let heartbeatAcked = true;
|
|
||||||
let lastSequence: number | null = null;
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
let resumeUrl: string | null = null;
|
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let destroyed = false;
|
|
||||||
let started = false; // singleton guard
|
|
||||||
|
|
||||||
type Logger = {
|
|
||||||
info: (msg: string) => void;
|
|
||||||
warn: (msg: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
||||||
const MAX_RECONNECT_DELAY_MS = 60_000;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
|
|
||||||
function sendPayload(data: Record<string, unknown>) {
|
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHeartbeat(intervalMs: number) {
|
|
||||||
stopHeartbeat();
|
|
||||||
heartbeatAcked = true;
|
|
||||||
|
|
||||||
// First heartbeat after jitter
|
|
||||||
const jitter = Math.floor(Math.random() * intervalMs);
|
|
||||||
const firstTimer = setTimeout(() => {
|
|
||||||
if (destroyed) return;
|
|
||||||
if (!heartbeatAcked) {
|
|
||||||
// Missed ACK — zombie connection, close and reconnect
|
|
||||||
ws?.close(4000, "missed heartbeat ack");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
heartbeatAcked = false;
|
|
||||||
sendPayload({ op: 1, d: lastSequence });
|
|
||||||
|
|
||||||
heartbeatInterval = setInterval(() => {
|
|
||||||
if (destroyed) return;
|
|
||||||
if (!heartbeatAcked) {
|
|
||||||
ws?.close(4000, "missed heartbeat ack");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
heartbeatAcked = false;
|
|
||||||
sendPayload({ op: 1, d: lastSequence });
|
|
||||||
}, intervalMs);
|
|
||||||
}, jitter);
|
|
||||||
|
|
||||||
// Store the first timer so we can clear it
|
|
||||||
heartbeatInterval = firstTimer as unknown as ReturnType<typeof setInterval>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHeartbeat() {
|
|
||||||
if (heartbeatInterval) {
|
|
||||||
clearInterval(heartbeatInterval);
|
|
||||||
clearTimeout(heartbeatInterval as unknown as ReturnType<typeof setTimeout>);
|
|
||||||
heartbeatInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
stopHeartbeat();
|
|
||||||
if (ws) {
|
|
||||||
// Remove all handlers to avoid ghost callbacks
|
|
||||||
ws.onopen = null;
|
|
||||||
ws.onmessage = null;
|
|
||||||
ws.onclose = null;
|
|
||||||
ws.onerror = null;
|
|
||||||
try { ws.close(1000); } catch { /* ignore */ }
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect(token: string, logger: Logger, isResume = false) {
|
|
||||||
if (destroyed) return;
|
|
||||||
|
|
||||||
// Clean up any existing connection first
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(url);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`);
|
|
||||||
scheduleReconnect(token, logger, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWs = ws; // capture for closure
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
if (currentWs !== ws || destroyed) return; // stale
|
|
||||||
|
|
||||||
reconnectAttempts = 0; // reset on successful open
|
|
||||||
|
|
||||||
if (isResume && sessionId) {
|
|
||||||
sendPayload({
|
|
||||||
op: 6,
|
|
||||||
d: { token, session_id: sessionId, seq: lastSequence },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sendPayload({
|
|
||||||
op: 2,
|
|
||||||
d: {
|
|
||||||
token,
|
|
||||||
intents: 0,
|
|
||||||
properties: {
|
|
||||||
os: "linux",
|
|
||||||
browser: "dirigent",
|
|
||||||
device: "dirigent",
|
|
||||||
},
|
|
||||||
presence: {
|
|
||||||
status: "online",
|
|
||||||
activities: [{ name: "Moderating", type: 3 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (evt: MessageEvent) => {
|
|
||||||
if (currentWs !== ws || destroyed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
|
|
||||||
const { op, t, s, d } = msg;
|
|
||||||
|
|
||||||
if (s != null) lastSequence = s;
|
|
||||||
|
|
||||||
switch (op) {
|
|
||||||
case 10: // Hello
|
|
||||||
startHeartbeat(d.heartbeat_interval);
|
|
||||||
break;
|
|
||||||
case 11: // Heartbeat ACK
|
|
||||||
heartbeatAcked = true;
|
|
||||||
break;
|
|
||||||
case 1: // Heartbeat request
|
|
||||||
sendPayload({ op: 1, d: lastSequence });
|
|
||||||
break;
|
|
||||||
case 0: // Dispatch
|
|
||||||
if (t === "READY") {
|
|
||||||
sessionId = d.session_id;
|
|
||||||
resumeUrl = d.resume_gateway_url;
|
|
||||||
logger.info("dirigent: moderator bot connected and online");
|
|
||||||
}
|
|
||||||
if (t === "RESUMED") {
|
|
||||||
logger.info("dirigent: moderator bot resumed");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 7: // Reconnect request
|
|
||||||
logger.info("dirigent: moderator bot reconnect requested by Discord");
|
|
||||||
cleanup();
|
|
||||||
scheduleReconnect(token, logger, true);
|
|
||||||
break;
|
|
||||||
case 9: // Invalid Session
|
|
||||||
logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`);
|
|
||||||
cleanup();
|
|
||||||
sessionId = d ? sessionId : null;
|
|
||||||
// Wait longer before re-identifying
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!destroyed) connect(token, logger, !!d && !!sessionId);
|
|
||||||
}, 3000 + Math.random() * 2000);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (evt: CloseEvent) => {
|
|
||||||
if (currentWs !== ws) return; // stale ws
|
|
||||||
stopHeartbeat();
|
|
||||||
if (destroyed) return;
|
|
||||||
|
|
||||||
const code = evt.code;
|
|
||||||
|
|
||||||
// Non-recoverable codes — stop reconnecting
|
|
||||||
if (code === 4004) {
|
|
||||||
logger.warn("dirigent: moderator bot token invalid (4004), stopping");
|
|
||||||
started = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
|
|
||||||
logger.warn(`dirigent: moderator bot fatal close (${code}), re-identifying`);
|
|
||||||
sessionId = null;
|
|
||||||
scheduleReconnect(token, logger, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`dirigent: moderator bot disconnected (code=${code}), will reconnect`);
|
|
||||||
const canResume = !!sessionId && code !== 4012;
|
|
||||||
scheduleReconnect(token, logger, canResume);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
// onclose will fire after this
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
|
|
||||||
if (destroyed) return;
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
||||||
|
|
||||||
// Exponential backoff with cap
|
|
||||||
reconnectAttempts++;
|
|
||||||
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
|
|
||||||
const jitter = Math.random() * 1000;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
|
|
||||||
logger.info(`dirigent: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
|
|
||||||
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connect(token, logger, resume);
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the moderator bot's Discord Gateway connection.
|
|
||||||
* Singleton: calling multiple times with the same token is safe (no-op).
|
|
||||||
*/
|
|
||||||
export function startModeratorPresence(token: string, logger: Logger): void {
|
|
||||||
if (started) {
|
|
||||||
logger.info("dirigent: moderator presence already started, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
started = true;
|
|
||||||
destroyed = false;
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
connect(token, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect the moderator bot.
|
|
||||||
*/
|
|
||||||
export function stopModeratorPresence(): void {
|
|
||||||
destroyed = true;
|
|
||||||
started = false;
|
|
||||||
if (reconnectTimer) {
|
|
||||||
clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
@@ -8,28 +8,15 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": true },
|
|
||||||
"discordOnly": { "type": "boolean", "default": true },
|
|
||||||
"listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" },
|
|
||||||
"humanList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
||||||
"agentList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
||||||
"channelPoliciesFile": { "type": "string", "default": "~/.openclaw/dirigent-channel-policies.json" },
|
|
||||||
"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 },
|
|
||||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
|
||||||
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
||||||
"moderatorBotToken": { "type": "string" },
|
"moderatorBotToken": { "type": "string" },
|
||||||
"multiMessageStartMarker": { "type": "string", "default": "↗️" },
|
"scheduleIdentifier": { "type": "string", "default": "➡️" },
|
||||||
"multiMessageEndMarker": { "type": "string", "default": "↙️" },
|
"identityFilePath": { "type": "string" },
|
||||||
"multiMessagePromptMarker": { "type": "string", "default": "⤵️" }
|
"channelStoreFilePath": { "type": "string" },
|
||||||
|
"debugMode": { "type": "boolean", "default": false },
|
||||||
|
"noReplyProvider": { "type": "string", "default": "dirigent" },
|
||||||
|
"noReplyModel": { "type": "string", "default": "no-reply" },
|
||||||
|
"sideCarPort": { "type": "number", "default": 8787 }
|
||||||
},
|
},
|
||||||
"required": ["noReplyProvider", "noReplyModel"]
|
"required": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,23 @@ function parseDiscordChannelIdFromSession(sessionKey: string): string | undefine
|
|||||||
return m?.[1];
|
return m?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textResult(text: string) {
|
||||||
|
return { content: [{ type: "text" as const, text }], details: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResult(text: string) {
|
||||||
|
return { content: [{ type: "text" as const, text }], details: { error: true } };
|
||||||
|
}
|
||||||
|
|
||||||
export function registerDirigentTools(deps: ToolDeps): void {
|
export function registerDirigentTools(deps: ToolDeps): void {
|
||||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
|
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
// dirigent-register
|
// dirigent-register
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool((ctx) => ({
|
||||||
name: "dirigent-register",
|
name: "dirigent-register",
|
||||||
|
label: "Dirigent Register",
|
||||||
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
|
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -51,18 +60,18 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["discordUserId"],
|
required: ["discordUserId"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const agentId = ctx?.agentId;
|
const agentId = ctx?.agentId;
|
||||||
if (!agentId) return { content: [{ type: "text", text: "Cannot resolve agentId from session context" }], isError: true };
|
if (!agentId) return errorResult("Cannot resolve agentId from session context");
|
||||||
const p = params as { discordUserId: string; agentName?: string };
|
const p = params as { discordUserId: string; agentName?: string };
|
||||||
identityRegistry.upsert({
|
identityRegistry.upsert({
|
||||||
agentId,
|
agentId,
|
||||||
discordUserId: p.discordUserId,
|
discordUserId: p.discordUserId,
|
||||||
agentName: p.agentName ?? agentId,
|
agentName: p.agentName ?? agentId,
|
||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: `Registered: agentId=${agentId} discordUserId=${p.discordUserId}` }] };
|
return textResult(`Registered: agentId=${agentId} discordUserId=${p.discordUserId}`);
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
// Helper: create channel + set mode
|
// Helper: create channel + set mode
|
||||||
@@ -72,7 +81,6 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
name: string;
|
name: string;
|
||||||
memberDiscordIds: string[];
|
memberDiscordIds: string[];
|
||||||
mode: "chat" | "report" | "work";
|
mode: "chat" | "report" | "work";
|
||||||
callerCtx: { agentId?: string };
|
|
||||||
}): Promise<{ ok: boolean; channelId?: string; error?: string }> {
|
}): Promise<{ ok: boolean; channelId?: string; error?: string }> {
|
||||||
if (!moderatorBotToken) return { ok: false, error: "moderatorBotToken not configured" };
|
if (!moderatorBotToken) return { ok: false, error: "moderatorBotToken not configured" };
|
||||||
|
|
||||||
@@ -112,6 +120,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool({
|
||||||
name: "create-chat-channel",
|
name: "create-chat-channel",
|
||||||
|
label: "Create Chat Channel",
|
||||||
description: "Create a new private Discord channel in the specified guild with mode=chat.",
|
description: "Create a new private Discord channel in the specified guild with mode=chat.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -126,16 +135,15 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["guildId", "name"],
|
required: ["guildId", "name"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const p = params as { guildId: string; name: string; participants?: string[] };
|
const p = params as { guildId: string; name: string; participants?: string[] };
|
||||||
const result = await createManagedChannel({
|
const result = await createManagedChannel({
|
||||||
guildId: p.guildId, name: p.name,
|
guildId: p.guildId, name: p.name,
|
||||||
memberDiscordIds: p.participants ?? [],
|
memberDiscordIds: p.participants ?? [],
|
||||||
mode: "chat",
|
mode: "chat",
|
||||||
callerCtx: { agentId: ctx?.agentId },
|
|
||||||
});
|
});
|
||||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
if (!result.ok) return errorResult(`Failed: ${result.error}`);
|
||||||
return { content: [{ type: "text", text: `Created chat channel: ${result.channelId}` }] };
|
return textResult(`Created chat channel: ${result.channelId}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +152,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool({
|
||||||
name: "create-report-channel",
|
name: "create-report-channel",
|
||||||
|
label: "Create Report Channel",
|
||||||
description: "Create a new private Discord channel with mode=report. Agents can post to it but are not woken by messages.",
|
description: "Create a new private Discord channel with mode=report. Agents can post to it but are not woken by messages.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -155,24 +164,24 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["guildId", "name"],
|
required: ["guildId", "name"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const p = params as { guildId: string; name: string; members?: string[] };
|
const p = params as { guildId: string; name: string; members?: string[] };
|
||||||
const result = await createManagedChannel({
|
const result = await createManagedChannel({
|
||||||
guildId: p.guildId, name: p.name,
|
guildId: p.guildId, name: p.name,
|
||||||
memberDiscordIds: p.members ?? [],
|
memberDiscordIds: p.members ?? [],
|
||||||
mode: "report",
|
mode: "report",
|
||||||
callerCtx: { agentId: ctx?.agentId },
|
|
||||||
});
|
});
|
||||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
if (!result.ok) return errorResult(`Failed: ${result.error}`);
|
||||||
return { content: [{ type: "text", text: `Created report channel: ${result.channelId}` }] };
|
return textResult(`Created report channel: ${result.channelId}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
// create-work-channel
|
// create-work-channel
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool((ctx) => ({
|
||||||
name: "create-work-channel",
|
name: "create-work-channel",
|
||||||
|
label: "Create Work Channel",
|
||||||
description: "Create a new private Discord workspace channel with mode=work (turn-manager disabled, mode locked).",
|
description: "Create a new private Discord workspace channel with mode=work (turn-manager disabled, mode locked).",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -184,7 +193,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["guildId", "name"],
|
required: ["guildId", "name"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const p = params as { guildId: string; name: string; members?: string[] };
|
const p = params as { guildId: string; name: string; members?: string[] };
|
||||||
// Include calling agent's Discord ID if known
|
// Include calling agent's Discord ID if known
|
||||||
const callerDiscordId = ctx?.agentId ? identityRegistry.findByAgentId(ctx.agentId)?.discordUserId : undefined;
|
const callerDiscordId = ctx?.agentId ? identityRegistry.findByAgentId(ctx.agentId)?.discordUserId : undefined;
|
||||||
@@ -195,18 +204,18 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
guildId: p.guildId, name: p.name,
|
guildId: p.guildId, name: p.name,
|
||||||
memberDiscordIds: members,
|
memberDiscordIds: members,
|
||||||
mode: "work",
|
mode: "work",
|
||||||
callerCtx: { agentId: ctx?.agentId },
|
|
||||||
});
|
});
|
||||||
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
if (!result.ok) return errorResult(`Failed: ${result.error}`);
|
||||||
return { content: [{ type: "text", text: `Created work channel: ${result.channelId}` }] };
|
return textResult(`Created work channel: ${result.channelId}`);
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
// create-discussion-channel
|
// create-discussion-channel
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool((ctx) => ({
|
||||||
name: "create-discussion-channel",
|
name: "create-discussion-channel",
|
||||||
|
label: "Create Discussion Channel",
|
||||||
description: "Create a structured discussion channel between agents. The calling agent becomes the initiator.",
|
description: "Create a structured discussion channel between agents. The calling agent becomes the initiator.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -220,7 +229,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["callbackGuildId", "callbackChannelId", "name", "discussionGuide", "participants"],
|
required: ["callbackGuildId", "callbackChannelId", "name", "discussionGuide", "participants"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const p = params as {
|
const p = params as {
|
||||||
callbackGuildId: string;
|
callbackGuildId: string;
|
||||||
callbackChannelId: string;
|
callbackChannelId: string;
|
||||||
@@ -230,13 +239,13 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
};
|
};
|
||||||
const initiatorAgentId = ctx?.agentId;
|
const initiatorAgentId = ctx?.agentId;
|
||||||
if (!initiatorAgentId) {
|
if (!initiatorAgentId) {
|
||||||
return { content: [{ type: "text", text: "Cannot resolve initiator agentId from session" }], isError: true };
|
return errorResult("Cannot resolve initiator agentId from session");
|
||||||
}
|
}
|
||||||
if (!moderatorBotToken) {
|
if (!moderatorBotToken) {
|
||||||
return { content: [{ type: "text", text: "moderatorBotToken not configured" }], isError: true };
|
return errorResult("moderatorBotToken not configured");
|
||||||
}
|
}
|
||||||
if (!onDiscussionCreate) {
|
if (!onDiscussionCreate) {
|
||||||
return { content: [{ type: "text", text: "Discussion service not available" }], isError: true };
|
return errorResult("Discussion service not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const botId = getBotUserIdFromToken(moderatorBotToken);
|
const botId = getBotUserIdFromToken(moderatorBotToken);
|
||||||
@@ -262,7 +271,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
logger: api.logger,
|
logger: api.logger,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { content: [{ type: "text", text: `Failed to create channel: ${String(err)}` }], isError: true };
|
return errorResult(`Failed to create channel: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -273,7 +282,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
concluded: false,
|
concluded: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { content: [{ type: "text", text: `Failed to register channel: ${String(err)}` }], isError: true };
|
return errorResult(`Failed to register channel: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onDiscussionCreate({
|
await onDiscussionCreate({
|
||||||
@@ -286,15 +295,16 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
participants: p.participants,
|
participants: p.participants,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { content: [{ type: "text", text: `Discussion channel created: ${channelId}` }] };
|
return textResult(`Discussion channel created: ${channelId}`);
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
// discussion-complete
|
// discussion-complete
|
||||||
// ───────────────────────────────────────────────
|
// ───────────────────────────────────────────────
|
||||||
api.registerTool({
|
api.registerTool((ctx) => ({
|
||||||
name: "discussion-complete",
|
name: "discussion-complete",
|
||||||
|
label: "Discussion Complete",
|
||||||
description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.",
|
description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -305,31 +315,25 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
},
|
},
|
||||||
required: ["discussionChannelId", "summary"],
|
required: ["discussionChannelId", "summary"],
|
||||||
},
|
},
|
||||||
handler: async (params, ctx) => {
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
const p = params as { discussionChannelId: string; summary: string };
|
const p = params as { discussionChannelId: string; summary: string };
|
||||||
const callerAgentId = ctx?.agentId;
|
const callerAgentId = ctx?.agentId;
|
||||||
if (!callerAgentId) {
|
if (!callerAgentId) {
|
||||||
return { content: [{ type: "text", text: "Cannot resolve agentId from session" }], isError: true };
|
return errorResult("Cannot resolve agentId from session");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rec = channelStore.getRecord(p.discussionChannelId);
|
const rec = channelStore.getRecord(p.discussionChannelId);
|
||||||
if (rec.mode !== "discussion") {
|
if (rec.mode !== "discussion") {
|
||||||
return { content: [{ type: "text", text: `Channel ${p.discussionChannelId} is not a discussion channel` }], isError: true };
|
return errorResult(`Channel ${p.discussionChannelId} is not a discussion channel`);
|
||||||
}
|
}
|
||||||
if (!rec.discussion) {
|
if (!rec.discussion) {
|
||||||
return { content: [{ type: "text", text: "Discussion metadata not found" }], isError: true };
|
return errorResult("Discussion metadata not found");
|
||||||
}
|
}
|
||||||
if (rec.discussion.initiatorAgentId !== callerAgentId) {
|
if (rec.discussion.initiatorAgentId !== callerAgentId) {
|
||||||
return {
|
return errorResult(`Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete`);
|
||||||
content: [{ type: "text", text: `Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!p.summary.includes("discussion-summary")) {
|
if (!p.summary.includes("discussion-summary")) {
|
||||||
return {
|
return errorResult("Summary path must be under {workspace}/discussion-summary/");
|
||||||
content: [{ type: "text", text: "Summary path must be under {workspace}/discussion-summary/" }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
channelStore.concludeDiscussion(p.discussionChannelId);
|
channelStore.concludeDiscussion(p.discussionChannelId);
|
||||||
@@ -343,7 +347,7 @@ export function registerDirigentTools(deps: ToolDeps): void {
|
|||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: [{ type: "text", text: `Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.` }] };
|
return textResult(`Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.`);
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
112
plugin/web/dirigent-api.ts
Normal file
112
plugin/web/dirigent-api.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import type { ChannelStore } from "../core/channel-store.js";
|
||||||
|
|
||||||
|
type Deps = {
|
||||||
|
api: OpenClawPluginApi;
|
||||||
|
channelStore: ChannelStore;
|
||||||
|
moderatorBotUserId: string | undefined;
|
||||||
|
scheduleIdentifier: string;
|
||||||
|
moderatorServiceUrl: string | undefined;
|
||||||
|
moderatorServiceToken: string | undefined;
|
||||||
|
debugMode: boolean;
|
||||||
|
onNewMessage: (event: {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
senderId: string;
|
||||||
|
guildId?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendJson(res: import("node:http").ServerResponse, status: number, payload: unknown): void {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: import("node:http").IncomingMessage): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
if (body.length > 1_000_000) {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("body too large"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(body ? (JSON.parse(body) as Record<string, unknown>) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error("invalid_json"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Dirigent plugin HTTP routes that the moderator service calls back into.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* POST /dirigent/api/moderator/message — inbound message notification from moderator service
|
||||||
|
* GET /dirigent/api/moderator/status — health/status check
|
||||||
|
*/
|
||||||
|
export function registerDirigentApi(deps: Deps): void {
|
||||||
|
const { api, moderatorServiceUrl, onNewMessage } = deps;
|
||||||
|
|
||||||
|
// ── POST /dirigent/api/moderator/message ─────────────────────────────────────
|
||||||
|
// Called by the moderator service on every Discord MESSAGE_CREATE event.
|
||||||
|
api.registerHttpRoute({
|
||||||
|
path: "/dirigent/api/moderator/message",
|
||||||
|
auth: "plugin",
|
||||||
|
match: "exact",
|
||||||
|
handler: async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = typeof body.channelId === "string" ? body.channelId : undefined;
|
||||||
|
const messageId = typeof body.messageId === "string" ? body.messageId : undefined;
|
||||||
|
const senderId = typeof body.senderId === "string" ? body.senderId : undefined;
|
||||||
|
const guildId = typeof body.guildId === "string" ? body.guildId : undefined;
|
||||||
|
|
||||||
|
if (!channelId || !senderId) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: "channelId and senderId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onNewMessage({
|
||||||
|
channelId,
|
||||||
|
messageId: messageId ?? "",
|
||||||
|
senderId,
|
||||||
|
guildId,
|
||||||
|
});
|
||||||
|
return sendJson(res, 200, { ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`dirigent: moderator/message handler error: ${String(err)}`);
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /dirigent/api/moderator/status ───────────────────────────────────────
|
||||||
|
api.registerHttpRoute({
|
||||||
|
path: "/dirigent/api/moderator/status",
|
||||||
|
auth: "plugin",
|
||||||
|
match: "exact",
|
||||||
|
handler: (_req, res) => {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
moderatorServiceUrl: moderatorServiceUrl ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), '..');
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
const pluginDir = path.join(root, 'plugin');
|
|
||||||
const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json'];
|
const checks = [
|
||||||
|
// Core plugin files
|
||||||
|
path.join(root, 'plugin', 'index.ts'),
|
||||||
|
path.join(root, 'plugin', 'turn-manager.ts'),
|
||||||
|
path.join(root, 'plugin', 'openclaw.plugin.json'),
|
||||||
|
path.join(root, 'plugin', 'package.json'),
|
||||||
|
// Sidecar
|
||||||
|
path.join(root, 'services', 'main.mjs'),
|
||||||
|
path.join(root, 'services', 'no-reply-api', 'server.mjs'),
|
||||||
|
path.join(root, 'services', 'moderator', 'index.mjs'),
|
||||||
|
];
|
||||||
|
|
||||||
let ok = true;
|
let ok = true;
|
||||||
for (const f of required) {
|
for (const p of checks) {
|
||||||
const p = path.join(pluginDir, f);
|
|
||||||
if (!fs.existsSync(p)) {
|
if (!fs.existsSync(p)) {
|
||||||
ok = false;
|
ok = false;
|
||||||
console.error(`missing: ${p}`);
|
console.error(`missing: ${p}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
|
const manifestPath = path.join(root, 'plugin', 'openclaw.plugin.json');
|
||||||
if (fs.existsSync(manifestPath)) {
|
if (fs.existsSync(manifestPath)) {
|
||||||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
for (const k of ['id', 'entry', 'configSchema']) {
|
for (const k of ['id', 'entry', 'configSchema']) {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
docker compose down
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
echo "[dirigent] building/starting no-reply API container"
|
|
||||||
docker compose up -d --build dirigent-no-reply-api
|
|
||||||
|
|
||||||
echo "[dirigent] health check"
|
|
||||||
curl -sS http://127.0.0.1:8787/health
|
|
||||||
|
|
||||||
echo "[dirigent] done"
|
|
||||||
@@ -120,7 +120,7 @@ const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
|
|||||||
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
|
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
|
||||||
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
|
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_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_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
|
||||||
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
|
||||||
|
|
||||||
function runOpenclaw(args, allowFail = false) {
|
function runOpenclaw(args, allowFail = false) {
|
||||||
@@ -143,10 +143,11 @@ if (mode === "install") {
|
|||||||
|
|
||||||
step(1, 7, "build dist assets");
|
step(1, 7, "build dist assets");
|
||||||
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
|
||||||
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
|
const sidecarSrc = path.resolve(REPO_ROOT, "services");
|
||||||
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
|
||||||
|
fs.rmSync(distPlugin, { recursive: true, force: true });
|
||||||
syncDirRecursive(pluginSrc, distPlugin);
|
syncDirRecursive(pluginSrc, distPlugin);
|
||||||
syncDirRecursive(noReplySrc, path.join(distPlugin, "no-reply-api"));
|
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
|
||||||
ok("dist assets built");
|
ok("dist assets built");
|
||||||
|
|
||||||
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
|
||||||
@@ -187,17 +188,10 @@ if (mode === "install") {
|
|||||||
}
|
}
|
||||||
setIfMissing("plugins.entries.dirigent.enabled", true);
|
setIfMissing("plugins.entries.dirigent.enabled", true);
|
||||||
const cp = "plugins.entries.dirigent.config";
|
const cp = "plugins.entries.dirigent.config";
|
||||||
setIfMissing(`${cp}.enabled`, true);
|
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
|
||||||
setIfMissing(`${cp}.discordOnly`, true);
|
|
||||||
setIfMissing(`${cp}.listMode`, "human-list");
|
|
||||||
setIfMissing(`${cp}.humanList`, []);
|
|
||||||
setIfMissing(`${cp}.agentList`, []);
|
|
||||||
setIfMissing(`${cp}.channelPoliciesFile`, path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
|
|
||||||
setIfMissing(`${cp}.endSymbols`, ["🔚"]);
|
|
||||||
setIfMissing(`${cp}.schedulingIdentifier`, "➡️");
|
|
||||||
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
|
||||||
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
|
||||||
setIfMissing(`${cp}.noReplyPort`, NO_REPLY_PORT);
|
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
|
||||||
// moderatorBotToken: intentionally not touched — set manually via:
|
// moderatorBotToken: intentionally not touched — set manually via:
|
||||||
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
|
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
|
||||||
ok("plugin configured");
|
ok("plugin configured");
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const pluginDir = path.join(root, "plugin");
|
|
||||||
const outDir = path.join(root, "dist", "dirigent");
|
|
||||||
|
|
||||||
fs.rmSync(outDir, { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
|
||||||
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`packaged plugin to ${outDir}`);
|
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
|
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
|
||||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
# The sidecar must already be running (it starts automatically with openclaw-gateway).
|
||||||
|
# Default base URL matches the sidecar's no-reply prefix.
|
||||||
AUTH_HEADER=()
|
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
|
||||||
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
||||||
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[1] health"
|
echo "[1] health"
|
||||||
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
|
curl -fsS "${BASE_URL}/health"
|
||||||
|
echo ""
|
||||||
|
|
||||||
echo "[2] models"
|
echo "[2] models"
|
||||||
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
|
curl -fsS "${BASE_URL}/v1/models" | head -c 200
|
||||||
|
echo ""
|
||||||
|
|
||||||
echo "[3] chat/completions"
|
echo "[3] chat/completions"
|
||||||
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
|
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
"${AUTH_HEADER[@]}" \
|
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
|
||||||
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
|
| head -c 300
|
||||||
| sed -n '1,20p'
|
echo ""
|
||||||
|
|
||||||
echo "[4] responses"
|
echo "[4] responses"
|
||||||
curl -sS -X POST "${BASE_URL}/v1/responses" \
|
curl -fsS -X POST "${BASE_URL}/v1/responses" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
"${AUTH_HEADER[@]}" \
|
-d '{"model":"no-reply","input":"hello"}' \
|
||||||
-d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
|
| head -c 300
|
||||||
| sed -n '1,20p'
|
echo ""
|
||||||
|
|
||||||
echo "smoke ok"
|
echo "smoke ok"
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
|
|
||||||
const BASE = "http://127.0.0.1:18787";
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHealth(retries = 30) {
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${BASE}/health`);
|
|
||||||
if (r.ok) return true;
|
|
||||||
} catch {}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(cond, msg) {
|
|
||||||
if (!cond) throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const token = "test-token";
|
|
||||||
const child = spawn("node", ["no-reply-api/server.mjs"], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" },
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout.on("data", () => {});
|
|
||||||
child.stderr.on("data", () => {});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ok = await waitForHealth();
|
|
||||||
assert(ok, "health check failed");
|
|
||||||
|
|
||||||
const unauth = await fetch(`${BASE}/v1/models`);
|
|
||||||
assert(unauth.status === 401, `expected 401, got ${unauth.status}`);
|
|
||||||
|
|
||||||
const models = await fetch(`${BASE}/v1/models`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
assert(models.ok, "authorized /v1/models failed");
|
|
||||||
const modelsJson = await models.json();
|
|
||||||
assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch");
|
|
||||||
|
|
||||||
const cc = await fetch(`${BASE}/v1/chat/completions`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }),
|
|
||||||
});
|
|
||||||
assert(cc.ok, "chat completions failed");
|
|
||||||
const ccJson = await cc.json();
|
|
||||||
assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY");
|
|
||||||
|
|
||||||
const rsp = await fetch(`${BASE}/v1/responses`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ model: "wg-test-model", input: "hi" }),
|
|
||||||
});
|
|
||||||
assert(rsp.ok, "responses failed");
|
|
||||||
const rspJson = await rsp.json();
|
|
||||||
assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY");
|
|
||||||
|
|
||||||
console.log("test-no-reply-api: ok");
|
|
||||||
} finally {
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((err) => {
|
|
||||||
console.error(`test-no-reply-api: fail: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
111
services/main.mjs
Normal file
111
services/main.mjs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Unified entry point for Dirigent services.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* /no-reply/* → no-reply API (strips /no-reply prefix)
|
||||||
|
* /moderator/* → moderator bot service (strips /moderator prefix)
|
||||||
|
* otherwise → 404
|
||||||
|
*
|
||||||
|
* Env vars:
|
||||||
|
* SERVICES_PORT (default 8787)
|
||||||
|
* MODERATOR_TOKEN Discord bot token (required for moderator)
|
||||||
|
* PLUGIN_API_URL (default http://127.0.0.1:18789)
|
||||||
|
* PLUGIN_API_TOKEN auth token for plugin API calls
|
||||||
|
* SCHEDULE_IDENTIFIER (default ➡️)
|
||||||
|
* DEBUG_MODE (default false)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import { createNoReplyHandler } from "./no-reply-api/server.mjs";
|
||||||
|
import { createModeratorService } from "./moderator/index.mjs";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.SERVICES_PORT || 8787);
|
||||||
|
const MODERATOR_TOKEN = process.env.MODERATOR_TOKEN || "";
|
||||||
|
const PLUGIN_API_URL = process.env.PLUGIN_API_URL || "http://127.0.0.1:18789";
|
||||||
|
const PLUGIN_API_TOKEN = process.env.PLUGIN_API_TOKEN || "";
|
||||||
|
const SCHEDULE_IDENTIFIER = process.env.SCHEDULE_IDENTIFIER || "➡️";
|
||||||
|
const DEBUG_MODE = process.env.DEBUG_MODE === "true" || process.env.DEBUG_MODE === "1";
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialize services ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const noReplyHandler = createNoReplyHandler();
|
||||||
|
|
||||||
|
let moderatorService = null;
|
||||||
|
if (MODERATOR_TOKEN) {
|
||||||
|
console.log("[dirigent-services] moderator bot enabled");
|
||||||
|
moderatorService = createModeratorService({
|
||||||
|
token: MODERATOR_TOKEN,
|
||||||
|
pluginApiUrl: PLUGIN_API_URL,
|
||||||
|
pluginApiToken: PLUGIN_API_TOKEN,
|
||||||
|
scheduleIdentifier: SCHEDULE_IDENTIFIER,
|
||||||
|
debugMode: DEBUG_MODE,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("[dirigent-services] MODERATOR_TOKEN not set — moderator disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP server ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
|
if (url === "/health") {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
services: {
|
||||||
|
noReply: true,
|
||||||
|
moderator: !!moderatorService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/no-reply")) {
|
||||||
|
req.url = url.slice("/no-reply".length) || "/";
|
||||||
|
return noReplyHandler(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/moderator")) {
|
||||||
|
if (!moderatorService) {
|
||||||
|
return sendJson(res, 503, { error: "moderator service not configured" });
|
||||||
|
}
|
||||||
|
req.url = url.slice("/moderator".length) || "/";
|
||||||
|
return moderatorService.httpHandler(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[dirigent-services] listening on 127.0.0.1:${PORT}`);
|
||||||
|
console.log(`[dirigent-services] /no-reply → no-reply API`);
|
||||||
|
if (moderatorService) {
|
||||||
|
console.log(`[dirigent-services] /moderator → moderator bot`);
|
||||||
|
console.log(`[dirigent-services] plugin API: ${PLUGIN_API_URL}`);
|
||||||
|
}
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log(`[dirigent-services] debug mode ON`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Graceful shutdown ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shutdown(signal) {
|
||||||
|
console.log(`[dirigent-services] received ${signal}, shutting down`);
|
||||||
|
if (moderatorService) {
|
||||||
|
moderatorService.stop();
|
||||||
|
}
|
||||||
|
server.close(() => {
|
||||||
|
console.log("[dirigent-services] server closed");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// Force-exit after 5s
|
||||||
|
setTimeout(() => process.exit(1), 5000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
514
services/moderator/index.mjs
Normal file
514
services/moderator/index.mjs
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* Moderator bot service.
|
||||||
|
*
|
||||||
|
* Exports createModeratorService(config) returning { httpHandler(req, res), stop() }.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Discord Gateway WS with intents GUILD_MESSAGES (512) | MESSAGE_CONTENT (32768)
|
||||||
|
* - On MESSAGE_CREATE dispatch: notify plugin API
|
||||||
|
* - HTTP sub-handler for /health, /me, /send, /delete-message, /create-channel, /guilds, /channels/:guildId
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { URL as NodeURL } from "node:url";
|
||||||
|
|
||||||
|
const DISCORD_API = "https://discord.com/api/v10";
|
||||||
|
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 60_000;
|
||||||
|
const INTENTS = 512 | 32768; // GUILD_MESSAGES | MESSAGE_CONTENT
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 1_000_000) {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("body too large"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(body ? JSON.parse(body) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error("invalid_json"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBotUserIdFromToken(token) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discord REST helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function discordGet(token, path) {
|
||||||
|
const r = await fetch(`${DISCORD_API}${path}`, {
|
||||||
|
headers: { Authorization: `Bot ${token}` },
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text().catch(() => "");
|
||||||
|
throw new Error(`Discord GET ${path} failed (${r.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discordPost(token, path, body) {
|
||||||
|
const r = await fetch(`${DISCORD_API}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { ok: r.ok, status: r.status, data: await r.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discordDelete(token, path) {
|
||||||
|
const r = await fetch(`${DISCORD_API}${path}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bot ${token}` },
|
||||||
|
});
|
||||||
|
return { ok: r.ok, status: r.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gateway connection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createGatewayConnection(token, onMessage, log) {
|
||||||
|
let ws = null;
|
||||||
|
let heartbeatTimer = null;
|
||||||
|
let heartbeatAcked = true;
|
||||||
|
let lastSequence = null;
|
||||||
|
let sessionId = null;
|
||||||
|
let resumeUrl = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
function sendPayload(data) {
|
||||||
|
if (ws?.readyState === 1 /* OPEN */) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
clearTimeout(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat(intervalMs) {
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeatAcked = true;
|
||||||
|
|
||||||
|
const jitter = Math.floor(Math.random() * intervalMs);
|
||||||
|
const firstTimer = setTimeout(() => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (!heartbeatAcked) {
|
||||||
|
ws?.close(4000, "missed heartbeat ack");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heartbeatAcked = false;
|
||||||
|
sendPayload({ op: 1, d: lastSequence });
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (!heartbeatAcked) {
|
||||||
|
ws?.close(4000, "missed heartbeat ack");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heartbeatAcked = false;
|
||||||
|
sendPayload({ op: 1, d: lastSequence });
|
||||||
|
}, intervalMs);
|
||||||
|
}, jitter);
|
||||||
|
|
||||||
|
heartbeatTimer = firstTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
stopHeartbeat();
|
||||||
|
if (ws) {
|
||||||
|
ws.onopen = null;
|
||||||
|
ws.onmessage = null;
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.onerror = null;
|
||||||
|
try { ws.close(1000); } catch { /* ignore */ }
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(resume) {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
|
||||||
|
reconnectAttempts++;
|
||||||
|
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
|
||||||
|
const delay = baseDelay + Math.random() * 1000;
|
||||||
|
|
||||||
|
log.info(`dirigent-moderator: reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect(resume);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(isResume = false) {
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`dirigent-moderator: ws constructor failed: ${String(err)}`);
|
||||||
|
scheduleReconnect(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWs = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (currentWs !== ws || destroyed) return;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
|
if (isResume && sessionId) {
|
||||||
|
sendPayload({
|
||||||
|
op: 6,
|
||||||
|
d: { token, session_id: sessionId, seq: lastSequence },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sendPayload({
|
||||||
|
op: 2,
|
||||||
|
d: {
|
||||||
|
token,
|
||||||
|
intents: INTENTS,
|
||||||
|
properties: {
|
||||||
|
os: "linux",
|
||||||
|
browser: "dirigent",
|
||||||
|
device: "dirigent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
if (currentWs !== ws || destroyed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
|
||||||
|
const { op, t, s, d } = msg;
|
||||||
|
|
||||||
|
if (s != null) lastSequence = s;
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 10: // Hello
|
||||||
|
startHeartbeat(d.heartbeat_interval);
|
||||||
|
break;
|
||||||
|
case 11: // Heartbeat ACK
|
||||||
|
heartbeatAcked = true;
|
||||||
|
break;
|
||||||
|
case 1: // Heartbeat request
|
||||||
|
sendPayload({ op: 1, d: lastSequence });
|
||||||
|
break;
|
||||||
|
case 0: // Dispatch
|
||||||
|
if (t === "READY") {
|
||||||
|
sessionId = d.session_id;
|
||||||
|
resumeUrl = d.resume_gateway_url;
|
||||||
|
log.info("dirigent-moderator: connected and ready");
|
||||||
|
} else if (t === "RESUMED") {
|
||||||
|
log.info("dirigent-moderator: session resumed");
|
||||||
|
} else if (t === "MESSAGE_CREATE") {
|
||||||
|
onMessage(d);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 7: // Reconnect
|
||||||
|
log.info("dirigent-moderator: reconnect requested by Discord");
|
||||||
|
cleanup();
|
||||||
|
scheduleReconnect(true);
|
||||||
|
break;
|
||||||
|
case 9: // Invalid Session
|
||||||
|
log.warn(`dirigent-moderator: invalid session, resumable=${d}`);
|
||||||
|
cleanup();
|
||||||
|
sessionId = d ? sessionId : null;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!destroyed) connect(!!d && !!sessionId);
|
||||||
|
}, 3000 + Math.random() * 2000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (evt) => {
|
||||||
|
if (currentWs !== ws) return;
|
||||||
|
stopHeartbeat();
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
const code = evt.code;
|
||||||
|
|
||||||
|
if (code === 4004) {
|
||||||
|
log.warn("dirigent-moderator: token invalid (4004), stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
|
||||||
|
log.warn(`dirigent-moderator: fatal close (${code}), re-identifying`);
|
||||||
|
sessionId = null;
|
||||||
|
scheduleReconnect(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`dirigent-moderator: disconnected (code=${code}), will reconnect`);
|
||||||
|
const canResume = !!sessionId && code !== 4012;
|
||||||
|
scheduleReconnect(canResume);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onclose will fire after this
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initial connection
|
||||||
|
connect(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
destroyed = true;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP route handler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createHttpHandler(token, botUserId, log) {
|
||||||
|
return async function httpHandler(req, res) {
|
||||||
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
if (req.method === "GET" && url === "/health") {
|
||||||
|
return sendJson(res, 200, { ok: true, botId: botUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /me
|
||||||
|
if (req.method === "GET" && url === "/me") {
|
||||||
|
try {
|
||||||
|
const data = await discordGet(token, "/users/@me");
|
||||||
|
return sendJson(res, 200, { id: data.id, username: data.username });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /guilds
|
||||||
|
if (req.method === "GET" && url === "/guilds") {
|
||||||
|
try {
|
||||||
|
const guilds = await discordGet(token, "/users/@me/guilds");
|
||||||
|
const ADMIN = 8n;
|
||||||
|
const adminGuilds = guilds
|
||||||
|
.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN)
|
||||||
|
.map((g) => ({ id: g.id, name: g.name }));
|
||||||
|
return sendJson(res, 200, { guilds: adminGuilds });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /channels/:guildId
|
||||||
|
const channelsMatch = url.match(/^\/channels\/(\d+)$/);
|
||||||
|
if (req.method === "GET" && channelsMatch) {
|
||||||
|
const guildId = channelsMatch[1];
|
||||||
|
try {
|
||||||
|
const channels = await discordGet(token, `/guilds/${guildId}/channels`);
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
channels: channels
|
||||||
|
.filter((c) => c.type === 0)
|
||||||
|
.map((c) => ({ id: c.id, name: c.name, type: c.type })),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /send
|
||||||
|
if (req.method === "POST" && url === "/send") {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
const { channelId, content } = body;
|
||||||
|
if (!channelId || !content) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: "channelId and content required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await discordPost(token, `/channels/${channelId}/messages`, { content });
|
||||||
|
if (!result.ok) {
|
||||||
|
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, { ok: true, messageId: result.data?.id });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /delete-message
|
||||||
|
if (req.method === "POST" && url === "/delete-message") {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
const { channelId, messageId } = body;
|
||||||
|
if (!channelId || !messageId) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: "channelId and messageId required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await discordDelete(token, `/channels/${channelId}/messages/${messageId}`);
|
||||||
|
return sendJson(res, 200, { ok: result.ok });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /create-channel
|
||||||
|
if (req.method === "POST" && url === "/create-channel") {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
const { guildId, name, permissionOverwrites = [] } = body;
|
||||||
|
if (!guildId || !name) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: "guildId and name required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await discordPost(token, `/guilds/${guildId}/channels`, {
|
||||||
|
name,
|
||||||
|
type: 0,
|
||||||
|
permission_overwrites: permissionOverwrites,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, { ok: true, channelId: result.data?.id });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin notification ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createNotifyPlugin(pluginApiUrl, pluginApiToken, log) {
|
||||||
|
return function notifyPlugin(message) {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
channelId: message.channel_id,
|
||||||
|
messageId: message.id,
|
||||||
|
senderId: message.author?.id,
|
||||||
|
guildId: message.guild_id,
|
||||||
|
content: message.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (pluginApiToken) {
|
||||||
|
headers["Authorization"] = `Bearer ${pluginApiToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${pluginApiUrl}/dirigent/api/moderator/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}).catch((err) => {
|
||||||
|
log.warn(`dirigent-moderator: notify plugin failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the moderator service.
|
||||||
|
*
|
||||||
|
* @param {object} config
|
||||||
|
* @param {string} config.token - Discord bot token
|
||||||
|
* @param {string} config.pluginApiUrl - e.g. "http://127.0.0.1:18789"
|
||||||
|
* @param {string} [config.pluginApiToken] - bearer token for plugin API
|
||||||
|
* @param {string} [config.scheduleIdentifier] - e.g. "➡️"
|
||||||
|
* @param {boolean} [config.debugMode]
|
||||||
|
* @returns {{ httpHandler: Function, stop: Function }}
|
||||||
|
*/
|
||||||
|
export function createModeratorService(config) {
|
||||||
|
const { token, pluginApiUrl, pluginApiToken = "", scheduleIdentifier = "➡️", debugMode = false } = config;
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
info: (msg) => console.log(`[dirigent-moderator] ${msg}`),
|
||||||
|
warn: (msg) => console.warn(`[dirigent-moderator] WARN ${msg}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (debugMode) {
|
||||||
|
log.info(`debug mode enabled, scheduleIdentifier=${scheduleIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode bot user ID from token
|
||||||
|
const botUserId = getBotUserIdFromToken(token);
|
||||||
|
log.info(`bot user id decoded: ${botUserId ?? "(unknown)"}`);
|
||||||
|
|
||||||
|
// Plugin notify callback (fire-and-forget)
|
||||||
|
const notifyPlugin = createNotifyPlugin(pluginApiUrl, pluginApiToken, log);
|
||||||
|
|
||||||
|
// Gateway connection
|
||||||
|
const gateway = createGatewayConnection(
|
||||||
|
token,
|
||||||
|
(message) => {
|
||||||
|
// Skip bot's own messages
|
||||||
|
if (message.author?.id === botUserId) return;
|
||||||
|
notifyPlugin(message);
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
|
||||||
|
// HTTP handler (caller strips /moderator prefix)
|
||||||
|
const httpHandler = createHttpHandler(token, botUserId, log);
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpHandler,
|
||||||
|
stop() {
|
||||||
|
log.info("stopping moderator service");
|
||||||
|
gateway.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
131
services/no-reply-api/server.mjs
Normal file
131
services/no-reply-api/server.mjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const modelName = process.env.NO_REPLY_MODEL || "no-reply";
|
||||||
|
const authToken = process.env.AUTH_TOKEN || "";
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(req) {
|
||||||
|
if (!authToken) return true;
|
||||||
|
const header = req.headers.authorization || "";
|
||||||
|
return header === `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noReplyChatCompletion(reqBody) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl_dirigent_${Date.now()}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: reqBody?.model || modelName,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: { role: "assistant", content: "NO_REPLY" },
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function noReplyResponses(reqBody) {
|
||||||
|
return {
|
||||||
|
id: `resp_dirigent_${Date.now()}`,
|
||||||
|
object: "response",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
model: reqBody?.model || modelName,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "NO_REPLY" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listModels() {
|
||||||
|
return {
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: modelName,
|
||||||
|
object: "model",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: "dirigent",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Node.js HTTP request handler for the no-reply API.
|
||||||
|
* When used as a sub-service inside main.mjs, the caller strips
|
||||||
|
* the "/no-reply" prefix from req.url before calling this handler.
|
||||||
|
*/
|
||||||
|
export function createNoReplyHandler() {
|
||||||
|
return function noReplyHandler(req, res) {
|
||||||
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
|
if (req.method === "GET" && url === "/health") {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
service: "dirigent-no-reply-api",
|
||||||
|
model: modelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url === "/v1/models") {
|
||||||
|
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
||||||
|
return sendJson(res, 200, listModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return sendJson(res, 401, { error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 1_000_000) req.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = body ? JSON.parse(body) : {};
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: "invalid_json" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/v1/chat/completions") {
|
||||||
|
return sendJson(res, 200, noReplyChatCompletion(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/v1/responses") {
|
||||||
|
return sendJson(res, 200, noReplyResponses(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone mode: run HTTP server if this file is the entry point
|
||||||
|
const isMain = process.argv[1] && process.argv[1].endsWith("server.mjs");
|
||||||
|
if (isMain) {
|
||||||
|
const port = Number(process.env.PORT || 8787);
|
||||||
|
const handler = createNoReplyHandler();
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`[dirigent-no-reply-api] listening on :${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user