1
0
mirror of https://github.com/sadfsdfdsa/allbot.git synced 2026-06-19 17:20:03 +02:00
Files
Artem Shuvaev 04a696f12d
coverage / coverage (push) Failing after 53s
fix: long messages
2024-10-06 21:14:23 +05:00

1486 lines
40 KiB
TypeScript

import { Context, NarrowedContext, Telegraf } from 'telegraf'
import { UserRepository } from './userRepository.js'
import { MetricsService } from './metrics.js'
import { message } from 'telegraf/filters'
import {
CallbackQuery,
Chat,
InlineKeyboardButton,
InlineKeyboardMarkup,
Update,
User,
} from 'telegraf/types'
import {
matchMentionsToUsers,
getMentionsFromEntities,
isChatGroup,
createSettingsKeyboard,
Settings,
} from './utils/utils.js'
import { MentionRepository } from './mentionRepository.js'
import {
ADDED_TO_CHAT_WELCOME_TEXT,
ALREADY_UNLIMITED,
CLEAN_UP_EMPTY_MENTION_TEXT,
EMPTY_DELETE_FROM_MENTION_TEXT,
EMPTY_DELETE_MENTION_TEXT,
GET_MENTIONS_TEXT,
HELP_COMMAND_TEXT,
INTRODUCE_CUSTOM_MENTIONS_TEXT,
NEED_TO_BUY_UNLIMITED,
NEW_MENTION_EXAMPLE,
NOT_EXISTED_MENTION_TEXT,
ONLY_ADMIN_ACTION_TEXT,
SETTINGS_TEXT,
} from './constants/texts.js'
import { LIMITS_MENTION_FOR_ADDING_PAY } from './constants/limits.js'
import { PaymentsRepository } from './paymentsRepository.js'
import { SettingsAction, SettingsRepository } from './settingsRepository.js'
import { BASE_INVOICE } from './constants/const.js'
type UniversalMessageOrActionUpdateCtx = NarrowedContext<
Context,
Update.MessageUpdate | Update.CallbackQueryUpdate<CallbackQuery>
>
export class Bot {
private bot: Telegraf
private readonly MENTION_COMMANDS = ['@all', '/all']
private readonly INCLUDE_PAY_LIMIT = LIMITS_MENTION_FOR_ADDING_PAY
private readonly EMOJI_SET = ['👋', '🫰']
private readonly EXAMPLES_BUTTON = {
callback_data: '/examples',
text: '🤔 Show examples',
}
private readonly BUY_MENTIONS_BUTTON = {
callback_data: '/buy',
text: ' Buy ⭐️Unlimited using Telegram Stars',
}
private isListening = false
private readonly activeQuery = new Set<Chat['id']>()
constructor(
private readonly userRepository: UserRepository,
private readonly metricsService: MetricsService,
private readonly mentionRepository: MentionRepository,
private readonly paymentsRepository: PaymentsRepository,
private readonly settingsRepository: SettingsRepository,
botName: string | undefined,
token: string | undefined
) {
if (!token) throw new Error('No tg token set')
if (botName) this.MENTION_COMMANDS.push(botName)
this.bot = new Telegraf(token, {
handlerTimeout: 400_000,
})
this.bot.telegram.setMyCommands([
{
command: 'all',
description: 'Mention all users in a group',
},
{
command: 'mention',
description:
'/mention some_group with additional text or see info about custom mentions in the group',
},
{
command: 'add_to',
description: '/add_to some_group @user1 @user2',
},
{
command: 'delete_from',
description: '/delete_from some_group @user1 @user2',
},
{
command: 'delete_mention',
description: '/delete_mention some_group',
},
{
command: 'settings',
description: 'Bot settings',
},
{
command: 'help',
description: 'Help information',
},
{
command: 'buy',
description: 'Get Unlimited custom mentions. Forever.',
},
])
this.registerBuyCommandAndActions()
this.registerHelpCommand()
this.registerSettingsCommand()
this.registerMentionCommand()
this.registerGetAllMentionCommand()
this.registerAddToMentionCommand()
this.registerDeleteFromMentionCommand()
this.registerDeleteMentionCommand()
// Should be last for not overriding commands below
this.registerHandleMessage()
this.registerSettingsChangeAction()
this.bot.action('/examples', async (ctx) => {
if (!ctx.chat?.id) return
await this.sendExamples(ctx)
})
this.bot.action(/^[/mention]+(-.+)?$/, (ctx) => {
if (!ctx.chat?.id) return
const data = (ctx.update.callback_query as any).data as string
const field = data.replace('/mention-', '')
console.log('[mention-action]', field, ctx.chat.id, ctx.from)
this.sendCustomMention(ctx, field, 'action').catch(
this.handleSendMessageError
)
})
this.bot.action('/intro_custom_mentions', async (ctx) => {
if (!ctx.chat?.id) return
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.chat.id.toString(),
action: 'mention.showIntro',
})
const isUnlimitedGroup = this.paymentsRepository.getHasGroupUnlimited(
ctx.chat.id
)
const text = `
${INTRODUCE_CUSTOM_MENTIONS_TEXT}${
isUnlimitedGroup ? ALREADY_UNLIMITED : NEED_TO_BUY_UNLIMITED
}
`
await ctx
.reply(text, {
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [
isUnlimitedGroup ? [] : [this.BUY_MENTIONS_BUTTON],
],
},
})
.catch(this.handleSendMessageError)
})
this.bot.on(message('new_chat_members'), async (ctx) => {
const { chat, message } = ctx
this.handleAddMembers(chat.id, message.new_chat_members)
const isBotAddingToGroup = this.tryDetectBotAddOrDelete(
chat.id,
message.new_chat_members,
'add'
)
if (!isBotAddingToGroup) return
await ctx
.reply(ADDED_TO_CHAT_WELCOME_TEXT, {
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[this.EXAMPLES_BUTTON]],
},
})
.catch(this.handleSendMessageError)
await this.sendExamples(ctx)
})
this.bot.on(message('left_chat_member'), ({ chat, message }) => {
this.handleDelete(chat.id, message.left_chat_member)
this.tryDetectBotAddOrDelete(
chat.id,
[message.left_chat_member],
'delete'
)
})
}
public async launch(): Promise<void> {
if (this.isListening) throw new Error('Bot already listening')
// console.log('To enable unlimited:', await this.bot.telegram.getChat('@chat'))
this.bot.botInfo = await this.bot.telegram.getMe()
console.log('[LAUNCH] Bot info: ', this.bot.botInfo)
console.log('[LAUNCH] Bot starting')
this.isListening = true
process.once('SIGINT', () => this.bot.stop('SIGINT'))
process.once('SIGTERM', () => this.bot.stop('SIGTERM'))
this.bot.catch((err) => {
this.handleSendMessageError(err, '[MAIN_CATCH]')
})
this.bot.launch()
}
private tryDetectBotAddOrDelete(
chatId: Chat['id'],
members: User[],
action: 'add' | 'delete'
): boolean {
const thisBot = members.find((user) => user.id === this.bot.botInfo?.id)
if (!thisBot) return false
const date = new Date()
this.metricsService.groupsCounter.inc({
action,
chatId,
time: date.toLocaleString('ru-RU', { timeZone: 'Asia/Yekaterinburg' }),
})
console.log(`[TEAM_CHANGE] Bot ${action} in ${chatId}`)
if (action === 'delete') {
this.userRepository.removeTeam(chatId)
}
return true
}
private registerSettingsChangeAction(): void {
this.bot.action(/^[/settings]+(_.+)?$/, async (ctx) => {
if (!ctx.chat?.id) return
const isAllowed = await this.getIsAllowed(
ctx.chat.id,
ctx.update.callback_query.from.id
)
if (!isAllowed) {
this.metricsService.restrictedAction.inc({
chatId: ctx.chat.id,
action: 'settingsChanged',
})
console.log(
`[settings] Not edited because not ${ctx.update.callback_query.from.username} admin in ${ctx.chat.id}`
)
await ctx.reply(`<strong>🛑 Only admins can edit settings</strong>`, {
parse_mode: 'HTML',
})
return
}
const data = (ctx.update.callback_query as any).data as string
const [, jsonData] = data.split('_')
const { s, ...rest } = JSON.parse(jsonData) as Settings
const newValue = !rest[s]
const changedSettings = {
...rest,
[s]: newValue,
}
await this.settingsRepository.updateSettings(ctx.chat.id, s, newValue)
const keyboard = createSettingsKeyboard(changedSettings)
console.log(`[settings] Edited ${s} with ${newValue} in ${ctx.chat.id}`)
await ctx.editMessageReplyMarkup({
inline_keyboard: keyboard,
})
})
}
private registerSettingsCommand(): void {
this.bot.command('settings', async (ctx) => {
const settings = await this.settingsRepository.getSettingsCompressed(
ctx.chat.id
)
const keyboard = createSettingsKeyboard(settings)
this.metricsService.commandsCounter.inc({
chatId: ctx.chat.id.toString(),
command: 'settings',
})
console.log('[settings] Send settings info')
await ctx
.reply(SETTINGS_TEXT, {
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: keyboard,
},
})
.catch(this.handleSendMessageError)
})
}
private registerMentionCommand(): void {
this.bot.command('mention', async (ctx) => {
if (!isChatGroup(ctx.message.chat.id)) {
await ctx
.reply(`👥 Only available in groups`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
const field = ctx.message.text.split(' ')[1]
if (!field) {
console.log('[mention] Empty mention', ctx.message.text)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mention.emptyMention',
})
const keyboard = await this.getKeyboardWithCustomMentions(
ctx.message.chat.id
)
if (!keyboard) {
await ctx
.reply(NOT_EXISTED_MENTION_TEXT, { parse_mode: 'HTML' })
.catch(this.handleSendMessageError)
return
}
await ctx
.reply(GET_MENTIONS_TEXT, {
parse_mode: 'HTML',
reply_markup: keyboard,
})
.catch(this.handleSendMessageError)
return
}
const isExists = await this.mentionRepository.checkIfMentionExists(
ctx.message.chat.id,
field
)
if (!isExists) {
console.log('[mention] Not exists', ctx.message.chat.id, field)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mention.notExisted',
})
const keyboard = await this.getKeyboardWithCustomMentions(
ctx.message.chat.id
)
if (!keyboard) {
await ctx
.reply(NOT_EXISTED_MENTION_TEXT, { parse_mode: 'HTML' })
.catch(this.handleSendMessageError)
return
}
await ctx
.reply('⚠️ Not existed mention. All mentions in the group:', {
parse_mode: 'HTML',
reply_markup: keyboard,
})
.catch(this.handleSendMessageError)
return
}
this.sendCustomMention(ctx, field, 'command')
})
}
private registerGetAllMentionCommand(): void {
this.bot.command('mentions', async (ctx) => {
if (!isChatGroup(ctx.message.chat.id)) {
await ctx
.reply(`👥 Only available in groups`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
const keyboard = await this.getKeyboardWithCustomMentions(
ctx.message.chat.id
)
if (!keyboard) {
console.log('[mentions] No mentions', ctx.message.chat.id)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentions.emptyMentions',
})
await ctx
.reply(
`0️⃣ There is no custom mentions. Try it out:\n${NEW_MENTION_EXAMPLE}`,
{
parse_mode: 'HTML',
}
)
.catch(this.handleSendMessageError)
return
}
console.log('[mentions] Print mentions', ctx.message.chat.id)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentions.getAll',
})
await ctx
.reply(GET_MENTIONS_TEXT, {
parse_mode: 'HTML',
reply_markup: keyboard,
})
.catch(this.handleSendMessageError)
})
}
private registerAddToMentionCommand(): void {
this.bot.command('add_to', async (ctx) => {
if (!isChatGroup(ctx.message.chat.id)) {
await ctx
.reply(`👥 Only available in groups`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
let field = ctx.message.text.split(' ')[1]
if (field) {
field = field.startsWith('@') ? field.slice(1) : field
}
if (!field) {
console.log('[add_to] Empty mention', ctx.message.text)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionAddTo.emptyMention',
})
await ctx
.reply(NOT_EXISTED_MENTION_TEXT, { parse_mode: 'HTML' })
.catch(this.handleSendMessageError)
return
}
const isAllowed = await this.onlyAdminActionGuard(
ctx,
'crudCustomMention'
)
if (!isAllowed) return
const mentions = getMentionsFromEntities(
ctx.message.text,
ctx.message.entities
)
const reversedUsers =
await this.userRepository.getUsersIdsByUsernamesInChat(
ctx.message.chat.id
)
const { successIds, successMentions, missedMentions } =
matchMentionsToUsers(mentions, reversedUsers)
const unsuccessStr = missedMentions.length
? `⚠️ Some users cannot be added: ${missedMentions.join(
', '
)}.\nProbably they are not in the group or did not write anything (see /help for more info or contact us)`
: ''
if (!successIds.length) {
console.log(
'[add_to] No success',
ctx.message.chat.id,
field,
successIds,
successMentions,
missedMentions
)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionAddTo.emptyCreating',
})
const notCreated = `🚫 <strong>Mention did not created</strong>. Add someone from the group as initial members.`
await ctx
.reply(`${notCreated}\n${unsuccessStr}`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
const isSuccess = await this.mentionRepository.addUsersToMention(
ctx.message.chat.id,
field,
successIds
)
if (!isSuccess) {
console.log('[add_to] Paid limit', ctx.message.chat.id, field)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionAddTo.limitsReached',
})
const inlineKeyboard = [[this.BUY_MENTIONS_BUTTON]]
await ctx
.reply(
`🚫 You have been reached a Free limit.
Need more? Try removing useless mentions using the /mention and /delete_mention commands.
<strong>Or you can buy in our store, this is an unlimited quantity, no subscriptions.</strong>
`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: inlineKeyboard,
},
}
)
.catch(this.handleSendMessageError)
return
}
const successStr = `➕ In mention <strong>${field}</strong> added: ${successMentions.join(
', '
)}
✅ Now you can call them <code>@${field}</code>`
console.log('[add_to] Created', ctx.message.chat.id, field)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionAddTo.added',
})
await ctx
.reply(`${successStr}\n\n${unsuccessStr}`, {
disable_notification: true,
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
})
}
private registerDeleteFromMentionCommand(): void {
this.bot.command('delete_from', async (ctx) => {
if (!isChatGroup(ctx.message.chat.id)) {
await ctx
.reply(`👥 Only available in groups`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
let field = ctx.message.text.split(' ')[1]
if (field) {
field = field.startsWith('@') ? field.slice(1) : field
}
if (!field) {
console.log('[delete_from] Empty mention', ctx.message.text)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDeleteFrom.emptyMention',
})
await ctx
.reply(EMPTY_DELETE_FROM_MENTION_TEXT, { parse_mode: 'HTML' })
.catch(this.handleSendMessageError)
return
}
const isAllowed = await this.onlyAdminActionGuard(
ctx,
'crudCustomMention'
)
if (!isAllowed) return
const mentions = getMentionsFromEntities(
ctx.message.text,
ctx.message.entities
)
const reversedUsers =
await this.userRepository.getUsersIdsByUsernamesInChat(
ctx.message.chat.id
)
const result = matchMentionsToUsers(mentions, reversedUsers)
const wasRemovedMention =
await this.mentionRepository.deleteUsersFromMention(
ctx.message.chat.id,
field,
result.successIds
)
if (result.successMentions.length) {
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDeleteFrom.edited',
})
const deletedStr = `✅ Mention <strong>${field}</strong> successfully edited`
await ctx
.reply(deletedStr, {
disable_notification: true,
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
console.log(
'[delete_from] Delete from mention',
field,
result.successIds.length,
ctx.message.chat.id
)
if (wasRemovedMention) {
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDeleteFrom.emptyMentionCleaned',
})
console.log('[delete_from] Clean mention', field, ctx.message.chat.id)
await ctx
.reply(CLEAN_UP_EMPTY_MENTION_TEXT, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
}
return
}
console.log('[delete_from] Problem', field, ctx.message.chat.id, result)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDeleteFrom.problems',
})
await ctx
.reply(
`⚠️ Looks like something wrong with mention, or it is already deleted.
Contact us via support chat from /help`,
{
disable_notification: true,
parse_mode: 'HTML',
}
)
.catch(this.handleSendMessageError)
})
}
private registerDeleteMentionCommand(): void {
this.bot.command('delete_mention', async (ctx) => {
if (!isChatGroup(ctx.message.chat.id)) {
await ctx
.reply(`👥 Only available in groups`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
let field = ctx.message.text.split(' ')[1]
if (field) {
field = field.startsWith('@') ? field.slice(1) : field
}
if (!field) {
console.log('[delete_mention] Empty mention', ctx.message.text)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDelete.emptyMention',
})
await ctx
.reply(EMPTY_DELETE_MENTION_TEXT, { parse_mode: 'HTML' })
.catch(this.handleSendMessageError)
return
}
const isAllowed = await this.onlyAdminActionGuard(
ctx,
'crudCustomMention'
)
if (!isAllowed) return
const wasDeleted = await this.mentionRepository.deleteMention(
ctx.message.chat.id,
field
)
if (wasDeleted) {
console.log(
'[delete_mention] Deleted mention',
ctx.message.chat.id,
field
)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDelete.deleted',
})
await ctx
.reply(`🗑 Mention <strong>${field}</strong> successfully deleted`, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
console.log('[delete_mention] Not deleted', ctx.message.chat.id, field)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.message.chat.id.toString(),
action: 'mentionDelete.noMention',
})
await ctx
.reply(
`🤷‍♂️ There is no mentions with that pattern. Try again or see all your mentions via /mention`,
{
parse_mode: 'HTML',
}
)
.catch(this.handleSendMessageError)
})
}
private registerBuyCommandAndActions(): void {
const sendInvoice = async (
ctx: UniversalMessageOrActionUpdateCtx
): Promise<void> => {
if (!ctx.chat?.id) return
const hasUnlimited = this.paymentsRepository.getHasGroupUnlimited(
ctx.chat.id
)
if (hasUnlimited) {
console.log('[buy] Already unlimited', ctx.chat.id)
await ctx
.sendMessage(ALREADY_UNLIMITED, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
console.log('[buy] Start buying', ctx.chat.id)
this.metricsService.commandsCounter.inc({
chatId: ctx.chat.id.toString(),
command: 'buy',
})
await ctx
.sendInvoice({
...BASE_INVOICE,
payload: this.paymentsRepository.getPayloadForInvoice(ctx.chat.id),
})
.catch(this.handleSendMessageError)
}
this.bot.command('buy', async (ctx) => {
await sendInvoice(ctx)
})
this.bot.action('/buy', async (ctx) => {
await sendInvoice(ctx)
})
this.bot.on('pre_checkout_query', async (ctx) => {
console.log('[buy] pre_checkout_query', ctx.from)
await ctx.answerPreCheckoutQuery(true).catch(this.handleSendMessageError)
})
this.bot.on('successful_payment', async (ctx) => {
const payload = ctx.update.message.successful_payment.invoice_payload
const chatId =
this.paymentsRepository.getParsedChatFromInvoicePayload(payload)
if (!chatId) {
console.error(
'[buy] Something wrong with parsed data: ',
ctx.chat.id,
ctx.update.message.successful_payment
)
return
}
console.log('[buy] successful_payment', ctx.chat.id)
this.metricsService.commandsCounter.inc({
chatId: ctx.chat.id.toString(),
command: 'successful_payment',
})
await this.paymentsRepository.setGroupLimit(chatId, 'unlimited')
await ctx
.sendMessage(ALREADY_UNLIMITED, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
})
}
private registerHelpCommand(): void {
this.bot.command('help', async (ctx) => {
console.log('[HELP] Send help info')
this.metricsService.commandsCounter.inc({
chatId: ctx.chat.id.toString(),
command: 'help',
})
await ctx
.reply(HELP_COMMAND_TEXT, {
reply_to_message_id: ctx.message.message_id,
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[this.EXAMPLES_BUTTON]],
},
})
.catch(this.handleSendMessageError)
})
}
private registerHandleMessage(): void {
this.bot.on(message('text'), async (ctx) => {
const {
message: { from, text },
chat: { id: chatId },
} = ctx
const introduceBtn = {
callback: '/intro_custom_mentions',
text: '⚡ Introduce @custom_mentions!',
}
const reply_markup = {
inline_keyboard: [
[
{
callback_data: introduceBtn.callback,
text: introduceBtn.text,
},
],
],
}
const START_TIME = Date.now()
if (!isChatGroup(chatId)) {
await this.handleDirectMessage(
ctx,
text,
ctx.message.text === '/start'
).catch(this.handleSendMessageError)
return
}
await this.userRepository.addUsers(chatId, [from])
const customMention = this.mentionRepository.getMentionForMsg(
chatId,
text
)
if (customMention) {
await this.sendCustomMention(ctx, customMention, 'message')
return
}
const isCallAll = this.MENTION_COMMANDS.some((command) =>
text.includes(command)
)
if (!isCallAll) return
const isAllowed = await this.onlyAdminActionGuard(ctx, 'useAllMention')
if (!isAllowed) return
const chatUsernames = await this.userRepository.getUsernamesByChatId(
chatId
)
const usernames = Object.values(chatUsernames).filter(
(username) => username !== from.username
)
if (!usernames.length) {
console.log('[ALL] Noone to mention', ctx.message.chat.id)
await ctx
.reply(
`🙈 It seems there is no one else here.
Someone should write something (read more /help).
<strong>You also can use our new feature!</strong>
`,
{
reply_to_message_id: ctx.message.message_id,
parse_mode: 'HTML',
reply_markup,
}
)
.catch(this.handleSendMessageError)
return
}
if (this.activeQuery.has(chatId)) {
console.log('[ALL] Block spam', chatId)
return
}
console.log(`[ALL] Start mention`, usernames.length, chatId)
this.activeQuery.add(chatId)
const includePay = usernames.length >= this.INCLUDE_PAY_LIMIT // Large group members count
await this.mentionPeople(ctx, usernames, {
includePay,
includePromo: introduceBtn,
afterMessageText: `
\n💡 Try also: <code>@all</code> or <code>@your_custom_mention</code> inside your message`,
}).catch(this.handleSendMessageError)
const END_TIME = Date.now()
const EXECUTE_TIME = END_TIME - START_TIME
console.log(
`[ALL] Mention with pattern in group for ${usernames.length} people, TIME=${EXECUTE_TIME}, includePay=${includePay}`,
chatId
)
this.metricsService.replyCounter.inc({
chatId: chatId.toString(),
withPayments: includePay ? 'true' : 'false',
})
this.metricsService.replyUsersCountHistogram.observe(usernames.length)
this.metricsService.replyUsersTimeHistogram.observe(EXECUTE_TIME)
})
}
private async mentionPeople(
ctx: UniversalMessageOrActionUpdateCtx,
usernames: string[],
options: {
includePay: boolean
includeField?: string
overrideReplyMessageId?: number
includePromo?: {
text: string
callback: string
}
afterMessageText?: string
}
): Promise<void> {
const chatId = ctx.chat?.id
if (!chatId) return
const messageId = options.overrideReplyMessageId ?? ctx.message?.message_id
const prefix = options.includeField ? `${options.includeField}: ` : ''
const promises = new Array<Promise<unknown>>()
const chunkSize = 5 // Telegram limitations for mentions per message
const chunksCount = 19 // Telegram limitations for messages
const brokenUsers = new Array<string>()
for (let i = 0; i < usernames.length; i += chunkSize) {
const chunk = usernames.slice(i, i + chunkSize)
const isLastMessage = i >= usernames.length - chunkSize
const emoji =
this.EMOJI_SET[Math.floor(Math.random() * this.EMOJI_SET.length)] ??
'🔊'
const str =
`${emoji} ${prefix}` +
chunk
.map((username) =>
username.startsWith('@') ? username : `@${username}`
)
.join(', ')
if (!isLastMessage) {
if (promises.length >= chunksCount) {
brokenUsers.push(...chunk)
continue
}
const execute = async (): Promise<unknown> => {
try {
const sendingResult = await ctx.sendMessage(str, {
parse_mode: 'HTML',
})
return sendingResult
} catch (error) {
const response:
| undefined
| { error_code: number; parameters: { retry_after: number } } = (
error as any
).response
if (response?.error_code === 429) {
console.log(
'[ALL] Error with timeout=',
response.parameters.retry_after
)
return new Promise((resolve) => {
setTimeout(async () => {
try {
await ctx.sendMessage(str, {
parse_mode: 'HTML',
})
resolve(null)
} catch (error) {
console.log('[ALL] Add users to broken')
brokenUsers.push(...chunk)
resolve(null)
}
}, (response.parameters.retry_after + 0.2) * 1000)
})
}
console.log(error)
return Promise.resolve()
}
}
promises.push(execute())
} else {
await Promise.all(
promises.map((promise) => promise.catch(this.handleSendMessageError))
).catch(this.handleSendMessageError)
const sendLastMsg = async (withReply = true): Promise<null> => {
return new Promise(async (resolve) => {
console.log('[ALL] Broken users:', brokenUsers.length)
let lastStr = str
/**
* Max Telegram message length - so we need to split this message by chunks
*/
const MAX_LENGTH = 4096
if (brokenUsers.length) {
lastStr =
lastStr +
', ' +
brokenUsers.map((username) => `@${username}`).join(', ')
}
if (options.afterMessageText) {
lastStr = `${lastStr} ${options.afterMessageText}`
}
const buttons: InlineKeyboardButton[] = []
if (options.includePay) {
buttons.push(this.BUY_MENTIONS_BUTTON)
}
if (options.includePromo) {
buttons.push({
text: options.includePromo.text,
callback_data: options.includePromo.callback,
})
}
const inlineKeyboard: InlineKeyboardMarkup['inline_keyboard'] = [
buttons,
]
try {
let arr: string[] = []
let currentStr = lastStr
while (currentStr.length >= MAX_LENGTH) {
const firstPart = currentStr.slice(0, 4000)
currentStr = currentStr.slice(4000)
arr.push(firstPart)
}
const promises: Promise<unknown>[] = []
arr.map((str) => {
return ctx.reply(str, {
reply_to_message_id: withReply ? messageId : undefined,
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: inlineKeyboard,
},
})
})
await Promise.all(promises)
resolve(null)
} catch (error) {
const response:
| undefined
| {
error_code: number
parameters: { retry_after: number }
description: string
} = (error as any).response
if (
response?.error_code === 400 &&
response.description ===
'Bad Request: message to reply not found'
) {
console.log(
'[ALL] Retry because can not reply to not found msg',
response
)
await sendLastMsg(false)
return resolve(null)
}
if (response?.error_code !== 429) {
console.error(error)
return resolve(null)
}
console.log(
'[ALL] Retry last msg',
response.parameters.retry_after
)
setTimeout(() => {
sendLastMsg(withReply)
}, (response.parameters.retry_after + 0.2) * 1000)
} finally {
this.activeQuery.delete(chatId)
}
})
}
await sendLastMsg().catch(this.handleSendMessageError)
}
}
}
private handleAddMembers(chatId: Chat['id'], users: User[]): Promise<void> {
return this.userRepository.addUsers(chatId, users)
}
private handleDelete(chatId: Chat['id'], user: User): Promise<void> {
return this.userRepository.deleteUser(chatId, user.id)
}
private handleSendMessageError(error: unknown, prefix = ''): void {
console.error(prefix, error)
}
private async sendCustomMention(
ctx: UniversalMessageOrActionUpdateCtx,
field: string,
source: 'message' | 'command' | 'action'
): Promise<void> {
if (!ctx.chat?.id) return
const isAllowed = await this.onlyAdminActionGuard(ctx, 'useCustomMention')
if (!isAllowed) return
const ids = await this.mentionRepository.getUsersIdsByMention(
ctx.chat.id,
field
)
const users = await this.userRepository.getUsersUsernamesByIdInChat(
ctx.chat.id
)
const idsToDelete: string[] = []
const usernamesToMention = ids.reduce<string[]>((acc, id) => {
const username = users[id]
if (!username) {
idsToDelete.push(id)
return acc
}
acc.push(username)
return acc
}, [])
if (idsToDelete.length) {
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.chat.id.toString(),
action: 'mention.deleteBrokeUsers',
})
this.mentionRepository.deleteUsersFromMention(
ctx.chat.id,
field,
idsToDelete
)
console.log('[mention] Clean up', ctx.chat.id, field, idsToDelete.length)
}
if (!usernamesToMention.length) {
console.log(
'[mention] Empty usernames to mention',
ctx.chat.id,
field,
usernamesToMention.length
)
this.metricsService.customMentionsActionCounter.inc({
chatId: ctx.chat.id.toString(),
action: 'mention.deleteMention',
})
await this.mentionRepository.deleteMention(ctx.chat.id, field)
await ctx
.reply(CLEAN_UP_EMPTY_MENTION_TEXT, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return
}
this.metricsService.customMentionsCounter.inc({
chatId: ctx.chat.id.toString(),
source,
})
console.log(
'[mention] Mention',
ctx.chat.id,
field,
usernamesToMention.length
)
let fieldWithMentioner = `<code>@${field}</code>`
if (ctx.from) {
fieldWithMentioner += ` from <a href="tg://user?id=${ctx.from.id}">${ctx.from.username}</a>`
}
await this.mentionPeople(ctx, usernamesToMention, {
includePay: usernamesToMention.length >= this.INCLUDE_PAY_LIMIT,
includeField: fieldWithMentioner,
}).catch(this.handleSendMessageError)
}
private async onlyAdminActionGuard(
ctx: UniversalMessageOrActionUpdateCtx,
action: SettingsAction
): Promise<boolean> {
if (!ctx.chat?.id) return true
const settings = await this.settingsRepository.getSettings(ctx.chat?.id)
const isActionOnlyForAdmin = settings[action]
const isAllowed =
!isActionOnlyForAdmin ||
(await this.getIsAllowed(ctx.chat?.id, ctx.from?.id))
console.log(
`[admin_guard] Check ${isAllowed} for ${action} admin ${ctx.chat.id} ${ctx.from?.id}`
)
if (isAllowed) return true
this.metricsService.restrictedAction.inc({
chatId: ctx.chat.id,
action,
})
await ctx
.reply(ONLY_ADMIN_ACTION_TEXT, {
parse_mode: 'HTML',
})
.catch(this.handleSendMessageError)
return false
}
private async getKeyboardWithCustomMentions(
chatId: Chat['id']
): Promise<InlineKeyboardMarkup | undefined> {
const data = await this.mentionRepository.getGroupMentions(chatId)
const entries = Object.entries(data)
if (!entries.length) {
return undefined
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [],
}
entries.forEach(([key, value]) => {
keyboard.inline_keyboard.push([
{
callback_data: `/mention-${key}`,
text: `${key}: ${value} member(s)`,
},
])
})
return keyboard
}
private async getIsAllowed(
chatId: Chat['id'],
userId: User['id'] | undefined
): Promise<boolean> {
if (!userId) return true
const member = await this.bot.telegram.getChatMember(chatId, userId)
const allowed = [
'administrator',
'creator', // disable while debug
]
console.log(`[is_allowed] Check ${member.status} for user ${userId}`)
return allowed.includes(member.status)
}
private async handleDirectMessage(
ctx: NarrowedContext<Context, Update.MessageUpdate>,
text: string,
isStart: boolean
): Promise<void> {
const {
message: { from },
} = ctx
console.log(
`[DIRECT_MSG] Direct message from ${text}`,
from.username,
isStart
)
await ctx.reply(
`👋 Hi!
🤖 This is a bot to improve your chatting experience, just like Slack or other team messengers.
1. You can mention /all (or tag <code>@all</code>) chat participants with one command.
2. Also you can create your own custom mentions and use them. For example <code>Hello, @frontend_dev</code>
❗️ But remember that I work only in <strong>groups</strong> and <strong>super groups</strong> (with additional permissions)
💫 <strong>Add me to your group and let the magic begin!</strong>`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [isStart ? [] : [this.EXAMPLES_BUTTON]],
},
}
)
if (!isStart) return
this.metricsService.commandsCounter.inc({
command: '/start',
})
await this.sendExamples(ctx)
}
private async sendExamples(
ctx: UniversalMessageOrActionUpdateCtx
): Promise<void> {
this.metricsService.commandsCounter.inc({
command: 'examples',
})
await ctx.reply(`👇 Below are some examples for you:`, {
parse_mode: 'HTML',
})
const ctxV3 = await ctx.reply('Hello @all')
const names = ['@bill', '@ivan', '@kate']
await this.mentionPeople(ctx, names, {
includePay: false,
overrideReplyMessageId: ctxV3.message_id,
})
const field = 'friends'
await ctx.reply(`/add_to ${field} @bill @ivan @kate`, {
parse_mode: 'HTML',
})
const successStr = `➕ In mention <strong>${field}</strong> added: ${names.join(
', '
)}
✅ Now you can call them <code>@${field}</code>`
await ctx.reply(`${successStr}`, {
disable_notification: true,
parse_mode: 'HTML',
})
let fieldWithMentioner = `<code>@${field}</code>`
if (ctx.from) {
fieldWithMentioner += ` from <a href="tg://user?id=${ctx.from.id}">${ctx.from.username}</a>`
}
const ctxV2 = await ctx.reply(`Hello @${field}`)
await this.mentionPeople(ctx, names, {
includePay: false,
includeField: fieldWithMentioner,
overrideReplyMessageId: ctxV2.message_id,
})
}
}