mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
feat: support per-bot workspace switching
This commit is contained in:
parent
adcace3512
commit
44dd50216a
64 changed files with 3878 additions and 712 deletions
|
|
@ -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) ?? '已安全保存',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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, '已安全保存'),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
96
plugin-src/client/workspace-editor.js
Normal file
96
plugin-src/client/workspace-editor.js
Normal 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' }, '未设置'),
|
||||
);
|
||||
}
|
||||
28
plugin-src/client/workspace-snapshot-fence.js
Normal file
28
plugin-src/client/workspace-snapshot-fence.js
Normal 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;
|
||||
},
|
||||
}), []);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 操作失败,请稍后重试。' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
25
plugin-src/host/channels/shared/workspace-rpc.mjs
Normal file
25
plugin-src/host/channels/shared/workspace-rpc.mjs
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.' } }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '企业微信操作失败,请稍后重试。' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 操作失败,请稍后重试。' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue