# 기존방법

- Slack Webhooks를 통해 Slack채널로 HTTP 요청

- Icoming Webhooks: 가장 간단한 방법중 하나로 Slack에서 발급된 URL로 HTTP POST 요청을 보냄

- Slack 웹훅을 사용하기 위해선 앱을 생성해야함

 

https://velog.io/@sssssssssy/nextjs-slack-webhooks

 

Next.js와 Slack Webhooks로 실시간 에러 알림 시스템 만들기

[FE] Next.js + Slack Webhooks 실시간 에러 알림 설정

velog.io

위의 링크를 통해 개발에 필요한 bot 생성 및 토큰 발행에 큰 도움을 받았다 (+git을 통해 기초코드까지 있음)

 

# 문자 발송은 바로 되지만 파일은 실패!

- 메시지 발송하는 방법은 위 링크에서 git을 통해 받은 코드와 설명을 읽은 후 바로 진행됨

- 그러나 여러가지 문제로 계속 실패 (특히, 권한 문제)

 

# 다양한 에러코드

1. not_int_channal

- 해당 에러코드는 내가 전달하고 싶은 Slack 채널에서 /invite @봇이름 을 하면 해결

 

2. method_deprecated

- 무슨 뜻이야..? 검색해보니 "사용되지 않는 메서드"

- 검색해보면 files.upload 를 사용하여 다 구현했다고 하는데 왜 이런 에러가?

- 공식 API문서를 보면 "files.upload는 2025년 3월에 종료되며 순차적인 웹 API 메서드로 대체됩니다."

- 코드 작성할때가 25년 3월 14일인데, 3월 11일에 종료되었다고 한다ㅠㅠ

이게 외 않대?

- 그럼 뭐 써요???

-  files.getUploadURLExternal 와 files.completeUploadExternal를 쓰라고 함

- 왜 파일업로드가 2개로 나누어졌지?

- files.getUploadURLExternal : 외부 파일 업로드에 대한 URL을 가져옵니다.

- files.completeUploadExternal:  files.getUploadURLExternal로 시작된 업로드를 완료합니다.

- 그럼 이걸로 시작해볼까?

 

 

3. missing_scope

- 메소드를 변경하니 새로운 문제 발생

- 권한을 추가해야한다는 뜻. slack api 웹페이지에서 OAuth & Permissions 메뉴를 들어간다.

- Scopes에서 Bot과 User의 Scopes를 설정할 수 있음

- 아마 필요한건 channels:read, chat:write, files:read, files:write로 생각됨

뭐가 필요한지 몰라서 다넣어봄

 

4. invalid_arguments

- '유효하지 않은 인자'

- 무엇을 잘못넣은거지....? 답은 공식 문서에 있다

 

5. 그래도 안됨ㅠㅠ

더보기
  • 파일 정보 읽기: fs.promises.stat과 fs.promises.readFile을 사용하여 파일 정보와 내용을 읽습니다.
  • 업로드 URL 얻기: files.getUploadURLExternal API를 호출하여 파일을 업로드할 URL을 얻습니다.
  • 파일 업로드: 얻은 URL에 PUT 요청으로 파일을 업로드합니다. 여기서 Buffer를 Blob으로 변환하여 업로드합니다.
  • 업로드 완료 처리: files.completeUploadExternal API를 호출하여 업로드를 완료합니다. 여기서 channels 속성을 제거했습니다.
  • 채널에 파일 공유: 업로드된 파일을 chat.postMessage API를 사용하여 채널에 공유합니다. 이 부분이 이전에 빠져있던 중요한 단계입니다. file_ids 배열에 업로드된 파일의 ID를 포함시켜 채널에 해당 파일을 첨부합니다.

각 메소드가 무엇을 하는건지 열심히 분석했지만 파일은 업로드가 안된다.

링크를 버튼으로 다운받게 하면 302 Found 에러가 발생한다.

보통 302에러는 사용자가 인증되지 않은 페이지에 엑세스하려고 할때 발생한다고 함.

다운로드 링크와 공개링크 둘다 안됨...

공개 링크의 경우 "files.sharedPublicURL"(파일을 공개/외부 공유가 가능하도록 함)를 통해 만들었는데도 안됨!

 

# 그래서 어떻게 해결했을까?

- API로 GET하고 POST를 해야한다는게 이해가 안됨

- 예를 들어 온도가 40도를 넘었을때, 변수로 40이라는 숫자가져오고

- "온도 40도가 넘었습니다" 라는 문장을 txt파일로 만들어서 Slack으로 전달하고 싶었음

- 계속 검색하다보니 npm에 slack/web-api 라이브러리 존재

 https://www.npmjs.com/package/@slack/web-api

 

@slack/web-api

Official library for using the Slack Platform's Web API. Latest version: 7.8.0, last published: 3 months ago. Start using @slack/web-api in your project by running `npm i @slack/web-api`. There are 644 other projects in the npm registry using @slack/web-ap

www.npmjs.com

 

- 이걸로 한번 시도해보자!

