Skip to content

Commit 207fbc4

Browse files
committed
feat(decorators): Add various command validation and user management decorators
- Implemented `BotIsAdmin` to ensure the bot is an admin before executing commands. - Added `Catch` decorator for custom error handling, logging specific errors like "bot was kicked". - Created `RestrictToGroupChats` to restrict commands to group chats, with appropriate feedback for invalid chat types. - Implemented `MessageValidator` to handle user validation, blacklisted words, and group leaving scenarios. - Added `RequireReply` to enforce commands to be replies to other messages. - Implemented `SaveUserData` to save user data in the database for both private and group chats. - Created `EnsureUserAndGroup` to ensure user and group data is saved and updated in the database. - Developed `OnlyAdminsCanUse` to restrict commands to only admins. These decorators improve user experience by handling various validation, error handling, and data management tasks in a more modular way.
1 parent 6cf67bd commit 207fbc4

6 files changed

Lines changed: 224 additions & 31 deletions

File tree

src/decorators/Bot.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { BotReply } from '../utils/chat/BotReply';
2+
import { BotInfo } from '../utils/chat/BotInfo';
3+
import { createDecorator } from '.';
4+
5+
export function BotIsAdmin() {
6+
return createDecorator(async (ctx, next, close) => {
7+
const reply = new BotReply(ctx);
8+
const bot = new BotInfo(ctx);
9+
const botIsAdmin = await bot.isAdmin();
10+
if (!botIsAdmin) {
11+
await reply.textReply('I need to be an administrator to execute this command.');
12+
close();
13+
}
14+
await next();
15+
});
16+
}

src/decorators/Catch.ts

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
1-
import { Context, GrammyError } from 'grammy';
1+
import { GrammyError } from 'grammy';
22
import { ErrorResponse } from '../types/ResponseTypes';
33
import logger from '../utils/logger';
4+
import { createDecorator } from './index';
45
import { ServiceProvider } from '../service/database/ServiceProvider';
56
export function Catch(customResponse?: ErrorResponse) {
6-
return function (target: any, propKey: string, descriptor: PropertyDescriptor) {
7-
const originalMethod = descriptor.value;
8-
descriptor.value = async function (...args: any[]) {
9-
const ctx: Context = (this as any)?.ctx || args[0];
10-
try {
11-
return await originalMethod.apply(this, args);
12-
} catch (error: any) {
13-
if (error instanceof GrammyError && error.error_code === 400 && error.description === 'Bad Request: message to be replied not found') {
14-
console.warn(`Message not found to reply to. Skipping...`);
15-
return;
16-
}
17-
if (error.error_code === 403 && error.description.includes('bot was kicked')) {
18-
const chatId = error.payload?.chat_id;
19-
logger.warn(`[Warning] Bot was kicked from a group (chat_id: ${error.payload.chat_id}). Skipping.`);
20-
const service = ServiceProvider.getInstance();
21-
const groupService = await service.getGroupService();
22-
await groupService.delete(chatId);
23-
return;
24-
}
25-
// Custom response or default error message
26-
const errorResponse: ErrorResponse = customResponse || {
27-
message: error.message,
28-
statusCode: error.statusCode || 500,
29-
category: 'General',
30-
};
31-
logger.error(`[Category: ${errorResponse.category}] Error in ${target.constructor.name}.${propKey}(): ${error.message}`);
7+
return createDecorator(async (ctx, next) => {
8+
try {
9+
await next();
10+
} catch (error: any) {
11+
if (error instanceof GrammyError && error.error_code === 400 && error.description === 'Bad Request: message to be replied not found') {
12+
console.warn(`Message not found to reply to. Skipping...`);
13+
return;
3214
}
33-
};
34-
35-
return descriptor;
36-
};
15+
if (error.error_code === 403 && error.description.includes('bot was kicked')) {
16+
const chatId = error.payload?.chat_id;
17+
logger.warn(`[Warning] Bot was kicked from a group (chat_id: ${error.payload.chat_id}). Skipping.`);
18+
const service = ServiceProvider.getInstance();
19+
const groupService = await service.getGroupService();
20+
await groupService.delete(chatId);
21+
return;
22+
}
23+
// Custom response or default error message
24+
const errorResponse: ErrorResponse = customResponse || {
25+
message: error.message,
26+
statusCode: error.statusCode || 500,
27+
category: 'General',
28+
};
29+
logger.error(`[Category: ${errorResponse.category}] : ${error.message}`);
30+
}
31+
});
3732
}

src/decorators/Context.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BotReply } from '../utils/chat/BotReply';
2+
import { MessagesService } from '../service/messages';
3+
import { createDecorator } from '.';
4+
/**
5+
* A decorator to restrict commands to group chats.
6+
* Provides user feedback for invalid chat types.
7+
*/
8+
export function RestrictToGroupChats() {
9+
return createDecorator(async (ctx, next, close) => {
10+
const chat = ctx.chat!;
11+
const reply = new BotReply(ctx);
12+
13+
try {
14+
if (chat.type === 'supergroup' || chat.type === 'group') {
15+
return await next();
16+
}
17+
18+
if (chat.type === 'private') {
19+
await reply.textReply('This command can only be used in group chats.');
20+
close();
21+
} else if (chat.type === 'channel') {
22+
await reply.textReply('This command cannot be used in channels.');
23+
close();
24+
} else {
25+
await reply.textReply('This command is not supported in this type of chat.');
26+
close();
27+
}
28+
} catch (error) {
29+
console.error('Error in RestrictToGroupChats decorator:', error);
30+
await reply.textReply('An unexpected error occurred. Please try again later.');
31+
close();
32+
}
33+
});
34+
}
35+
export function MessageValidator() {
36+
return createDecorator(async (ctx, next) => {
37+
const messageService = new MessagesService(ctx);
38+
await Promise.all([messageService.isNewUser(), messageService.isCode(), messageService.checkAndHandleBlacklistedWords(), messageService.userIsLeftGroup()]);
39+
next();
40+
});
41+
}
42+
/**
43+
* A decorator to ensure the command is being triggered as a reply to another message.
44+
* Provides user feedback if the command is not triggered as a reply.
45+
*/
46+
export function RequireReply() {
47+
return createDecorator(async (ctx, next, close) => {
48+
const reply = new BotReply(ctx);
49+
let replyMessage = ctx.message?.reply_to_message;
50+
if (ctx.message?.reply_to_message?.forum_topic_created) {
51+
replyMessage = undefined;
52+
}
53+
try {
54+
if (!replyMessage) {
55+
await reply.textReply('This command must be a reply to another message.');
56+
close();
57+
return;
58+
}
59+
60+
await next();
61+
} catch (error) {
62+
console.error('Error in RequireReply decorator:', error);
63+
await reply.textReply('An unexpected error occurred. Please try again later.');
64+
close();
65+
}
66+
});
67+
}

