dsh-im-ops/src/channels/shared/workspace-command.mjs
liuwenbo00 6366404c42 feat(feishu): interactive card menus and session/workspace list cards
- /m (or /menu) opens a card menu; /sessionlist and /workspacelist render
  cards with bind/switch buttons; number replies stay usable as a fallback
  when the app does not subscribe card.action.trigger.
- card.action.trigger callbacks validate the operator's open_id against the
  allowed senders: group members outside the allowlist can never drive
  binding, workspace switches or other card actions.
- Session-list pagination uses page numbers everywhere (buttons carry
  sessions:<page>), fixing the previous double page-size scaling that
  skipped pages past 20 sessions.
- Bind/workspace failures map to safe user-facing messages instead of raw
  error details.
- Apps registered after this change subscribe card.action.trigger during
  the scan flow.
2026-08-21 10:03:23 +08:00

375 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
const SESSION_LIST_COMMAND = /^\/sessionlist(?:\s+([\s\S]+))?$/i;
const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
const MAX_SESSION_ID_LENGTH = 256;
const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
const SESSION_BIND_USAGE = '用法:/session Session ID 或当前工作区序号(/session N)';
const SESSION_LIST_USAGE = [
'用法:',
'/sessionlist 列出当前工作区会话',
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
'/sessionlist 工作区绝对路径 列出指定工作区会话',
].join('\n');
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) || UNSAFE_DISPLAY_TEXT.test(value)) return null;
return resolve(value);
}
function safeDisplayText(value) {
if (typeof value !== 'string') return '';
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
}
function validSessionId(value) {
return typeof value === 'string'
&& value.length > 0
&& value.length <= MAX_SESSION_ID_LENGTH
&& !/\p{White_Space}/u.test(value)
&& !UNSAFE_DISPLAY_TEXT.test(value);
}
async function existingWorkspacePaths(values) {
const checked = await Promise.all(values.map(async (value) => {
const workspace = normalizedWorkspacePath(value);
if (!workspace) return null;
try {
if (!(await stat(workspace)).isDirectory()) return null;
return normalizedWorkspacePath(await realpath(workspace));
} catch {
return null;
}
}));
return [...new Set(checked.filter(Boolean))];
}
async function selectedWorkspacePath(value) {
if (typeof value !== 'string' || !isAbsolute(value.trim())) {
return { error: `工作区必须是绝对路径。\n${SESSION_LIST_USAGE}` };
}
const workspace = normalizedWorkspacePath(value.trim());
if (!workspace) {
return { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
}
let info;
try {
info = await stat(workspace);
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
if (!info.isDirectory()) {
return { error: `工作区路径必须指向一个目录。\n${SESSION_LIST_USAGE}` };
}
try {
const canonical = normalizedWorkspacePath(await realpath(workspace));
return canonical
? { workspace: canonical }
: { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
} catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
}
}
export async function workspacePathSnapshot(harness) {
const listed = await harness.listWorkspaces();
const currentValue = typeof harness?.currentWorkspace === 'function'
? harness.currentWorkspace()
: null;
const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
const paths = [...new Set([...(current ? [current] : []), ...registered])];
harness.assertWorkspaceScope?.();
return { current: current ?? null, paths };
}
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 { current, paths } = await workspacePathSnapshot(harness);
if (paths.length === 0) {
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
}
const lines = [
`当前 Harness Host 上存在的工作区(${paths.length}):`,
...paths.map((workspace, index) => (
`${index + 1}. ${workspace}${workspace === current ? '(当前)' : ''}`
)),
'',
'切换用法:/workspace 工作区绝对路径',
'查看会话:/sessionlist 工作区序号或绝对路径',
];
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 resolveSessionListWorkspace(selector, harness) {
if (!selector) {
if (typeof harness?.currentWorkspace !== 'function') {
return { error: '当前机器人没有可用的工作区。' };
}
const selected = await selectedWorkspacePath(harness.currentWorkspace());
harness.assertWorkspaceScope?.();
return selected;
}
if (/^\d+$/u.test(selector)) {
if (typeof harness?.listWorkspaces !== 'function') {
return { error: '当前机器人暂不支持按序号选择工作区。' };
}
const { paths } = await workspacePathSnapshot(harness);
const position = Number(selector);
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
return { error: '工作区序号不存在,请先执行 /workspacelist。' };
}
return { workspace: paths[position - 1] };
}
const selected = await selectedWorkspacePath(selector);
harness.assertWorkspaceScope?.();
return selected;
}
function formatSessionRelativeTime(value) {
const ms = typeof value === 'number' && Number.isFinite(value) ? value : null;
if (ms === null) return '';
const date = new Date(ms);
if (Number.isNaN(date.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const now = new Date();
const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
if (dayDiff === 0) return `今天 ${hm}`;
if (dayDiff === 1) return `昨天 ${hm}`;
if (dayDiff === 2) return `前天 ${hm}`;
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}月${date.getDate()}日 ${hm}`;
}
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
}
function sessionListMessage(workspace, sessions, { currentWorkspace = false } = {}) {
const rows = sessions.map((session) => {
const sessionId = safeDisplayText(session?.sessionId);
if (!sessionId) throw new TypeError('Harness returned an invalid session id');
const title = session?.summaryAvailable === false
? '标题暂不可用'
: safeDisplayText(session?.title) || '暂无标题';
const timeText = formatSessionRelativeTime(session?.time);
const annotation = `${timeText ? ` · ${timeText}` : ''}${session?.archived === true ? '(已归档)' : ''}`;
return `${title}${annotation}\n ID: ${sessionId}`;
});
if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`;
return [
`工作区:${workspace}`,
`会话(${rows.length}):`,
'',
...rows.map((row, index) => `${index + 1}. ${row}`),
'',
currentWorkspace
? '绑定用法:/session Session ID 或当前工作区序号(/session N)'
: '绑定用法:/session Session ID\n提示:/session N 只按机器人当前工作区的序号绑定。',
].join('\n');
}
async function currentSessionListWorkspace(harness) {
if (typeof harness?.currentWorkspace !== 'function') return null;
const [current] = await existingWorkspacePaths([harness.currentWorkspace()]);
harness.assertWorkspaceScope?.();
return current ?? null;
}
async function runSessionListCommand(match, harness) {
if (typeof harness?.listWorkspaceSessions !== 'function') {
return commandResult('当前机器人暂不支持列出工作区会话。');
}
const selector = match[1]?.trim() ?? '';
try {
const resolved = await resolveSessionListWorkspace(selector, harness);
if (resolved.error) return commandResult(resolved.error);
const listed = await harness.listWorkspaceSessions(resolved.workspace);
if (!listed || !Array.isArray(listed.sessions)) {
throw new TypeError('Harness returned an invalid workspace session list');
}
harness.assertWorkspaceScope?.();
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
const currentWorkspace = await currentSessionListWorkspace(harness);
const message = sessionListMessage(workspace, listed.sessions, {
currentWorkspace: workspace === currentWorkspace,
});
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {
if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区会话。');
}
return commandResult('暂时无法获取工作区会话列表,请稍后重试。');
}
}
function sessionBindErrorMessage(error) {
if (error?.code === 'session-id-invalid') {
return `Session ID 格式无效。\n${SESSION_BIND_USAGE}`;
}
if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
return '未找到该会话,请先执行 /sessionlist 确认 Session ID。';
}
if (error?.code === 'session-subagent-unsupported') {
return '子代理会话不能绑定到机器人对话,请选择普通会话。';
}
if (error?.code === 'session-workspace-ambiguous') {
return '该会话的工作区归属不明确,暂时无法绑定。';
}
if (error?.code === 'session-summary-unavailable') {
return '暂时无法读取该会话的信息,请稍后重试。';
}
if (error?.code === 'workspace-bot-not-found') {
return '机器人正在移除或已重新接入,无法绑定原对话的会话。';
}
if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
.includes(error?.code)) {
return '工作区或会话状态已发生变化,请重试。';
}
return '暂时无法绑定会话,请稍后重试。';
}
async function runSessionBindCommand(command, harness, conversationKey) {
const match = SESSION_BIND_COMMAND.exec(command);
let sessionId = match?.[1];
if (typeof sessionId === 'string' && /^\d+$/u.test(sessionId)) {
// 序号模式:把 /session N 解析成当前工作区会话列表中的第 N 个会话
if (typeof harness?.listWorkspaceSessions !== 'function'
|| typeof harness?.currentWorkspace !== 'function') {
return commandResult('当前机器人暂不支持按序号绑定,请使用 /session Session ID。');
}
try {
const selected = await selectedWorkspacePath(harness.currentWorkspace());
if (selected.error) return commandResult(selected.error);
const listed = await harness.listWorkspaceSessions(selected.workspace);
if (!listed || !Array.isArray(listed.sessions)) {
throw new TypeError('Harness returned an invalid workspace session list');
}
harness.assertWorkspaceScope?.();
const position = Number(sessionId);
if (!Number.isSafeInteger(position) || position < 1
|| position > listed.sessions.length) {
return commandResult('会话序号不存在,请先执行 /sessionlist 查看序号。');
}
const selectedSessionId = listed.sessions[position - 1]?.sessionId;
if (!validSessionId(selectedSessionId)) {
throw new TypeError('Harness returned an invalid session id');
}
sessionId = selectedSessionId;
} catch (error) {
if (error?.code === 'workspace-bot-not-found') {
return commandResult(sessionBindErrorMessage(error));
}
return commandResult('暂时无法获取会话列表,请稍后重试。');
}
}
if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE);
if (typeof harness?.bindWorkspaceSession !== 'function') {
return commandResult('当前机器人暂不支持绑定已有会话。');
}
if (typeof conversationKey !== 'string' || !conversationKey) {
return commandResult('当前消息缺少可绑定的会话上下文。');
}
try {
const bound = await harness.bindWorkspaceSession(conversationKey, sessionId);
harness.assertWorkspaceScope?.();
const workspace = normalizedWorkspacePath(bound?.workspace);
const boundSessionId = safeDisplayText(bound?.sessionId);
if (!workspace || !boundSessionId) {
throw new TypeError('Harness returned an invalid bound session');
}
const title = safeDisplayText(bound?.title) || '暂无标题';
const message = [
'当前聊天已绑定会话:',
`工作区:${workspace}`,
`标题:${title}`,
`ID:${boundSessionId}`,
`归档:${bound?.archived === true ? '是' : '否'}`,
].join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {
return commandResult(sessionBindErrorMessage(error));
}
}
export async function runWorkspaceCommand(text, harness, conversationKey) {
if (typeof text !== 'string') return null;
const command = text.trim();
if (SESSION_BIND_PREFIX.test(command)) {
return runSessionBindCommand(command, harness, conversationKey);
}
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
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 commandResult('用法:/workspace 工作区绝对路径');
}
if (typeof harness?.switchWorkspace !== 'function') {
return commandResult('当前机器人暂不支持切换工作区。');
}
try {
const current = await harness.switchWorkspace(workspace);
return commandResult(`工作区已切换为:${current}`);
} catch (error) {
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
return commandResult(`${error.message}\n用法:/workspace 工作区绝对路径`);
}
if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法切换原会话的工作区。');
}
throw error;
}
}