Skip to content

Commit d786e46

Browse files
committed
feature: 센트리 Slack 알림 기능 통합
1 parent 2009215 commit d786e46

File tree

14 files changed

+1453
-0
lines changed

14 files changed

+1453
-0
lines changed

src/controllers/slack.controller.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { NextFunction, Request, RequestHandler, Response } from 'express';
2+
import { SlackService } from '@/services/slack.service';
3+
import { SentryService } from '@/services/sentry.service';
4+
import logger from '@/configs/logger.config';
5+
import { PermissionCheckResponseDto, SlackSuccessResponseDto } from '@/types';
6+
import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type';
7+
import { getNewStatusFromAction } from '@/utils/sentry.util';
8+
9+
export class SlackController {
10+
constructor(
11+
private slackService: SlackService,
12+
private sentryService: SentryService,
13+
) {}
14+
15+
checkPermissions: RequestHandler = async (
16+
req: Request,
17+
res: Response<PermissionCheckResponseDto>,
18+
next: NextFunction,
19+
): Promise<void> => {
20+
try {
21+
const permissions = await this.slackService.checkPermissions();
22+
const response = new PermissionCheckResponseDto(true, 'Slack 권한 확인 완료', permissions, null);
23+
res.status(200).json(response);
24+
} catch (error) {
25+
logger.error('Slack 권한 확인 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
26+
next(error);
27+
}
28+
};
29+
30+
testBot: RequestHandler = async (
31+
req: Request,
32+
res: Response<SlackSuccessResponseDto>,
33+
next: NextFunction,
34+
): Promise<void> => {
35+
try {
36+
if (!this.slackService.hasBotToken() && !this.slackService.hasWebhookUrl()) {
37+
const response = new SlackSuccessResponseDto(
38+
false,
39+
'SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL 환경 변수가 설정되지 않았습니다.',
40+
{},
41+
'MISSING_SLACK_CONFIG'
42+
);
43+
res.status(400).json(response);
44+
return;
45+
}
46+
47+
const testMessage = {
48+
text: '🤖 봇 테스트 메시지입니다!',
49+
attachments: [
50+
{
51+
color: 'good',
52+
fields: [
53+
{
54+
title: '테스트 결과',
55+
value: '✅ Slack 연동이 정상적으로 작동합니다.',
56+
short: false,
57+
},
58+
],
59+
footer: `테스트 시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`,
60+
},
61+
],
62+
};
63+
64+
await this.slackService.sendMessage(testMessage);
65+
const response = new SlackSuccessResponseDto(true, '봇 테스트 메시지 전송 완료!', {}, null);
66+
res.status(200).json(response);
67+
} catch (error) {
68+
logger.error('봇 테스트 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
69+
next(error);
70+
}
71+
};
72+
73+
handleInteractive: RequestHandler = async (
74+
req: Request,
75+
res: Response,
76+
next: NextFunction,
77+
): Promise<void> => {
78+
try {
79+
const payload = JSON.parse(req.body.payload);
80+
81+
if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) {
82+
const action = payload.actions[0];
83+
84+
if (action.name === 'sentry_action') {
85+
const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':');
86+
87+
const actionData: SentryActionData = {
88+
action: actionType as SentryApiAction,
89+
issueId,
90+
organizationSlug,
91+
projectSlug,
92+
};
93+
94+
if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) {
95+
logger.info('Processing Sentry action:', actionData);
96+
97+
const result = await this.sentryService.handleIssueAction(actionData);
98+
99+
if (result.success) {
100+
const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {});
101+
res.json(updatedMessage);
102+
} else {
103+
const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {});
104+
res.json(errorMessage);
105+
}
106+
return;
107+
}
108+
}
109+
}
110+
111+
res.json({ text: '❌ 잘못된 요청입니다.' });
112+
} catch (error) {
113+
logger.error('Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
114+
next(error);
115+
}
116+
};
117+
118+
private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown {
119+
const { action } = actionData;
120+
121+
const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
122+
123+
if (updatedMessage.attachments && updatedMessage.attachments[0]) {
124+
const newStatus = getNewStatusFromAction(action);
125+
const statusColors = {
126+
'resolved': 'good',
127+
'ignored': 'warning',
128+
'archived': '#808080',
129+
'unresolved': 'danger',
130+
};
131+
132+
updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good';
133+
134+
const statusMapping = {
135+
'resolved': 'RESOLVED',
136+
'ignored': 'IGNORED',
137+
'archived': 'ARCHIVED',
138+
'unresolved': 'UNRESOLVED',
139+
};
140+
141+
const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase();
142+
updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`;
143+
144+
delete updatedMessage.attachments[0].actions;
145+
}
146+
147+
return updatedMessage;
148+
}
149+
150+
private createErrorMessage(error: string, originalMessage: unknown): unknown {
151+
const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
152+
153+
if (updatedMessage.attachments && updatedMessage.attachments[0]) {
154+
updatedMessage.attachments[0].fields.push({
155+
title: '❌ 오류 발생',
156+
value: error,
157+
short: false,
158+
});
159+
160+
updatedMessage.attachments[0].color = 'danger';
161+
}
162+
163+
return updatedMessage;
164+
}
165+
}

src/controllers/webhook.controller.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { NextFunction, Request, RequestHandler, Response } from 'express';
2+
import { SlackService } from '@/services/slack.service';
3+
import { SentryService } from '@/services/sentry.service';
4+
import { SentryWebhookData, SlackMessage } from '@/types';
5+
import logger from '@/configs/logger.config';
6+
import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util';
7+
8+
export class WebhookController {
9+
constructor(
10+
private slackService: SlackService,
11+
private sentryService: SentryService,
12+
) {}
13+
14+
handleSentryWebhook: RequestHandler = async (
15+
req: Request,
16+
res: Response,
17+
next: NextFunction,
18+
): Promise<void> => {
19+
try {
20+
const sentryData = req.body;
21+
22+
const slackMessage = await this.formatSentryDataForSlack(sentryData);
23+
24+
if (slackMessage === null) {
25+
logger.info('기존 메시지 업데이트 완료, 새 메시지 전송 생략');
26+
res.status(200).json({ message: 'Webhook processed successfully' });
27+
return;
28+
}
29+
30+
const issueId = sentryData.data?.issue?.id;
31+
await this.slackService.sendMessage(slackMessage, issueId);
32+
33+
res.status(200).json({ message: 'Webhook processed successfully' });
34+
} catch (error) {
35+
logger.error('Sentry webhook 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
36+
next(error);
37+
}
38+
};
39+
40+
private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise<SlackMessage | null> {
41+
const { action, data } = sentryData;
42+
43+
if (action === 'resolved' || action === 'unresolved' || action === 'ignored') {
44+
return await this.handleIssueStatusChange(sentryData);
45+
}
46+
47+
if (action === 'created' && data.issue) {
48+
return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
49+
}
50+
51+
return {
52+
text: `🔔 Sentry 이벤트: ${action || 'Unknown action'}`,
53+
attachments: [
54+
{
55+
color: 'warning',
56+
fields: [
57+
{
58+
title: '이벤트 타입',
59+
value: action || 'Unknown',
60+
short: true,
61+
},
62+
],
63+
},
64+
],
65+
};
66+
}
67+
68+
private async handleIssueStatusChange(sentryData: SentryWebhookData): Promise<SlackMessage | null> {
69+
const { data } = sentryData;
70+
const issue = data.issue;
71+
72+
if (!issue) {
73+
logger.warn('이슈 정보가 없습니다:', sentryData);
74+
return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
75+
}
76+
77+
logger.info(`이슈 상태 변경 감지: ${issue.id}${sentryData.action}`);
78+
79+
const messageInfo = this.slackService.getMessageInfo(issue.id);
80+
81+
if (messageInfo) {
82+
logger.info('기존 메시지 발견, 업데이트 시도');
83+
84+
try {
85+
const updatedMessage = createStatusUpdateMessage(
86+
sentryData,
87+
this.sentryService.hasSentryToken()
88+
);
89+
90+
await this.slackService.updateMessage(
91+
messageInfo.channel,
92+
messageInfo.ts,
93+
updatedMessage
94+
);
95+
96+
logger.info('기존 메시지 업데이트 완료');
97+
return null;
98+
99+
} catch (error) {
100+
logger.error('메시지 업데이트 실패, 새 메시지로 전송:', error instanceof Error ? error.message : '알 수 없는 오류');
101+
102+
}
103+
} else {
104+
logger.info('기존 메시지 없음, 새 메시지 생성');
105+
}
106+
107+
return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
108+
}
109+
}

src/routes/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import PostRouter from './post.router';
44
import NotiRouter from './noti.router';
55
import LeaderboardRouter from './leaderboard.router';
66
import TotalStatsRouter from './totalStats.router';
7+
import WebhookRouter from './webhook.router';
8+
import SlackRouter from './slack.router';
9+
710

811
const router: Router = express.Router();
912

@@ -16,5 +19,8 @@ router.use('/', PostRouter);
1619
router.use('/', NotiRouter);
1720
router.use('/', LeaderboardRouter);
1821
router.use('/', TotalStatsRouter);
22+
router.use('/', WebhookRouter);
23+
router.use('/', SlackRouter);
24+
1925

2026
export default router;

src/routes/slack.router.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import express, { Router } from 'express';
2+
import { SlackController } from '@/controllers/slack.controller';
3+
import { SentryService } from '@/services/sentry.service';
4+
import { SlackService } from '@/services/slack.service';
5+
6+
const router: Router = express.Router();
7+
8+
const slackService = new SlackService();
9+
const sentryService = new SentryService();
10+
const slackController = new SlackController(slackService, sentryService);
11+
12+
/**
13+
* @swagger
14+
* /slack/check-permissions:
15+
* get:
16+
* summary: Slack 권한 확인
17+
* description: Slack Bot의 권한 상태를 확인합니다.
18+
* tags: [Slack]
19+
* responses:
20+
* 200:
21+
* description: 권한 확인 성공
22+
* content:
23+
* application/json:
24+
* schema:
25+
* $ref: '#/components/schemas/PermissionCheckResponseDto'
26+
* 400:
27+
* description: Bot Token 미설정
28+
* 500:
29+
* description: 서버 오류
30+
*/
31+
router.get('/slack/check-permissions', slackController.checkPermissions);
32+
33+
/**
34+
* @swagger
35+
* /slack/test-bot:
36+
* post:
37+
* summary: 봇 테스트
38+
* description: Slack Bot 테스트 메시지를 전송합니다.
39+
* tags: [Slack]
40+
* responses:
41+
* 200:
42+
* description: 테스트 메시지 전송 성공
43+
* content:
44+
* application/json:
45+
* schema:
46+
* $ref: '#/components/schemas/SlackSuccessResponseDto'
47+
* 400:
48+
* description: Slack 연동 미설정
49+
* 500:
50+
* description: 서버 오류
51+
*/
52+
router.post('/slack/test-bot', slackController.testBot);
53+
54+
/**
55+
* @swagger
56+
* /slack/interactive:
57+
* post:
58+
* summary: Slack Interactive Components 처리
59+
* description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다.
60+
* tags: [Slack]
61+
* requestBody:
62+
* required: true
63+
* content:
64+
* application/x-www-form-urlencoded:
65+
* schema:
66+
* type: object
67+
* properties:
68+
* payload:
69+
* type: string
70+
* description: JSON 형태의 Slack payload (URL encoded)
71+
* responses:
72+
* 200:
73+
* description: 상호작용 처리 성공
74+
* content:
75+
* application/json:
76+
* schema:
77+
* type: object
78+
* properties:
79+
* text:
80+
* type: string
81+
* example: "버튼 클릭 처리 완료"
82+
* response_type:
83+
* type: string
84+
* enum: [in_channel, ephemeral]
85+
* 400:
86+
* description: 잘못된 요청
87+
*/
88+
router.post('/slack/interactive', slackController.handleInteractive);
89+
90+
export default router;

0 commit comments

Comments
 (0)