feat: support per-bot workspace switching

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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