Skip to content

Commit ea58af8

Browse files
committed
feat: persist telegram skill and mcp state
1 parent b1cbdbf commit ea58af8

12 files changed

Lines changed: 248 additions & 5 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
BOT_TOKEN=123456789:telegram-token-from-botfather
22
ALLOWED_USER_IDS=123456789,987654321
33
PROACTIVE_USER_IDS=123456789
4+
STATE_FILE=.codex-telegram-claws-state.json
45

56
CODEX_COMMAND=codex
67
CODEX_ARGS=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
.env
33
.DS_Store
44
npm-debug.log*
5+
.codex-telegram-claws-state.json

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Minimum required:
4747
```bash
4848
BOT_TOKEN=123456789:telegram-token
4949
ALLOWED_USER_IDS=123456789
50+
STATE_FILE=.codex-telegram-claws-state.json
5051
WORKSPACE_ROOT=.
5152
CODEX_WORKDIR=.
5253
```
@@ -201,6 +202,7 @@ Required:
201202
```bash
202203
BOT_TOKEN=...
203204
ALLOWED_USER_IDS=123456789,987654321
205+
STATE_FILE=.codex-telegram-claws-state.json
204206
WORKSPACE_ROOT=.
205207
CODEX_WORKDIR=.
206208
```
@@ -211,6 +213,7 @@ Common options:
211213
CODEX_COMMAND=codex
212214
CODEX_ARGS=
213215
WORKSPACE_ROOT=/Users/yourname/projects
216+
STATE_FILE=/path/to/codex-telegram-claws-state.json
214217
SHELL_ENABLED=false
215218
SHELL_READ_ONLY=true
216219
SHELL_ALLOWED_COMMANDS=["pwd","ls","git status","git diff --stat","npm test","npm run check"]
@@ -274,6 +277,7 @@ Telegram can manage runtime usage of Bot-side MCP and skills, but not install ar
274277
- MCP servers are process-level runtime resources: list, inspect, reconnect, enable, disable
275278
- Skills are chat-level routing switches: each chat can enable or disable `github` and `mcp` independently
276279
- Codex's own MCP remains separate and is not managed through these bot commands
280+
- Runtime state is persisted to `STATE_FILE`, so `/mcp enable|disable` and `/skill on|off` survive bot restarts
277281

278282
## Troubleshooting
279283

src/config.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ function resolveDirectory(value, name, fallback = process.cwd()) {
6464
return resolvedFallback;
6565
}
6666

