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: 1 addition & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Set `chatActions.stopOnNavigation: true` to stop refreshing active chat actions

Page cleanup can be controlled with global `clearChatOnPageOpen`, component `clearChatOnOpen`, or transition `clearChat`. All are optional and the fallback remains `true`, preserving historical behavior. `spamProtection` is also configurable and defaults to `true`.

Page cleanup is applied consistently for `openPage()`, `goToPage()`, and inline-button navigation between different components. Callback actions within the currently open component do not trigger page-level cleanup.
Inline callback routing, `goToPage()`, and `goToPlugin()` preserve the current message so target actions can replace it with `update()`. Page-level cleanup remains part of explicit `openPage()` lifecycle; actions can still request cleanup through their existing `clearChat` behavior.

The returned TBF application now has an idempotent `stop(signal?)` method. Automatic `SIGINT` and `SIGTERM` handling is opt-in through `gracefulShutdown.handleSignals` and defaults to `false`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ You can use them inside functions declared in Component or in actions.
### Component's action methods
Routing
* `this.goToAction({action, data?})` sends user to action inside your component.
* `this.goToPage({page, action?, data?})` opens a page and applies its `clearChatOnOpen`/global cleanup policy.
* `this.goToPage({page, action?, data?})` routes to a page while preserving the current callback message for `update()`.
* `this.goToPlugin({page, action?, data?})` it's like `goToPage()` but it sends user to plugin.