src/decorators/Database.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ServiceProvider } from '../service/database/ServiceProvider';
2+
import { createDecorator } from '.';
3+
import { Context } from 'grammy';
4+
5+
export function SaveUserData() {
6+
return createDecorator(async (ctx, next) => {
7+
const databaseService = ServiceProvider.getInstance();
8+
const [userService, groupService] = await Promise.all([databaseService.getUserService(), databaseService.getGroupService()]);
9+
10+
const userData = {
11+
first_name: ctx.from?.first_name!,
12+
id: ctx.from?.id!,
13+
username: ctx.from?.username!,
14+
};
15+
if (ctx.chat!.type === 'group' || ctx.chat!.type === 'supergroup') {
16+
await userService.save(userData);
17+
await groupService.save(ctx);
18+
await groupService.updateMembers(ctx.chat!.id, ctx.from?.id!, ctx);
19+
} else {
20+
await userService.save(userData);
21+
}
22+
await next();
23+
});
24+
}
25+
export function EnsureUserAndGroup(userSource: 'from' | 'reply' = 'reply') {
26+
return createDecorator(async (ctx: Context, next, close) => {
27+
try {
28+
const service = ServiceProvider.getInstance();
29+
const userService = await service.getUserService();
30+
const groupService = await service.getGroupService();
31+
const userContext = userSource === 'reply' && ctx.message?.reply_to_message?.from && !ctx.message.reply_to_message.forum_topic_created ? ctx.message.reply_to_message.from : ctx.from;
32+
const userId = userContext?.id;
33+
const groupId = ctx.chat?.id;
34+
if (!userId || !groupId) {
35+
console.error('User ID or Group ID is missing.');
36+
close();
37+
return;
38+
}
39+
const userData = {
40+
first_name: userContext!.first_name!,
41+
id: userId,
42+
username: userContext!.username!,
43+
};
44+
let user = await userService.getByTelegramId(userId);
45+
if (!user) {
46+
user = await userService.save(userData);
47+
}
48+
let group = await groupService.getByGroupId(groupId);
49+
if (!group) {
50+
group = await groupService.save(ctx);
51+
}
52+
await next();
53+
} catch (error) {
54+
console.error('Error in EnsureUserAndGroup decorator:', error);
55+
close();
56+
}
57+
});
58+
}

src/decorators/User.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { BotReply } from '../utils/chat/BotReply';
2+
import { ChatInfo } from '../utils/chat/ChatInfo';
3+
import { createDecorator } from '.';
4+
5+
/**
6+
* A decorator that ensures only admins can execute the decorated method.
7+
* If the user is not an admin, a message is sent, and the execution is halted.
8+
*/
9+
export function OnlyAdminsCanUse() {
10+
return createDecorator(async (ctx, next, close) => {
11+
const reply = new BotReply(ctx);
12+
const chatInfo = new ChatInfo(ctx);
13+
14+
try {
15+
const userIsAdmin = await chatInfo.userIsAdmin();
16+
17+
if (!userIsAdmin) {
18+
await reply.textReply('Sorry, but you need to be an admin to use this command!');
19+
close();
20+
return;
21+
}
22+
return await next();
23+
} catch (error) {
24+
console.error('Error in OnlyAdminsCanUse decorator:', error);
25+
await reply.textReply('An unexpected error occurred. Please try again later.');
26+
close();
27+
}
28+
});
29+
}

src/decorators/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Context, MiddlewareFn } from 'grammy';
2+
3+
export function createDecorator(middleware: (ctx: Context, next: () => Promise<void>, close: () => void) => Promise<void>) {
4+
return function (_target: any, _propertyKey: string | symbol, descriptor: PropertyDescriptor) {
5+
const originalMethod = descriptor.value;
6+
7+
descriptor.value = async function (...args: any[]) {
8+
const ctx: Context = (this as any)?.ctx || args[0];
9+
let shouldContinue = true;
10+
const close = () => {
11+
shouldContinue = false;
12+
};
13+
await middleware(
14+
ctx,
15+
async () => {
16+
if (shouldContinue) {
17+
return await originalMethod.apply(this, [...args]);
18+
}
19+
},
20+
close
21+
);
22+
23+
return;
24+
};
25+
26+
return descriptor;
27+
};
28+
}

0 commit comments

Comments
 (0)