Skip to content

Commit 61a767c

Browse files
committed
feat(bot): i18n, user locale API, and beta bootstrap command
- Store userLocales in state; POST /api/bot/user-locale/sync and /set (bot secret) - Russian and English copy via i18n; localized inline keyboard labels - Bootstrap: npx -y @spawn-dock/create@beta --token … - Preview notify uses owner locale from state Made-with: Cursor
1 parent 2b5cd73 commit 61a767c

16 files changed

Lines changed: 525 additions & 131 deletions

bot-resource/helo-description.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
SpawnDock bot for Telegram Mini Apps tied to the SpawnDock control plane
44

5-
/new <title> — registers a project and replies with a one-time bootstrap command: npx @spawn-dock/create --token … Run that locally, then npm run dev, the bot sends preview URL, TMA gateway URL, and a Telegram Mini App open link
5+
/new <title> — registers a project and replies with a one-time bootstrap command: npx -y @spawn-dock/create@beta --token … Run that locally, then npm run dev, the bot sends preview URL, TMA gateway URL, and a Telegram Mini App open link
66

77
/launch <slug> — shows the same URLs and whether the dev tunnel is connected or offline
88

src/__tests__/bot-commands.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,29 @@ describe("parseCommand", () => {
2121
it("rejects /launch without slug", () => {
2222
expect(parseCommand("/launch").tag).toBe("launch-missing");
2323
});
24+
it("parses /lang without arg as lang-help", () => {
25+
expect(parseCommand("/lang")).toEqual({ tag: "lang-help" });
26+
});
27+
it("parses /lang en", () => {
28+
expect(parseCommand("/lang en")).toEqual({ tag: "lang-set", locale: "en" });
29+
});
30+
it("parses /lang ru", () => {
31+
expect(parseCommand("/lang ru")).toEqual({ tag: "lang-set", locale: "ru" });
32+
});
33+
it("parses unknown /lang code as lang-invalid", () => {
34+
expect(parseCommand("/lang de")).toEqual({ tag: "lang-invalid", arg: "de" });
35+
});
2436
it("returns unknown for random text", () => {
2537
expect(parseCommand("hello").tag).toBe("unknown");
2638
});
2739
});
2840

2941
describe("bot messages", () => {
30-
it("createdMessage has bootstrap and npm run agent only (no preview links yet)", () => {
42+
it("createdMessage (ru) has bootstrap and npm run agent only (no preview links yet)", () => {
3143
const message = createdMessage(
44+
"ru",
3245
"demo-app",
33-
"npx @spawn-dock/create --token pair_demo demo-app",
46+
"npx -y @spawn-dock/create@beta --token pair_demo",
3447
);
3548

3649
expect(message).not.toContain("Preview URL:");
@@ -45,8 +58,21 @@ describe("bot messages", () => {
4558
expect(message).not.toContain("pnpm");
4659
});
4760

61+
it("createdMessage (en) uses English copy", () => {
62+
const message = createdMessage(
63+
"en",
64+
"demo-app",
65+
"npx -y @spawn-dock/create@beta --token pair_demo",
66+
);
67+
68+
expect(message).toContain("Project");
69+
expect(message).toContain("1. Run the bootstrap command locally:");
70+
expect(message).toContain("<code>npm run agent</code>");
71+
});
72+
4873
it("includes TMA and preview links in launchMessage", () => {
4974
const message = launchMessage(
75+
"en",
5076
"demo-app",
5177
"connected",
5278
"https://example.com/tma?tgWebAppStartParam=demo-app",
@@ -57,7 +83,7 @@ describe("bot messages", () => {
5783
expect(message).toContain("TMA URL:");
5884
expect(message).toContain("Telegram Link:");
5985
expect(message).toContain("Preview URL:");
60-
expect(message).toContain("Tunnel Status:");
86+
expect(message).toContain("Tunnel status:");
6187
expect(message).toContain("<code>npm run agent</code>");
6288
});
6389
});

src/__tests__/mcp-auth.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const state: StoreState = {
1515
revokedAt: null,
1616
}],
1717
tunnelSessions: [],
18+
userLocales: {},
1819
};
1920

2021
describe("readMcpApiKey", () => {

src/__tests__/server.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ process.env.TELEGRAM_MINI_APP_SHORT_NAME ??= "tma";
2727

2828
function createRuntime(): Runtime {
2929
return {
30-
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [] },
30+
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [], userLocales: {} },
3131
connectionsBySlug: new Map(),
3232
pendingResponses: new Map(),
3333
};
@@ -49,6 +49,7 @@ function createAuthorizedRuntime(): Runtime {
4949
revokedAt: null,
5050
}],
5151
tunnelSessions: [],
52+
userLocales: {},
5253
},
5354
};
5455
}
@@ -58,7 +59,7 @@ const authorizedHeaders = {
5859
};
5960

6061
const mockRuntime: Runtime = {
61-
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [] },
62+
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [], userLocales: {} },
6263
connectionsBySlug: new Map(),
6364
pendingResponses: new Map(),
6465
};
@@ -115,7 +116,7 @@ describe("Express server", () => {
115116

116117
it("POST /v1/bootstrap/claim returns flat bootstrap fields", async () => {
117118
const app = createApp({
118-
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [] },
119+
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [], userLocales: {} },
119120
connectionsBySlug: new Map(),
120121
pendingResponses: new Map(),
121122
});
@@ -171,7 +172,7 @@ describe("Express server", () => {
171172

172173
it("POST /v1/bootstrap/claim replays the same credential when the token was already claimed", async () => {
173174
const app = createApp({
174-
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [] },
175+
state: { projects: [], pairingTokens: [], deviceCredentials: [], tunnelSessions: [], userLocales: {} },
175176
connectionsBySlug: new Map(),
176177
pendingResponses: new Map(),
177178
});
@@ -208,6 +209,35 @@ describe("Express server", () => {
208209
expect(res.body.error).toBe("bot_unauthorized");
209210
});
210211

212+
it("POST /api/bot/user-locale/sync stores locale from Telegram language_code", async () => {
213+
const runtime = createRuntime();
214+
const app = createApp(runtime);
215+
216+
const res = await request(app)
217+
.post("/api/bot/user-locale/sync")
218+
.set(botHeaders)
219+
.send({ ownerTelegramId: 99, telegramLanguageCode: "ru-RU" });
220+
221+
expect(res.status).toBe(200);
222+
expect(res.body.locale).toBe("ru");
223+
expect(runtime.state.userLocales["99"]).toBe("ru");
224+
});
225+
226+
it("POST /api/bot/user-locale/set updates explicit locale", async () => {
227+
const runtime = createRuntime();
228+
runtime.state.userLocales["5"] = "ru";
229+
const app = createApp(runtime);
230+
231+
const res = await request(app)
232+
.post("/api/bot/user-locale/set")
233+
.set(botHeaders)
234+
.send({ ownerTelegramId: 5, locale: "en" });
235+
236+
expect(res.status).toBe(200);
237+
expect(res.body.locale).toBe("en");
238+
expect(runtime.state.userLocales["5"]).toBe("en");
239+
});
240+
211241
it("POST /projects accepts bot-authorized creation without /api prefix", async () => {
212242
const app = createApp(createRuntime());
213243

@@ -284,6 +314,7 @@ describe("Express server", () => {
284314
pairingTokens: [],
285315
deviceCredentials: [],
286316
tunnelSessions: [],
317+
userLocales: {},
287318
},
288319
connectionsBySlug: new Map(),
289320
pendingResponses: new Map(),

src/bot/commands.ts

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
// src/bot/commands.ts
2+
import type { BotLocale } from "../types.js";
3+
import { isBotLocale } from "./user-locale-state.js";
4+
25
export type BotCommand =
36
| { tag: "start" }
47
| { tag: "help" }
58
| { tag: "new"; title: string }
69
| { tag: "launch"; slug: string }
710
| { tag: "launch-missing" }
11+
| { tag: "lang-help" }
12+
| { tag: "lang-set"; locale: BotLocale }
13+
| { tag: "lang-invalid"; arg: string }
814
| { tag: "unknown"; input: string };
915

1016
export function parseCommand(input: string): BotCommand {
1117
const text = input.trim().replace(/\s+/g, " ");
1218
if (text === "/start") return { tag: "start" };
1319
if (text === "/help") return { tag: "help" };
20+
if (text.startsWith("/lang")) {
21+
const rest = text.slice(5).trim().toLowerCase();
22+
if (!rest) return { tag: "lang-help" };
23+
if (isBotLocale(rest)) return { tag: "lang-set", locale: rest };
24+
return { tag: "lang-invalid", arg: rest };
25+
}
1426
if (text.startsWith("/new")) {
1527
const title = text.slice(4).trim();
1628
return { tag: "new", title: title || "SpawnDock App" };
@@ -22,65 +34,10 @@ export function parseCommand(input: string): BotCommand {
2234
return { tag: "unknown", input: text };
2335
}
2436

25-
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
26-
const escAttr = (s: string) => s
27-
.replace(/&/g, "&amp;")
28-
.replace(/"/g, "&quot;")
29-
.replace(/</g, "&lt;")
30-
.replace(/>/g, "&gt;");
31-
32-
function renderLink(url: string): string {
33-
return `<a href="${escAttr(url)}">${esc(url)}</a>`;
34-
}
35-
36-
export function welcomeMessage(): string {
37-
return [
38-
"SpawnDock bot готов.",
39-
"",
40-
"Команды:",
41-
"/new &lt;название проекта&gt; — создать TMA и получить bootstrap-команду",
42-
"/launch &lt;slug&gt; — получить TMA и preview ссылки проекта",
43-
"/help — показать справку",
44-
].join("\n");
45-
}
46-
47-
export function unknownMessage(): string {
48-
return "Не понял команду.\n\nИспользуй /new &lt;название проекта&gt; или /help.";
49-
}
50-
51-
export function launchUsageMessage(): string {
52-
return "Используй /launch &lt;slug&gt;, чтобы получить preview URL и текущий статус туннеля.";
53-
}
54-
55-
/** First message after /new: bootstrap + npm only. Preview links are sent later when the tunnel is up (see preview-notify). */
56-
export function createdMessage(slug: string, bootstrapCmd: string): string {
57-
return [
58-
`Проект <b>${esc(slug)}</b> создан.`,
59-
"",
60-
"1. Запусти bootstrap-команду локально:",
61-
`<code>${esc(bootstrapCmd)}</code>`,
62-
"Эту команду можно запускать повторно для этого проекта.",
63-
"",
64-
"2. После bootstrap запусти агента и превью одной командой:",
65-
"<code>npm run agent</code>",
66-
].join("\n");
67-
}
68-
69-
export function launchMessage(
70-
slug: string,
71-
status: "connected" | "offline",
72-
tmaUrl: string,
73-
previewUrl: string,
74-
telegramMiniAppUrl: string,
75-
): string {
76-
return [
77-
`Проект <b>${esc(slug)}</b>.`,
78-
"",
79-
`Tunnel Status: <b>${esc(status)}</b>`,
80-
`TMA URL: ${renderLink(tmaUrl)}`,
81-
`Telegram Link: ${renderLink(telegramMiniAppUrl)}`,
82-
`Preview URL: ${renderLink(previewUrl)}`,
83-
"",
84-
"Если preview офлайн, запусти <code>npm run agent</code> в каталоге проекта.",
85-
].join("\n");
86-
}
37+
export {
38+
welcomeMessage,
39+
unknownMessage,
40+
createdMessage,
41+
launchMessage,
42+
launchUsageMessage,
43+
} from "./i18n.js";

0 commit comments

Comments
 (0)