From e53047f889e19ca67c6d4db46bbbfdbefd8106ec Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Tue, 10 Nov 2026 18:14:24 +0000 Subject: [PATCH] telegram: follow the Studio language instead of defaulting to Ukrainian (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/lang answers `c.lang || 'en'`, and a fresh install has no saved `lang`, so the bot's own 'uk' fallback made it answer in Ukrainian under an English UI — the pairing screen first of all. The bot now takes the same default as the SPA and resolves a missing key to English, never to Ukrainian. The pairing screen is the one place with no Studio preference to read yet, so it answers in the writer's Telegram client locale when we ship that language and in the Studio language otherwise. Everything after pairing is unchanged: the configured language still governs. --- package.json | 2 +- server.js | 4 +- telegram-bot.js | 43 +++++++++---- test/telegram-lang.test.js | 127 +++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 test/telegram-lang.test.js diff --git a/package.json b/package.json index 592689eb..4fd7ecd5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js && node test/task-text-limit.test.js && node test/terminal-clipboard.test.js && node test/terminal-addons.test.js", + "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js && node test/task-text-limit.test.js && node test/terminal-clipboard.test.js && node test/terminal-addons.test.js && node test/telegram-lang.test.js", "postinstall": "node scripts/install-hooks.js", "release": "node scripts/release.js", "release:mac": "node scripts/release-mac.js", diff --git a/server.js b/server.js index 6a4c4573..44a046e0 100644 --- a/server.js +++ b/server.js @@ -10616,7 +10616,7 @@ function initTelegramBot() { const tg = c.telegram; if (!tg || !tg.enabled || !tg.botToken) return; - telegramBot = new TelegramBot(db, { log, lang: c.lang || 'uk', getRoster: telegramRoster, isolateUnit: _isolateTelegramUnit }); + telegramBot = new TelegramBot(db, { log, lang: c.lang || 'en', getRoster: telegramRoster, isolateUnit: _isolateTelegramUnit }); telegramBot.acceptNewConnections = tg.acceptNewConnections !== false; _attachTelegramListeners(telegramBot); @@ -10660,7 +10660,7 @@ app.post('/api/telegram/start', (req, res) => { } // Start new bot - telegramBot = new TelegramBot(db, { log, lang: c.lang || 'uk', getRoster: telegramRoster, isolateUnit: _isolateTelegramUnit }); + telegramBot = new TelegramBot(db, { log, lang: c.lang || 'en', getRoster: telegramRoster, isolateUnit: _isolateTelegramUnit }); telegramBot.acceptNewConnections = c.telegram.acceptNewConnections !== false; _attachTelegramListeners(telegramBot); diff --git a/telegram-bot.js b/telegram-bot.js index 4ffaa73b..1b439b24 100644 --- a/telegram-bot.js +++ b/telegram-bot.js @@ -139,7 +139,10 @@ class TelegramBot extends EventEmitter { this._pollTimer = null; this._offset = 0; this._acceptNewConnections = true; - this.lang = opts.lang || 'uk'; + // The Studio UI language, not a Telegram-only default: /api/lang falls back to + // English and a fresh install has no saved `lang`, so a 'uk' fallback here made the + // bot answer in Ukrainian under an English UI (issue #135). + this.lang = opts.lang || 'en'; // Resolves a workdir to its bot roster plus whether this chat's engine can run one. // Projects live in a JSON file that only server.js reads, so this arrives as a // callback rather than a query — same composition as the Forum module's facade. @@ -199,9 +202,13 @@ class TelegramBot extends EventEmitter { // ─── i18n ───────────────────────────────────────────────────────────────── - _t(key, params = {}) { - const dict = BOT_I18N[this.lang] || BOT_I18N.uk; - let text = dict[key] || BOT_I18N.uk[key] || key; + // `lang` overrides the instance language for one call (the pre-pairing screen, which + // has no Studio preference to read yet). The last-resort fallback is English, the same + // language the SPA itself falls back to, never Ukrainian by accident. + _t(key, params = {}, lang = null) { + const target = lang || this.lang; + const dict = BOT_I18N[target] || BOT_I18N.en; + let text = dict[key] || BOT_I18N.en[key] || key; for (const [k, v] of Object.entries(params)) { // A function replacer, not a string one: String.replace(regex, string) treats // '$&', '$`', "$'" and '$$' in the REPLACEMENT as special patterns. A param @@ -212,6 +219,15 @@ class TelegramBot extends EventEmitter { return text; } + // Resolve the language for a message that arrives before a device is paired: there is + // no Studio preference to apply yet and the writer may not be the Studio owner at all, + // so the pairing screen answers in whatever locale their Telegram client reports, as + // long as we ship it. Everything after pairing stays on the Studio language. + _langForUser(msg) { + const code = String(msg?.from?.language_code || '').toLowerCase().split('-')[0]; + return BOT_I18N[code] ? code : this.lang; + } + // ─── Database ────────────────────────────────────────────────────────────── _initDb() { @@ -925,26 +941,27 @@ class TelegramBot extends EventEmitter { const userId = msg.from.id; const chatId = msg.chat.id; const text = msg.text.trim(); + const lang = this._langForUser(msg); // Check if blocked if (this._isBlocked(userId)) { - await this._sendMessage(chatId, this._t('blocked')); + await this._sendMessage(chatId, this._t('blocked', {}, lang)); return; } // /start command if (text === '/start') { if (!this._acceptNewConnections) { - await this._sendMessage(chatId, this._t('new_conn_disabled')); + await this._sendMessage(chatId, this._t('new_conn_disabled', {}, lang)); return; } - await this._sendMessage(chatId, this._t('start_pairing')); + await this._sendMessage(chatId, this._t('start_pairing', {}, lang)); return; } // Anything else — treat as pairing code attempt if (!this._acceptNewConnections) { - await this._sendMessage(chatId, this._t('new_conn_off')); + await this._sendMessage(chatId, this._t('new_conn_off', {}, lang)); return; } @@ -960,7 +977,7 @@ class TelegramBot extends EventEmitter { } catch (err) { // UNIQUE constraint — user already paired (shouldn't happen, but handle gracefully) if (err.message?.includes('UNIQUE')) { - await this._sendMessage(chatId, this._t('already_paired')); + await this._sendMessage(chatId, this._t('already_paired', {}, lang)); return; } throw err; @@ -971,11 +988,11 @@ class TelegramBot extends EventEmitter { this.log.info(`[telegram] Device paired: ${displayName} (@${username || 'no-username'}) [${userId}]`); - await this._sendMessage(chatId, this._t('paired_ok', { name: this._escHtml(displayName) })); + await this._sendMessage(chatId, this._t('paired_ok', { name: this._escHtml(displayName) }, lang)); // Set persistent Reply Keyboard (dynamic, context-aware) const ctx = this._getContext(userId); - await this._sendReplyKeyboard(chatId, ctx, this._t('use_menu')); + await this._sendReplyKeyboard(chatId, ctx, this._t('use_menu', {}, lang)); // Emit event so UI can update in real-time this.emit('device_paired', { @@ -990,9 +1007,9 @@ class TelegramBot extends EventEmitter { const remaining = MAX_FAILED_ATTEMPTS - attempts; if (remaining <= 0) { - await this._sendMessage(chatId, this._t('blocked')); + await this._sendMessage(chatId, this._t('blocked', {}, lang)); } else { - await this._sendMessage(chatId, this._t('invalid_code', { remaining })); + await this._sendMessage(chatId, this._t('invalid_code', { remaining }, lang)); } } } diff --git a/test/telegram-lang.test.js b/test/telegram-lang.test.js new file mode 100644 index 00000000..dcb22d5e --- /dev/null +++ b/test/telegram-lang.test.js @@ -0,0 +1,127 @@ +// Issue #135 — the Telegram bot answered in Ukrainian regardless of the Studio language. +// +// What must hold: +// - an unconfigured bot speaks English, matching the SPA's own fallback (/api/lang +// answers `c.lang || 'en'`), not Ukrainian; +// - a message that arrives before pairing answers in the writer's Telegram client +// locale when we ship that language, and in the bot's language otherwise — there is +// no Studio preference to read before a device is paired; +// - a missing key falls back to English, not to Ukrainian and not to the raw key; +// - the Studio language still wins for everything after pairing. +// +// NEVER touches the real Telegram API: `_callApi` is replaced with a recorder. +// Run: node test/telegram-lang.test.js +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tglang-')); +const db = require('../db-adapter')(path.join(dir, 'test.db')); +db.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at TEXT, updated_at TEXT, + workdir TEXT, git_root TEXT, model TEXT, engine TEXT, run_engine TEXT, agent_mode TEXT, claude_session_id TEXT); + CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, type TEXT, + content TEXT, tool_name TEXT, agent_id TEXT, created_at TEXT DEFAULT (datetime('now'))); + CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT, description TEXT, notes TEXT, status TEXT, + sort_order INTEGER, session_id TEXT, workdir TEXT, git_root TEXT, model TEXT, mode TEXT, agent_mode TEXT, + max_turns INTEGER, attachments TEXT, depends_on TEXT, chain_id TEXT, source_session_id TEXT, + scheduled_at TEXT, recurrence TEXT, recurrence_end_at TEXT, effort TEXT, run_engine TEXT, + created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))); +`); + +const TelegramBot = require('../telegram-bot'); +const BOT_I18N = require('../telegram-bot-i18n'); +const log = { info() {}, warn() {}, error() {}, debug() {} }; + +function makeBot(lang) { + const bot = new TelegramBot(db, { log, lang }); + bot._calls = []; + bot._callApi = async (method, params) => { + bot._calls.push({ method, ...params }); + if (method === 'sendMessage') return { message_id: bot._calls.length, chat: { id: params.chat_id } }; + return {}; + }; + bot._forum._api.callApi = (method, params) => bot._callApi(method, params); + return bot; +} + +const sentText = bot => bot._calls.filter(c => c.method === 'sendMessage').map(c => c.text || '').join('\n'); + +(async () => { + console.log('language resolution:'); + { + // No opts.lang at all — the shape a fresh install produces. + const bot = new TelegramBot(db, { log }); + check('an unconfigured bot speaks English', bot.lang, 'en'); + check('and its pairing screen is the English one', + bot._t('start_pairing'), BOT_I18N.en.start_pairing); + check('a configured Studio language still wins', + new TelegramBot(db, { log, lang: 'uk' })._t('start_pairing'), BOT_I18N.uk.start_pairing); + } + + console.log('\nthe fallback chain ends in English, never Ukrainian:'); + { + const bot = makeBot('en'); + check('an unknown language falls back to English', + TelegramBot.prototype._t.call({ lang: 'zz' }, 'start_pairing'), BOT_I18N.en.start_pairing); + // A language dict that exists but lacks the key: the uk dict is complete today, so + // this is exercised through a key nothing defines. + check('a missing key returns the key, not Ukrainian text', + bot._t('no_such_key_at_all'), 'no_such_key_at_all'); + check('params still interpolate with an explicit language', + bot._t('invalid_code', { remaining: 3 }, 'ru'), + BOT_I18N.ru.invalid_code.replace('{remaining}', '3')); + } + + console.log('\npre-pairing screen follows the Telegram client locale:'); + { + const bot = makeBot('uk'); // Studio set to Ukrainian by the owner + const write = (langCode, text = '/start') => { + bot._calls.length = 0; + return bot._handleUpdate({ + message: { + message_id: 1, date: Math.floor(Date.now() / 1000), text, + from: { id: 9001, first_name: 'Guest', language_code: langCode }, + chat: { id: 9001, type: 'private' }, + }, + }).then(() => sentText(bot)); + }; + + const french = await write('fr'); + check('a French client gets the French pairing screen', french, BOT_I18N.fr.start_pairing); + const hebrew = await write('he-IL'); + check('a regional code is reduced to its language', hebrew, BOT_I18N.he.start_pairing); + const german = await write('de'); + check('an unsupported locale falls back to the Studio language', german, BOT_I18N.uk.start_pairing); + const none = await write(undefined); + check('no locale at all falls back to the Studio language', none, BOT_I18N.uk.start_pairing); + + // The pairing-code failure path is the same screen and must match it. + const bad = await write('fr', 'ABC123'); + // The count itself depends on how many attempts this user already burned, so only + // the template's language is asserted here. + check('a failed pairing code is answered in the same language', + bad.startsWith(BOT_I18N.fr.invalid_code.split('{remaining}')[0]), true); + } + + console.log('\nthe Studio language still governs paired chats:'); + { + const bot = makeBot('uk'); + check('main menu is Ukrainian', bot._t('main_title'), BOT_I18N.uk.main_title); + check('the pairing override is scoped to the pairing flow', + bot._t('main_title', {}, null), BOT_I18N.uk.main_title); + } + + console.log(`\n${pass} passed, ${fail} failed`); + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} + process.exit(fail ? 1 : 0); +})();