From 74ab963e92fe2b6d0e7a1366a1ce4615a8e32264 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 18 Jan 2025 22:43:13 +0700 Subject: [PATCH] format: format it --- .prettierrc | 9 +- .vscode/extensions.json | 6 +- host.json | 4 +- package-lock.json | 17 + package.json | 2 + scripts/_config.ts | 12 +- scripts/libs/AzureFunctionsClient.ts | 37 +- scripts/libs/SecretManager.ts | 111 +++-- scripts/libs/TelegramClient.ts | 24 +- scripts/libs/TunnelNgrokManager.ts | 319 +++++++------- scripts/libs/interfaces/TunnelManager.ts | 5 +- scripts/pre-deploy.ts | 34 +- scripts/tunnel.ts | 26 +- scripts/utils/error.ts | 18 +- scripts/utils/logger/console-logger.ts | 34 +- scripts/utils/logger/logger.ts | 3 +- scripts/utils/logger/pino-logger.ts | 98 ++--- src/bootstrap.ts | 62 +-- src/bot/ai/characters.ts | 6 +- src/bot/ai/openai.ts | 203 ++++----- src/bot/bot.ts | 502 ++++++++++++----------- src/bot/languages.ts | 16 +- src/entities/messages.ts | 124 +++--- src/env.ts | 136 +++--- src/functions/telegramBot.ts | 16 +- src/libs/azure-table.ts | 40 +- src/middlewares/authorize.ts | 38 +- tsconfig.json | 8 +- 28 files changed, 974 insertions(+), 936 deletions(-) diff --git a/.prettierrc b/.prettierrc index 66d0e40..21bf06e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,7 @@ { - "printWidth": 110, - "singleQuote": true, - "semi": true, - "useTabs": true + "printWidth": 140, + "singleQuote": true, + "semi": true, + "useTabs": false, + "tabWidth": 2 } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index dde673d..e910edc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,3 @@ { - "recommendations": [ - "ms-azuretools.vscode-azurefunctions" - ] -} \ No newline at end of file + "recommendations": ["ms-azuretools.vscode-azurefunctions"] +} diff --git a/host.json b/host.json index 344d7e0..11bf947 100644 --- a/host.json +++ b/host.json @@ -8,9 +8,7 @@ } } }, - "watchDirectories": [ - "dist" - ], + "watchDirectories": ["dist"], "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" diff --git a/package-lock.json b/package-lock.json index af6b9c3..edc883e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "bun": "^1.1.43", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "prettier": "^3.4.2", "release-it": "^18.1.1", "rimraf": "^6.0.1", "type-fest": "^4.32.0", @@ -6181,6 +6182,22 @@ "node": ">= 0.4" } }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", diff --git a/package.json b/package.json index 55734fd..c964cd4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "tsc", "watch": "tsc -w", "clean": "rimraf dist", + "format": "prettier --write .", "dev": "run-p watch start tunnel", "prestart": "npm run clean && npm run build", "start": "func start", @@ -21,6 +22,7 @@ "bun": "^1.1.43", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "prettier": "^3.4.2", "release-it": "^18.1.1", "rimraf": "^6.0.1", "type-fest": "^4.32.0", diff --git a/scripts/_config.ts b/scripts/_config.ts index 881de88..40e2bcc 100644 --- a/scripts/_config.ts +++ b/scripts/_config.ts @@ -1,8 +1,8 @@ -import { LogLevel } from "./utils/logger"; +import { LogLevel } from './utils/logger'; export const config = { - localEnvPath: '.dev.vars', - renew: false, - logLevel: 'info' as LogLevel, - telegramWebhookPath: '/api/telegramBot', -} + localEnvPath: '.dev.vars', + renew: false, + logLevel: 'info' as LogLevel, + telegramWebhookPath: '/api/telegramBot', +}; diff --git a/scripts/libs/AzureFunctionsClient.ts b/scripts/libs/AzureFunctionsClient.ts index 408ec04..de750c2 100644 --- a/scripts/libs/AzureFunctionsClient.ts +++ b/scripts/libs/AzureFunctionsClient.ts @@ -2,24 +2,27 @@ import { $ } from 'bun'; import { console } from 'inspector'; export interface AzureFunctionsClientOptions { - functionName: string; - functionAppName: string; - resourceGroup: string; - subscription: string; + functionName: string; + functionAppName: string; + resourceGroup: string; + subscription: string; } export class AzureFunctionsClient { - constructor(private readonly options: AzureFunctionsClientOptions) { } - // https://thadaw-my-bot.azurewebsites.net/api/telegramBot?code= - async getFunctionKey(keyName = 'default'): Promise { - try { - const functionKeys = (await $`az functionapp function keys list --function-name ${this.options.functionName} --name ${this.options.functionAppName} --resource-group ${this.options.resourceGroup} --subscription "${this.options.subscription}"`).stdout.toString().trim(); - const key: Record = JSON.parse(functionKeys); - return key[keyName] ?? undefined; - } catch (error) { - console.error(error); - return undefined; - } - } - + constructor(private readonly options: AzureFunctionsClientOptions) {} + // https://thadaw-my-bot.azurewebsites.net/api/telegramBot?code= + async getFunctionKey(keyName = 'default'): Promise { + try { + const functionKeys = ( + await $`az functionapp function keys list --function-name ${this.options.functionName} --name ${this.options.functionAppName} --resource-group ${this.options.resourceGroup} --subscription "${this.options.subscription}"` + ).stdout + .toString() + .trim(); + const key: Record = JSON.parse(functionKeys); + return key[keyName] ?? undefined; + } catch (error) { + console.error(error); + return undefined; + } + } } diff --git a/scripts/libs/SecretManager.ts b/scripts/libs/SecretManager.ts index 2b7e6ca..f0016da 100644 --- a/scripts/libs/SecretManager.ts +++ b/scripts/libs/SecretManager.ts @@ -1,72 +1,67 @@ - import fs from 'fs'; import { $ } from 'bun'; export interface SecretManagerOptions { - localEnvPath: string; - renew: boolean; + localEnvPath: string; + renew: boolean; } export class SecretManager { - constructor(public options: SecretManagerOptions) { - console.log('SecretManager created'); - } - - async start(): Promise { - console.log('Starting secret generation'); - const secret = this.getSecret(); - if (!this.options.renew) { - console.log('Renew option not set, skipping secret upload'); - return secret; - } - this.saveSecret(secret, this.options.localEnvPath); - await this.uploadSecret(secret); - return secret; - } + constructor(public options: SecretManagerOptions) { + console.log('SecretManager created'); + } - /** - * Randomly generates a secret string of a given length. - * @param length - Length of the secret string - * @returns A randomly generated secret string - */ - getSecret(length: number = 100): string { - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; + async start(): Promise { + console.log('Starting secret generation'); + const secret = this.getSecret(); + if (!this.options.renew) { + console.log('Renew option not set, skipping secret upload'); + return secret; + } + this.saveSecret(secret, this.options.localEnvPath); + await this.uploadSecret(secret); + return secret; + } - for (let i = 0; i < length; i++) { - const randomIndex = Math.floor(Math.random() * characters.length); - result += characters[randomIndex]; - } + /** + * Randomly generates a secret string of a given length. + * @param length - Length of the secret string + * @returns A randomly generated secret string + */ + getSecret(length: number = 100): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; - return result; - } + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + result += characters[randomIndex]; + } - async saveSecret(secret: string, targetPath: string): Promise { - const localEnvExists = fs.existsSync(targetPath); - if (!localEnvExists) { - await fs.promises.writeFile(targetPath, ''); - } - // Renew the secret, by replacing the old one - let localEnv = fs.readFileSync(targetPath, 'utf-8'); - if(localEnv.includes('WEBHOOK_SECRET=')) { - localEnv = localEnv.replace(/WEBHOOK_SECRET=.*/, `WEBHOOK_SECRET=${secret}`); - } else { - localEnv += `\nWEBHOOK_SECRET=${secret}`; - } - await fs.promises.writeFile(targetPath, localEnv); - } + return result; + } - async uploadSecret(secret: string): Promise { - // Upload the secret to Cloudflare Workers Secrets - console.log('Uploading secret to Cloudflare Workers Secrets'); - const tmpFile = `.dev.tmp.vars`; - await this.saveSecret(secret, tmpFile); - await $`npx wrangler secret bulk ${tmpFile}`; - // Clean up the temporary file - fs.unlinkSync(tmpFile); + async saveSecret(secret: string, targetPath: string): Promise { + const localEnvExists = fs.existsSync(targetPath); + if (!localEnvExists) { + await fs.promises.writeFile(targetPath, ''); + } + // Renew the secret, by replacing the old one + let localEnv = fs.readFileSync(targetPath, 'utf-8'); + if (localEnv.includes('WEBHOOK_SECRET=')) { + localEnv = localEnv.replace(/WEBHOOK_SECRET=.*/, `WEBHOOK_SECRET=${secret}`); + } else { + localEnv += `\nWEBHOOK_SECRET=${secret}`; + } + await fs.promises.writeFile(targetPath, localEnv); + } - } + async uploadSecret(secret: string): Promise { + // Upload the secret to Cloudflare Workers Secrets + console.log('Uploading secret to Cloudflare Workers Secrets'); + const tmpFile = `.dev.tmp.vars`; + await this.saveSecret(secret, tmpFile); + await $`npx wrangler secret bulk ${tmpFile}`; + // Clean up the temporary file + fs.unlinkSync(tmpFile); + } } - - - diff --git a/scripts/libs/TelegramClient.ts b/scripts/libs/TelegramClient.ts index 65d8b71..5c9af42 100644 --- a/scripts/libs/TelegramClient.ts +++ b/scripts/libs/TelegramClient.ts @@ -1,19 +1,19 @@ -import { ConsoleLogger, Logger } from "../utils/logger"; +import { ConsoleLogger, Logger } from '../utils/logger'; export interface TelegramBotClientOptions { - logger?: Logger; - token: string; + logger?: Logger; + token: string; } export class TelegramBotClient { - private logger: Logger; - constructor(private readonly options: TelegramBotClientOptions) { - this.logger = options.logger ?? new ConsoleLogger(); - } + private logger: Logger; + constructor(private readonly options: TelegramBotClientOptions) { + this.logger = options.logger ?? new ConsoleLogger(); + } - async setWebhook(url: string) { - const targetUrl = `https://api.telegram.org/bot${this.options.token}/setWebhook?url=${url}`; - this.logger.info(`Setting webhook to ${targetUrl.replace(this.options.token, '**BOT_TOKEN**')}`); - await fetch(targetUrl); - } + async setWebhook(url: string) { + const targetUrl = `https://api.telegram.org/bot${this.options.token}/setWebhook?url=${url}`; + this.logger.info(`Setting webhook to ${targetUrl.replace(this.options.token, '**BOT_TOKEN**')}`); + await fetch(targetUrl); + } } diff --git a/scripts/libs/TunnelNgrokManager.ts b/scripts/libs/TunnelNgrokManager.ts index f08499a..39a548b 100644 --- a/scripts/libs/TunnelNgrokManager.ts +++ b/scripts/libs/TunnelNgrokManager.ts @@ -1,182 +1,181 @@ -import { $ } from "bun"; -import { TunnelManager } from "./interfaces/TunnelManager"; +import { $ } from 'bun'; +import { TunnelManager } from './interfaces/TunnelManager'; import fs from 'fs'; -import path from "path"; -import { z } from "zod"; +import path from 'path'; +import { z } from 'zod'; import { Logger } from '../utils/logger/logger'; import { ConsoleLogger } from '../utils/logger/console-logger'; -import { getErrorMessage } from "../utils/error"; +import { getErrorMessage } from '../utils/error'; export interface TunnelNgrokManagerOptions { - ngrokPort: number; - forwardPort: number; - logPath: string; - healthCheckUrl: string; - /** - * Interval in milliseconds to check if the tunnel is ready. - * @default 1000 ms - */ - healthCheckInterval: number; - preStart?: (tunnelUrl: string, logger: Logger) => Promise | void; - logger: Logger; + ngrokPort: number; + forwardPort: number; + logPath: string; + healthCheckUrl: string; + /** + * Interval in milliseconds to check if the tunnel is ready. + * @default 1000 ms + */ + healthCheckInterval: number; + preStart?: (tunnelUrl: string, logger: Logger) => Promise | void; + logger: Logger; } export class TunnelNgrokManager implements TunnelManager { - public options: TunnelNgrokManagerOptions; - private logger: Logger; - static resourceInfoSchema = z.object({ - tunnels: z.array( - z.object({ - public_url: z.string(), - }) - ), - }); + public options: TunnelNgrokManagerOptions; + private logger: Logger; + static resourceInfoSchema = z.object({ + tunnels: z.array( + z.object({ + public_url: z.string(), + }), + ), + }); - public static readonly defaultOptions: TunnelNgrokManagerOptions = { - forwardPort: 7071, - ngrokPort: 4040, - logPath: './.logs/ngrok.log', - healthCheckUrl: 'http://localhost:7071/', - healthCheckInterval: 1000, - logger: new ConsoleLogger(), - }; + public static readonly defaultOptions: TunnelNgrokManagerOptions = { + forwardPort: 7071, + ngrokPort: 4040, + logPath: './.logs/ngrok.log', + healthCheckUrl: 'http://localhost:7071/', + healthCheckInterval: 1000, + logger: new ConsoleLogger(), + }; - constructor(options?: Partial) { - this.options = Object.assign({}, TunnelNgrokManager.defaultOptions, options); - this.logger = this.options.logger; - this.logger.debug(`TunnelNgrokManager created with options: ${JSON.stringify(this.options)}`); - } + constructor(options?: Partial) { + this.options = Object.assign({}, TunnelNgrokManager.defaultOptions, options); + this.logger = this.options.logger; + this.logger.debug(`TunnelNgrokManager created with options: ${JSON.stringify(this.options)}`); + } - async start(): Promise { - try { - this.logger.info('Starting tunnel'); - await this.killProcess(); + async start(): Promise { + try { + this.logger.info('Starting tunnel'); + await this.killProcess(); - if (!fs.existsSync(path.dirname(this.options.logPath))) { - fs.mkdirSync(path.dirname(this.options.logPath), { recursive: true }); - } - // Show backend status - this.waitUntilUrlReady(this.options.healthCheckUrl, 'Backend').then(() => { - this.logger.info('Backend is ready'); - }); - // Run preStart function - this.preStart(); - // Setup signal handlers - this.setupNgrokSignalHandlers(); - // Start ngrok tunnel - await $`ngrok http ${this.options.forwardPort} --log=stdout > ${this.options.logPath}` - } - catch (error) { - throw new Error(`Error starting ngrok tunnel: ${error}`); - } - } + if (!fs.existsSync(path.dirname(this.options.logPath))) { + fs.mkdirSync(path.dirname(this.options.logPath), { recursive: true }); + } + // Show backend status + this.waitUntilUrlReady(this.options.healthCheckUrl, 'Backend').then(() => { + this.logger.info('Backend is ready'); + }); + // Run preStart function + this.preStart(); + // Setup signal handlers + this.setupNgrokSignalHandlers(); + // Start ngrok tunnel + await $`ngrok http ${this.options.forwardPort} --log=stdout > ${this.options.logPath}`; + } catch (error) { + throw new Error(`Error starting ngrok tunnel: ${error}`); + } + } - setupNgrokSignalHandlers() { - const isWindows = process.platform === "win32"; - if (isWindows) { - this.logger.error("This script is not supported on Windows."); - return; - } - this.setupSignalHandlers(); - } + setupNgrokSignalHandlers() { + const isWindows = process.platform === 'win32'; + if (isWindows) { + this.logger.error('This script is not supported on Windows.'); + return; + } + this.setupSignalHandlers(); + } - private async findNgrokProcessId(disableFindFromPort = false): Promise { - try { - const processName = 'ngrok'; - const ngrokProcessId = (await $`ps aux | grep ${processName} | grep -v grep | awk '{print $2}'`).stdout.toString().trim(); - if (ngrokProcessId) { - return ngrokProcessId; - } - if (disableFindFromPort) { - return undefined; - } - // Try to find the process id from port using lsof - const ngrokProcessIdFromPort = await $`lsof -t -i :${this.options.ngrokPort}`; - if (ngrokProcessIdFromPort) { - return ngrokProcessIdFromPort.stdout.toString().trim(); - } - return undefined; - } catch (error: unknown) { - return undefined; - } - } + private async findNgrokProcessId(disableFindFromPort = false): Promise { + try { + const processName = 'ngrok'; + const ngrokProcessId = (await $`ps aux | grep ${processName} | grep -v grep | awk '{print $2}'`).stdout.toString().trim(); + if (ngrokProcessId) { + return ngrokProcessId; + } + if (disableFindFromPort) { + return undefined; + } + // Try to find the process id from port using lsof + const ngrokProcessIdFromPort = await $`lsof -t -i :${this.options.ngrokPort}`; + if (ngrokProcessIdFromPort) { + return ngrokProcessIdFromPort.stdout.toString().trim(); + } + return undefined; + } catch (error: unknown) { + return undefined; + } + } - async killProcess(): Promise { - const pid = await this.findNgrokProcessId(); - if (!pid) { - this.logger.debug('Ngrok process not found'); - return; - } - this.logger.debug(`Killing ngrok process with pid ${pid}`); - await $`kill -9 ${pid}`; - } + async killProcess(): Promise { + const pid = await this.findNgrokProcessId(); + if (!pid) { + this.logger.debug('Ngrok process not found'); + return; + } + this.logger.debug(`Killing ngrok process with pid ${pid}`); + await $`kill -9 ${pid}`; + } - // Function to handle cleanup and exit signals - private setupSignalHandlers() { - const signals = ["SIGTERM", "SIGINT", "SIGHUP"]; - signals.forEach((signal) => - process.on(signal, async () => { - this.logger.info(`Received ${signal}. Cleaning up...`); - await this.killProcess(); - this.logger.info('Exiting...'); - process.exit(0); - }) - ); - }; + // Function to handle cleanup and exit signals + private setupSignalHandlers() { + const signals = ['SIGTERM', 'SIGINT', 'SIGHUP']; + signals.forEach((signal) => + process.on(signal, async () => { + this.logger.info(`Received ${signal}. Cleaning up...`); + await this.killProcess(); + this.logger.info('Exiting...'); + process.exit(0); + }), + ); + } - async preStart(): Promise { - if (!this.options.preStart) { - return; - } - // Get the tunnel URL - const tunnelResourceInfoUrl = `http://localhost:${this.options.ngrokPort}/api/tunnels`; - await this.waitUntilUrlReady(tunnelResourceInfoUrl, 'Ngrok Tunnel'); - const tunnelUrl = await this.getTunnelUrl(tunnelResourceInfoUrl); - if (!tunnelUrl) { - throw new Error('Failed to get Ngrok tunnel Public URL'); - } - // Run the preStart function - await this.options.preStart(tunnelUrl, this.logger); - } + async preStart(): Promise { + if (!this.options.preStart) { + return; + } + // Get the tunnel URL + const tunnelResourceInfoUrl = `http://localhost:${this.options.ngrokPort}/api/tunnels`; + await this.waitUntilUrlReady(tunnelResourceInfoUrl, 'Ngrok Tunnel'); + const tunnelUrl = await this.getTunnelUrl(tunnelResourceInfoUrl); + if (!tunnelUrl) { + throw new Error('Failed to get Ngrok tunnel Public URL'); + } + // Run the preStart function + await this.options.preStart(tunnelUrl, this.logger); + } - async getTunnelUrl(url: string): Promise { - const tunnelResponse = await fetch(url); - // Somehow fetch api convert xml to json automatically - const tunnelJson = await tunnelResponse.text() - const tunnelResourceInfo = this.getTunnelResourceInfo(JSON.parse(tunnelJson)); - if (tunnelResourceInfo.tunnels.length > 0) { - return tunnelResourceInfo.tunnels[0].public_url; - } - return undefined; - } + async getTunnelUrl(url: string): Promise { + const tunnelResponse = await fetch(url); + // Somehow fetch api convert xml to json automatically + const tunnelJson = await tunnelResponse.text(); + const tunnelResourceInfo = this.getTunnelResourceInfo(JSON.parse(tunnelJson)); + if (tunnelResourceInfo.tunnels.length > 0) { + return tunnelResourceInfo.tunnels[0].public_url; + } + return undefined; + } - /** - * Internal method to check if the backend is ready. - */ - async waitUntilUrlReady(url: string, serviceName: string): Promise { - const isBackendReady = async (): Promise => { - try { - const response = await fetch(url, { method: 'GET' }); - return response.status === 200; - } catch (error) { - // Assuming non-200 or fetch errors mean the tunnel is not ready yet. - this.logger.debug(`"${serviceName}" is not ready yet`); - return false; - } - }; + /** + * Internal method to check if the backend is ready. + */ + async waitUntilUrlReady(url: string, serviceName: string): Promise { + const isBackendReady = async (): Promise => { + try { + const response = await fetch(url, { method: 'GET' }); + return response.status === 200; + } catch (error) { + // Assuming non-200 or fetch errors mean the tunnel is not ready yet. + this.logger.debug(`"${serviceName}" is not ready yet`); + return false; + } + }; - while (!(await isBackendReady())) { - await new Promise(resolve => setTimeout(resolve, this.options.healthCheckInterval)); - } - this.logger.debug(`"${serviceName}" is ready`); - } + while (!(await isBackendReady())) { + await new Promise((resolve) => setTimeout(resolve, this.options.healthCheckInterval)); + } + this.logger.debug(`"${serviceName}" is ready`); + } - getTunnelResourceInfo(tunnelResourceInfo: unknown): z.infer { - try { - return TunnelNgrokManager.resourceInfoSchema.parse(tunnelResourceInfo); - } catch (error: unknown) { - this.logger.error(getErrorMessage(error)); - throw new Error('Invalid Ngrok Tunnel Resource Info schema, ngrok may have changed its API'); - } - } + getTunnelResourceInfo(tunnelResourceInfo: unknown): z.infer { + try { + return TunnelNgrokManager.resourceInfoSchema.parse(tunnelResourceInfo); + } catch (error: unknown) { + this.logger.error(getErrorMessage(error)); + throw new Error('Invalid Ngrok Tunnel Resource Info schema, ngrok may have changed its API'); + } + } } diff --git a/scripts/libs/interfaces/TunnelManager.ts b/scripts/libs/interfaces/TunnelManager.ts index df47e01..68bac09 100644 --- a/scripts/libs/interfaces/TunnelManager.ts +++ b/scripts/libs/interfaces/TunnelManager.ts @@ -1,6 +1,5 @@ - export interface TunnelManager { - start(): Promise; + start(): Promise; - getTunnelUrl(url: string): Promise; + getTunnelUrl(url: string): Promise; } diff --git a/scripts/pre-deploy.ts b/scripts/pre-deploy.ts index 70a9efa..9895702 100644 --- a/scripts/pre-deploy.ts +++ b/scripts/pre-deploy.ts @@ -1,27 +1,27 @@ import 'dotenv/config'; -import { getDevelopmentEnv } from "../src/env"; +import { getDevelopmentEnv } from '../src/env'; import { TelegramBotClient } from './libs/TelegramClient'; import { config } from './_config'; import { AzureFunctionsClient } from './libs/AzureFunctionsClient'; export async function preDeploy() { - const env = getDevelopmentEnv(process.env); - const telegramBotClient = new TelegramBotClient({ - token: env.BOT_TOKEN, - }); - const webhookUrl = new URL(config.telegramWebhookPath, env.TELEGRAM_WEBHOOK_URL); - const code = await new AzureFunctionsClient({ - functionName: env.AZURE_FUNCTIONS_NAME, - functionAppName: env.AZURE_FUNCTIONS_APP_NAME, - resourceGroup: env.AZURE_FUNCTIONS_RESOURCE_GROUP, - subscription: env.AZURE_FUNCTIONS_SUBSCRIPTION, - }).getFunctionKey(); - if (!code) { - throw new Error('Azure Function key is not set'); - } - webhookUrl.searchParams.set('code', code); - await telegramBotClient.setWebhook(webhookUrl.toString()); + const env = getDevelopmentEnv(process.env); + const telegramBotClient = new TelegramBotClient({ + token: env.BOT_TOKEN, + }); + const webhookUrl = new URL(config.telegramWebhookPath, env.TELEGRAM_WEBHOOK_URL); + const code = await new AzureFunctionsClient({ + functionName: env.AZURE_FUNCTIONS_NAME, + functionAppName: env.AZURE_FUNCTIONS_APP_NAME, + resourceGroup: env.AZURE_FUNCTIONS_RESOURCE_GROUP, + subscription: env.AZURE_FUNCTIONS_SUBSCRIPTION, + }).getFunctionKey(); + if (!code) { + throw new Error('Azure Function key is not set'); + } + webhookUrl.searchParams.set('code', code); + await telegramBotClient.setWebhook(webhookUrl.toString()); } preDeploy(); diff --git a/scripts/tunnel.ts b/scripts/tunnel.ts index 6d5a3a4..45d24c9 100644 --- a/scripts/tunnel.ts +++ b/scripts/tunnel.ts @@ -1,24 +1,24 @@ import 'dotenv/config'; -import { getDevelopmentEnv } from "../src/env"; +import { getDevelopmentEnv } from '../src/env'; import { config } from './_config'; import { TunnelNgrokManager } from './libs/TunnelNgrokManager'; import { createPinoLogger } from './utils/logger'; import { TelegramBotClient } from './libs/TelegramClient'; function startTunnel() { - const env = getDevelopmentEnv(process.env); - const tunnelManager = new TunnelNgrokManager({ - logger: createPinoLogger('tunnel', config.logLevel), - preStart: async (tunnelUrl, logger) => { - const telegramBotClient = new TelegramBotClient({ - token: env.BOT_TOKEN, - logger, - }); - await telegramBotClient.setWebhook(new URL(config.telegramWebhookPath, tunnelUrl).toString()); - } - }); - tunnelManager.start(); + const env = getDevelopmentEnv(process.env); + const tunnelManager = new TunnelNgrokManager({ + logger: createPinoLogger('tunnel', config.logLevel), + preStart: async (tunnelUrl, logger) => { + const telegramBotClient = new TelegramBotClient({ + token: env.BOT_TOKEN, + logger, + }); + await telegramBotClient.setWebhook(new URL(config.telegramWebhookPath, tunnelUrl).toString()); + }, + }); + tunnelManager.start(); } startTunnel(); diff --git a/scripts/utils/error.ts b/scripts/utils/error.ts index 681c99e..9a929ac 100644 --- a/scripts/utils/error.ts +++ b/scripts/utils/error.ts @@ -1,12 +1,12 @@ -import { ZodError } from "zod"; -import { fromZodError } from "zod-validation-error"; +import { ZodError } from 'zod'; +import { fromZodError } from 'zod-validation-error'; export function getErrorMessage(error: unknown): string { - if (error instanceof ZodError) { - return fromZodError(error).message - } else if (error instanceof Error) { - return error.message + ' Trace: ' + error.stack - } else { - return 'Unknown error' + error - } + if (error instanceof ZodError) { + return fromZodError(error).message; + } else if (error instanceof Error) { + return error.message + ' Trace: ' + error.stack; + } else { + return 'Unknown error' + error; + } } diff --git a/scripts/utils/logger/console-logger.ts b/scripts/utils/logger/console-logger.ts index eea5544..e37b46b 100644 --- a/scripts/utils/logger/console-logger.ts +++ b/scripts/utils/logger/console-logger.ts @@ -1,49 +1,51 @@ - -import { config } from "../../_config"; -import { Logger, LogLevel } from "./logger"; +import { config } from '../../_config'; +import { Logger, LogLevel } from './logger'; export const createLogger = (scope: string): ConsoleLogger | undefined => new ConsoleLogger(scope, config.logLevel); export class ConsoleLogger implements Logger { - private static readonly LEVELS = ["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]; + private static readonly LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']; private logLevelIndex: number; - constructor(private scope?: string, level: LogLevel = "debug") { + constructor( + private scope?: string, + level: LogLevel = 'debug', + ) { this.logLevelIndex = ConsoleLogger.LEVELS.indexOf(level.toUpperCase()); if (this.logLevelIndex === -1) { - throw new Error(`Invalid log level: ${level}. Valid levels are ${ConsoleLogger.LEVELS.join(", ")}.`); + throw new Error(`Invalid log level: ${level}. Valid levels are ${ConsoleLogger.LEVELS.join(', ')}.`); } } private logMessage(level: LogLevel, messages: unknown[]) { const levelIndex = ConsoleLogger.LEVELS.indexOf(level.toUpperCase()); if (levelIndex < this.logLevelIndex) return; - const formattedMessage = messages.join(" "); + const formattedMessage = messages.join(' '); - if (level === "debug") return console.debug(formattedMessage); - if (level === "info") return console.info(formattedMessage); - if (level === "warn") return console.warn(formattedMessage); + if (level === 'debug') return console.debug(formattedMessage); + if (level === 'info') return console.info(formattedMessage); + if (level === 'warn') return console.warn(formattedMessage); return console.error(formattedMessage); } info(...messages: unknown[]) { - this.logMessage("info", messages); + this.logMessage('info', messages); } debug(...message: unknown[]) { - this.logMessage("debug", message); + this.logMessage('debug', message); } error(...message: unknown[]) { - this.logMessage("error", message); + this.logMessage('error', message); } warn(...message: unknown[]) { - this.logMessage("warn", message); + this.logMessage('warn', message); } fatal(...message: unknown[]) { - this.logMessage("fatal", message); - return new Error(message.join(" ")); + this.logMessage('fatal', message); + return new Error(message.join(' ')); } } diff --git a/scripts/utils/logger/logger.ts b/scripts/utils/logger/logger.ts index 2af32fa..071c84f 100644 --- a/scripts/utils/logger/logger.ts +++ b/scripts/utils/logger/logger.ts @@ -1,5 +1,4 @@ - -export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; export interface Logger { info(...messages: unknown[]): void; diff --git a/scripts/utils/logger/pino-logger.ts b/scripts/utils/logger/pino-logger.ts index fdc1bdb..38174d3 100644 --- a/scripts/utils/logger/pino-logger.ts +++ b/scripts/utils/logger/pino-logger.ts @@ -1,9 +1,9 @@ // Copy from https://github.com/mildronize/blog-v8/blob/655a5aeed388c8e8613dd7d6c06339f0b1966eed/snippets/src/utils/logger.ts // More detail: https://letmutex.com/article/logging-with-pinojs-log-to-file-http-and-even-email -import pino from "pino"; -import PinoPretty from "pino-pretty"; -import { Logger } from "./logger"; +import pino from 'pino'; +import PinoPretty from 'pino-pretty'; +import { Logger } from './logger'; /** * Create a pino logger with the given name and level @@ -16,66 +16,70 @@ import { Logger } from "./logger"; * @returns */ export const createPinoLogger = (name: string, level: pino.LevelWithSilentOrString) => { - return new PinoLogger(pino({ - name, - // Set the global log level to the lowest level - // We adjust the level per transport - level: 'trace', - hooks: {}, - serializers: { - // Handle error properties as Error and serialize them correctly - err: pino.stdSerializers.err, - error: pino.stdSerializers.err, - validationErrors: pino.stdSerializers.err, - }, - }, pino.multistream([ - { - level, - stream: PinoPretty({ - // Sync log should not be used in production - sync: true, - colorize: true, - }) - }, - // { - // level: 'info', - // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - // stream: pino.transport({ - // target: './app.log' - // }) - // }, - // { - // level: "debug", - // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - // stream: pino.transport({ - // target: 'pino-opentelemetry-transport', - // }) - // } - ]))); -} - + return new PinoLogger( + pino( + { + name, + // Set the global log level to the lowest level + // We adjust the level per transport + level: 'trace', + hooks: {}, + serializers: { + // Handle error properties as Error and serialize them correctly + err: pino.stdSerializers.err, + error: pino.stdSerializers.err, + validationErrors: pino.stdSerializers.err, + }, + }, + pino.multistream([ + { + level, + stream: PinoPretty({ + // Sync log should not be used in production + sync: true, + colorize: true, + }), + }, + // { + // level: 'info', + // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // stream: pino.transport({ + // target: './app.log' + // }) + // }, + // { + // level: "debug", + // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // stream: pino.transport({ + // target: 'pino-opentelemetry-transport', + // }) + // } + ]), + ), + ); +}; // ---------------------------- // pino logger // ---------------------------- export class PinoLogger implements Logger { - constructor(private logger: pino.Logger) { } + constructor(private logger: pino.Logger) {} info(...messages: string[]) { - this.logger.info(messages.join(" ")); + this.logger.info(messages.join(' ')); } debug(...messages: string[]) { - this.logger.debug(messages.join(" ")); + this.logger.debug(messages.join(' ')); } error(...messages: string[]) { - this.logger.error(messages.join(" ")); + this.logger.error(messages.join(' ')); } warn(...messages: string[]) { this.logger.warn(messages); } fatal(...messages: string[]) { - this.logger.fatal(messages.join(" ")); - return new Error(messages.join(" ")); + this.logger.fatal(messages.join(' ')); + return new Error(messages.join(' ')); } } diff --git a/src/bootstrap.ts b/src/bootstrap.ts index fef240d..02fb6d3 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,40 +1,40 @@ import 'dotenv/config'; -import { BotApp } from "./bot/bot"; -import { getEnv } from "./env"; -import { OpenAIClient } from "./bot/ai/openai"; -import { AzureTable } from "./libs/azure-table"; -import { IMessageEntity } from "./entities/messages"; -import { TableClient } from "@azure/data-tables"; +import { BotApp } from './bot/bot'; +import { getEnv } from './env'; +import { OpenAIClient } from './bot/ai/openai'; +import { AzureTable } from './libs/azure-table'; +import { IMessageEntity } from './entities/messages'; +import { TableClient } from '@azure/data-tables'; import { Bot } from 'grammy'; import { generateUpdateMiddleware } from 'telegraf-middleware-console-time'; const env = getEnv(process.env); export function bootstrap(): { - bot: Bot, - asyncTask: () => Promise + bot: Bot; + asyncTask: () => Promise; } { - const aiClient = new OpenAIClient(env.OPENAI_API_KEY); - const azureTableClient = { - messages: new AzureTable( - TableClient.fromConnectionString(env.AZURE_TABLE_CONNECTION_STRING, `${env.AZURE_TABLE_PREFIX}Bot`) - ) - } - const botApp = new BotApp({ - botToken: env.BOT_TOKEN, - botInfo: JSON.parse(env.BOT_INFO), - allowUserIds: env.ALLOWED_USER_IDS, - aiClient, - azureTableClient - }); - if(env.NODE_ENV === 'development') { - botApp.instance.use(generateUpdateMiddleware()); - } - botApp.init(); - return { - bot: botApp.instance, - asyncTask: async () => { - await azureTableClient.messages.createTable(); - } - } + const aiClient = new OpenAIClient(env.OPENAI_API_KEY); + const azureTableClient = { + messages: new AzureTable( + TableClient.fromConnectionString(env.AZURE_TABLE_CONNECTION_STRING, `${env.AZURE_TABLE_PREFIX}Bot`), + ), + }; + const botApp = new BotApp({ + botToken: env.BOT_TOKEN, + botInfo: JSON.parse(env.BOT_INFO), + allowUserIds: env.ALLOWED_USER_IDS, + aiClient, + azureTableClient, + }); + if (env.NODE_ENV === 'development') { + botApp.instance.use(generateUpdateMiddleware()); + } + botApp.init(); + return { + bot: botApp.instance, + asyncTask: async () => { + await azureTableClient.messages.createTable(); + }, + }; } diff --git a/src/bot/ai/characters.ts b/src/bot/ai/characters.ts index c978c11..f51a9f0 100644 --- a/src/bot/ai/characters.ts +++ b/src/bot/ai/characters.ts @@ -9,10 +9,10 @@ export const language = 'Thai'; export type SystemRoleKey = 'friend'; export const SystemRole: Record = { - friend: [{ role: 'system', content: 'You are friendly nice friend' }], + friend: [{ role: 'system', content: 'You are friendly nice friend' }], }; export type CharacterRoleKey = 'Riko'; export const CharacterRole: Record = { - Riko: [{ role: 'system', content: `I'm Riko, female with happy, friendly and playful, Speaking ${language} ${seperateSentence}` }], -} + Riko: [{ role: 'system', content: `I'm Riko, female with happy, friendly and playful, Speaking ${language} ${seperateSentence}` }], +}; diff --git a/src/bot/ai/openai.ts b/src/bot/ai/openai.ts index 4bdf35e..0c9f335 100644 --- a/src/bot/ai/openai.ts +++ b/src/bot/ai/openai.ts @@ -3,8 +3,8 @@ import type { ChatCompletionMessageParam } from 'openai/resources'; import { SystemRole, CharacterRole, sentenceEnd } from './characters'; export interface PreviousMessage { - type: 'text' | 'photo'; - content: string; + type: 'text' | 'photo'; + content: string; } /** @@ -15,108 +15,115 @@ export interface PreviousMessage { export type ChatMode = 'natural' | 'default'; export class OpenAIClient { - characterRole: keyof typeof CharacterRole; - client: OpenAI; - model: string = 'gpt-4o-mini'; - timeout: number = 20 * 1000; // 20 seconds, default is 10 minutes (By OpenAI) - /** - * The limit of previous messages to chat with the AI, this prevent large tokens be sent to the AI - * For reducing the cost of the API and prevent the AI to be confused - * - * @default 10 - */ - previousMessageLimit: number = 10; - /** - * The answer mode of the AI, this is the default answer mode of the AI - * Use this to prevent the AI to generate long answers or to be confused - */ - // answerMode = 'The answers are within 4 sentences'; - /** - * Split the sentence when the AI generate the response, - * Prevent not to generate long answers, reply with multiple chat messages - */ - splitSentence: boolean = true; + characterRole: keyof typeof CharacterRole; + client: OpenAI; + model: string = 'gpt-4o-mini'; + timeout: number = 20 * 1000; // 20 seconds, default is 10 minutes (By OpenAI) + /** + * The limit of previous messages to chat with the AI, this prevent large tokens be sent to the AI + * For reducing the cost of the API and prevent the AI to be confused + * + * @default 10 + */ + previousMessageLimit: number = 10; + /** + * The answer mode of the AI, this is the default answer mode of the AI + * Use this to prevent the AI to generate long answers or to be confused + */ + // answerMode = 'The answers are within 4 sentences'; + /** + * Split the sentence when the AI generate the response, + * Prevent not to generate long answers, reply with multiple chat messages + */ + splitSentence: boolean = true; - constructor(apiKey: string) { - this.client = new OpenAI({ apiKey, timeout: this.timeout }); - this.characterRole = 'Riko'; - } + constructor(apiKey: string) { + this.client = new OpenAI({ apiKey, timeout: this.timeout }); + this.characterRole = 'Riko'; + } - /** - * The answer mode of the AI, this is the default answer mode of the AI - * Use this to prevent the AI to generate long answers or to be confused - */ - private dynamicLimitAnswerSentences(start: number, end: number) { - const answerMode = `The answers are within XXX sentences`; - const randomLimit = Math.floor(Math.random() * (end - start + 1)) + start; - return answerMode.replace('XXX', randomLimit.toString()); - } + /** + * The answer mode of the AI, this is the default answer mode of the AI + * Use this to prevent the AI to generate long answers or to be confused + */ + private dynamicLimitAnswerSentences(start: number, end: number) { + const answerMode = `The answers are within XXX sentences`; + const randomLimit = Math.floor(Math.random() * (end - start + 1)) + start; + return answerMode.replace('XXX', randomLimit.toString()); + } - /** - * Chat with the AI, the AI API is stateless we need to keep track of the conversation - * - * @param {AgentCharacterKey} character - The character of the agent - * @param {string[]} messages - The messages to chat with the AI - * @param {string[]} [previousMessages=[]] - The previous messages to chat with the AI - * @returns - */ - async chat(character: keyof typeof SystemRole, chatMode: ChatMode, messages: string[], previousMessages: PreviousMessage[] = []): Promise { - const chatCompletion = await this.client.chat.completions.create({ - messages: [ - ...SystemRole[character], - ...CharacterRole[this.characterRole], - ...(chatMode === 'natural' ? this.generateSystemMessages([this.dynamicLimitAnswerSentences(3, 5)]) : []), - // ...this.generateSystemMessages([this.answerMode]), - ...this.generatePreviousMessages(previousMessages), - ...this.generateTextMessages(messages), - ], - model: this.model, - }); - const response = chatCompletion.choices[0].message.content ?? ''; - if (this.splitSentence) { - return response.split(sentenceEnd).map((sentence) => sentence.trim()); - } - return [response]; - } + /** + * Chat with the AI, the AI API is stateless we need to keep track of the conversation + * + * @param {AgentCharacterKey} character - The character of the agent + * @param {string[]} messages - The messages to chat with the AI + * @param {string[]} [previousMessages=[]] - The previous messages to chat with the AI + * @returns + */ + async chat( + character: keyof typeof SystemRole, + chatMode: ChatMode, + messages: string[], + previousMessages: PreviousMessage[] = [], + ): Promise { + const chatCompletion = await this.client.chat.completions.create({ + messages: [ + ...SystemRole[character], + ...CharacterRole[this.characterRole], + ...(chatMode === 'natural' ? this.generateSystemMessages([this.dynamicLimitAnswerSentences(3, 5)]) : []), + // ...this.generateSystemMessages([this.answerMode]), + ...this.generatePreviousMessages(previousMessages), + ...this.generateTextMessages(messages), + ], + model: this.model, + }); + const response = chatCompletion.choices[0].message.content ?? ''; + if (this.splitSentence) { + return response.split(sentenceEnd).map((sentence) => sentence.trim()); + } + return [response]; + } - private generateSystemMessages(messages: string[]) { - return messages.map((message) => ({ role: 'system', content: message } satisfies ChatCompletionMessageParam)); - } + private generateSystemMessages(messages: string[]) { + return messages.map((message) => ({ role: 'system', content: message }) satisfies ChatCompletionMessageParam); + } - private generatePreviousMessages(messages: PreviousMessage[]) { - return messages.slice(0, this.previousMessageLimit).map((message) => { - if(message.type === 'text') { - return { role: 'assistant', content: message.content } satisfies ChatCompletionMessageParam; - } - // TODO: Try to not use previous messages for image, due to cost of the API - return { role: 'user', content: [{ type: 'image_url', image_url: { url: message.content } }] } satisfies ChatCompletionMessageParam; - }); - } + private generatePreviousMessages(messages: PreviousMessage[]) { + return messages.slice(0, this.previousMessageLimit).map((message) => { + if (message.type === 'text') { + return { role: 'assistant', content: message.content } satisfies ChatCompletionMessageParam; + } + // TODO: Try to not use previous messages for image, due to cost of the API + return { role: 'user', content: [{ type: 'image_url', image_url: { url: message.content } }] } satisfies ChatCompletionMessageParam; + }); + } - private generateTextMessages(messages: string[]) { - return messages.map((message) => ({ role: 'user', content: message } satisfies ChatCompletionMessageParam)); - } + private generateTextMessages(messages: string[]) { + return messages.map((message) => ({ role: 'user', content: message }) satisfies ChatCompletionMessageParam); + } - private generateImageMessage(imageUrl: string) { - return { - role: 'user', - content: [{ - type: 'image_url', - image_url: { url: imageUrl }, - }] - } as ChatCompletionMessageParam; - } + private generateImageMessage(imageUrl: string) { + return { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: imageUrl }, + }, + ], + } as ChatCompletionMessageParam; + } - async chatWithImage(character: keyof typeof SystemRole, messages: string[], imageUrl: string, previousMessages: PreviousMessage[] = []) { - const chatCompletion = await this.client.chat.completions.create({ - messages: [ - ...SystemRole[character], - ...this.generateTextMessages(messages), - ...this.generatePreviousMessages(previousMessages), - this.generateImageMessage(imageUrl), - ], - model: this.model, - }); - return chatCompletion.choices[0].message.content; - } + async chatWithImage(character: keyof typeof SystemRole, messages: string[], imageUrl: string, previousMessages: PreviousMessage[] = []) { + const chatCompletion = await this.client.chat.completions.create({ + messages: [ + ...SystemRole[character], + ...this.generateTextMessages(messages), + ...this.generatePreviousMessages(previousMessages), + this.generateImageMessage(imageUrl), + ], + model: this.model, + }); + return chatCompletion.choices[0].message.content; + } } diff --git a/src/bot/bot.ts b/src/bot/bot.ts index b633dd2..fa452fd 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -1,273 +1,295 @@ -import { Bot, Context } from "grammy"; -import type { UserFromGetMe } from "grammy/types"; +import { Bot, Context } from 'grammy'; +import type { UserFromGetMe } from 'grammy/types'; -import { authorize } from "../middlewares/authorize"; -import { ChatMode, OpenAIClient, PreviousMessage } from "./ai/openai"; -import { t } from "./languages"; -import { AzureTable } from "../libs/azure-table"; -import { IMessageEntity, MessageEntity } from "../entities/messages"; -import { ODataExpression } from "ts-odata-client"; +import { authorize } from '../middlewares/authorize'; +import { ChatMode, OpenAIClient, PreviousMessage } from './ai/openai'; +import { t } from './languages'; +import { AzureTable } from '../libs/azure-table'; +import { IMessageEntity, MessageEntity } from '../entities/messages'; +import { ODataExpression } from 'ts-odata-client'; import telegramifyMarkdown from 'telegramify-markdown'; type BotAppContext = Context; export interface TelegramMessageType { - /** - * Incoming reply_to_message, when the user reply existing message - * Use this for previous message context - */ - replyToMessage?: string; - /** - * Incoming text message - */ - text?: string; - /** - * Incoming caption message (with photo) - */ - caption?: string; - /** - * Incoming photo file_path - */ - photo?: string; - /** - * Incoming audio file_path - */ - // audio?: string; + /** + * Incoming reply_to_message, when the user reply existing message + * Use this for previous message context + */ + replyToMessage?: string; + /** + * Incoming text message + */ + text?: string; + /** + * Incoming caption message (with photo) + */ + caption?: string; + /** + * Incoming photo file_path + */ + photo?: string; + /** + * Incoming audio file_path + */ + // audio?: string; } export interface BotAppOptions { - botToken: string; - azureTableClient: { - messages: AzureTable; - }; - aiClient: OpenAIClient; - botInfo?: UserFromGetMe; - allowUserIds?: number[]; - protectedBot?: boolean; + botToken: string; + azureTableClient: { + messages: AzureTable; + }; + aiClient: OpenAIClient; + botInfo?: UserFromGetMe; + allowUserIds?: number[]; + protectedBot?: boolean; } export class TelegramApiClient { - baseUrl = 'https://api.telegram.org'; - constructor(public botToken: string) { } + baseUrl = 'https://api.telegram.org'; + constructor(public botToken: string) {} - async getMe() { - const response = await fetch(`${this.baseUrl}/bot${this.botToken}/getMe`); - if (!response.ok) { - throw new Error(`Failed to get the bot info: ${response.statusText}`); - } - const data = await response.json(); - return data; - } + async getMe() { + const response = await fetch(`${this.baseUrl}/bot${this.botToken}/getMe`); + if (!response.ok) { + throw new Error(`Failed to get the bot info: ${response.statusText}`); + } + const data = await response.json(); + return data; + } - /** - * Get Download URL for the file - * - * @ref https://core.telegram.org/bots/api#getfile - * @param filePath - * @returns - */ + /** + * Get Download URL for the file + * + * @ref https://core.telegram.org/bots/api#getfile + * @param filePath + * @returns + */ - getFileUrl(filePath: string): string { - return `${this.baseUrl}/file/bot${this.botToken}/${filePath}`; - } + getFileUrl(filePath: string): string { + return `${this.baseUrl}/file/bot${this.botToken}/${filePath}`; + } } export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export class BotApp { - private bot: Bot; - private telegram: TelegramApiClient; - private protectedBot: boolean; - constructor(public options: BotAppOptions) { - this.bot = new Bot(options.botToken, { - botInfo: options.botInfo, - }); - this.telegram = new TelegramApiClient(options.botToken); - this.protectedBot = options.protectedBot ?? true; - } + private bot: Bot; + private telegram: TelegramApiClient; + private protectedBot: boolean; + constructor(public options: BotAppOptions) { + this.bot = new Bot(options.botToken, { + botInfo: options.botInfo, + }); + this.telegram = new TelegramApiClient(options.botToken); + this.protectedBot = options.protectedBot ?? true; + } - init() { - console.log('BotApp init'); - if (this.protectedBot === true) { - this.bot.use(authorize(this.options.allowUserIds ?? [])); - } - this.bot.command("whoiam", async (ctx: Context) => { - await ctx.reply(`${t.yourAre} ${ctx.from?.first_name} (id: ${ctx.message?.from?.id})`); - }); - this.bot.command("ai", async (ctx) => { - // With the `ai` command, the user can chat with the AI using Full Response Mode - const incomingMessage = ctx.match; - return this.handleMessageText(ctx, this.options.aiClient, this.options.azureTableClient.messages, { - incomingMessage, - }, 'default'); - }); - this.bot.api.setMyCommands([ - { command: 'whoiam', description: 'Who am I' }, - { command: 'ai', description: 'Chat With AI using Full Response' }, - ]); - this.bot.on('message', async (ctx: Context) => - this.allMessagesHandler(ctx, this.options.aiClient, this.telegram, this.options.azureTableClient.messages, 'natural') - ); - this.bot.catch((err) => { - console.error('Bot error', err); - }); - return this; - } + init() { + console.log('BotApp init'); + if (this.protectedBot === true) { + this.bot.use(authorize(this.options.allowUserIds ?? [])); + } + this.bot.command('whoiam', async (ctx: Context) => { + await ctx.reply(`${t.yourAre} ${ctx.from?.first_name} (id: ${ctx.message?.from?.id})`); + }); + this.bot.command('ai', async (ctx) => { + // With the `ai` command, the user can chat with the AI using Full Response Mode + const incomingMessage = ctx.match; + return this.handleMessageText( + ctx, + this.options.aiClient, + this.options.azureTableClient.messages, + { + incomingMessage, + }, + 'default', + ); + }); + this.bot.api.setMyCommands([ + { command: 'whoiam', description: 'Who am I' }, + { command: 'ai', description: 'Chat With AI using Full Response' }, + ]); + this.bot.on('message', async (ctx: Context) => + this.allMessagesHandler(ctx, this.options.aiClient, this.telegram, this.options.azureTableClient.messages, 'natural'), + ); + this.bot.catch((err) => { + console.error('Bot error', err); + }); + return this; + } - async start() { - await this.bot.start({ - onStart(botInfo) { - console.log(new Date(), 'Bot starts as', botInfo.username); - }, - }); - } + async start() { + await this.bot.start({ + onStart(botInfo) { + console.log(new Date(), 'Bot starts as', botInfo.username); + }, + }); + } - private maskBotToken(text: string, action: 'mask' | 'unmask') { - if (action === 'mask') return text.replace(new RegExp(this.options.botToken, 'g'), '${{BOT_TOKEN}}'); - return text.replace(new RegExp('${{BOT_TOKEN}}', 'g'), this.options.botToken); - } + private maskBotToken(text: string, action: 'mask' | 'unmask') { + if (action === 'mask') return text.replace(new RegExp(this.options.botToken, 'g'), '${{BOT_TOKEN}}'); + return text.replace(new RegExp('${{BOT_TOKEN}}', 'g'), this.options.botToken); + } - private async handlePhoto(ctx: BotAppContext, aiClient: OpenAIClient, azureTableMessageClient: AzureTable, photo: { photoUrl: string, caption?: string }) { - await ctx.reply(`${t.readingImage}...`); - const incomingMessages = photo.caption ? [photo.caption] : []; - if (photo.caption) { - await azureTableMessageClient.insert(await new MessageEntity({ - payload: photo.caption, - userId: String(ctx.from?.id), - senderId: String(ctx.from?.id), - type: 'text', - }).init()); - } - await azureTableMessageClient.insert(await new MessageEntity({ - payload: this.maskBotToken(photo.photoUrl, 'mask'), - userId: String(ctx.from?.id), - senderId: String(ctx.from?.id), - type: 'photo', - }).init()); - const message = await aiClient.chatWithImage('friend', incomingMessages, photo.photoUrl); - if (!message) { - await ctx.reply(t.sorryICannotUnderstand); - return; - } - await ctx.reply(message); - await azureTableMessageClient.insert(await new MessageEntity({ - payload: message, - userId: String(ctx.from?.id), - senderId: String(ctx.from?.id), - type: 'text', - }).init()); - } + private async handlePhoto( + ctx: BotAppContext, + aiClient: OpenAIClient, + azureTableMessageClient: AzureTable, + photo: { photoUrl: string; caption?: string }, + ) { + await ctx.reply(`${t.readingImage}...`); + const incomingMessages = photo.caption ? [photo.caption] : []; + if (photo.caption) { + await azureTableMessageClient.insert( + await new MessageEntity({ + payload: photo.caption, + userId: String(ctx.from?.id), + senderId: String(ctx.from?.id), + type: 'text', + }).init(), + ); + } + await azureTableMessageClient.insert( + await new MessageEntity({ + payload: this.maskBotToken(photo.photoUrl, 'mask'), + userId: String(ctx.from?.id), + senderId: String(ctx.from?.id), + type: 'photo', + }).init(), + ); + const message = await aiClient.chatWithImage('friend', incomingMessages, photo.photoUrl); + if (!message) { + await ctx.reply(t.sorryICannotUnderstand); + return; + } + await ctx.reply(message); + await azureTableMessageClient.insert( + await new MessageEntity({ + payload: message, + userId: String(ctx.from?.id), + senderId: String(ctx.from?.id), + type: 'text', + }).init(), + ); + } - private async allMessagesHandler( - ctx: Context, - aiClient: OpenAIClient, - telegram: TelegramApiClient, - azureTableMessageClient: AzureTable, - chatMode: ChatMode - ) { - // classifying the message type - const messages: TelegramMessageType = { - replyToMessage: ctx.message?.reply_to_message?.text, - text: ctx.message?.text, - caption: ctx.message?.caption, - photo: ctx.message?.photo ? (await ctx.getFile()).file_path : undefined, - } - if (messages.text === undefined && messages.caption === undefined && messages.photo === undefined) { - await ctx.reply(t.sorryICannotUnderstandMessageType); - return; - } + private async allMessagesHandler( + ctx: Context, + aiClient: OpenAIClient, + telegram: TelegramApiClient, + azureTableMessageClient: AzureTable, + chatMode: ChatMode, + ) { + // classifying the message type + const messages: TelegramMessageType = { + replyToMessage: ctx.message?.reply_to_message?.text, + text: ctx.message?.text, + caption: ctx.message?.caption, + photo: ctx.message?.photo ? (await ctx.getFile()).file_path : undefined, + }; + if (messages.text === undefined && messages.caption === undefined && messages.photo === undefined) { + await ctx.reply(t.sorryICannotUnderstandMessageType); + return; + } - const incomingMessage = messages.text || messages.caption; + const incomingMessage = messages.text || messages.caption; - if (messages.photo) { - const photoUrl = telegram.getFileUrl(messages.photo); - await this.handlePhoto(ctx, aiClient, azureTableMessageClient, { photoUrl: photoUrl, caption: incomingMessage }); - return; - } - if (!incomingMessage || ctx.from?.id === undefined) { - await ctx.reply(t.sorryICannotUnderstand); - return; - } - await this.handleMessageText( - ctx, - aiClient, - azureTableMessageClient, - { - incomingMessage: incomingMessage, - replyToMessage: messages.replyToMessage, - }, - chatMode); - } + if (messages.photo) { + const photoUrl = telegram.getFileUrl(messages.photo); + await this.handlePhoto(ctx, aiClient, azureTableMessageClient, { photoUrl: photoUrl, caption: incomingMessage }); + return; + } + if (!incomingMessage || ctx.from?.id === undefined) { + await ctx.reply(t.sorryICannotUnderstand); + return; + } + await this.handleMessageText( + ctx, + aiClient, + azureTableMessageClient, + { + incomingMessage: incomingMessage, + replyToMessage: messages.replyToMessage, + }, + chatMode, + ); + } - private async handleMessageText( - ctx: Context, - aiClient: OpenAIClient, - azureTableMessageClient: AzureTable, - messageContext: { incomingMessage: string | undefined; replyToMessage?: string; }, - chatMode: ChatMode, - ) { - const { incomingMessage, replyToMessage } = messageContext; - if (!aiClient) { - await ctx.reply(`${t.sorryICannotUnderstand} (aiClient is not available)`); - return; - } - if (!incomingMessage) { - await ctx.reply('Please send a text message'); - return; - } + private async handleMessageText( + ctx: Context, + aiClient: OpenAIClient, + azureTableMessageClient: AzureTable, + messageContext: { incomingMessage: string | undefined; replyToMessage?: string }, + chatMode: ChatMode, + ) { + const { incomingMessage, replyToMessage } = messageContext; + if (!aiClient) { + await ctx.reply(`${t.sorryICannotUnderstand} (aiClient is not available)`); + return; + } + if (!incomingMessage) { + await ctx.reply('Please send a text message'); + return; + } - // Save the incoming message to the database - await azureTableMessageClient.insert(await new MessageEntity({ - payload: incomingMessage, - userId: String(ctx.from?.id), - senderId: String(ctx.from?.id), - type: 'text', - }).init()); + // Save the incoming message to the database + await azureTableMessageClient.insert( + await new MessageEntity({ + payload: incomingMessage, + userId: String(ctx.from?.id), + senderId: String(ctx.from?.id), + type: 'text', + }).init(), + ); - // Step 1: add inthe replyToMessage to the previousMessage in first chat - const previousMessage: PreviousMessage[] = replyToMessage ? [{ type: 'text', content: replyToMessage }] : []; - // Step 2: Load previous messages from the database + // Step 1: add inthe replyToMessage to the previousMessage in first chat + const previousMessage: PreviousMessage[] = replyToMessage ? [{ type: 'text', content: replyToMessage }] : []; + // Step 2: Load previous messages from the database - if (ctx.from?.id) { - let countMaxPreviousMessage = aiClient.previousMessageLimit; - const query = ODataExpression.forV4() - .filter((p) => p.userId.$equals(String(ctx.from?.id))) - .build(); - for await (const entity of azureTableMessageClient.list(query)) { - if (countMaxPreviousMessage <= 0) { - break; - } - previousMessage.push({ type: entity.type, content: entity.payload }); - countMaxPreviousMessage--; - } - } else { - console.log(`userId is not available, skipping loading previous messages`); - } - previousMessage.reverse(); - // Step 3: Chat with AI - const messages = await aiClient.chat('friend', chatMode, [incomingMessage], previousMessage); - await azureTableMessageClient.insert(await new MessageEntity({ - payload: messages.join(' '), - userId: String(ctx.from?.id), - senderId: String(0), - type: 'text', - }).init()); - let countNoResponse = 0; - for (const message of messages) { - if (!message) { - countNoResponse++; - continue; - } - await delay(100); - await ctx.reply(telegramifyMarkdown(message, 'escape'), { parse_mode: 'MarkdownV2' }); - } - if (countNoResponse === messages.length) { - await ctx.reply(t.sorryICannotUnderstand); - return; - } - } + if (ctx.from?.id) { + let countMaxPreviousMessage = aiClient.previousMessageLimit; + const query = ODataExpression.forV4() + .filter((p) => p.userId.$equals(String(ctx.from?.id))) + .build(); + for await (const entity of azureTableMessageClient.list(query)) { + if (countMaxPreviousMessage <= 0) { + break; + } + previousMessage.push({ type: entity.type, content: entity.payload }); + countMaxPreviousMessage--; + } + } else { + console.log(`userId is not available, skipping loading previous messages`); + } + previousMessage.reverse(); + // Step 3: Chat with AI + const messages = await aiClient.chat('friend', chatMode, [incomingMessage], previousMessage); + await azureTableMessageClient.insert( + await new MessageEntity({ + payload: messages.join(' '), + userId: String(ctx.from?.id), + senderId: String(0), + type: 'text', + }).init(), + ); + let countNoResponse = 0; + for (const message of messages) { + if (!message) { + countNoResponse++; + continue; + } + await delay(100); + await ctx.reply(telegramifyMarkdown(message, 'escape'), { parse_mode: 'MarkdownV2' }); + } + if (countNoResponse === messages.length) { + await ctx.reply(t.sorryICannotUnderstand); + return; + } + } - get instance() { - return this.bot; - } + get instance() { + return this.bot; + } } diff --git a/src/bot/languages.ts b/src/bot/languages.ts index 260d84e..5a4ff3c 100644 --- a/src/bot/languages.ts +++ b/src/bot/languages.ts @@ -1,15 +1,15 @@ // Prefer female character const thaiLanguage = { - start: "เริ่ม", - yourAre: "คุณคือ", - readingImage: "กำลังอ่านรูป", - sorryICannotUnderstand: "ขอโทษด้วยค่ะ ฉันไม่เข้าใจที่คุณพิมพ์มา", - sorryICannotUnderstandMessageType: "ขอโทษด้วยค่ะ ฉันไม่เข้าใจประเภทข้อความนี้ ตอนนี้ฉันสามารถเข้าใจแค่ข้อความและรูปภาพค่ะ", -} as const + start: 'เริ่ม', + yourAre: 'คุณคือ', + readingImage: 'กำลังอ่านรูป', + sorryICannotUnderstand: 'ขอโทษด้วยค่ะ ฉันไม่เข้าใจที่คุณพิมพ์มา', + sorryICannotUnderstandMessageType: 'ขอโทษด้วยค่ะ ฉันไม่เข้าใจประเภทข้อความนี้ ตอนนี้ฉันสามารถเข้าใจแค่ข้อความและรูปภาพค่ะ', +} as const; const langConfig = { - 'th': thaiLanguage, - 'en': undefined, // English is not implemented yet + th: thaiLanguage, + en: undefined, // English is not implemented yet } as const; // Default language is Thai diff --git a/src/entities/messages.ts b/src/entities/messages.ts index dfcd26b..f69f77b 100644 --- a/src/entities/messages.ts +++ b/src/entities/messages.ts @@ -1,6 +1,6 @@ import type { AzureTableEntityBase, AsyncEntityKeyGenerator } from '../libs/azure-table'; import type { SetOptional } from 'type-fest'; -import { sha3 } from "hash-wasm"; +import { sha3 } from 'hash-wasm'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; dayjs.extend(utc); @@ -9,25 +9,25 @@ dayjs.extend(utc); * Message History entity */ export interface IMessageEntity extends AzureTableEntityBase { - /** - * Text or Photo URL (Photo URL may invalid after a certain time) - */ - payload: string; - /** - * Telegram User ID - * Typically, it's a 10-digit number - */ - userId: string; - createdAt: Date; - /** - * Sender ID, User ID of the sender, - * If it's a bot, use `0` as the senderId - */ - senderId: string; - /** - * Message type, text or photo - */ - type: 'text' | 'photo'; + /** + * Text or Photo URL (Photo URL may invalid after a certain time) + */ + payload: string; + /** + * Telegram User ID + * Typically, it's a 10-digit number + */ + userId: string; + createdAt: Date; + /** + * Sender ID, User ID of the sender, + * If it's a bot, use `0` as the senderId + */ + senderId: string; + /** + * Message type, text or photo + */ + type: 'text' | 'photo'; } /** @@ -36,58 +36,56 @@ export interface IMessageEntity extends AzureTableEntityBase { * - RowKey: `{LogTailTimestamp}-{messageHash:10}` (Created Date, Message Hash with first 10 characters) */ export class MessageEntity implements AsyncEntityKeyGenerator { + constructor(private readonly _value: SetOptional) { + if (!_value.createdAt) _value.createdAt = new Date(); + } - constructor(private readonly _value: SetOptional) { - if (!_value.createdAt) _value.createdAt = new Date(); - } + get value(): IMessageEntity { + if (!this._value.partitionKey || !this._value.rowKey) + throw new Error('PartitionKey or RowKey is not set, please call `init()` method first'); + return this._value as IMessageEntity; + } - get value(): IMessageEntity { - if (!this._value.partitionKey || !this._value.rowKey) throw new Error('PartitionKey or RowKey is not set, please call `init()` method first'); - return this._value as IMessageEntity; - } + /** + * Initialize the entity with PartitionKey and RowKey, + * If `batchOrder` is set, it will be used to generate the RowKey, + * otherwise, `batchOrder` will be set to 0 + * @param batchOrder + * @returns + */ + async init(): Promise { + this._value.partitionKey = await this.getPartitionKey(); + this._value.rowKey = await this.getRowKey(); + return this.value as IMessageEntity; + } - /** - * Initialize the entity with PartitionKey and RowKey, - * If `batchOrder` is set, it will be used to generate the RowKey, - * otherwise, `batchOrder` will be set to 0 - * @param batchOrder - * @returns - */ - async init(): Promise { - this._value.partitionKey = await this.getPartitionKey(); - this._value.rowKey = await this.getRowKey(); - return this.value as IMessageEntity; - } + async getPartitionKey() { + const object = this._value; + return `${dayjs(object.createdAt).utc().format('YYYY')}-${object.userId.padStart(20, '0')}`; + } - async getPartitionKey() { - const object = this._value; - return `${dayjs(object.createdAt).utc().format('YYYY')}-${object.userId.padStart(20, '0')}`; - } + async getRowKey(): Promise { + const object = this._value; + return `${this.calculateDescendingIndex(Math.floor(Date.now() / 1000))}-${await this.hash(object.payload)}`; + } - async getRowKey(): Promise { - const object = this._value; - return `${this.calculateDescendingIndex(Math.floor(Date.now() / 1000))}-${await this.hash(object.payload)}`; - } + async hash(message: string, limit = 10): Promise { + return (await sha3(message)).slice(0, limit); + } - async hash(message: string, limit = 10): Promise { - return (await sha3(message)).slice(0, limit); - } - - /** - * Calculate the descending index based on the timestamp, for log tail pattern - * @ref https://learn.microsoft.com/en-us/azure/storage/tables/table-storage-design-patterns#log-tail-pattern - * - * @param timestamp Unix timestamp - * @param maxTimestamp Default to 100_000_000_000 (Represent November 16, 5138, at 09:46:40) which is larger enough for the next 2000 years - * @returns - */ - calculateDescendingIndex(timestamp: number, maxTimestamp: number = 100_000_000_000): string { + /** + * Calculate the descending index based on the timestamp, for log tail pattern + * @ref https://learn.microsoft.com/en-us/azure/storage/tables/table-storage-design-patterns#log-tail-pattern + * + * @param timestamp Unix timestamp + * @param maxTimestamp Default to 100_000_000_000 (Represent November 16, 5138, at 09:46:40) which is larger enough for the next 2000 years + * @returns + */ + calculateDescendingIndex(timestamp: number, maxTimestamp: number = 100_000_000_000): string { // Subtract the timestamp from the maximum possible value const descendingIndex = maxTimestamp - timestamp; // Pad with zeros to ensure uniform length for consistent sorting return descendingIndex.toString().padStart(maxTimestamp.toString().length, '0'); -} - - + } } diff --git a/src/env.ts b/src/env.ts index 8fe75ee..cf46499 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,86 +1,84 @@ -import { z, ZodError } from "zod"; +import { z, ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; import { getErrorMessage } from '../scripts/utils/error'; export const envSchema = z.object({ - NODE_ENV: z.string().default('production'), - BOT_TOKEN: z.string(), - BOT_INFO: z.string(), - /** - * Comma separated list of user ids - * Accept only messages from these users - * - * Example: 1234567890,0987654321 - * Convert to: [1234567890, 0987654321] - * - */ - ALLOWED_USER_IDS: z.preprocess((val: unknown) => { - if (val === '' || val === undefined || val === null) return []; - if (typeof val === 'number') return [val]; - return typeof val === 'string' ? val.trim().split(',').map(Number) : []; - }, z.array(z.number())), - /** - * Protected Bot - * - * @default true - */ - PROTECTED_BOT: z.boolean().default(true), - /** - * OpenAI API Key - */ - OPENAI_API_KEY: z.string(), - /** - * Azure Table Connection String - */ - AZURE_TABLE_CONNECTION_STRING: z.string(), - /** - * Use for share multiple app in one Azure Storage Account - */ - AZURE_TABLE_PREFIX: z.string().default('MyBot'), + NODE_ENV: z.string().default('production'), + BOT_TOKEN: z.string(), + BOT_INFO: z.string(), + /** + * Comma separated list of user ids + * Accept only messages from these users + * + * Example: 1234567890,0987654321 + * Convert to: [1234567890, 0987654321] + * + */ + ALLOWED_USER_IDS: z.preprocess((val: unknown) => { + if (val === '' || val === undefined || val === null) return []; + if (typeof val === 'number') return [val]; + return typeof val === 'string' ? val.trim().split(',').map(Number) : []; + }, z.array(z.number())), + /** + * Protected Bot + * + * @default true + */ + PROTECTED_BOT: z.boolean().default(true), + /** + * OpenAI API Key + */ + OPENAI_API_KEY: z.string(), + /** + * Azure Table Connection String + */ + AZURE_TABLE_CONNECTION_STRING: z.string(), + /** + * Use for share multiple app in one Azure Storage Account + */ + AZURE_TABLE_PREFIX: z.string().default('MyBot'), }); /** * Development Environment Schema */ export const developmentEnvSchema = envSchema.extend({ - /** - * Telegram webhook URL - */ - TELEGRAM_WEBHOOK_URL: z.string(), - /** - * Azure Functions Name - */ - AZURE_FUNCTIONS_NAME: z.string(), - /** - * Azure Functions App Name - */ - AZURE_FUNCTIONS_APP_NAME: z.string(), - /** - * Azure Functions Resource Group - */ - AZURE_FUNCTIONS_RESOURCE_GROUP: z.string(), - /** - * Azure Functions Subscription - */ - AZURE_FUNCTIONS_SUBSCRIPTION: z.string(), + /** + * Telegram webhook URL + */ + TELEGRAM_WEBHOOK_URL: z.string(), + /** + * Azure Functions Name + */ + AZURE_FUNCTIONS_NAME: z.string(), + /** + * Azure Functions App Name + */ + AZURE_FUNCTIONS_APP_NAME: z.string(), + /** + * Azure Functions Resource Group + */ + AZURE_FUNCTIONS_RESOURCE_GROUP: z.string(), + /** + * Azure Functions Subscription + */ + AZURE_FUNCTIONS_SUBSCRIPTION: z.string(), }); export function getDevelopmentEnv(env: unknown) { - try { - return developmentEnvSchema.parse(env); - } catch (error: unknown) { - console.error(getErrorMessage(error)); - throw new Error('Invalid environment variables'); - } + try { + return developmentEnvSchema.parse(env); + } catch (error: unknown) { + console.error(getErrorMessage(error)); + throw new Error('Invalid environment variables'); + } } export function getEnv(env: unknown) { - try { - return envSchema.parse(env); - } catch (error: unknown) { - console.error(getErrorMessage(error)); - throw new Error('Invalid environment variables'); - } + try { + return envSchema.parse(env); + } catch (error: unknown) { + console.error(getErrorMessage(error)); + throw new Error('Invalid environment variables'); + } } - - diff --git a/src/functions/telegramBot.ts b/src/functions/telegramBot.ts index f4bc2e5..795f01c 100644 --- a/src/functions/telegramBot.ts +++ b/src/functions/telegramBot.ts @@ -1,14 +1,14 @@ -import { app } from "@azure/functions"; +import { app } from '@azure/functions'; import { webhookCallback } from 'grammy'; -import { bootstrap } from "../bootstrap"; +import { bootstrap } from '../bootstrap'; const { bot, asyncTask } = bootstrap(); app.http('telegramBot', { - methods: ['GET', 'POST'], - authLevel: 'function', - handler: async (request, _context) => { - await asyncTask(); - return webhookCallback(bot, "azure-v4")(request as any) - } + methods: ['GET', 'POST'], + authLevel: 'function', + handler: async (request, _context) => { + await asyncTask(); + return webhookCallback(bot, 'azure-v4')(request as any); + }, }); diff --git a/src/libs/azure-table.ts b/src/libs/azure-table.ts index a644f0c..fc153e5 100644 --- a/src/libs/azure-table.ts +++ b/src/libs/azure-table.ts @@ -22,8 +22,8 @@ export type InferAzureTable = T extends AzureTable ? U : never; export interface AsyncEntityKeyGenerator { getPartitionKey: () => Promise; getRowKey: () => Promise; - init: () => Promise; - value: T; + init: () => Promise; + value: T; } /** @@ -40,7 +40,7 @@ export interface AsyncEntityKeyGenerator { export interface EntityKeyGenerator { getPartitionKey: () => string; getRowKey: () => string; - value: T; + value: T; } /** @@ -65,10 +65,7 @@ export class AzureTable { * * select prop type may incorrect */ - list( - queryOptions?: ListTableEntitiesOptions['queryOptions'], - listTableEntitiesOptions?: Omit - ) { + list(queryOptions?: ListTableEntitiesOptions['queryOptions'], listTableEntitiesOptions?: Omit) { return this.client.listEntities({ ...listTableEntitiesOptions, queryOptions, @@ -77,7 +74,7 @@ export class AzureTable { async listAll( queryOptions?: ListTableEntitiesOptions['queryOptions'], - listTableEntitiesOptions?: Omit + listTableEntitiesOptions?: Omit, ) { const entities = this.list(queryOptions, listTableEntitiesOptions); const result = []; @@ -90,7 +87,7 @@ export class AzureTable { async count( queryOptions?: ListTableEntitiesOptions['queryOptions'], - listTableEntitiesOptions?: Omit + listTableEntitiesOptions?: Omit, ) { let count = 0; const entities = this.list(queryOptions, listTableEntitiesOptions); @@ -101,8 +98,6 @@ export class AzureTable { return count; } - - async insert(entity: TEntity) { return this.client.createEntity(entity); } @@ -117,7 +112,7 @@ export class AzureTable { const entityChunks = chunk(entities, this.maxBatchChange); for (const entityChunk of entityChunks) { const transaction = new TableTransaction(); - entityChunk.forEach(entity => transaction.createEntity(entity)); + entityChunk.forEach((entity) => transaction.createEntity(entity)); await this.client.submitTransaction(transaction.actions); } } @@ -132,7 +127,7 @@ export class AzureTable { const entityChunks = chunk(entities, this.maxBatchChange); for (const entityChunk of entityChunks) { const transaction = new TableTransaction(); - entityChunk.forEach(entity => transaction.upsertEntity(entity)); + entityChunk.forEach((entity) => transaction.upsertEntity(entity)); await this.client.submitTransaction(transaction.actions); } } @@ -144,7 +139,7 @@ export class AzureTable { const entityChunks = chunk(entities, this.maxBatchChange); for (const entityChunk of entityChunks) { const transaction = new TableTransaction(); - entityChunk.forEach(entity => { + entityChunk.forEach((entity) => { const { partitionKey, rowKey } = entity; transaction.deleteEntity(partitionKey, rowKey); }); @@ -161,12 +156,15 @@ export class AzureTable { * @returns */ groupPartitionKey(entities: TEntity[]) { - return entities.reduce((acc, cur) => { - if (!acc[cur.partitionKey]) { - acc[cur.partitionKey] = []; - } - acc[cur.partitionKey].push(cur); - return acc; - }, {} as Record); + return entities.reduce( + (acc, cur) => { + if (!acc[cur.partitionKey]) { + acc[cur.partitionKey] = []; + } + acc[cur.partitionKey].push(cur); + return acc; + }, + {} as Record, + ); } } diff --git a/src/middlewares/authorize.ts b/src/middlewares/authorize.ts index 205e8e7..3792778 100644 --- a/src/middlewares/authorize.ts +++ b/src/middlewares/authorize.ts @@ -1,27 +1,25 @@ -import { Context, NextFunction } from "grammy"; +import { Context, NextFunction } from 'grammy'; /** * Middleware function that checks if the user is authorized to use the bot * @param allowedUserIds Array of user IDs that are allowed to use the bot * @returns Middleware function that checks if the user is authorized to use the bot */ -export function authorize( - allowedUserIds: number[] -) { - return async function (ctx: Context, next: NextFunction) { - const replyMessage = 'You are not authorized to use this bot'; - if (!ctx.message?.from?.id) { - console.log('No user ID found'); - await ctx.reply(replyMessage); - return; - } - // TODO: Check Chat Type later, e.g. ctx.chat?.type === 'private' - if (allowedUserIds.includes(ctx.message?.from?.id)) { - console.log(`User ${ctx.message?.from?.id} is authorized`); - await next(); - } else { - console.log(`User ${ctx.message?.from?.id} is not authorized from authorized users ${JSON.stringify(allowedUserIds)}`); - await ctx.reply(replyMessage); - } - } +export function authorize(allowedUserIds: number[]) { + return async function (ctx: Context, next: NextFunction) { + const replyMessage = 'You are not authorized to use this bot'; + if (!ctx.message?.from?.id) { + console.log('No user ID found'); + await ctx.reply(replyMessage); + return; + } + // TODO: Check Chat Type later, e.g. ctx.chat?.type === 'private' + if (allowedUserIds.includes(ctx.message?.from?.id)) { + console.log(`User ${ctx.message?.from?.id} is authorized`); + await next(); + } else { + console.log(`User ${ctx.message?.from?.id} is not authorized from authorized users ${JSON.stringify(allowedUserIds)}`); + await ctx.reply(replyMessage); + } + }; } diff --git a/tsconfig.json b/tsconfig.json index 16cc6d3..026b3e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,9 @@ "outDir": "dist", "rootDir": ".", "sourceMap": true, - "skipLibCheck": true, - "esModuleInterop": true, - "strict": true + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts"] }