feat: add unified IM access policies

This commit is contained in:
xmanrui 2026-09-01 10:45:12 +08:00
parent 075d2713c1
commit 840e5aa857
89 changed files with 5573 additions and 1676 deletions

View file

@ -21,6 +21,7 @@ import {
resolveDiscordMessageRoute,
} from '../../../src/channels/discord/discord-runtime.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import {
DISCORD_ENDPOINTS,
createDiscordRpcHandler,
@ -1442,9 +1443,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
const routingStarted = deferred();
const releaseRouting = deferred();
const seen = new Set();
const deliveries = [];
const prompts = [];
let socket;
let reads = 0;
let accessReads = 0;
let threadStarts = 0;
let config = {
group: {
enabled: true,
@ -1453,6 +1457,24 @@ test('Discord captures context settings before asynchronous Thread routing and u
},
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
};
const allowedAccessSettings = {
direct: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [
{ id: '333333333333333333', canExecuteCommands: true },
{ id: '333333333333333334', canExecuteCommands: false },
],
},
},
};
let accessSettings = allowedAccessSettings;
const runtime = new DiscordRuntime({
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
token: TOKEN,
@ -1460,6 +1482,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
botId: 'discord_internal',
getSettings: () => { reads += 1; return config; },
},
accessPolicy: {
getSettings: () => {
accessReads += 1;
return accessSettings;
},
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
@ -1475,12 +1503,16 @@ test('Discord captures context settings before asynchronous Thread routing and u
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
startThreadFromMessage: async () => {
threadStarts += 1;
routingStarted.resolve();
await releaseRouting.promise;
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
},
sendTyping: async () => {},
createMessage: async () => ({ id: '888888888888888890' }),
createMessage: async (request) => {
deliveries.push(request);
return { id: '888888888888888890' };
},
editMessage: async ({ messageId }) => ({ id: messageId }),
}),
createWebSocket: () => {
@ -1498,13 +1530,37 @@ test('Discord captures context settings before asynchronous Thread routing and u
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
}) });
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: '111111111111111189', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333332', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> denied before Thread`,
} }) });
await eventually(() => runtime.status.messagesRejected === 1);
assert.equal(threadStarts, 0, 'a denied member must not create a Discord Thread');
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111188', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333334', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> /new`,
} }) });
await eventually(() => runtime.status.messagesRejected === 2);
assert.equal(threadStarts, 0, 'a command-denied member must not create a Discord Thread');
assert.equal(deliveries.at(-1)?.channelId, parentId);
assert.equal(deliveries.at(-1)?.content, COMMAND_PERMISSION_DENIED_MESSAGE);
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
} }) });
await routingStarted.promise;
assert.equal(threadStarts, 1);
config = { ...config, group: { ...config.group, enabled: false } };
accessSettings = {
...allowedAccessSettings,
group: {
...allowedAccessSettings.group,
allowlist: { users: [] },
},
};
releaseRouting.resolve();
await eventually(() => runtime.status.messagesReplied === 1);
assert.match(prompts[0], /accepted before routing/);
@ -1513,7 +1569,10 @@ test('Discord captures context settings before asynchronous Thread routing and u
senderName: 'Group Nick', botId: 'discord_internal',
});
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
assert.equal(accessReads, 3,
'each source event reads access once and the Thread keeps its arrival decision');
accessSettings = allowedAccessSettings;
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
@ -1521,6 +1580,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
await eventually(() => runtime.status.messagesReplied === 2);
assert.equal(prompts[1], 'second without enhancement');
assert.equal(reads, 2);
assert.equal(accessReads, 4, 'the next managed-Thread event reads the latest policy once');
});
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {