Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ npm test -- tests/reliability-rescue.e2e.test.ts
|---|---|
| `/help` | 查看帮助 |
| `/panel` | 打开控制面板(模型、角色、强度状态、停止、撤回) |
| `/commands` | 生成并发送最新命令清单文件 |
| `/model` | 查看当前模型 |
| `/model <provider:model>` | 切换模型(支持 `provider/model`) |
| `/effort` | 查看当前会话推理强度与当前模型可选档位 |
Expand Down Expand Up @@ -853,6 +854,7 @@ npm test -- tests/reliability-rescue.e2e.test.ts
| `/clear free session <sessionId>` / `/clear_free_session <sessionId>` | 删除指定 OpenCode 会话,并移除所有本地绑定映射与该会话绑定的 Cron |
| `/compact` | 调用 OpenCode summarize,压缩当前会话上下文 |
| `!<shell命令>` | 透传白名单 shell 命令(如 `!ls`、`!pwd`、`!mkdir`、`!git status`) |
| `//<命令名>` | 透传命名空间 slash 命令(如 `//superpowers:brainstorming`) |
| `/create_chat` / `/建群` | 私聊中调出建群卡片(下拉选择后点击"创建群聊"生效) |
| `/send <绝对路径>` | 发送指定路径的文件到当前群聊 |
| `/restart opencode` | 重启本地 OpenCode 进程(仅 loopback) |
Expand Down
54 changes: 54 additions & 0 deletions src/commands/command-doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { mkdir, writeFile } from 'fs/promises';
import path from 'path';

export interface CommandDocItem {
name: string;
description?: string;
source?: string;
}

export interface CommandDocData {
updatedAt: string;
total: number;
groups: Record<string, CommandDocItem[]>;
}

export const COMMAND_DOC_PATH = path.resolve('docs', 'generated', 'commands.md');

export async function writeCommandDoc(data: CommandDocData): Promise<string> {
await mkdir(path.dirname(COMMAND_DOC_PATH), { recursive: true });
const lines: string[] = [];
lines.push('# OpenCode 命令清单');
lines.push('');
lines.push(`更新时间:${data.updatedAt}`);
lines.push(`命令总数:${data.total}`);
lines.push('');

const groupConfig: Array<{ key: string; title: string }> = [
{ key: 'command', title: '内置命令' },
{ key: 'mcp', title: 'MCP 命令' },
{ key: 'skill', title: '技能命令' },
{ key: 'other', title: '其他' },
];

for (const { key, title } of groupConfig) {
const items = data.groups[key] || [];
if (items.length === 0) continue;
lines.push(`## ${title}`);
lines.push(`共 ${items.length} 条`);
lines.push('');
lines.push('| 命令 | 描述 |');
lines.push('| --- | --- |');
for (const item of items) {
const desc = item.description || '-';
// 表格内换行用 <br>,管道符转义
const safeDesc = desc.replace(/\|/g, '\\|').replace(/\n/g, '<br>');
lines.push(`| \`/${item.name}\` | ${safeDesc} |`);
}
lines.push('');
}

const content = `${lines.join('\n').trim()}\n`;
await writeFile(COMMAND_DOC_PATH, content, 'utf8');
return COMMAND_DOC_PATH;
}
60 changes: 57 additions & 3 deletions src/commands/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type CommandType =
| 'session' // 会话操作
| 'project' // 项目/目录操作
| 'sessions' // 列出会话
| 'commands' // 列出可用命令
| 'clear' // 清空对话
| 'panel' // 控制面板
| 'effort' // 调整推理强度
Expand Down Expand Up @@ -95,6 +96,44 @@ function isSlashCommandToken(token: string): boolean {
return /^[\p{L}\p{N}_.?-]+$/u.test(normalized);
}

function isDoubleSlashCommandToken(token: string): boolean {
const normalized = token.trim();
if (!normalized) {
return false;
}

if (normalized.includes('/') || normalized.includes('\\')) {
return false;
}

// 双斜杠透传允许命名空间(:)
return /^[\p{L}\p{N}_.?:-]+$/u.test(normalized);
}

function parseDoubleSlashCommand(trimmed: string): ParsedCommand | null {
if (!trimmed.startsWith('//')) {
return null;
}

const body = trimmed.slice(2).trimStart();
if (!body || body.includes('\n')) {
return null;
}

const parts = body.split(/\s+/);
const first = parts[0]?.trim();
if (!first || !isDoubleSlashCommandToken(first)) {
return null;
}

return {
type: 'command',
commandName: first, // 保留原始大小写,OpenCode 命令区分大小写
commandArgs: parts.slice(1).join(' '),
commandPrefix: '/',
};
}