import { getErrorResponse } from '@/app/utils/helper';
import { config } from '../../../../config';
import { NextRequest } from 'next/server';
import { WebClient } from '@slack/web-api';

/**
 * Slack API를 통한 파일 전송 프로세스
 * 1. WebClient 초기화 (Bot Token 사용)
 * 2. filesUploadV2 메서드로 파일 업로드
 * 3. 업로드된 파일 ID를 사용하여 메시지 전송
 */

type BodyType = {
  location: string;
  message: string;
  fileType?: 'text';
};

export async function POST(req: NextRequest) {
  try {
    // 클라이언트로부터 요청 데이터 파싱
    const { message, location, fileType } = await req.json() as BodyType;
    console.log('[REQUEST] 받은 요청:', { message, location, fileType });

    // Slack WebClient 초기화 - Bot Token 사용
    // Bot Token에는 files:write, chat:write 등의 권한이 필요
    const client = new WebClient(config.slack.botToken);

    if (fileType === 'text') {
      const content = "계약현황";  // 파일에 포함될 텍스트 내용
      const fileName = "state.txt"; // 생성될 파일명

      try {
        // Step 1: 파일 업로드 준비
        console.log('[SLACK] 파일 업로드 시작');
        
        // Step 2: filesUploadV2 메서드 사용하여 파일 업로드
        // - Buffer.from(content): 문자열을 버퍼로 변환 (바이트 단위로 변환)
        // - filename: Slack에 표시될 파일 이름
        // - channels: 파일이 공유될 채널 ID
        // - initial_comment: 파일과 함께 표시될 초기 메시지
        const uploadResponse = await client.filesUploadV2({
          file: Buffer.from(content),          // 텍스트 내용을 버퍼로 변환(중요!)
          filename: fileName,                   // 파일 이름 설정
          title: fileName,                     // Slack에서 표시될 제목
          channels: config.slack.channelId,     // 파일을 공유할 채널
          initial_comment: "새로운 계약현황 파일이 업로드되었습니다."  // 초기 메시지
        });

        console.log('[SLACK] 파일 업로드 응답:', uploadResponse); // 성공했으면 끝!

        // Step 3: 업로드 성공 여부 확인
        if (!uploadResponse.ok) {
          throw new Error('파일 업로드 실패');
        }

        // Step 4: 업로드된 파일 정보로 메시지 전송
        // files 배열의 첫 번째 항목에서 파일 ID를 추출
        const fileId = uploadResponse.files?.[0]?.files;
        if (fileId) {
          // Step 5: chat.postMessage로 파일 관련 메시지 전송
          // - channel: 메시지를 보낼 채널
          // - blocks: 메시지의 구조화된 레이아웃
          // - text: 기본 텍스트 (블록이 표시되지 않을 때 사용)
          const messageResponse = await client.chat.postMessage({
            channel: config.slack.channelId || '',
            text: `텍스트 파일이 업로드되었습니다: ${fileName}`,
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*텍스트 파일이 업로드되었습니다*\n\n• 파일명: ${fileName}\n• 내용: ${content}`
                }
              }
            ],
          });

          console.log('[SLACK] 메시지 전송 결과:', messageResponse);
        }

        // Step 6: 클라이언트에 성공 응답 반환
        return new Response(JSON.stringify({
          ok: true,
          message: '파일이 성공적으로 전송되었습니다.',
          filename: fileName
        }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });

      } catch (error) {
        // 파일 업로드 과정에서 발생한 에러 처리
        console.error('[ERROR] Slack API 오류:', error);
        throw error;
      }
    }

    // 일반 에러 메시지 전송 (파일 업로드가 아닌 경우)
    const messageResponse = await client.chat.postMessage({
      channel: config.slack.channelId || '',
      text: `Error Report\nLocation: ${location}\nMessage: ${message}`
    });

    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });

  } catch (error) {
    console.error('[ERROR]', error);
    return getErrorResponse(
      500, 
      error instanceof Error ? error.message : '오류가 발생했습니다.'
    );
  }
}

 

드디어 다운로드 가능!

 

엑셀 스타일 다운받기

import { getErrorResponse } from '@/app/utils/helper';
import { config } from '../../../../config';
import { NextRequest } from 'next/server';
import { WebClient } from '@slack/web-api';
import XLSX from 'xlsx-js-style';  // xlsx-js-style로 변경

type BodyType = {
  location: string;
  message: string;
  fileType?: 'excel';  // 파일 타입을 엑셀로 변경
};

export async function POST(req: NextRequest) {
  try {
    const { message, location, fileType } = await req.json() as BodyType;
    console.log('[REQUEST] 받은 요청:', { message, location, fileType });

    const client = new WebClient(config.slack.botToken);

    if (fileType === 'excel') {
      // 헤더 데이터 정의 (스타일 포함)
      const headers = [
        {
          v: '계약 ID',
          t: 's',
          s: {
            font: { bold: true, color: { rgb: 'FFFFFF' }, sz: 12 },
            fill: { fgColor: { rgb: '1F497D' } },
            alignment: { horizontal: 'center', vertical: 'center' },
            border: {
              top: { style: 'medium', color: { rgb: '000000' } },
              bottom: { style: 'medium', color: { rgb: '000000' } },
              left: { style: 'thin', color: { rgb: '000000' } },
              right: { style: 'thin', color: { rgb: '000000' } }
            }
          }
        },
        // 나머지 헤더들도 동일한 스타일로 정의
        {v: '데이터센터', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '서버 타입', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: 'CPU', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: 'RAM', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '스토리지', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '계약기간', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '월 비용', t: 's', s: { /* 동일한 헤더 스타일 */ }}
      ];

      // 데이터 행 생성 (스타일 포함)
      const rows = [
        ['DC001', '강남 IDC', '웹 서버', 'Intel Xeon 8코어', '32GB', 'SSD 1TB', 12, 450000],
        ['DC002', '분당 센터', 'DB 서버', 'AMD EPYC 16코어', '64GB', 'NVMe 2TB', 24, 750000],
        ['DC003', '판교 IDC', '백업 서버', 'Intel Xeon 4코어', '16GB', 'HDD 4TB', 6, 300000],
        ['DC004', '광주 센터', '로드밸런서', 'AMD EPYC 8코어', '32GB', 'SSD 500GB', 12, 400000],
        ['DC005', '부산 IDC', '캐시 서버', 'Intel Xeon 12코어', '128GB', 'NVMe 1TB', 24, 600000],
      ].map(row => row.map((cell, index) => {
        const baseStyle = {
          font: { name: 'Arial', sz: 11 },
          alignment: { horizontal: 'center', vertical: 'center' },
          border: {
            top: { style: 'thin', color: { rgb: 'D9D9D9' } },
            bottom: { style: 'thin', color: { rgb: 'D9D9D9' } },
            left: { style: 'thin', color: { rgb: 'D9D9D9' } },
            right: { style: 'thin', color: { rgb: 'D9D9D9' } }
          }
        };

        // 각 컬럼별 특수 스타일 적용
        if (index === 0) {  // 계약 ID
          return {
            v: cell,
            t: 's',
            s: {
              ...baseStyle,
              font: { ...baseStyle.font, color: { rgb: '0066CC' } }
            }
          };
        } else if (index === 6) {  // 계약기간
          return {
            v: cell,
            t: 'n',
            s: {
              ...baseStyle,
              alignment: { horizontal: 'right' },
              numFmt: '0"개월"'
            }
          };
        } else if (index === 7) {  // 월 비용
          return {
            v: cell,
            t: 'n',
            s: {
              ...baseStyle,
              font: { ...baseStyle.font, color: { rgb: 'FF0000' } },
              alignment: { horizontal: 'right' },
              numFmt: '#,##0"원"'
            }
          };
        }
        return { v: cell, t: 's', s: baseStyle };
      }));

      // 워크북 생성
      const wb = XLSX.utils.book_new();
      const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);

      // 열 너비 설정
      ws['!cols'] = [
        { wch: 12 },  // 계약 ID
        { wch: 15 },  // 데이터센터
        { wch: 12 },  // 서버 타입
        { wch: 20 },  // CPU
        { wch: 10 },  // RAM
        { wch: 12 },  // 스토리지
        { wch: 10 },  // 계약기간
        { wch: 15 }   // 월 비용
      ];

      // 행 높이 설정
      ws['!rows'] = [{ hpt: 30 }];  // 헤더 행 높이

      // 워크시트를 워크북에 추가
      XLSX.utils.book_append_sheet(wb, ws, "계약현황");

      // 엑셀 파일 생성
      const excelBuffer = XLSX.write(wb, {
        type: 'buffer',
        bookType: 'xlsx'
      });

      const fileName = `datacenter_contracts_${new Date().toISOString().split('T')[0]}.xlsx`;

      try {
        console.log('[SLACK] 파일 업로드 시작');
        
        const uploadResponse = await client.filesUploadV2({
          file: excelBuffer,
          filename: fileName,
          title: '데이터센터 계약현황',
          channels: config.slack.channelId,
          initial_comment: "📊 최신 데이터센터 계약현황 보고서가 업로드되었습니다."
        });

        console.log('[SLACK] 파일 업로드 응답:', uploadResponse);

        if (!uploadResponse.ok) {
          throw new Error('파일 업로드 실패');
        }

        // 메시지 전송
        const fileId = uploadResponse.files?.[0]?.files;
        if (fileId) {
          await client.chat.postMessage({
            channel: config.slack.channelId || '',
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*데이터센터 계약현황 보고서*\n\n• 파일명: ${fileName}\n• 업데이트: ${new Date().toLocaleString()}`
                }
              }
            ],
          });
        }

        return new Response(JSON.stringify({
          ok: true,
          message: '엑셀 파일이 성공적으로 전송되었습니다.',
          filename: fileName
        }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });

      } catch (error) {
        console.error('[ERROR] Slack API 오류:', error);
        throw error;
      }
    }

    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });

  } catch (error) {
    console.error('[ERROR]', error);
    return getErrorResponse(
      500, 
      error instanceof Error ? error.message : '오류가 발생했습니다.'
    );
  }
}
728x90

+ Recent posts