Messaging
Expand Down
28 changes: 12 additions & 16 deletions lib/page_loader.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/page_loader.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@powerdot/telegram_bot_framework",
"version": "2.0.3",
"version": "2.0.4",
"description": "A component-oriented Telegram bot framework built on Telegraf",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down
28 changes: 13 additions & 15 deletions src/page_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,13 @@ function loader({ db, config, inputComponents, componentType, loadedComponents,
let found_component = loadedComponents.find(x => x.id == component && x.type == type);
if (found_component) {
if (config.chatActions?.stopOnNavigation) chatActionManager.stop(_this.ctx);
await db.setValue(_this.ctx, "step", component + "�" + action);
let action_fn = extractHandler(found_component.actions[action]);
try {
return await found_component.open?.({ ctx: _this.ctx, data, action });
const actionBinding = { ..._this, ...{ id: component } };
return await runAction(found_component.actions[action], actionBinding, () =>
action_fn.bind(actionBinding)({ ctx: _this.ctx, data })
);
} catch (error) {
console.error("goToComponent error", error);
return null;
Expand Down Expand Up @@ -488,13 +493,6 @@ function loader({ db, config, inputComponents, componentType, loadedComponents,
let main_fn = extractHandler(pageObject.actions.main);
main_fn.bind(binding);
}
const prepareOpen = async (ctx: TBFContext, clearChat?: boolean) => {
if (ctx.from) {
await db.setValue(ctx, "from", ctx.from);
}
const shouldClearChat = clearChat ?? pageObject.clearChatOnOpen ?? config.clearChatOnPageOpen ?? true;
if (shouldClearChat) await db.messages.removeMessages(ctx);
};
if (!pageObject.onCallbackQuery) {
pageObject.onCallbackQuery = async (ctx: TBFContext) => {
pageObject.ctx = ctx;
Expand Down Expand Up @@ -571,11 +569,7 @@ function loader({ db, config, inputComponents, componentType, loadedComponents,
if (!pageObject.call) {
pageObject.call = async (ctx) => {
if (config.debug) console.log("[call]", pageObject.id, ctx.routing);
if (ctx.routing.type == 'callback_query') {
const previousComponent = ctx.routing.step?.split("�")[0];
if (previousComponent !== pageObject.id) await prepareOpen(ctx);
await pageObject.onCallbackQuery(ctx);
}
if (ctx.routing.type == 'callback_query') await pageObject.onCallbackQuery(ctx);
if (ctx.routing.type == 'message') await pageObject.onMessage(ctx);
const eventHandler = pageObject.events?.[ctx.updateType];
if (eventHandler) await eventHandler.bind({ ...binding, ctx })(ctx);
Expand All @@ -587,12 +581,16 @@ function loader({ db, config, inputComponents, componentType, loadedComponents,
action,
clearChat,
}: { ctx: TBFContext, data: any, action: string, clearChat?: boolean }) {
if (ctx.from) {
await db.setValue(ctx, "from", ctx.from);
}
let act = action || 'main';
let action_fn = extractHandler(pageObject.actions[act]);
await prepareOpen(ctx, clearChat);
const shouldClearChat = clearChat ?? pageObject.clearChatOnOpen ?? config.clearChatOnPageOpen ?? true;
if (shouldClearChat) await db.messages.removeMessages(ctx);
await db.setValue(ctx, "step", pageObject.id + "�" + act);
const actionBinding = { ...binding, ctx };
return await runAction(pageObject.actions[act], actionBinding, () =>
await runAction(pageObject.actions[act], actionBinding, () =>
action_fn.bind(actionBinding)({ ctx, data })
);
}
Expand Down
112 changes: 33 additions & 79 deletions test/page_navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,19 @@ import test from "node:test";

import loadPages from "../src/page_loader";

function callbackContext(step: string, component: string) {
function routing(step: string, component: string) {
return {
chatId: 77,
updateType: "callback_query",
routing: {
type: "callback_query",
component,
action: "main",
data: undefined,
step,
message: undefined,
isMessageFromUser: false,
},
} as never;
type: "callback_query",
component,
action: "main",
data: undefined,
step,
message: undefined,
isMessageFromUser: false,
};
}

test("page cleanup is consistent across programmatic and callback navigation", async () => {
test("callback and goToPage navigation preserve the message used by update", async () => {
const root = mkdtempSync(join(tmpdir(), "tbf-navigation-"));
const pagesPath = join(root, "pages");
const pluginsPath = join(root, "plugins");
Expand All @@ -36,35 +32,31 @@ test("page cleanup is consistent across programmatic and callback navigation", a
}
});
`);
writeFileSync(join(pagesPath, "progress.js"), `
writeFileSync(join(pagesPath, "target.js"), `
module.exports = () => ({
actions: {
main() {
globalThis.__tbfNavigationOrder.push("progress");
return "progress-result";
}
}
});
`);
writeFileSync(join(pagesPath, "settings.js"), `
module.exports = () => ({
clearChatOnOpen: false,
actions: {
main() { globalThis.__tbfNavigationOrder.push("settings"); }
main() { return this.update({ text: "Target" }); }
}
});
`);

const order: string[] = [];
let removals = 0;
let edits = 0;
const db = {
messages: {
async removeMessages() { order.push("remove"); },
async removeMessages() { removals += 1; },
},
async setValue(_ctx: unknown, key: string, value: unknown) {
if (key === "step") order.push(`step:${value}`);
async setValue() {},
} as never;
const ctx = {
chatId: 77,
updateType: "callback_query",
routing: routing("source�main", "source"),
async editMessageText() {
edits += 1;
return true;
},
} as never;
(globalThis as any).__tbfNavigationOrder = order;

try {
const { pages } = loadPages({
Expand All @@ -76,59 +68,21 @@ test("page cleanup is consistent across programmatic and callback navigation", a
},
});
const source = pages.find(page => page.id === "source")!;
const progress = pages.find(page => page.id === "progress")!;
const settings = pages.find(page => page.id === "settings")!;
const sourceCtx = { chatId: 77 } as never;
const target = pages.find(page => page.id === "target")!;

await source.open?.({ ctx: sourceCtx, action: "main", data: undefined });
await source.open?.({ ctx, action: "main", data: undefined });
const sourceBinding = (globalThis as any).__tbfSourceBinding;

order.length = 0;
const result = await sourceBinding.goToPage({ page: "progress", action: "main" });
assert.equal(result, "progress-result");
assert.deepEqual(order, ["remove", "step:progress�main", "progress"]);

order.length = 0;
await sourceBinding.goToPage({ page: "settings", action: "main" });
assert.deepEqual(order, ["step:settings�main", "settings"]);

order.length = 0;
await progress.call?.(callbackContext("source�main", "progress"));
assert.deepEqual(order, ["remove", "step:progress�main", "progress"]);

order.length = 0;
await progress.call?.(callbackContext("progress�details", "progress"));
assert.deepEqual(order, ["step:progress�main", "progress"]);

order.length = 0;
await settings.call?.(callbackContext("progress�main", "settings"));
assert.deepEqual(order, ["step:settings�main", "settings"]);

const noCleanupOrder: string[] = [];
(globalThis as any).__tbfNavigationOrder = noCleanupOrder;
const noCleanupDb = {
messages: {
async removeMessages() { noCleanupOrder.push("remove"); },
},
async setValue(_ctx: unknown, key: string, value: unknown) {
if (key === "step") noCleanupOrder.push(`step:${value}`);
},
} as never;
const { pages: pagesWithoutGlobalCleanup } = loadPages({
db: noCleanupDb,
config: {
pages: { path: pagesPath },
plugins: { path: pluginsPath },
clearChatOnPageOpen: false,
},
});
const progressWithoutGlobalCleanup = pagesWithoutGlobalCleanup.find(page => page.id === "progress")!;
await sourceBinding.goToPage({ page: "target", action: "main" });
assert.equal(removals, 0);
assert.equal(edits, 1);

await progressWithoutGlobalCleanup.call?.(callbackContext("source�main", "progress"));
assert.deepEqual(noCleanupOrder, ["step:progress�main", "progress"]);
(ctx as any).routing = routing("source�main", "target");
await target.call?.(ctx);
assert.equal(removals, 0);
assert.equal(edits, 2);
} finally {
delete (globalThis as any).__tbfSourceBinding;
delete (globalThis as any).__tbfNavigationOrder;
rmSync(root, { recursive: true, force: true });
}
});
Loading