First build

This commit is contained in:
Lucid Kobold
2025-02-19 11:11:47 -05:00
43 changed files with 4061 additions and 7177 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -6,39 +6,46 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-test: build-and-push-docker-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [20.x] node-version: [20.x]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Log into registry ${{ env.REGISTRY }}
uses: actions/setup-node@v3 uses: docker/login-action@v2
with: with:
node-version: ${{ matrix.node-version }} registry: ${{ env.REGISTRY }}
cache: npm username: ${{ github.actor }}
- run: npm ci password: ${{ secrets.GITHUB_TOKEN }}
- run: npm run lint - name: Setup Docker buildx
- run: npm run typecheck uses: docker/setup-buildx-action@v2
- name: Extract Docker metadata
auto-merge: id: meta
if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' uses: docker/metadata-action@v4
runs-on: ubuntu-latest
needs: build-and-test
permissions:
contents: write
pull-requests: write
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with: with:
github-token: '${{ secrets.GITHUB_TOKEN }}' images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Auto-merge - name: Build and Push Versioned Docker Image
if: steps.metadata.outputs.update-type != 'version-update:semver-major' id: build-and-push
run: gh pr merge --auto --squash "$PR_URL" uses: docker/build-push-action@v4
env: if: ${{ github.ref != 'refs/heads/main' }}
PR_URL: ${{github.event.pull_request.html_url}} with:
GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 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 }}

17
.prettierignore Normal file
View File

@@ -0,0 +1,17 @@
# Ignore artifacts:
build
coverage
.next
out
.yarn
.github
.env*
.eslintrc.json
.gitignore
.yarnrc.yml
next-env.d.ts
next-env.d
package*
tsconfig.json
yarn.lock
next.config.js

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "none",
"tabWidth": 2,
"bracketSameLine": false,
"arrowParens": "avoid"
}

35
Dockerfile Normal file
View 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"]

View File