67+
function resolveFile(value, fallback) {
68+
const candidate = path.resolve(value || fallback);
69+
const directory = path.dirname(candidate);
70+
71+
if (!fs.existsSync(directory)) {
72+
fs.mkdirSync(directory, { recursive: true });
73+
}
74+
75+
return candidate;
76+
}
77+
6778
function normalizeMcpServer(raw, index) {
6879
if (!raw || typeof raw !== "object") return null;
6980
if (!raw.name || !raw.command) {
@@ -113,7 +124,11 @@ export function loadConfig() {
113124

114125
return {
115126
app: {
116-
name: "codex-telegram-claws"
127+
name: "codex-telegram-claws",
128+
stateFile: resolveFile(
129+
process.env.STATE_FILE,
130+
path.join(process.cwd(), ".codex-telegram-claws-state.json")
131+
)
117132
},
118133
workspace: {
119134
root: workspaceRoot

src/index.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Telegraf } from "telegraf";
22
import { loadConfig } from "./config.js";
3+
import { RuntimeStateStore } from "./runtimeStateStore.js";
34
import { createAuthMiddleware } from "./bot/middleware.js";
45
import { registerHandlers } from "./bot/handlers.js";
56
import { Router } from "./orchestrator/router.js";
@@ -15,10 +16,25 @@ const config = loadConfig();
1516
const bot = new Telegraf(config.telegram.botToken, {
1617
handlerTimeout: 120000
1718
});
19+
const stateStore = new RuntimeStateStore({ config });
20+
let mcpClient;
21+
let skillRegistry;
22+
23+
async function saveRuntimeState() {
24+
if (!mcpClient || !skillRegistry) return;
25+
await stateStore.save({
26+
mcp: mcpClient.exportState(),
27+
skills: skillRegistry.exportState()
28+
});
29+
}
1830

1931
bot.use(createAuthMiddleware(config));
2032

21-
const mcpClient = new McpClient(config);
33+
const runtimeState = await stateStore.load();
34+
mcpClient = new McpClient(config, {
35+
onChange: () => void saveRuntimeState()
36+
});
37+
mcpClient.restoreState(runtimeState.mcp);
2238
await mcpClient.connectAll().catch((error) => {
2339
console.error("[mcp] connect failed:", error.message);
2440
});
@@ -29,7 +45,10 @@ const skills = {
2945
github: githubSkill,
3046
mcp: mcpSkill
3147
};
32-
const skillRegistry = new SkillRegistry(skills);
48+
skillRegistry = new SkillRegistry(skills, {
49+
onChange: () => void saveRuntimeState()
50+
});
51+
skillRegistry.restoreState(runtimeState.skills);
3352

3453
const router = new Router({
3554
skills,

src/orchestrator/mcpClient.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ function normalizeToolContent(result) {
2525
}
2626

2727
export class McpClient {
28-
constructor(config) {
28+
constructor(config, { onChange } = {}) {
2929
this.config = config;
3030
this.connections = new Map();
3131
this.disabledServers = new Set();
32+
this.onChange = onChange;
3233
}
3334

3435
hasServers() {
@@ -147,6 +148,7 @@ export class McpClient {
147148

148149
this.disabledServers.add(serverName);
149150
await this.disconnectServer(serverName);
151+
this.onChange?.(this.exportState());
150152
return this.listServers().find((server) => server.name === serverName) || null;
151153
}
152154

@@ -157,9 +159,24 @@ export class McpClient {
157159

158160
this.disabledServers.delete(serverName);
159161
await this.connectServerByName(serverName);
162+
this.onChange?.(this.exportState());
160163
return this.listServers().find((server) => server.name === serverName) || null;
161164
}
162165

166+
exportState() {
167+
return {
168+
disabledServers: [...this.disabledServers].sort()
169+
};
170+
}
171+
172+
restoreState(snapshot = {}) {
173+
const disabledServers = Array.isArray(snapshot?.disabledServers)
174+
? snapshot.disabledServers.filter((serverName) => this.hasServer(serverName))
175+
: [];
176+
177+
this.disabledServers = new Set(disabledServers);
178+
}
179+
163180
async listTools(serverName) {
164181
const conn = this.connections.get(serverName);
165182
if (!conn) throw new Error(`MCP server not connected: ${serverName}`);

src/orchestrator/skillRegistry.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export class SkillRegistry {
2-
constructor(skills = {}) {
2+
constructor(skills = {}, { onChange } = {}) {
33
this.skillNames = Object.keys(skills).sort();
44
this.chatStates = new Map();
5+
this.onChange = onChange;
56
}
67

78
normalizeSkillName(name) {
@@ -47,13 +48,47 @@ export class SkillRegistry {
4748
const normalized = this.ensureKnownSkill(name);
4849
const state = this.ensureChatState(chatId);
4950
state.enabledSkills.add(normalized);
51+
this.onChange?.(this.exportState());
5052
return this.list(chatId);
5153
}
5254

5355
disable(chatId, name) {
5456
const normalized = this.ensureKnownSkill(name);
5557
const state = this.ensureChatState(chatId);
5658
state.enabledSkills.delete(normalized);
59+
this.onChange?.(this.exportState());
5760
return this.list(chatId);
5861
}
62+
63+
exportState() {
64+
const chats = {};
65+
for (const [chatId, state] of this.chatStates.entries()) {
66+
chats[chatId] = {
67+
enabledSkills: [...state.enabledSkills].sort()
68+
};
69+
}
70+
71+
return {
72+
chats
73+
};
74+
}
75+
76+
restoreState(snapshot = {}) {
77+
const chats = snapshot?.chats;
78+
if (!chats || typeof chats !== "object") return;
79+
80+
this.chatStates.clear();
81+
82+
for (const [chatId, state] of Object.entries(chats)) {
83+
const enabledSkills = Array.isArray(state?.enabledSkills)
84+
? state.enabledSkills
85+
.map((skill) => this.normalizeSkillName(skill))
86+
.filter((skill) => this.skillNames.includes(skill))
87+
: this.skillNames;
88+
89+
this.chatStates.set(String(chatId), {
90+
enabledSkills: new Set(enabledSkills)
91+
});
92+
}
93+
}
5994
}

src/runtimeStateStore.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import fs from "node:fs/promises";
2+
3+
function defaultState() {
4+
return {
5+
version: 1,
6+
mcp: {
7+
disabledServers: []
8+
},
9+
skills: {
10+
chats: {}
11+
}
12+
};
13+
}
14+
15+
export class RuntimeStateStore {
16+
constructor({ config }) {
17+
this.file = config.app.stateFile;
18+
this.writeQueue = Promise.resolve();
19+
}
20+
21+
async load() {
22+
try {
23+
const raw = await fs.readFile(this.file, "utf8");
24+
const parsed = JSON.parse(raw);
25+
return {
26+
...defaultState(),
27+
...parsed,
28+
mcp: {
29+
...defaultState().mcp,
30+
...(parsed?.mcp || {})
31+
},
32+
skills: {
33+
...defaultState().skills,
34+
...(parsed?.skills || {})
35+
}
36+
};
37+
} catch (error) {
38+
if (error?.code === "ENOENT") {
39+
return defaultState();
40+
}
41+
42+
console.warn(`[state] failed to load runtime state: ${error.message}`);
43+
return defaultState();
44+
}
45+
}
46+
47+
async save(snapshot) {
48+
const payload = JSON.stringify(
49+
{
50+
version: 1,
51+
updatedAt: new Date().toISOString(),
52+
...snapshot
53+
},
54+
null,
55+
2
56+
);
57+
58+
this.writeQueue = this.writeQueue
59+
.then(async () => {
60+
const tempFile = `${this.file}.tmp`;
61+
await fs.writeFile(tempFile, payload, "utf8");
62+
await fs.rename(tempFile, this.file);
63+
})
64+
.catch((error) => {
65+
console.warn(`[state] failed to save runtime state: ${error.message}`);
66+
});
67+
68+
return this.writeQueue;
69+
}
70+
}

tests/config.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import test from "node:test";
22
import assert from "node:assert/strict";
3+
import os from "node:os";
4+
import path from "node:path";
35
import { loadConfig } from "../src/config.js";
46

57
const ENV_KEYS = [
68
"BOT_TOKEN",
79
"ALLOWED_USER_IDS",
810
"PROACTIVE_USER_IDS",
11+
"STATE_FILE",
912
"CODEX_COMMAND",
1013
"CODEX_ARGS",
1114
"CODEX_WORKDIR",
@@ -63,11 +66,13 @@ function withMutedWarnings(fn) {
6366
}
6467

6568
test("loadConfig parses env values into runtime config", () => {
69+
const stateFile = path.join(os.tmpdir(), "codex-telegram-claws-runtime-state.json");
6670
const config = withEnv(
6771
{
6872
BOT_TOKEN: "telegram-token",
6973
ALLOWED_USER_IDS: "1, 2",
7074
PROACTIVE_USER_IDS: "2",
75+
STATE_FILE: stateFile,
7176
CODEX_COMMAND: "codex",
7277
CODEX_ARGS: "--approval-mode auto \"--model gpt-5\"",
7378
CODEX_WORKDIR: ".",
@@ -94,6 +99,7 @@ test("loadConfig parses env values into runtime config", () => {
9499
);
95100

96101
assert.equal(config.telegram.botToken, "telegram-token");
102+
assert.equal(config.app.stateFile, stateFile);
97103
assert.deepEqual(config.telegram.allowedUserIds, ["1", "2"]);
98104
assert.deepEqual(config.telegram.proactiveUserIds, ["2"]);
99105
assert.equal(config.runner.command, "codex");

tests/mcpClient.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,16 @@ test("mcp client reconnect refreshes a known enabled server", async () => {
9292
assert.deepEqual(closes, ["context7"]);
9393
assert.equal(connectCalls, 1);
9494
});
95+
96+
test("mcp client exports and restores disabled server state", () => {
97+
const client = createClient();
98+
client.restoreState({
99+
disabledServers: ["sequential-thinking"]
100+
});
101+
102+
assert.deepEqual(client.exportState(), {
103+
disabledServers: ["sequential-thinking"]
104+
});
105+
assert.equal(client.isServerEnabled("sequential-thinking"), false);
106+
assert.equal(client.isServerEnabled("context7"), true);
107+
});

0 commit comments

Comments
 (0)