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

@ -58,6 +58,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
@ -374,6 +378,7 @@ export class DingtalkHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -397,6 +402,7 @@ export class DingtalkHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createDingtalkBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -415,6 +421,7 @@ export class DingtalkHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
@ -439,17 +446,13 @@ export class DingtalkHarnessBridge {
const sender = senderStaffId(message);
if (!messageId || !sender || this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
this.#contextEnhancement,
message.conversationType === '1' || message.conversationType === 1 ? 'direct'
: message.conversationType === '2' || message.conversationType === 2 ? 'group' : null,
) : contextSnapshot);
const conversationType = String(message.conversationType) === '2' ? 'group'
: String(message.conversationType) === '1' ? 'direct' : null;
let key;
try {
key = conversationKey(message, sender);
} catch {
this.#acceptedMessageIds.delete(messageId);
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
return Promise.resolve();
@ -461,9 +464,6 @@ export class DingtalkHarnessBridge {
} catch {
// An unsafe reply route must never be able to submit an approval.
}
if (sessionWebhook && String(message.conversationType) !== '2') {
rememberConnectionTestTarget(this.#state, { sessionWebhook });
}
const pending = this.#pendingInteractions.get(key);
const promptMessage = dingtalkInboundMessage(message, {
api: this.#api,
@ -473,6 +473,26 @@ export class DingtalkHarnessBridge {
const commandText = nonEmptyString(promptMessage.content) ?? '';
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
const direct = String(message.conversationType) !== '2';
if (addressed) {
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: sender,
text: commandText,
hasImages: hasInboundImages(promptMessage),
hasFiles: hasInboundFiles(promptMessage),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(message, messageId, sessionWebhook, access);
}
}
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
this.#contextEnhancement,
conversationType,
) : contextSnapshot);
if (sessionWebhook && direct) {
rememberConnectionTestTarget(this.#state, { sessionWebhook });
}
const statusReaction = sessionWebhook && addressed ? this.#startStatusReaction(message) : null;
const finish = (task) => Promise.resolve(task).then(
(value) => {
@ -855,6 +875,38 @@ export class DingtalkHarnessBridge {
return task;
}
#finishAccessDecision(message, messageId, sessionWebhook, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed' && sessionWebhook) {
increment(this.#status, 'messagesReceived');
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(
sessionWebhook,
t(COMMAND_PERMISSION_DENIED_MESSAGE),
this.#atUsersFor(message),
);
increment(this.#status, 'messagesReplied');
this.#status.lastReplyAt = new Date().toISOString();
} else {
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-dingtalk] failed to apply inbound access policy', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async #process(message, messageId, sender, key, {
alreadyRecorded = false,
preparedMessage,

View file

@ -130,6 +130,7 @@ export class DingtalkRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#maxMessageChars;
@ -152,6 +153,7 @@ export class DingtalkRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
maxMessageChars = 4_000,
@ -170,6 +172,7 @@ export class DingtalkRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#maxMessageChars = maxMessageChars;
@ -238,6 +241,7 @@ export class DingtalkRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -3,6 +3,7 @@ import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
import { DiscordApi } from './discord-api.mjs';
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
@ -433,6 +434,7 @@ export class DiscordRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -464,6 +466,7 @@ export class DiscordRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -480,6 +483,7 @@ export class DiscordRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -559,6 +563,7 @@ export class DiscordRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -730,6 +735,24 @@ export class DiscordRuntime {
async #acceptMessage(message, bridge) {
const messageId = String(message?.id ?? '');
if (!messageId || this.#state.hasSeen(messageId)) return;
const preflight = normalizeDiscordMessage(message, this.#config.platformId);
let accessDecision;
if (preflight?.kind === 'group' && preflight.addressed === true
&& preflight.senderIsBot !== true) {
accessDecision = evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'group',
senderIds: [preflight.senderId],
text: preflight.content,
hasImages: preflight.images.length > 0,
hasFiles: preflight.files.length > 0,
});
if (!accessDecision.allowed) {
// Let the shared bridge apply its normal silent/command-denial behavior,
// but do so against the source channel before creating a Thread.
await bridge.accept(preflight, { accessDecision });
return;
}
}
let route = this.#routing.get(messageId);
if (!route) {
const contextSnapshot = captureContextEnhancement(
@ -750,7 +773,12 @@ export class DiscordRuntime {
}
try {
const normalized = await route.pendingRoute;
if (normalized) await bridge.accept(normalized, { contextSnapshot: route.contextSnapshot });
if (normalized) {
await bridge.accept(normalized, {
contextSnapshot: route.contextSnapshot,
...(accessDecision ? { accessDecision } : {}),
});
}
} catch (error) {
if (error?.code === 'discord-thread-create-uncertain') {
await this.#state.markSeen(messageId);

View file

@ -59,6 +59,11 @@ import {
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { beginStatusReaction } from '../shared/status-reaction.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -135,6 +140,17 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
/** Matches fast card commands that should not be queued behind a running task. */
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
if (hasImages || hasFiles || typeof text !== 'string') return false;
const command = text.trim();
return MENU_COMMAND.test(command)
|| REPAIR_COMMAND_PREFIX.test(command)
|| WATCH_COMMAND.test(command)
|| UNWATCH_COMMAND.test(command)
|| WATCHLIST_COMMAND.test(command)
|| ARCHIVED_COMMAND.test(command);
}
/** Canonical workspace/session help advertised by every bridge family. */
const WORKSPACE_HELP_LINES = [
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
@ -385,6 +401,7 @@ export class FeishuHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#queues = new Map();
#batchInputs = new BatchInputManager();
#pendingInteractions = new Map();
@ -451,6 +468,7 @@ export class FeishuHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status,
allowedSenderOpenIds = new Set(),
botId,
@ -488,6 +506,7 @@ export class FeishuHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#allowedSenderOpenIds = allowedSenderOpenIds;
this.#botId = nonEmptyString(botId);
@ -526,10 +545,10 @@ export class FeishuHarnessBridge {
if (this.#signal?.aborted) return Promise.resolve();
const messageId = nonEmptyString(event?.message?.message_id);
if (!messageId || isBotSender(event)) return Promise.resolve();
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
if (!this.#accessPolicy && !isAllowedSender(event, this.#allowedSenderOpenIds)) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the legacy allowlist');
return Promise.resolve();
}
const addressed = this.#isAddressed(event);
@ -551,6 +570,28 @@ export class FeishuHarnessBridge {
return Promise.resolve();
}
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const hasImages = hasInboundImages(commandMessage);
const hasFiles = hasInboundFiles(commandMessage);
const conversationType = event.message.chat_type === 'p2p' ? 'direct'
: event.message.chat_type === 'group' ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: senderOpenId(event),
text: commandText,
hasImages,
hasFiles,
isCommand: isSharedLocalCommand(commandText, {
hasImages,
hasFiles,
}) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key)),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(event, messageId, access);
}
if (event.message.chat_type === 'p2p') {
const chatId = nonEmptyString(event.message.chat_id);
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@ -558,11 +599,9 @@ export class FeishuHarnessBridge {
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
conversationType,
));
const processingReaction = this.#beginReaction(messageId);
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const batchText = event.message.message_type === 'text'
? nonEmptyString(extractText(event)) ?? ''
: '';
@ -792,6 +831,38 @@ export class FeishuHarnessBridge {
return current;
}
#finishAccessDecision(event, messageId, access) {
let current;
current = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
await this.#send(
event.message.chat_id,
t(COMMAND_PERMISSION_DENIED_MESSAGE),
{ replyTo: event.message.message_id },
);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.warn?.('[dsh-feishu] failed to apply inbound access policy');
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(current);
});
this.#commandTasks.add(current);
return current;
}
#enqueueMessage(event, messageId, key, processingReaction, {
releaseMessageId = true,
alreadyRecorded = false,
@ -1490,10 +1561,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.operator?.operator_id?.user_id)
?? nonEmptyString(event?.open_id)
?? nonEmptyString(event?.user_id);
const operatorAllowed = operatorOpenId !== null
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
if (!operatorAllowed) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
if (!operatorOpenId) return Promise.resolve();
if (!this.#accessPolicy
&& !this.#allowedSenderOpenIds.has('*')
&& !this.#allowedSenderOpenIds.has(operatorOpenId)) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed legacy sender');
return Promise.resolve();
}
const actionValue = callbackObject(event?.action?.value);
@ -1532,6 +1604,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.message_id);
const route = messageId ? this.#cardKeys.get(messageId) : null;
if (!route) {
// A route is also the trusted direct/group scope for the unified policy.
// Without it, fail closed instead of producing an unauthorised side effect.
if (this.#accessPolicy) return Promise.resolve();
// Legacy callers without a unified policy still receive the current
// expired-card guidance introduced by the upstream thread-reply fix.
// The card predates this process (the in-memory mapping resets on
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id)
@ -1542,6 +1619,21 @@ export class FeishuHarnessBridge {
}
return Promise.resolve();
}
const conversationType = route.key.startsWith('p2p:') ? 'direct'
: route.key.startsWith('group:') ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: operatorOpenId,
isCommand: true,
});
if (!access.allowed) {
if (access.reason === 'command-not-allowed') {
return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
.catch(() => undefined);
}
this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
return Promise.resolve();
}
// A used card is recent even if it was first created long ago.
this.#cardKeys.delete(messageId);
this.#cardKeys.set(messageId, route);

View file

@ -114,6 +114,7 @@ export class FeishuRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#replyTimeoutMs;
#connectTimeoutMs;
#requestTimeoutMs;
@ -143,6 +144,7 @@ export class FeishuRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
repair,
replyTimeoutMs = 600000,
connectTimeoutMs = 15000,
@ -176,6 +178,7 @@ export class FeishuRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#repair = repair ?? null;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -276,6 +279,7 @@ export class FeishuRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
botId: this.#botId,

View file

@ -54,6 +54,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
import { t } from '../shared/i18n.mjs';
@ -110,9 +114,6 @@ function conversationKey(message) {
}
function senderAllowed(message, ownerUserOpenid) {
// QR binding yields a C2C user_openid, while group events identify senders
// with a group-scoped member_openid. Treat group membership plus @mention as
// the access boundary, and keep the scanner restriction for private chats.
return message?.kind === 'group'
|| ownerUserOpenid === '*'
|| message?.senderId === ownerUserOpenid;
@ -376,6 +377,7 @@ export class QqHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -398,6 +400,7 @@ export class QqHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createQqBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -417,6 +420,7 @@ export class QqHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -439,6 +443,26 @@ export class QqHarnessBridge {
|| this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(message);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const commandText = safeText(message);
if (addressed) {
const access = this.#accessPolicy
? evaluateInboundAccess(this.#accessPolicy, {
conversationType: message.kind === 'c2c' ? 'direct' : 'group',
senderIds: sender,
text: commandText,
hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
})
: senderAllowed(message, this.#ownerUserOpenid)
? { allowed: true, reason: 'legacy-owner' }
: { allowed: false, reason: 'sender-not-allowed' };
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(message, messageId, access);
}
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
message.kind === 'c2c' ? 'direct' : 'group',
@ -450,20 +474,16 @@ export class QqHarnessBridge {
rememberConnectionTestTarget(this.#state, message.replyTarget);
}
const pending = this.#pendingInteractions.get(key);
const commandText = safeText(message);
const allowed = senderAllowed(message, this.#ownerUserOpenid);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (batchCommand && allowed && addressed && message.kind === 'group') {
if (batchCommand && addressed && message.kind === 'group') {
return this.#finishBatchResult(
message,
messageId,
{ message: batchInputGroupUnsupportedMessage() },
);
}
if (allowed && message.kind === 'c2c'
if (message.kind === 'c2c'
&& (batchCommand || batchStatus.phase === 'collecting')) {
const exactBatchStart = /^\/batch$/iu.test(commandText);
const result = exactBatchStart
@ -493,7 +513,7 @@ export class QqHarnessBridge {
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && allowed && addressed) {
if (commandRunner && addressed) {
let task;
task = this.#processFastCommand(
message,
@ -578,10 +598,9 @@ export class QqHarnessBridge {
alreadyRecorded = false,
batchSubmission = null,
} = {}) {
const allowed = senderAllowed(message, this.#ownerUserOpenid);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const preparedMessage = allowed && addressed
const preparedMessage = addressed
? prefetchInboundFiles(
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
{ signal: this.#signal },
@ -668,6 +687,34 @@ export class QqHarnessBridge {
return task;
}
#finishAccessDecision(message, messageId, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#bot.sendText(message.replyTarget, t(COMMAND_PERMISSION_DENIED_MESSAGE));
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to apply inbound access policy:', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
@ -730,11 +777,6 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId);
messageRecorded = true;
};
if (!senderAllowed(message, this.#ownerUserOpenid)) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const target = message.replyTarget;

View file

@ -5,6 +5,7 @@ import {
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
function timeoutError() {
@ -32,6 +33,7 @@ export class QqRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -50,6 +52,7 @@ export class QqRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -64,6 +67,7 @@ export class QqRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -166,6 +170,7 @@ export class QqRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -177,8 +182,21 @@ export class QqRuntime {
bot.use(contentSanitizer({ parseFaceTags: true }));
bot.use?.(this.#typingMiddleware({
keepAlive: true,
predicate: (ctx) => this.#config.ownerUserOpenid === '*'
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
predicate: (ctx) => {
const message = ctx?.message;
if (!message || (message.kind === 'group'
&& message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
if (!this.#accessPolicy) {
return message.kind === 'group'
|| this.#config.ownerUserOpenid === '*'
|| message.senderId === this.#config.ownerUserOpenid;
}
return evaluateInboundAccess(this.#accessPolicy, {
conversationType: message.kind === 'c2c' ? 'direct' : 'group',
senderIds: message.senderId,
text: typeof message.content === 'string' ? message.content.trim() : '',
}).allowed;
},
}));
let readyResolve;

View file

@ -0,0 +1,210 @@
// Shared by the Host and settings UI; keep this module browser-compatible.
export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
const POLICY_KEYS = ['direct', 'group'];
const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
const ALLOWLIST_KEYS = ['users'];
const USER_KEYS = ['id', 'canExecuteCommands'];
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function invalidAccessPolicy(message) {
const error = new TypeError(message);
error.code = 'access-policy-invalid';
return error;
}
function hasExactKeys(input, keys) {
return input && typeof input === 'object' && !Array.isArray(input)
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
&& Reflect.ownKeys(input).length === keys.length
&& keys.every((key) => Object.hasOwn(input, key));
}
/** Normalize one opaque channel identity without interpreting its contents. */
export function normalizeAccessPolicyUserId(value) {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
value = String(value);
} else if (typeof value === 'bigint') {
value = String(value);
}
if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
const id = value.trim();
if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
}
return id;
}
function validateAccessPolicyUser(input) {
if (!hasExactKeys(input, USER_KEYS)) {
throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
}
if (typeof input.canExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('命令权限必须是布尔值。');
}
return Object.freeze({
id: normalizeAccessPolicyUserId(input.id),
canExecuteCommands: input.canExecuteCommands,
});
}
function validateUsers(input, { listMessage, duplicateMessage }) {
if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
const users = input.map(validateAccessPolicyUser);
if (new Set(users.map(({ id }) => id)).size !== users.length) {
throw invalidAccessPolicy(duplicateMessage);
}
return Object.freeze(users);
}
function validateOpenSettings(input) {
if (!hasExactKeys(input, OPEN_KEYS)) {
throw invalidAccessPolicy('开放模式设置必须完整。');
}
if (typeof input.defaultCanExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
}
return Object.freeze({
defaultCanExecuteCommands: input.defaultCanExecuteCommands,
commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
listMessage: '开放模式命令权限覆盖用户必须是数组。',
duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
}),
});
}
function validateAllowlistSettings(input) {
if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
throw invalidAccessPolicy('白名单模式设置必须完整。');
}
return Object.freeze({
users: validateUsers(input.users, {
listMessage: '白名单模式用户必须是数组。',
duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
}),
});
}
function validateAccessPolicyScope(input) {
if (!hasExactKeys(input, SCOPE_KEYS)) {
throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
}
if (!ACCESS_POLICY_MODES.includes(input.mode)) {
throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
}
return Object.freeze({
mode: input.mode,
open: validateOpenSettings(input.open),
allowlist: validateAllowlistSettings(input.allowlist),
});
}
/** Validate one canonical direct + group atomic save. */
export function validateAccessPolicy(input) {
if (!hasExactKeys(input, POLICY_KEYS)) {
throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
}
return Object.freeze({
direct: validateAccessPolicyScope(input.direct),
group: validateAccessPolicyScope(input.group),
});
}
/** Normalize canonical persisted/runtime data; damaged settings fail closed. */
export function normalizeAccessPolicy(input) {
try {
return validateAccessPolicy(input);
} catch {
return null;
}
}
/** Small canonical constructor used by channel initialization. */
export function createAccessPolicyScope(options) {
return validateAccessPolicyScope(options === undefined ? {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: { users: [] },
} : options);
}
const DENY_SCOPE = createAccessPolicyScope();
export const DEFAULT_ACCESS_POLICY = Object.freeze({
direct: DENY_SCOPE,
group: DENY_SCOPE,
});
export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
export function createAccessPolicy({
direct = DEFAULT_ACCESS_POLICY.direct,
group = DEFAULT_ACCESS_POLICY.group,
} = {}) {
return validateAccessPolicy({ direct, group });
}
const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
/**
* Decide access for one ordinary message or one already-recognized command.
* `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
* Privileged/owner bypass is intentionally handled by the inbound adapter.
*/
export function evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand = false,
equals = (left, right) => left === right,
} = {}) {
const normalizedPolicy = normalizeAccessPolicy(policy);
if (!normalizedPolicy) return POLICY_UNAVAILABLE;
if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
|| typeof isCommand !== 'boolean' || typeof equals !== 'function') {
return INVALID_CONTEXT;
}
const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
.flatMap((candidate) => {
try {
return [normalizeAccessPolicyUserId(candidate)];
} catch {
return [];
}
});
if (candidates.length === 0) return SENDER_UNAVAILABLE;
const scope = normalizedPolicy[conversationType];
const users = scope.mode === 'open'
? scope.open.commandPermissionOverrides
: scope.allowlist.users;
let matchedUsers;
try {
matchedUsers = users.filter((user) => (
candidates.some((candidate) => equals(candidate, user.id) === true)
));
} catch {
return INVALID_CONTEXT;
}
if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
if (isCommand) {
const canExecuteCommands = scope.mode === 'open'
? (matchedUsers.length > 0
? matchedUsers.every((user) => user.canExecuteCommands)
: scope.open.defaultCanExecuteCommands)
: matchedUsers.every((user) => user.canExecuteCommands);
if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
}
return ALLOWED;
}

View file

@ -13,6 +13,10 @@ import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import {
normalizeAccessPolicy,
validateAccessPolicy,
} from './access-policy.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -131,6 +135,26 @@ function normalizeDeliveryTargets(value) {
return deliveryTargets;
}
function normalizeAccessPolicies(value, workspaces) {
const accessPolicies = Object.create(null);
if (value === undefined) return accessPolicies;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
// Preserve the distinction between a missing policy (eligible for startup
// initialization) and damaged persisted data (fail closed).
for (const botId of Object.keys(workspaces)) accessPolicies[botId] = null;
return accessPolicies;
}
for (const [botId, policy] of Object.entries(value)) {
try {
botIdOf(botId);
accessPolicies[botId] = normalizeAccessPolicy(policy);
} catch {
// An invalid key cannot identify a bot, so it is isolated and ignored.
}
}
return accessPolicies;
}
function normalizeDocument(value) {
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
@ -168,15 +192,52 @@ function normalizeDocument(value) {
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
if (!deliveryTargets) return null;
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
const version = value.accessPolicies === undefined ? value.version : 2;
return {
version: value.version,
// A v1 file cannot be emitted with this optional v2 section. If one is
// recovered from an interrupted/manual edit, retain it on the next write.
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
};
}
function storedDocument({
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}) {
const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
document.accessPolicies = accessPolicies;
}
return document;
}
async function writeStoredDocument(path, document) {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, path);
}
export async function validateWorkspacePath(value) {
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
const error = new Error('工作区必须是绝对路径。');
@ -208,6 +269,7 @@ export class BotWorkspaceStore {
#agentPresets = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
#accessPolicies = Object.create(null);
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -233,6 +295,7 @@ export class BotWorkspaceStore {
this.#agentPresets = normalized.agentPresets;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
this.#accessPolicies = normalized.accessPolicies;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#version = 1;
@ -240,6 +303,7 @@ export class BotWorkspaceStore {
this.#agentPresets = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
this.#accessPolicies = Object.create(null);
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -278,6 +342,13 @@ export class BotWorkspaceStore {
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
accessPolicyFor(botId) {
const id = botIdOf(botId);
return this.has(id) && Object.hasOwn(this.#accessPolicies, id)
? this.#accessPolicies[id]
: null;
}
listDeliveryTargets(botId) {
const id = botIdOf(botId);
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
@ -373,28 +444,53 @@ export class BotWorkspaceStore {
}
}
async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
async ensure(botId, {
workspace = this.#defaultWorkspace,
defaultAgentPreset,
initialAccessPolicy,
} = {}) {
const id = botIdOf(botId);
const initialWorkspace = resolve(workspace);
return this.#enqueue(id, async () => {
if (!this.#workspaces[id]) {
const agentPreset = validateAgentPresetId(defaultAgentPreset);
const createsBot = !this.#workspaces[id];
const initializesAccessPolicy = initialAccessPolicy !== undefined
&& !Object.hasOwn(this.#accessPolicies, id);
if (createsBot || initializesAccessPolicy) {
const accessPolicy = initializesAccessPolicy
? validateAccessPolicy(initialAccessPolicy)
: undefined;
const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
const previousAgentPreset = this.#agentPresets[id];
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
const nextAccessPolicies = initializesAccessPolicy
? { ...this.#accessPolicies, [id]: accessPolicy }
: this.#accessPolicies;
if (createsBot) {
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
}
const nextVersion = initializesAccessPolicy ? 2 : this.#version;
try {
await this.#persist();
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
nextVersion,
nextAccessPolicies,
);
} catch (error) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (createsBot) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
}
throw error;
}
this.#accessPolicies = nextAccessPolicies;
this.#version = nextVersion;
} else if (!this.#generations.has(id)) {
this.#generations.set(id, this.#freshGeneration());
}
@ -487,6 +583,30 @@ export class BotWorkspaceStore {
});
}
async setAccessPolicy(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
const policy = validateAccessPolicy(value);
return this.#enqueue(id, async () => {
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const next = { ...this.#accessPolicies, [id]: policy };
// Inbound messages keep the previous committed snapshot until rename succeeds.
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
2,
next,
);
this.#accessPolicies = next;
this.#version = 2;
return policy;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -646,6 +766,7 @@ export class BotWorkspaceStore {
...Object.keys(this.#agentPresets),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
...Object.keys(this.#accessPolicies),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -663,6 +784,7 @@ export class BotWorkspaceStore {
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
}
: bot),
};
@ -694,12 +816,14 @@ export class BotWorkspaceStore {
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
delete this.#accessPolicies[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -733,24 +857,16 @@ export class BotWorkspaceStore {
contextEnhancement = this.#contextEnhancement,
deliveryTargets = this.#deliveryTargets,
version = this.#version,
accessPolicies = this.#accessPolicies,
) {
const document = { version, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, this.#path);
await writeStoredDocument(this.#path, storedDocument({
version,
workspaces: this.#workspaces,
agentPresets: this.#agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}));
this.#dirtyRemovals.clear();
}
@ -758,7 +874,8 @@ export class BotWorkspaceStore {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0) {
|| Object.keys(this.#deliveryTargets).length > 0
|| Object.keys(this.#accessPolicies).length > 0) {
await this.#persist();
return;
}
@ -1280,6 +1397,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return result;
});
};
const updateAccessPolicy = (botId, value, projectStatus) => {
const incarnation = workspaces.incarnationFor(botId);
const policy = validateAccessPolicy(value);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
const decorated = workspaces.decorateStatus(snapshot);
const updated = {
...decorated,
bots: decorated.bots.map((bot) => bot?.botId === botId
? { ...bot, accessPolicy: policy } : bot),
...(catalog ? { agentPresetCatalog: catalog } : {}),
};
// Prepare the complete channel-specific response before commit. Failed
// projections and disk writes must leave the live policy unchanged.
const result = projectStatus ? await projectStatus(updated) : updated;
await workspaces.setAccessPolicy(botId, policy, { incarnation });
return result;
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
// Fence the old runtime without changing the durable mapping. A crash
// before the controller removes its config therefore keeps the bot's
@ -1320,6 +1462,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,32 @@
import { isBatchInputCommand } from './batch-input.mjs';
import { isCompactCommand } from './compact-command.mjs';
import { isControlCommand } from './control-command.mjs';
import { isHistoryCommand } from './history-command.mjs';
import { isModelCommand } from './model-command.mjs';
import { isPresetCommand } from './preset-command.mjs';
import { isWorkspaceCommand } from './workspace-command.mjs';
const SIMPLE_TEXT_COMMANDS = new Set(['/help', '/status', '/new']);
/**
* Match only commands that the shared bridges already execute locally.
* Unknown slash-prefixed text remains an ordinary prompt.
*/
export function isSharedLocalCommand(text, {
hasImages = false,
hasFiles = false,
} = {}) {
if (typeof text !== 'string') return false;
const command = text.trim();
if (!command) return false;
if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
if (!hasFiles && (
isControlCommand(command)
|| isModelCommand(command)
|| isPresetCommand(command)
)) return true;
if (hasImages || hasFiles) return false;
return SIMPLE_TEXT_COMMANDS.has(command.toLowerCase())
|| isWorkspaceCommand(command)
|| isCompactCommand(command);
}

View file

@ -70,9 +70,8 @@ function compactErrorMessage(error) {
* Unknown input returns null so the caller may continue ordinary message routing.
*/
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
if (typeof text !== 'string') return null;
if (!isCompactCommand(text)) return null;
const match = COMPACT_COMMAND.exec(text.trim());
if (!match) return null;
if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
if (typeof state?.sessionFor !== 'function') {
return commandResult(t('当前机器人没有可用的会话状态。'));
@ -94,3 +93,7 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
return commandResult(compactErrorMessage(error));
}
}
export function isCompactCommand(text) {
return typeof text === 'string' && COMPACT_COMMAND.test(text.trim());
}

View file

@ -185,4 +185,6 @@ export default {
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
'你可以发送普通消息,但没有执行命令的权限。':
'You can send regular messages, but you do not have permission to run commands.',
};

View file

@ -0,0 +1,43 @@
import { evaluateAccessPolicy } from './access-policy.mjs';
import { isSharedLocalCommand } from './command-permission.mjs';
export const COMMAND_PERMISSION_DENIED_MESSAGE = '你可以发送普通消息,但没有执行命令的权限。';
const POLICY_NOT_CONFIGURED = Object.freeze({ allowed: true, reason: 'policy-not-configured' });
const PRIVILEGED_SENDER = Object.freeze({ allowed: true, reason: 'privileged-sender' });
/**
* Read one committed policy snapshot and decide a single inbound event.
* A missing provider is kept backward-compatible for direct bridge fixtures;
* production always injects a provider, whose missing/damaged value fails closed.
*/
export function evaluateInboundAccess(accessPolicy, {
conversationType,
senderIds,
text = '',
hasImages = false,
hasFiles = false,
isCommand = isSharedLocalCommand(text, { hasImages, hasFiles }),
} = {}) {
if (!accessPolicy) return POLICY_NOT_CONFIGURED;
try {
if (typeof accessPolicy.isPrivileged === 'function'
&& accessPolicy.isPrivileged(senderIds, conversationType) === true) {
return PRIVILEGED_SENDER;
}
} catch {
// A broken privilege lookup must not bypass the persisted policy.
}
let policy = null;
try {
policy = accessPolicy.getSettings();
} catch {
// Provider failures are equivalent to an unavailable persisted policy.
}
return evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand,
...(typeof accessPolicy.equals === 'function' ? { equals: accessPolicy.equals } : {}),
});
}

View file

@ -1,4 +1,8 @@
import { t } from './i18n.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from './inbound-access.mjs';
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
@ -129,6 +133,7 @@ export class TextHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -149,6 +154,7 @@ export class TextHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createTextBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -162,6 +168,7 @@ export class TextHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -176,7 +183,7 @@ export class TextHarnessBridge {
return structuredClone(this.#status);
}
accept(message, { contextSnapshot } = {}) {
accept(message, { contextSnapshot, accessDecision } = {}) {
if (this.#signal?.aborted) return Promise.resolve();
const conversationId = cleanText(message?.conversationId);
const kind = message?.kind === 'group' ? 'group' : 'direct';
@ -187,6 +194,40 @@ export class TextHarnessBridge {
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
return Promise.resolve();
}
// Preserve the channel trigger boundary. Access policy never turns an
// unaddressed group message into a denial reply.
if (kind === 'group' && normalized.addressed !== true) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(normalized, messageId, null);
}
if (this.#accessPolicy || accessDecision) {
const hasImages = hasInboundImages(normalized);
const hasFiles = hasInboundFiles(normalized);
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
conversationType: kind,
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
text: normalized.content,
hasImages,
hasFiles,
});
if (!decision.allowed) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
// Mark policy denials so a webhook replay cannot repeat local work or
// a command-permission notice.
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(
normalized,
messageId,
decision.reason === 'command-not-allowed'
? t(COMMAND_PERMISSION_DENIED_MESSAGE)
: null,
{ recordReceived: decision.reason === 'command-not-allowed' },
);
}
}
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
: contextSnapshot);
@ -350,13 +391,15 @@ export class TextHarnessBridge {
return this.#enqueueMessage(normalized, messageId, senderId, key);
}
#finishLocalMessage(message, messageId, reply) {
#finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
let task;
task = (async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (recordReceived) {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (reply) await this.#bot.sendText(message.replyTarget, reply);
this.#status.lastError = null;
})().catch(async (error) => {

View file

@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
}
export async function runWorkspaceCommand(text, harness, conversationKey) {
if (typeof text !== 'string') return null;
if (!isWorkspaceCommand(text)) return null;
const command = text.trim();
if (SESSION_BIND_PREFIX.test(command)) {
return runSessionBindCommand(command, harness, conversationKey);
@ -385,3 +385,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
throw error;
}
}
export function isWorkspaceCommand(text) {
if (typeof text !== 'string') return false;
const command = text.trim();
return SESSION_BIND_PREFIX.test(command)
|| SESSION_LIST_COMMAND.test(command)
|| WORKSPACE_LIST_COMMAND.test(command)
|| WORKSPACE_COMMAND.test(command);
}

View file

@ -347,6 +347,7 @@ export class SlackRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -371,6 +372,7 @@ export class SlackRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -387,6 +389,7 @@ export class SlackRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -464,6 +467,7 @@ export class SlackRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -14,7 +14,6 @@ import {
} from './telegram-rich-message.mjs';
import {
TELEGRAM_ACCESS_MODES,
normalizeTelegramAccessPolicy,
} from './config-store.mjs';
export const TELEGRAM_COMMAND_MENU = Object.freeze([
@ -669,12 +668,11 @@ export class TelegramRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#createApi;
#createHttpTransport;
#accessMode;
#allowedPrivateUserIds;
#status = createTelegramRuntimeStatus();
#httpTransport = null;
#api = null;
@ -689,6 +687,7 @@ export class TelegramRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options),
@ -702,13 +701,11 @@ export class TelegramRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi;
this.#createHttpTransport = createHttpTransport;
const accessPolicy = normalizeTelegramAccessPolicy(config);
this.#accessMode = accessPolicy.accessMode;
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
}
get status() {
@ -805,6 +802,7 @@ export class TelegramRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -865,10 +863,7 @@ export class TelegramRuntime {
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
});
if (message && telegramInboundAllowed(message, {
accessMode: this.#accessMode,
allowedPrivateUserIds: this.#allowedPrivateUserIds,
})) {
if (message) {
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
if (signal.aborted) return;
this.#logger.error?.(
@ -876,9 +871,6 @@ export class TelegramRuntime {
error,
);
});
} else if (message) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
cursor = update.update_id + 1;
await this.#state.setCursor(cursor);

View file

@ -51,6 +51,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@ -490,6 +494,7 @@ export class WecomHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -512,6 +517,7 @@ export class WecomHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createWecomBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -530,6 +536,7 @@ export class WecomHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -557,16 +564,28 @@ export class WecomHarnessBridge {
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(frame);
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const conversationType = body.chattype === 'single' ? 'direct' : 'group';
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: senderId,
text: commandText,
hasImages: hasInboundImages(commandMessage),
hasFiles: hasInboundFiles(commandMessage),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(frame, messageId, chatId, access);
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
body.chattype === 'single' ? 'direct' : 'group',
conversationType,
));
if (body.chattype === 'single') {
rememberConnectionTestTarget(this.#state, { chatId });
}
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (batchCommand && body.chattype === 'group') {
@ -760,6 +779,34 @@ export class WecomHarnessBridge {
return task;
}
#finishAccessDecision(frame, messageId, chatId, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#sendImmediate(frame, chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to apply inbound access policy', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),

View file

@ -29,6 +29,7 @@ export class WecomRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -47,6 +48,7 @@ export class WecomRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -61,6 +63,7 @@ export class WecomRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -111,6 +114,7 @@ export class WecomRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -57,6 +57,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
@ -260,6 +264,7 @@ export class WeixinHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -292,6 +297,7 @@ export class WeixinHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createWeixinBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -312,6 +318,7 @@ export class WeixinHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -332,6 +339,22 @@ export class WeixinHarnessBridge {
const sender = nonEmptyString(message?.from_user_id);
if (!messageId || !sender || this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const access = this.#accessPolicy
? evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'direct',
senderIds: sender,
text: commandText,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
})
: sender === this.#ownerUserId
? { allowed: true, reason: 'legacy-owner' }
: { allowed: false, reason: 'sender-not-allowed' };
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(messageId, sender, message, access);
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
'direct',
@ -343,11 +366,9 @@ export class WeixinHarnessBridge {
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
const pending = this.#pendingInteractions.get(key);
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (sender === this.#ownerUserId
&& (batchCommand || batchStatus.phase === 'collecting')) {
if (batchCommand || batchStatus.phase === 'collecting') {
const exactBatchStart = /^\/batch$/iu.test(commandText);
const result = exactBatchStart
&& batchStatus.phase === 'idle'
@ -380,7 +401,7 @@ export class WeixinHarnessBridge {
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && sender === this.#ownerUserId) {
if (commandRunner) {
let task;
task = this.#processFastCommand(
message,
@ -467,12 +488,10 @@ export class WeixinHarnessBridge {
alreadyRecorded = false,
batchSubmission = null,
} = {}) {
const preparedMessage = message.from_user_id === this.#ownerUserId
? prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
)
: undefined;
const preparedMessage = prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
);
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
@ -518,6 +537,36 @@ export class WeixinHarnessBridge {
return task;
}
#finishAccessDecision(messageId, sender, message, access) {
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(sender, t(COMMAND_PERMISSION_DENIED_MESSAGE), contextToken, runId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-weixin] failed to apply inbound access policy:', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),
@ -590,12 +639,6 @@ export class WeixinHarnessBridge {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (sender !== this.#ownerUserId) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
let batchSettled = batchSubmission === null;

View file

@ -109,6 +109,7 @@ export class WeixinRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#maxMessageChars;
@ -126,6 +127,7 @@ export class WeixinRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
@ -140,6 +142,7 @@ export class WeixinRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#maxMessageChars = maxMessageChars;
@ -182,6 +185,7 @@ export class WeixinRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
import {
areJidsSameUser,
downloadMediaMessage,
jidDecode,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
@ -13,7 +14,6 @@ import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifac
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import {
WHATSAPP_ACCESS_MODES,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
@ -37,6 +37,36 @@ const VIEW_ONCE_WRAPPER_KEYS = new Set([
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
]);
const WHATSAPP_ACCESS_POLICY_USER_SERVERS = new Set([
's.whatsapp.net',
'c.us',
'lid',
'hosted',
'hosted.lid',
]);
function normalizeWhatsappAccessPolicyId(value) {
if (typeof value !== 'string') return null;
const candidate = value.trim();
if (/^\+?\d+$/.test(candidate)) {
return `${candidate.replace(/^\+/, '')}@s.whatsapp.net`;
}
const decoded = jidDecode(candidate);
if (!decoded || !/^\d+$/.test(decoded.user)
|| !WHATSAPP_ACCESS_POLICY_USER_SERVERS.has(decoded.server)) return null;
return candidate;
}
export function whatsappAccessPolicyIdsEqual(left, right) {
const normalizedLeft = normalizeWhatsappAccessPolicyId(left);
const normalizedRight = normalizeWhatsappAccessPolicyId(right);
if (!normalizedLeft || !normalizedRight) return false;
try {
return areJidsSameUser(normalizedLeft, normalizedRight) === true;
} catch {
return false;
}
}
function hasViewOnceWrapper(content) {
let current = content;
@ -538,12 +568,11 @@ export class WhatsappRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#mediaUploadTimeoutMs;
#accessMode;
#allowedPrivateNumbers;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
@ -558,6 +587,7 @@ export class WhatsappRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
@ -572,6 +602,7 @@ export class WhatsappRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -583,21 +614,12 @@ export class WhatsappRuntime {
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
);
this.#createSession = createSession;
this.setAccessPolicy(config);
}
get status() {
return structuredClone(this.#status);
}
setAccessPolicy(value) {
const policy = normalizeWhatsappAccessPolicy(value);
this.#accessMode = policy.accessMode;
this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
this.#config = { ...this.#config, ...policy };
return policy;
}
async start() {
if (this.#status.ready && this.#session) return this.status;
if (this.#starting) return this.#starting;
@ -636,14 +658,6 @@ export class WhatsappRuntime {
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (!whatsappInboundAllowed(message, {
accessMode: this.#accessMode,
allowedNumbers: this.#allowedPrivateNumbers,
})) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {
@ -678,6 +692,14 @@ export class WhatsappRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy ? {
botId: this.#accessPolicy.botId,
getSettings: (...args) => this.#accessPolicy.getSettings(...args),
...(typeof this.#accessPolicy.isPrivileged === 'function' ? {
isPrivileged: (...args) => this.#accessPolicy.isPrivileged(...args),
} : {}),
equals: whatsappAccessPolicyIdsEqual,
} : undefined,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,