Skip to content

powerdot/Telegram-Bot-Framework

Repository files navigation

TBF • Telegram Bot Framework

Project Status: Concept – Minimal or no implementation has been done yet, or the repository is only intended to be a limited example, demo, or proof-of-concept.

Node.js framework based on Telegraf to facilitate writing multi-level and multi-page bots.

✈️ Examples are availiable here

Features

  • Component-like developing (pages/plugins)
  • Built-in SQLite and MongoDB storage (routing, sessions, user data, etc.)
  • Built-in Express.js support
  • Works with JS and TS
  • Supports inherit Telegram and Telegraf methods

Installation

Requirements

TBF 2 requires Node.js 24 or newer.

SQLite is used by default and stores bot data in ./data/tbf.sqlite. No external database server is required.

Install package

npm install @powerdot/telegram_bot_framework

Add to your project

For JS project:

// index.js
let { TBF } = require("@powerdot/telegram_bot_framework")

For TS project:

// index.ts
import { TBF } from "@powerdot/telegram_bot_framework"

Create enter point for your bot:

// index.js / index.ts

TBF({
    telegram: {
        token: "xxx", // provide your token
    }
}).then(({ bot, openPage }) => {
    // If bot is ready, you can define own middlewares

    // here is one for /start command
    bot.command("start", async (ctx) => {
        // open page with ID "index"
        await openPage({ ctx, page: "index" })
    })
})

Storage

For a custom SQLite file:

TBF({
    telegram: { token: "xxx" },
    storage: {
        driver: "sqlite",
        filename: "./data/my-bot.sqlite"
    }
})

Use filename: ":memory:" for tests. For MongoDB deployments:

TBF({
    telegram: { token: "xxx" },
    storage: {
        driver: "mongodb",
        url: "mongodb://localhost:27017",
        dbName: "my_bot"
    }
})

The previous mongo: { url, dbName } option remains available for compatibility, but new applications should use storage.

Runtime behavior

New configuration options preserve the historical TBF behavior when omitted:

TBF({
    telegram: { token: "xxx" },
    config: {
        autoRemoveMessages: true,
        clearChatOnPageOpen: true,
        spamProtection: true,
        gracefulShutdown: {
            handleSignals: false
        }
    }
});

clearChatOnPageOpen controls immediate cleanup during programmatic page navigation. It is separate from autoRemoveMessages, which periodically removes old tracked messages.

Cleanup can be overridden for one page:

Component(() => ({
    clearChatOnOpen: false,
    actions: { main() {} }
}));

Or for one transition:

await openPage({
    ctx,
    page: "history",
    clearChat: false
});

The priority is transition option, page option, global config, and finally the compatible default true.

await openPage() waits until the selected page action completes.

The page and plugin loader accepts callable CommonJS exports from .js, .cjs, .ts, and .cts entries, including directory entry points. TypeScript declarations, source maps, JSON, documentation, and other build assets are ignored, so applications do not need to disable .d.ts or source-map output inside their compiled page directories.

Graceful shutdown

TBF returns an idempotent stop() method that stops Telegraf, the message cleanup timer and HTTP server, then closes storage:

Stopping is safe when Telegraf polling is already inactive. TBF still closes the HTTP server and storage client, while unexpected Telegraf shutdown errors continue to be reported.

const app = await TBF({ telegram: { token } });

process.once("SIGTERM", () => {
    void app.stop("SIGTERM");
});

TBF can register SIGINT and SIGTERM handlers itself when explicitly enabled:

config: {
    gracefulShutdown: {
        handleSignals: true
    }
}

Automatic signal handling is disabled by default so the framework does not take ownership of the host application's process lifecycle.

So next step is create index page (check Introduction to Pages section below).

Introduction to Pages

Here we will get acquainted with the concept of pages in the telegram bot.

Page

  1. Create 'pages' folder in your project
  2. Create 'index.js' file in 'pages' folder
  3. Paste code below to 'index.js' file
// pages/index.js

let { Component } = require("@powerdot/telegram_bot_framework")
module.exports = Component(() => { // (1)
    return { // (2)
        actions: {
            async main() { // (3)
                await this.clearChat(); // (4)
                this.send({ // (5)
                    text: `Hey!`
                });
            },
        }
    }
})

