mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
feat: add workspace list command
This commit is contained in:
parent
c44340accb
commit
1fe6180813
17 changed files with 570 additions and 118 deletions
|
|
@ -70,11 +70,14 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
|
|||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
|
||||
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
|
||||
|
||||
示例:`/workspace /Users/alice/projects/my-app`
|
||||
|
||||
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
|
||||
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。
|
||||
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行,不区分管理员和普通用户。
|
||||
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
|
||||
- 切换成功后只清除当前机器人的旧 Harness 会话映射,不影响其他机器人。
|
||||
- 新工作区对后续消息生效;已经开始生成的回复会继续完成。
|
||||
|
||||
|
|
@ -172,11 +175,14 @@ Each bot maintains an independent Harness workspace. A newly connected bot recor
|
|||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `/workspace <absolute workspace path>` | Switch the current bot's Harness workspace. |
|
||||
| `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. |
|
||||
|
||||
Example: `/workspace /Users/alice/projects/my-app`
|
||||
|
||||
- The path must be an existing absolute directory. The bot returns an actionable error and the correct usage when validation fails.
|
||||
- `/workspacelist` takes no arguments. It combines the Harness global registry with the current bot's path. When that current path still exists and is safe to display, it appears first and is marked as current. Any listed path can be copied directly into `/workspace`.
|
||||
- Any user who is already within the platform bot's visibility scope and can normally message it can run the command; there is no additional administrator/ordinary-user distinction.
|
||||
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
|
||||
- A successful switch clears only the current bot's old Harness session mappings and does not affect other bots.
|
||||
- The new workspace applies to subsequent messages; a reply that has already started generating is allowed to finish.
|
||||
|
||||
|
|
|
|||
208
lib/index.js
208
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -15,6 +15,7 @@ const HELP_TEXT = [
|
|||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -218,7 +219,9 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#send(sessionWebhook, workspaceCommand.message);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sessionWebhook, reply);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
function workspacePaths(value) {
|
||||
if (!Array.isArray(value?.items)) return [];
|
||||
return value.items.flatMap((item) => (
|
||||
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
||||
));
|
||||
}
|
||||
|
||||
function sleep(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -216,6 +224,11 @@ export class HarnessClient {
|
|||
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
||||
}
|
||||
|
||||
async listWorkspaces(options = {}) {
|
||||
await this.ensureRunning(options);
|
||||
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const { workspace = this.#workspace, ...rpcOptions } = options;
|
||||
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const HELP_TEXT = [
|
|||
'直接发送问题即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -111,7 +112,9 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#send(event.message.chat_id, workspaceCommand.message);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(event.message.chat_id, reply);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
if (!Array.isArray(value?.items)) return [];
|
||||
return value.items.flatMap((item) => (
|
||||
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
||||
));
|
||||
}
|
||||
|
||||
function messageText(event) {
|
||||
return (event?.data?.message?.content ?? [])
|
||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
|
|
@ -197,6 +205,11 @@ export class HarnessClient {
|
|||
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
||||
}
|
||||
|
||||
async listWorkspaces(options = {}) {
|
||||
await this.ensureRunning();
|
||||
return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const HELP_TEXT = [
|
|||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -127,7 +128,9 @@ export class QqHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#bot.sendText(target, workspaceCommand.message);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,7 +398,41 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
const sessionGenerations = new Map();
|
||||
const scopedHarness = new Proxy(harness, {
|
||||
get(target, property) {
|
||||
if (property === 'currentWorkspace') return () => workspaces.workspaceFor(botId);
|
||||
if (property === 'currentWorkspace') {
|
||||
return () => {
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
return workspaces.workspaceFor(botId);
|
||||
};
|
||||
}
|
||||
if (property === 'assertWorkspaceScope') {
|
||||
return () => {
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
|
||||
return async (...args) => {
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const paths = await target.listWorkspaces(...args);
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
}
|
||||
if (property === 'switchWorkspace') {
|
||||
return (workspace) => {
|
||||
if (!isCurrentScope()) {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export class TextHarnessBridge {
|
|||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n'));
|
||||
|
|
@ -115,7 +116,9 @@ export class TextHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#bot.sendText(target, workspaceCommand.message);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,114 @@
|
|||
import { stat } from 'node:fs/promises';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
|
||||
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
|
||||
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
|
||||
|
||||
function commandResult(message, messages = [message]) {
|
||||
return { handled: true, message, messages };
|
||||
}
|
||||
|
||||
function normalizedWorkspacePath(value) {
|
||||
if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
|
||||
|| !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
async function existingWorkspacePaths(values) {
|
||||
const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
|
||||
const checked = await Promise.all(unique.map(async (workspace) => {
|
||||
try {
|
||||
return (await stat(workspace)).isDirectory() ? workspace : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
return checked.filter(Boolean);
|
||||
}
|
||||
|
||||
export function splitWorkspaceCommandMessage(message) {
|
||||
const messages = [];
|
||||
let offset = 0;
|
||||
while (offset < message.length) {
|
||||
let end = Math.min(offset + MAX_COMMAND_MESSAGE_LENGTH, message.length);
|
||||
if (end < message.length) {
|
||||
const lineBreak = message.lastIndexOf('\n', end - 1);
|
||||
if (lineBreak >= offset) {
|
||||
end = lineBreak + 1;
|
||||
} else {
|
||||
const trailing = message.charCodeAt(end - 1);
|
||||
const leading = message.charCodeAt(end);
|
||||
if (trailing >= 0xd800 && trailing <= 0xdbff
|
||||
&& leading >= 0xdc00 && leading <= 0xdfff) end -= 1;
|
||||
}
|
||||
}
|
||||
messages.push(message.slice(offset, end));
|
||||
offset = end;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function runWorkspaceListCommand(match, harness) {
|
||||
if (match[1]?.trim()) return commandResult('用法:/workspacelist');
|
||||
if (typeof harness?.listWorkspaces !== 'function') {
|
||||
return commandResult('当前机器人暂不支持列出工作区。');
|
||||
}
|
||||
try {
|
||||
const listed = await harness.listWorkspaces();
|
||||
const current = typeof harness.currentWorkspace === 'function'
|
||||
? normalizedWorkspacePath(harness.currentWorkspace())
|
||||
: null;
|
||||
const paths = await existingWorkspacePaths([
|
||||
...(current ? [current] : []),
|
||||
...(Array.isArray(listed) ? listed : []),
|
||||
]);
|
||||
harness.assertWorkspaceScope?.();
|
||||
if (paths.length === 0) {
|
||||
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
|
||||
}
|
||||
const lines = [
|
||||
`当前 Harness Host 上存在的工作区(${paths.length}):`,
|
||||
...paths.map((workspace, index) => (
|
||||
`${index + 1}. ${workspace}${workspace === current ? '(当前)' : ''}`
|
||||
)),
|
||||
'',
|
||||
'切换用法:/workspace 工作区绝对路径',
|
||||
];
|
||||
const message = lines.join('\n');
|
||||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||||
} catch (error) {
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区。');
|
||||
}
|
||||
return commandResult('暂时无法获取工作区列表,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const match = WORKSPACE_COMMAND.exec(text.trim());
|
||||
const command = text.trim();
|
||||
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
|
||||
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
|
||||
const match = WORKSPACE_COMMAND.exec(command);
|
||||
if (!match) return null;
|
||||
const workspace = match[1]?.trim();
|
||||
if (!workspace) {
|
||||
return { handled: true, message: '用法:/workspace 工作区绝对路径' };
|
||||
return commandResult('用法:/workspace 工作区绝对路径');
|
||||
}
|
||||
if (typeof harness?.switchWorkspace !== 'function') {
|
||||
return { handled: true, message: '当前机器人暂不支持切换工作区。' };
|
||||
return commandResult('当前机器人暂不支持切换工作区。');
|
||||
}
|
||||
try {
|
||||
const current = await harness.switchWorkspace(workspace);
|
||||
return { handled: true, message: `工作区已切换为:${current}` };
|
||||
return commandResult(`工作区已切换为:${current}`);
|
||||
} catch (error) {
|
||||
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
|
||||
return { handled: true, message: `${error.message}\n用法:/workspace 工作区绝对路径` };
|
||||
return commandResult(`${error.message}\n用法:/workspace 工作区绝对路径`);
|
||||
}
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return { handled: true, message: '机器人正在移除或已重新接入,无法切换原会话的工作区。' };
|
||||
return commandResult('机器人正在移除或已重新接入,无法切换原会话的工作区。');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const HELP_TEXT = [
|
|||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -187,7 +188,9 @@ export class WecomHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#sendImmediate(frame, chatId, workspaceCommand.message);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#sendImmediate(frame, chatId, reply);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
if (!Array.isArray(value?.items)) return [];
|
||||
return value.items.flatMap((item) => (
|
||||
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
||||
));
|
||||
}
|
||||
|
||||
function assistantMessageText(event) {
|
||||
return (event?.data?.message?.content ?? [])
|
||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
|
|
@ -187,6 +195,11 @@ export class HarnessClient {
|
|||
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
||||
}
|
||||
|
||||
async listWorkspaces(options = {}) {
|
||||
await this.ensureRunning();
|
||||
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const HELP_TEXT = [
|
|||
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -138,7 +139,9 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
if (workspaceCommand) {
|
||||
await this.#send(sender, workspaceCommand.message, contextToken, runId);
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sender, reply, contextToken, runId);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,53 @@ import test from 'node:test';
|
|||
|
||||
import { HarnessClient, HarnessReplyTracker, HarnessRpcError } from '../../../src/channels/dingtalk/harness-client.mjs';
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths and forwards request options', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
const options = {
|
||||
signal: new AbortController().signal,
|
||||
rpcId: 'dingtalk-workspace-list',
|
||||
};
|
||||
let ensuredWith;
|
||||
let rpcCall;
|
||||
let response = {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/workspace-one',
|
||||
title: 'private title',
|
||||
sessionIds: ['private-session'],
|
||||
},
|
||||
{ workspaceId: 'relative', path: 'relative/workspace' },
|
||||
null,
|
||||
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
|
||||
],
|
||||
archivedSessionIds: ['private-archive'],
|
||||
};
|
||||
client.ensureRunning = async (received) => { ensuredWith = received; };
|
||||
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
|
||||
rpcCall = { method, payload, timeoutMs, options: rpcOptions };
|
||||
return response;
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.listWorkspaces(options), [
|
||||
'/tmp/workspace-one',
|
||||
'/tmp/workspace two',
|
||||
]);
|
||||
assert.equal(ensuredWith, options);
|
||||
assert.deepEqual(rpcCall, {
|
||||
method: 'workspace.list',
|
||||
payload: {},
|
||||
timeoutMs: 30_000,
|
||||
options,
|
||||
});
|
||||
|
||||
response = null;
|
||||
assert.deepEqual(await client.listWorkspaces(), []);
|
||||
});
|
||||
|
||||
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
|
||||
const update = tracker.consume([
|
||||
|
|
|
|||
|
|
@ -5,6 +5,46 @@ import {
|
|||
HarnessReplyTracker,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
const options = { rpcId: 'feishu-workspace-list' };
|
||||
const calls = [];
|
||||
let response = {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/workspace-one',
|
||||
title: 'private title',
|
||||
sessionIds: ['private-session'],
|
||||
},
|
||||
{ workspaceId: 'relative', path: 'relative/workspace' },
|
||||
null,
|
||||
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
|
||||
],
|
||||
archivedSessionIds: ['private-archive'],
|
||||
};
|
||||
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
|
||||
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
|
||||
calls.push({ method, payload, timeoutMs, options: rpcOptions });
|
||||
return response;
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.listWorkspaces(options), [
|
||||
'/tmp/workspace-one',
|
||||
'/tmp/workspace two',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'ensureRunning' },
|
||||
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
|
||||
]);
|
||||
|
||||
response = { items: 'invalid' };
|
||||
assert.deepEqual(await client.listWorkspaces(), []);
|
||||
});
|
||||
|
||||
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
|
||||
const methods = [];
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,47 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
||||
import { HarnessClient, HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
});
|
||||
const options = { rpcId: 'weixin-workspace-list' };
|
||||
const calls = [];
|
||||
let response = {
|
||||
items: [
|
||||
{
|
||||
workspaceId: 'workspace-one',
|
||||
path: '/tmp/workspace-one',
|
||||
title: 'private title',
|
||||
sessionIds: ['private-session'],
|
||||
},
|
||||
{ workspaceId: 'relative', path: 'relative/workspace' },
|
||||
null,
|
||||
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
|
||||
],
|
||||
archivedSessionIds: ['private-archive'],
|
||||
};
|
||||
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
|
||||
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
|
||||
calls.push({ method, payload, timeoutMs, options: rpcOptions });
|
||||
return response;
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.listWorkspaces(options), [
|
||||
'/tmp/workspace-one',
|
||||
'/tmp/workspace two',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'ensureRunning' },
|
||||
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
|
||||
]);
|
||||
|
||||
response = { items: 'invalid' };
|
||||
assert.deepEqual(await client.listWorkspaces(), []);
|
||||
});
|
||||
|
||||
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
validateWorkspacePath,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
|
||||
import {
|
||||
runWorkspaceCommand,
|
||||
splitWorkspaceCommandMessage,
|
||||
} from '../src/channels/shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
|
||||
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
|
||||
import { HarnessClient as FeishuHarnessClient } from '../src/channels/feishu/harness-client.mjs';
|
||||
|
|
@ -755,6 +758,140 @@ test('/workspace command preserves spaces and returns actionable validation mess
|
|||
);
|
||||
});
|
||||
|
||||
test('/workspacelist returns existing absolute paths with the current workspace first', async (t) => {
|
||||
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const thirdWorkspace = join(root, 'third');
|
||||
await mkdir(thirdWorkspace);
|
||||
let listCalls = 0;
|
||||
const harness = {
|
||||
currentWorkspace() { return defaultWorkspace; },
|
||||
async listWorkspaces() {
|
||||
listCalls += 1;
|
||||
return [
|
||||
alternateWorkspace,
|
||||
defaultWorkspace,
|
||||
alternateWorkspace,
|
||||
thirdWorkspace,
|
||||
join(root, 'missing'),
|
||||
'relative/path',
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runWorkspaceCommand('/WORKSPACELIST', harness);
|
||||
assert.equal(result.handled, true);
|
||||
assert.match(result.message, /工作区(3)/);
|
||||
assert.match(result.message, new RegExp(`1\\. ${defaultWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(当前)`));
|
||||
assert.ok(result.message.indexOf(defaultWorkspace) < result.message.indexOf(alternateWorkspace));
|
||||
assert.ok(result.message.indexOf(alternateWorkspace) < result.message.indexOf(thirdWorkspace));
|
||||
assert.doesNotMatch(result.message, /missing|relative\/path/);
|
||||
assert.match(result.message, /切换用法:\/workspace 工作区绝对路径/);
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
assert.equal(listCalls, 1);
|
||||
|
||||
assert.match((await runWorkspaceCommand('/workspacelist extra', harness)).message, /用法/);
|
||||
assert.equal(listCalls, 1);
|
||||
assert.match((await runWorkspaceCommand('/workspacelist', {})).message, /暂不支持/);
|
||||
assert.match((await runWorkspaceCommand('/workspacelist', {
|
||||
async listWorkspaces() { throw new Error('private host detail'); },
|
||||
})).message, /暂时无法获取/);
|
||||
assert.match((await runWorkspaceCommand('/workspacelist', {
|
||||
async listWorkspaces() { return []; },
|
||||
})).message, /没有仍然存在/);
|
||||
});
|
||||
|
||||
test('/workspacelist splits a long registry without dropping paths', async (t) => {
|
||||
const { root, defaultWorkspace } = await fixture(t);
|
||||
const paths = Array.from({ length: 48 }, (_, index) => (
|
||||
join(root, `workspace-${String(index).padStart(2, '0')}`)
|
||||
));
|
||||
await Promise.all(paths.map((workspace) => mkdir(workspace)));
|
||||
const result = await runWorkspaceCommand('/workspacelist', {
|
||||
currentWorkspace() { return defaultWorkspace; },
|
||||
async listWorkspaces() { return paths; },
|
||||
});
|
||||
|
||||
assert.ok(result.messages.length > 1);
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
assert.ok(result.messages.every((message) => message.length <= 1_800));
|
||||
for (const workspace of paths) assert.ok(result.message.includes(workspace));
|
||||
});
|
||||
|
||||
test('/workspacelist hides unsafe Unicode paths and rechecks the bot scope', async (t) => {
|
||||
const { root, defaultWorkspace } = await fixture(t);
|
||||
const unsafePaths = [
|
||||
join(root, 'line\u2028separator'),
|
||||
join(root, 'bidi\u202ereversal'),
|
||||
join(root, 'control\u0085next-line'),
|
||||
];
|
||||
await Promise.all(unsafePaths.map((workspace) => mkdir(workspace)));
|
||||
|
||||
const filtered = await runWorkspaceCommand('/workspacelist', {
|
||||
currentWorkspace() { return defaultWorkspace; },
|
||||
async listWorkspaces() { return unsafePaths; },
|
||||
});
|
||||
assert.match(filtered.message, /工作区(1)/);
|
||||
for (const workspace of unsafePaths) assert.ok(!filtered.message.includes(workspace));
|
||||
|
||||
const stale = await runWorkspaceCommand('/workspacelist', {
|
||||
currentWorkspace() { return defaultWorkspace; },
|
||||
async listWorkspaces() { return [defaultWorkspace]; },
|
||||
assertWorkspaceScope() {
|
||||
const error = new Error('old bot lifecycle');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
assert.match(stale.message, /正在移除或已重新接入/);
|
||||
});
|
||||
|
||||
test('workspace command message splitting bounds a single very long path', () => {
|
||||
const message = `/workspace/${'nested/'.repeat(600)}project-😀`;
|
||||
const messages = splitWorkspaceCommandMessage(message);
|
||||
assert.ok(messages.length > 1);
|
||||
assert.ok(messages.every((part) => part.length <= 1_800));
|
||||
assert.equal(messages.join(''), message);
|
||||
});
|
||||
|
||||
test('all nine channel bridge families advertise and fan out /workspacelist replies', async () => {
|
||||
const bridgeFiles = [
|
||||
'../src/channels/shared/text-harness-bridge.mjs',
|
||||
'../src/channels/weixin/weixin-bridge.mjs',
|
||||
'../src/channels/feishu/bridge.mjs',
|
||||
'../src/channels/dingtalk/dingtalk-bridge.mjs',
|
||||
'../src/channels/wecom/wecom-bridge.mjs',
|
||||
'../src/channels/qq/qq-bridge.mjs',
|
||||
];
|
||||
for (const file of bridgeFiles) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
assert.match(source, /\/workspacelist 列出工作区绝对路径/);
|
||||
assert.match(source, /workspaceCommand\.messages \?\? \[workspaceCommand\.message\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale bot scope cannot finish listing workspaces after same-id rebinding', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_list');
|
||||
let finishList;
|
||||
const harness = {
|
||||
listWorkspaces() {
|
||||
return new Promise((resolve) => { finishList = resolve; });
|
||||
},
|
||||
};
|
||||
const oldScope = createBotScopedHarness(harness, {
|
||||
botId: 'bot_list',
|
||||
workspaces,
|
||||
state: { async clearSessions() {} },
|
||||
});
|
||||
const pending = oldScope.listWorkspaces();
|
||||
await workspaces.retireAfterConfigCommit('bot_list');
|
||||
await workspaces.ensure('bot_list', { workspace: alternateWorkspace });
|
||||
finishList([defaultWorkspace]);
|
||||
|
||||
await assert.rejects(pending, { code: 'workspace-bot-not-found' });
|
||||
});
|
||||
|
||||
for (const [name, Client] of [
|
||||
['Weixin', WeixinHarnessClient],
|
||||
['Feishu', FeishuHarnessClient],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue