feat: support per-bot workspace switching

This commit is contained in:
xmanrui 2026-08-17 23:56:27 +08:00
parent adcace3512
commit 44dd50216a
64 changed files with 3878 additions and 712 deletions

View file

@ -63,6 +63,8 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
飞书扫码绑定会把扫码者作为允许使用者;手动凭据同样无法识别扫码人,因此使用飞书应用的可见范围作为入站访问范围。请在飞书开放平台中只向信任的租户、群或成员开放应用。
每个机器人维护独立的 Harness 工作区。新接入机器人会把 Harness Host 进程当时的工作目录(`process.cwd()`)记录为默认值;该路径会持久化,不会因为以后从其他目录重启 Host 而改变。设置页的机器人卡片会显示当前路径,并可直接修改。任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以发送 `/workspace /工作区绝对路径` 切换工作区,不再区分管理员和普通用户。插件只接受存在的绝对目录;切换成功后会清除该机器人的旧 Harness 会话映射,其他机器人不受影响。切换只对后续消息生效;已经开始生成的回复会继续完成。
## 设计
- Harness 中只注册一个「IM机器人」设置页;
@ -150,6 +152,8 @@ QQ QR binding uses Tencent's official QQBot v2 flow. Tencent's default authoriza
Feishu QR binding records the scanner as an allowed user. Manual credentials cannot identify a scanner, so the Feishu application's visibility becomes its inbound access scope. Restrict the application to trusted tenants, groups, or members.
Each bot maintains an independent Harness workspace. A newly connected bot records the Harness Host process's current working directory (`process.cwd()`) as its default; the path is persisted and does not change when the Host is later restarted from another directory. Every bot card shows the current path and lets it be edited. Any user who is already within that platform bot's visibility scope and can normally message the bot can send `/workspace /absolute/path` to switch it, with no additional administrator/ordinary-user distinction. Only existing absolute directories are accepted. A successful switch clears that bot's old Harness session mappings without affecting other bots. The switch applies to subsequent messages; a reply that has already started generating is allowed to finish.
## Design
- Registers a single **IM Bot** settings page in Harness.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -145,6 +146,7 @@ function normalizeBot(value) {
state,
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace, 4_096) ?? '',
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',

View file