Ok, now we have a page with ID index and one action named main.
*Page ID is file name without extension.

🤔 But how it works?

  • (1) - We create component with Component function. And exported it to module.exports.
  • (2) - Component function must to return object of our component.
  • (3) - We define action named main in actions key. It's a default action for every component.
  • (4) - We call clearChat method to clear chat.
  • (5) - We send message with send method.

That's all.

TBF data flow scheme

Checkout the scheme below: Main idea of this scheme is to display that Express and Pages/Plugins are connected by MongoDB database.
And also you can send user's data with API as in this example. And of course your API can interact with TBF Engine (send messages to users, etc.). through connection with TBF entry point.
By the way, you can see there is two routings:

  • TBF routing (TBF Engine, for Bot)
    You can't change routing rules without forking this project. TBF takes care of it by default.
  • Express routing (Express, for Web)
    You can change routing rules on yourself in webserver/index.js file.

Let's start!

Run your bot (node index.js) and send /start command to it.

👀 Let's see what happens.

  1. Your bot waiting for /start command.
// index.js

bot.command("start", async (ctx) => {
    await openPage({ ctx, page: "index" })
})
  1. When you send /start command, your bot will open page with ID index by openPage* function. *openPage() function provided by TBF Engine.
  2. TBF automatically adds default action name to openPage({}) arguments if you don't provide it:
// from
await openPage({ ctx, page: "index" })
// to
await openPage({ ctx, page: "index", action: "main" })

So that's why we need to add main to our actions in component. It's just a default action.
4. TBF triggers main action in index page.

// pages/index.js

