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) { 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);