@ -2,6 +2,8 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import {
DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL,
@ -208,6 +210,7 @@ export function AccountCard({
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -231,6 +234,11 @@ export function AccountCard({
h('dd', null, account.connected ? 'Stream 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'),
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -256,6 +264,7 @@ function AccountList(props) {
busy: props.busyByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -279,6 +288,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const statusRequestRef = React.useRef(0);
const workspaceFence = useWorkspaceSnapshotFence();
const noticeFrameRef = React.useRef(null);
const focusFrameRef = React.useRef(null);
@ -335,11 +345,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
restoreProvisioning = false,
} = {}) => {
if (!mountedRef.current || signal?.aborted) return undefined;
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
const requestId = statusRequestRef.current + 1;
statusRequestRef.current = requestId;
const canCommit = () => mountedRef.current
&& !signal?.aborted
&& statusRequestRef.current === requestId;
&& statusRequestRef.current === requestId
&& workspaceFence.canCommitStatus(workspaceVersion);
if (!silent && canCommit()) {
setModel((current) => ({ ...current, phase: 'loading', error: null }));
}
@ -373,7 +386,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
}));
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -450,6 +463,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
if (!mountedRef.current) return;
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -458,21 +472,25 @@ export function DingtalkSettingsTab({ rpcCall }) {
{ clientId: identity, clientSecret: secret },
));
if (!mountedRef.current) return;
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
setCredentialOpen(false);
announce('钉钉机器人凭据已绑定。');
} catch (error) {
if (mountedRef.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBusy(false);
}
}, [announce, invoke]);
}, [announce, invoke, loadStatus, workspaceFence]);
const cancelProvisioning = React.useCallback(async () => {
if (!mountedRef.current) return;
@ -575,12 +593,20 @@ export function DingtalkSettingsTab({ rpcCall }) {
const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => {
if (!mountedRef.current) return undefined;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, operation);
try {
await invoke(endpoint, payload);
if (!mountedRef.current) return undefined;
const snapshot = await loadStatus({ silent: true, restoreProvisioning: false });
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (!mountedRef.current) return undefined;
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
announce(typeof success === 'function' ? success(snapshot) : success);
return snapshot;
} catch (error) {
@ -588,9 +614,11 @@ export function DingtalkSettingsTab({ rpcCall }) {
announce(`操作失败:${presentError(error).message}`);
return undefined;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
const reconnect = React.useCallback((account) => runBotAction({
account,
@ -602,6 +630,30 @@ export function DingtalkSettingsTab({ rpcCall }) {
: '钉钉仍未连接,插件会继续自动重试。',
}), [runBotAction]);
const saveWorkspace = React.useCallback(async (account, workspace) => {
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'workspace');
try {
const snapshot = normalizeSnapshot(await invoke(
DINGTALK_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React.useCallback(async (account) => {
const snapshot = await runBotAction({
account,
@ -688,6 +740,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
busyByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -17,6 +17,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
reconnectBot: "bot.reconnect",
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
disconnect: "connection.disconnect",
@ -159,6 +160,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
state: authoritativeState(value.state, connected),
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
error: normalizeError(value.error),

View file

@ -14,6 +14,8 @@ import {
unwrapRpcResult,
} from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { WorkspaceEditor } from "../../workspace-editor.js";
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
import { installFeishuStyles } from "./styles.js";
export const name = "feishu-settings";
@ -364,6 +366,7 @@ export function BotCard({
actionError,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -409,6 +412,11 @@ export function BotCard({
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"),
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
),
h(WorkspaceEditor, {
workspace: connection.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
summary) : null,
@ -448,6 +456,7 @@ function BotList(props) {
actionError: props.errorsByBot[bot.botId],
removing: props.removeTargetId === bot.botId,
onReconnect: () => props.onReconnect(bot),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
onRequestRemove: () => props.onRequestRemove(bot),
onConfirmRemove: () => props.onConfirmRemove(bot),
onCancelRemove: props.onCancelRemove,
@ -525,8 +534,15 @@ export function FeishuSettingsTab({ rpcCall }) {
const cardRefs = React.useRef(new Map());
const removeButtonRefs = React.useRef(new Map());
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const scheduleAnimationFrame = useAnimationFrameScheduler();
React.useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const announce = React.useCallback((message) => {
setAnnouncement("");
scheduleAnimationFrame(() => {
@ -548,23 +564,27 @@ export function FeishuSettingsTab({ rpcCall }) {
}, []);
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null || !mountedRef.current) return undefined;
if (!silent) setPageBusy(true);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
if (signal?.aborted || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
mergeSnapshot(snapshot, { restoreProvisioning });
return snapshot;
} catch (error) {
if (signal?.aborted || error?.name === "AbortError") return undefined;
if (signal?.aborted || error?.name === "AbortError" || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
const presented = presentError(error);
setModel((current) => current.phase === "loading" || !silent
? { ...current, phase: "error", pageError: presented }
: { ...current, statusError: presented });
return undefined;
} finally {
if (!silent && !signal?.aborted) setPageBusy(false);
if (!silent && !signal?.aborted && mountedRef.current) setPageBusy(false);
}
}, [invoke, mergeSnapshot]);
}, [invoke, mergeSnapshot, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -645,6 +665,7 @@ export function FeishuSettingsTab({ rpcCall }) {
}, [announce, invoke, model.provisioning?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setCredentialBusy(true);
setCredentialError(null);
try {
@ -652,15 +673,19 @@ export function FeishuSettingsTab({ rpcCall }) {
FEISHU_ENDPOINTS.bindCredentials,
{ appId: identity, appSecret: secret },
));
mergeSnapshot(snapshot);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
setCredentialOpen(false);
announce("飞书机器人凭据已绑定。");
} catch (error) {
setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setCredentialBusy(false);
}
}, [announce, invoke, mergeSnapshot]);
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
const cancelProvisioning = React.useCallback(async () => {
const attemptId = model.provisioning?.attemptId;
@ -798,11 +823,14 @@ export function FeishuSettingsTab({ rpcCall }) {
const reconnectOneBot = React.useCallback(async (connection) => {
const { botId, bot } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "reconnect");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
mergeSnapshot(snapshot);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
const refreshed = snapshot.bots.find((item) => item.botId === botId);
if (!refreshed?.connected) {
const error = new Error(
@ -818,9 +846,31 @@ export function FeishuSettingsTab({ rpcCall }) {
setBotError(botId, error);
announce(`${bot.name}操作失败,请查看机器人状态。`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(botId, null);
}
}, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
}, [announce, invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const saveWorkspace = React.useCallback(async (connection, workspace) => {
const { botId } = connection;
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(botId, "workspace");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(
FEISHU_ENDPOINTS.setWorkspace,
{ botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
mergeSnapshot(snapshot);
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(botId, null);
}
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const requestRemove = React.useCallback((connection) => {
setRemoveTargetId(connection.botId);
@ -834,32 +884,29 @@ export function FeishuSettingsTab({ rpcCall }) {
const confirmRemove = React.useCallback(async (connection) => {
const { botId, bot } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "delete");
setBotError(botId, null);
try {
await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
const snapshot = normalizeBotsSnapshot(await invoke(
FEISHU_ENDPOINTS.deleteBot,
{ botId, confirm: true },
));
setRemoveTargetId(null);
setModel((current) => {
const bots = current.bots.filter((item) => item.botId !== botId);
return {
...current,
bots,
totals: {
configured: bots.length,
connected: bots.filter((item) => item.connected).length,
},
};
});
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
await loadStatus({ silent: true });
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}移除失败,请重试。`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(botId, null);
}
}, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
const provision = model.provisioning;
let provisionContent = null;
@ -950,6 +997,7 @@ export function FeishuSettingsTab({ rpcCall }) {
errorsByBot,
removeTargetId,
onReconnect: (bot) => void reconnectOneBot(bot),
onWorkspaceSave: saveWorkspace,
onRequestRemove: requestRemove,
onConfirmRemove: (bot) => void confirmRemove(bot),
onCancelRemove: cancelRemove,

View file

@ -8,6 +8,7 @@ export const QQ_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -79,6 +80,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { QqLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
QQ_ENDPOINTS,
@ -146,7 +148,16 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -162,6 +173,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dl', { className: 'ddt-metrics dim-botMetrics' },
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -182,6 +198,7 @@ export function QqSettingsTab({ rpcCall }) {
const [credentialError, setCredentialError] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -201,10 +218,13 @@ export function QqSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(QQ_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -212,12 +232,13 @@ export function QqSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -258,6 +279,7 @@ export function QqSettingsTab({ rpcCall }) {
}, [invoke, provision?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -266,14 +288,18 @@ export function QqSettingsTab({ rpcCall }) {
{ appId: identity, appSecret: secret },
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const closeProvision = React.useCallback(async () => {
setBusy(true);
@ -318,17 +344,22 @@ export function QqSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
return snapshot;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current }; delete next[account.botId]; return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
@ -349,6 +380,12 @@ export function QqSettingsTab({ rpcCall }) {
busy: busyByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
QQ_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -24,6 +24,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
export function createTokenChannelApi(channel, connectionSummary) {
@ -47,6 +48,7 @@ export function createTokenChannelApi(channel, connectionSummary) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props },
@ -51,7 +53,7 @@ export function createTokenChannelSettings(definition) {
emptyActionLabel = '填写 Bot Token',
} = definition;
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@ -73,6 +75,11 @@ export function createTokenChannelSettings(definition) {
h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' },
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -106,6 +113,7 @@ export function createTokenChannelSettings(definition) {
const [busyByBot, setBusyByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
React.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
@ -124,13 +132,17 @@ export function createTokenChannelSettings(definition) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal));
if (!mounted.current || signal?.aborted) return;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({
...current,
phase: silent ? current.phase : 'error',
@ -138,7 +150,7 @@ export function createTokenChannelSettings(definition) {
}));
}
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -160,6 +172,7 @@ export function createTokenChannelSettings(definition) {
}, [loadStatus, model.phase]);
const bindCredentials = React.useCallback(async (values) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -168,30 +181,37 @@ export function createTokenChannelSettings(definition) {
credentialPayload(values),
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(api.presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) {
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' },
@ -208,6 +228,12 @@ export function createTokenChannelSettings(definition) {
endpoints.reconnectBot,
{ botId: account.botId },
),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
endpoints.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -8,6 +8,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -79,6 +80,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { WecomLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
WECOM_ENDPOINTS,
@ -146,7 +148,16 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -162,6 +173,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dl', { className: 'ddt-metrics dim-botMetrics' },
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -182,6 +198,7 @@ export function WecomSettingsTab({ rpcCall }) {
const [credentialError, setCredentialError] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -201,10 +218,13 @@ export function WecomSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -212,12 +232,13 @@ export function WecomSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -258,6 +279,7 @@ export function WecomSettingsTab({ rpcCall }) {
}, [invoke, provision?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -266,14 +288,18 @@ export function WecomSettingsTab({ rpcCall }) {
{ botId: identity, secret },
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const closeProvision = React.useCallback(async () => {
setBusy(true);
@ -318,17 +344,22 @@ export function WecomSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
return snapshot;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current }; delete next[account.botId]; return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
@ -349,6 +380,12 @@ export function WecomSettingsTab({ rpcCall }) {
busy: busyByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
WECOM_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -7,6 +7,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -103,6 +104,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
connected,
configured: value.configured === true,
workspace: string(value.workspace).slice(0, 4_096),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),

View file

@ -15,6 +15,8 @@ import {
unwrapRpcResult,
} from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installWeixinStyles } from './styles.js';
export const name = 'weixin-settings';
@ -193,7 +195,16 @@ function checkedTime(timestamp) {
}
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -211,6 +222,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'),
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'dxw-actions dim-cardActions' },
@ -236,6 +252,7 @@ function AccountList(props) {
busy: props.busyByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -269,8 +286,15 @@ export function WeixinSettingsTab({ rpcCall }) {
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now());
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const scheduleAnimationFrame = useAnimationFrameScheduler();
React.useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const announce = React.useCallback((value) => {
setNotice('');
scheduleAnimationFrame(() => {
@ -285,10 +309,13 @@ export function WeixinSettingsTab({ rpcCall }) {
silent = false,
restoreProvisioning = false,
} = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null || !mountedRef.current) return undefined;
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
if (signal?.aborted || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
@ -302,7 +329,8 @@ export function WeixinSettingsTab({ rpcCall }) {
}
return snapshot;
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError') return undefined;
if (signal?.aborted || error?.name === 'AbortError' || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel((current) => ({
...current,
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
@ -310,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
}));
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -466,35 +494,66 @@ export function WeixinSettingsTab({ rpcCall }) {
}, []);
const reconnect = React.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'reconnect');
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
}
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
} catch (error) {
announce(`连接检查失败:${presentError(error).message}`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
const saveWorkspace = React.useCallback(async (account, workspace) => {
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'workspace');
try {
const snapshot = normalizeSnapshot(await invoke(
WEIXIN_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
});
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'delete');
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
botId: account.botId,
confirm: true,
}));
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
}
setRemoveTarget(null);
announce('微信账号及本机凭据已移除。');
} catch (error) {
announce(`移除失败:${presentError(error).message}`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') {
@ -554,6 +613,7 @@ export function WeixinSettingsTab({ rpcCall }) {
busyByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -7,6 +7,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
@ -80,6 +81,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { WhatsappLogoGlyph } from '../../channel-logos.js';
import { QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
WHATSAPP_ENDPOINTS,
@ -159,6 +161,7 @@ export function WhatsappAccountCard({
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -186,6 +189,11 @@ export function WhatsappAccountCard({
h('dd', null, account.connected ? 'WhatsApp Web' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' },
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -213,6 +221,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
const [removeTarget, setRemoveTarget] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -232,10 +241,13 @@ export function WhatsappSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WHATSAPP_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -243,7 +255,8 @@ export function WhatsappSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({
...current,
phase: silent ? current.phase : 'error',
@ -252,7 +265,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -350,20 +363,23 @@ export function WhatsappSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) {
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' },
@ -380,6 +396,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId: account.botId },
),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
WHATSAPP_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -44,6 +44,19 @@ const EN = Object.freeze({
'已断开': 'Disconnected',
'消息通道': 'Message channel',
'最近检查': 'Last checked',
'当前工作区': 'Current workspace',
'工作区绝对路径': 'Absolute workspace path',
'/绝对路径/到/工作区': '/absolute/path/to/workspace',
'修改': 'Change',
'保存': 'Save',
'保存中…': 'Saving…',
'未设置': 'Not set',
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
'请输入工作区绝对路径。': 'Enter an absolute workspace path.',
'工作区必须是绝对路径。': 'The workspace must be an absolute path.',
'工作区路径不存在。': 'The workspace path does not exist.',
'工作区路径必须指向一个目录。': 'The workspace path must point to a directory.',
'找不到要修改的机器人。': 'The bot could not be found.',
'尚未检查': 'Not checked yet',
'刚刚': 'Just now',
'检查连接': 'Check connection',

View file

@ -133,6 +133,19 @@ const CSS = String.raw`
.dim-panel .dim-confirm strong, .dim-panel .dim-confirm h4 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: 1.4; font-weight: 650; }
.dim-panel .dim-confirm p { margin: 7px 0 0; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; }
.dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-panel .dim-workspace { min-width: 0; display: grid; gap: 7px; margin-top: 14px; padding: 11px 12px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-panel .dim-workspaceHeader { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
.dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; }
.dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; }
.dim-panel .dim-workspacePath { min-width: 0; display: block; overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
.dim-panel .dim-workspaceForm { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; }
.dim-panel .dim-workspaceForm input { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.dim-panel .dim-workspaceForm input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 10%); }
.dim-panel .dim-workspaceActions { display: flex; gap: 6px; }
.dim-panel .dim-workspaceActions button { min-height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
.dim-panel .dim-workspaceActions button[type="submit"] { border-color: #1677ff; color: white; background: #1677ff; }
.dim-panel .dim-workspaceActions button:disabled { cursor: not-allowed; opacity: .55; }
.dim-panel .dim-workspaceError { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; }
.dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
.dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }

View file

@ -0,0 +1,96 @@
import * as React from 'react';
import { h } from './i18n.js';
function looksLikeAbsolutePath(value) {
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
}
export function WorkspaceEditor({ workspace, disabled = false, onSave }) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(workspace ?? '');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const inputRef = React.useRef(null);
const editButtonRef = React.useRef(null);
const restoreFocus = React.useRef(false);
React.useEffect(() => {
if (!editing) setDraft(workspace ?? '');
}, [workspace, editing]);
React.useEffect(() => {
if (editing) inputRef.current?.focus();
else if (restoreFocus.current) {
restoreFocus.current = false;
editButtonRef.current?.focus();
}
}, [editing]);
const cancel = () => {
setDraft(workspace ?? '');
setError(null);
restoreFocus.current = true;
setEditing(false);
};
const submit = async (event) => {
event.preventDefault();
const value = draft.trim();
if (!value || saving || disabled) return;
if (!looksLikeAbsolutePath(value)) {
setError('工作区必须是绝对路径。');
return;
}
setSaving(true);
setError(null);
try {
await onSave?.(value);
restoreFocus.current = true;
setEditing(false);
} catch (cause) {
setError(cause?.message ?? '工作区修改失败,请重试。');
} finally {
setSaving(false);
}
};
return h('div', { className: 'dim-workspace' },
h('div', { className: 'dim-workspaceHeader' },
h('span', null, '当前工作区'),
editing ? null : h('button', {
type: 'button',
ref: editButtonRef,
className: 'dim-workspaceEdit',
onClick: () => { setEditing(true); setError(null); },
disabled,
}, '修改')),
editing
? h('form', { className: 'dim-workspaceForm', onSubmit: submit },
h('input', {
ref: inputRef,
value: draft,
onChange: (event) => setDraft(event.target.value),
placeholder: '/绝对路径/到/工作区',
'aria-label': '工作区绝对路径',
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
maxLength: 4_096,
required: true,
disabled: saving || disabled,
}),
h('div', { className: 'dim-workspaceActions' },
h('button', {
type: 'submit', disabled: saving || disabled || !draft.trim(),
}, saving ? '保存中…' : '保存'),
h('button', { type: 'button', onClick: cancel, disabled: saving || disabled }, '取消')),
error ? h('p', { className: 'dim-workspaceError', role: 'alert' }, error) : null)
: workspace
? React.createElement('code', {
className: 'dim-workspacePath',
title: workspace,
}, workspace)
: h('code', { className: 'dim-workspacePath' }, '未设置'),
);
}

View file

@ -0,0 +1,28 @@
import * as React from 'react';
/** Keep every full-snapshot response ordered behind the latest client mutation. */
export function useWorkspaceSnapshotFence() {
const state = React.useRef({ version: 0, pendingMutations: 0 });
return React.useMemo(() => Object.freeze({
beginStatus() {
return state.current.pendingMutations === 0 ? state.current.version : null;
},
canCommitStatus(version) {
return version !== null
&& state.current.pendingMutations === 0
&& state.current.version === version;
},
beginMutation() {
state.current.pendingMutations += 1;
state.current.version += 1;
return state.current.version;
},
canCommitMutation(version) {
return state.current.version === version;
},
endMutation() {
state.current.pendingMutations = Math.max(0, state.current.pendingMutations - 1);
return state.current.pendingMutations === 0;
},
}), []);
}

View file

@ -8,6 +8,12 @@ import { DingtalkController } from '../../../../src/channels/dingtalk/dingtalk-c
import { DingtalkRuntime } from '../../../../src/channels/dingtalk/dingtalk-runtime.mjs';
import { HarnessClient } from '../../../../src/channels/dingtalk/harness-client.mjs';
import { DingtalkStateStore } from '../../../../src/channels/dingtalk/state-store.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
@ -26,6 +32,7 @@ function pluginPaths(config) {
root,
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -45,6 +52,19 @@ export async function createProductionController(ctx, config = {}, internals = {
: (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const canListConfiguredBots = typeof configStore.list === 'function';
const configuredBots = canListConfiguredBots ? configStore.list() : [];
if (canListConfiguredBots) {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const deviceAuth = internals.deviceAuth ?? new DeviceAuth({
baseUrl: config.registrationBaseUrl,
});
@ -61,23 +81,25 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
const coreController = new Controller({
deviceAuth,
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
clientSecret,
harness,
state,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
maxMessageChars: config.maxMessageChars ?? 4_000,
connectTimeoutMs: config.connectTimeoutMs ?? 15_000,
@ -94,15 +116,16 @@ export async function createProductionController(ctx, config = {}, internals = {
stateStores.delete(botId);
if (state && typeof state.remove === 'function') {
await state.remove();
return;
}
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
export const DINGTALK_ENDPOINTS = Object.freeze({
@ -10,6 +11,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
approveSender: 'bot.sender.approve',
revokeSender: 'bot.sender.revoke',
});
@ -76,6 +78,10 @@ function payloadFailure(endpoint, payload) {
? null
: 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
return exactKeys(payload, ['botId', 'requestId', 'confirm'])
&& validId(payload.botId)
@ -207,6 +213,12 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
value = await publicStatus(
await controller.approveSender(payload.botId, payload.requestId),
@ -219,8 +231,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
);
}
return signal?.aborted ? cancelled() : { ok: true, value };
} catch {
return signal?.aborted ? cancelled() : internalFailure();
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted ? cancelled() : workspaceError
? { ok: false, error: workspaceError }
: internalFailure();
}
};
}

View file

@ -12,6 +12,12 @@ import {
} from '../../../../src/channels/feishu/plugin-config-store.mjs';
import { MultiBotDshFeishuController } from '../../../../src/channels/feishu/multi-bot-controller.mjs';
import { StateStore } from '../../../../src/channels/feishu/state-store.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
@ -34,6 +40,7 @@ function pluginPaths(config) {
config: resolve(config.configPath ?? join(root, 'config.json')),
legacyState: resolve(config.statePath ?? join(root, 'state.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -58,6 +65,24 @@ export async function createProductionController(ctx, config = {}, internals = {
: (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const canListConfiguredBots = typeof configStore.list === 'function';
const listConfiguredBots = () => canListConfiguredBots ? configStore.list() : [];
const configuredBots = listConfiguredBots();
if (canListConfiguredBots) {
await workspaces.reconcile(configuredBots.map((bot) => bot.id));
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id)));
const observedConfigStore = typeof configStore.removeBot === 'function'
? observeBotWorkspaceRemovals(configStore, {
workspaces,
method: 'removeBot',
botIdFromRemoved: (removed) => removed.id,
})
: configStore;
// State is lazy per bot. A corrupt legacy file can therefore fail only the
// migrated bot and cannot prevent healthy v2 bots from starting.
const stateStores = new Map();
@ -75,9 +100,14 @@ export async function createProductionController(ctx, config = {}, internals = {
}
return state;
};
const stateForBotId = async (botId) => {
const botConfig = listConfiguredBots().find((bot) => bot.id === botId);
if (!botConfig) throw new Error('Unknown Feishu bot');
return stateFor(botConfig);
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
// This plugin is already hosted by a running DSH process. Starting a
// second DSH would create a competing server and lifecycle.
@ -85,21 +115,24 @@ export async function createProductionController(ctx, config = {}, internals = {
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
const coreController = new Controller({
registerApp: (options) => lark.registerApp(options),
verifyApp,
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
const state = await stateFor(botConfig);
const id = botId ?? botConfig.id ?? botConfig.appId;
await workspaces.ensure(id);
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
return new Runtime({
lark,
appId: botConfig.appId,
appSecret,
domain: botConfig.domain,
ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
harness,
state,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
logger: {
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
@ -118,6 +151,10 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor: stateForBotId,
});
const supervisor = createSupervisor({
controller,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import {
FEISHU_ENDPOINTS,
FEISHU_RPC_CHANNEL,
@ -174,6 +175,7 @@ function publicBotEntry(entry) {
bot: publicBot(source.bot),
health: publicHealth(source, connected),
};
if (typeof source.workspace === 'string' && source.workspace) result.workspace = source.workspace;
const error = publicError(source.error);
if (error) result.error = error;
return result;
@ -268,6 +270,10 @@ function validPayload(endpoint, payload) {
? null
: 'Deleting a bot requires a valid botId and confirm=true.';
}
if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown Feishu endpoint.';
}
@ -429,14 +435,23 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
} else if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await toPublicFeishuStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
{ encodeQr: cachedEncodeQr },
);
} else {
if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
}
if (signal?.aborted) return cancelled();
return { ok: true, value };
} catch {
return signal?.aborted ? cancelled() : internalFailure();
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted ? cancelled() : workspaceError
? { ok: false, error: { ...workspaceError, details: {} } }
: internalFailure();
}
};
}

View file

@ -8,6 +8,12 @@ import { QqController } from '../../../../src/channels/qq/qq-controller.mjs';
import { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs';
import { QqQrAuth } from '../../../../src/channels/qq/qr-auth.mjs';
import { QqStateStore } from '../../../../src/channels/qq/state-store.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
@ -25,6 +31,7 @@ function pluginPaths(config) {
return {
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -42,6 +49,16 @@ export async function createProductionController(ctx, config = {}, internals = {
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:qq') : (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? 'deepseek-harness' });
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
@ -55,41 +72,50 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
const coreController = new Controller({
qrAuth,
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
createRuntime: async ({ botId, config: botConfig, appSecret }) => new Runtime({
config: botConfig,
appSecret,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
appSecret,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
});
},
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
if (state && typeof state.remove === 'function') {
await state.remove();
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
export const QQ_RPC_CHANNEL = '/qq';
export const QQ_ENDPOINTS = Object.freeze({
@ -10,6 +11,7 @@ export const QQ_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
@ -57,6 +59,10 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === QQ_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown QQ endpoint.';
}
@ -120,16 +126,24 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value };
} catch {
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: false, error: { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
: { ok: false, error: workspaceError
?? { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
}
};
}

View file

@ -3,6 +3,12 @@ import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
export function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
@ -19,6 +25,7 @@ export function pluginPaths(config, channel) {
return {
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -37,6 +44,16 @@ export async function createTokenProductionController(ctx, config, internals, de
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
const paths = pluginPaths(config, channel);
const configStore = await new ResolvedConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
const stateFor = async (botId) => {
@ -49,41 +66,50 @@ export async function createTokenProductionController(ctx, config, internals, de
};
const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new ResolvedController({
const coreController = new ResolvedController({
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
...(internals.inspectToken ? { inspectToken: internals.inspectToken } : {}),
createRuntime: async ({ botId, config: botConfig, token }) => new ResolvedRuntime({
config: botConfig,
token,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
createRuntime: async ({ botId, config: botConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
config: botConfig,
token,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
});
},
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
if (state && typeof state.remove === 'function') {
await state.remove();
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
channel,
controller,

View file

@ -1,10 +1,16 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import {
publicWorkspaceError,
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from './workspace-rpc.mjs';
export const TOKEN_BOT_ENDPOINTS = Object.freeze({
status: 'connection.status',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
@ -45,6 +51,10 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown bot endpoint.';
}
@ -59,6 +69,8 @@ function sanitizePublic(value) {
}
function operationError(channel, error) {
const workspaceError = publicWorkspaceError(error);
if (workspaceError) return workspaceError;
if (error?.code === 'webhook-configured') {
return { code: 'webhook-configured', message: error.message };
}
@ -93,6 +105,9 @@ export function createTokenBotRpcHandler(controller, { channel }) {
value = await controller.bindCredentials(payload);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
value = await controller.reconnectBot(payload.botId);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);
} else {
value = await controller.deleteBot(payload.botId);
}

View file

@ -0,0 +1,25 @@
import { isAbsolute } from 'node:path';
export const SET_WORKSPACE_ENDPOINT = 'bot.workspace.set';
export function validWorkspacePayload(payload) {
return payload !== null
&& typeof payload === 'object'
&& !Array.isArray(payload)
&& Object.keys(payload).every((key) => ['botId', 'workspace'].includes(key))
&& typeof payload.botId === 'string'
&& /^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)
&& typeof payload.workspace === 'string'
&& payload.workspace.length <= 4_096
&& isAbsolute(payload.workspace.trim());
}
export function publicWorkspaceError(error) {
if (![
'workspace-not-absolute',
'workspace-not-found',
'workspace-not-directory',
'workspace-bot-not-found',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -6,6 +6,12 @@ import { SlackController } from '../../../../src/channels/slack/slack-controller
import { SlackHarnessClient } from '../../../../src/channels/slack/harness-client.mjs';
import { SlackRuntime } from '../../../../src/channels/slack/slack-runtime.mjs';
import { SlackStateStore } from '../../../../src/channels/slack/state-store.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
@ -23,6 +29,16 @@ export async function createProductionController(ctx, config = {}, internals = {
? ctx.logger('dsh-im:slack') : (ctx.logger ?? console);
const paths = pluginPaths(config, 'slack');
const configStore = await new ResolvedConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
const stateFor = async (botId) => {
@ -35,42 +51,51 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new ResolvedController({
const coreController = new ResolvedController({
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
...(internals.inspectCredentials ? { inspectCredentials: internals.inspectCredentials } : {}),
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => new ResolvedRuntime({
config: botConfig,
botToken,
appToken,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
config: botConfig,
botToken,
appToken,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
});
},
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
if (state && typeof state.remove === 'function') {
await state.remove();
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
channel: 'slack',
controller,

View file

@ -1,4 +1,9 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import {
publicWorkspaceError,
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from '../shared/workspace-rpc.mjs';
export const SLACK_RPC_CHANNEL = '/slack';
export const SLACK_ENDPOINTS = Object.freeze({
@ -6,6 +11,7 @@ export const SLACK_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
export const SLACK_RPC_ENDPOINTS = Object.freeze(Object.values(SLACK_ENDPOINTS));
@ -54,6 +60,10 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === SLACK_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown Slack endpoint.';
}
@ -68,6 +78,8 @@ function sanitizePublic(value) {
}
function operationError(error) {
const workspaceError = publicWorkspaceError(error);
if (workspaceError) return workspaceError;
if (error?.code === 'slack-invalid-bot-token') {
return { code: 'invalid-bot-token', message: 'Slack Bot Token 无效,请确认使用以 xoxb- 开头的令牌。' };
}
@ -103,6 +115,10 @@ export function createSlackRpcHandler(controller) {
if (endpoint === SLACK_ENDPOINTS.status) value = await controller.status();
else if (endpoint === SLACK_ENDPOINTS.bindCredentials) value = await controller.bindCredentials(payload);
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) value = await controller.reconnectBot(payload.botId);
else if (endpoint === SLACK_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);
}
else value = await controller.deleteBot(payload.botId);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }

View file

@ -8,6 +8,12 @@ import { WecomQrAuth } from '../../../../src/channels/wecom/qr-auth.mjs';
import { WecomStateStore } from '../../../../src/channels/wecom/state-store.mjs';
import { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs';
import { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
@ -25,6 +31,7 @@ function pluginPaths(config) {
return {
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -42,6 +49,16 @@ export async function createProductionController(ctx, config = {}, internals = {
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:wecom') : (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const qrAuth = internals.qrAuth ?? new QrAuth({
source: config.qrSource ?? 'deepseek-harness',
platform: config.qrPlatform,
@ -58,42 +75,51 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
const coreController = new Controller({
qrAuth,
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
createRuntime: async ({ botId, config: botConfig, secret }) => new Runtime({
config: botConfig,
secret,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
createRuntime: async ({ botId, config: botConfig, secret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
secret,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
});
},
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
if (state && typeof state.remove === 'function') {
await state.remove();
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
export const WECOM_RPC_CHANNEL = '/wecom';
export const WECOM_ENDPOINTS = Object.freeze({
@ -10,6 +11,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
@ -57,6 +59,10 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === WECOM_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown Enterprise WeChat endpoint.';
}
@ -125,16 +131,24 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value };
} catch {
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: false, error: { code: 'wecom-operation-failed', message: '企业微信操作失败,请稍后重试。' } };
: { ok: false, error: workspaceError
?? { code: 'wecom-operation-failed', message: '企业微信操作失败,请稍后重试。' } };
}
};
}

View file

@ -8,6 +8,12 @@ import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mj
import { createWeixinApi } from '../../../../src/channels/weixin/weixin-api.mjs';
import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
@ -26,6 +32,7 @@ function pluginPaths(config) {
root,
config: resolve(config.configPath ?? join(root, 'config.json')),
accounts: resolve(config.accountsDir ?? join(root, 'accounts')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
};
}
@ -45,6 +52,16 @@ export async function createProductionController(ctx, config = {}, internals = {
: (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const stateStores = new Map();
const statePath = (botId) => resolve(paths.accounts, botId, 'state.json');
@ -58,24 +75,26 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
const coreController = new Controller({
api,
credentials: ctx.credentials,
configStore,
configStore: observedConfigStore,
logger,
createRuntime: async ({ botId, config: accountConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
api,
config: accountConfig,
token,
harness,
state,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
maxMessageChars: config.maxMessageChars ?? 4_000,
logger: {
@ -91,15 +110,16 @@ export async function createProductionController(ctx, config = {}, internals = {
stateStores.delete(botId);
if (state && typeof state.remove === 'function') {
await state.remove();
return;
}
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,5 +1,10 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import {
publicWorkspaceError,
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from '../shared/workspace-rpc.mjs';
export const WEIXIN_RPC_CHANNEL = '/weixin';
export const WEIXIN_ENDPOINTS = Object.freeze({
@ -10,6 +15,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
export const WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS));
@ -58,6 +64,10 @@ function payloadFailure(endpoint, payload) {
? null
: 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === WEIXIN_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown Weixin endpoint.';
}
@ -156,12 +166,21 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
if (!value) return badRequest('The provisioning attempt no longer exists.');
} else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === WEIXIN_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}
return signal?.aborted ? cancelled() : { ok: true, value };
} catch {
return signal?.aborted ? cancelled() : internalFailure();
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted ? cancelled() : workspaceError
? { ok: false, error: workspaceError }
: internalFailure();
}
};
}

View file

@ -8,6 +8,12 @@ import { WhatsappStateStore } from '../../../../src/channels/whatsapp/state-stor
import { WhatsappController } from '../../../../src/channels/whatsapp/whatsapp-controller.mjs';
import { WhatsappRuntime } from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
@ -32,6 +38,7 @@ function pluginPaths(config) {
return {
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
authPath,
};
}
@ -49,6 +56,16 @@ export async function createProductionController(ctx, config = {}, internals = {
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const defaultWorkspace = resolve(config.workspace ?? process.cwd());
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
const workspaces = internals.workspaces
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
const stateFor = async (botId) => {
@ -61,31 +78,36 @@ export async function createProductionController(ctx, config = {}, internals = {
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
configStore,
const coreController = new Controller({
configStore: observedConfigStore,
authPath: paths.authPath,
createSession,
logger,
createRuntime: async ({ botId, config: botConfig, authDir }) => new Runtime({
config: botConfig,
authDir,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
createSession,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
createRuntime: async ({ botId, config: botConfig, authDir }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
authDir,
harness: workspaceScope.harness,
state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
createSession,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
});
},
deleteAuth: (authDirectory) => rm(paths.authPath(authDirectory), {
recursive: true,
force: true,
@ -93,14 +115,18 @@ export async function createProductionController(ctx, config = {}, internals = {
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
if (state && typeof state.remove === 'function') {
await state.remove();
} else {
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const supervisor = createSupervisor({
channel: 'whatsapp',
controller,

View file

@ -1,6 +1,7 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
export const WHATSAPP_ENDPOINTS = Object.freeze({
@ -10,6 +11,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
});
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
@ -44,6 +46,10 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
return 'Unknown WhatsApp endpoint.';
}
@ -114,16 +120,24 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value };
} catch {
} catch (error) {
const workspaceError = publicWorkspaceError(error);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: false, error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
: { ok: false, error: workspaceError
?? { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
}
};
}

View file

@ -3,6 +3,8 @@ import {
splitDingtalkText,
} from './dingtalk-api.mjs';
import { createDingTalkCardStream } from './dingtalk-card-stream.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@ -12,6 +14,7 @@ const HELP_TEXT = [
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -213,12 +216,12 @@ export class DingtalkHarnessBridge {
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
return;
}
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId, { signal: this.#signal }))) {
sessionId = await this.#harness.createSession({ signal: this.#signal });
await this.#state.setSession(key, sessionId);
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(sessionWebhook, workspaceCommand.message);
return;
}
if (typeof this.#api.createAiCard === 'function'
&& typeof this.#api.updateAiCard === 'function'
&& typeof this.#api.finishAiCard === 'function') {
@ -232,12 +235,20 @@ export class DingtalkHarnessBridge {
});
cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
}
const answer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
onUpdate: cardStarted
? (update) => cardStream.push(progressText(update))
: undefined,
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
onUpdate: cardStarted
? (update) => cardStream.push(progressText(update))
: undefined,
},
});
const streamed = cardStarted && await cardStream.finish(answer);
if (!streamed) await this.#send(sessionWebhook, answer);

View file

@ -610,7 +610,12 @@ export class DingtalkController {
await this.#stopRuntime(identity.botId);
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
else if (this.#configStore.get(identity.botId)) {
await this.#configStore.remove(identity.botId).catch(() => undefined);
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', cleanupError);
});
}
}
await this.#restoreCredential(identity.secretRef, previousSecret);
if (previousConfig && cleanString(previousSecret?.value)) {

View file

@ -217,10 +217,11 @@ export class HarnessClient {
}
async workspaceId(options = {}) {
const { items } = await this.rpc('workspace.list', {}, 30_000, options);
const existing = items.find((item) => item.path === this.#workspace);
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
const existing = items.find((item) => item.path === workspace);
if (existing) return existing.workspaceId;
const created = await this.rpc('workspace.create', { path: this.#workspace }, 30_000, options);
const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
return created.workspace.workspaceId;
}

View file

@ -117,6 +117,11 @@ export class DingtalkStateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
const id = nonEmptyString(messageId);
return Boolean(id && this.#state.seenMessageIds.includes(id));

View file

@ -5,12 +5,15 @@ import {
isBotSender,
splitText,
} from './message-utils.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
const HELP_TEXT = [
'北汇星河 AIOS 已连接 DeepSeek Harness。',
'',
'直接发送问题即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -106,25 +109,30 @@ export class FeishuHarnessBridge {
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
return;
}
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(key, sessionId);
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(event.message.chat_id, workspaceCommand.message);
return;
}
console.info(`[bridge] processing ${event.message.chat_type} message ${messageId} in ${sessionId}`);
await this.#answerWithStream(event, sessionId, text);
console.info(`[bridge] processing ${event.message.chat_type} message ${messageId}`);
await this.#answerWithStream(event, key, text);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
}
async #answerWithStream(event, sessionId, text) {
async #answerWithStream(event, key, text) {
const chatId = event.message.chat_id;
const messageId = event.message.message_id;
if (!this.#channel?.stream) {
const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: { timeoutMs: this.#replyTimeoutMs },
});
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return;
@ -136,13 +144,19 @@ export class FeishuHarnessBridge {
await this.#channel.stream(chatId, {
markdown: async (controller) => {
promptStarted = true;
completedAnswer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
onUpdate: async (update) => {
await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
({ answer: completedAnswer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: {
timeoutMs: this.#replyTimeoutMs,
onUpdate: async (update) => {
await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
},
},
});
}));
await controller.setContent(completedAnswer);
},
}, { replyTo: messageId });
@ -158,7 +172,13 @@ export class FeishuHarnessBridge {
if (promptStarted) throw error;
console.warn('[bridge] native Feishu stream unavailable; using text fallback:', error.message);
const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: { timeoutMs: this.#replyTimeoutMs },
});
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
}

View file

@ -197,17 +197,18 @@ export class HarnessClient {
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
}
async workspaceId() {
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});
const existing = items.find((item) => item.path === this.#workspace);
const existing = items.find((item) => item.path === workspace);
if (existing) return existing.workspaceId;
const created = await this.rpc('workspace.create', { path: this.#workspace });
const created = await this.rpc('workspace.create', { path: workspace });
return created.workspace.workspaceId;
}
async createSession() {
async createSession(options = {}) {
await this.ensureRunning();
const workspaceId = await this.workspaceId();
const workspaceId = await this.workspaceId(options);
const created = await this.rpc('session.create', {
workspaceId,
agentPreset: this.#agentPreset,

View file

@ -41,6 +41,11 @@ export class StateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}

View file

@ -1,8 +1,12 @@
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
const HELP_TEXT = [
'QQ 机器人已连接 DeepSeek Harness。',
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -121,11 +125,11 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(key, sessionId);
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#bot.sendText(target, workspaceCommand.message);
await this.#state.markSeen(messageId);
return;
}
let stream = null;
@ -137,16 +141,22 @@ export class QqHarnessBridge {
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
}
}
const answer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
onUpdate: stream ? async (update) => {
const progress = update.type === 'text'
? update.text
: update.type === 'tool'
? `正在使用${update.name}…`
: update.text;
if (progress) await stream.update(progress);
} : undefined,
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: {
timeoutMs: this.#replyTimeoutMs,
onUpdate: stream ? async (update) => {
const progress = update.type === 'text'
? update.text
: update.type === 'tool'
? `正在使用${update.name}…`
: update.text;
if (progress) await stream.update(progress);
} : undefined,
},
});
if (stream) {
try {

View file

@ -388,7 +388,14 @@ export class QqController {
if (record.controller.signal.aborted) {
await this.#stopRuntime(identity.botId);
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
else await this.#configStore.remove(identity.botId).catch(() => undefined);
else {
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-im:qq] cancelled bot state cleanup failed:', cleanupError);
});
}
}
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}

View file

@ -56,6 +56,11 @@ export class QqStateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}

View file

@ -0,0 +1,584 @@
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
function botIdOf(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
throw new TypeError('Invalid bot id');
}
return value;
}
function normalizeDocument(value) {
if (!value || value.version !== 1 || !value.workspaces
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
const workspaces = {};
for (const [botId, workspace] of Object.entries(value.workspaces)) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)
|| typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
workspaces[botId] = resolve(workspace);
}
return { version: 1, workspaces };
}
export async function validateWorkspacePath(value) {
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
const error = new Error('工作区必须是绝对路径。');
error.code = 'workspace-not-absolute';
throw error;
}
const workspace = resolve(value.trim());
let info;
try {
info = await stat(workspace);
} catch (cause) {
const error = new Error('工作区路径不存在。', { cause });
error.code = 'workspace-not-found';
throw error;
}
if (!info.isDirectory()) {
const error = new Error('工作区路径必须指向一个目录。');
error.code = 'workspace-not-directory';
throw error;
}
return workspace;
}
export class BotWorkspaceStore {
#path;
#defaultWorkspace;
#workspaces = {};
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
#nextIncarnation = 1;
#removals = new Map();
#removalDetails = new WeakMap();
#dirtyRemovals = new Set();
#writeQueue = Promise.resolve();
#botQueues = new Map();
constructor(path, { defaultWorkspace = process.cwd() } = {}) {
if (typeof path !== 'string' || !path) throw new TypeError('workspace store path is required');
this.#path = path;
this.#defaultWorkspace = resolve(defaultWorkspace);
}
async load() {
try {
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im workspace config is invalid');
this.#workspaces = normalized.workspaces;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#workspaces = {};
}
this.#generations.clear();
this.#nextGeneration = 1;
this.#incarnations.clear();
this.#nextIncarnation = 1;
this.#removals.clear();
this.#dirtyRemovals.clear();
for (const botId of Object.keys(this.#workspaces)) {
this.#generations.set(botId, this.#freshGeneration());
this.#incarnations.set(botId, this.#freshIncarnation());
}
return this;
}
has(botId) {
const id = botIdOf(botId);
return Object.hasOwn(this.#workspaces, id) && !this.#removals.has(id);
}
incarnationFor(botId) {
return this.#incarnations.get(botIdOf(botId)) ?? null;
}
workspaceFor(botId) {
return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
}
generationFor(botId) {
return this.#generations.get(botIdOf(botId)) ?? null;
}
async whenIdle() {
await this.#writeQueue;
}
async whenBotIdle(botId) {
const id = botIdOf(botId);
while (true) {
const pending = this.#botQueues.get(id);
if (!pending) return;
await pending;
if (this.#botQueues.get(id) === pending) return;
}
}
async ensure(botId, { workspace = this.#defaultWorkspace } = {}) {
const id = botIdOf(botId);
const initialWorkspace = resolve(workspace);
return this.#enqueue(id, async () => {
if (!this.#workspaces[id]) {
this.#workspaces[id] = initialWorkspace;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
try {
await this.#persist();
} catch (error) {
delete this.#workspaces[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
throw error;
}
} else if (!this.#generations.has(id)) {
this.#generations.set(id, this.#freshGeneration());
}
return this.#workspaces[id];
});
}
async setWorkspace(botId, value, { clearSessions, incarnation } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const workspace = await validateWorkspacePath(value);
return this.#enqueue(id, async () => {
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
if (workspace === this.workspaceFor(id)) return workspace;
const previous = this.#workspaces[id];
// Advance first so a session creation that started before this queued
// transition can never be written back after the clear.
this.#generations.set(id, this.#freshGeneration());
// Clear the old session mapping before publishing the new workspace.
// A crash can then lose conversation continuity, but can never pair the
// new workspace with sessions created in the old one.
await clearSessions?.();
this.#workspaces[id] = workspace;
try {
await this.#persist();
} catch (error) {
this.#workspaces[id] = previous;
throw error;
}
return workspace;
});
}
async invalidateSessions(botId, { clearSessions } = {}) {
const id = botIdOf(botId);
return this.#enqueue(id, async () => {
this.#generations.set(id, this.#freshGeneration());
await clearSessions?.();
});
}
/** Fence one lifecycle and return the opaque token required to abort/finish it. */
async beginRemoval(botId, { clearSessions } = {}) {
const id = botIdOf(botId);
return this.#enqueue(id, async () => {
const existing = this.#removals.get(id);
if (existing) return existing;
const transaction = Object.freeze({});
this.#removals.set(id, transaction);
this.#removalDetails.set(transaction, {
botId: id,
incarnation: this.incarnationFor(id),
});
this.#generations.set(id, this.#freshGeneration());
try {
await clearSessions?.();
} catch (error) {
if (this.#removals.get(id) === transaction) this.#removals.delete(id);
throw error;
}
return transaction;
});
}
/** Re-open only the lifecycle represented by transaction; stale tokens are no-ops. */
async abortRemoval(transaction) {
const { botId: id } = this.#removalDetailsFor(transaction);
return this.#enqueue(id, async () => {
if (this.#removals.get(id) !== transaction) return false;
this.#removals.delete(id);
if (Object.hasOwn(this.#workspaces, id)) {
this.#generations.set(id, this.#freshGeneration());
if (!this.#incarnations.has(id)) {
this.#incarnations.set(id, this.#freshIncarnation());
}
}
return true;
});
}
/** Retire only the lifecycle represented by transaction; stale tokens are no-ops. */
async finishRemoval(transaction) {
const { botId: id, incarnation } = this.#removalDetailsFor(transaction);
return this.#enqueue(id, async () => {
if (this.#removals.get(id) !== transaction) {
return { removed: false, persisted: true, error: null, stale: true };
}
if (this.incarnationFor(id) !== incarnation) {
this.#removals.delete(id);
return { removed: false, persisted: true, error: null, stale: true };
}
this.#removals.delete(id);
return this.#retireCurrentIncarnation(id);
});
}
/** Commit the workspace lifecycle after the config store durably removed a bot. */
async retireAfterConfigCommit(botId) {
const id = botIdOf(botId);
return this.#enqueue(id, async () => {
this.#removals.delete(id);
return this.#retireCurrentIncarnation(id);
});
}
async remove(botId) {
const result = await this.retireAfterConfigCommit(botId);
if (result.error) throw result.error;
return result.removed;
}
async reconcile(activeBotIds) {
const active = new Set([...activeBotIds].map(botIdOf));
const candidates = new Set([...Object.keys(this.#workspaces), ...this.#dirtyRemovals]);
for (const botId of candidates) {
if (!active.has(botId)) await this.remove(botId);
}
}
decorateStatus(status) {
if (!status || typeof status !== 'object' || !Array.isArray(status.bots)) return status;
return {
...status,
bots: status.bots.map((bot) => bot?.botId
? { ...bot, workspace: this.workspaceFor(bot.botId) }
: bot),
};
}
#freshGeneration() {
const generation = this.#nextGeneration;
this.#nextGeneration += 1;
return generation;
}
#freshIncarnation() {
const incarnation = this.#nextIncarnation;
this.#nextIncarnation += 1;
return incarnation;
}
#removalDetailsFor(transaction) {
if (!transaction || typeof transaction !== 'object') {
throw new TypeError('Invalid workspace removal transaction');
}
const details = this.#removalDetails.get(transaction);
if (!details) throw new TypeError('Invalid workspace removal transaction');
return details;
}
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const needsCleanup = hadWorkspace || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
removed: false, persisted: true, error: null, stale: false,
};
try {
await this.#persistCurrentDocument();
return {
removed: hadWorkspace, persisted: true, error: null, stale: false,
};
} catch (error) {
this.#dirtyRemovals.add(id);
return {
removed: hadWorkspace, persisted: false, error, stale: false,
};
}
}
async #enqueue(botId, operation) {
const queued = this.#writeQueue.then(operation, operation);
const settled = queued.then(() => undefined, () => undefined);
this.#writeQueue = settled;
this.#botQueues.set(botId, settled);
void settled.finally(() => {
if (this.#botQueues.get(botId) === settled) this.#botQueues.delete(botId);
});
return queued;
}
async #persist() {
const document = { version: 1, workspaces: this.#workspaces };
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);
this.#dirtyRemovals.clear();
}
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0) {
await this.#persist();
return;
}
try {
await unlink(this.#path);
this.#dirtyRemovals.clear();
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#dirtyRemovals.clear();
}
}
}
function decorateResult(workspaces, result) {
return result && typeof result.then === 'function'
? result.then((value) => workspaces.decorateStatus(value))
: workspaces.decorateStatus(result);
}
function targetStatus(controller) {
return Promise.resolve(controller.status());
}
/** Observe the config store's durable removal commit without changing its API. */
export function observeBotWorkspaceRemovals(
configStore,
{ workspaces, method = 'remove', botIdFromRemoved = (removed) => removed?.botId },
) {
if (!configStore || !workspaces || typeof configStore[method] !== 'function') {
throw new TypeError('configStore removal observer dependencies are required');
}
return new Proxy(configStore, {
get(target, property) {
const value = Reflect.get(target, property, target);
if (property === method) {
return async (...args) => {
const removed = await value.apply(target, args);
const botId = removed ? botIdFromRemoved(removed, args) : null;
if (botId) await workspaces.retireAfterConfigCommit(botId);
return removed;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
if (!harness || !workspaces || !state) throw new TypeError('harness, workspaces, and state are required');
const incarnation = workspaces.incarnationFor(botId);
const isCurrentScope = () => workspaces.has(botId)
&& workspaces.incarnationFor(botId) === incarnation;
const sessionGenerations = new Map();
const scopedHarness = new Proxy(harness, {
get(target, property) {
if (property === 'currentWorkspace') return () => workspaces.workspaceFor(botId);
if (property === 'switchWorkspace') {
return (workspace) => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
return Promise.reject(error);
}
return workspaces.setWorkspace(botId, workspace, {
clearSessions: () => state.clearSessions(),
incarnation,
});
};
}
if (property === 'createSession') {
return async (options = {}) => {
await workspaces.whenBotIdle(botId);
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const generation = workspaces.generationFor(botId);
const sessionId = await target.createSession({
...options,
workspace: workspaces.workspaceFor(botId),
});
sessionGenerations.set(sessionId, generation);
return sessionId;
};
}
if (property === 'sessionExists') {
return (sessionId, ...args) => {
if (!isCurrentScope()) return false;
const generation = sessionGenerations.get(sessionId);
if (generation !== undefined && generation !== workspaces.generationFor(botId)) {
sessionGenerations.delete(sessionId);
return false;
}
return target.sessionExists(sessionId, ...args);
};
}
if (property === 'ask') {
return (sessionId, ...args) => {
const generation = sessionGenerations.get(sessionId);
sessionGenerations.delete(sessionId);
if (!isCurrentScope()
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
const error = new Error('The bot workspace changed before this prompt started.');
error.code = WORKSPACE_SESSION_STALE;
throw error;
}
return target.ask(sessionId, ...args);
};
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const scopedState = new Proxy(state, {
get(target, property) {
if (property === 'sessionFor') {
return (key, ...args) => {
if (!isCurrentScope()) return null;
const sessionId = target.sessionFor(key, ...args);
if (sessionId && !sessionGenerations.has(sessionId)) {
sessionGenerations.set(sessionId, workspaces.generationFor(botId));
}
return sessionId;
};
}
if (property === 'setSession') {
return (key, sessionId, ...args) => {
const generation = sessionGenerations.get(sessionId);
if (!isCurrentScope()
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
sessionGenerations.delete(sessionId);
return false;
}
return target.setSession(key, sessionId, ...args);
};
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
return Object.freeze({ harness: scopedHarness, state: scopedState });
}
export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness;
}
export function createWorkspaceAwareController(controller, { workspaces, stateFor }) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required');
}
const transitions = new Map();
const withBotTransition = (botId, operation) => {
const previous = transitions.get(botId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
transitions.set(botId, current);
return current.finally(() => {
if (transitions.get(botId) === current) transitions.delete(botId);
});
};
const updateWorkspace = (botId, workspace) => {
// Capture at API invocation, before even waiting for an older outer
// transition. A queued request still belongs to the incarnation that the
// caller observed, not a deterministic same-id rebind that appears later.
const incarnation = workspaces.incarnationFor(botId);
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 state = await stateFor(botId);
await workspaces.setWorkspace(botId, workspace, {
clearSessions: () => state.clearSessions(),
incarnation,
});
return workspaces.decorateStatus(await controller.status());
});
};
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
// workspace, while a crash after that commit is healed by startup
// reconciliation.
const removal = await workspaces.beginRemoval(botId, {
clearSessions: async () => {
try {
const state = await stateFor(botId);
if (!state || typeof state.clearSessions !== 'function') {
throw new TypeError('bot state does not support session cleanup');
}
await state.clearSessions();
} catch (error) {
console.warn(
`[dsh-im] ignored session cleanup failure while deleting bot ${botId}:`,
error?.message ?? error,
);
}
},
});
try {
const result = await invokeDelete();
await workspaces.finishRemoval(removal);
return workspaces.decorateStatus(result);
} catch (error) {
const after = await targetStatus(controller).catch(() => null);
const knownAbsent = Array.isArray(after?.bots)
&& !after.bots.some((bot) => bot?.botId === botId);
if (knownAbsent) await workspaces.finishRemoval(removal);
else await workspaces.abortRemoval(removal);
throw error;
}
});
return new Proxy(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {
return (botId, ...args) => deleteWithWorkspace(
botId,
() => value.call(target, botId, ...args),
);
}
if (property === 'disconnect') {
return async (...args) => {
const before = await target.status();
const botId = before?.bots?.[0]?.botId;
if (!botId) return decorateResult(workspaces, value.apply(target, args));
return deleteWithWorkspace(botId, () => value.apply(target, args));
};
}
return (...args) => decorateResult(workspaces, value.apply(target, args));
},
});
}

View file

@ -57,6 +57,11 @@ export class ConversationStateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}

View file

@ -1,3 +1,6 @@
import { runWorkspaceCommand } from './workspace-command.mjs';
import { askInWorkspaceSession } from './workspace-session.mjs';
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
@ -97,6 +100,7 @@ export class TextHarnessBridge {
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n'));
@ -109,6 +113,12 @@ export class TextHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#bot.sendText(target, workspaceCommand.message);
await this.#state.markSeen(messageId);
return;
}
const conversationKey = `${message.kind}:${message.conversationId}`;
if (command === '/new') {
await this.#state.clearSession(conversationKey);
@ -117,12 +127,6 @@ export class TextHarnessBridge {
return;
}
let sessionId = this.#state.sessionFor(conversationKey);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(conversationKey, sessionId);
}
await this.#bot.sendTyping?.(target).catch((error) => {
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
});
@ -138,13 +142,19 @@ export class TextHarnessBridge {
);
}
}
const answer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
onUpdate: stream ? async (update) => {
const progress = update.type === 'text' ? update.text
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
if (progress) await stream.update(progress);
} : undefined,
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key: conversationKey,
text,
askOptions: {
timeoutMs: this.#replyTimeoutMs,
onUpdate: stream ? async (update) => {
const progress = update.type === 'text' ? update.text
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
if (progress) await stream.update(progress);
} : undefined,
},
});
if (stream) {
try {

View file

@ -0,0 +1,26 @@
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
export async function runWorkspaceCommand(text, harness) {
if (typeof text !== 'string') return null;
const match = WORKSPACE_COMMAND.exec(text.trim());
if (!match) return null;
const workspace = match[1]?.trim();
if (!workspace) {
return { handled: true, message: '用法:/workspace 工作区绝对路径' };
}
if (typeof harness?.switchWorkspace !== 'function') {
return { handled: true, message: '当前机器人暂不支持切换工作区。' };
}
try {
const current = await harness.switchWorkspace(workspace);
return { handled: true, message: `工作区已切换为:${current}` };
} catch (error) {
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
return { handled: true, message: `${error.message}\n用法:/workspace 工作区绝对路径` };
}
if (error?.code === 'workspace-bot-not-found') {
return { handled: true, message: '机器人正在移除或已重新接入,无法切换原会话的工作区。' };
}
throw error;
}
}

View file

@ -0,0 +1,44 @@
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
async function sessionExists(harness, sessionId, options) {
return options === undefined
? harness.sessionExists(sessionId)
: harness.sessionExists(sessionId, options);
}
async function createSession(harness, options) {
return options === undefined
? harness.createSession()
: harness.createSession(options);
}
/**
* Resolve, persist, and ask through a session that belongs to the bot's
* current workspace. A concurrent workspace switch invalidates the scoped
* session and retries before any prompt is sent to the stale session.
*/
export async function askInWorkspaceSession({
harness,
state,
key,
text,
createOptions,
existsOptions,
askOptions,
}) {
while (true) {
let sessionId = state.sessionFor(key);
if (!sessionId || !(await sessionExists(harness, sessionId, existsOptions))) {
sessionId = await createSession(harness, createOptions);
if (await state.setSession(key, sessionId) === false) continue;
}
try {
return {
sessionId,
answer: await harness.ask(sessionId, text, askOptions),
};
} catch (error) {
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
}
}
}

View file

@ -54,6 +54,11 @@ export class WecomStateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}

View file

@ -1,10 +1,13 @@
import { generateReqId } from '@wecom/aibot-node-sdk';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
const HELP_TEXT = [
'企业微信机器人已连接 DeepSeek Harness。',
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -182,11 +185,11 @@ export class WecomHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(key, sessionId);
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#sendImmediate(frame, chatId, workspaceCommand.message);
await this.#state.markSeen(messageId);
return;
}
streamId = this.#generateReqId('stream');
@ -197,14 +200,20 @@ export class WecomHarnessBridge {
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
}
const answer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
? async (update) => {
const progress = splitUtf8(progressText(update))[0];
if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
}
: undefined,
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: {
timeoutMs: this.#replyTimeoutMs,
onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
? async (update) => {
const progress = splitUtf8(progressText(update))[0];
if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
}
: undefined,
},
});
const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。');

View file

@ -400,7 +400,14 @@ export class WecomController {
if (record.controller.signal.aborted) {
await this.#stopRuntime(identity.botId);
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
else await this.#configStore.remove(identity.botId).catch(() => undefined);
else {
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-im:wecom] cancelled bot state cleanup failed:', cleanupError);
});
}
}
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}

View file

@ -187,17 +187,18 @@ export class HarnessClient {
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
}
async workspaceId() {
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});
const existing = items.find((item) => item.path === this.#workspace);
const existing = items.find((item) => item.path === workspace);
if (existing) return existing.workspaceId;
const created = await this.rpc('workspace.create', { path: this.#workspace });
const created = await this.rpc('workspace.create', { path: workspace });
return created.workspace.workspaceId;
}
async createSession() {
async createSession(options = {}) {
await this.ensureRunning();
const workspaceId = await this.workspaceId();
const workspaceId = await this.workspaceId(options);
const created = await this.rpc('session.create', {
workspaceId,
agentPreset: this.#agentPreset,

View file

@ -62,6 +62,11 @@ export class WeixinStateStore {
await this.#persist();
}
async clearSessions() {
this.#state.sessions = {};
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}

View file

@ -3,12 +3,15 @@ import {
splitWeixinText,
weixinMessageId,
} from './weixin-api.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
const HELP_TEXT = [
'微信已连接 DeepSeek Harness。',
'',
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -133,14 +136,21 @@ export class WeixinHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(sender, workspaceCommand.message, contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const key = conversationKey(sender);
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(key, sessionId);
}
const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
const { answer } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
text,
askOptions: { timeoutMs: this.#replyTimeoutMs },
});
await this.#send(sender, answer, contextToken, runId);
await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;

View file

@ -468,7 +468,12 @@ export class WeixinController {
await this.#stopRuntime(identity.botId);
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
else if (this.#configStore.get(identity.botId)) {
await this.#configStore.remove(identity.botId).catch(() => undefined);
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-weixin] failed to clean up cancelled bot state:', cleanupError);
});
}
}
await this.#restoreCredential(identity.tokenRef, previousToken);
if (previousConfig && previousToken?.value) {

View file

@ -311,7 +311,14 @@ export class WhatsappController {
if (record.controller.signal.aborted || this.#closed || error?.name === 'AbortError') {
await this.#stopRuntime(botId);
if (previous) await this.#configStore.save(previous).catch(() => undefined);
else await this.#configStore.remove(botId).catch(() => undefined);
else {
const removed = await this.#configStore.remove(botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-im:whatsapp] cancelled bot state cleanup failed:', cleanupError);
});
}
}
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
if (previous) await this.#startRuntime(previous).catch(() => undefined);
record.state = 'cancelled';

View file

@ -22,6 +22,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
});

View file

@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js';
import { DiscordSettingsTab } from '../plugin-src/client/channels/discord/index.js';
const { act, create } = TestRenderer;
function submitEvent() {
return { preventDefault() {} };
}
function deferred() {
let resolve;
const promise = new Promise((onResolve) => { resolve = onResolve; });
return { promise, resolve };
}
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function discordSnapshot(workspace) {
return {
revision: 1,
bots: [{
botId: 'discord_test',
connected: true,
state: 'connected',
workspace,
bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' },
health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
}],
};
}
function twoBotDiscordSnapshot(firstWorkspace) {
const first = discordSnapshot(firstWorkspace).bots[0];
return {
revision: 1,
bots: [
{ ...first, botId: 'discord_first', bot: { ...first.bot, name: 'First Bot' } },
{
...first,
botId: 'discord_second',
workspace: '/workspace/second',
bot: { ...first.bot, name: 'Second Bot' },
},
],
};
}
test('WorkspaceEditor shows the current path and saves an edited absolute path', async () => {
const saved = [];
function Fixture() {
const [workspace, setWorkspace] = React.useState('/workspace/current');
return React.createElement(WorkspaceEditor, {
workspace,
async onSave(value) {
saved.push(value);
setWorkspace(value);
},
});
}
let renderer;
await act(async () => { renderer = create(React.createElement(Fixture)); });
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current');
await act(async () => {
renderer.root.findByType('button').props.onClick();
});
const input = renderer.root.findByType('input');
assert.equal(input.props.value, '/workspace/current');
assert.equal(input.props.maxLength, 4_096);
await act(async () => {
input.props.onChange({ target: { value: ' /workspace/next project ' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.deepEqual(saved, ['/workspace/next project']);
assert.equal(renderer.root.findByType('code').props.title, '/workspace/next project');
});
test('WorkspaceEditor keeps editing and presents a rejected workspace error', async () => {
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() {
const error = new Error('工作区路径不存在。');
error.code = 'workspace-not-found';
throw error;
},
}));
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/missing' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区路径不存在。');
assert.equal(renderer.root.findByType('input').props.value, '/workspace/missing');
});
test('WorkspaceEditor rejects a relative path before calling the Host', async () => {
let saves = 0;
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() { saves += 1; },
}));
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: 'relative/path' } });
});
await act(async () => {
await renderer.root.findByType('form').props.onSubmit(submitEvent());
});
assert.equal(saves, 0);
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区必须是绝对路径。');
});
test('WorkspaceEditor moves keyboard focus into and back out of edit mode', async () => {
let inputFocus = 0;
let editFocus = 0;
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
async onSave() {},
}), {
createNodeMock(element) {
if (element.type === 'input') return { focus() { inputFocus += 1; } };
if (element.props?.className === 'dim-workspaceEdit') {
return { focus() { editFocus += 1; } };
}
return {};
},
});
});
await act(async () => { renderer.root.findByType('button').props.onClick(); });
assert.equal(inputFocus, 1);
const cancel = renderer.root.findAllByType('button')
.find((button) => button.children.join('') === '取消');
await act(async () => { cancel.props.onClick(); });
assert.equal(editFocus, 1);
});
test('a status response started before saving cannot restore the old workspace', async (t) => {
const previousWindow = globalThis.window;
let intervalCallback;
globalThis.window = {
setInterval(callback) { intervalCallback = callback; return 1; },
clearInterval() {},
};
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleStatus = deferred();
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
if (statusCalls === 1) return { ok: true, value: discordSnapshot('/workspace/current') };
if (statusCalls === 2) return staleStatus.promise;
return { ok: true, value: discordSnapshot('/workspace/new') };
}
if (endpoint === 'bot.workspace.set') {
return { ok: true, value: discordSnapshot('/workspace/new') };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
intervalCallback();
await flushMicrotasks();
});
await act(async () => {
renderer.root.findAllByType('button')
.find((button) => button.children.join('') === '修改').props.onClick();
});
await act(async () => {
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
await renderer.root.findByType('form').props.onSubmit(submitEvent());
await flushMicrotasks();
});
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
await act(async () => {
staleStatus.resolve({ ok: true, value: discordSnapshot('/workspace/old') });
await flushMicrotasks();
});
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
await act(async () => { renderer.unmount(); });
});
test('an older reconnect snapshot from another bot cannot restore a saved workspace', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleReconnect = deferred();
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
return {
ok: true,
value: twoBotDiscordSnapshot(statusCalls === 1 ? '/workspace/current' : '/workspace/new'),
};
}
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
if (endpoint === 'bot.workspace.set') {
return { ok: true, value: twoBotDiscordSnapshot('/workspace/new') };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
await act(async () => {
secondCard.findAllByType('button')
.find((button) => button.children.join('') === '检查连接').props.onClick();
await flushMicrotasks();
});
await act(async () => {
firstCard.findAllByType('button')
.find((button) => button.children.join('') === '修改').props.onClick();
});
await act(async () => {
firstCard.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
await firstCard.findByType('form').props.onSubmit(submitEvent());
await flushMicrotasks();
});
staleReconnect.resolve({ ok: true, value: twoBotDiscordSnapshot('/workspace/old') });
await act(async () => { await flushMicrotasks(); });
assert.equal(
renderer.root.findByProps({ 'data-bot-id': 'discord_first' }).findByType('code').props.title,
'/workspace/new',
);
await act(async () => { renderer.unmount(); });
});
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const staleReconnect = deferred();
const initialSnapshot = twoBotDiscordSnapshot('/workspace/first');
const deletedSnapshot = { ...initialSnapshot, bots: initialSnapshot.bots.slice(1) };
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') {
statusCalls += 1;
return { ok: true, value: statusCalls === 1 ? initialSnapshot : deletedSnapshot };
}
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
if (endpoint === 'bot.delete') return { ok: true, value: deletedSnapshot };
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
await act(async () => {
secondCard.findAllByType('button')
.find((button) => button.children.join('') === '检查连接').props.onClick();
await flushMicrotasks();
});
await act(async () => {
firstCard.findAllByType('button')
.find((button) => button.children.join('') === '移除接入').props.onClick();
});
await act(async () => {
await firstCard.findAllByType('button')
.find((button) => button.children.join('') === '确认移除接入').props.onClick();
await flushMicrotasks();
});
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
staleReconnect.resolve({ ok: true, value: initialSnapshot });
await act(async () => { await flushMicrotasks(); });
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
await act(async () => { renderer.unmount(); });
});

826
test/workspace.test.mjs Normal file
View file

@ -0,0 +1,826 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
BotWorkspaceStore,
createBotScopedHarness,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
import { HarnessClient as FeishuHarnessClient } from '../src/channels/feishu/harness-client.mjs';
import { HarnessClient as DingtalkHarnessClient } from '../src/channels/dingtalk/harness-client.mjs';
import { ConversationStateStore } from '../src/channels/shared/conversation-state-store.mjs';
import { WeixinStateStore } from '../src/channels/weixin/state-store.mjs';
import { StateStore as FeishuStateStore } from '../src/channels/feishu/state-store.mjs';
import { DingtalkStateStore } from '../src/channels/dingtalk/state-store.mjs';
import { WecomStateStore } from '../src/channels/wecom/state-store.mjs';
import { QqStateStore } from '../src/channels/qq/state-store.mjs';
import {
TOKEN_BOT_ENDPOINTS,
createTokenBotRpcHandler,
} from '../plugin-src/host/channels/shared/rpc.mjs';
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-workspace-'));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'default');
const alternateWorkspace = join(root, 'alternate workspace');
await Promise.all([
mkdir(defaultWorkspace),
mkdir(alternateWorkspace),
]);
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
}
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.equal(await store.ensure('bot_one'), defaultWorkspace);
assert.equal(await store.ensure('bot_two'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(store.workspaceFor('bot_two'), defaultWorkspace);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(reloaded.workspaceFor('bot_two'), defaultWorkspace);
});
test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspace', async (t) => {
const { root } = await fixture(t);
const store = await new BotWorkspaceStore(join(root, 'cwd-workspaces.json')).load();
assert.equal(await store.ensure('bot_cwd'), process.cwd());
});
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'workspace-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await store.ensure('bot_io');
await store.setWorkspace('bot_io', alternateWorkspace);
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'blocks workspace persistence');
let clears = 0;
await assert.rejects(store.setWorkspace('bot_io', defaultWorkspace, {
clearSessions: async () => { clears += 1; },
}));
assert.equal(clears, 1);
assert.equal(store.workspaceFor('bot_io'), alternateWorkspace);
await assert.rejects(store.remove('bot_io'));
assert.equal(store.has('bot_io'), false);
assert.equal(store.workspaceFor('bot_io'), defaultWorkspace);
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
const staleDisk = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
assert.equal(staleDisk.workspaceFor('bot_io'), alternateWorkspace);
await staleDisk.reconcile([]);
assert.equal(staleDisk.has('bot_io'), false);
const blockedParent = join(root, 'blocked-parent');
await writeFile(blockedParent, 'not a directory');
const broken = new BotWorkspaceStore(join(blockedParent, 'workspaces.json'), { defaultWorkspace });
await assert.rejects(broken.ensure('bot_new'));
assert.equal(broken.workspaceFor('bot_new'), defaultWorkspace);
});
test('workspace validation rejects relative, missing, and file paths', async (t) => {
const { root } = await fixture(t);
const file = join(root, 'file.txt');
await writeFile(file, 'not a directory');
await assert.rejects(validateWorkspacePath('relative/path'), { code: 'workspace-not-absolute' });
await assert.rejects(validateWorkspacePath(join(root, 'missing')), { code: 'workspace-not-found' });
await assert.rejects(validateWorkspacePath(file), { code: 'workspace-not-directory' });
});
test('bot-scoped Harness creates sessions in each bot workspace and switching clears sessions', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
let cleared = 0;
const state = { async clearSessions() { cleared += 1; } };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await one.switchWorkspace(alternateWorkspace);
await Promise.all([one.createSession(), two.createSession()]);
assert.equal(cleared, 1);
assert.deepEqual(calls.map((call) => call.workspace), [
defaultWorkspace,
alternateWorkspace,
defaultWorkspace,
]);
});
test('an old session cannot be written back while RPC switches the bot workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_race');
let finishCreation;
let existenceChecks = 0;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
async sessionExists() {
existenceChecks += 1;
return true;
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_race', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_race' }] }; },
}, {
workspaces,
stateFor: async () => state,
});
const oldSession = scope.harness.createSession();
await controller.updateWorkspace('bot_race', alternateWorkspace);
finishCreation('session-from-old-workspace');
const sessionId = await oldSession;
assert.equal(await scope.state.setSession('conversation', sessionId), false);
assert.equal(persistedSession, null);
const oldSessionForLookup = scope.harness.createSession();
await controller.updateWorkspace('bot_race', defaultWorkspace);
finishCreation('second-session-from-old-workspace');
assert.equal(await scope.harness.sessionExists(await oldSessionForLookup), false);
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
});
test('a prompt retries in the new workspace when switching after session creation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstSet;
let releaseFirstSet;
const firstSet = new Promise((resolveSet) => { markFirstSet = resolveSet; });
const firstSetGate = new Promise((resolveSet) => { releaseFirstSet = resolveSet; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return `answer-${sessionId}`; },
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) {
persistedSession = sessionId;
if (sessionId === 'session-1') {
markFirstSet();
await firstSetGate;
}
},
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_prompt', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const prompting = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'hello',
});
await firstSet;
await controller.updateWorkspace('bot_prompt', alternateWorkspace);
releaseFirstSet();
const result = await prompting;
assert.equal(result.answer, 'answer-session-2');
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, ['session-2']);
assert.equal(persistedSession, 'session-2');
});
test('a workspace switch lets an already-started reply finish and moves the next message', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_started_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstAskStarted;
let releaseFirstAsk;
const firstAskStarted = new Promise((resolveStarted) => { markFirstAskStarted = resolveStarted; });
const firstAskGate = new Promise((resolveAsk) => { releaseFirstAsk = resolveAsk; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId, text) {
asks.push({ sessionId, text });
if (asks.length === 1) {
markFirstAskStarted();
await firstAskGate;
}
return `answer-${sessionId}`;
},
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_started_prompt', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_started_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const first = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'first',
});
await firstAskStarted;
await controller.updateWorkspace('bot_started_prompt', alternateWorkspace);
assert.equal(workspaces.workspaceFor('bot_started_prompt'), alternateWorkspace);
releaseFirstAsk();
assert.deepEqual(await first, {
sessionId: 'session-1',
answer: 'answer-session-1',
});
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'second',
}), {
sessionId: 'session-2',
answer: 'answer-session-2',
});
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, [
{ sessionId: 'session-1', text: 'first' },
{ sessionId: 'session-2', text: 'second' },
]);
});
test('deleting and rebinding a bot cannot accept an old in-flight session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_rebind');
let finishCreation;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const oldScope = createBotWorkspaceScope(harness, { botId: 'bot_rebind', workspaces, state });
let bots = [{ botId: 'bot_rebind' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() { bots = []; return { bots }; },
}, { workspaces, stateFor: async () => state });
const oldSession = oldScope.harness.createSession();
await controller.deleteBot('bot_rebind');
await workspaces.ensure('bot_rebind');
finishCreation('session-before-delete');
assert.equal(await oldScope.state.setSession('conversation', await oldSession), false);
assert.equal(persistedSession, null);
await assert.rejects(oldScope.harness.switchWorkspace(alternateWorkspace), {
code: 'workspace-bot-not-found',
});
const reboundScope = createBotWorkspaceScope(harness, {
botId: 'bot_rebind', workspaces, state,
});
assert.equal(await reboundScope.harness.switchWorkspace(alternateWorkspace), alternateWorkspace);
});
test('a successful public delete clears sessions before a same-id rebind', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reused_id');
let persistedSession = 'old-session';
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
let bots = [{ botId: 'bot_reused_id' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
assert.equal(persistedSession, null, 'session cleanup precedes the config deletion');
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => state });
await controller.deleteBot('bot_reused_id');
await workspaces.ensure('bot_reused_id');
const asks = [];
const scope = createBotWorkspaceScope({
async createSession() { return 'new-session'; },
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return 'new-answer'; },
}, { botId: 'bot_reused_id', workspaces, state });
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'after rebind',
}), { sessionId: 'new-session', answer: 'new-answer' });
assert.deepEqual(asks, ['new-session']);
assert.equal(persistedSession, 'new-session');
});
test('session cleanup load or clear failures do not block public deletion', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const warnings = [];
t.mock.method(console, 'warn', (...args) => { warnings.push(args); });
for (const failure of ['load', 'clear']) {
const botId = `bot_cleanup_${failure}`;
await workspaces.ensure(botId);
let bots = [{ botId }];
let deletions = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
deletions += 1;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => {
if (failure === 'load') throw new Error('state load failed');
return { async clearSessions() { throw new Error('session clear failed'); } };
},
});
await controller.deleteBot(botId);
assert.equal(deletions, 1);
assert.equal(workspaces.has(botId), false);
}
assert.equal(warnings.length, 2);
assert.ok(warnings.every(([message]) => message.includes('ignored session cleanup failure')));
});
test('an old deletion transaction cannot retire a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_lifecycle');
await workspaces.setWorkspace('bot_lifecycle', alternateWorkspace);
const firstIncarnation = workspaces.incarnationFor('bot_lifecycle');
let bots = [{ botId: 'bot_lifecycle', lifecycle: 'old' }];
const observedStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot(botId) {
// The config commit retires the old lifecycle before the outer adapter
// resumes. Simulate a queued same-account provisioning completing in
// that gap and creating a new incarnation with the deterministic id.
await observedStore.remove(botId);
await workspaces.ensure(botId, { workspace: defaultWorkspace });
bots = [{ botId, lifecycle: 'rebound' }];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const result = await controller.deleteBot('bot_lifecycle');
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), defaultWorkspace);
assert.notEqual(workspaces.incarnationFor('bot_lifecycle'), firstIncarnation);
assert.equal(result.bots[0].workspace, defaultWorkspace);
const staleRemoval = await workspaces.beginRemoval('bot_lifecycle');
await workspaces.retireAfterConfigCommit('bot_lifecycle');
await workspaces.ensure('bot_lifecycle', { workspace: alternateWorkspace });
const latestIncarnation = workspaces.incarnationFor('bot_lifecycle');
assert.equal(await workspaces.abortRemoval(staleRemoval), false);
assert.equal((await workspaces.finishRemoval(staleRemoval)).stale, true);
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), alternateWorkspace);
assert.equal(workspaces.incarnationFor('bot_lifecycle'), latestIncarnation);
});
test('a workspace update for an old incarnation cannot mutate a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_update_aba');
let markStateRequested;
let releaseState;
const stateRequested = new Promise((resolveRequested) => { markStateRequested = resolveRequested; });
const stateGate = new Promise((resolveState) => { releaseState = resolveState; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_update_aba' }] }; },
}, {
workspaces,
stateFor: async () => {
markStateRequested();
await stateGate;
return { async clearSessions() { clears += 1; } };
},
});
const updating = controller.updateWorkspace('bot_update_aba', alternateWorkspace);
await stateRequested;
await workspaces.retireAfterConfigCommit('bot_update_aba');
await workspaces.ensure('bot_update_aba', { workspace: defaultWorkspace });
releaseState();
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.equal(clears, 0);
assert.equal(workspaces.has('bot_update_aba'), true);
assert.equal(workspaces.workspaceFor('bot_update_aba'), defaultWorkspace);
});
test('a blocked workspace switch for one bot does not block another bot session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_slow'), workspaces.ensure('bot_ready')]);
let markClearStarted;
let releaseClear;
const clearStarted = new Promise((resolveClear) => { markClearStarted = resolveClear; });
const clearGate = new Promise((resolveClear) => { releaseClear = resolveClear; });
const stateSlow = {
async clearSessions() { markClearStarted(); await clearGate; },
};
const stateReady = { async clearSessions() {} };
const created = [];
const harness = {
async createSession({ workspace }) { created.push(workspace); return 'ready-session'; },
};
const slow = createBotScopedHarness(harness, {
botId: 'bot_slow', workspaces, state: stateSlow,
});
const ready = createBotScopedHarness(harness, {
botId: 'bot_ready', workspaces, state: stateReady,
});
const switching = slow.switchWorkspace(alternateWorkspace);
await clearStarted;
assert.equal(await ready.createSession(), 'ready-session');
assert.deepEqual(created, [defaultWorkspace]);
releaseClear();
await switching;
});
test('clearing workspace sessions preserves message deduplication and channel cursors', async (t) => {
const { root } = await fixture(t);
const stores = [
['shared', await new ConversationStateStore(join(root, 'shared-state.json')).load()],
['weixin', await new WeixinStateStore(join(root, 'weixin-state.json')).load()],
['feishu', await new FeishuStateStore(join(root, 'feishu-state.json')).load()],
['dingtalk', await new DingtalkStateStore(join(root, 'dingtalk-state.json'), {
idFactory: () => 'request',
now: () => '2026-08-17T00:00:00.000Z',
}).load()],
['wecom', await new WecomStateStore(join(root, 'wecom-state.json')).load()],
['qq', await new QqStateStore(join(root, 'qq-state.json')).load()],
];
for (const [name, store] of stores) {
await store.setSession('conversation', `session-${name}`);
await store.markSeen(`message-${name}`);
}
await stores[0][1].setCursor(42);
await stores[1][1].setGetUpdatesBuf('next-weixin-cursor');
await stores[3][1].recordPendingSender('staff-one', 'User One');
for (const [name, store] of stores) {
await store.clearSessions();
assert.equal(store.sessionFor('conversation'), null, `${name} clears its Harness session`);
assert.equal(store.hasSeen(`message-${name}`), true, `${name} keeps message deduplication`);
}
assert.equal(stores[0][1].cursor(), 42);
assert.equal(stores[1][1].getUpdatesBuf(), 'next-weixin-cursor');
assert.equal(stores[3][1].pendingSenders().length, 1);
});
test('workspace-aware controller decorates status and updates one bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const cleared = [];
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_one' }, { botId: 'bot_two' }] }; },
async deleteBot(botId) { return { bots: [{ botId: botId === 'bot_one' ? 'bot_two' : 'bot_one' }] }; },
}, {
workspaces,
stateFor: async (botId) => ({ async clearSessions() { cleared.push(botId); } }),
});
const updated = await controller.updateWorkspace('bot_one', alternateWorkspace);
assert.equal(updated.bots[0].workspace, alternateWorkspace);
assert.equal(updated.bots[1].workspace, defaultWorkspace);
assert.deepEqual(cleared, ['bot_one']);
await assert.rejects(controller.updateWorkspace('missing_bot', alternateWorkspace), {
code: 'workspace-bot-not-found',
});
});
test('workspace updates serialize with deletion and cannot recreate a removed bot mapping', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_delete');
await workspaces.setWorkspace('bot_delete', alternateWorkspace);
let bots = [{ botId: 'bot_delete' }];
let releaseDelete;
let markDeleteStarted;
const deleteStarted = new Promise((resolveStarted) => { markDeleteStarted = resolveStarted; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => ({ async clearSessions() { clears += 1; } }),
});
const deleting = controller.deleteBot('bot_delete');
await deleteStarted;
const lateUpdate = controller.updateWorkspace('bot_delete', defaultWorkspace);
releaseDelete();
await deleting;
await assert.rejects(lateUpdate, { code: 'workspace-bot-not-found' });
assert.equal(workspaces.workspaceFor('bot_delete'), defaultWorkspace);
assert.equal(clears, 1);
});
test('workspace deletion keeps the durable path until the bot config commits', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_durable');
await workspaces.setWorkspace('bot_durable', alternateWorkspace);
let bots = [{ botId: 'bot_durable' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_durable');
await deleteStarted;
assert.equal(JSON.parse(await readFile(path, 'utf8')).workspaces.bot_durable, alternateWorkspace);
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_durable'), false);
});
test('a failed bot deletion aborts the fence without rewriting its workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_abort');
await workspaces.setWorkspace('bot_abort', alternateWorkspace);
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope({ async createSession() {} }, {
botId: 'bot_abort', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_abort' }] }; },
async deleteBot() { throw new Error('config removal failed'); },
}, { workspaces, stateFor: async () => state });
await assert.rejects(controller.deleteBot('bot_abort'), /config removal failed/);
assert.equal(workspaces.has('bot_abort'), true);
assert.equal(workspaces.workspaceFor('bot_abort'), alternateWorkspace);
assert.equal(await scope.harness.switchWorkspace(defaultWorkspace), defaultWorkspace);
});
test('a committed bot deletion stays retired when workspace cleanup persistence fails', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'delete-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await workspaces.ensure('bot_commit');
await workspaces.setWorkspace('bot_commit', alternateWorkspace);
let bots = [{ botId: 'bot_commit' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_commit');
await deleteStarted;
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'block cleanup persistence');
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_commit'), false);
await assert.rejects(workspaces.setWorkspace('bot_commit', defaultWorkspace), {
code: 'workspace-bot-not-found',
});
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
assert.equal(JSON.parse(await readFile(storePath, 'utf8')).workspaces.bot_commit, alternateWorkspace);
await workspaces.reconcile([]);
await assert.rejects(readFile(storePath, 'utf8'), { code: 'ENOENT' });
});
test('config-store removal observation retires workspaces after the config commit', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_remove'), workspaces.ensure('bot_feishu')]);
const tokenStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const feishuStore = observeBotWorkspaceRemovals({
async removeBot(id) { return { id }; },
}, {
workspaces,
method: 'removeBot',
botIdFromRemoved: (removed) => removed.id,
});
await tokenStore.remove('bot_remove');
await feishuStore.removeBot('bot_feishu');
assert.equal(workspaces.has('bot_remove'), false);
assert.equal(workspaces.has('bot_feishu'), false);
});
test('/workspace command preserves spaces and returns actionable validation messages', async (t) => {
const { alternateWorkspace } = await fixture(t);
const switched = [];
const harness = { async switchWorkspace(path) { switched.push(path); return path; } };
assert.equal(await runWorkspaceCommand('hello', harness), null);
assert.match((await runWorkspaceCommand('/workspace', harness)).message, /用法/);
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, harness)).message,
new RegExp(alternateWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
assert.deepEqual(switched, [alternateWorkspace]);
const invalidHarness = {
async switchWorkspace() {
const error = new Error('工作区路径不存在。');
error.code = 'workspace-not-found';
throw error;
},
};
const invalid = await runWorkspaceCommand('/workspace /missing/workspace', invalidHarness);
assert.match(invalid.message, /路径不存在/);
assert.match(invalid.message, /用法:\/workspace 工作区绝对路径/);
const removedHarness = {
async switchWorkspace() {
const error = new Error('bot removed');
error.code = 'workspace-bot-not-found';
throw error;
},
};
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, removedHarness)).message,
/正在移除或已重新接入/,
);
});
for (const [name, Client] of [
['Weixin', WeixinHarnessClient],
['Feishu', FeishuHarnessClient],
['DingTalk', DingtalkHarnessClient],
]) {
test(`${name} Harness creates a session with an explicit workspace override`, async () => {
const client = new Client({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/default-workspace',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
const calls = [];
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeout, options) => {
calls.push({ method, payload, options });
if (method === 'workspace.list') return { items: [] };
if (method === 'workspace.create') return { workspace: { workspaceId: 'workspace-new' } };
if (method === 'session.create') return { sessionId: 'session-new' };
throw new Error(`Unexpected RPC: ${method}`);
};
const signal = new AbortController().signal;
const options = name === 'DingTalk'
? { workspace: '/explicit-workspace', signal }
: { workspace: '/explicit-workspace' };
assert.equal(await client.createSession(options), 'session-new');
assert.deepEqual(calls.map(({ method }) => method), [
'workspace.list', 'workspace.create', 'session.create',
]);
assert.equal(calls[1].payload.path, '/explicit-workspace');
if (name === 'DingTalk') assert.equal(calls[0].options.signal, signal);
});
}
test('workspace RPC validates payloads and returns the updated public status', async (t) => {
const { root, path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces,
stateFor: async () => ({ async clearSessions() {} }),
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const success = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: alternateWorkspace,
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
const relative = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: 'relative/path',
});
assert.equal(relative.error.code, 'bad-request');
const missing = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: join(root, 'missing'),
});
assert.equal(missing.error.code, 'workspace-not-found');
assert.match(missing.error.message, /不存在/);
});