@@ -13,15 +13,15 @@ Bot starter template based on [grammY](https://grammy.dev/) bot framework.
- Logger (powered by [pino](https://github.com/pinojs/pino)) - Logger (powered by [pino](https://github.com/pinojs/pino))
- Ultrafast and multi-runtime server (powered by [hono](https://github.com/honojs/hono)) - Ultrafast and multi-runtime server (powered by [hono](https://github.com/honojs/hono))
- Ready-to-use deployment setups: - Ready-to-use deployment setups:
- [Docker](#docker-dockercom) - [Docker](#docker-dockercom)
- [Vercel](#vercel-vercelcom) - [Vercel](#vercel-vercelcom)
- Examples: - Examples:
- grammY plugins: - grammY plugins:
- [Conversations](#grammy-conversations-grammydevpluginsconversations) - [Conversations](#grammy-conversations-grammydevpluginsconversations)
- Databases: - Databases:
- [Prisma ORM](#prisma-orm-prismaio) - [Prisma ORM](#prisma-orm-prismaio)
- Runtimes: - Runtimes:
- [Bun](#bun-bunsh) - [Bun](#bun-bunsh)
## Usage ## Usage
@@ -29,46 +29,49 @@ Follow these steps to set up and run your bot using this template:
1. **Create a New Repository** 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). 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** 2. **Environment Variables Setup**
Create an environment variables file by copying the provided example file: Create an environment variables file by copying the provided example file:
```bash
cp .env.example .env ```bash
``` # development
Open the newly created `.env` file and set the `BOT_TOKEN` environment variable. 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** 3. **Launching the Bot**
You can run your bot in both development and production modes. You can run your bot in both development and production modes.
**Development Mode:** **Development Mode:**
Install the required dependencies: Install the required dependencies:
```bash
npm install ```bash
``` npm install
Start the bot in watch mode (auto-reload when code changes): ```
```bash
npm run dev Start the bot in watch mode (auto-reload when code changes):
```
```bash
docker compose up
```
**Production Mode:** **Production Mode:**
Install only production dependencies: Set `DEBUG` environment variable to `false` in your `.env` file.
```bash
npm install --only=prod
```
Set `DEBUG` environment variable to `false` in your `.env` file. Start the bot in production mode:
Start the bot in production mode: ```bash
```bash docker compose -f compose.yml -f compose.prod.yml up
npm run start:force # skip type checking and start ```
# or
npm start # with type checking (requires development dependencies)
```
### List of Available Commands ### List of Available Commands

9
compose.override.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
bot:
ports:
- "3000:80"
volumes:
- ".:/usr/src"
env_file:
- .env.bot.dev
command: npm run dev

4
compose.prod.yml Normal file
View File

@@ -0,0 +1,4 @@
services:
bot:
env_file:
- .env.bot.prod

4
compose.yml Normal file
View File

@@ -0,0 +1,4 @@
services:
bot:
build:
context: .

View File

@@ -1,5 +1,25 @@
import antfu from '@antfu/eslint-config' import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default antfu({ /** @type {import('eslint').Linter.Config[]} */
export default [
}) { files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"comma-dangle": [
"error",
{
arrays: "never",
objects: "never",
imports: "never",
exports: "never",
functions: "never"
}
]
}
}
];

6652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"type": "module", "type": "module",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447",
"description": "Telegram bot starter template", "description": "Telegram bot starter template",
"imports": { "imports": {
"#root/*": "./build/src/*" "#root/*": "./build/src/*"
@@ -21,7 +22,8 @@
"dev": "tsc-watch --onSuccess \"tsx ./src/main.ts\"", "dev": "tsc-watch --onSuccess \"tsx ./src/main.ts\"",
"start": "tsc && tsx ./src/main.ts", "start": "tsc && tsx ./src/main.ts",
"start:force": "tsx ./src/main.ts", "start:force": "tsx ./src/main.ts",
"prepare": "husky || true" "prepare": "husky || true",
"pretty": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@grammyjs/auto-chat-action": "0.1.1", "@grammyjs/auto-chat-action": "0.1.1",
@@ -43,12 +45,16 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "4.3.0", "@antfu/eslint-config": "4.3.0",
"@eslint/js": "^9.20.0",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"globals": "^15.15.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^15.4.3", "lint-staged": "^15.4.3",
"prettier": "^3.5.1",
"tsc-watch": "^6.2.1", "tsc-watch": "^6.2.1",
"typescript": "^5.7.3" "typescript": "^5.7.3",
"typescript-eslint": "^8.24.1"
}, },
"lint-staged": { "lint-staged": {
"*.ts": "eslint" "*.ts": "eslint"

View File

@@ -1,5 +1,5 @@
import { createCallbackData } from 'callback-data' import { createCallbackData } from "callback-data";
export const changeLanguageData = createCallbackData('language', { export const changeLanguageData = createCallbackData("language", {
code: String, code: String
}) });

View File

@@ -1,26 +1,27 @@
import type { Config } from '#root/config.js' import type { Config } from "#root/config.js";
import type { Logger } from '#root/logger.js' import type { Logger } from "#root/logger.js";
import type { AutoChatActionFlavor } from '@grammyjs/auto-chat-action' import type { AutoChatActionFlavor } from "@grammyjs/auto-chat-action";
import type { HydrateFlavor } from '@grammyjs/hydrate' import type { HydrateFlavor } from "@grammyjs/hydrate";
import type { I18nFlavor } from '@grammyjs/i18n' import type { I18nFlavor } from "@grammyjs/i18n";
import type { ParseModeFlavor } from '@grammyjs/parse-mode' import type { ParseModeFlavor } from "@grammyjs/parse-mode";
import type { Context as DefaultContext, SessionFlavor } from 'grammy' import type { Context as DefaultContext, SessionFlavor } from "grammy";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface SessionData { export interface SessionData {
// field?: string; // field?: string;
} }
interface ExtendedContextFlavor { interface ExtendedContextFlavor {
logger: Logger logger: Logger;
config: Config config: Config;
} }
export type Context = ParseModeFlavor< export type Context = ParseModeFlavor<
HydrateFlavor< HydrateFlavor<
DefaultContext & DefaultContext &
ExtendedContextFlavor & ExtendedContextFlavor &
SessionFlavor<SessionData> & SessionFlavor<SessionData> &
I18nFlavor & I18nFlavor &
AutoChatActionFlavor AutoChatActionFlavor
> >
> >;

View File

@@ -1,21 +1,21 @@
import type { Context } from '#root/bot/context.js' import { chatAction } from "@grammyjs/auto-chat-action";
import { isAdmin } from '#root/bot/filters/is-admin.js' import { Composer } from "grammy";
import { setCommandsHandler } from '#root/bot/handlers/commands/setcommands.js' import type { Context } from "#root/bot/context.js";
import { logHandle } from '#root/bot/helpers/logging.js' import { isAdmin } from "#root/bot/filters/is-admin.js";
import { chatAction } from '@grammyjs/auto-chat-action' import { setCommandsHandler } from "#root/bot/handlers/commands/setcommands.js";
import { Composer } from 'grammy' import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>() const composer = new Composer<Context>();
const feature = composer const feature = composer
.chatType('private') .chatType("private")
.filter(isAdmin) .filter(ctx => isAdmin(ctx.config.botAdmins)(ctx));
feature.command( feature.command(
'setcommands', "setcommands",
logHandle('command-setcommands'), logHandle("command-setcommands"),
chatAction('typing'), chatAction("typing"),
setCommandsHandler, setCommandsHandler
) );
export { composer as adminFeature } export { composer as adminFeature };

View File

@@ -0,0 +1,49 @@
import { Composer } from "grammy";
import type { Context } from "#root/bot/context.js";
import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>();
const feature = composer.chatType(["group", "supergroup"]);
const GROUP_IDS = process.env.GROUP_IDS
? process.env.GROUP_IDS.split(",")
: undefined;
feature.hears(
/(x.com|twitter.com)/g,
logHandle("blacklist-detection"),
async (ctx: Context) => {
if (ctx.chat && ctx.msg) {
if (GROUP_IDS !== undefined) {
const groupID = ctx.chat.id;
const flag = GROUP_IDS.includes(`${groupID}`);
const username = ctx.msg.from?.username;
if (flag) {
ctx.msg.delete();
await ctx.reply(
ctx.t(
`@${username} Twitter and X links are not allowed here\\. Please consider sharing the media directly or from other social media sources or websites\\. No administration action was taken against you other than the message being deleted\\.`
),
{ parse_mode: "MarkdownV2" }
);
}
}
if (!GROUP_IDS) {
await ctx.reply(
ctx.t(
`There was a problem retrieving the whitelist. Check the env variables and try again\\.`
),
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
}
);
export { composer as blacklistDetection };

View File

@@ -0,0 +1,32 @@
import { Composer } from "grammy";
import type { Context } from "#root/bot/context.js";
import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>();
const feature = composer.chatType(["group", "supergroup", "private"]);
// const GROUP_IDS = process.env.GROUP_IDS
// ? process.env.GROUP_IDS.split(",")
// : undefined;
feature.hears(
"/botInfo",
logHandle("bot-info-command"),
async (ctx: Context) => {
console.info(
"BOT INFO! BOT INFO! BOT INFO! BOT INFO! BOT INFO! BOT INFO! BOT INFO! BOT INFO!"
);
if (ctx.chat && ctx.msg) {
await ctx.reply(
`I am a bot designed to delete any Twitter/X links and reformatting services within groups\\. By default I only work with whitelisted group IDs\\.\n\nYou can fork me from this link: https://github\\.com/LucidCreationsMedia/No\\-Twitter\\-Bot and deploy me for use in your own groups\\!`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
);
export { composer as botInfoCommand };

View File

@@ -0,0 +1,32 @@
import { Composer } from "grammy";
import type { Context } from "#root/bot/context.js";
import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>();
const feature = composer.chatType(["group", "supergroup"]);
const GROUP_IDS = process.env.GROUP_IDS
? process.env.GROUP_IDS.split(",")
: undefined;
feature.hears(
"/getGroupID",
logHandle("get-group-id"),
async (ctx: Context) => {
if (ctx.chat && ctx.msg) {
if (GROUP_IDS !== undefined) {
const groupID = ctx.chat.id;
const flag = GROUP_IDS.includes(`${groupID}`);
if (flag) {
await ctx.reply(`The group id is: \`${groupID}\``, {
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
});
}
}
}
}
);
export { composer as getGroupIDCommand };

View File

@@ -0,0 +1,64 @@
import { Composer } from "grammy";
import type { Context } from "#root/bot/context.js";
import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>();
const feature = composer.chatType(["group", "supergroup"]);
const GROUP_IDS = process.env.GROUP_IDS
? process.env.GROUP_IDS.split(",")
: undefined;
feature.hears(
"/help",
logHandle("blacklist-detection"),
async (ctx: Context) => {
if (ctx.chat && ctx.msg) {
if (GROUP_IDS !== undefined) {
const groupID = ctx.chat.id;
const flag = GROUP_IDS.includes(`${groupID}`);
// const username = ctx.msg.from?.username;
if (flag) {
await ctx.reply(
`**Here are the availible commands you can use:**\n\n/getGroupID \\- replied with the ID of the group I am in\\.\n\n/isLCMGRoup \\- Checks if this group's ID is on the whitelist and responds accordingly\\.\n\n/botInfo \\- Info about me and how to fork me to deploy for your own use\\.\n\n/help \\- Displays this help message\\.`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
if (!flag) {
await ctx.reply(
`**Since this is not a whitelisted group the features are limited\\!\\!**\n\nHere are the availible commands you can use:\n\n/isLCMGRoup \\- Checks if this group's ID is on the whitelist and responds accordingly\\.\n\n/botInfo \\- Info about me and how to fork me to deploy for your own use\\.\n\n/help \\- Displays this help message\\.`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
await ctx.reply(
`This group is NOT in the whitelisted and is NOT a part of the LCM Telegram groups/communities\\. I am a bot designed to delete any Twitter/X links and reformatting services within groups\\. You can fork me from this link: https://github\\.com/LucidCreationsMedia/No\\-Twitter\\-Bot and deploy me for use in your own groups\\!`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
if (!GROUP_IDS) {
await ctx.reply(
`There was a problem retrieving the whitelist. Check the env variables and try again\\.`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
}
);
export { composer as helpCommand };

View File

@@ -0,0 +1,57 @@
import { Composer } from "grammy";
import type { Context } from "#root/bot/context.js";
import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>();
const feature = composer.chatType(["group", "supergroup"]);
const GROUP_IDS = process.env.GROUP_IDS
? process.env.GROUP_IDS.split(",")
: undefined;
feature.hears(
"/isLCMGroup",
logHandle("is-LCM-group"),
async (ctx: Context) => {
if (ctx.chat && ctx.msg) {
const groupID = ctx.chat.id;
if (GROUP_IDS !== undefined) {
const flag = GROUP_IDS.includes(`${groupID}`);
if (flag) {
await ctx.reply(
`This group is in the whitelisted and is a part of the LCM Telegram groups/communities\\. I should be deleting any Twitter/X links and reformatting services within this group\\.`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
if (!flag) {
await ctx.reply(
`This group is NOT in the whitelisted and is NOT a part of the LCM Telegram groups/communities\\. I am a bot designed to delete any Twitter/X links and reformatting services within groups\\. You can fork me from this link: https://github.com/LucidCreationsMedia/No-Twitter-Bot and deploy me for use in your own groups!`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
if (!GROUP_IDS) {
await ctx.reply(
`There was a problem retrieving the whitelist. Check the env variables and try again\\.`,
{
parse_mode: "MarkdownV2",
reply_parameters: { message_id: ctx.msg.message_id }
}
);
}
}
}
);
export { composer as isLCMGroup };

View File

@@ -1,36 +1,36 @@
import type { Context } from '#root/bot/context.js' import { Composer } from "grammy";
import { changeLanguageData } from '#root/bot/callback-data/change-language.js' import { changeLanguageData } from "#root/bot/callback-data/change-language.js";
import { logHandle } from '#root/bot/helpers/logging.js' import type { Context } from "#root/bot/context.js";
import { i18n } from '#root/bot/i18n.js' import { logHandle } from "#root/bot/helpers/logging.js";
import { createChangeLanguageKeyboard } from '#root/bot/keyboards/change-language.js' import { i18n } from "#root/bot/i18n.js";
import { Composer } from 'grammy' import { createChangeLanguageKeyboard } from "#root/bot/keyboards/change-language.js";
const composer = new Composer<Context>() const composer = new Composer<Context>();
const feature = composer.chatType('private') const feature = composer.chatType("private");
feature.command('language', logHandle('command-language'), async (ctx) => { feature.command("language", logHandle("command-language"), async ctx => {
return ctx.reply(ctx.t('language-select'), { return ctx.reply(ctx.t("language-select"), {
reply_markup: await createChangeLanguageKeyboard(ctx), reply_markup: await createChangeLanguageKeyboard(ctx)
}) });
}) });
feature.callbackQuery( feature.callbackQuery(
changeLanguageData.filter(), changeLanguageData.filter(),
logHandle('keyboard-language-select'), logHandle("keyboard-language-select"),
async (ctx) => { async ctx => {
const { code: languageCode } = changeLanguageData.unpack( const { code: languageCode } = changeLanguageData.unpack(
ctx.callbackQuery.data, ctx.callbackQuery.data
) );
if (i18n.locales.includes(languageCode)) { if (i18n.locales.includes(languageCode)) {
await ctx.i18n.setLocale(languageCode) await ctx.i18n.setLocale(languageCode);
return ctx.editMessageText(ctx.t('language-changed'), { return ctx.editMessageText(ctx.t("language-changed"), {
reply_markup: await createChangeLanguageKeyboard(ctx), reply_markup: await createChangeLanguageKeyboard(ctx)
}) });
} }
}, }
) );
export { composer as languageFeature } export { composer as languageFeature };

View File

@@ -1,17 +1,17 @@
import type { Context } from '#root/bot/context.js' import { Composer } from "grammy";
import { logHandle } from '#root/bot/helpers/logging.js' import type { Context } from "#root/bot/context.js";
import { Composer } from 'grammy' import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>() const composer = new Composer<Context>();
const feature = composer.chatType('private') const feature = composer.chatType("private");
feature.on('message', logHandle('unhandled-message'), (ctx) => { feature.on("message", logHandle("unhandled-message"), ctx => {
return ctx.reply(ctx.t('unhandled')) return ctx.reply(ctx.t("unhandled"));
}) });
feature.on('callback_query', logHandle('unhandled-callback-query'), (ctx) => { feature.on("callback_query", logHandle("unhandled-callback-query"), ctx => {
return ctx.answerCallbackQuery() return ctx.answerCallbackQuery();
}) });
export { composer as unhandledFeature } export { composer as unhandledFeature };

View File

@@ -1,13 +1,18 @@
import type { Context } from '#root/bot/context.js' import { Composer } from "grammy";
import { logHandle } from '#root/bot/helpers/logging.js' import type { Context } from "#root/bot/context.js";
import { Composer } from 'grammy' import { logHandle } from "#root/bot/helpers/logging.js";
const composer = new Composer<Context>() const composer = new Composer<Context>();
const feature = composer.chatType('private') const feature = composer.chatType("private");
feature.command('start', logHandle('command-start'), (ctx) => { feature.command("start", logHandle("command-start"), ctx => {
return ctx.reply(ctx.t('welcome')) return ctx.reply(
}) ctx.t(
"Welcome! I am a bot created by Lucid for Lucid Creations media groups. I am designed to delete any Twitter/X links and reformatting services within groups. By default I only work with whitelisted group IDs. You can fork me from this link: https://github.com/LucidCreationsMedia/No-Twitter-Bot and deploy me for use in your own groups!"
),
{ parse_mode: "MarkdownV2" }
);
});
export { composer as welcomeFeature } export { composer as welcomeFeature };

View File

@@ -1,5 +1,5 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
export function isAdmin(ctx: Context) { export function isAdmin(ctx: Context) {
return !!ctx.from && ctx.config.botAdmins.includes(ctx.from.id) return !!ctx.from && ctx.config.botAdmins.includes(ctx.from.id);
} }

View File

@@ -1,49 +1,53 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import type { LanguageCode } from '@grammyjs/types' import type { LanguageCode } from "@grammyjs/types";
import type { CommandContext } from 'grammy' import type { CommandContext } from "grammy";
import { i18n } from '#root/bot/i18n.js' import { i18n } from "#root/bot/i18n.js";
import { Command, CommandGroup } from '@grammyjs/commands' import { Command, CommandGroup } from "@grammyjs/commands";
function addCommandLocalizations(command: Command) { function addCommandLocalizations(command: Command) {
i18n.locales.forEach((locale) => { i18n.locales.forEach(locale => {
command.localize( command.localize(
locale as LanguageCode, locale as LanguageCode,
command.name, command.name,
i18n.t(locale, `${command.name}.description`), i18n.t(locale, `${command.name}.description`)
) );
}) });
return command return command;
} }
function addCommandToChats(command: Command, chats: number[]) { function addCommandToChats(command: Command, chats: number[]) {
for (const chatId of chats) { for (const chatId of chats) {
command.addToScope({ command.addToScope({
type: 'chat', type: "chat",
chat_id: chatId, chat_id: chatId
}) });
} }
} }
export async function setCommandsHandler(ctx: CommandContext<Context>) { export async function setCommandsHandler(ctx: CommandContext<Context>) {
const start = new Command('start', i18n.t('en', 'start.description')) const start = new Command(
.addToScope({ type: 'all_private_chats' }) "start",
addCommandLocalizations(start) i18n.t("en", "start.description")
addCommandToChats(start, ctx.config.botAdmins) ).addToScope({ type: "all_private_chats" });
addCommandLocalizations(start);
addCommandToChats(start, ctx.config.botAdmins);
const language = new Command('language', i18n.t('en', 'language.description')) const language = new Command(
.addToScope({ type: 'all_private_chats' }) "language",
addCommandLocalizations(language) i18n.t("en", "language.description")
addCommandToChats(language, ctx.config.botAdmins) ).addToScope({ type: "all_private_chats" });
addCommandLocalizations(language);
addCommandToChats(language, ctx.config.botAdmins);
const setcommands = new Command('setcommands', i18n.t('en', 'setcommands.description')) const setcommands = new Command(
addCommandToChats(setcommands, ctx.config.botAdmins) "setcommands",
i18n.t("en", "setcommands.description")
);
addCommandToChats(setcommands, ctx.config.botAdmins);
const commands = new CommandGroup() const commands = new CommandGroup().add(start).add(language).add(setcommands);
.add(start)
.add(language)
.add(setcommands)
await commands.setCommands(ctx) await commands.setCommands(ctx);
return ctx.reply(ctx.t('admin-commands-updated')) return ctx.reply(ctx.t("admin-commands-updated"));
} }

View File

@@ -1,12 +1,12 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import type { ErrorHandler } from 'grammy' import type { ErrorHandler } from "grammy";
import { getUpdateInfo } from '#root/bot/helpers/logging.js' import { getUpdateInfo } from "#root/bot/helpers/logging.js";
export const errorHandler: ErrorHandler<Context> = (error) => { export const errorHandler: ErrorHandler<Context> = error => {
const { ctx } = error const { ctx } = error;
ctx.logger.error({ ctx.logger.error({
err: error.error, err: error.error,
update: getUpdateInfo(ctx), update: getUpdateInfo(ctx)
}) });
} };

View File

@@ -1,7 +1,7 @@
export function chunk<T>(array: T[], size: number) { export function chunk<T>(array: T[], size: number) {
const result = [] const result = [];
for (let index = 0; index < array.length; index += size) for (let index = 0; index < array.length; index += size)
result.push(array.slice(index, index + size)) result.push(array.slice(index, index + size));
return result return result;
} }

View File

@@ -1,20 +1,20 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import type { Update } from '@grammyjs/types' import type { Update } from "@grammyjs/types";
import type { Middleware } from 'grammy' import type { Middleware } from "grammy";
export function getUpdateInfo(ctx: Context): Omit<Update, 'update_id'> { export function getUpdateInfo(ctx: Context): Omit<Update, "update_id"> {
const { update_id, ...update } = ctx.update const { /*update_id,*/ ...update } = ctx.update;
return update return update;
} }
export function logHandle(id: string): Middleware<Context> { export function logHandle(id: string): Middleware<Context> {
return (ctx, next) => { return (ctx, next) => {
ctx.logger.info({ ctx.logger.info({
msg: `Handle "${id}"`, msg: `Handle "${id}"`,
...(id.startsWith('unhandled') ? { update: getUpdateInfo(ctx) } : {}), ...(id.startsWith("unhandled") ? { update: getUpdateInfo(ctx) } : {})
}) });
return next() return next();
} };
} }

View File

@@ -1,15 +1,15 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import path from 'node:path' import path from "node:path";
import process from 'node:process' import process from "node:process";
import { I18n } from '@grammyjs/i18n' import { I18n } from "@grammyjs/i18n";
export const i18n = new I18n<Context>({ export const i18n = new I18n<Context>({
defaultLocale: 'en', defaultLocale: "en",
directory: path.resolve(process.cwd(), 'locales'), directory: path.resolve(process.cwd(), "locales"),
useSession: true, useSession: true,
fluentBundleOptions: { fluentBundleOptions: {
useIsolating: false, useIsolating: false
}, }
}) });
export const isMultipleLocales = i18n.locales.length > 1 export const isMultipleLocales = i18n.locales.length > 1;

View File

@@ -1,75 +1,89 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import type { Config } from '#root/config.js' import type { Config } from "#root/config.js";
import type { Logger } from '#root/logger.js' import type { Logger } from "#root/logger.js";
import type { BotConfig } from 'grammy' import type { BotConfig } from "grammy";
import { adminFeature } from '#root/bot/features/admin.js' import { adminFeature } from "#root/bot/features/admin.js";
import { languageFeature } from '#root/bot/features/language.js' import { languageFeature } from "#root/bot/features/language.js";
import { unhandledFeature } from '#root/bot/features/unhandled.js' import { unhandledFeature } from "#root/bot/features/unhandled.js";
import { welcomeFeature } from '#root/bot/features/welcome.js' import { welcomeFeature } from "#root/bot/features/welcome.js";
import { errorHandler } from '#root/bot/handlers/error.js' import { errorHandler } from "#root/bot/handlers/error.js";
import { i18n, isMultipleLocales } from '#root/bot/i18n.js' import { i18n, isMultipleLocales } from "#root/bot/i18n.js";
import { session } from '#root/bot/middlewares/session.js' import { session } from "#root/bot/middlewares/session.js";
import { updateLogger } from '#root/bot/middlewares/update-logger.js' import { updateLogger } from "#root/bot/middlewares/update-logger.js";
import { autoChatAction } from '@grammyjs/auto-chat-action' import { autoChatAction } from "@grammyjs/auto-chat-action";
import { hydrate } from '@grammyjs/hydrate' import { hydrate } from "@grammyjs/hydrate";
import { hydrateReply, parseMode } from '@grammyjs/parse-mode' import { hydrateReply, parseMode } from "@grammyjs/parse-mode";
import { sequentialize } from '@grammyjs/runner' import { sequentialize } from "@grammyjs/runner";
import { MemorySessionStorage, Bot as TelegramBot } from 'grammy' import { MemorySessionStorage, Bot as TelegramBot } from "grammy";
import { blacklistDetection } from "./features/blacklistDelete.js";
import { botInfoCommand } from "./features/botInfoCommand.js";
import { getGroupIDCommand } from "./features/getGroupIDCommand.js";
import { helpCommand } from "./features/helpCommand.js";
import { isLCMGroup } from "./features/isLCMGroup.js";
interface Dependencies { interface Dependencies {
config: Config config: Config;
logger: Logger logger: Logger;
} }
function getSessionKey(ctx: Omit<Context, 'session'>) { function getSessionKey(ctx: Omit<Context, "session">) {
return ctx.chat?.id.toString() return ctx.chat?.id.toString();
} }
export function createBot(token: string, dependencies: Dependencies, botConfig?: BotConfig<Context>) { export function createBot(
const { token: string,
config, dependencies: Dependencies,
logger, botConfig?: BotConfig<Context>
} = dependencies ) {
const { config, logger } = dependencies;
const bot = new TelegramBot<Context>(token, botConfig) const bot = new TelegramBot<Context>(token, botConfig);
bot.use(async (ctx, next) => { bot.use(async (ctx, next) => {
ctx.config = config ctx.config = config;
ctx.logger = logger.child({ ctx.logger = logger.child({
update_id: ctx.update.update_id, update_id: ctx.update.update_id
}) });
await next() await next();
}) });
const protectedBot = bot.errorBoundary(errorHandler) const protectedBot = bot.errorBoundary(errorHandler);
// Middlewares // Middlewares
bot.api.config.use(parseMode('HTML')) bot.api.config.use(parseMode("HTML"));
if (config.isPollingMode) if (config.isPollingMode) protectedBot.use(sequentialize(getSessionKey));
protectedBot.use(sequentialize(getSessionKey)) if (config.isDebug) protectedBot.use(updateLogger());
if (config.isDebug) protectedBot.use(autoChatAction(bot.api));
protectedBot.use(updateLogger()) protectedBot.use(hydrateReply);
protectedBot.use(autoChatAction(bot.api)) protectedBot.use(hydrate());
protectedBot.use(hydrateReply) protectedBot.use(
protectedBot.use(hydrate()) session({
protectedBot.use(session({ getSessionKey,
getSessionKey, storage: new MemorySessionStorage()
storage: new MemorySessionStorage(), })
})) );
protectedBot.use(i18n) protectedBot.use(i18n);
// Handlers // Handlers
protectedBot.use(welcomeFeature) protectedBot.use(welcomeFeature);
protectedBot.use(adminFeature) protectedBot.use(adminFeature);
if (isMultipleLocales) if (isMultipleLocales) protectedBot.use(languageFeature);
protectedBot.use(languageFeature)
// Commands
protectedBot.use(botInfoCommand);
protectedBot.use(getGroupIDCommand);
protectedBot.use(isLCMGroup);
protectedBot.use(helpCommand);
// Blacklist Feature
protectedBot.use(blacklistDetection);
// must be the last handler // must be the last handler
protectedBot.use(unhandledFeature) protectedBot.use(unhandledFeature);
return bot return bot;
} }
export type Bot = ReturnType<typeof createBot> export type Bot = ReturnType<typeof createBot>;

View File

@@ -1,28 +1,28 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import { changeLanguageData } from '#root/bot/callback-data/change-language.js' import { changeLanguageData } from "#root/bot/callback-data/change-language.js";
import { chunk } from '#root/bot/helpers/keyboard.js' import { chunk } from "#root/bot/helpers/keyboard.js";
import { i18n } from '#root/bot/i18n.js' import { i18n } from "#root/bot/i18n.js";
import { InlineKeyboard } from 'grammy' import { InlineKeyboard } from "grammy";
import ISO6391 from 'iso-639-1' import ISO6391 from "iso-639-1";
export async function createChangeLanguageKeyboard(ctx: Context) { export async function createChangeLanguageKeyboard(ctx: Context) {
const currentLocaleCode = await ctx.i18n.getLocale() const currentLocaleCode = await ctx.i18n.getLocale();
const getLabel = (code: string) => { const getLabel = (code: string) => {
const isActive = code === currentLocaleCode const isActive = code === currentLocaleCode;
return `${isActive ? '' : ''}${ISO6391.getNativeName(code)}` return `${isActive ? "" : ""}${ISO6391.getNativeName(code)}`;
} };
return InlineKeyboard.from( return InlineKeyboard.from(
chunk( chunk(
i18n.locales.map(localeCode => ({ i18n.locales.map(localeCode => ({
text: getLabel(localeCode), text: getLabel(localeCode),
callback_data: changeLanguageData.pack({ callback_data: changeLanguageData.pack({
code: localeCode, code: localeCode
}), })
})), })),
2, 2
), )
) );
} }

View File

@@ -1,13 +1,16 @@
import type { Context, SessionData } from '#root/bot/context.js' import type { Context, SessionData } from "#root/bot/context.js";
import type { Middleware, SessionOptions } from 'grammy' import type { Middleware, SessionOptions } from "grammy";
import { session as createSession } from 'grammy' import { session as createSession } from "grammy";
type Options = Pick<SessionOptions<SessionData, Context>, 'getSessionKey' | 'storage'> type Options = Pick<
SessionOptions<SessionData, Context>,
"getSessionKey" | "storage"
>;
export function session(options: Options): Middleware<Context> { export function session(options: Options): Middleware<Context> {
return createSession({ return createSession({
getSessionKey: options.getSessionKey, getSessionKey: options.getSessionKey,
storage: options.storage, storage: options.storage,
initial: () => ({}), initial: () => ({})
}) });
} }

View File

@@ -1,35 +1,34 @@
import type { Context } from '#root/bot/context.js' import type { Context } from "#root/bot/context.js";
import type { Middleware } from 'grammy' import type { Middleware } from "grammy";
import { performance } from 'node:perf_hooks' import { performance } from "node:perf_hooks";
import { getUpdateInfo } from '#root/bot/helpers/logging.js' import { getUpdateInfo } from "#root/bot/helpers/logging.js";
export function updateLogger(): Middleware<Context> { export function updateLogger(): Middleware<Context> {
return async (ctx, next) => { return async (ctx, next) => {
ctx.api.config.use((previous, method, payload, signal) => { ctx.api.config.use((previous, method, payload, signal) => {
ctx.logger.debug({ ctx.logger.debug({
msg: 'Bot API call', msg: "Bot API call",
method, method,
payload, payload
}) });
return previous(method, payload, signal) return previous(method, payload, signal);
}) });
ctx.logger.debug({ ctx.logger.debug({
msg: 'Update received', msg: "Update received",
update: getUpdateInfo(ctx), update: getUpdateInfo(ctx)
}) });
const startTime = performance.now() const startTime = performance.now();
try { try {
await next() await next();
} } finally {
finally { const endTime = performance.now();
const endTime = performance.now()
ctx.logger.debug({ ctx.logger.debug({
msg: 'Update processed', msg: "Update processed",
elapsed: endTime - startTime, elapsed: endTime - startTime
}) });
} }
} };
} }

View File

@@ -1,98 +1,124 @@
import process from 'node:process' import process from "node:process";
import { API_CONSTANTS } from 'grammy' import { API_CONSTANTS } from "grammy";
import * as v from 'valibot' import * as v from "valibot";
const baseConfigSchema = v.object({ const baseConfigSchema = v.object({
debug: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.boolean()), 'false'), debug: v.optional(
logLevel: v.optional(v.pipe(v.string(), v.picklist(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'])), 'info'), v.pipe(v.string(), v.transform(JSON.parse), v.boolean()),
botToken: v.pipe(v.string(), v.regex(/^\d+:[\w-]+$/, 'Invalid token')), "false"
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())), '[]'), 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', [ const configSchema = v.variant("botMode", [
// polling config // polling config
v.pipe( v.pipe(
v.object({ v.object({
botMode: v.literal('polling'), botMode: v.literal("polling"),
...baseConfigSchema.entries, ...baseConfigSchema.entries
}), }),
v.transform(input => ({ v.transform(input => ({
...input, ...input,
isDebug: input.debug, isDebug: input.debug,
isWebhookMode: false as const, isWebhookMode: false as const,
isPollingMode: true as const, isPollingMode: true as const
})), }))
), ),
// webhook config // webhook config
v.pipe( v.pipe(
v.object({ v.object({
botMode: v.literal('webhook'), botMode: v.literal("webhook"),
...baseConfigSchema.entries, ...baseConfigSchema.entries,
botWebhook: v.pipe(v.string(), v.url()), botWebhook: v.pipe(v.string(), v.url()),
botWebhookSecret: v.pipe(v.string(), v.minLength(12)), botWebhookSecret: v.pipe(v.string(), v.minLength(12)),
serverHost: v.optional(v.string(), '0.0.0.0'), serverHost: v.optional(v.string(), "0.0.0.0"),
serverPort: v.optional(v.pipe(v.string(), v.transform(Number), v.number()), '80'), serverPort: v.optional(
v.pipe(v.string(), v.transform(Number), v.number()),
"80"
)
}), }),
v.transform(input => ({ v.transform(input => ({
...input, ...input,
isDebug: input.debug, isDebug: input.debug,
isWebhookMode: true as const, isWebhookMode: true as const,
isPollingMode: false as const, isPollingMode: false as const
})), }))
), )
]) ]);
export type Config = v.InferOutput<typeof configSchema> export type Config = v.InferOutput<typeof configSchema>;
export type PollingConfig = v.InferOutput<typeof configSchema['options'][0]> export type PollingConfig = v.InferOutput<(typeof configSchema)["options"][0]>;
export type WebhookConfig = v.InferOutput<typeof configSchema['options'][1]> export type WebhookConfig = v.InferOutput<(typeof configSchema)["options"][1]>;
export function createConfig(input: v.InferInput<typeof configSchema>) { export function createConfig(input: v.InferInput<typeof configSchema>) {
return v.parse(configSchema, input) return v.parse(configSchema, input);
} }
export const config = createConfigFromEnvironment() export const config = createConfigFromEnvironment();
function createConfigFromEnvironment() { function createConfigFromEnvironment() {
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}` type CamelCase<S extends string> =
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}` S extends `${infer P1}_${infer P2}${infer P3}`
: Lowercase<S> ? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>;
type KeysToCamelCase<T> = { type KeysToCamelCase<T> = {
[K in keyof T as CamelCase<string & K>]: T[K] extends object ? KeysToCamelCase<T[K]> : T[K] [K in keyof T as CamelCase<string & K>]: T[K] extends object
} ? KeysToCamelCase<T[K]>
: T[K];
};
function toCamelCase(str: string): string { function toCamelCase(str: string): string {
return str.toLowerCase().replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase()) return str
.toLowerCase()
.replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase());
} }
function convertKeysToCamelCase<T>(obj: T): KeysToCamelCase<T> { function convertKeysToCamelCase<T>(obj: T): KeysToCamelCase<T> {
const result: any = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const key in obj) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
const camelCaseKey = toCamelCase(key) const camelCaseKey = toCamelCase(key);
result[camelCaseKey] = obj[key] result[camelCaseKey] = obj[key];
} }
} }
return result return result;
} }
try { try {
process.loadEnvFile() process.loadEnvFile();
} } catch {
catch {
// No .env file found // No .env file found
} }
try { try {
// @ts-expect-error create config from environment variables // @ts-expect-error create config from environment variables
const config = createConfig(convertKeysToCamelCase(process.env)) const config = createConfig(convertKeysToCamelCase(process.env));
return config return config;
} } catch (error) {
catch (error) { throw new Error("Invalid config", {
throw new Error('Invalid config', { cause: error
cause: error, });
})
} }
} }

View File

@@ -1,5 +1,5 @@
import { config } from '#root/config.js' import { config } from "#root/config.js";
import { pino } from 'pino' import { pino } from "pino";
export const logger = pino({ export const logger = pino({
level: config.logLevel, level: config.logLevel,
@@ -8,24 +8,24 @@ export const logger = pino({
...(config.isDebug ...(config.isDebug
? [ ? [
{ {
target: 'pino-pretty', target: "pino-pretty",
level: config.logLevel, level: config.logLevel,
options: { options: {
ignore: 'pid,hostname', ignore: "pid,hostname",
colorize: true, colorize: true,
translateTime: true, translateTime: true
}, }
}, }
] ]
: [ : [
{ {
target: 'pino/file', target: "pino/file",
level: config.logLevel, level: config.logLevel,
options: {}, options: {}
}, }
]), ])
], ]
}, }
}) });
export type Logger = typeof logger export type Logger = typeof logger;

View File

@@ -1,111 +1,104 @@
#!/usr/bin/env tsx #!/usr/bin/env tsx
/* eslint-disable antfu/no-top-level-await */
import type { PollingConfig, WebhookConfig } from '#root/config.js' import type { PollingConfig, WebhookConfig } from "#root/config.js";
import type { RunnerHandle } from '@grammyjs/runner' import type { RunnerHandle } from "@grammyjs/runner";
import process from 'node:process' import process from "node:process";
import { createBot } from '#root/bot/index.js' import { createBot } from "#root/bot/index.js";
import { config } from '#root/config.js' import { config } from "#root/config.js";
import { logger } from '#root/logger.js' import { logger } from "#root/logger.js";
import { createServer, createServerManager } from '#root/server/index.js' import { createServer, createServerManager } from "#root/server/index.js";
import { run } from '@grammyjs/runner' import { run } from "@grammyjs/runner";
async function startPolling(config: PollingConfig) { async function startPolling(config: PollingConfig) {
const bot = createBot(config.botToken, { const bot = createBot(config.botToken, {
config, config,
logger, logger
}) });
let runner: undefined | RunnerHandle // eslint-disable-next-line prefer-const
let runner: undefined | RunnerHandle;
// graceful shutdown // graceful shutdown
onShutdown(async () => { onShutdown(async () => {
logger.info('Shutdown') logger.info("Shutdown");
await runner?.stop() await runner?.stop();
}) });
await Promise.all([ await Promise.all([bot.init(), bot.api.deleteWebhook()]);
bot.init(),
bot.api.deleteWebhook(),
])
// start bot // start bot
runner = run(bot, { runner = run(bot, {
runner: { runner: {
fetch: { fetch: {
allowed_updates: config.botAllowedUpdates, allowed_updates: config.botAllowedUpdates
}, }
}, }
}) });
logger.info({ logger.info({
msg: 'Bot running...', msg: "Bot running...",
username: bot.botInfo.username, username: bot.botInfo.username
}) });
} }
async function startWebhook(config: WebhookConfig) { async function startWebhook(config: WebhookConfig) {
const bot = createBot(config.botToken, { const bot = createBot(config.botToken, {
config, config,
logger, logger
}) });
const server = createServer({ const server = createServer({
bot, bot,
config, config,
logger, logger
}) });
const serverManager = createServerManager(server, { const serverManager = createServerManager(server, {
host: config.serverHost, host: config.serverHost,
port: config.serverPort, port: config.serverPort
}) });
// graceful shutdown // graceful shutdown
onShutdown(async () => { onShutdown(async () => {
logger.info('Shutdown') logger.info("Shutdown");
await serverManager.stop() await serverManager.stop();
}) });
// to prevent receiving updates before the bot is ready // to prevent receiving updates before the bot is ready
await bot.init() await bot.init();
// start server // start server
const info = await serverManager.start() const info = await serverManager.start();
logger.info({ logger.info({
msg: 'Server started', msg: "Server started",
url: info.url, url: info.url
}) });
// set webhook // set webhook
await bot.api.setWebhook(config.botWebhook, { await bot.api.setWebhook(config.botWebhook, {
allowed_updates: config.botAllowedUpdates, allowed_updates: config.botAllowedUpdates,
secret_token: config.botWebhookSecret, secret_token: config.botWebhookSecret
}) });
logger.info({ logger.info({
msg: 'Webhook was set', msg: "Webhook was set",
url: config.botWebhook, url: config.botWebhook
}) });
} }
try { try {
if (config.isWebhookMode) if (config.isWebhookMode) await startWebhook(config);
await startWebhook(config) else if (config.isPollingMode) await startPolling(config);
else if (config.isPollingMode) } catch (error) {
await startPolling(config) logger.error(error);
} process.exit(1);
catch (error) {
logger.error(error)
process.exit(1)
} }
// Utils // Utils
function onShutdown(cleanUp: () => Promise<void>) { function onShutdown(cleanUp: () => Promise<void>) {
let isShuttingDown = false let isShuttingDown = false;
const handleShutdown = async () => { const handleShutdown = async () => {
if (isShuttingDown) if (isShuttingDown) return;
return isShuttingDown = true;
isShuttingDown = true await cleanUp();
await cleanUp() };
} process.on("SIGINT", handleShutdown);
process.on('SIGINT', handleShutdown) process.on("SIGTERM", handleShutdown);
process.on('SIGTERM', handleShutdown)
} }

View File

@@ -1,8 +1,8 @@
import type { Logger } from '#root/logger.js' import type { Logger } from "#root/logger.js";
export interface Env { export interface Env {
Variables: { Variables: {
requestId: string requestId: string;
logger: Logger logger: Logger;
} };
} }

View File

@@ -1,102 +1,98 @@
import type { Bot } from '#root/bot/index.js' import type { Bot } from "#root/bot/index.js";
import type { Config } from '#root/config.js' import type { Config } from "#root/config.js";
import type { Logger } from '#root/logger.js' import type { Logger } from "#root/logger.js";
import type { Env } from '#root/server/environment.js' import type { Env } from "#root/server/environment.js";
import { setLogger } from '#root/server/middlewares/logger.js' import { setLogger } from "#root/server/middlewares/logger.js";
import { requestId } from '#root/server/middlewares/request-id.js' import { requestId } from "#root/server/middlewares/request-id.js";
import { requestLogger } from '#root/server/middlewares/request-logger.js' import { requestLogger } from "#root/server/middlewares/request-logger.js";
import { serve } from '@hono/node-server' import { serve } from "@hono/node-server";
import { webhookCallback } from 'grammy' import { webhookCallback } from "grammy";
import { Hono } from 'hono' import { Hono } from "hono";
import { HTTPException } from 'hono/http-exception' import { HTTPException } from "hono/http-exception";
import { getPath } from 'hono/utils/url' import { getPath } from "hono/utils/url";
interface Dependencies { interface Dependencies {
bot: Bot bot: Bot;
config: Config config: Config;
logger: Logger logger: Logger;
} }
export function createServer(dependencies: Dependencies) { export function createServer(dependencies: Dependencies) {
const { const { bot, config, logger } = dependencies;
bot,
config,
logger,
} = dependencies
const server = new Hono<Env>() const server = new Hono<Env>();
server.use(requestId()) server.use(requestId());
server.use(setLogger(logger)) server.use(setLogger(logger));
if (config.isDebug) if (config.isDebug) server.use(requestLogger());
server.use(requestLogger())
server.onError(async (error, c) => { server.onError(async (error, c) => {
if (error instanceof HTTPException) { if (error instanceof HTTPException) {
if (error.status < 500) if (error.status < 500) c.var.logger.info(error);
c.var.logger.info(error) else c.var.logger.error(error);
else
c.var.logger.error(error)
return error.getResponse() return error.getResponse();
} }
// unexpected error // unexpected error
c.var.logger.error({ c.var.logger.error({
err: error, err: error,
method: c.req.raw.method, method: c.req.raw.method,
path: getPath(c.req.raw), path: getPath(c.req.raw)
}) });
return c.json( return c.json(
{ {
error: 'Oops! Something went wrong.', error: "Oops! Something went wrong."
}, },
500, 500
) );
}) });
server.get('/', c => c.json({ status: true })) server.get("/", c => c.json({ status: true }));
if (config.isWebhookMode) { if (config.isWebhookMode) {
server.post( server.post(
'/webhook', "/webhook",
webhookCallback(bot, 'hono', { webhookCallback(bot, "hono", {
secretToken: config.botWebhookSecret, secretToken: config.botWebhookSecret
}), })
) );
} }
return server return server;
} }
export type Server = Awaited<ReturnType<typeof createServer>> export type Server = Awaited<ReturnType<typeof createServer>>;
export function createServerManager(server: Server, options: { host: string, port: number }) { export function createServerManager(
let handle: undefined | ReturnType<typeof serve> server: Server,
options: { host: string; port: number }
) {
let handle: undefined | ReturnType<typeof serve>;
return { return {
start() { start() {
return new Promise<{ url: string }>((resolve) => { return new Promise<{ url: string }>(resolve => {
handle = serve( handle = serve(
{ {
fetch: server.fetch, fetch: server.fetch,
hostname: options.host, hostname: options.host,
port: options.port, port: options.port
}, },
info => resolve({ info =>
url: info.family === 'IPv6' resolve({
? `http://[${info.address}]:${info.port}` url:
: `http://${info.address}:${info.port}`, info.family === "IPv6"
}), ? `http://[${info.address}]:${info.port}`
) : `http://${info.address}:${info.port}`
}) })
);
});
}, },
stop() { stop() {
return new Promise<void>((resolve) => { return new Promise<void>(resolve => {
if (handle) if (handle) handle.close(() => resolve());
handle.close(() => resolve()) else resolve();
else });
resolve() }
}) };
},
}
} }

View File

@@ -1,15 +1,15 @@
import type { Logger } from '#root/logger.js' import type { Logger } from "#root/logger.js";
import type { MiddlewareHandler } from 'hono' import type { MiddlewareHandler } from "hono";
export function setLogger(logger: Logger): MiddlewareHandler { export function setLogger(logger: Logger): MiddlewareHandler {
return async (c, next) => { return async (c, next) => {
c.set( c.set(
'logger', "logger",
logger.child({ logger.child({
requestId: c.get('requestId'), requestId: c.get("requestId")
}), })
) );
await next() await next();
} };
} }

View File

@@ -1,10 +1,10 @@
import type { MiddlewareHandler } from 'hono' import type { MiddlewareHandler } from "hono";
import { randomUUID } from 'node:crypto' import { randomUUID } from "node:crypto";
export function requestId(): MiddlewareHandler { export function requestId(): MiddlewareHandler {
return async (c, next) => { return async (c, next) => {
c.set('requestId', randomUUID()) c.set("requestId", randomUUID());
await next() await next();
} };
} }

View File

@@ -1,27 +1,27 @@
import type { MiddlewareHandler } from 'hono' import type { MiddlewareHandler } from "hono";
import { getPath } from 'hono/utils/url' import { getPath } from "hono/utils/url";
export function requestLogger(): MiddlewareHandler { export function requestLogger(): MiddlewareHandler {
return async (c, next) => { return async (c, next) => {
const { method } = c.req const { method } = c.req;
const path = getPath(c.req.raw) const path = getPath(c.req.raw);
c.var.logger.debug({ c.var.logger.debug({
msg: 'Incoming request', msg: "Incoming request",
method, method,
path, path
}) });
const startTime = performance.now() const startTime = performance.now();
await next() await next();
const endTime = performance.now() const endTime = performance.now();
c.var.logger.debug({ c.var.logger.debug({
msg: 'Request completed', msg: "Request completed",
method, method,
path, path,
status: c.res.status, status: c.res.status,
elapsed: endTime - startTime, elapsed: endTime - startTime
}) });
} };
} }

View File

@@ -5,9 +5,7 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"paths": { "paths": {
"#root/*": [ "#root/*": ["./src/*"]
"./src/*"
]
}, },
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
@@ -17,7 +15,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"preserveWatchOutput": true "preserveWatchOutput": true
}, },
"include": [ "include": ["src/**/*"]
"src/**/*"
]
} }

3153
yarn.lock Normal file

File diff suppressed because it is too large Load Diff