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:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
build-and-push-docker-image:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
auto-merge:
if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
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
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:
github-token: '${{ secrets.GITHUB_TOKEN }}'
- name: Auto-merge
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
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 }}

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

@@ -34,10 +34,16 @@ Follow these steps to set up and run your bot using this template:
2. **Environment Variables Setup**
Create an environment variables file by copying the provided example file:
```bash
cp .env.example .env
# development
cp .env.example .env.bot.dev
# production
cp .env.example .env.bot.prod
```
Open the newly created `.env` file and set the `BOT_TOKEN` environment variable.
Open the newly created `.env.bot.dev` and `.env.bot.prod` files and set the `BOT_TOKEN` environment variable.
3. **Launching the Bot**
@@ -46,28 +52,25 @@ Follow these steps to set up and run your bot using this template:
**Development Mode:**
Install the required dependencies:
```bash
npm install
```
Start the bot in watch mode (auto-reload when code changes):
```bash
npm run dev
docker compose up
```
**Production Mode:**
Install only production dependencies:
```bash
npm install --only=prod
```
Set `DEBUG` environment variable to `false` in your `.env` file.
Start the bot in production mode:
```bash
npm run start:force # skip type checking and start
# or
npm start # with type checking (requires development dependencies)
docker compose -f compose.yml -f compose.prod.yml up
```
### 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",
"version": "0.1.0",
"private": true,
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447",
"description": "Telegram bot starter template",
"imports": {
"#root/*": "./build/src/*"
@@ -21,7 +22,8 @@
"dev": "tsc-watch --onSuccess \"tsx ./src/main.ts\"",
"start": "tsc && tsx ./src/main.ts",
"start:force": "tsx ./src/main.ts",
"prepare": "husky || true"
"prepare": "husky || true",
"pretty": "prettier --write ."
},
"dependencies": {
"@grammyjs/auto-chat-action": "0.1.1",
@@ -43,12 +45,16 @@
},
"devDependencies": {
"@antfu/eslint-config": "4.3.0",
"@eslint/js": "^9.20.0",
"@types/node": "^22.13.4",
"eslint": "^9.20.1",
"globals": "^15.15.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"prettier": "^3.5.1",
"tsc-watch": "^6.2.1",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1"
},
"lint-staged": {
"*.ts": "eslint"

View File

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

View File

@@ -1,18 +1,19 @@
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'
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";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface SessionData {
// field?: string;
}
interface ExtendedContextFlavor {
logger: Logger
config: Config
logger: Logger;
config: Config;
}
export type Context = ParseModeFlavor<
@@ -23,4 +24,4 @@ export type Context = ParseModeFlavor<
I18nFlavor &
AutoChatActionFlavor
>
>
>;

View File

@@ -1,21 +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'
import { chatAction } from "@grammyjs/auto-chat-action";
import { Composer } from "grammy";
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";
const composer = new Composer<Context>()
const composer = new Composer<Context>();
const feature = composer
.chatType('private')
.filter(isAdmin)
.chatType("private")
.filter(ctx => isAdmin(ctx.config.botAdmins)(ctx));
feature.command(
'setcommands',
logHandle('command-setcommands'),
chatAction('typing'),
setCommandsHandler,
)
"setcommands",
logHandle("command-setcommands"),
chatAction("typing"),
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 { 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'
import { Composer } from "grammy";
import { changeLanguageData } from "#root/bot/callback-data/change-language.js";
import type { Context } from "#root/bot/context.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";
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) => {
return ctx.reply(ctx.t('language-select'), {
reply_markup: await createChangeLanguageKeyboard(ctx),
})
})
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) => {
logHandle("keyboard-language-select"),
async ctx => {
const { code: languageCode } = changeLanguageData.unpack(
ctx.callbackQuery.data,
)
ctx.callbackQuery.data
);
if (i18n.locales.includes(languageCode)) {
await ctx.i18n.setLocale(languageCode)
await ctx.i18n.setLocale(languageCode);
return ctx.editMessageText(ctx.t('language-changed'), {
reply_markup: await createChangeLanguageKeyboard(ctx),
})
return ctx.editMessageText(ctx.t("language-changed"), {
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 { logHandle } from '#root/bot/helpers/logging.js'
import { Composer } from 'grammy'
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 composer = new Composer<Context>();
const feature = composer.chatType('private')
const feature = composer.chatType("private");
feature.on('message', logHandle('unhandled-message'), (ctx) => {
return ctx.reply(ctx.t('unhandled'))
})
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()
})
feature.on("callback_query", logHandle("unhandled-callback-query"), ctx => {
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 { logHandle } from '#root/bot/helpers/logging.js'
import { Composer } from 'grammy'
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 composer = new Composer<Context>();
const feature = composer.chatType('private')
const feature = composer.chatType("private");
feature.command('start', logHandle('command-start'), (ctx) => {
return ctx.reply(ctx.t('welcome'))
})
feature.command("start", logHandle("command-start"), ctx => {
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) {
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 { LanguageCode } from '@grammyjs/types'
import type { CommandContext } from 'grammy'
import { i18n } from '#root/bot/i18n.js'
import { Command, CommandGroup } from '@grammyjs/commands'
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) => {
i18n.locales.forEach(locale => {
command.localize(
locale as LanguageCode,
command.name,
i18n.t(locale, `${command.name}.description`),
)
})
return command
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,
})
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 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 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 setcommands = new Command(
"setcommands",
i18n.t("en", "setcommands.description")
);
addCommandToChats(setcommands, ctx.config.botAdmins);
const commands = new CommandGroup()
.add(start)
.add(language)
.add(setcommands)
const commands = new CommandGroup().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 { ErrorHandler } from 'grammy'
import { getUpdateInfo } from '#root/bot/helpers/logging.js'
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
export const errorHandler: ErrorHandler<Context> = error => {
const { ctx } = error;
ctx.logger.error({
err: error.error,
update: getUpdateInfo(ctx),
})
}
update: getUpdateInfo(ctx)
});
};

View File

@@ -1,7 +1,7 @@
export function chunk<T>(array: T[], size: number) {
const result = []
const result = [];
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 { Update } from '@grammyjs/types'
import type { Middleware } from 'grammy'
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
export function getUpdateInfo(ctx: Context): Omit<Update, "update_id"> {
const { /*update_id,*/ ...update } = ctx.update;
return 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) } : {}),
})
...(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 path from 'node:path'
import process from 'node:process'
import { I18n } from '@grammyjs/i18n'
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'),
defaultLocale: "en",
directory: path.resolve(process.cwd(), "locales"),
useSession: true,
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 { 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'
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";
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 {
config: Config
logger: Logger
config: Config;
logger: Logger;
}
function getSessionKey(ctx: Omit<Context, 'session'>) {
return ctx.chat?.id.toString()
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
export function createBot(
token: string,
dependencies: Dependencies,
botConfig?: BotConfig<Context>
) {
const { config, logger } = dependencies;
const bot = new TelegramBot<Context>(token, botConfig)
const bot = new TelegramBot<Context>(token, botConfig);
bot.use(async (ctx, next) => {
ctx.config = config
ctx.config = config;
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
bot.api.config.use(parseMode('HTML'))
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({
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)
storage: new MemorySessionStorage()
})
);
protectedBot.use(i18n);
// Handlers
protectedBot.use(welcomeFeature)
protectedBot.use(adminFeature)
if (isMultipleLocales)
protectedBot.use(languageFeature)
protectedBot.use(welcomeFeature);
protectedBot.use(adminFeature);
if (isMultipleLocales) 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
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 { 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'
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 currentLocaleCode = await ctx.i18n.getLocale();
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(
chunk(
i18n.locales.map(localeCode => ({
text: getLabel(localeCode),
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 { Middleware, SessionOptions } from 'grammy'
import { session as createSession } from 'grammy'
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'>
type Options = Pick<
SessionOptions<SessionData, Context>,
"getSessionKey" | "storage"
>;
export function session(options: Options): Middleware<Context> {
return createSession({
getSessionKey: options.getSessionKey,
storage: options.storage,
initial: () => ({}),
})
initial: () => ({})
});
}

View File

@@ -1,35 +1,34 @@
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'
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',
msg: "Bot API call",
method,
payload,
})
payload
});
return previous(method, payload, signal)
})
return previous(method, payload, signal);
});
ctx.logger.debug({
msg: 'Update received',
update: getUpdateInfo(ctx),
})
msg: "Update received",
update: getUpdateInfo(ctx)
});
const startTime = performance.now()
const startTime = performance.now();
try {
await next()
}
finally {
const endTime = performance.now()
await next();
} finally {
const endTime = performance.now();
ctx.logger.debug({
msg: 'Update processed',
elapsed: endTime - startTime,
})
}
msg: "Update processed",
elapsed: endTime - startTime
});
}
};
}

