feat(discord-control): add channel-private-update and member-list field projection
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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` 选子字段
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user