Привет, в прошлой статье мы пытались заинтегрировать слак приложение с хантфлоу, сейчас попробуем написать самостоятельное слак приложение со своей бд.
Цель у бота
Идея приложения была в рассылке новостей некоторым участникам слак пространства, которые смогли бы ответить на эту рассылку.
Самое простое решение, которое мы придумали, заключалось в создании новой слак учетки(условно аккаунт поддержки куда можно задавать свои вопросы). Плюс мы добавили слак приложение, которое будет от имени этой учетки делать рассылку. Далее, мы добавляем админку, в которой определяем какие пользователи могут делать рассылку и каким пользователям эта рассылка должна приходить.
Технические уточнения
Так как моя основная деятельность это фронтенд я решил писать бэк на nodejs, взял фреймворк NestJS, а в качестве бд использовал Postgres.
NestJS (стартуем)
Давайте начнем с создания Nest приложения. Я не буду описывать как это сделать, это написано на сайте NestJS.
Первое что мы сделаем это добавим сервис для работы с Postgres. В качестве либы, которая будет ходить в бд, я решил попробовать Prisma. Посмотреть как соединить Призму и Нест можно тут.
Создание таблиц
Пишем запросы на создание таблиц в Postgres
CREATE TABLE slack_user (
"id" serial PRIMARY KEY,
"username" VARCHAR ( 50 ) UNIQUE NOT NULL,
"is_active" boolean DEFAULT FALSE
);
CREATE TABLE slack_admin_user (
"id" serial PRIMARY KEY,
"username" VARCHAR ( 50 ) UNIQUE NOT NULL,
"is_active" boolean DEFAULT FALSE
);
Как я уже описал выше, нам нужно понимать кто может отправлять рассылку и кто должен получить сообщение с рассылкой.
Я решил разделить пользователей на 2 таблички.
И сразу же напишем 2 призма модели в файл prisma/schema.prisma:
// то что призма генерит сама
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// то что добавляем мы
model slack_user {
id Int @default(autoincrement()) @id
username String?
is_active Boolean @default(false)
}
model slack_admin_user {
id Int @default(autoincrement()) @id
username String?
is_active Boolean @default(false)
}
Нам осталось только выполнить:
$ primsa generate
Вернемся к Nest
Первое что я сделал, это добавил общий модуль для работы с пользователями, мне нужен был сервис который умеет работать как с админами так и с просто пользователями. Я написал один базовый сервис и через provide сделал два экспортируемых сервиса. Давайте посмотрим код.
Код модуля:
const providers = [
{
provide: 'SLACK_USERS',
useFactory: (prisma: PrismaService) =>
new SlackBaseUsersService('slack_user', prisma),
inject: [PrismaService],
},
{
provide: 'SLACK_ADMIN_USERS',
useFactory: (prisma: PrismaService) =>
new SlackBaseUsersService('slack_admin_user', prisma),
inject: [PrismaService],
},
];
@Module({
imports: [PrismaModule],
providers: [...providers],
exports: [...providers],
})
export class SlackBaseUsersModule {}
Код сервиса:
@Injectable()
export class SlackBaseUsersService<T extends 'slack_admin_user' | 'slack_user'> {
constructor(protected userTable: T, private prismaService: PrismaService) {}
loadActive() {
return this.prismaService[this.userTable].findMany({
where: { is_active: true },
});
}
findOneByUsername(username: string) {
return this.prismaService[this.userTable].findFirst({
where: { username },
});
}
load() {
return this.prismaService[this.userTable].findMany();
}
create(data: { username: string; is_active: boolean }) {
return this.prismaService[this.userTable].create({ data });
}
update(userId: number, data: { is_active: boolean }) {
return this.prismaService[this.userTable].update({
where: { id: userId },
data,
});
}
delete(userId: number) {
return this.prismaService[this.userTable].delete({ where: { id: userId } });
}
}
Отлично какой-то код мы написали, теперь предлагаю отвлечься на слак.
Слак приложение
Создаем слак приложение
Создать слак приложение можно на сайте Slack Api.
Рассылка
Окей, мы создали пустое слак приложение, теперь нужно придумать как и откуда мы будем делать отправку. Мы решили делать отправку рассылок через модальное окно в слаке.
Заходим в настройки нашего приложения и в левом меню переходим на вкладку Interactivity & Shortcuts и включаем свитчер на Interactivity.
Далее в разделе Shortcuts создаем новый shortcut, нам нужно задать и запомнить callback_id, это нам понадобится при обработке этого shortcut'а на сервере. Request URL пока не заполняем.
По урлу я накидал то как должна выглядеть модалка, мне хватило одного поля textarea.
Права Slack приложения
Пока не закрыли окно со слаком, давайте сразу дадим необходимые права для отправки сообщений нашему приложению. Нас интересует метод chat.postMessage
поэтому в админке Slack App, на вкладке OAuth & Permissions выставляем нужные права User Token Scopes.
Обработка shortcut’ов в NestJS
В несте создаем новый модуль и добавляем контроллер который будет обрабатывать наши shortcut’ы:
@Controller('slack')
export class SlackController {
constructor(private slackService: SlackService) {}
@Post('/shortcuts')
onShortcut(@Body() body) {
return new Promise((resolve, reject) => {
try {
const payload = JSON.parse(body.payload);
resolve(payload);
} catch (e) {
reject(e);
}
}).then((json) => {
return this.slackService.onShortcut(json);
});
}
}
а так же сервис:
@Injectable()
export class SlackService {
constructor(
@Inject('SLACK_ADMIN_USERS')
private usersAdminService: SlackBaseUsersService<'slack_admin_user'>,
@Inject('SLACK_USERS')
private usersService: SlackBaseUsersService<'slack_user'>,
private configService: ConfigService<AppConfig>,
) {}
private get keys() {
return this.configService.get<AppConfig['keys']>('keys');
}
private botBot = new WebClient(this.keys.slackBotKey);
private userBot = new WebClient(this.keys.slackUserBotKey);
async onShortcut(payload) {
if (
payload.type === 'view_submission' &&
payload.view.callback_id === 'sendPostModal'
) {
return this.sendPostsToUsers(
payload.view.state.values.post.ml_input.value,
);
}
const admin = await this.usersAdminService.findOneByUsername(
payload.user.id,
);
if (!admin) {
throw new ForbiddenException();
}
if (payload.type === 'shortcut' && payload.callback_id === 'sendPost') {
return this.openCreatePostView(payload.trigger_id);
}
return payload.challenge;
}
private openCreatePostView(trigger_id) {
return this.botBot.views.open({
trigger_id,
view: jsonView as View,
});
}
private openAminModalViews(trigger_id, username) {
return this.generateUrl(username).then((url) => {
return this.botBot.views.open({
trigger_id,
view: openAdminModalViewCreator(url),
});
});
}
private async sendPostsToUsers(message: string) {
const users = await this.usersService.loadActive();
users.forEach((user) =>
this.userBot.chat
.postMessage({
channel: user.username,
text: message,
})
.catch((err) => {
console.error(err);
}),
);
}
}
Отлично. Теперь в тут добавляем ссылку на наш метод:
Отлично. Теперь у нас в shortcut’ах есть рабочая команда, вызвать меню можно в любом чате при вводе /.
Админка
На админке не хотелось бы особо останавливаться, так как мы сделали обычное фронтовое приложение на реакте с 2 табличками “Список админов” и “Список пользователей, кому отправляем рассылку”. Но удобными решениями поделюсь в краткой форме.
Авторизация
С авторизацией мы не стали заморачиваться, а положились на слак. Мы добавили еще один shortcut. При его вызове мы смотрим, кто вызвал и, если username вызвавшего есть в таблице админов, мы открываем модалку ссылкой на админку с токеном(который живет 15 минут).
Добавление пользователей
Для добавления пользователей мы воспользовались еще одним слаковским методом.
Нам он понадобился для более удобного добавления пользователей из выпадающего списка:
Финал
На этом хотелось бы закончить. Надеюсь, вам помогут мои наработки для написания своего слак бота и вы попробуете такие фичи как открытие модалок и shortcut’ы.