diff --git a/package.json b/package.json index 592689e..4fd7ecd 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 6a4c457..44a046e 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 4ffaa73..1b439b2 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 0000000..dcb22d5 --- /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); +})();