Skip to content

Commit b1cbdbf

Browse files
committed
fix: improve telegram command guidance
1 parent bb99824 commit b1cbdbf

6 files changed

Lines changed: 134 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ General:
132132
- `/plan <task>` - ask Codex for a plan only, without direct file modification intent
133133
- `/model [name|reset]` - show or set the model override for the current chat
134134
- `/skill list` - show skill switches for the current chat
135+
- `/skill status` - alias of `/skill list`
135136
- `/skill on <name>` - enable a skill for the current chat
136137
- `/skill off <name>` - disable a skill for the current chat
137138
- `/sh <command>` - run a safe allowlisted Linux command in the current project (disabled by default)

src/bot/commandUtils.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,49 @@ export function extractCommandPayload(rawText = "", commandName) {
33
return String(rawText).replace(pattern, "").trim();
44
}
55

6+
function levenshteinDistance(a, b) {
7+
const left = String(a);
8+
const right = String(b);
9+
const dp = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
10+
11+
for (let i = 0; i <= left.length; i += 1) dp[i][0] = i;
12+
for (let j = 0; j <= right.length; j += 1) dp[0][j] = j;
13+
14+
for (let i = 1; i <= left.length; i += 1) {
15+
for (let j = 1; j <= right.length; j += 1) {
16+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
17+
dp[i][j] = Math.min(
18+
dp[i - 1][j] + 1,
19+
dp[i][j - 1] + 1,
20+
dp[i - 1][j - 1] + cost
21+
);
22+
}
23+
}
24+
25+
return dp[left.length][right.length];
26+
}
27+
28+
export function suggestClosestWord(input, candidates, maxDistance = 2) {
29+
const normalizedInput = String(input || "").trim().toLowerCase();
30+
if (!normalizedInput) return "";
31+
32+
let best = "";
33+
let bestDistance = Number.POSITIVE_INFINITY;
34+
35+
for (const candidate of candidates) {
36+
const normalizedCandidate = String(candidate || "").trim().toLowerCase();
37+
if (!normalizedCandidate) continue;
38+
39+
const distance = levenshteinDistance(normalizedInput, normalizedCandidate);
40+
if (distance < bestDistance) {
41+
best = normalizedCandidate;
42+
bestDistance = distance;
43+
}
44+
}
45+
46+
return bestDistance <= maxDistance ? best : "";
47+
}
48+
649
export function buildPlanPrompt(task) {
750
return [
851
"Planning mode only.",

src/bot/handlers.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export function registerHandlers({
8989
"/plan <task> - 仅生成执行计划,不直接修改代码",
9090
"/model [name|reset] - 查看或设置当前 chat 的模型",
9191
"/skill list - 查看当前 chat 的 skill 开关",
92+
"/skill status - 同 /skill list",
9293
"/skill on <name> - 启用 skill",
9394
"/skill off <name> - 禁用 skill",
9495
"/sh <command> - 运行受限 Linux 命令 (默认关闭)",
@@ -244,7 +245,7 @@ export function registerHandlers({
244245

245246
bot.command("skill", async (ctx) => {
246247
const payload = extractCommandPayload(ctx.message.text, "skill");
247-
if (!payload || /^list$/i.test(payload)) {
248+
if (!payload || /^(list|status)$/i.test(payload)) {
248249
await sendChunkedMarkdown(
249250
ctx,
250251
[
@@ -525,7 +526,18 @@ export function registerHandlers({
525526

526527
bot.on("text", async (ctx) => {
527528
const text = ctx.message.text?.trim() || "";
528-
if (!text || text.startsWith("/")) return;
529+
if (!text) return;
530+
if (/^\/\s+\S+/.test(text)) {
531+
await sendChunkedMarkdown(
532+
ctx,
533+
[
534+
"命令格式错误:`/` 后面不要加空格。",
535+
`try: ${text.replace(/^\/\s+/, "/")}`
536+
].join("\n")
537+
);
538+
return;
539+
}
540+
if (text.startsWith("/")) return;
529541

530542
try {
531543
const route = await router.routeMessage(text, {

src/orchestrator/skills/mcpSkill.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { suggestClosestWord } from "../../bot/commandUtils.js";
2+
13
export class McpSkill {
24
constructor({ mcpClient }) {
35
this.mcpClient = mcpClient;
@@ -27,6 +29,7 @@ export class McpSkill {
2729

2830
async handleCommand(rawText) {
2931
const stripped = rawText.replace(/^\/mcp(@\w+)?\s*/i, "").trim();
32+
const supportedSubcommands = ["list", "status", "reconnect", "enable", "disable", "tools", "call"];
3033
if (!stripped) {
3134
return {
3235
text: [
@@ -168,6 +171,11 @@ export class McpSkill {
168171
};
169172
}
170173

171-
return { text: "未知 MCP 子命令。支持: tools, call。" };
174+
const suggested = suggestClosestWord(subcommand, supportedSubcommands);
175+
return {
176+
text: suggested
177+
? `未知 MCP 子命令: ${subcommand}。你是不是想输入 \`/mcp ${suggested}\`?`
178+
: `未知 MCP 子命令: ${subcommand}。支持: ${supportedSubcommands.join(", ")}。`
179+
};
172180
}
173181
}

tests/commandUtils.test.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import test from "node:test";
22
import assert from "node:assert/strict";
3-
import { buildPlanPrompt, extractCommandPayload } from "../src/bot/commandUtils.js";
3+
import {
4+
buildPlanPrompt,
5+
extractCommandPayload,
6+
suggestClosestWord
7+
} from "../src/bot/commandUtils.js";
48

59
test("extractCommandPayload removes telegram command prefix and bot suffix", () => {
610
assert.equal(extractCommandPayload("/exec@ExampleBot run tests", "exec"), "run tests");
@@ -15,3 +19,8 @@ test("buildPlanPrompt forces planning-only behavior", () => {
1519
assert.match(prompt, /Do not modify files/);
1620
assert.match(prompt, /Task:\nrefactor src\/index\.js/);
1721
});
22+
23+
test("suggestClosestWord returns the nearest supported command when the typo is small", () => {
24+
assert.equal(suggestClosestWord("ststus", ["list", "status", "tools"]), "status");
25+
assert.equal(suggestClosestWord("zzz", ["list", "status", "tools"]), "");
26+
});

tests/mcpSkill.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { McpSkill } from "../src/orchestrator/skills/mcpSkill.js";
4+
5+
function createSkill() {
6+
return new McpSkill({
7+
mcpClient: {
8+
hasServers: () => true,
9+
listServers: () => [
10+
{
11+
name: "context7",
12+
enabled: true,
13+
connected: true,
14+
command: "npx",
15+
args: ["-y", "@upstash/context7-mcp"],
16+
cwd: process.cwd()
17+
}
18+
],
19+
reconnectServer: async (name) => ({
20+
name,
21+
enabled: true,
22+
connected: true
23+
}),
24+
enableServer: async (name) => ({
25+
name,
26+
enabled: true,
27+
connected: true
28+
}),
29+
disableServer: async (name) => ({
30+
name,
31+
enabled: false,
32+
connected: false
33+
}),
34+
listTools: async () => [],
35+
callTool: async () => ""
36+
}
37+
});
38+
}
39+
40+
test("mcp skill suggests the closest subcommand for small typos", async () => {
41+
const skill = createSkill();
42+
const result = await skill.execute({
43+
text: "/mcp ststus"
44+
});
45+
46+
assert.match(result.text, /\/mcp status/);
47+
});
48+
49+
test("mcp skill returns expanded help text when no subcommand is provided", async () => {
50+
const skill = createSkill();
51+
const result = await skill.execute({
52+
text: "/mcp"
53+
});
54+
55+
assert.match(result.text, /\/mcp list/);
56+
assert.match(result.text, /\/mcp status \[server\]/);
57+
});

0 commit comments

Comments
 (0)