diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index b8b0773..45a7c8a 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -16,13 +16,17 @@ const poolConfig: pg.PoolConfig = { max: 10, // 최대 연결 수 idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) + ssl: false, + // ssl: { + // rejectUnauthorized: false, + // }, }; -if (process.env.NODE_ENV === 'production') { - poolConfig.ssl = { - rejectUnauthorized: false, - }; -} +// if (process.env.NODE_ENV === 'production') { +// poolConfig.ssl = { +// rejectUnauthorized: false, +// }; +// } const pool = new Pool(poolConfig); diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts new file mode 100644 index 0000000..5a631dc --- /dev/null +++ b/src/controllers/webhook.controller.ts @@ -0,0 +1,204 @@ +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 { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type'; +import logger from '@/configs/logger.config'; +import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util'; +import { getNewStatusFromAction } from '@/utils/sentry.util'; + +export class WebhookController { + constructor( + private slackService: SlackService, + private sentryService: SentryService, + ) {} + + handleSentryWebhook: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const sentryData = req.body; + + 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); + } + }; + + handleSlackInteractive: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const payload = JSON.parse(req.body.payload); + + 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('Slack Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + + private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise { + 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 { + 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()); + } + + private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown { + const { action } = actionData; + + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + 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'; + + 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; + } + + return updatedMessage; + } + + private createErrorMessage(error: string, originalMessage: unknown): unknown { + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + if (updatedMessage.attachments && updatedMessage.attachments[0]) { + updatedMessage.attachments[0].fields.push({ + title: '❌ 오류 발생', + value: error, + short: false, + }); + + updatedMessage.attachments[0].color = 'danger'; + } + + return updatedMessage; + } +} \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 0406af2..49ca492 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,8 @@ 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'; + const router: Router = express.Router(); @@ -16,5 +18,6 @@ router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); router.use('/', TotalStatsRouter); +router.use('/', WebhookRouter); export default router; diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts new file mode 100644 index 0000000..6d7740f --- /dev/null +++ b/src/routes/webhook.router.ts @@ -0,0 +1,94 @@ +import express, { Router } from 'express'; +import { WebhookController } from '@/controllers/webhook.controller'; +import { SentryService } from '@/services/sentry.service'; +import { SlackService } from '@/services/slack.service'; + +const router: Router = express.Router(); + +// 서비스 인스턴스 생성 +const sentryService = new SentryService(); +const slackService = new SlackService(); + +// 컨트롤러 인스턴스 생성 +const webhookController = new WebhookController(slackService, sentryService); + +/** + * @swagger + * /webhook/sentry: + * post: + * summary: Sentry webhook 처리 + * description: Sentry에서 전송되는 webhook 이벤트를 처리합니다. + * tags: [Webhook] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * description: Sentry 액션 타입 + * enum: [created, resolved, unresolved, ignored] + * data: + * type: object + * properties: + * issue: + * type: object + * description: Sentry 이슈 정보 + * actor: + * type: object + * description: 액션을 수행한 사용자 정보 + * responses: + * 200: + * description: Webhook 처리 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Webhook 처리 완료" + * 500: + * description: 서버 오류 + */ +router.post('/webhook/sentry', webhookController.handleSentryWebhook); + +/** + * @swagger + * /webhook/slack/interactive: + * post: + * summary: Slack Interactive Components 처리 + * description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다. + * tags: [Webhook] + * 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('/webhook/slack/interactive', webhookController.handleSlackInteractive); + +export default router; \ No newline at end of file diff --git a/src/services/sentry.service.ts b/src/services/sentry.service.ts new file mode 100644 index 0000000..ba581df --- /dev/null +++ b/src/services/sentry.service.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; +import { SentryActionData, SentryActionResult } from '@/types'; +import logger from '@/configs/logger.config'; + +export class SentryService { + private readonly sentryToken: string; + + constructor() { + this.sentryToken = process.env.SENTRY_AUTH_TOKEN || ''; + } + + + hasSentryToken(): boolean { + return !!this.sentryToken; + } + + async handleIssueAction(actionData: SentryActionData): Promise { + if (!this.sentryToken) { + return { success: false, error: 'Sentry 토큰이 설정되지 않았습니다.' }; + } + + try { + const { action, issueId, organizationSlug, projectSlug } = actionData; + const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/`; + + let data: Record; + + switch (action) { + case 'resolve': + data = { status: 'resolved' }; + break; + case 'ignore': + data = { status: 'ignored' }; + break; + case 'archive': + data = { status: 'ignored', statusDetails: { ignoreUntilEscalating: true } }; + break; + case 'delete': + return await this.deleteIssue(organizationSlug, projectSlug, issueId); + default: + return { success: false, error: `지원하지 않는 액션: ${action}` }; + } + + const response = await axios.put(`${url}`, { + issues: [issueId], + ...data, + }, { + headers: { + 'Authorization': `Bearer ${this.sentryToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 200) { + logger.info(`Sentry 이슈 ${action} 성공:`, { issueId, action }); + return { success: true }; + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Sentry 이슈 ${actionData.action} 실패:`, errorMessage); + return { success: false, error: errorMessage }; + } + } + + private async deleteIssue(organizationSlug: string, projectSlug: string, issueId: string): Promise { + try { + const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/${issueId}/`; + + const response = await axios.delete(url, { + headers: { + 'Authorization': `Bearer ${this.sentryToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 202 || response.status === 204) { + logger.info('Sentry 이슈 삭제 성공:', { issueId }); + return { success: true }; + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Sentry 이슈 삭제 실패:', errorMessage); + return { success: false, error: errorMessage }; + } + } +} \ No newline at end of file diff --git a/src/services/slack.service.ts b/src/services/slack.service.ts new file mode 100644 index 0000000..6b8ebca --- /dev/null +++ b/src/services/slack.service.ts @@ -0,0 +1,236 @@ +import axios from 'axios'; +import { SlackMessage, SlackApiResponse, StoredMessageInfo, SlackPermissionsData } from '@/types'; +import { normalizeChannelId } from '@/utils/slack.util'; +import logger from '@/configs/logger.config'; + +const issueMessageMap = new Map(); + +export class SlackService { + private readonly webhookUrl: string; + private readonly botToken: string; + private readonly channelId: string; + + constructor() { + this.webhookUrl = process.env.SLACK_WEBHOOK_URL || ''; + this.botToken = process.env.SLACK_BOT_TOKEN || ''; + this.channelId = process.env.SLACK_CHANNEL_ID || '#general'; + + // 24시간마다 오래된 메시지 정보 정리 + setInterval(() => this.cleanupOldMessages(), 24 * 60 * 60 * 1000); + } + + hasBotToken(): boolean { + return !!this.botToken; + } + + hasWebhookUrl(): boolean { + return !!this.webhookUrl; + } + + storeMessageInfo(issueId: string, messageInfo: Omit): void { + issueMessageMap.set(issueId, { + ...messageInfo, + timestamp: Date.now(), + }); + } + + getMessageInfo(issueId: string): StoredMessageInfo | undefined { + return issueMessageMap.get(issueId); + } + + async checkPermissions(): Promise { + if (!this.botToken) { + return { + hasToken: false, + isValid: false, + permissions: [], + botInfo: null, + channelAccess: false, + recommendations: [ + 'SLACK_BOT_TOKEN 환경 변수를 설정해주세요.', + 'Slack 앱에서 Bot Token을 생성하고 적절한 권한을 부여해주세요.', + ], + }; + } + + try { + const authResponse = await axios.get('https://slack.com/api/auth.test', { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!authResponse.data.ok) { + throw new Error(authResponse.data.error || 'Token validation failed'); + } + + const channelId = normalizeChannelId(this.channelId); + + const channelResponse = await axios.get('https://slack.com/api/conversations.info', { + params: { channel: channelId }, + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + let historyAccess = false; + + try { + const historyResponse = await axios.get('https://slack.com/api/conversations.history', { + params: { channel: channelId, limit: 1 }, + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + historyAccess = historyResponse.data.ok; + } catch (error) { + logger.error('History access check failed:', error); + } + + const permissions = [ + 'chat:write', + 'channels:read', + ...(historyAccess ? ['channels:history'] : []), + ]; + + const recommendations = []; + if (!channelResponse.data.ok) { + recommendations.push(`채널 ${this.channelId}에 대한 접근 권한이 없습니다. 봇을 채널에 초대해주세요.`); + } + if (!historyAccess) { + recommendations.push('메시지 업데이트 기능을 위해 channels:history 권한이 필요합니다.'); + } + + return { + hasToken: true, + isValid: authResponse.data.ok, + permissions, + botInfo: { + userId: authResponse.data.user_id, + username: authResponse.data.user, + teamId: authResponse.data.team_id, + teamName: authResponse.data.team, + }, + channelAccess: channelResponse.data.ok, + recommendations: recommendations.length > 0 ? recommendations : ['모든 권한이 정상적으로 설정되었습니다.'], + }; + } catch (error) { + logger.error('Slack 권한 확인 중 오류:', error); + return { + hasToken: true, + isValid: false, + permissions: [], + botInfo: null, + channelAccess: false, + recommendations: [ + 'Bot Token이 유효하지 않습니다.', + 'Slack 앱 설정을 확인하고 올바른 토큰을 사용해주세요.', + ], + }; + } + } + + async sendMessage(message: SlackMessage, issueId?: string): Promise { + // Interactive 기능이 있다면 Bot Token 사용, 없다면 Webhook 사용 + if (this.botToken) { + return await this.sendMessageWithBot(message, issueId); + } else if (this.webhookUrl) { + return await this.sendMessageWithWebhook(message); + } else { + throw new Error('Slack 설정이 없습니다. SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL을 설정해주세요.'); + } + } + + private async sendMessageWithWebhook(message: SlackMessage): Promise { + try { + const response = await axios.post(this.webhookUrl, message, { + headers: { 'Content-Type': 'application/json' }, + }); + + logger.info('Slack 메시지 전송 성공 (Webhook)'); + return { success: true, data: response.data }; + } catch (error) { + logger.error('Slack 메시지 전송 실패 (Webhook):', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private async sendMessageWithBot(message: SlackMessage, issueId?: string): Promise { + try { + const channelId = normalizeChannelId(this.channelId); + + const response = await axios.post('https://slack.com/api/chat.postMessage', { + channel: channelId, + ...message, + }, { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.data.ok) { + logger.info('Slack 메시지 전송 성공 (Bot)', { channel: channelId }); + + if (issueId && response.data.ts) { + this.storeMessageInfo(issueId, { + channel: channelId, + ts: response.data.ts, + }); + } + + return { success: true, data: response.data }; + } else { + throw new Error(response.data.error || 'Message send failed'); + } + } catch (error) { + logger.error('Slack 메시지 전송 실패 (Bot):', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + async updateMessage(channel: string, ts: string, updatedMessage: SlackMessage): Promise { + if (!this.botToken) { + throw new Error('메시지 업데이트에는 Bot Token이 필요합니다.'); + } + + try { + const response = await axios.post('https://slack.com/api/chat.update', { + channel, + ts, + ...updatedMessage, + }, { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.data.ok) { + logger.info('Slack 메시지 업데이트 성공', { channel, ts }); + return { success: true, data: response.data }; + } else { + throw new Error(response.data.error || 'Message update failed'); + } + } catch (error) { + logger.error('Slack 메시지 업데이트 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private cleanupOldMessages(): void { + const now = Date.now(); + const twentyFourHours = 24 * 60 * 60 * 1000; + + for (const [issueId, messageInfo] of issueMessageMap.entries()) { + if (now - messageInfo.timestamp > twentyFourHours) { + issueMessageMap.delete(issueId); + } + } + + logger.info(`오래된 메시지 정보 정리 완료. 현재 저장된 메시지: ${issueMessageMap.size}개`); + } +} \ No newline at end of file diff --git a/src/types/dto/requests/slackRequest.type.ts b/src/types/dto/requests/slackRequest.type.ts new file mode 100644 index 0000000..9638882 --- /dev/null +++ b/src/types/dto/requests/slackRequest.type.ts @@ -0,0 +1,3 @@ +export interface SlackInteractiveRequestBody { + payload: string; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 7a247d8..5194b72 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,5 +38,39 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; +// Sentry 관련 +export type { + SentryIssuePriority, + SentryIssueStatus, + SentryAction, + SentryApiAction, +} from '@/types/models/Sentry.type'; +export type { + SentryProject, + SentryIssueMetadata, + SentryIssue, + SentryActor, + SentryWebhookData, + SentryActionData, + SentryActionResult, +} from '@/types/models/Sentry.type'; + +// Slack 관련 +export type { + SlackAttachmentField, + SlackAction, + SlackAttachment, + SlackMessage, + SlackInteractiveAction, + SlackInteractivePayload, + StoredMessageInfo, + SlackApiResponse, + SlackPermissionsData, +} from '@/types/models/Slack.type'; + +export type { + SlackInteractiveRequestBody, +} from '@/types/dto/requests/slackRequest.type'; + // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts new file mode 100644 index 0000000..6520579 --- /dev/null +++ b/src/types/models/Sentry.type.ts @@ -0,0 +1,71 @@ + +export type SentryIssuePriority = 'high' | 'medium' | 'low'; + +export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; + +export type SentryAction = 'created' | 'resolved' | 'unresolved' | 'ignored'; + +export interface SentryOrganization { + id: string; + slug: string; + name: string; +} + +export interface SentryProject { + id: string; + name: string; + slug: string; + platform?: string; + organization?: SentryOrganization; +} + +export interface SentryIssueMetadata { + value?: string; + type?: string; +} + +export interface SentryIssue { + id: string; + shortId?: string; + title: string; + culprit?: string; + metadata?: SentryIssueMetadata; + status?: SentryIssueStatus; + priority?: SentryIssuePriority; + count: number; + userCount: number; + firstSeen: string; + lastSeen?: string; + project?: SentryProject; + platform?: string; + permalink?: string; +} + +export interface SentryActor { + id: string; + name: string; + email?: string; +} + +export interface SentryWebhookData { + action: SentryAction; + data: { + issue: SentryIssue; + project?: SentryProject; + }; + actor?: SentryActor; +} + +export type SentryApiAction = 'resolve' | 'unresolve' | 'ignore' | 'archive' | 'unarchive' | 'delete'; + +export interface SentryActionData { + issueId: string; + organizationSlug: string; + projectSlug: string; + action: SentryApiAction; +} + +export interface SentryActionResult { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/src/types/models/Slack.type.ts b/src/types/models/Slack.type.ts new file mode 100644 index 0000000..eeded83 --- /dev/null +++ b/src/types/models/Slack.type.ts @@ -0,0 +1,87 @@ +export interface SlackAttachmentField { + title: string; + value: string; + short: boolean; +} + +export interface SlackAction { + type: 'button'; + text: string; + name?: string; + value?: string; + url?: string; + style?: 'default' | 'primary' | 'danger' | 'good'; + confirm?: { + title: string; + text: string; + ok_text: string; + dismiss_text: string; + }; +} + +export interface SlackAttachment { + callback_id?: string; + color?: string; + fields?: SlackAttachmentField[]; + actions?: SlackAction[]; + footer?: string; + footer_icon?: string; + ts?: number; + text?: string; + title?: string; + title_link?: string; + mrkdwn_in?: string[]; +} + +export interface SlackMessage { + text: string; + attachments?: SlackAttachment[]; + response_type?: 'in_channel' | 'ephemeral'; + [key: string]: unknown; +} + +export interface SlackInteractiveAction { + name: string; + value: string; + type: string; +} + +export interface SlackInteractivePayload { + type: string; + callback_id?: string; + actions?: SlackInteractiveAction[]; + original_message?: SlackMessage; + response_url?: string; + user?: { + id: string; + name: string; + }; +} + +export interface StoredMessageInfo { + channel: string; + ts: string; + timestamp: number; +} + +export interface SlackApiResponse { + success: boolean; + error?: string; + data?: unknown; +} + +export interface SlackBotInfo { + userId: string; + username: string; + teamId: string; + teamName: string; +} + +export interface SlackPermissionsData { + hasToken: boolean; + isValid: boolean; + permissions: string[]; + botInfo: SlackBotInfo | null; + channelAccess: boolean; + recommendations: string[]; +} \ No newline at end of file diff --git a/src/utils/sentry.util.ts b/src/utils/sentry.util.ts new file mode 100644 index 0000000..5d746d2 --- /dev/null +++ b/src/utils/sentry.util.ts @@ -0,0 +1,63 @@ +import { SentryActionData } from '@/types'; + +/** + * Sentry API 액션에 따른 새로운 상태를 반환하는 함수 + * @param action - Sentry API 액션 + * @returns 새로운 이슈 상태 + */ +export function getNewStatusFromAction(action: string): string { + const statusMap: Record = { + 'resolve': 'resolved', + 'ignore': 'ignored', + 'archive': 'archived', + 'delete': 'deleted', + }; + + return statusMap[action] || 'unresolved'; +} + +/** + * Sentry API 요청 데이터를 생성하는 함수 + * @param actionData - 액션 데이터 + * @returns API 요청을 위한 데이터와 메소드 + */ +export function prepareSentryApiRequest(actionData: SentryActionData): { + method: 'PUT' | 'DELETE'; + data?: { status: string }; +} { + const { action } = actionData; + + switch (action) { + case 'resolve': + return { method: 'PUT', data: { status: 'resolved' } }; + case 'unresolve': + return { method: 'PUT', data: { status: 'unresolved' } }; + case 'archive': + return { method: 'PUT', data: { status: 'ignored' } }; + case 'unarchive': + return { method: 'PUT', data: { status: 'unresolved' } }; + case 'delete': + return { method: 'DELETE' }; + default: + throw new Error('지원되지 않는 액션입니다.'); + } +} + +/** + * Sentry API URL을 생성하는 함수 + * @param issueId - 이슈 ID + * @returns Sentry API URL + */ +export function getSentryApiUrl(issueId: string): string { + return `https://sentry.io/api/0/issues/${issueId}/`; +} + +/** + * Sentry 이슈 URL을 생성하는 함수 + * @param issueId - 이슈 ID + * @param orgSlug - 조직 슬러그 (기본: velog-dashboardv2) + * @returns Sentry 이슈 상세 페이지 URL + */ +export function getSentryIssueUrl(issueId: string, orgSlug: string = 'velog-dashboardv2'): string { + return `https://sentry.io/organizations/${orgSlug}/issues/${issueId}/`; +} \ No newline at end of file diff --git a/src/utils/slack.util.ts b/src/utils/slack.util.ts new file mode 100644 index 0000000..fbb6874 --- /dev/null +++ b/src/utils/slack.util.ts @@ -0,0 +1,431 @@ +import { SlackMessage, SlackAction, SentryIssue, SentryWebhookData } from '@/types'; +import { SlackAttachment } from '@/types/models/Slack.type'; + +/** + * 날짜/시간을 상대적 또는 절대적 형식으로 포맷팅하는 함수 + * @param dateString - 포맷팅할 날짜 문자열 + * @returns 포맷팅된 날짜 문자열 + */ +export function formatDateTime(dateString?: string): string { + if (!dateString) return 'Unknown'; + + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // 상대 시간 표시 + if (diffMins < 1) return '방금 전'; + if (diffMins < 60) return `${diffMins}분 전`; + if (diffHours < 24) return `${diffHours}시간 전`; + if (diffDays < 7) return `${diffDays}일 전`; + + // 절대 시간 표시 (한국 시간) + return date.toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return dateString; + } +} + +/** + * 이슈 상태별 액션 버튼을 생성하는 함수 + * @param issue - Sentry 이슈 정보 + * @param issueUrl - 이슈 상세 페이지 URL + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns Slack 액션 버튼 배열 + */ +export function generateIssueActions(issue: SentryIssue, issueUrl: string, hasSentryToken: boolean = false): SlackAction[] { + const actions: SlackAction[] = [ + { + type: 'button', + text: '🔍 Sentry에서 자세히 보기', + url: issueUrl, + style: 'primary' + } + ]; + + // Interactive 기능이 활성화된 경우에만 액션 버튼 추가 + if (!hasSentryToken) { + return actions; + } + + const issueStatus = issue.status || 'unresolved'; + const baseActionData = { + issueId: issue.id, + projectSlug: issue.project?.slug || 'unknown' + }; + + switch (issueStatus) { + case 'unresolved': + // 미해결 상태: 해결, 보관, 삭제 버튼 + actions.push( + { + type: 'button', + text: '✅ 문제 해결', + name: 'resolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'resolve' }), + style: 'good', + confirm: { + title: '이슈 해결 확인', + text: '이 이슈를 해결됨으로 표시하시겠습니까?', + ok_text: '해결', + dismiss_text: '취소' + } + }, + { + type: 'button', + text: '📦 보관', + name: 'archive_issue', + value: JSON.stringify({ ...baseActionData, action: 'archive' }), + style: 'default', + confirm: { + title: '이슈 보관 확인', + text: '이 이슈를 보관하시겠습니까?', + ok_text: '보관', + dismiss_text: '취소' + } + }, + { + type: 'button', + text: '🗑️ 삭제', + name: 'delete_issue', + value: JSON.stringify({ ...baseActionData, action: 'delete' }), + style: 'danger', + confirm: { + title: '이슈 삭제 확인', + text: '이 이슈를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + ok_text: '삭제', + dismiss_text: '취소' + } + } + ); + break; + + case 'resolved': + // 해결됨 상태: 해결 취소, 보관, 삭제 버튼 + actions.push( + { + type: 'button', + text: '↩️ 해결 취소', + name: 'unresolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'unresolve' }), + style: 'default', + confirm: { + title: '해결 취소 확인', + text: '이 이슈의 해결 상태를 취소하시겠습니까?', + ok_text: '취소', + dismiss_text: '아니오' + } + }, + { + type: 'button', + text: '📦 보관', + name: 'archive_issue', + value: JSON.stringify({ ...baseActionData, action: 'archive' }), + style: 'default' + } + ); + break; + + case 'ignored': + // 보관 상태: 보관 취소, 해결, 삭제 버튼 + actions.push( + { + type: 'button', + text: '📤 보관 취소', + name: 'unarchive_issue', + value: JSON.stringify({ ...baseActionData, action: 'unarchive' }), + style: 'default', + confirm: { + title: '보관 취소 확인', + text: '이 이슈의 보관 상태를 취소하시겠습니까?', + ok_text: '취소', + dismiss_text: '아니오' + } + }, + { + type: 'button', + text: '✅ 문제 해결', + name: 'resolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'resolve' }), + style: 'good' + } + ); + break; + } + + return actions; +} + +/** + * Sentry 이슈 생성 이벤트를 Slack 메시지로 변환하는 함수 + * @param sentryData - Sentry 웹훅 데이터 + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns Slack 메시지 객체 + */ +export function formatSentryIssueForSlack(sentryData: SentryWebhookData, hasSentryToken: boolean): SlackMessage { + const { action, data } = sentryData; + const issue = data.issue; + + if (!issue) { + return { + text: `🔔 Sentry 이벤트: ${action}`, + attachments: [ + { + color: 'warning', + fields: [ + { + title: '오류', + value: '이슈 정보를 찾을 수 없습니다.', + short: false, + }, + ], + }, + ], + }; + } + + const statusEmoji = getStatusEmoji(issue.status); + const statusColor = getStatusColor(issue.status); + + const fields = [ + { + title: '프로젝트', + value: issue.project?.name || 'Unknown', + short: true, + }, + { + title: '상태', + value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, + short: true, + }, + { + title: '발생 횟수', + value: issue.count?.toString() || '0', + short: true, + }, + { + title: '사용자 수', + value: issue.userCount?.toString() || '0', + short: true, + }, + ]; + + if (issue.culprit) { + fields.push({ + title: '위치', + value: issue.culprit, + short: false, + }); + } + + const attachment: SlackAttachment = { + color: statusColor, + title: issue.title || 'Unknown Error', + title_link: issue.permalink, + fields, + footer: `Sentry | ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, + mrkdwn_in: ['text', 'pretext'], + }; + + // Sentry 토큰이 있을 때만 Interactive 버튼 추가 + if (hasSentryToken && issue.status !== 'resolved') { + attachment.actions = createActionButtons( + issue.id, + data.project?.organization?.slug || issue.project?.organization?.slug, + data.project?.slug || issue.project?.slug + ); + } + + return { + text: `🚨 *${getActionText(action)}*`, + attachments: [attachment], + }; +} + +/** + * Sentry 이슈 상태 변경을 위한 Slack 메시지 업데이트 함수 + * @param sentryData - Sentry 웹훅 데이터 + * @param originalMessage - 원본 Slack 메시지 + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns 업데이트된 Slack 메시지 + */ +export function createStatusUpdateMessage(sentryData: SentryWebhookData, hasSentryToken: boolean = false): SlackMessage { + const { data } = sentryData; + const issue = data.issue; + + if (!issue) { + return { + text: '❌ 이슈 정보를 찾을 수 없습니다.', + }; + } + + const statusEmoji = getStatusEmoji(issue.status); + const statusColor = getStatusColor(issue.status); + + const fields = [ + { + title: '프로젝트', + value: issue.project?.name || 'Unknown', + short: true, + }, + { + title: '상태', + value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, + short: true, + }, + { + title: '발생 횟수', + value: issue.count?.toString() || '0', + short: true, + }, + { + title: '사용자 수', + value: issue.userCount?.toString() || '0', + short: true, + }, + ]; + + if (issue.culprit) { + fields.push({ + title: '위치', + value: issue.culprit, + short: false, + }); + } + + const attachment: SlackAttachment = { + color: statusColor, + title: issue.title || 'Unknown Error', + title_link: issue.permalink, + fields, + footer: `Sentry | 상태 변경: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, + mrkdwn_in: ['text', 'pretext'], + }; + + // 해결되지 않은 상태이고 Sentry 토큰이 있을 때만 액션 버튼 추가 + if (hasSentryToken && issue.status !== 'resolved') { + attachment.actions = createActionButtons( + issue.id, + data.project?.organization?.slug || issue.project?.organization?.slug, + data.project?.slug || issue.project?.slug + ); + } + + return { + text: `🔄 *이슈 상태가 변경되었습니다*`, + attachments: [attachment], + }; +} + +/** + * 채널 ID 형식을 정규화하는 함수 + * @param channelId - 채널 ID 또는 이름 + * @returns 정규화된 채널 ID + */ +export function normalizeChannelId(channelId: string): string { + if (!channelId.startsWith('C') && !channelId.startsWith('#')) { + return '#' + channelId; + } + return channelId; +} + +function createActionButtons(issueId: string, organizationSlug?: string, projectSlug?: string): SlackAction[] { + if (!organizationSlug || !projectSlug) { + return []; + } + + return [ + { + name: 'sentry_action', + text: '✅ 해결', + type: 'button' as const, + value: `resolve:${issueId}:${organizationSlug}:${projectSlug}`, + style: 'primary', + confirm: { + title: '이슈 해결', + text: '이 이슈를 해결된 상태로 변경하시겠습니까?', + ok_text: '해결', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '🔇 무시', + type: 'button' as const, + value: `ignore:${issueId}:${organizationSlug}:${projectSlug}`, + confirm: { + title: '이슈 무시', + text: '이 이슈를 무시하시겠습니까?', + ok_text: '무시', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '📦 보관', + type: 'button' as const, + value: `archive:${issueId}:${organizationSlug}:${projectSlug}`, + confirm: { + title: '이슈 보관', + text: '이 이슈를 보관하시겠습니까?', + ok_text: '보관', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '🗑️ 삭제', + type: 'button' as const, + value: `delete:${issueId}:${organizationSlug}:${projectSlug}`, + style: 'danger', + confirm: { + title: '이슈 삭제', + text: '⚠️ 이 작업은 되돌릴 수 없습니다. 정말로 이 이슈를 삭제하시겠습니까?', + ok_text: '삭제', + dismiss_text: '취소', + }, + }, + ]; +} + +function getStatusEmoji(status?: string): string { + const emojiMap: Record = { + 'unresolved': '🔴', + 'resolved': '✅', + 'ignored': '🔇', + 'archived': '📦', + }; + return emojiMap[status || 'unresolved'] || '❓'; +} + +function getStatusColor(status?: string): string { + const colorMap: Record = { + 'unresolved': 'danger', + 'resolved': 'good', + 'ignored': 'warning', + 'archived': '#808080', + }; + return colorMap[status || 'unresolved'] || 'warning'; +} + +function getActionText(action?: string): string { + const actionMap: Record = { + 'created': '새로운 오류가 발생했습니다', + 'resolved': '오류가 해결되었습니다', + 'unresolved': '오류가 다시 발생했습니다', + 'ignored': '오류가 무시되었습니다', + 'assigned': '오류가 할당되었습니다', + }; + return actionMap[action || 'created'] || `오류 이벤트: ${action}`; +} \ No newline at end of file