feat(discord-control): add private-channel and member-list admin API
This commit is contained in:
@@ -9,3 +9,6 @@
|
||||
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
||||
- Added helper scripts for smoke/dev lifecycle and rule validation
|
||||
- Added no-touch config rendering and integration docs
|
||||
- Added discord-control-api with:
|
||||
- `channel-private-create` (create private channel for allowlist)
|
||||
- `member-list` (guild members list with pagination)
|
||||
|
||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin
|
||||
.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up
|
||||
|
||||
check:
|
||||
cd plugin && npm run check
|
||||
@@ -23,3 +23,6 @@ render-config:
|
||||
|
||||
package-plugin:
|
||||
node scripts/package-plugin.mjs
|
||||
|
||||
discord-control-up:
|
||||
cd discord-control-api && node server.mjs
|
||||
|
||||
@@ -19,6 +19,7 @@ The no-reply provider returns `NO_REPLY` for any input.
|
||||
|
||||
- `plugin/` — OpenClaw plugin (before_model_resolve hook)
|
||||
- `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY`
|
||||
- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表)
|
||||
- `docs/` — rollout, integration, run-mode notes
|
||||
- `scripts/` — smoke/dev/helper checks
|
||||
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`)
|
||||
@@ -39,6 +40,8 @@ node scripts/render-openclaw-config.mjs
|
||||
|
||||
See `docs/RUN_MODES.md` for Docker mode.
|
||||
|
||||
Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。
|
||||
|
||||
---
|
||||
|
||||
## Development plan (incremental commits)
|
||||
|
||||
9
discord-control-api/package.json
Normal file
9
discord-control-api/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "whispergate-discord-control-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
179
discord-control-api/server.mjs
Normal file
179
discord-control-api/server.mjs
Normal file
@@ -0,0 +1,179 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT || 8790);
|
||||
const authToken = process.env.AUTH_TOKEN || "";
|
||||
const discordToken = process.env.DISCORD_BOT_TOKEN || "";
|
||||
const discordBase = "https://discord.com/api/v10";
|
||||
|
||||
const BIT_VIEW_CHANNEL = 1024n;
|
||||
const BIT_SEND_MESSAGES = 2048n;
|
||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
||||
|
||||
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 ensureDiscordToken() {
|
||||
if (!discordToken) {
|
||||
const e = new Error("missing DISCORD_BOT_TOKEN");
|
||||
e.status = 500;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function discordRequest(path, init = {}) {
|
||||
ensureDiscordToken();
|
||||
const headers = {
|
||||
Authorization: `Bot ${discordToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers || {}),
|
||||
};
|
||||
|
||||
const r = await fetch(`${discordBase}${path}`, { ...init, headers });
|
||||
const text = await r.text();
|
||||
let data = text;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {}
|
||||
|
||||
if (!r.ok) {
|
||||
const e = new Error(`discord_api_error ${r.status}`);
|
||||
e.status = r.status;
|
||||
e.details = data;
|
||||
throw e;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toStringMask(v, fallback) {
|
||||
if (v === undefined || v === null || v === "") return String(fallback);
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number") return String(Math.floor(v));
|
||||
if (typeof v === "bigint") return String(v);
|
||||
throw new Error("invalid mask");
|
||||
}
|
||||
|
||||
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
|
||||
const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY;
|
||||
const denyDefault = BIT_VIEW_CHANNEL;
|
||||
|
||||
const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault);
|
||||
const targetAllow = toStringMask(allowMask, allowDefault);
|
||||
|
||||
const overwrites = [
|
||||
{
|
||||
id: guildId,
|
||||
type: 0,
|
||||
allow: "0",
|
||||
deny: everyoneDeny,
|
||||
},
|
||||
];
|
||||
|
||||
for (const roleId of allowedRoleIds) {
|
||||
overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
for (const userId of allowedUserIds) {
|
||||
overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" });
|
||||
}
|
||||
|
||||
return overwrites;
|
||||
}
|
||||
|
||||
async function actionChannelPrivateCreate(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
const name = String(body.name || "").trim();
|
||||
if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 });
|
||||
if (!name) throw Object.assign(new Error("name is required"), { status: 400 });
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
type: Number.isInteger(body.type) ? body.type : 0,
|
||||
parent_id: body.parentId || undefined,
|
||||
topic: body.topic || undefined,
|
||||
position: Number.isInteger(body.position) ? body.position : undefined,
|
||||
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
|
||||
permission_overwrites: buildPrivateOverwrites({
|
||||
guildId,
|
||||
allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [],
|
||||
allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [],
|
||||
allowMask: body.allowMask,
|
||||
denyEveryoneMask: body.denyEveryoneMask,
|
||||
}),
|
||||
};
|
||||
|
||||
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return { ok: true, action: "channel-private-create", channel };
|
||||
}
|
||||
|
||||
async function actionMemberList(body) {
|
||||
const guildId = String(body.guildId || "").trim();
|
||||
if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 });
|
||||
|
||||
const limitRaw = Number(body.limit ?? 100);
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
const after = body.after ? String(body.after) : undefined;
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
if (after) qs.set("after", after);
|
||||
|
||||
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
|
||||
return { ok: true, action: "member-list", guildId, count: Array.isArray(members) ? members.length : 0, members };
|
||||
}
|
||||
|
||||
async function handleAction(body) {
|
||||
const action = String(body.action || "").trim();
|
||||
if (!action) throw Object.assign(new Error("action is required"), { status: 400 });
|
||||
|
||||
if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
|
||||
if (action === "member-list") return await actionMemberList(body);
|
||||
|
||||
throw Object.assign(new Error(`unsupported action: ${action}`), { status: 400 });
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
return sendJson(res, 200, { ok: true, service: "discord-control-api" });
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
|
||||
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 > 2_000_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
const result = await handleAction(parsed);
|
||||
return sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
return sendJson(res, err?.status || 500, {
|
||||
error: "request_failed",
|
||||
message: String(err?.message || err),
|
||||
details: err?.details,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[discord-control-api] listening on :${port}`);
|
||||
});
|
||||
85
docs/DISCORD_CONTROL.md
Normal file
85
docs/DISCORD_CONTROL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Discord Control API
|
||||
|
||||
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
||||
|
||||
1. 创建指定名单可见的私人频道
|
||||
2. 查看 server 成员列表(分页)
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cd discord-control-api
|
||||
export DISCORD_BOT_TOKEN='xxx'
|
||||
# optional
|
||||
# export AUTH_TOKEN='strong-token'
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Health:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8790/health
|
||||
```
|
||||
|
||||
## Unified action endpoint
|
||||
|
||||
`POST /v1/discord/action`
|
||||
|
||||
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
||||
- Body: `{ "action": "...", ... }`
|
||||
|
||||
---
|
||||
|
||||
## Action: channel-private-create
|
||||
|
||||
与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channel-private-create",
|
||||
"guildId": "123",
|
||||
"name": "private-room",
|
||||
"type": 0,
|
||||
"parentId": "456",
|
||||
"topic": "secret",
|
||||
"position": 3,
|
||||
"nsfw": false,
|
||||
"allowedUserIds": ["111", "222"],
|
||||
"allowedRoleIds": ["333"],
|
||||
"allowMask": "67648",
|
||||
"denyEveryoneMask": "1024"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。
|
||||
- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。
|
||||
- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。
|
||||
|
||||
---
|
||||
|
||||
## Action: member-list
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "member-list",
|
||||
"guildId": "123",
|
||||
"limit": 100,
|
||||
"after": "0"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `limit` 1~1000
|
||||
- `after` 用于分页(Discord snowflake)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。
|
||||
- 若无权限,Discord API 会返回 403 并原样透出错误信息。
|
||||
Reference in New Issue
Block a user