From fc4fd21bbaea9ef98e8de4f3e24bc40245a83af6 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Tue, 25 Aug 2026 10:16:04 +0200 Subject: [PATCH 1/2] fix: /cancel command handler + forum topic sync for newly added projects /cancel was advertised in the native Telegram command menu (_setCommands) but had no case in the private-chat command switch, so it fell through to "unknown command". Added _cmdCancel: clears pending attachments and lands on the dialog overview (if a session is active) or the main menu. Forum-mode project topics only synced on the initial /connect sweep, or reactively when session activity happened to reference a workdir. A project added afterward with no chat yet never got a topic. Added ensureProjectTopic() (check-then-create, idempotent) to TelegramBotForum, refactored _syncProjectTopics to use it, and wired TelegramBot#notifyProjectAdded to fan out to every connected forum right after POST /api/projects registers a project. --- server.js | 2 ++ telegram-bot-forum.js | 24 ++++++++++++++++++++---- telegram-bot.js | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/server.js b/server.js index dd8412b6..1f02df43 100644 --- a/server.js +++ b/server.js @@ -8381,6 +8381,7 @@ app.post('/api/projects', (req,res) => { const id = 'proj-' + genId(); projects.push({ id, name, workdir, isRemote:true, remoteHostId, remoteHost: rh.host, sshKeyPath: rh.sshKeyPath||'', password: rh.password||'', port: rh.port||Number(port)||22, createdAt:new Date().toISOString() }); saveProjects(projects); + if (telegramBot) telegramBot.notifyProjectAdded(workdir, name).catch(() => {}); return res.json({ ok:true, id, actions }); } // Local project (existing behavior) @@ -8399,6 +8400,7 @@ app.post('/api/projects', (req,res) => { const id = 'proj-' + genId(); projects.push({ id, name, workdir, createdAt:new Date().toISOString() }); saveProjects(projects); + if (telegramBot) telegramBot.notifyProjectAdded(workdir, name).catch(() => {}); res.json({ ok:true, id, actions }); } catch(e) { res.status(500).json({ error:e.message }); } }); diff --git a/telegram-bot-forum.js b/telegram-bot-forum.js index 05e00e3e..a6ad8145 100644 --- a/telegram-bot-forum.js +++ b/telegram-bot-forum.js @@ -552,17 +552,33 @@ class TelegramBotForum { const workdir = typeof project === 'string' ? project : (project?.workdir || project?.path); if (!workdir) continue; - const existing = this._api.stmts.getForumTopicByWorkdir.get(chatId, 'project', workdir); - if (existing) continue; - const name = (typeof project === 'object' && project?.name) || null; - await this.createProjectTopic(chatId, workdir, name); + await this.ensureProjectTopic(chatId, workdir, name); } } catch { // projects.json may not exist — that's fine } } + /** + * Ensure a project topic exists in this forum chat for the given workdir, + * creating it if missing. No-op (returns the existing thread_id) if one + * already exists. + * + * Public — called both by `_syncProjectTopics` (initial /connect sweep over + * every project on disk) and by `TelegramBot#notifyProjectAdded` (server.js + * calls that right after `POST /api/projects` registers a new one). Without + * the second caller, a project created after the forum's initial /connect + * never got a topic until some session activity happened to reference it via + * `handleActivityCallback`'s auto-create — which for a brand-new project with + * no chat history yet may never happen, so its topic silently never appeared. + */ + async ensureProjectTopic(chatId, workdir, name) { + const existing = this._api.stmts.getForumTopicByWorkdir.get(chatId, 'project', workdir); + if (existing) return existing.thread_id; + return this.createProjectTopic(chatId, workdir, name); + } + /** * Handle commands in the General topic or unknown topics. */ diff --git a/telegram-bot.js b/telegram-bot.js index dc4deb7a..8060b64a 100644 --- a/telegram-bot.js +++ b/telegram-bot.js @@ -1021,6 +1021,7 @@ class TelegramBot extends EventEmitter { case '/forum': return this._forum.cmdForum(chatId, userId); case '/tunnel': return this._cmdTunnel(chatId, userId); case '/url': return this._cmdUrl(chatId); + case '/cancel': return this._cmdCancel(chatId, userId); default: await this._sendMessage(chatId, this._t('error_unknown_cmd', { cmd }), { reply_markup: JSON.stringify({ inline_keyboard: [ @@ -1389,6 +1390,15 @@ class TelegramBot extends EventEmitter { return this._screenSettings(chatId, userId); } + // FSM state is already reset generically before the command switch (FSM-03) — + // this only needs to clear pending attachments and land the user somewhere sane. + async _cmdCancel(chatId, userId) { + const ctx = this._getContext(userId); + ctx.pendingAttachments = []; + if (ctx.sessionId) return this._screenDialog(chatId, userId, { mode: 'overview' }); + return this._screenMainMenu(chatId, userId); + } + // ─── Tunnel Commands ────────────────────────────────────────────────────── async _cmdTunnel(chatId, userId, { editMsgId } = {}) { @@ -1494,6 +1504,28 @@ class TelegramBot extends EventEmitter { } } + /** + * Ensure every connected forum has a topic for a newly registered project. + * Called by server.js right after `POST /api/projects` adds one. Without this, + * a project's topic list only refreshed on the initial /connect sweep, or + * reactively when session activity happened to reference the workdir — a + * project added afterward with no chat yet never got a topic at all. + */ + async notifyProjectAdded(workdir, name) { + if (!this.running || !workdir) return; + const devices = this._stmts.getAllDevices.all(); + const seenChats = new Set(); + for (const dev of devices) { + if (!dev.forum_chat_id || seenChats.has(dev.forum_chat_id)) continue; + seenChats.add(dev.forum_chat_id); + try { + await this._forum.ensureProjectTopic(dev.forum_chat_id, workdir, name); + } catch (err) { + this.log.warn(`[telegram] Failed to sync project topic for ${workdir}: ${err.message}`); + } + } + } + // ─── Text Messages (Send to Chat) ───────────────────────────────────────── async _handleTextMessage(msg) { From d8cac9152be5ddb45aa1979ef10170f44359b7c2 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Tue, 25 Aug 2026 10:26:13 +0200 Subject: [PATCH 2/2] test: cover /cancel and notifyProjectAdded (topic sync on project add) Adds coverage for both fixes in fc4fd21: - /cancel clears pending attachments and lands on a sane screen instead of falling through to the "unknown command" reply. - notifyProjectAdded creates exactly one topic per connected forum, is idempotent on repeat calls, and no-ops on an empty workdir. Also fixes a pre-existing test-harness leak: the media-attachment block (forum attachments land in the forum-scoped store) overwrote bot._callApi with a mock that had no createForumTopic branch and never restored the original afterward, so every test running after it silently got {} back from createForumTopic instead of a message_thread_id -- surfaced as a "cannot bind SQLite parameter 1" error swallowed by notifyProjectAdded's own catch/warn. The new tests are what exposed it; unrelated to fc4fd21's application code. --- test/telegram-behaviour.test.js | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/telegram-behaviour.test.js b/test/telegram-behaviour.test.js index fe511bb0..758180dd 100644 --- a/test/telegram-behaviour.test.js +++ b/test/telegram-behaviour.test.js @@ -148,6 +148,7 @@ const msg = (over = {}) => ({ dctx.pendingAttachments = []; dctx.sessionId = 'a1'; // Stub the file download so no network call happens. + const preMediaCallApi = bot._callApi; bot._callApi = async (method, params) => { calls.push({ method, ...params }); if (method === 'getFile') return { file_path: 'photos/x.jpg' }; @@ -158,6 +159,7 @@ const msg = (over = {}) => ({ global.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }); await bot._handleMediaMessage(msg({ text: undefined, photo: [{ file_id: 'F1' }], message_thread_id: 4 })); global.fetch = realFetch; + bot._callApi = preMediaCallApi; check('a photo sent in a topic is stored for THAT topic', fctx.pendingAttachments.length, 1); check('and does not leak into the private-chat context', dctx.pendingAttachments.length, 0); } @@ -404,6 +406,48 @@ const msg = (over = {}) => ({ bot._callApi = origCall; } + console.log('\n/cancel is advertised in the native menu and must actually be handled:'); + { + const privMsg = (over = {}) => ({ + message_id: 1, from: { id: USER }, chat: { id: PRIVATE_CHAT, type: 'private' }, text: '/cancel', ...over, + }); + + reset(); sent.length = 0; + const ctx = bot._getContext(USER); + ctx.sessionId = 'a1'; + ctx.pendingAttachments = [{ name: 'x.png' }]; + await bot._handleUpdate({ message: privMsg() }); + check('never falls through to "unknown command"', sends().some(s => /Unknown command/i.test(s.text || '')), false); + check('clears pending attachments', ctx.pendingAttachments.length, 0); + + reset(); sent.length = 0; + ctx.sessionId = null; + await bot._handleUpdate({ message: privMsg() }); + check('with no active session, lands on the main menu, not an error', sends().some(s => /Unknown command/i.test(s.text || '')), false); + } + + console.log('\na project added after /connect still gets a forum topic:'); + { + reset(); + // No topic exists yet for /w/gamma in any forum. + const before = db.prepare("SELECT * FROM forum_topics WHERE type='project' AND workdir='/w/gamma'").get(); + check('no topic exists yet', before, undefined); + + await bot.notifyProjectAdded('/w/gamma', 'Gamma'); + const created = calls.filter(c => c.method === 'createForumTopic'); + check('exactly one topic is created (one connected forum)', created.length, 1); + const row = db.prepare("SELECT * FROM forum_topics WHERE type='project' AND workdir='/w/gamma'").get(); + check('and it is recorded against the workdir', row?.workdir, '/w/gamma'); + + reset(); + await bot.notifyProjectAdded('/w/gamma', 'Gamma'); + check('a second registration creates no duplicate', calls.some(c => c.method === 'createForumTopic'), false); + + reset(); + await bot.notifyProjectAdded('', 'No workdir'); + check('an empty workdir is a no-op, not a crash', calls.some(c => c.method === 'createForumTopic'), false); + } + console.log(`\n${pass} passed, ${fail} failed`); try { fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); } catch {} process.exit(fail ? 1 : 0);