function parseBangShellCommand(trimmed: string): ParsedCommand | null {
if (!trimmed.startsWith('!')) {
return null;
Expand Down Expand Up @@ -136,6 +175,12 @@ export function parseCommand(text: string): ParsedCommand {
const trimmed = text.trim();
const lower = trimmed.toLowerCase();

// //xxx:yyy 透传命令(用于支持带命名空间的 slash)
const doubleSlashCommand = parseDoubleSlashCommand(trimmed);
if (doubleSlashCommand) {
return doubleSlashCommand;
}

// ! 开头的 shell 透传(白名单)
const bangCommand = parseBangShellCommand(trimmed);
if (bangCommand) {
Expand Down Expand Up @@ -186,7 +231,8 @@ export function parseCommand(text: string): ParsedCommand {
return { type: 'prompt', text: trimmed };
}

const cmd = parts[0].toLowerCase();
const originalCmd = parts[0]; // 保留原始大小写
const cmd = originalCmd.toLowerCase(); // 用于 switch 匹配
const args = parts.slice(1);
const rawArgsText = body.slice(parts[0].length).trim();

Expand Down Expand Up @@ -271,6 +317,12 @@ export function parseCommand(text: string): ParsedCommand {
case 'list':
return { type: 'sessions', listAll: args.length > 0 && args[0].toLowerCase() === 'all' };

case 'commands':
case 'slash':
case 'slash-commands':
case 'slash_commands':
return { type: 'commands' };

case 'project':
if (args.length === 0) {
return { type: 'project', projectAction: 'list' };
Expand Down Expand Up @@ -391,10 +443,10 @@ export function parseCommand(text: string): ParsedCommand {
}

default:
// 未知命令透传到OpenCode
// 未知命令透传到OpenCode(保留原始大小写)
return {
type: 'command',
commandName: cmd,
commandName: originalCmd,
commandArgs: args.join(' '),
commandPrefix: '/',
};
Expand Down Expand Up @@ -447,13 +499,15 @@ export function getHelpText(): string {
• \`/project list\` 列出可用项目;\`/project default\` 查看/设置/清除群默认项目
• \`/clear\` 等价 \`/session new\`;\`/clear free session\` 清理空闲群聊并手动扫描僵尸 Cron
• \`/status\` 查看当前绑定状态和群聊生命周期信息
• \`/commands\` 生成并发送最新命令清单文件

💡 **提示**
• 切换的模型/角色仅对**当前会话**生效。
• 强度优先级:\`#临时覆盖\` > \`/effort 会话默认\` > OpenCode 默认。
• \`/cron\` 支持自然语言,复杂语义默认交给 OpenCode 解析后再落盘为调度任务。
• Cron 默认绑定创建它的聊天窗口与当前 OpenCode 会话;如果当前聊天没有绑定会话,将拒绝创建。
• 其他未知 \`/xxx\` 命令会自动透传给 OpenCode(会话已绑定时生效)。
• 支持 \`//xxx\` 形式透传命名空间命令(如 \`//superpowers:brainstorming\`)。
• 支持透传白名单 shell 命令:\`!cd\`、\`!ls\`、\`!mkdir\`、\`!rm\`、\`!cp\`、\`!mv\`、\`!git\` 等;\`!vi\` / \`!vim\` / \`!nano\` 不会透传。
• 如果遇到问题,试着使用 \`/panel\` 面板操作更方便。

Expand Down
3 changes: 2 additions & 1 deletion src/feishu/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,10 @@ function buildCreateChatSelectorElements(data: CreateChatCardData): object[] {
});

// 3. 工作项目选择器(可选)
const projectCandidates = data.projectOptions || [];
const projectOpts = [
{ text: { tag: 'plain_text', content: '跟随默认项目' }, value: '__default__' },
...(data.projectOptions || []).map(project => ({
...projectCandidates.map(project => ({
text: {
tag: 'plain_text',
content: `${project.name}(${project.directory.length > 40 ? '...' + project.directory.slice(-37) : project.directory})`,
Expand Down
109 changes: 109 additions & 0 deletions src/handlers/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../opencode/client.js';
import { chatSessionStore } from '../store/chat-session.js';
import { buildControlCard, buildStatusCard } from '../feishu/cards.js';
import { writeCommandDoc, type CommandDocData } from '../commands/command-doc.js';
import { modelConfig, userConfig } from '../config.js';
import { sendFileToFeishu } from './file-sender.js';
import { lifecycleHandler } from './lifecycle.js';
Expand Down Expand Up @@ -836,6 +837,10 @@ export class CommandHandler {
case 'panel':
await this.handlePanel(chatId, messageId, context.chatType);
break;

case 'commands':
await this.handleCommandsCard(chatId, messageId);
break;

case 'sessions':
await this.handleListSessions(chatId, messageId, command.listAll);
Expand Down Expand Up @@ -1818,6 +1823,110 @@ export class CommandHandler {
});
}

private buildCommandListText(commands: Array<{ name: string; description?: string; source?: string; hints?: string[] }>): string {
if (!commands || commands.length === 0) {
return '当前未获取到可用命令列表。';
}

const groups: Record<string, Array<{ name: string; description?: string; hints?: string[] }>> = {
command: [],
mcp: [],
skill: [],
other: [],
};

for (const cmd of commands) {
const source = cmd.source || 'other';
const target = groups[source] || groups.other;
target.push(cmd);
}

const lines: string[] = ['📚 OpenCode 可用命令'];
const pushGroup = (label: string, items: Array<{ name: string; description?: string; hints?: string[] }>): void => {
if (items.length === 0) return;
const sorted = items.slice().sort((a, b) => a.name.localeCompare(b.name));
lines.push(`\n【${label}】`);
for (const item of sorted) {
const desc = item.description ? ` - ${item.description}` : '';
lines.push(`• /${item.name}${desc}`);
}
};

pushGroup('内置', groups.command);
pushGroup('MCP', groups.mcp);
pushGroup('技能', groups.skill);
pushGroup('其他', groups.other);

lines.push('\n💡 支持 // 命名空间透传,例如://superpowers:brainstorming');
return lines.join('\n');
}

private splitLongText(text: string, maxLength: number): string[] {
const trimmed = text.trim();
if (!trimmed) {
return [];
}

if (trimmed.length <= maxLength) {
return [trimmed];
}

const lines = trimmed.split('\n');
const chunks: string[] = [];
let buffer = '';
for (const line of lines) {
const next = buffer ? `${buffer}\n${line}` : line;
if (next.length > maxLength && buffer) {
chunks.push(buffer);
buffer = line;
} else {
buffer = next;
}
}

if (buffer.trim().length > 0) {
chunks.push(buffer);
}

return chunks;
}

private async handleCommandsCard(chatId: string, messageId: string): Promise<void> {
try {
const commands = await opencodeClient.getCommands();
const groups: Record<string, Array<{ name: string; description?: string; source?: string }>> = {
command: [],
mcp: [],
skill: [],
other: [],
};
for (const cmd of commands) {
const source = cmd.source || 'other';
const target = groups[source] || groups.other;
target.push(cmd);
}

const docData: CommandDocData = {
updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
total: commands.length,
groups,
};

const docPath = await writeCommandDoc(docData);
const result = await sendFileToFeishu({ filePath: docPath, chatId });
if (!result.success) {
await feishuClient.reply(messageId, `❌ 命令清单发送失败: ${result.error || '未知错误'}`);
return;
}

await feishuClient.reply(messageId, `✅ 命令清单已发送:${result.fileName || 'commands.md'}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await feishuClient.reply(messageId, `❌ 获取命令列表失败: ${errorMessage}`);
}
}


public async pushPanelCard(chatId: string, chatType: 'p2p' | 'group' = 'group'): Promise<void> {
const card = await this.buildPanelCard(chatId, chatType);
await feishuClient.sendCard(chatId, card);
Expand Down
26 changes: 22 additions & 4 deletions src/handlers/file-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { promises as fsp } from 'fs';
import * as path from 'path';
import { feishuClient } from '../feishu/client.js';
import { DirectoryPolicy } from '../utils/directory-policy.js';
import { COMMAND_DOC_PATH } from '../commands/command-doc.js';

// 系统生成文件豁免列表(绕过 ALLOWED_DIRECTORIES 校验)
const SYSTEM_GENERATED_PATHS: Set<string> = new Set([
path.resolve(COMMAND_DOC_PATH),
]);

// 敏感文件名黑名单(基于文件名模式匹配)
const SENSITIVE_NAME_PATTERNS = [
Expand Down Expand Up @@ -41,26 +47,38 @@ const SENSITIVE_PATH_PREFIXES = [
* 注意:resolvedPath 必须已经经过 path.resolve() 处理(绝对路径)。
*/
export function validateFilePath(resolvedPath: string): { safe: boolean; reason?: string } {
// 0. 允许目录白名单校验(未配置时直接拒绝)
// 0. 系统生成文件豁免(绕过白名单校验)
if (SYSTEM_GENERATED_PATHS.has(resolvedPath)) {
// 仍需检查敏感文件名
const basename = path.basename(resolvedPath);
for (const pattern of SENSITIVE_NAME_PATTERNS) {
if (pattern.test(basename)) {
return { safe: false, reason: `拒绝发送敏感文件: ${basename}` };
}
}
return { safe: true };
}

// 1. 允许目录白名单校验(未配置时直接拒绝)
if (!DirectoryPolicy.isAllowedPath(resolvedPath)) {
return { safe: false, reason: '路径不在允许的工作目录范围内' };
}

const basename = path.basename(resolvedPath);

// 1. 精确文件名匹配
// 2. 精确文件名匹配
if (SENSITIVE_EXACT_NAMES.has(basename)) {
return { safe: false, reason: `拒绝发送敏感文件: ${basename}` };
}

// 2. 文件名模式匹配
// 3. 文件名模式匹配
for (const pattern of SENSITIVE_NAME_PATTERNS) {
if (pattern.test(basename)) {
return { safe: false, reason: `拒绝发送敏感文件: ${basename}` };
}
}

// 3. 路径目录黑名单(统一转为正斜杠以兼容 Windows 路径格式)
// 4. 路径目录黑名单(统一转为正斜杠以兼容 Windows 路径格式)
const normalizedPath = resolvedPath.replace(/\\/g, '/');
for (const prefix of SENSITIVE_PATH_PREFIXES) {
if (normalizedPath.includes(prefix)) {
Expand Down
Loading