Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }); }
});
Expand Down
24 changes: 20 additions & 4 deletions telegram-bot-forum.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
32 changes: 32 additions & 0 deletions telegram-bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions test/telegram-behaviour.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading