feat(discord-control): add channel-private-update and member-list field projection

This commit is contained in:
2026-02-25 21:59:51 +00:00
parent 4bec5982a5
commit f20728b02d
3 changed files with 138 additions and 3 deletions

View File

@@ -11,4 +11,5 @@
- Added no-touch config rendering and integration docs - Added no-touch config rendering and integration docs
- Added discord-control-api with: - Added discord-control-api with:
- `channel-private-create` (create private channel for allowlist) - `channel-private-create` (create private channel for allowlist)
- `member-list` (guild members list with pagination) - `channel-private-update` (update allowlist/overwrites for existing channel)
- `member-list` (guild members list with pagination + optional field projection)

View File

@@ -8,6 +8,7 @@ const discordBase = "https://discord.com/api/v10";
const enabledActions = { const enabledActions = {
channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false",
channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false",
memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false",
}; };
@@ -78,6 +79,9 @@ function ensureActionEnabled(action) {
if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) {
throw fail(403, "action_disabled", "channel-private-create is disabled"); throw fail(403, "action_disabled", "channel-private-create is disabled");
} }
if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) {
throw fail(403, "action_disabled", "channel-private-update is disabled");
}
if (action === "member-list" && !enabledActions.memberList) { if (action === "member-list" && !enabledActions.memberList) {
throw fail(403, "action_disabled", "member-list is disabled"); throw fail(403, "action_disabled", "member-list is disabled");
} }
@@ -138,6 +142,34 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds =
return overwrites; return overwrites;
} }
function parseFieldList(input) {
if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean);
if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean);
return [];
}
function pick(obj, keys) {
if (!obj || typeof obj !== "object") return obj;
const out = {};
for (const k of keys) {
if (k in obj) out[k] = obj[k];
}
return out;
}
function projectMember(member, fields) {
if (!fields.length) return member;
const base = pick(member, fields);
if (fields.some((f) => f.startsWith("user.")) && member?.user) {
const userFields = fields
.filter((f) => f.startsWith("user."))
.map((f) => f.slice(5))
.filter(Boolean);
base.user = pick(member.user, userFields);
}
return base;
}
async function actionChannelPrivateCreate(body) { async function actionChannelPrivateCreate(body) {
const guildId = String(body.guildId || "").trim(); const guildId = String(body.guildId || "").trim();
const name = String(body.name || "").trim(); const name = String(body.name || "").trim();
@@ -173,6 +205,67 @@ async function actionChannelPrivateCreate(body) {
return { ok: true, action: "channel-private-create", channel }; return { ok: true, action: "channel-private-create", channel };
} }
async function actionChannelPrivateUpdate(body) {
const guildId = String(body.guildId || "").trim();
const channelId = String(body.channelId || "").trim();
if (!guildId) throw fail(400, "bad_request", "guildId is required");
if (!channelId) throw fail(400, "bad_request", "channelId is required");
ensureGuildAllowed(guildId);
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
const denyMask = toStringMask(body.denyMask, 0n);
const mode = String(body.mode || "merge").trim();
const addUserIds = Array.isArray(body.addUserIds) ? body.addUserIds.map(String) : [];
const addRoleIds = Array.isArray(body.addRoleIds) ? body.addRoleIds.map(String) : [];
const removeTargetIds = Array.isArray(body.removeTargetIds) ? body.removeTargetIds.map(String) : [];
const existing = await discordRequest(`/channels/${channelId}`);
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
let next = [];
if (mode === "replace") {
// keep @everyone deny if present, otherwise set one
const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || {
id: guildId,
type: 0,
allow: "0",
deny: String(BIT_VIEW_CHANNEL),
};
next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) });
} else {
next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") }));
}
const removeSet = new Set(removeTargetIds);
if (removeSet.size > 0) {
next = next.filter((o) => !removeSet.has(String(o.id)));
}
const upsert = (id, type) => {
const idx = next.findIndex((o) => String(o.id) === String(id));
const row = { id: String(id), type, allow: allowMask, deny: denyMask };
if (idx >= 0) next[idx] = row;
else next.push(row);
};
for (const userId of addUserIds) upsert(userId, 1);
for (const roleId of addRoleIds) upsert(roleId, 0);
const payload = { permission_overwrites: next };
if (body.dryRun === true) {
return { ok: true, action: "channel-private-update", dryRun: true, payload, mode };
}
const channel = await discordRequest(`/channels/${channelId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
return { ok: true, action: "channel-private-update", mode, channel };
}
async function actionMemberList(body) { async function actionMemberList(body) {
const guildId = String(body.guildId || "").trim(); const guildId = String(body.guildId || "").trim();
if (!guildId) throw fail(400, "bad_request", "guildId is required"); if (!guildId) throw fail(400, "bad_request", "guildId is required");
@@ -181,13 +274,23 @@ async function actionMemberList(body) {
const limitRaw = Number(body.limit ?? 100); const limitRaw = Number(body.limit ?? 100);
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 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 after = body.after ? String(body.after) : undefined;
const fields = parseFieldList(body.fields);
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set("limit", String(limit)); qs.set("limit", String(limit));
if (after) qs.set("after", after); if (after) qs.set("after", after);
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
return { ok: true, action: "member-list", guildId, count: Array.isArray(members) ? members.length : 0, members }; const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
return {
ok: true,
action: "member-list",
guildId,
count: Array.isArray(projected) ? projected.length : 0,
fields: fields.length ? fields : undefined,
members: projected,
};
} }
async function handleAction(body) { async function handleAction(body) {
@@ -196,6 +299,7 @@ async function handleAction(body) {
ensureActionEnabled(action); ensureActionEnabled(action);
if (action === "channel-private-create") return await actionChannelPrivateCreate(body); if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
if (action === "channel-private-update") return await actionChannelPrivateUpdate(body);
if (action === "member-list") return await actionMemberList(body); if (action === "member-list") return await actionMemberList(body);
throw fail(400, "unsupported_action", `unsupported action: ${action}`); throw fail(400, "unsupported_action", `unsupported action: ${action}`);

View File

@@ -16,6 +16,7 @@ export AUTH_TOKEN='strong-token'
# export REQUIRE_AUTH_TOKEN=true # export REQUIRE_AUTH_TOKEN=true
# optional action gates # optional action gates
# export ENABLE_CHANNEL_PRIVATE_CREATE=true # export ENABLE_CHANNEL_PRIVATE_CREATE=true
# export ENABLE_CHANNEL_PRIVATE_UPDATE=true
# export ENABLE_MEMBER_LIST=true # export ENABLE_MEMBER_LIST=true
# optional allowlist # optional allowlist
# export ALLOWED_GUILD_IDS='123,456' # export ALLOWED_GUILD_IDS='123,456'
@@ -70,6 +71,33 @@ curl -sS http://127.0.0.1:8790/health
--- ---
## Action: channel-private-update
对现有频道的白名单/覆盖权限做增删改。
### Request
```json
{
"action": "channel-private-update",
"guildId": "123",
"channelId": "789",
"mode": "merge",
"addUserIds": ["111"],
"addRoleIds": ["333"],
"removeTargetIds": ["222"],
"allowMask": "67648",
"denyMask": "0",
"dryRun": false
}
```
说明:
- `mode=merge`:在现有覆盖基础上增删
- `mode=replace`:重建覆盖(保留/补上 @everyone deny
---
## Action: member-list ## Action: member-list
### Request ### Request
@@ -79,13 +107,15 @@ curl -sS http://127.0.0.1:8790/health
"action": "member-list", "action": "member-list",
"guildId": "123", "guildId": "123",
"limit": 100, "limit": 100,
"after": "0" "after": "0",
"fields": ["user.id", "user.username", "nick", "roles", "joined_at"]
} }
``` ```
说明: 说明:
- `limit` 1~1000 - `limit` 1~1000
- `after` 用于分页Discord snowflake - `after` 用于分页Discord snowflake
- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段
--- ---