let { Component } = require("@powerdot/telegram_bot_framework")
module.exports = Component(() => {
    return {
        actions: { 
            async main() { ... // <-- triggered action
  1. And now we have only 2 commands to execute:
// pages/index.js

...
async main() {
    await this.clearChat(); // TBF Engine's method to clear chat with user
    this.send({ // TBF Engine's method to send message back to user
        text: `Hey!` 
    });
}
...

🎉 Congratulations!
You have successfully created your bot with TBF.
👉 Check out examples here is code and demos!

TBF API

Development and tests

Use Node.js 24 and install the locked dependencies:

nvm use
npm ci

Run the complete pre-merge verification locally:

npm run check

The check includes TypeScript validation, unit tests with enforced coverage thresholds, a production build, and a CommonJS package smoke test. Coverage currently guards callback packing, routing helpers, component discovery, bot middleware, and SQLite storage with minimums of 95% lines, 85% branches, and 99% functions.

GitHub Actions runs the same checks on Node.js 24 for every push and pull request, and also validates the contents of the npm package with npm pack --dry-run.

Publishing

Validate exactly what would be published without changing the npm registry:

npm run release:dry-run

Publish manually from an authenticated npm CLI:

npm run release:publish

npm publish automatically runs prepublishOnly, which performs the complete npm run check pipeline before uploading anything. Because this is a scoped package, publishConfig always selects the public npm registry and public access.

Every push or merge to master runs the Release package GitHub Actions workflow. The workflow reads the version from package.json, verifies the package, publishes a missing version to npm, and creates a GitHub Release with the matching v<version> tag and generated release notes. If that version and release already exist, it exits without publishing duplicates. Therefore, bump package.json before merging a release into master.

Configure npm Trusted Publishing for this repository and the publish.yml workflow before using it. The workflow uses short-lived OIDC credentials and does not require an NPM_TOKEN secret. It creates the GitHub Release as a draft first and makes it public only after npm publishing succeeds. A failed run can be retried with the workflow's Run workflow button on master.

Components

Pages and plugins are components.

Component object

Component is a function that returns object with:

{
    id?: string;
    actions: {
        "main"(){ ... },
        ...(){ ... }
    },
    events?: {
        message_reaction(ctx) { ... },
        poll_answer(ctx) { ... }
    },
    call?: () => {}
    onCallbackQuery?: () => {}
    onMessage?: () => {}
    open?: () => {}
}

The ? mark after key name means that key is optional.
If you don't want to change core logic of your Page/Plugin, you can work only with actions.

  • id defines uniq ID of your component. It's automatically generated by component's file name if you don't provide it.
  • actions is a object with actions inside. main action is default action for every component.
  • events subscribes the component to Telegram update types that do not have a page route.
  • call() is a function that will be triggered when user calls your bot. It's route user's message/action to onCallbackQuery() of onMessage(). You can override it in your component, but it can destruct your bot.
  • onCallbackQuery() is a function that will be triggered when user sends callback query to your bot. You can override it in your component, but it can destruct your bot.
  • onMessage() is a function that will be triggered when user sends message (text, sticker, location...) to your bot. You can override it in your component, but it can destruct your bot.
  • open() is a function that can open current Component programmatically.

Easiest way to create component is to use Component function.

let { Component } = require("@powerdot/telegram_bot_framework");
module.exports = Component(() => {
    return {
        actions: {
            async main() {
                // code here
            },
        }
    }
})

TBF provides to component

  • db TBF database object
  • config App's configuration
  • parseButtons Function that parses TBF Buttons/Keyboard to Telegraf format.
let { Component } = require("@powerdot/telegram_bot_framework");
module.exports = Component(({db, config, parseButtons}) => {
                             ^   ^       ^

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?}) 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

  • this.send({text, buttons?, keyboard?}) sends message to user.
  • this.reply({text, buttons?, keyboard?, options?}) replies to the current message.
  • this.update({text, buttons?, keyboard?}) updates bot's visible last message.
  • this.sendMediaGroup() sends media group to user.
  • this.sendPhoto(), sendVideo(), sendAnimation(), sendAudio(), sendDocument(), sendVoice(), sendSticker() send media and register resulting messages for cleanup.
  • this.sendLocation() and this.sendPoll() send location and poll messages.
  • this.sendChatAction("typing") sends a chat action.
  • this.withChatAction("typing", callback) keeps a chat action active while an asynchronous operation runs.
  • this.react("👍") reacts to the current message.
  • this.clearChat() clears chat with user.

Low-level Telegram API

  • this.api(method, payload) calls any Telegram Bot API method, including methods newer than the installed Telegraf convenience API.

Datastore

  • this.user({user_id?}) returns user database object.
    • .get() returns all user data.
    • .list() returns users list.
    • .setValue(key, value) sets value under key under user.
    • .getValue(key) gets value under key under user.
    • .removeValue(key) removes value under key under user.
    • .destroy() destroys user and all his data.
    • .collection contains methods for user's collections
      • .find(query) find one row in user's collection
      • .findAll(query) find many rows in user's collection
      • .insert(value) insert data to user's collection
      • .update(query, value) update one row in user's collection
      • .updateMany(query, value) update many rows in user's collection
      • .delete(query) delete one row in user's collection
      • .deleteMany(query) delete many rows in user's collection

Component's action properties

  • this.id component's id
  • this.ctx TBF context with Telegraf context. There is available all Telegraf/Telegram methods and data keys. TBF also normalizes ctx.chatId, ctx.fromId, and ctx.senderChatId; sender fields are undefined when Telegram does not provide them.
  • this.type type of component: page/plugin

Also TBF provides data to action.

actions: {
    async main({data}) {
                ^

Action's handlers

There are 2 types of handers for callback and message.
Callback handler can be defined in two ways.
1. As default

actions: {
    async main() {
        // there is callback handler
    },
}

1.1. Strict described - as method of action

actions: {
    main: {
        async handler() {
            // there is callback handler
        },
    }
}

Message handler can be defined only strictly - as method of action

actions: {
    main: {
        async messageHandler() {
            // there is message handler
        },
    }
}

TBF provides to message handler:

  • text, photo, video, animation, document, voice, audio, poll, sticker, location, contact, venue, game, invoice, dice
actions: {
    main: {
        async messageHandler({text, location}) {
                              ^     ^

For example, if you want catch only text message from user:

actions: {
    main: {
        async messageHandler({text}) {
            if(!text) return false;
            // do something with text...

Oh! What is false in return?!
return false says to TBF to remove user's message. Without false in return his message stay in chat until next chat clearing.

Telegram events

Use events for updates that are not routed through a page button or the user's current step:

module.exports = Component(() => ({
    actions: {
        async main() {
            await this.send({ text: "React to this message" });
        }
    },
    events: {
        async message_reaction(ctx) {
            console.log("Reaction update", ctx.update);
        },
        async poll_answer(ctx) {
            console.log("Poll answer", ctx.update);
        },
        async chat_join_request(ctx) {
            await this.api("approveChatJoinRequest", {
                chat_id: ctx.chat.id,
                user_id: ctx.from.id
            });
        }
    }
}));

Any Telegraf ctx.updateType can be used as an event name. Unrouted events are delivered only to components that explicitly subscribe to that event.

Modern Telegram options can be passed without waiting for a TBF release:

await this.reply({
    text: "Reply inside a topic",
    options: {
        message_thread_id: 42,
        link_preview_options: { is_disabled: true }
    }
});

await this.react(["👍", "🔥"], { is_big: true });

const answer = await this.withChatAction("typing", async () => {
    return generateAnswer();
});

await this.send({ text: answer });

await this.api("sendMessageDraft", {
    chat_id: this.ctx.chatId,
    draft_id: 1,
    text: "Generating…"
});

For an entire action or message handler, the same behavior can be declared without a wrapper:

actions: {
    generate: {
        chatAction: "typing",
        async handler() {
            const answer = await generateAnswer();
            await this.send({ text: answer });
        }
    }
}

chatAction is opt-in and defaults to undefined, preserving the previous behavior. TBF refreshes the status every four seconds and always stops the timer when the operation completes or throws.

To also stop the current status immediately when navigating to another action, page, or plugin, enable navigation tracking:

TBF({
    telegram: { token },
    config: {
        chatActions: {
            stopOnNavigation: true
        }
    }
});

The option defaults to false. Telegram Bot API has no explicit cancel operation: TBF stops refreshing the status, and Telegram removes it within five seconds or as soon as the bot sends a new message.

Combine callback and message handlers.

We can imagine a little action that asks for user's name.

  1. User triggers /start command that triggers index page that automatically triggers main action handler.
    (1) So now user got a message from bot Hey, send me your name!
main: {
    async handler(){
        this.send({ text: "Hey, send me your name!" }); // (1)
    },
    async messageHandler({text}) {
        if(!text) return false;
        this.update({text: `Your name is ${text}`}); // 2
    },
}
  1. When user sends message (text in our case) to bot it will handled by current page (index) and current action (main) by messageHandler.
  2. We are updating (2) last bot's message (1) with handled text. User message will be automatically removed by TBF

Express module

TBF wraps Express and runs it on own.
But TBF requires files in /webserver/ directory with your logic and also shares bot, db, database, conponents with your executive files.

  • bot - is Telegraf bot object
  • db - MongoDB instance
  • database - TBF MongoDB database collections
  • components - list of loaded pages and plugins

There is couple examples of your webserver in TS:

// webserver/index.ts

import type { WebServerArgs } from "@powerdot/telegram_bot_framework/types";
module.exports = ({ bot, db, database, components }: WebServerArgs) => {
  let express = require("express");
  let router = express.Router();
  let path = require("path");

  router.use('/api', require('./api')({ bot, db, database, components } as WebServerArgs));

  return router;
}

As you can see:

  1. You are creating not app but router, because TBF creates own app,
  2. You can pass WebServerArgs to your api module and etc.
    Here is example of ./api/index.ts
// webserver/api/index.ts

import type { WebServerArgs } from "@powerdot/telegram_bot_framework/types";

module.exports = ({ bot, database }: WebServerArgs) => {
    let express = require("express");
    let router = express.Router();

    router.get("/posts", async (req, res) => {
        // some code here
        // for example do something with database or bot
    });

    return router;
}

You need always wrap your express routers to function to provide data from parent modules and TBF.
Maybe later this concept will be changed...

Templates

Here is templates to start your project as quick as possible.
🕺 Template with only Bot
💃 Template with Bot + Webserver

With them you can easily to touch TBF and write own bot.


Powered by @powerdot

About

New way to build page-like bots.

Topics

Resources

Stars

10 stars

Watchers

1 watching

Forks

Contributors