mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 12:53:17 +08:00
feat: add unified IM access policies
This commit is contained in:
parent
075d2713c1
commit
840e5aa857
89 changed files with 5573 additions and 1676 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
210
src/channels/shared/access-policy.mjs
Normal file
210
src/channels/shared/access-policy.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
32
src/channels/shared/command-permission.mjs
Normal file
32
src/channels/shared/command-permission.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
43
src/channels/shared/inbound-access.mjs
Normal file
43
src/channels/shared/inbound-access.mjs
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue