1
0
mirror of https://github.com/sadfsdfdsa/allbot.git synced 2024-11-19 00:31:42 +02:00

feat: prometheus and express for healthchecking

This commit is contained in:
Artem Shuvaev 2023-09-03 03:13:22 +05:00
parent e702e1c5f0
commit 88965d4f47
14 changed files with 1053 additions and 113 deletions

View File

@ -1,3 +1,4 @@
REDIS_URI=rediss://your-host-uri
TG_TOKEN=STRING_FROM_BOT_FATHER
BOT_NAME=@allsuperior_bot
BOT_NAME=@allsuperior_bot
SERVER_PORT=8080

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
.env
.env.test
.env.prod
node_modules

View File

@ -11,4 +11,5 @@ It's require `.env` after fork:
REDIS_URI=rediss://your-host-uri
TG_TOKEN=STRING_FROM_BOT_FATHER
BOT_NAME=@allsuperior_bot
SERVER_PORT=8080
```

View File

@ -1,14 +0,0 @@
import { Telegraf } from 'telegraf'
import { createBot } from './bot.js'
describe('bot', () => {
describe('#createBot', () => {
test('should throw error if not token passed', () => {
expect(() => createBot(undefined)).toThrow()
})
test('should return instance of bot', () => {
expect(createBot('123') instanceof Telegraf).toBeTruthy()
})
})
})

View File

@ -1,12 +1,103 @@
import { Telegraf } from 'telegraf'
import { Context, Telegraf } from 'telegraf'
import { UserRepository } from './repository.js'
import { MetricsService } from './metrics.js'
import { message } from 'telegraf/filters'
import { Chat, Message, User } from 'telegraf/types'
export const createBot = (token?: string) => {
console.log('Starting bot')
if (!token) {
throw new Error('No tg token set')
}
const bot = new Telegraf(token)
return bot
type HandleMessagePayload = {
chatId: Chat['id']
text: string
from: NonNullable<Message['from']>
messageId: Message['message_id']
}
export class Bot {
private bot: Telegraf
private readonly COMMANDS = ['@all', '/all']
private isListening = false
constructor(
private readonly userRepository: UserRepository,
private readonly metricsService: MetricsService,
botName: string | undefined,
token: string | undefined
) {
if (!token) throw new Error('No tg token set')
if (botName) this.COMMANDS.push(botName)
this.bot = new Telegraf(token)
this.bot.on(message('text'), async (ctx) => {
const {
message: { from, text, message_id },
chat: { id },
} = ctx
await this.handleMessage(
{
from,
text,
messageId: message_id,
chatId: id,
},
(...args: Parameters<Context['sendMessage']>) => ctx.reply(...args)
)
})
this.bot.on(message('new_chat_members'), ({ message, chat }) =>
this.handleAddMembers(chat.id, message.new_chat_members)
)
this.bot.on(message('left_chat_member'), ({ chat, message }) =>
this.handleDelete(chat.id, message.left_chat_member)
)
}
public launch(): void {
if (this.isListening) throw new Error('Bot already listening')
console.log('Bot starting')
this.isListening = true
process.once('SIGINT', () => this.bot.stop('SIGINT'))
process.once('SIGTERM', () => this.bot.stop('SIGTERM'))
this.bot.launch()
}
private async handleMessage(
{ from, text, messageId, chatId }: HandleMessagePayload,
reply: Context['reply']
): Promise<void> {
await this.userRepository.addUsers(chatId, [from])
const isCallAll = this.COMMANDS.some((command) => text.includes(command))
console.log(`Message, should reply=${isCallAll}`, text)
if (!isCallAll) return
const chatUsernames = await this.userRepository.getUsernamesByChatId(chatId)
if (!Object.values(chatUsernames).length) return
const str = Object.values(chatUsernames).map((username) => `@${username} `)
this.metricsService.addReply()
reply(`All from ${from.username}: ${str}`, {
reply_to_message_id: messageId,
})
}
private handleAddMembers(chatId: Chat['id'], users: User[]): Promise<void> {
console.log('Try add new members', users)
return this.userRepository.addUsers(chatId, users)
}
private handleDelete(chatId: Chat['id'], user: User): Promise<void> {
console.log('Delete user', user.username)
return this.userRepository.deleteUser(chatId, user.id)
}
}

38
core/metrics.ts Normal file
View File

@ -0,0 +1,38 @@
import { Registry, Counter, collectDefaultMetrics } from 'prom-client'
export class MetricsService {
private readonly registry: Registry
private readonly replyCounter: Counter
constructor(measureDefaultMetrics = true) {
this.registry = new Registry()
this.replyCounter = new Counter({
name: 'allbot_replies_counter',
help: 'The number of total replies of bot',
})
this.registry.registerMetric(this.replyCounter)
if (!measureDefaultMetrics) return
collectDefaultMetrics({
register: this.registry,
})
}
public addReply(): void {
this.replyCounter.inc()
}
public async getMetrics(): Promise<{
contentType: string
metrics: string
}> {
const metrics = await this.registry.metrics()
return {
metrics,
contentType: this.registry.contentType,
}
}
}

41
core/server.ts Normal file
View File

@ -0,0 +1,41 @@
import express from 'express'
import { MetricsService } from './metrics.js'
export class Server {
private app: ReturnType<typeof express>
private isListening = false
constructor(
private readonly metrics: MetricsService,
private readonly port: string | undefined
) {
if (!port) throw new Error('Incorrect port')
this.app = express()
this.app.get('/health', (req, res) => {
res.send({
status: 200,
})
})
this.app.get('/metrics', async (req, res) => {
const { contentType, metrics: metricsString } =
await this.metrics.getMetrics()
res.setHeader('Content-Type', contentType)
res.send(metricsString)
})
}
public listen(): void {
if (this.isListening) throw new Error('Double listen')
this.isListening = true
this.app.listen(Number(this.port), () => {
console.log(`Server is listening on port ${this.port}`)
})
}
}

66
dist/core/bot.js vendored
View File

@ -1,9 +1,61 @@
import { Telegraf } from 'telegraf';
export const createBot = (token) => {
console.log('Starting bot');
if (!token) {
throw new Error('No tg token set');
import { message } from 'telegraf/filters';
export class Bot {
userRepository;
metricsService;
bot;
COMMANDS = ['@all', '/all'];
isListening = false;
constructor(userRepository, metricsService, botName, token) {
this.userRepository = userRepository;
this.metricsService = metricsService;
if (!token)
throw new Error('No tg token set');
if (botName)
this.COMMANDS.push(botName);
this.bot = new Telegraf(token);
this.bot.on(message('text'), async (ctx) => {
const { message: { from, text, message_id }, chat: { id }, } = ctx;
await this.handleMessage({
from,
text,
messageId: message_id,
chatId: id,
}, (...args) => ctx.reply(...args));
});
this.bot.on(message('new_chat_members'), ({ message, chat }) => this.handleAddMembers(chat.id, message.new_chat_members));
this.bot.on(message('left_chat_member'), ({ chat, message }) => this.handleDelete(chat.id, message.left_chat_member));
}
const bot = new Telegraf(token);
return bot;
};
launch() {
if (this.isListening)
throw new Error('Bot already listening');
console.log('Bot starting');
this.isListening = true;
process.once('SIGINT', () => this.bot.stop('SIGINT'));
process.once('SIGTERM', () => this.bot.stop('SIGTERM'));
this.bot.launch();
}
async handleMessage({ from, text, messageId, chatId }, reply) {
await this.userRepository.addUsers(chatId, [from]);
const isCallAll = this.COMMANDS.some((command) => text.includes(command));
console.log(`Message, should reply=${isCallAll}`, text);
if (!isCallAll)
return;
const chatUsernames = await this.userRepository.getUsernamesByChatId(chatId);
if (!Object.values(chatUsernames).length)
return;
const str = Object.values(chatUsernames).map((username) => `@${username} `);
this.metricsService.addReply();
reply(`All from ${from.username}: ${str}`, {
reply_to_message_id: messageId,
});
}
handleAddMembers(chatId, users) {
console.log('Try add new members', users);
return this.userRepository.addUsers(chatId, users);
}
handleDelete(chatId, user) {
console.log('Delete user', user.username);
return this.userRepository.deleteUser(chatId, user.id);
}
}

28
dist/core/metrics.js vendored Normal file
View File

@ -0,0 +1,28 @@
import { Registry, Counter, collectDefaultMetrics } from 'prom-client';
export class MetricsService {
registry;
replyCounter;
constructor(measureDefaultMetrics = true) {
this.registry = new Registry();
this.replyCounter = new Counter({
name: 'allbot_replies_counter',
help: 'The number of total replies of bot',
});
this.registry.registerMetric(this.replyCounter);
if (!measureDefaultMetrics)
return;
collectDefaultMetrics({
register: this.registry,
});
}
addReply() {
this.replyCounter.inc();
}
async getMetrics() {
const metrics = await this.registry.metrics();
return {
metrics,
contentType: this.registry.contentType,
};
}
}

32
dist/core/server.js vendored Normal file
View File

@ -0,0 +1,32 @@
import express from 'express';
export class Server {
metrics;
port;
app;
isListening = false;
constructor(metrics, port) {
this.metrics = metrics;
this.port = port;
if (!port)
throw new Error('Incorrect port');
this.app = express();
}
listen() {
if (this.isListening)
throw new Error('Double listen');
this.isListening = true;
this.app.get('/health', (req, res) => {
res.send({
status: 200,
});
});
this.app.get('/metrics', async (req, res) => {
const { contentType, metrics: metricsString } = await this.metrics.getMetrics();
res.setHeader('Content-Type', contentType);
res.send(metricsString);
});
this.app.listen(Number(this.port), () => {
console.log(`Server is listening on port ${this.port}`);
});
}
}

44
dist/index.js vendored
View File

@ -1,42 +1,16 @@
import { message } from 'telegraf/filters';
import 'dotenv/config';
import { createDB } from './core/db.js';
import { UserRepository } from './core/repository.js';
import { createBot } from './core/bot.js';
const NAME = process.env.BOT_NAME;
const ALL_COMMANDS = ['@all', '/all'];
if (NAME)
ALL_COMMANDS.push(NAME);
import { Bot } from './core/bot.js';
import { MetricsService } from './core/metrics.js';
import { Server } from './core/server.js';
const main = async () => {
const client = await createDB(process.env.REDIS_URI);
const repository = new UserRepository(client);
const bot = createBot(process.env.TG_TOKEN);
bot.on(message('text'), async (ctx) => {
const { message: { from, text, message_id }, chat: { id }, } = ctx;
await repository.addUsers(id, [from]);
const isCallAll = ALL_COMMANDS.some((command) => text.includes(command));
console.log(`Message, should reply=${isCallAll}`, text);
if (!isCallAll)
return;
const chatUsernames = await repository.getUsernamesByChatId(id);
if (!Object.values(chatUsernames).length)
return;
const str = Object.values(chatUsernames).map((username) => `@${username} `);
ctx.reply(`All from ${from.username}: ${str}`, {
reply_to_message_id: message_id,
});
});
bot.on(message('new_chat_members'), async ({ message, chat: { id } }) => {
console.log('Try add new members', message.new_chat_members);
await repository.addUsers(id, message.new_chat_members);
});
bot.on(message('left_chat_member'), async (ctx) => {
console.log('Delete user', ctx.message.left_chat_member.username);
await client.hDel(`${ctx.chat.id}`, `${ctx.message.left_chat_member.id}`);
});
const metricsService = new MetricsService();
const server = new Server(metricsService, process.env.SERVER_PORT);
const dbClient = await createDB(process.env.REDIS_URI);
const userRepository = new UserRepository(dbClient);
const bot = new Bot(userRepository, metricsService, process.env.BOT_NAME, process.env.TG_TOKEN);
bot.launch();
// Enable graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));
server.listen();
};
main();

View File

@ -1,55 +1,28 @@
import { message } from 'telegraf/filters'
import 'dotenv/config'
import { createDB } from './core/db.js'
import { UserRepository } from './core/repository.js'
import { createBot } from './core/bot.js'
const NAME = process.env.BOT_NAME
const ALL_COMMANDS = ['@all', '/all']
if (NAME) ALL_COMMANDS.push(NAME)
import { Bot } from './core/bot.js'
import { MetricsService } from './core/metrics.js'
import { Server } from './core/server.js'
const main = async (): Promise<void> => {
const client = await createDB(process.env.REDIS_URI)
const repository = new UserRepository(client)
const bot = createBot(process.env.TG_TOKEN)
const metricsService = new MetricsService()
bot.on(message('text'), async (ctx) => {
const {
message: { from, text, message_id },
chat: { id },
} = ctx
const server = new Server(metricsService, process.env.SERVER_PORT)
await repository.addUsers(id, [from])
const dbClient = await createDB(process.env.REDIS_URI)
const isCallAll = ALL_COMMANDS.some((command) => text.includes(command))
console.log(`Message, should reply=${isCallAll}`, text)
if (!isCallAll) return
const userRepository = new UserRepository(dbClient)
const chatUsernames = await repository.getUsernamesByChatId(id)
if (!Object.values(chatUsernames).length) return
const str = Object.values(chatUsernames).map((username) => `@${username} `)
ctx.reply(`All from ${from.username}: ${str}`, {
reply_to_message_id: message_id,
})
})
bot.on(message('new_chat_members'), async ({ message, chat: { id } }) => {
console.log('Try add new members', message.new_chat_members)
await repository.addUsers(id, message.new_chat_members)
})
bot.on(message('left_chat_member'), async (ctx) => {
console.log('Delete user', ctx.message.left_chat_member.username)
await client.hDel(`${ctx.chat.id}`, `${ctx.message.left_chat_member.id}`)
})
const bot = new Bot(
userRepository,
metricsService,
process.env.BOT_NAME,
process.env.TG_TOKEN
)
bot.launch()
// Enable graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'))
process.once('SIGTERM', () => bot.stop('SIGTERM'))
server.listen()
}
main()

728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
"author": "Artem Shuvaev <shuvaevlol@gmail.com>",
"license": "ISC",
"devDependencies": {
"@types/express": "^4.17.17",
"@types/jest": "^29.5.4",
"@types/node": "^20.5.3",
"jest": "^29.6.4",
@ -24,6 +25,8 @@
},
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"prom-client": "^14.2.0",
"redis": "^4.6.8",
"telegraf": "^4.12.2"
}