Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/controllers/slack.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { SlackService } from '@/services/slack.service';
import { SentryService } from '@/services/sentry.service';
import logger from '@/configs/logger.config';
import { PermissionCheckResponseDto, SlackSuccessResponseDto } from '@/types';
import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type';
import { getNewStatusFromAction } from '@/utils/sentry.util';

export class SlackController {
constructor(
private slackService: SlackService,
private sentryService: SentryService,
) {}

checkPermissions: RequestHandler = async (
req: Request,
res: Response<PermissionCheckResponseDto>,
next: NextFunction,
): Promise<void> => {
try {
const permissions = await this.slackService.checkPermissions();
const response = new PermissionCheckResponseDto(true, 'Slack 권한 확인 완료', permissions, null);
res.status(200).json(response);
} catch (error) {
logger.error('Slack 권한 확인 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
next(error);
}
};

testBot: RequestHandler = async (
req: Request,
res: Response<SlackSuccessResponseDto>,
next: NextFunction,
): Promise<void> => {
try {
if (!this.slackService.hasBotToken() && !this.slackService.hasWebhookUrl()) {
const response = new SlackSuccessResponseDto(
false,
'SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL 환경 변수가 설정되지 않았습니다.',
{},
'MISSING_SLACK_CONFIG'
);
res.status(400).json(response);
return;
}

const testMessage = {
text: '🤖 봇 테스트 메시지입니다!',
attachments: [
{
color: 'good',
fields: [
{
title: '테스트 결과',
value: '✅ Slack 연동이 정상적으로 작동합니다.',
short: false,
},
],
footer: `테스트 시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`,
},
],
};

await this.slackService.sendMessage(testMessage);
const response = new SlackSuccessResponseDto(true, '봇 테스트 메시지 전송 완료!', {}, null);
res.status(200).json(response);
} catch (error) {
logger.error('봇 테스트 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
next(error);
}
};

handleInteractive: RequestHandler = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const payload = JSON.parse(req.body.payload);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

JSON 파싱에 에러 처리 추가 필요

JSON.parse에 대한 에러 처리가 없어 잘못된 페이로드로 인해 서버가 크래시될 수 있습니다.

다음과 같이 수정해주세요:

-     const payload = JSON.parse(req.body.payload);
+     let payload;
+     try {
+       payload = JSON.parse(req.body.payload);
+     } catch (error) {
+       logger.error('Invalid JSON payload:', error);
+       res.json({ text: '❌ 잘못된 요청 형식입니다.' });
+       return;
+     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payload = JSON.parse(req.body.payload);
let payload;
try {
payload = JSON.parse(req.body.payload);
} catch (error) {
logger.error('Invalid JSON payload:', error);
res.json({ text: '❌ 잘못된 요청 형식입니다.' });
return;
}
🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts at line 79, the JSON.parse call on
req.body.payload lacks error handling, which can cause the server to crash if
the payload is malformed. Wrap the JSON.parse call in a try-catch block to catch
parsing errors and handle them gracefully, such as by returning an error
response or logging the issue without crashing the server.


if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) {
const action = payload.actions[0];

if (action.name === 'sentry_action') {
const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':');

const actionData: SentryActionData = {
action: actionType as SentryApiAction,
issueId,
organizationSlug,
projectSlug,
};

if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) {
logger.info('Processing Sentry action:', actionData);

const result = await this.sentryService.handleIssueAction(actionData);

if (result.success) {
const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {});
res.json(updatedMessage);
} else {
const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {});
res.json(errorMessage);
}
return;
}
}
}

res.json({ text: '❌ 잘못된 요청입니다.' });
} catch (error) {
logger.error('Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
next(error);
}
};