View File

@@ -1,98 +1,124 @@
import process from 'node:process'
import { API_CONSTANTS } from 'grammy'
import * as v from 'valibot'
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())), '[]'),
})
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', [
const configSchema = v.variant("botMode", [
// polling config
v.pipe(
v.object({
botMode: v.literal('polling'),
...baseConfigSchema.entries,
botMode: v.literal("polling"),
...baseConfigSchema.entries
}),
v.transform(input => ({
...input,
isDebug: input.debug,
isWebhookMode: false as const,
isPollingMode: true as const,
})),
isPollingMode: true as const
}))
),
// webhook config
v.pipe(
v.object({
botMode: v.literal('webhook'),
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'),
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,
})),
),
])
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 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)
return v.parse(configSchema, input);
}
export const config = createConfigFromEnvironment()
export const config = createConfigFromEnvironment();
function createConfigFromEnvironment() {
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}`
type CamelCase<S extends string> =
S extends `${infer P1}_${infer P2}${infer P3}`
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>
: Lowercase<S>;
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 {
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> {
const result: any = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const camelCaseKey = toCamelCase(key)
result[camelCaseKey] = obj[key]
const camelCaseKey = toCamelCase(key);
result[camelCaseKey] = obj[key];
}
}
return result
return result;
}
try {
process.loadEnvFile()
}
catch {
process.loadEnvFile();
} catch {
// No .env file found
}
try {
// @ts-expect-error create config from environment variables
const config = createConfig(convertKeysToCamelCase(process.env))
const config = createConfig(convertKeysToCamelCase(process.env));
return config
}
catch (error) {
throw new Error('Invalid config', {
cause: error,
})
return config;
} catch (error) {
throw new Error("Invalid config", {
cause: error
});
}
}

View File

@@ -1,5 +1,5 @@
import { config } from '#root/config.js'
import { pino } from 'pino'
import { config } from "#root/config.js";
import { pino } from "pino";
export const logger = pino({
level: config.logLevel,
@@ -8,24 +8,24 @@ export const logger = pino({
...(config.isDebug
? [
{
target: 'pino-pretty',
target: "pino-pretty",
level: config.logLevel,
options: {
ignore: 'pid,hostname',
ignore: "pid,hostname",
colorize: true,
translateTime: true,
},
},
translateTime: true
}
}
]
: [
{
target: 'pino/file',
target: "pino/file",
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
/* 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'
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
logger
});
// eslint-disable-next-line prefer-const
let runner: undefined | RunnerHandle;
// graceful shutdown
onShutdown(async () => {
logger.info('Shutdown')
await runner?.stop()
})
logger.info("Shutdown");
await runner?.stop();
});
await Promise.all([
bot.init(),
bot.api.deleteWebhook(),
])
await Promise.all([bot.init(), bot.api.deleteWebhook()]);
// start bot
runner = run(bot, {
runner: {
fetch: {
allowed_updates: config.botAllowedUpdates,
},
},
})
allowed_updates: config.botAllowedUpdates
}
}
});
logger.info({
msg: 'Bot running...',
username: bot.botInfo.username,
})
msg: "Bot running...",
username: bot.botInfo.username
});
}
async function startWebhook(config: WebhookConfig) {
const bot = createBot(config.botToken, {
config,
logger,
})
logger
});
const server = createServer({
bot,
config,
logger,
})
logger
});
const serverManager = createServerManager(server, {
host: config.serverHost,
port: config.serverPort,
})
port: config.serverPort
});
// graceful shutdown
onShutdown(async () => {
logger.info('Shutdown')
await serverManager.stop()
})
logger.info("Shutdown");
await serverManager.stop();
});
// to prevent receiving updates before the bot is ready
await bot.init()
await bot.init();
// start server
const info = await serverManager.start()
const info = await serverManager.start();
logger.info({
msg: 'Server started',
url: info.url,
})
msg: "Server started",
url: info.url
});
// set webhook
await bot.api.setWebhook(config.botWebhook, {
allowed_updates: config.botAllowedUpdates,
secret_token: config.botWebhookSecret,
})
secret_token: config.botWebhookSecret
});
logger.info({
msg: 'Webhook was set',
url: config.botWebhook,
})
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)
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
let isShuttingDown = false;
const handleShutdown = async () => {
if (isShuttingDown)
return
isShuttingDown = true
await cleanUp()
}
process.on('SIGINT', handleShutdown)
process.on('SIGTERM', handleShutdown)
if (isShuttingDown) return;
isShuttingDown = true;
await cleanUp();
};
process.on("SIGINT", 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 {
Variables: {
requestId: string
logger: Logger
}
requestId: string;
logger: Logger;
};
}

View File

@@ -1,102 +1,98 @@
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'
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
bot: Bot;
config: Config;
logger: Logger;
}
export function createServer(dependencies: Dependencies) {
const {
bot,
config,
logger,
} = dependencies
const { bot, config, logger } = dependencies;
const server = new Hono<Env>()
const server = new Hono<Env>();
server.use(requestId())
server.use(setLogger(logger))
if (config.isDebug)
server.use(requestLogger())
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)
if (error.status < 500) c.var.logger.info(error);
else c.var.logger.error(error);
return error.getResponse()
return error.getResponse();
}
// unexpected error
c.var.logger.error({
err: error,
method: c.req.raw.method,
path: getPath(c.req.raw),
})
path: getPath(c.req.raw)
});
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) {
server.post(
'/webhook',
webhookCallback(bot, 'hono', {
secretToken: config.botWebhookSecret,
}),
)
"/webhook",
webhookCallback(bot, "hono", {
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 }) {
let handle: undefined | ReturnType<typeof serve>
export function createServerManager(
server: Server,
options: { host: string; port: number }
) {
let handle: undefined | ReturnType<typeof serve>;
return {
start() {
return new Promise<{ url: string }>((resolve) => {
return new Promise<{ url: string }>(resolve => {
handle = serve(
{
fetch: server.fetch,
hostname: options.host,
port: options.port,
port: options.port
},
info => resolve({
url: info.family === 'IPv6'
info =>
resolve({
url:
info.family === "IPv6"
? `http://[${info.address}]:${info.port}`
: `http://${info.address}:${info.port}`,
}),
)
: `http://${info.address}:${info.port}`
})
);
});
},
stop() {
return new Promise<void>((resolve) => {
if (handle)
handle.close(() => resolve())
else
resolve()
})
},
return new Promise<void>(resolve => {
if (handle) handle.close(() => resolve());
else resolve();
});
}
};
}

View File

@@ -1,15 +1,15 @@
import type { Logger } from '#root/logger.js'
import type { MiddlewareHandler } from 'hono'
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",
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 { randomUUID } from 'node:crypto'
import type { MiddlewareHandler } from "hono";
import { randomUUID } from "node:crypto";
export function requestId(): MiddlewareHandler {
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 { getPath } from 'hono/utils/url'
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)
const { method } = c.req;
const path = getPath(c.req.raw);
c.var.logger.debug({
msg: 'Incoming request',
msg: "Incoming request",
method,
path,
})
const startTime = performance.now()
path
});
const startTime = performance.now();
await next()
await next();
const endTime = performance.now()
const endTime = performance.now();
c.var.logger.debug({
msg: 'Request completed',
msg: "Request completed",
method,
path,
status: c.res.status,
elapsed: endTime - startTime,
})
}
elapsed: endTime - startTime
});
};
}

View File

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

3153
yarn.lock Normal file

File diff suppressed because it is too large Load Diff