Initialize deploy/docker-compose
This commit is contained in:
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
BOT_TOKEN=
|
||||||
|
BOT_MODE=polling
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
DEBUG=true
|
||||||
|
BOT_WEBHOOK=https://www.example.com/webhook
|
||||||
|
BOT_WEBHOOK_SECRET=RANDOM_SECRET_VALUE
|
||||||
|
SERVER_HOST=localhost
|
||||||
|
SERVER_PORT=3000
|
||||||
|
BOT_ADMINS=[1]
|
||||||
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
51
.github/workflows/main.yml
vendored
Normal file
51
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-docker-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [20.x]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
- name: Build and Push Versioned Docker Image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
if: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
- name: Build and Push Latest Docker Image
|
||||||
|
id: build-and-push-latest
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
138
.gitignore
vendored
Normal file
138
.gitignore
vendored
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript build
|
||||||
|
build
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Jetbrains IDE
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Ignore SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"macabeus.vscode-fluent",
|
||||||
|
"mikestead.dotenv"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
.vscode/settings.json
vendored
Normal file
19
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"eslint.codeActionsOnSave.mode": "problems",
|
||||||
|
"eslint.format.enable": true,
|
||||||
|
"eslint.useFlatConfig": true,
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[markdown]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:lts-slim AS base
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /usr/src
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Files required by npm install
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install app dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Bundle app source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Type check app
|
||||||
|
RUN npm run typecheck
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
# Files required by npm install
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install only production app dependencies
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Bundle app source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Start the app
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["node", "--import", "tsx", "./src/main.ts"]
|
||||||
366
README.md
Normal file
366
README.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<h1 align="center">🤖 Telegram Bot Template</h1>
|
||||||
|
|
||||||
|
<img align="right" width="35%" src="https://github.com/bot-base/telegram-bot-template/assets/26162440/c4371683-3e99-4b1c-ae8e-11ccbea78f4b">
|
||||||
|
|
||||||
|
Bot starter template based on [grammY](https://grammy.dev/) bot framework.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Scalable structure
|
||||||
|
- Config loading and validation
|
||||||
|
- Internationalization, language changing
|
||||||
|
- Graceful shutdown
|
||||||
|
- Logger (powered by [pino](https://github.com/pinojs/pino))
|
||||||
|
- Ultrafast and multi-runtime server (powered by [hono](https://github.com/honojs/hono))
|
||||||
|
- Ready-to-use deployment setups:
|
||||||
|
- [Docker](#docker-dockercom)
|
||||||
|
- [Vercel](#vercel-vercelcom)
|
||||||
|
- Examples:
|
||||||
|
- grammY plugins:
|
||||||
|
- [Conversations](#grammy-conversations-grammydevpluginsconversations)
|
||||||
|
- Databases:
|
||||||
|
- [Prisma ORM](#prisma-orm-prismaio)
|
||||||
|
- Runtimes:
|
||||||
|
- [Bun](#bun-bunsh)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Follow these steps to set up and run your bot using this template:
|
||||||
|
|
||||||
|
1. **Create a New Repository**
|
||||||
|
|
||||||
|
Start by creating a new repository using this template. You can do this by clicking [here](https://github.com/bot-base/telegram-bot-template/generate).
|
||||||
|
|
||||||
|
2. **Environment Variables Setup**
|
||||||
|
|
||||||
|
Create an environment variables file by copying the provided example file:
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
cp .env.example .env.bot.dev
|
||||||
|
|
||||||
|
# production
|
||||||
|
cp .env.example .env.bot.prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the newly created `.env.bot.dev` and `.env.bot.prod` files and set the `BOT_TOKEN` environment variable.
|
||||||
|
|
||||||
|
3. **Launching the Bot**
|
||||||
|
|
||||||
|
You can run your bot in both development and production modes.
|
||||||
|
|
||||||
|
**Development Mode:**
|
||||||
|
|
||||||
|
Install the required dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
Start the bot in watch mode (auto-reload when code changes):
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production Mode:**
|
||||||
|
|
||||||
|
Set `DEBUG` environment variable to `false` in your `.env` file.
|
||||||
|
|
||||||
|
Start the bot in production mode:
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yml -f compose.prod.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
### List of Available Commands
|
||||||
|
|
||||||
|
- `npm run lint` — Lint source code.
|
||||||
|
- `npm run format` — Format source code.
|
||||||
|
- `npm run typecheck` — Run type checking.
|
||||||
|
- `npm run dev` — Start the bot in development mode.
|
||||||
|
- `npm run start` — Start the bot.
|
||||||
|
- `npm run start:force` — Starts the bot without type checking.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── locales # Localization files
|
||||||
|
└── src
|
||||||
|
├── bot # Code related to bot
|
||||||
|
│ ├── callback-data # Callback data builders
|
||||||
|
│ ├── features # Bot features
|
||||||
|
│ ├── filters # Update filters
|
||||||
|
│ ├── handlers # Update handlers
|
||||||
|
│ ├── helpers # Helper functions
|
||||||
|
│ ├── keyboards # Keyboard builders
|
||||||
|
│ ├── middlewares # Bot middlewares
|
||||||
|
│ ├── i18n.ts # Internationalization setup
|
||||||
|
│ ├── context.ts # Context object definition
|
||||||
|
│ └── index.ts # Bot entry point
|
||||||
|
├── server # Code related to web server
|
||||||
|
│ ├── middlewares # Server middlewares
|
||||||
|
│ ├── environment # Server environment setup
|
||||||
|
│ └── index.ts # Server entry point
|
||||||
|
├── config.ts # Application config
|
||||||
|
├── logger.ts # Logging setup
|
||||||
|
└── main.ts # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Docker ([docker.com](https://docker.com))
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
[deploy/docker-compose](https://github.com/bot-base/telegram-bot-template/tree/deploy/docker-compose)
|
||||||
|
([open diff](https://github.com/bot-base/telegram-bot-template/compare/deploy/docker-compose))
|
||||||
|
|
||||||
|
Use in your project:
|
||||||
|
|
||||||
|
1. Add the template repository as a remote
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add template git@github.com:bot-base/telegram-bot-template.git
|
||||||
|
git remote update
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Merge deployment setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git merge template/deploy/docker-compose -X theirs --squash --no-commit --allow-unrelated-histories
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Follow [the usage instructions](https://github.com/bot-base/telegram-bot-template/tree/deploy/docker-compose#usage) in the `deploy/docker-compose` branch.
|
||||||
|
|
||||||
|
### Vercel ([vercel.com](https://vercel.com))
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
[deploy/vercel](https://github.com/bot-base/telegram-bot-template/tree/deploy/vercel)
|
||||||
|
([open diff](https://github.com/bot-base/telegram-bot-template/compare/deploy/vercel))
|
||||||
|
|
||||||
|
Use in your project:
|
||||||
|
|
||||||
|
1. Add the template repository as a remote
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add template git@github.com:bot-base/telegram-bot-template.git
|
||||||
|
git remote update
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Merge deployment setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git merge template/deploy/vercel -X theirs --squash --no-commit --allow-unrelated-histories
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Follow [the usage instructions](https://github.com/bot-base/telegram-bot-template/tree/deploy/vercel#usage) in the `deploy/vercel` branch.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### grammY conversations ([grammy.dev/plugins/conversations](https://grammy.dev/plugins/conversations))
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
[example/plugin-conversations](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-conversations)
|
||||||
|
([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/plugin-conversations))
|
||||||
|
|
||||||
|
Use in your project:
|
||||||
|
|
||||||
|
1. Add the template repository as a remote
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add template git@github.com:bot-base/telegram-bot-template.git
|
||||||
|
git remote update
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Merge example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git merge template/example/plugin-conversations -X theirs --squash --no-commit --allow-unrelated-histories
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i @grammyjs/conversations
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Follow [the usage instructions](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-conversations#usage) in the `example/plugin-conversations` branch.
|
||||||
|
|
||||||
|
### Prisma ORM ([prisma.io](https://prisma.io))
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
[example/orm-prisma](https://github.com/bot-base/telegram-bot-template/tree/example/orm-prisma)
|
||||||
|
([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/orm-prisma))
|
||||||
|
|
||||||
|
Use in your project:
|
||||||
|
|
||||||
|
1. Add the template repository as a remote
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add template git@github.com:bot-base/telegram-bot-template.git
|
||||||
|
git remote update
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Merge example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git merge template/example/orm-prisma -X theirs --squash --no-commit --allow-unrelated-histories
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i -D prisma
|
||||||
|
npm i @prisma/client
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Follow [the usage instructions](https://github.com/bot-base/telegram-bot-template/tree/example/orm-prisma#usage) in the `example/orm-prisma` branch.
|
||||||
|
|
||||||
|
### Bun ([bun.sh](https://bun.sh))
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
[example/runtime-bun](https://github.com/bot-base/telegram-bot-template/tree/example/runtime-bun)
|
||||||
|
([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/runtime-bun))
|
||||||
|
|
||||||
|
Use in your project:
|
||||||
|
|
||||||
|
1. Add the template repository as a remote
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add template git@github.com:bot-base/telegram-bot-template.git
|
||||||
|
git remote update
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Merge example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git merge template/example/runtime-bun -X theirs --squash --no-commit --allow-unrelated-histories
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# remove Node-related dependencies
|
||||||
|
npm r @types/node tsx tsc-watch
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
bun i
|
||||||
|
|
||||||
|
# remove npm lockfile
|
||||||
|
rm package-lock.json
|
||||||
|
|
||||||
|
# install bun typings
|
||||||
|
bun add -d @types/bun
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Follow [the usage instructions](https://github.com/bot-base/telegram-bot-template/tree/example/runtime-bun#usage) in the `example/runtime-bun` branch.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Variable</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_TOKEN</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Telegram Bot API token obtained from <a href="https://t.me/BotFather">@BotFather</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_MODE</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Specifies method to receive incoming updates (<code>polling</code> or <code>webhook</code>).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LOG_LEVEL</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional.</i>
|
||||||
|
Specifies the application log level. <br/>
|
||||||
|
Use <code>info</code> for general logging. Check the <a href="https://github.com/pinojs/pino/blob/master/docs/api.md#level-string">Pino documentation</a> for more log level options. <br/>
|
||||||
|
Defaults to <code>info</code>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>DEBUG</td>
|
||||||
|
<td>Boolean</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional.</i>
|
||||||
|
Enables debug mode. You may use <code>config.isDebug</code> flag to enable debugging functions.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_WEBHOOK</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional in <code>polling</code> mode.</i>
|
||||||
|
Webhook endpoint URL, used to configure webhook.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_WEBHOOK_SECRET</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional in <code>polling</code> mode.</i>
|
||||||
|
A secret token that is used to ensure that a request is sent from Telegram, used to configure webhook.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SERVER_HOST</td>
|
||||||
|
<td>
|
||||||
|
String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional in <code>polling</code> mode.</i> Specifies the server hostname. <br/>
|
||||||
|
Defaults to <code>0.0.0.0</code>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SERVER_PORT</td>
|
||||||
|
<td>
|
||||||
|
Number
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional in <code>polling</code> mode.</i> Specifies the server port. <br/>
|
||||||
|
Defaults to <code>80</code>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_ALLOWED_UPDATES</td>
|
||||||
|
<td>
|
||||||
|
Array of String
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional.</i> A JSON-serialized list of the update types you want your bot to receive. See <a href="https://core.telegram.org/bots/api#update">Update</a> for a complete list of available update types. <br/>
|
||||||
|
Defaults to an empty array (all update types except <code>chat_member</code>, <code>message_reaction</code> and <code>message_reaction_count</code>).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>BOT_ADMINS</td>
|
||||||
|
<td>
|
||||||
|
Array of Number
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i>Optional.</i>
|
||||||
|
Administrator user IDs.
|
||||||
|
Use this to specify user IDs that have special privileges, such as executing <code>/setcommands</code>. <br/>
|
||||||
|
Defaults to an empty array.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
10
compose.override.yml
Normal file
10
compose.override.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
bot:
|
||||||
|
ports:
|
||||||
|
- '3000:80'
|
||||||
|
volumes:
|
||||||
|
- '.:/usr/src'
|
||||||
|
env_file:
|
||||||
|
- .env.bot.dev
|
||||||
|
command: npm run dev
|
||||||
5
compose.prod.yml
Normal file
5
compose.prod.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
bot:
|
||||||
|
env_file:
|
||||||
|
- .env.bot.prod
|
||||||
5
compose.yml
Normal file
5
compose.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
5
eslint.config.js
Normal file
5
eslint.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
|
||||||
|
})
|
||||||
25
locales/en.ftl
Normal file
25
locales/en.ftl
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
## Commands
|
||||||
|
|
||||||
|
start =
|
||||||
|
.description = Start the bot
|
||||||
|
language =
|
||||||
|
.description = Change language
|
||||||
|
setcommands =
|
||||||
|
.description = Set bot commands
|
||||||
|
|
||||||
|
## Welcome Feature
|
||||||
|
|
||||||
|
welcome = Welcome!
|
||||||
|
|
||||||
|
## Language Feature
|
||||||
|
|
||||||
|
language-select = Please, select your language
|
||||||
|
language-changed = Language successfully changed!
|
||||||
|
|
||||||
|
## Admin Feature
|
||||||
|
|
||||||
|
admin-commands-updated = Commands updated.
|
||||||
|
|
||||||
|
## Unhandled Feature
|
||||||
|
|
||||||
|
unhandled = Unrecognized command. Try /start
|
||||||
6652
package-lock.json
generated
Normal file
6652
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-bot-template",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Telegram bot starter template",
|
||||||
|
"imports": {
|
||||||
|
"#root/*": "./build/src/*"
|
||||||
|
},
|
||||||
|
"author": "deptyped <deptyped@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "eslint . --fix",
|
||||||
|
"typecheck": "tsc",
|
||||||
|
"build": "tsc --noEmit false",
|
||||||
|
"dev": "tsc-watch --onSuccess \"tsx ./src/main.ts\"",
|
||||||
|
"start": "tsc && tsx ./src/main.ts",
|
||||||
|
"start:force": "tsx ./src/main.ts",
|
||||||
|
"prepare": "husky || true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@grammyjs/auto-chat-action": "0.1.1",
|
||||||
|
"@grammyjs/commands": "1.0.5",
|
||||||
|
"@grammyjs/hydrate": "1.4.1",
|
||||||
|
"@grammyjs/i18n": "1.1.2",
|
||||||
|
"@grammyjs/parse-mode": "1.11.1",
|
||||||
|
"@grammyjs/runner": "2.0.3",
|
||||||
|
"@grammyjs/types": "3.19.0",
|
||||||
|
"@hono/node-server": "1.13.8",
|
||||||
|
"callback-data": "1.1.1",
|
||||||
|
"grammy": "1.35.0",
|
||||||
|
"hono": "4.7.2",
|
||||||
|
"iso-639-1": "3.1.5",
|
||||||
|
"pino": "9.6.0",
|
||||||
|
"pino-pretty": "13.0.0",
|
||||||
|
"tsx": "4.19.3",
|
||||||
|
"valibot": "0.42.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "4.3.0",
|
||||||
|
"@types/node": "^22.13.4",
|
||||||
|
"eslint": "^9.20.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.4.3",
|
||||||
|
"tsc-watch": "^6.2.1",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.ts": "eslint"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/bot/callback-data/change-language.ts
Normal file
5
src/bot/callback-data/change-language.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createCallbackData } from 'callback-data'
|
||||||
|
|
||||||
|
export const changeLanguageData = createCallbackData('language', {
|
||||||
|
code: String,
|
||||||
|
})
|
||||||
26
src/bot/context.ts
Normal file
26
src/bot/context.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Config } from '#root/config.js'
|
||||||
|
import type { Logger } from '#root/logger.js'
|
||||||
|
import type { AutoChatActionFlavor } from '@grammyjs/auto-chat-action'
|
||||||
|
import type { HydrateFlavor } from '@grammyjs/hydrate'
|
||||||
|
import type { I18nFlavor } from '@grammyjs/i18n'
|
||||||
|
import type { ParseModeFlavor } from '@grammyjs/parse-mode'
|
||||||
|
import type { Context as DefaultContext, SessionFlavor } from 'grammy'
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
// field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtendedContextFlavor {
|
||||||
|
logger: Logger
|
||||||
|
config: Config
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Context = ParseModeFlavor<
|
||||||
|
HydrateFlavor<
|
||||||
|
DefaultContext &
|
||||||
|
ExtendedContextFlavor &
|
||||||
|
SessionFlavor<SessionData> &
|
||||||
|
I18nFlavor &
|
||||||
|
AutoChatActionFlavor
|
||||||
|
>
|
||||||
|
>
|
||||||
21
src/bot/features/admin.ts
Normal file
21
src/bot/features/admin.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import { isAdmin } from '#root/bot/filters/is-admin.js'
|
||||||
|
import { setCommandsHandler } from '#root/bot/handlers/commands/setcommands.js'
|
||||||
|
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||||
|
import { chatAction } from '@grammyjs/auto-chat-action'
|
||||||
|
import { Composer } from 'grammy'
|
||||||
|
|
||||||
|
const composer = new Composer<Context>()
|
||||||
|
|
||||||
|
const feature = composer
|
||||||
|
.chatType('private')
|
||||||
|
.filter(isAdmin)
|
||||||
|
|
||||||
|
feature.command(
|
||||||
|
'setcommands',
|
||||||
|
logHandle('command-setcommands'),
|
||||||
|
chatAction('typing'),
|
||||||
|
setCommandsHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
export { composer as adminFeature }
|
||||||
36
src/bot/features/language.ts
Normal file
36
src/bot/features/language.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import { changeLanguageData } from '#root/bot/callback-data/change-language.js'
|
||||||
|
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||||
|
import { i18n } from '#root/bot/i18n.js'
|
||||||
|
import { createChangeLanguageKeyboard } from '#root/bot/keyboards/change-language.js'
|
||||||
|
import { Composer } from 'grammy'
|
||||||
|
|
||||||
|
const composer = new Composer<Context>()
|
||||||
|
|
||||||
|
const feature = composer.chatType('private')
|
||||||
|
|
||||||
|
feature.command('language', logHandle('command-language'), async (ctx) => {
|
||||||
|
return ctx.reply(ctx.t('language-select'), {
|
||||||
|
reply_markup: await createChangeLanguageKeyboard(ctx),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
feature.callbackQuery(
|
||||||
|
changeLanguageData.filter(),
|
||||||
|
logHandle('keyboard-language-select'),
|
||||||
|
async (ctx) => {
|
||||||
|
const { code: languageCode } = changeLanguageData.unpack(
|
||||||
|
ctx.callbackQuery.data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (i18n.locales.includes(languageCode)) {
|
||||||
|
await ctx.i18n.setLocale(languageCode)
|
||||||
|
|
||||||
|
return ctx.editMessageText(ctx.t('language-changed'), {
|
||||||
|
reply_markup: await createChangeLanguageKeyboard(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export { composer as languageFeature }
|
||||||
17
src/bot/features/unhandled.ts
Normal file
17
src/bot/features/unhandled.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||||
|
import { Composer } from 'grammy'
|
||||||
|
|
||||||
|
const composer = new Composer<Context>()
|
||||||
|
|
||||||
|
const feature = composer.chatType('private')
|
||||||
|
|
||||||
|
feature.on('message', logHandle('unhandled-message'), (ctx) => {
|
||||||
|
return ctx.reply(ctx.t('unhandled'))
|
||||||
|
})
|
||||||
|
|
||||||
|
feature.on('callback_query', logHandle('unhandled-callback-query'), (ctx) => {
|
||||||
|
return ctx.answerCallbackQuery()
|
||||||
|
})
|
||||||
|
|
||||||
|
export { composer as unhandledFeature }
|
||||||
13
src/bot/features/welcome.ts
Normal file
13
src/bot/features/welcome.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||||
|
import { Composer } from 'grammy'
|
||||||
|
|
||||||
|
const composer = new Composer<Context>()
|
||||||
|
|
||||||
|
const feature = composer.chatType('private')
|
||||||
|
|
||||||
|
feature.command('start', logHandle('command-start'), (ctx) => {
|
||||||
|
return ctx.reply(ctx.t('welcome'))
|
||||||
|
})
|
||||||
|
|
||||||
|
export { composer as welcomeFeature }
|
||||||
5
src/bot/filters/is-admin.ts
Normal file
5
src/bot/filters/is-admin.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
|
||||||
|
export function isAdmin(ctx: Context) {
|
||||||
|
return !!ctx.from && ctx.config.botAdmins.includes(ctx.from.id)
|
||||||
|
}
|
||||||
49
src/bot/handlers/commands/setcommands.ts
Normal file
49
src/bot/handlers/commands/setcommands.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import type { LanguageCode } from '@grammyjs/types'
|
||||||
|
import type { CommandContext } from 'grammy'
|
||||||
|
import { i18n } from '#root/bot/i18n.js'
|
||||||
|
import { Command, CommandGroup } from '@grammyjs/commands'
|
||||||
|
|
||||||
|
function addCommandLocalizations(command: Command) {
|
||||||
|
i18n.locales.forEach((locale) => {
|
||||||
|
command.localize(
|
||||||
|
locale as LanguageCode,
|
||||||
|
command.name,
|
||||||
|
i18n.t(locale, `${command.name}.description`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCommandToChats(command: Command, chats: number[]) {
|
||||||
|
for (const chatId of chats) {
|
||||||
|
command.addToScope({
|
||||||
|
type: 'chat',
|
||||||
|
chat_id: chatId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCommandsHandler(ctx: CommandContext<Context>) {
|
||||||
|
const start = new Command('start', i18n.t('en', 'start.description'))
|
||||||
|
.addToScope({ type: 'all_private_chats' })
|
||||||
|
addCommandLocalizations(start)
|
||||||
|
addCommandToChats(start, ctx.config.botAdmins)
|
||||||
|
|
||||||
|
const language = new Command('language', i18n.t('en', 'language.description'))
|
||||||
|
.addToScope({ type: 'all_private_chats' })
|
||||||
|
addCommandLocalizations(language)
|
||||||
|
addCommandToChats(language, ctx.config.botAdmins)
|
||||||
|
|
||||||
|
const setcommands = new Command('setcommands', i18n.t('en', 'setcommands.description'))
|
||||||
|
addCommandToChats(setcommands, ctx.config.botAdmins)
|
||||||
|
|
||||||
|
const commands = new CommandGroup()
|
||||||
|
.add(start)
|
||||||
|
.add(language)
|
||||||
|
.add(setcommands)
|
||||||
|
|
||||||
|
await commands.setCommands(ctx)
|
||||||
|
|
||||||
|
return ctx.reply(ctx.t('admin-commands-updated'))
|
||||||
|
}
|
||||||
12
src/bot/handlers/error.ts
Normal file
12
src/bot/handlers/error.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import type { ErrorHandler } from 'grammy'
|
||||||
|
import { getUpdateInfo } from '#root/bot/helpers/logging.js'
|
||||||
|
|
||||||
|
export const errorHandler: ErrorHandler<Context> = (error) => {
|
||||||
|
const { ctx } = error
|
||||||
|
|
||||||
|
ctx.logger.error({
|
||||||
|
err: error.error,
|
||||||
|
update: getUpdateInfo(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
7
src/bot/helpers/keyboard.ts
Normal file
7
src/bot/helpers/keyboard.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function chunk<T>(array: T[], size: number) {
|
||||||
|
const result = []
|
||||||
|
for (let index = 0; index < array.length; index += size)
|
||||||
|
result.push(array.slice(index, index + size))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
20
src/bot/helpers/logging.ts
Normal file
20
src/bot/helpers/logging.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import type { Update } from '@grammyjs/types'
|
||||||
|
import type { Middleware } from 'grammy'
|
||||||
|
|
||||||
|
export function getUpdateInfo(ctx: Context): Omit<Update, 'update_id'> {
|
||||||
|
const { update_id, ...update } = ctx.update
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logHandle(id: string): Middleware<Context> {
|
||||||
|
return (ctx, next) => {
|
||||||
|
ctx.logger.info({
|
||||||
|
msg: `Handle "${id}"`,
|
||||||
|
...(id.startsWith('unhandled') ? { update: getUpdateInfo(ctx) } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/bot/i18n.ts
Normal file
15
src/bot/i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { I18n } from '@grammyjs/i18n'
|
||||||
|
|
||||||
|
export const i18n = new I18n<Context>({
|
||||||
|
defaultLocale: 'en',
|
||||||
|
directory: path.resolve(process.cwd(), 'locales'),
|
||||||
|
useSession: true,
|
||||||
|
fluentBundleOptions: {
|
||||||
|
useIsolating: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const isMultipleLocales = i18n.locales.length > 1
|
||||||
75
src/bot/index.ts
Normal file
75
src/bot/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import type { Config } from '#root/config.js'
|
||||||
|
import type { Logger } from '#root/logger.js'
|
||||||
|
import type { BotConfig } from 'grammy'
|
||||||
|
import { adminFeature } from '#root/bot/features/admin.js'
|
||||||
|
import { languageFeature } from '#root/bot/features/language.js'
|
||||||
|
import { unhandledFeature } from '#root/bot/features/unhandled.js'
|
||||||
|
import { welcomeFeature } from '#root/bot/features/welcome.js'
|
||||||
|
import { errorHandler } from '#root/bot/handlers/error.js'
|
||||||
|
import { i18n, isMultipleLocales } from '#root/bot/i18n.js'
|
||||||
|
import { session } from '#root/bot/middlewares/session.js'
|
||||||
|
import { updateLogger } from '#root/bot/middlewares/update-logger.js'
|
||||||
|
import { autoChatAction } from '@grammyjs/auto-chat-action'
|
||||||
|
import { hydrate } from '@grammyjs/hydrate'
|
||||||
|
import { hydrateReply, parseMode } from '@grammyjs/parse-mode'
|
||||||
|
import { sequentialize } from '@grammyjs/runner'
|
||||||
|
import { MemorySessionStorage, Bot as TelegramBot } from 'grammy'
|
||||||
|
|
||||||
|
interface Dependencies {
|
||||||
|
config: Config
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionKey(ctx: Omit<Context, 'session'>) {
|
||||||
|
return ctx.chat?.id.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBot(token: string, dependencies: Dependencies, botConfig?: BotConfig<Context>) {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
} = dependencies
|
||||||
|
|
||||||
|
const bot = new TelegramBot<Context>(token, botConfig)
|
||||||
|
|
||||||
|
bot.use(async (ctx, next) => {
|
||||||
|
ctx.config = config
|
||||||
|
ctx.logger = logger.child({
|
||||||
|
update_id: ctx.update.update_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
const protectedBot = bot.errorBoundary(errorHandler)
|
||||||
|
|
||||||
|
// Middlewares
|
||||||
|
bot.api.config.use(parseMode('HTML'))
|
||||||
|
|
||||||
|
if (config.isPollingMode)
|
||||||
|
protectedBot.use(sequentialize(getSessionKey))
|
||||||
|
if (config.isDebug)
|
||||||
|
protectedBot.use(updateLogger())
|
||||||
|
protectedBot.use(autoChatAction(bot.api))
|
||||||
|
protectedBot.use(hydrateReply)
|
||||||
|
protectedBot.use(hydrate())
|
||||||
|
protectedBot.use(session({
|
||||||
|
getSessionKey,
|
||||||
|
storage: new MemorySessionStorage(),
|
||||||
|
}))
|
||||||
|
protectedBot.use(i18n)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
protectedBot.use(welcomeFeature)
|
||||||
|
protectedBot.use(adminFeature)
|
||||||
|
if (isMultipleLocales)
|
||||||
|
protectedBot.use(languageFeature)
|
||||||
|
|
||||||
|
// must be the last handler
|
||||||
|
protectedBot.use(unhandledFeature)
|
||||||
|
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bot = ReturnType<typeof createBot>
|
||||||
28
src/bot/keyboards/change-language.ts
Normal file
28
src/bot/keyboards/change-language.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import { changeLanguageData } from '#root/bot/callback-data/change-language.js'
|
||||||
|
import { chunk } from '#root/bot/helpers/keyboard.js'
|
||||||
|
import { i18n } from '#root/bot/i18n.js'
|
||||||
|
import { InlineKeyboard } from 'grammy'
|
||||||
|
import ISO6391 from 'iso-639-1'
|
||||||
|
|
||||||
|
export async function createChangeLanguageKeyboard(ctx: Context) {
|
||||||
|
const currentLocaleCode = await ctx.i18n.getLocale()
|
||||||
|
|
||||||
|
const getLabel = (code: string) => {
|
||||||
|
const isActive = code === currentLocaleCode
|
||||||
|
|
||||||
|
return `${isActive ? '✅ ' : ''}${ISO6391.getNativeName(code)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return InlineKeyboard.from(
|
||||||
|
chunk(
|
||||||
|
i18n.locales.map(localeCode => ({
|
||||||
|
text: getLabel(localeCode),
|
||||||
|
callback_data: changeLanguageData.pack({
|
||||||
|
code: localeCode,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/bot/middlewares/session.ts
Normal file
13
src/bot/middlewares/session.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context, SessionData } from '#root/bot/context.js'
|
||||||
|
import type { Middleware, SessionOptions } from 'grammy'
|
||||||
|
import { session as createSession } from 'grammy'
|
||||||
|
|
||||||
|
type Options = Pick<SessionOptions<SessionData, Context>, 'getSessionKey' | 'storage'>
|
||||||
|
|
||||||
|
export function session(options: Options): Middleware<Context> {
|
||||||
|
return createSession({
|
||||||
|
getSessionKey: options.getSessionKey,
|
||||||
|
storage: options.storage,
|
||||||
|
initial: () => ({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
35
src/bot/middlewares/update-logger.ts
Normal file
35
src/bot/middlewares/update-logger.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Context } from '#root/bot/context.js'
|
||||||
|
import type { Middleware } from 'grammy'
|
||||||
|
import { performance } from 'node:perf_hooks'
|
||||||
|
import { getUpdateInfo } from '#root/bot/helpers/logging.js'
|
||||||
|
|
||||||
|
export function updateLogger(): Middleware<Context> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
ctx.api.config.use((previous, method, payload, signal) => {
|
||||||
|
ctx.logger.debug({
|
||||||
|
msg: 'Bot API call',
|
||||||
|
method,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
return previous(method, payload, signal)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.logger.debug({
|
||||||
|
msg: 'Update received',
|
||||||
|
update: getUpdateInfo(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
const startTime = performance.now()
|
||||||
|
try {
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
const endTime = performance.now()
|
||||||
|
ctx.logger.debug({
|
||||||
|
msg: 'Update processed',
|
||||||
|
elapsed: endTime - startTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/config.ts
Normal file
98
src/config.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import process from 'node:process'
|
||||||
|
import { API_CONSTANTS } from 'grammy'
|
||||||
|
import * as v from 'valibot'
|
||||||
|
|
||||||
|
const baseConfigSchema = v.object({
|
||||||
|
debug: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.boolean()), 'false'),
|
||||||
|
logLevel: v.optional(v.pipe(v.string(), v.picklist(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'])), 'info'),
|
||||||
|
botToken: v.pipe(v.string(), v.regex(/^\d+:[\w-]+$/, 'Invalid token')),
|
||||||
|
botAllowedUpdates: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.array(v.picklist(API_CONSTANTS.ALL_UPDATE_TYPES))), '[]'),
|
||||||
|
botAdmins: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.array(v.number())), '[]'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const configSchema = v.variant('botMode', [
|
||||||
|
// polling config
|
||||||
|
v.pipe(
|
||||||
|
v.object({
|
||||||
|
botMode: v.literal('polling'),
|
||||||
|
...baseConfigSchema.entries,
|
||||||
|
}),
|
||||||
|
v.transform(input => ({
|
||||||
|
...input,
|
||||||
|
isDebug: input.debug,
|
||||||
|
isWebhookMode: false as const,
|
||||||
|
isPollingMode: true as const,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
// webhook config
|
||||||
|
v.pipe(
|
||||||
|
v.object({
|
||||||
|
botMode: v.literal('webhook'),
|
||||||
|
...baseConfigSchema.entries,
|
||||||
|
botWebhook: v.pipe(v.string(), v.url()),
|
||||||
|
botWebhookSecret: v.pipe(v.string(), v.minLength(12)),
|
||||||
|
serverHost: v.optional(v.string(), '0.0.0.0'),
|
||||||
|
serverPort: v.optional(v.pipe(v.string(), v.transform(Number), v.number()), '80'),
|
||||||
|
}),
|
||||||
|
v.transform(input => ({
|
||||||
|
...input,
|
||||||
|
isDebug: input.debug,
|
||||||
|
isWebhookMode: true as const,
|
||||||
|
isPollingMode: false as const,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
export type Config = v.InferOutput<typeof configSchema>
|
||||||
|
export type PollingConfig = v.InferOutput<typeof configSchema['options'][0]>
|
||||||
|
export type WebhookConfig = v.InferOutput<typeof configSchema['options'][1]>
|
||||||
|
|
||||||
|
export function createConfig(input: v.InferInput<typeof configSchema>) {
|
||||||
|
return v.parse(configSchema, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = createConfigFromEnvironment()
|
||||||
|
|
||||||
|
function createConfigFromEnvironment() {
|
||||||
|
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}`
|
||||||
|
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
|
||||||
|
: Lowercase<S>
|
||||||
|
|
||||||
|
type KeysToCamelCase<T> = {
|
||||||
|
[K in keyof T as CamelCase<string & K>]: T[K] extends object ? KeysToCamelCase<T[K]> : T[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCamelCase(str: string): string {
|
||||||
|
return str.toLowerCase().replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertKeysToCamelCase<T>(obj: T): KeysToCamelCase<T> {
|
||||||
|
const result: any = {}
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
const camelCaseKey = toCamelCase(key)
|
||||||
|
result[camelCaseKey] = obj[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.loadEnvFile()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// No .env file found
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-expect-error create config from environment variables
|
||||||
|
const config = createConfig(convertKeysToCamelCase(process.env))
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new Error('Invalid config', {
|
||||||
|
cause: error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/logger.ts
Normal file
31
src/logger.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { config } from '#root/config.js'
|
||||||
|
import { pino } from 'pino'
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: config.logLevel,
|
||||||
|
transport: {
|
||||||
|
targets: [
|
||||||
|
...(config.isDebug
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
target: 'pino-pretty',
|
||||||
|
level: config.logLevel,
|
||||||
|
options: {
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
colorize: true,
|
||||||
|
translateTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
target: 'pino/file',
|
||||||
|
level: config.logLevel,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Logger = typeof logger
|
||||||
111
src/main.ts
Normal file
111
src/main.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/* eslint-disable antfu/no-top-level-await */
|
||||||
|
|
||||||
|
import type { PollingConfig, WebhookConfig } from '#root/config.js'
|
||||||
|
import type { RunnerHandle } from '@grammyjs/runner'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { createBot } from '#root/bot/index.js'
|
||||||
|
import { config } from '#root/config.js'
|
||||||
|
import { logger } from '#root/logger.js'
|
||||||
|
import { createServer, createServerManager } from '#root/server/index.js'
|
||||||
|
import { run } from '@grammyjs/runner'
|
||||||
|
|
||||||
|
async function startPolling(config: PollingConfig) {
|
||||||
|
const bot = createBot(config.botToken, {
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
let runner: undefined | RunnerHandle
|
||||||
|
|
||||||
|
// graceful shutdown
|
||||||
|
onShutdown(async () => {
|
||||||
|
logger.info('Shutdown')
|
||||||
|
await runner?.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
bot.init(),
|
||||||
|
bot.api.deleteWebhook(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// start bot
|
||||||
|
runner = run(bot, {
|
||||||
|
runner: {
|
||||||
|
fetch: {
|
||||||
|
allowed_updates: config.botAllowedUpdates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
msg: 'Bot running...',
|
||||||
|
username: bot.botInfo.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWebhook(config: WebhookConfig) {
|
||||||
|
const bot = createBot(config.botToken, {
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
const server = createServer({
|
||||||
|
bot,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
const serverManager = createServerManager(server, {
|
||||||
|
host: config.serverHost,
|
||||||
|
port: config.serverPort,
|
||||||
|
})
|
||||||
|
|
||||||
|
// graceful shutdown
|
||||||
|
onShutdown(async () => {
|
||||||
|
logger.info('Shutdown')
|
||||||
|
await serverManager.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
// to prevent receiving updates before the bot is ready
|
||||||
|
await bot.init()
|
||||||
|
|
||||||
|
// start server
|
||||||
|
const info = await serverManager.start()
|
||||||
|
logger.info({
|
||||||
|
msg: 'Server started',
|
||||||
|
url: info.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
// set webhook
|
||||||
|
await bot.api.setWebhook(config.botWebhook, {
|
||||||
|
allowed_updates: config.botAllowedUpdates,
|
||||||
|
secret_token: config.botWebhookSecret,
|
||||||
|
})
|
||||||
|
logger.info({
|
||||||
|
msg: 'Webhook was set',
|
||||||
|
url: config.botWebhook,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (config.isWebhookMode)
|
||||||
|
await startWebhook(config)
|
||||||
|
else if (config.isPollingMode)
|
||||||
|
await startPolling(config)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
|
||||||
|
function onShutdown(cleanUp: () => Promise<void>) {
|
||||||
|
let isShuttingDown = false
|
||||||
|
const handleShutdown = async () => {
|
||||||
|
if (isShuttingDown)
|
||||||
|
return
|
||||||
|
isShuttingDown = true
|
||||||
|
await cleanUp()
|
||||||
|
}
|
||||||
|
process.on('SIGINT', handleShutdown)
|
||||||
|
process.on('SIGTERM', handleShutdown)
|
||||||
|
}
|
||||||
8
src/server/environment.ts
Normal file
8
src/server/environment.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Logger } from '#root/logger.js'
|
||||||
|
|
||||||
|
export interface Env {
|
||||||
|
Variables: {
|
||||||
|
requestId: string
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/server/index.ts
Normal file
102
src/server/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Bot } from '#root/bot/index.js'
|
||||||
|
import type { Config } from '#root/config.js'
|
||||||
|
import type { Logger } from '#root/logger.js'
|
||||||
|
import type { Env } from '#root/server/environment.js'
|
||||||
|
import { setLogger } from '#root/server/middlewares/logger.js'
|
||||||
|
import { requestId } from '#root/server/middlewares/request-id.js'
|
||||||
|
import { requestLogger } from '#root/server/middlewares/request-logger.js'
|
||||||
|
import { serve } from '@hono/node-server'
|
||||||
|
import { webhookCallback } from 'grammy'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
import { getPath } from 'hono/utils/url'
|
||||||
|
|
||||||
|
interface Dependencies {
|
||||||
|
bot: Bot
|
||||||
|
config: Config
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(dependencies: Dependencies) {
|
||||||
|
const {
|
||||||
|
bot,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
} = dependencies
|
||||||
|
|
||||||
|
const server = new Hono<Env>()
|
||||||
|
|
||||||
|
server.use(requestId())
|
||||||
|
server.use(setLogger(logger))
|
||||||
|
if (config.isDebug)
|
||||||
|
server.use(requestLogger())
|
||||||
|
|
||||||
|
server.onError(async (error, c) => {
|
||||||
|
if (error instanceof HTTPException) {
|
||||||
|
if (error.status < 500)
|
||||||
|
c.var.logger.info(error)
|
||||||
|
else
|
||||||
|
c.var.logger.error(error)
|
||||||
|
|
||||||
|
return error.getResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unexpected error
|
||||||
|
c.var.logger.error({
|
||||||
|
err: error,
|
||||||
|
method: c.req.raw.method,
|
||||||
|
path: getPath(c.req.raw),
|
||||||
|
})
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'Oops! Something went wrong.',
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get('/', c => c.json({ status: true }))
|
||||||
|
|
||||||
|
if (config.isWebhookMode) {
|
||||||
|
server.post(
|
||||||
|
'/webhook',
|
||||||
|
webhookCallback(bot, 'hono', {
|
||||||
|
secretToken: config.botWebhookSecret,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Server = Awaited<ReturnType<typeof createServer>>
|
||||||
|
|
||||||
|
export function createServerManager(server: Server, options: { host: string, port: number }) {
|
||||||
|
let handle: undefined | ReturnType<typeof serve>
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
return new Promise<{ url: string }>((resolve) => {
|
||||||
|
handle = serve(
|
||||||
|
{
|
||||||
|
fetch: server.fetch,
|
||||||
|
hostname: options.host,
|
||||||
|
port: options.port,
|
||||||
|
},
|
||||||
|
info => resolve({
|
||||||
|
url: info.family === 'IPv6'
|
||||||
|
? `http://[${info.address}]:${info.port}`
|
||||||
|
: `http://${info.address}:${info.port}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (handle)
|
||||||
|
handle.close(() => resolve())
|
||||||
|
else
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/server/middlewares/logger.ts
Normal file
15
src/server/middlewares/logger.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Logger } from '#root/logger.js'
|
||||||
|
import type { MiddlewareHandler } from 'hono'
|
||||||
|
|
||||||
|
export function setLogger(logger: Logger): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set(
|
||||||
|
'logger',
|
||||||
|
logger.child({
|
||||||
|
requestId: c.get('requestId'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/server/middlewares/request-id.ts
Normal file
10
src/server/middlewares/request-id.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
export function requestId(): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set('requestId', randomUUID())
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/server/middlewares/request-logger.ts
Normal file
27
src/server/middlewares/request-logger.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono'
|
||||||
|
import { getPath } from 'hono/utils/url'
|
||||||
|
|
||||||
|
export function requestLogger(): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
const { method } = c.req
|
||||||
|
const path = getPath(c.req.raw)
|
||||||
|
|
||||||
|
c.var.logger.debug({
|
||||||
|
msg: 'Incoming request',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
await next()
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
c.var.logger.debug({
|
||||||
|
msg: 'Request completed',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
status: c.res.status,
|
||||||
|
elapsed: endTime - startTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"rootDir": ".",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"paths": {
|
||||||
|
"#root/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"outDir": "build",
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"preserveWatchOutput": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user