private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown {
const { action } = actionData;

const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

비효율적인 깊은 복사 방식 개선

JSON.parse(JSON.stringify())는 성능에 부정적 영향을 미치고 순환 참조나 함수 등을 처리할 수 없습니다.

구조화된 복제(structured clone) 또는 라이브러리 사용을 권장합니다:

-   const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
+   const updatedMessage = structuredClone(originalMessage);

또는 lodash를 사용:

+   import { cloneDeep } from 'lodash';
-   const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
+   const updatedMessage = cloneDeep(originalMessage);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
const updatedMessage = structuredClone(originalMessage);
🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts at line 121, replace the inefficient deep
copy using JSON.parse(JSON.stringify()) with a more robust method such as
structuredClone if available, or use a utility function from a library like
lodash's cloneDeep to handle deep copying safely and efficiently, especially to
support circular references and functions.


if (updatedMessage.attachments && updatedMessage.attachments[0]) {
const newStatus = getNewStatusFromAction(action);
const statusColors = {
'resolved': 'good',
'ignored': 'warning',
'archived': '#808080',
'unresolved': 'danger',
};

updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

옵셔널 체이닝 사용 권장

정적 분석 도구에서 제안한 대로 옵셔널 체이닝을 사용하여 코드를 더 안전하게 만들어주세요.

-   if (updatedMessage.attachments && updatedMessage.attachments[0]) {
+   if (updatedMessage.attachments?.[0]) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (updatedMessage.attachments && updatedMessage.attachments[0]) {
const newStatus = getNewStatusFromAction(action);
const statusColors = {
'resolved': 'good',
'ignored': 'warning',
'archived': '#808080',
'unresolved': 'danger',
};
updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good';
if (updatedMessage.attachments?.[0]) {
const newStatus = getNewStatusFromAction(action);
const statusColors = {
'resolved': 'good',
'ignored': 'warning',
'archived': '#808080',
'unresolved': 'danger',
};
updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good';
🧰 Tools
🪛 Biome (1.9.4)

[error] 128-131: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts around lines 123 to 132, the code
accesses updatedMessage.attachments[0] without optional chaining, which can
cause runtime errors if attachments or the first element is undefined. Update
the code to use optional chaining when accessing attachments and its first
element to safely handle cases where these might be undefined, improving code
safety and preventing potential crashes.


const statusMapping = {
'resolved': 'RESOLVED',
'ignored': 'IGNORED',
'archived': 'ARCHIVED',
'unresolved': 'UNRESOLVED',
};

const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase();
updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`;

delete updatedMessage.attachments[0].actions;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

delete 연산자 사용 지양

delete 연산자는 성능에 부정적 영향을 미칩니다. undefined 할당을 사용해주세요.

-     delete updatedMessage.attachments[0].actions;
+     updatedMessage.attachments[0].actions = undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
delete updatedMessage.attachments[0].actions;
updatedMessage.attachments[0].actions = undefined;
🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts at line 144, replace the use of the
delete operator on updatedMessage.attachments[0].actions with assigning
undefined to updatedMessage.attachments[0].actions to avoid performance issues
caused by delete.

}

return updatedMessage;
}

private createErrorMessage(error: string, originalMessage: unknown): unknown {
const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

깊은 복사 방식 개선 (에러 메시지 생성)

여기서도 동일한 비효율적인 복사 방식이 사용되고 있습니다.

-   const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
+   const updatedMessage = structuredClone(originalMessage);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updatedMessage = JSON.parse(JSON.stringify(originalMessage));
const updatedMessage = structuredClone(originalMessage);
🧰 Tools
🪛 Biome (1.9.4)

[error] 151-153: Avoid the delete operator which can impact performance.

Unsafe fix: Use an undefined assignment instead.

(lint/performance/noDelete)

🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts at line 151, the code uses
JSON.parse(JSON.stringify(originalMessage)) for deep copying, which is
inefficient and can cause issues with certain data types. Replace this with a
more efficient and reliable deep copy method, such as using a utility function
like lodash's cloneDeep or a custom deep copy function that handles complex
objects properly.


if (updatedMessage.attachments && updatedMessage.attachments[0]) {
updatedMessage.attachments[0].fields.push({
title: '❌ 오류 발생',
value: error,
short: false,
});

updatedMessage.attachments[0].color = 'danger';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

옵셔널 체이닝 사용 권장 (에러 메시지)

에러 메시지 생성 부분에서도 옵셔널 체이닝을 사용해주세요.

-   if (updatedMessage.attachments && updatedMessage.attachments[0]) {
+   if (updatedMessage.attachments?.[0]) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (updatedMessage.attachments && updatedMessage.attachments[0]) {
updatedMessage.attachments[0].fields.push({
title: '❌ 오류 발생',
value: error,
short: false,
});
updatedMessage.attachments[0].color = 'danger';
}
if (updatedMessage.attachments?.[0]) {
updatedMessage.attachments[0].fields.push({
title: '❌ 오류 발생',
value: error,
short: false,
});
updatedMessage.attachments[0].color = 'danger';
}
🤖 Prompt for AI Agents
In src/controllers/slack.controller.ts around lines 153 to 161, the error
message assignment uses a direct reference to the error variable without
optional chaining. Update the code to use optional chaining when accessing the
error message to safely handle cases where the error might be undefined or null,
preventing potential runtime errors.


return updatedMessage;
}
}
109 changes: 109 additions & 0 deletions src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { SlackService } from '@/services/slack.service';
import { SentryService } from '@/services/sentry.service';
import { SentryWebhookData, SlackMessage } from '@/types';
import logger from '@/configs/logger.config';
import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util';

export class WebhookController {
constructor(
private slackService: SlackService,
private sentryService: SentryService,
) {}

handleSentryWebhook: RequestHandler = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const sentryData = req.body;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

웹훅 페이로드에 대한 입력 검증 추가 필요

req.body를 직접 사용하는 것은 보안상 위험할 수 있습니다. Sentry 웹훅의 유효성을 검증하는 로직을 추가해주세요.

다음과 같은 개선사항을 제안합니다:

  handleSentryWebhook: RequestHandler = async (
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> => {
    try {
-     const sentryData = req.body;
+     const sentryData = req.body as SentryWebhookData;
+     
+     // 웹훅 페이로드 기본 검증
+     if (!sentryData || !sentryData.action || !sentryData.data) {
+       logger.warn('유효하지 않은 Sentry 웹훅 페이로드:', sentryData);
+       res.status(400).json({ message: 'Invalid webhook payload' });
+       return;
+     }
      
      const slackMessage = await this.formatSentryDataForSlack(sentryData);
🤖 Prompt for AI Agents
In src/controllers/webhook.controller.ts at line 20, the code directly assigns
req.body to sentryData without validating the webhook payload, which poses a
security risk. Add input validation logic to verify that req.body contains the
expected structure and fields of a valid Sentry webhook payload before using it.
This can include checking required properties, data types, and possibly
verifying a signature or token if applicable.


const slackMessage = await this.formatSentryDataForSlack(sentryData);

if (slackMessage === null) {
logger.info('기존 메시지 업데이트 완료, 새 메시지 전송 생략');
res.status(200).json({ message: 'Webhook processed successfully' });
return;
}

const issueId = sentryData.data?.issue?.id;
await this.slackService.sendMessage(slackMessage, issueId);

res.status(200).json({ message: 'Webhook processed successfully' });
} catch (error) {
logger.error('Sentry webhook 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류');
next(error);
}
};

private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise<SlackMessage | null> {
const { action, data } = sentryData;

if (action === 'resolved' || action === 'unresolved' || action === 'ignored') {
return await this.handleIssueStatusChange(sentryData);
}

if (action === 'created' && data.issue) {
return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
}

return {
text: `🔔 Sentry 이벤트: ${action || 'Unknown action'}`,
attachments: [
{
color: 'warning',
fields: [
{
title: '이벤트 타입',
value: action || 'Unknown',
short: true,
},
],
},
],
};
}

private async handleIssueStatusChange(sentryData: SentryWebhookData): Promise<SlackMessage | null> {
const { data } = sentryData;
const issue = data.issue;

if (!issue) {
logger.warn('이슈 정보가 없습니다:', sentryData);
return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
}

logger.info(`이슈 상태 변경 감지: ${issue.id} → ${sentryData.action}`);

const messageInfo = this.slackService.getMessageInfo(issue.id);

if (messageInfo) {
logger.info('기존 메시지 발견, 업데이트 시도');

try {
const updatedMessage = createStatusUpdateMessage(
sentryData,
this.sentryService.hasSentryToken()
);

await this.slackService.updateMessage(
messageInfo.channel,
messageInfo.ts,
updatedMessage
);

logger.info('기존 메시지 업데이트 완료');
return null;

} catch (error) {
logger.error('메시지 업데이트 실패, 새 메시지로 전송:', error instanceof Error ? error.message : '알 수 없는 오류');

}
} else {
logger.info('기존 메시지 없음, 새 메시지 생성');
}

return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken());
}
}
6 changes: 6 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import PostRouter from './post.router';
import NotiRouter from './noti.router';
import LeaderboardRouter from './leaderboard.router';
import TotalStatsRouter from './totalStats.router';
import WebhookRouter from './webhook.router';
import SlackRouter from './slack.router';


const router: Router = express.Router();

Expand All @@ -16,5 +19,8 @@ router.use('/', PostRouter);
router.use('/', NotiRouter);
router.use('/', LeaderboardRouter);
router.use('/', TotalStatsRouter);
router.use('/', WebhookRouter);
router.use('/', SlackRouter);


export default router;
90 changes: 90 additions & 0 deletions src/routes/slack.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import express, { Router } from 'express';
import { SlackController } from '@/controllers/slack.controller';
import { SentryService } from '@/services/sentry.service';
import { SlackService } from '@/services/slack.service';

const router: Router = express.Router();

const slackService = new SlackService();
const sentryService = new SentryService();
const slackController = new SlackController(slackService, sentryService);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

의존성 주입 패턴으로 리팩토링 필요

서비스들이 라우터 파일에서 직접 인스턴스화되고 있습니다. 이는 테스트 가능성을 저해하고 여러 인스턴스가 생성될 수 있는 문제가 있습니다.

서비스 인스턴스를 외부에서 주입받도록 수정하세요:

-const slackService = new SlackService();
-const sentryService = new SentryService();
-const slackController = new SlackController(slackService, sentryService);
+export const createSlackRouter = (slackService: SlackService, sentryService: SentryService): Router => {
+  const router: Router = express.Router();
+  const slackController = new SlackController(slackService, sentryService);

그리고 파일 끝부분을 다음과 같이 수정하세요:

-export default router;
+  // 라우트 정의들...
+  
+  return router;
+};
🤖 Prompt for AI Agents
In src/routes/slack.router.ts around lines 8 to 10, the SlackService and
SentryService are instantiated directly inside the router file, which reduces
testability and can cause multiple instances. Refactor the code to accept these
service instances via dependency injection by modifying the router to receive
them as parameters or through constructor injection. Remove direct instantiation
from this file and ensure the services are passed in from an external module or
the application entry point.


/**
* @swagger
* /slack/check-permissions:
* get:
* summary: Slack 권한 확인
* description: Slack Bot의 권한 상태를 확인합니다.
* tags: [Slack]
* responses:
* 200:
* description: 권한 확인 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/PermissionCheckResponseDto'
* 400:
* description: Bot Token 미설정
* 500:
* description: 서버 오류
*/
router.get('/slack/check-permissions', slackController.checkPermissions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

컨트롤러 메서드의 컨텍스트 바인딩 필요

클래스 메서드를 콜백으로 전달할 때 this 컨텍스트가 손실될 수 있습니다.

메서드를 바인딩하거나 화살표 함수로 래핑하세요:

-router.get('/slack/check-permissions', slackController.checkPermissions);
+router.get('/slack/check-permissions', slackController.checkPermissions.bind(slackController));

-router.post('/slack/test-bot', slackController.testBot);
+router.post('/slack/test-bot', slackController.testBot.bind(slackController));

-router.post('/slack/interactive', slackController.handleInteractive);
+router.post('/slack/interactive', slackController.handleInteractive.bind(slackController));

또는 컨트롤러 생성자에서 메서드를 바인딩하세요.

Also applies to: 52-52, 88-88

🤖 Prompt for AI Agents
In src/routes/slack.router.ts at lines 31, 52, and 88, the controller methods
are passed as callbacks without binding, causing loss of the `this` context. Fix
this by either binding the methods to the controller instance using
`.bind(this)` when passing them as callbacks or by wrapping the calls in arrow
functions to preserve context. Alternatively, bind the methods in the
controller's constructor to ensure `this` is correctly set.


/**
* @swagger
* /slack/test-bot:
* post:
* summary: 봇 테스트
* description: Slack Bot 테스트 메시지를 전송합니다.
* tags: [Slack]
* responses:
* 200:
* description: 테스트 메시지 전송 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SlackSuccessResponseDto'
* 400:
* description: Slack 연동 미설정
* 500:
* description: 서버 오류
*/
router.post('/slack/test-bot', slackController.testBot);

/**
* @swagger
* /slack/interactive:
* post:
* summary: Slack Interactive Components 처리
* description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다.
* tags: [Slack]
* requestBody:
* required: true
* content:
* application/x-www-form-urlencoded:
* schema:
* type: object
* properties:
* payload:
* type: string
* description: JSON 형태의 Slack payload (URL encoded)
* responses:
* 200:
* description: 상호작용 처리 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* text:
* type: string
* example: "버튼 클릭 처리 완료"
* response_type:
* type: string
* enum: [in_channel, ephemeral]
* 400:
* description: 잘못된 요청
*/
router.post('/slack/interactive', slackController.handleInteractive);

export default router;
Loading
Loading