From f20728b02deb1efd77a864ab5d7fccfbbe9c77da Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 21:59:51 +0000 Subject: [PATCH] feat(discord-control): add channel-private-update and member-list field projection --- CHANGELOG.md | 3 +- discord-control-api/server.mjs | 106 ++++++++++++++++++++++++++++++++- docs/DISCORD_CONTROL.md | 32 +++++++++- 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b617e..92b3321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,5 @@ - 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) + - `channel-private-update` (update allowlist/overwrites for existing channel) + - `member-list` (guild members list with pagination + optional field projection) diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 92ccc99..8bd582b 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -8,6 +8,7 @@ const discordBase = "https://discord.com/api/v10"; const enabledActions = { channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", + channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false", memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", }; @@ -78,6 +79,9 @@ function ensureActionEnabled(action) { if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { throw fail(403, "action_disabled", "channel-private-create is disabled"); } + if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) { + throw fail(403, "action_disabled", "channel-private-update is disabled"); + } if (action === "member-list" && !enabledActions.memberList) { throw fail(403, "action_disabled", "member-list is disabled"); } @@ -138,6 +142,34 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = 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) { const guildId = String(body.guildId || "").trim(); const name = String(body.name || "").trim(); @@ -173,6 +205,67 @@ async function actionChannelPrivateCreate(body) { 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) { const guildId = String(body.guildId || "").trim(); 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 limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; const after = body.after ? String(body.after) : undefined; + const fields = parseFieldList(body.fields); 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 }; + 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) { @@ -196,6 +299,7 @@ async function handleAction(body) { ensureActionEnabled(action); if (action === "channel-private-create") return await actionChannelPrivateCreate(body); + if (action === "channel-private-update") return await actionChannelPrivateUpdate(body); if (action === "member-list") return await actionMemberList(body); throw fail(400, "unsupported_action", `unsupported action: ${action}`); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 0a506ca..732926e 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -16,6 +16,7 @@ export AUTH_TOKEN='strong-token' # export REQUIRE_AUTH_TOKEN=true # optional action gates # export ENABLE_CHANNEL_PRIVATE_CREATE=true +# export ENABLE_CHANNEL_PRIVATE_UPDATE=true # export ENABLE_MEMBER_LIST=true # optional allowlist # export ALLOWED_GUILD_IDS='123,456' @@ -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 ### Request @@ -79,13 +107,15 @@ curl -sS http://127.0.0.1:8790/health "action": "member-list", "guildId": "123", "limit": 100, - "after": "0" + "after": "0", + "fields": ["user.id", "user.username", "nick", "roles", "joined_at"] } ``` 说明: - `limit` 1~1000 - `after` 用于分页(Discord snowflake) +- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段 ---