Skip to content

Commit d915bae

Browse files
committed
feat: add explicit bot restart control
1 parent ea58af8 commit d915bae

9 files changed

Lines changed: 164 additions & 19 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ General:
138138
- `/skill off <name>` - disable a skill for the current chat
139139
- `/sh <command>` - run a safe allowlisted Linux command in the current project (disabled by default)
140140
- `/sh --confirm <command>` - confirm a dangerous command when writable mode is enabled
141+
- `/restart` - restart the bot process explicitly from Telegram
141142
- `/interrupt` - send `Ctrl+C` to current PTY session
142143
- `/stop` - terminate current PTY session
143144
- `/cron_now` - trigger daily summary immediately

src/bot/handlers.js

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export function registerHandlers({
5555
shellManager,
5656
skills,
5757
skillRegistry,
58-
scheduler
58+
scheduler,
59+
adminActions
5960
}) {
6061
bot.start(async (ctx) => {
6162
await sendChunkedMarkdown(
@@ -94,6 +95,7 @@ export function registerHandlers({
9495
"/skill off <name> - 禁用 skill",
9596
"/sh <command> - 运行受限 Linux 命令 (默认关闭)",
9697
"/sh --confirm <command> - 确认执行高风险命令",
98+
"/restart - 重启 bot 进程",
9799
"/interrupt - 向 Codex CLI 发送 Ctrl+C",
98100
"/stop - 终止当前 chat 的 PTY 会话",
99101
"/cron_now - 立即触发一次日报推送",
@@ -265,17 +267,29 @@ export function registerHandlers({
265267
}
266268

267269
try {
270+
const actionResult = /^on$/i.test(action)
271+
? skillRegistry.enable(ctx.chat.id, rawName)
272+
: skillRegistry.disable(ctx.chat.id, rawName);
268273
if (/^on$/i.test(action)) {
269-
skillRegistry.enable(ctx.chat.id, rawName);
270-
} else {
271-
skillRegistry.disable(ctx.chat.id, rawName);
274+
await sendChunkedMarkdown(
275+
ctx,
276+
[
277+
actionResult.changed
278+
? `skill ${rawName} 已启用。`
279+
: `skill ${rawName} 已处于启用状态。`,
280+
...formatSkillLines(actionResult.skills)
281+
].join("\n")
282+
);
283+
return;
272284
}
273285

274286
await sendChunkedMarkdown(
275287
ctx,
276288
[
277-
`skill ${rawName}${/^on$/i.test(action) ? "启用" : "禁用"}。`,
278-
...formatSkillLines(skillRegistry.list(ctx.chat.id))
289+
actionResult.changed
290+
? `skill ${rawName} 已禁用。`
291+
: `skill ${rawName} 已处于禁用状态。`,
292+
...formatSkillLines(actionResult.skills)
279293
].join("\n")
280294
);
281295
} catch (error) {
@@ -293,6 +307,16 @@ export function registerHandlers({
293307
);
294308
});
295309

310+
bot.command("restart", async (ctx) => {
311+
if (!adminActions?.restart) {
312+
await sendChunkedMarkdown(ctx, "当前环境未启用 bot 重启控制。");
313+
return;
314+
}
315+
316+
await sendChunkedMarkdown(ctx, "正在重启 bot 进程...");
317+
await adminActions.restart();
318+
});
319+
296320
bot.command("exec", async (ctx) => {
297321
const task = extractCommandPayload(ctx.message.text, "exec");
298322
if (!task) {
@@ -527,6 +551,10 @@ export function registerHandlers({
527551
bot.on("text", async (ctx) => {
528552
const text = ctx.message.text?.trim() || "";
529553
if (!text) return;
554+
if (/^(\s*bot||restart bot)$/i.test(text)) {
555+
await sendChunkedMarkdown(ctx, "请使用 /restart,而不是把它当作普通消息发送。");
556+
return;
557+
}
530558
if (/^\/\s+\S+/.test(text)) {
531559
await sendChunkedMarkdown(
532560
ctx,

src/index.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { spawn } from "node:child_process";
2+
import process from "node:process";
13
import { Telegraf } from "telegraf";
24
import { loadConfig } from "./config.js";
35
import { RuntimeStateStore } from "./runtimeStateStore.js";
@@ -28,6 +30,28 @@ async function saveRuntimeState() {
2830
});
2931
}
3032

33+
async function restartBotProcess() {
34+
await saveRuntimeState();
35+
36+
const bootstrapScript = [
37+
"const { spawn } = require('node:child_process');",
38+
"setTimeout(() => {",
39+
` const child = spawn(process.execPath, ['src/index.js'], { cwd: ${JSON.stringify(process.cwd())}, env: process.env, detached: true, stdio: 'ignore' });`,
40+
" child.unref();",
41+
"}, 1500);"
42+
].join("\n");
43+
44+
const launcher = spawn(process.execPath, ["-e", bootstrapScript], {
45+
cwd: process.cwd(),
46+
env: process.env,
47+
detached: true,
48+
stdio: "ignore"
49+
});
50+
launcher.unref();
51+
52+
await shutdown("RESTART");
53+
}
54+
3155
bot.use(createAuthMiddleware(config));
3256

3357
const runtimeState = await stateStore.load();
@@ -76,7 +100,10 @@ registerHandlers({
76100
shellManager,
77101
skills,
78102
skillRegistry,
79-
scheduler
103+
scheduler,
104+
adminActions: {
105+
restart: restartBotProcess
106+
}
80107
});
81108

82109
bot.catch(async (error, ctx) => {

src/orchestrator/mcpClient.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,34 @@ export class McpClient {
146146
throw new Error(`Unknown MCP server: ${serverName}`);
147147
}
148148

149+
if (this.disabledServers.has(serverName)) {
150+
const current = this.listServers().find((server) => server.name === serverName) || null;
151+
return current ? { ...current, changed: false } : null;
152+
}
153+
149154
this.disabledServers.add(serverName);
150155
await this.disconnectServer(serverName);
151156
this.onChange?.(this.exportState());
152-
return this.listServers().find((server) => server.name === serverName) || null;
157+
const current = this.listServers().find((server) => server.name === serverName) || null;
158+
return current ? { ...current, changed: true } : null;
153159
}
154160

155161
async enableServer(serverName) {
156162
if (!this.hasServer(serverName)) {
157163
throw new Error(`Unknown MCP server: ${serverName}`);
158164
}
159165

166+
if (!this.disabledServers.has(serverName) && this.isServerConnected(serverName)) {
167+
const current = this.listServers().find((server) => server.name === serverName) || null;
168+
return current ? { ...current, changed: false } : null;
169+
}
170+
171+
const changed = this.disabledServers.has(serverName);
160172
this.disabledServers.delete(serverName);
161173
await this.connectServerByName(serverName);
162174
this.onChange?.(this.exportState());
163-
return this.listServers().find((server) => server.name === serverName) || null;
175+
const current = this.listServers().find((server) => server.name === serverName) || null;
176+
return current ? { ...current, changed } : null;
164177
}
165178

166179
exportState() {

src/orchestrator/skillRegistry.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,29 @@ export class SkillRegistry {
4747
enable(chatId, name) {
4848
const normalized = this.ensureKnownSkill(name);
4949
const state = this.ensureChatState(chatId);
50+
const changed = !state.enabledSkills.has(normalized);
5051
state.enabledSkills.add(normalized);
51-
this.onChange?.(this.exportState());
52-
return this.list(chatId);
52+
if (changed) {
53+
this.onChange?.(this.exportState());
54+
}
55+
return {
56+
changed,
57+
skills: this.list(chatId)
58+
};
5359
}
5460

5561
disable(chatId, name) {
5662
const normalized = this.ensureKnownSkill(name);
5763
const state = this.ensureChatState(chatId);
64+
const changed = state.enabledSkills.has(normalized);
5865
state.enabledSkills.delete(normalized);
59-
this.onChange?.(this.exportState());
60-
return this.list(chatId);
66+
if (changed) {
67+
this.onChange?.(this.exportState());
68+
}
69+
return {
70+
changed,
71+
skills: this.list(chatId)
72+
};
6173
}
6274

6375
exportState() {

src/orchestrator/skills/mcpSkill.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ export class McpSkill {
109109

110110
const result = await this.mcpClient.enableServer(serverName);
111111
return {
112-
text: `${result.name} 已启用。connected: ${result.connected ? "yes" : "no"}`
112+
text: result.changed
113+
? `${result.name} 已启用。connected: ${result.connected ? "yes" : "no"}`
114+
: `${result.name} 已处于启用状态。connected: ${result.connected ? "yes" : "no"}`
113115
};
114116
}
115117

@@ -121,7 +123,9 @@ export class McpSkill {
121123

122124
const result = await this.mcpClient.disableServer(serverName);
123125
return {
124-
text: `${result.name} 已禁用。connected: ${result.connected ? "yes" : "no"}`
126+
text: result.changed
127+
? `${result.name} 已禁用。connected: ${result.connected ? "yes" : "no"}`
128+
: `${result.name} 已处于禁用状态。connected: ${result.connected ? "yes" : "no"}`
125129
};
126130
}
127131

tests/mcpClient.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ test("mcp client disable and enable update runtime state", async () => {
5555
});
5656
};
5757

58-
await client.disableServer("context7");
58+
const disabled = await client.disableServer("context7");
59+
assert.equal(disabled.changed, true);
5960
assert.equal(client.isServerEnabled("context7"), false);
6061
assert.equal(client.isServerConnected("context7"), false);
6162

62-
await client.enableServer("context7");
63+
const enabled = await client.enableServer("context7");
64+
assert.equal(enabled.changed, true);
6365
assert.equal(client.isServerEnabled("context7"), true);
6466
assert.equal(client.isServerConnected("context7"), true);
6567
assert.equal(connectCalls, 1);
@@ -105,3 +107,23 @@ test("mcp client exports and restores disabled server state", () => {
105107
assert.equal(client.isServerEnabled("sequential-thinking"), false);
106108
assert.equal(client.isServerEnabled("context7"), true);
107109
});
110+
111+
test("mcp client reports idempotent enable and disable operations", async () => {
112+
const client = createClient();
113+
client.connectServer = async (server) => {
114+
client.connections.set(server.name, {
115+
transport: {
116+
close: async () => {}
117+
}
118+
});
119+
};
120+
client.connections.set("context7", {
121+
transport: {
122+
close: async () => {}
123+
}
124+
});
125+
126+
assert.equal((await client.enableServer("context7")).changed, false);
127+
assert.equal((await client.disableServer("context7")).changed, true);
128+
assert.equal((await client.disableServer("context7")).changed, false);
129+
});

tests/mcpSkill.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,28 @@ test("mcp skill returns expanded help text when no subcommand is provided", asyn
5555
assert.match(result.text, /\/mcp list/);
5656
assert.match(result.text, /\/mcp status \[server\]/);
5757
});
58+
59+
test("mcp skill returns idempotent enable feedback", async () => {
60+
const skill = new McpSkill({
61+
mcpClient: {
62+
hasServers: () => true,
63+
listServers: () => [],
64+
reconnectServer: async () => null,
65+
enableServer: async (name) => ({
66+
name,
67+
enabled: true,
68+
connected: true,
69+
changed: false
70+
}),
71+
disableServer: async () => null,
72+
listTools: async () => [],
73+
callTool: async () => ""
74+
}
75+
});
76+
77+
const result = await skill.execute({
78+
text: "/mcp enable context7"
79+
});
80+
81+
assert.match(result.text, //);
82+
});

tests/skillRegistry.test.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ test("skill registry toggles skills per chat without affecting other chats", ()
2020
mcp: {}
2121
});
2222

23-
registry.disable(1, "github");
23+
const disabled = registry.disable(1, "github");
2424

25+
assert.equal(disabled.changed, true);
2526
assert.equal(registry.isEnabled(1, "github"), false);
2627
assert.equal(registry.isEnabled(2, "github"), true);
2728

28-
registry.enable(1, "github");
29+
const enabled = registry.enable(1, "github");
30+
assert.equal(enabled.changed, true);
2931
assert.equal(registry.isEnabled(1, "github"), true);
3032
});
3133

@@ -54,3 +56,14 @@ test("skill registry exports and restores chat state", () => {
5456
assert.equal(restored.isEnabled(1, "github"), false);
5557
assert.equal(restored.isEnabled(1, "mcp"), true);
5658
});
59+
60+
test("skill registry reports idempotent enable and disable operations", () => {
61+
const registry = new SkillRegistry({
62+
github: {},
63+
mcp: {}
64+
});
65+
66+
assert.equal(registry.enable(1, "github").changed, false);
67+
assert.equal(registry.disable(1, "github").changed, true);
68+
assert.equal(registry.disable(1, "github").changed, false);
69+
});

0 commit comments

Comments
 (0)