├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── container.yml │ └── nodejs.yml ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── copy-template-into-existing-project.sh ├── locales ├── de.ftl └── en.ftl ├── package.json ├── source ├── bot │ ├── index.ts │ ├── menu │ │ ├── general.ts │ │ ├── index.ts │ │ └── settings │ │ │ ├── index.ts │ │ │ └── language.ts │ └── my-context.ts ├── magic.test.ts ├── magic.ts ├── telegram-typescript-bot-template.ts └── translation.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.md 3 | **/*.test.* 4 | **/test.* 5 | dist 6 | Dockerfile 7 | node_modules 8 | test 9 | tsconfig.json 10 | 11 | botfather-settings 12 | 13 | # botfiles 14 | sessions 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_style = tab 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | # https://projectfluent.org/ 9 | [*.ftl] 10 | indent_size = 2 11 | indent_style = space 12 | 13 | [*.{yml,yaml}] 14 | indent_size = 2 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | open-pull-requests-limit: 30 9 | versioning-strategy: increase 10 | schedule: 11 | interval: "weekly" 12 | day: "saturday" 13 | time: "02:42" # UTC 14 | commit-message: 15 | prefix: "build(npm):" 16 | groups: 17 | patches: 18 | update-types: ["patch"] 19 | ignore: 20 | - dependency-name: "@types/node" 21 | update-types: ["version-update:semver-major"] 22 | 23 | - package-ecosystem: "docker" 24 | directory: "/" 25 | schedule: 26 | interval: "weekly" 27 | day: "saturday" 28 | time: "02:42" # UTC 29 | commit-message: 30 | prefix: "build(container):" 31 | 32 | - package-ecosystem: "github-actions" 33 | directory: "/" 34 | schedule: 35 | interval: "weekly" 36 | day: "saturday" 37 | time: "02:42" # UTC 38 | commit-message: 39 | prefix: "ci(actions):" 40 | -------------------------------------------------------------------------------- /.github/workflows/container.yml: -------------------------------------------------------------------------------- 1 | name: Build container 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | workflow_dispatch: 10 | # Build regularly in order to have up to date base image in the edge image 11 | schedule: 12 | - cron: "42 2 * * 6" # weekly on Saturday 2:42 UTC 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | jobs: 23 | docker: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: docker/metadata-action@v5 27 | id: meta 28 | with: 29 | images: | 30 | ghcr.io/${{ github.repository }} 31 | tags: | 32 | type=edge 33 | type=semver,pattern={{version}} 34 | type=semver,pattern={{major}}.{{minor}} 35 | type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} 36 | 37 | - uses: docker/setup-qemu-action@v3 38 | - uses: docker/setup-buildx-action@v3 39 | 40 | - name: Login to GHCR 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.repository_owner }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - uses: docker/build-push-action@v6 48 | with: 49 | platforms: linux/amd64, linux/arm64/v8 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | name: Node.js 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | steps: 14 | - uses: actions/setup-node@v6 15 | with: 16 | node-version: 24 17 | - uses: actions/checkout@v6 18 | - run: npm ci 19 | - run: npm test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | dist 3 | node_modules 4 | 5 | # botfiles 6 | sessions 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/alpine:3.23 AS packages 2 | RUN apk upgrade --no-cache \ 3 | && apk add --no-cache npm 4 | WORKDIR /build 5 | COPY package.json package-lock.json ./ 6 | RUN npm ci --no-audit --no-fund --no-update-notifier --omit=dev 7 | 8 | 9 | FROM docker.io/library/alpine:3.23 AS final 10 | RUN apk upgrade --no-cache \ 11 | && apk add --no-cache nodejs 12 | 13 | WORKDIR /app 14 | VOLUME /app/persist 15 | 16 | COPY package.json ./ 17 | COPY --from=packages /build/node_modules ./node_modules 18 | COPY locales locales 19 | COPY source ./ 20 | 21 | ENTRYPOINT ["node", "--enable-source-maps"] 22 | CMD ["telegram-typescript-bot-template.ts"] 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) EdJoPaTo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # telegram-typescript-bot-template 2 | 3 | > Template for Telegram bots written in TypeScript 4 | 5 | If you haven't written TypeScript before, don't be scared of it. 6 | It is nearly the same as JavaScript. 7 | But it provides more helpful information before something goes wrong during the runtime. 8 | 9 | I learned a lot about JavaScript itself when I started diving into TypeScript. 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install 15 | ``` 16 | 17 | ## Start the bot 18 | 19 | ### Local development 20 | 21 | Write to the @BotFather on Telegram and create your bot. 22 | You will get a token that looks like this: `123:abc`. 23 | Use it as an environment variable (for example via `export BOT_TOKEN=123:abc`). 24 | Tip: When you create a separate bot for your development you can use production and development in parallel. 25 | 26 | The bot stores persistent data within the `persist` folder. 27 | So also create this folder before starting it for the first time. 28 | 29 | ```bash 30 | mkdir persist 31 | ``` 32 | 33 | Then go ahead and start the bot 34 | 35 | ```bash 36 | npm start 37 | ``` 38 | 39 | ### Production 40 | 41 | See the Dockerfile. 42 | You can build a container using it. 43 | But this repo isn't about containers. 44 | For more information about them, take a look elsewhere. 45 | 46 | The container is meant to be used with an environment variable named `BOT_TOKEN`. 47 | 48 | The container has one volume (`/app/persist`) which will contain persistent data your bot creates. 49 | Make sure to explicitly use that volume (for example, make sure it's synced or tied to the host in a multi-node setup). 50 | 51 | ## Basic Folder structure example 52 | 53 | - `source` contains your TypeScript source files. Subfolders contain specifics about your implementation 54 | - `bot` may contain files relevant for the telegram bot 55 | - `menu` may contain specifics about the bot, the menu that is shown on /start 56 | - `magic` may contain something relevant for doing magic. It is not relevant to the bot directly, but it is used by it. 57 | - `locales` contains translation strings for your bot. That way it can speak multiple languages. 58 | - `dist` will contain transpiled JavaScript files. 59 | - `persist` will contain persistent data your bot uses. Make sure to keep that data persistent (Backups for example). 60 | 61 | ## Improve 62 | 63 | Do you think something is missing? 64 | Feel free to add it. 65 | Then everyone can learn that even easier than before :) 66 | -------------------------------------------------------------------------------- /copy-template-into-existing-project.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | # Usage 5 | # Go to the project you want to improve via this template 6 | # cd ~/git/my-project 7 | # Run this script from the working directory of that project 8 | # ~/git/telegram-typescript-bot-template/copy-template-into-existing-project.sh 9 | 10 | git diff --quiet || (echo "repo unclean. stage or commit first" && exit 1) 11 | 12 | name=$(basename "$PWD") 13 | templatedir="$(dirname "$0")" 14 | 15 | cp -r \ 16 | "$templatedir/"{package.json,tsconfig.json,.editorconfig,.gitattributes,.github,.gitignore,.dockerignore,Dockerfile} \ 17 | . 18 | 19 | echo "everything copied" 20 | 21 | # Replace template name with folder name 22 | # macOS: add '' after -i like this: sed -i '' "s/… 23 | sed -i "s/telegram-typescript-bot-template/$name/g" package.json Dockerfile .github/**/*.yml 24 | 25 | git --no-pager status --short 26 | -------------------------------------------------------------------------------- /locales/de.ftl: -------------------------------------------------------------------------------- 1 | welcome = 2 | Hey {$name}! 3 | 4 | Es ist relativ einfach Übersetzungen für deinen Bot zu schreiben, sobald du verstanden hast, wie man fluent Dateien schreibt. 5 | 6 | help = This is some example help text. 7 | 8 | menu-back = zurück… 9 | menu-main = Hauptmenü… 10 | menu-settings = Einstellungen 11 | menu-language = Sprache 12 | 13 | settings-body = 14 | Hier kannst du Dinge zu diesem Bot einstellen. 15 | 16 | Oder wenn du der Entwickler dieses Bots bist: Dinge hinzufügen damit Nutzer diese einstellen können. 17 | settings-language = Wähle deine Sprache 18 | -------------------------------------------------------------------------------- /locales/en.ftl: -------------------------------------------------------------------------------- 1 | welcome = 2 | Hey {$name}! 3 | 4 | Writing translations for your bot is relatively easy once you know how to write proper fluent files. 5 | 6 | help = This is some example help text. 7 | 8 | menu-back = back… 9 | menu-main = main… 10 | menu-settings = Settings 11 | menu-language = Language 12 | 13 | settings-body = 14 | Feel free to set the settings you prefer. 15 | 16 | Or as the developer: Feel free to add the things you want users to adjust. 17 | settings-language = Select your language 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegram-typescript-bot-template", 3 | "private": true, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "node --env-file-if-exists=.env --enable-source-maps source/telegram-typescript-bot-template.ts", 8 | "test": "tsc && xo && node --enable-source-maps --test" 9 | }, 10 | "type": "module", 11 | "engines": { 12 | "node": ">=24" 13 | }, 14 | "devEngines": { 15 | "packageManager": { 16 | "name": "npm" 17 | } 18 | }, 19 | "dependencies": { 20 | "@grammyjs/i18n": "^1.1.2", 21 | "@grammyjs/storage-file": "^2.5.1", 22 | "grammy": "^1.38.3", 23 | "grammy-inline-menu": "^9.2.0", 24 | "telegraf-middleware-console-time": "^3.0.0", 25 | "telegram-format": "^3.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^24.10.1", 29 | "typescript": "^5.9.2", 30 | "xo": "^1.2.3" 31 | }, 32 | "xo": { 33 | "rules": { 34 | "@typescript-eslint/naming-convention": "off", 35 | "unicorn/prevent-abbreviations": "off" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /source/bot/index.ts: -------------------------------------------------------------------------------- 1 | import {env} from 'node:process'; 2 | import {FileAdapter} from '@grammyjs/storage-file'; 3 | import {Bot, session} from 'grammy'; 4 | import {MenuMiddleware} from 'grammy-inline-menu'; 5 | import {generateUpdateMiddleware} from 'telegraf-middleware-console-time'; 6 | import {html as format} from 'telegram-format'; 7 | import {danceWithFairies, fightDragons} from '../magic.ts'; 8 | import {i18n} from '../translation.ts'; 9 | import {menu} from './menu/index.ts'; 10 | import type {MyContext, Session} from './my-context.ts'; 11 | 12 | const token = env['BOT_TOKEN']; 13 | if (!token) { 14 | throw new Error('You have to provide the bot-token from @BotFather via environment variable (BOT_TOKEN)'); 15 | } 16 | 17 | const bot = new Bot(token); 18 | 19 | bot.use(session({ 20 | initial: (): Session => ({}), 21 | storage: new FileAdapter(), 22 | })); 23 | 24 | bot.use(i18n.middleware()); 25 | 26 | if (env['NODE_ENV'] !== 'production') { 27 | // Show what telegram updates (messages, button clicks, ...) are happening (only in development) 28 | bot.use(generateUpdateMiddleware()); 29 | } 30 | 31 | bot.command('help', async ctx => ctx.reply(ctx.t('help'))); 32 | 33 | bot.command('magic', async ctx => { 34 | const combatResult = fightDragons(); 35 | const fairyThoughts = danceWithFairies(); 36 | 37 | let text = ''; 38 | text += combatResult; 39 | text += '\n\n'; 40 | text += fairyThoughts; 41 | 42 | return ctx.reply(text); 43 | }); 44 | 45 | bot.command('html', async ctx => { 46 | let text = ''; 47 | text += format.bold('Some'); 48 | text += ' '; 49 | text += format.spoiler('HTML'); 50 | await ctx.reply(text, {parse_mode: format.parse_mode}); 51 | }); 52 | 53 | const menuMiddleware = new MenuMiddleware('/', menu); 54 | bot.command('start', async ctx => menuMiddleware.replyToContext(ctx)); 55 | bot.command( 56 | 'settings', 57 | async ctx => menuMiddleware.replyToContext(ctx, '/settings/'), 58 | ); 59 | bot.use(menuMiddleware.middleware()); 60 | 61 | // False positive as bot is not a promise 62 | // eslint-disable-next-line unicorn/prefer-top-level-await 63 | bot.catch(error => { 64 | console.error('ERROR on handling update occured', error); 65 | }); 66 | 67 | export async function start(): Promise { 68 | // The commands you set here will be shown as /commands like /start or /magic in your telegram client. 69 | await bot.api.setMyCommands([ 70 | {command: 'start', description: 'open the menu'}, 71 | {command: 'magic', description: 'do magic'}, 72 | {command: 'html', description: 'some html _mode example'}, 73 | {command: 'help', description: 'show the help'}, 74 | {command: 'settings', description: 'open the settings'}, 75 | ]); 76 | 77 | await bot.start({ 78 | onStart(botInfo) { 79 | console.log(new Date(), 'Bot starts as', botInfo.username); 80 | }, 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /source/bot/menu/general.ts: -------------------------------------------------------------------------------- 1 | import {createBackMainMenuButtons} from 'grammy-inline-menu'; 2 | import type {MyContext} from '../my-context.ts'; 3 | 4 | export const backButtons = createBackMainMenuButtons( 5 | ctx => ctx.t('menu-back'), 6 | ctx => ctx.t('menu-main'), 7 | ); 8 | -------------------------------------------------------------------------------- /source/bot/menu/index.ts: -------------------------------------------------------------------------------- 1 | import {MenuTemplate} from 'grammy-inline-menu'; 2 | import type {MyContext} from '../my-context.ts'; 3 | import {menu as settingsMenu} from './settings/index.ts'; 4 | 5 | export const menu = new MenuTemplate(ctx => 6 | ctx.t('welcome', {name: ctx.from!.first_name})); 7 | 8 | menu.url({ 9 | text: 'Telegram API Documentation', 10 | url: 'https://core.telegram.org/bots/api', 11 | }); 12 | menu.url({ 13 | text: 'grammY Documentation', 14 | url: 'https://grammy.dev/', 15 | }); 16 | menu.url({ 17 | text: 'Inline Menu Documentation', 18 | url: 'https://github.com/EdJoPaTo/grammy-inline-menu', 19 | }); 20 | 21 | menu.submenu('settings', settingsMenu, { 22 | text: ctx => '⚙️' + ctx.t('menu-settings'), 23 | }); 24 | -------------------------------------------------------------------------------- /source/bot/menu/settings/index.ts: -------------------------------------------------------------------------------- 1 | import {MenuTemplate} from 'grammy-inline-menu'; 2 | import type {MyContext} from '../../my-context.ts'; 3 | import {backButtons} from '../general.ts'; 4 | import {menu as languageMenu} from './language.ts'; 5 | 6 | export const menu = new MenuTemplate(ctx => 7 | ctx.t('settings-body')); 8 | 9 | menu.submenu('lang', languageMenu, { 10 | text: ctx => '🏳️‍🌈' + ctx.t('menu-language'), 11 | }); 12 | 13 | menu.manualRow(backButtons); 14 | -------------------------------------------------------------------------------- /source/bot/menu/settings/language.ts: -------------------------------------------------------------------------------- 1 | import {MenuTemplate} from 'grammy-inline-menu'; 2 | import {getAvailableLocales} from '../../../translation.ts'; 3 | import type {MyContext} from '../../my-context.ts'; 4 | import {backButtons} from '../general.ts'; 5 | 6 | export const menu = new MenuTemplate(ctx => 7 | ctx.t('settings-language')); 8 | 9 | menu.select('lang', { 10 | choices: getAvailableLocales, 11 | isSet: async (ctx, key) => await ctx.i18n.getLocale() === key, 12 | async set(ctx, key) { 13 | await ctx.i18n.setLocale(key); 14 | return true; 15 | }, 16 | }); 17 | 18 | menu.manualRow(backButtons); 19 | -------------------------------------------------------------------------------- /source/bot/my-context.ts: -------------------------------------------------------------------------------- 1 | import type {I18nFlavor} from '@grammyjs/i18n'; 2 | import type {Context as BaseContext, SessionFlavor} from 'grammy'; 3 | 4 | export type Session = { 5 | page?: number; 6 | }; 7 | 8 | export type MyContext = BaseContext & SessionFlavor & I18nFlavor; 9 | -------------------------------------------------------------------------------- /source/magic.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'node:test'; 2 | import {fightDragons} from './magic.ts'; 3 | 4 | await test('can battle the dragon', () => { 5 | fightDragons(); 6 | }); 7 | -------------------------------------------------------------------------------- /source/magic.ts: -------------------------------------------------------------------------------- 1 | export function feedTheDragons(): void { 2 | // Sometimes you need to initialize stuff. 3 | // Like feeding dragons before you can fight them all day long. 4 | console.log('Feed the dragons…'); 5 | 6 | console.log('Looks like they arnt hungry anymore. But somehow the poeple helping you transporting the food are gone too…'); 7 | } 8 | 9 | export function fightDragons(): string { 10 | // In a folder like this you can do stuff not directly related to your Telegram bot. 11 | // When your bot will need to fight dragons but doesnt do it by itself this is the right place to do it. 12 | return 'Fought the dragon. Dragon vanished. No treasure. Sad.'; 13 | } 14 | 15 | export function danceWithFairies(): string { 16 | const thoughtsWhileDancing = [ 17 | 'No one else can see the fairies but you.', 18 | 'People think you are crazy.', 19 | 'But that is ok.', 20 | 'Everyone is.', 21 | ]; 22 | 23 | return thoughtsWhileDancing.join('\n'); 24 | } 25 | -------------------------------------------------------------------------------- /source/telegram-typescript-bot-template.ts: -------------------------------------------------------------------------------- 1 | import {start as startBot} from './bot/index.ts'; 2 | import {feedTheDragons} from './magic.ts'; 3 | 4 | feedTheDragons(); 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 7 | startBot(); 8 | -------------------------------------------------------------------------------- /source/translation.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '@grammyjs/i18n'; 2 | 3 | export const i18n = new I18n({ 4 | defaultLocale: 'en', 5 | useSession: true, 6 | directory: 'locales', 7 | }); 8 | 9 | // TODO: get this from the context directly. Maybe `ctx.i18n.getAvailableLocales()`? 10 | export function getAvailableLocales(): readonly string[] { 11 | return i18n.locales; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "erasableSyntaxOnly": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "libReplacement": false, 7 | "module": "node20", 8 | "moduleDetection": "force", 9 | "noEmit": true, 10 | "noEmitOnError": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitOverride": true, 13 | "noImplicitReturns": true, 14 | "noPropertyAccessFromIndexSignature": true, 15 | "noUncheckedIndexedAccess": true, 16 | "noUncheckedSideEffectImports": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "dist", 20 | "removeComments": true, 21 | "rewriteRelativeImportExtensions": true, 22 | "skipLibCheck": true, 23 | "sourceMap": true, 24 | "strict": true, 25 | "target": "es2024", // Node.js 22 26 | "verbatimModuleSyntax": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------