Profile picture

[Python] 나만의 슬랙 봇(Slack Bot) 만들기

JaehyoJJAng2024년 10월 25일

개요

슬랙에서 운영 중인 커뮤니티를 활발하게 운영할 수 있는 나만의 슬랙 봇을 만들어보자.

가상의 시나리오를 부여하고, 시나리오에서 발견된 문제점들을 '나만의 슬랙 봇'으로 해결하는 과정을 기록해보려고 한다.


시나리오

발단: 커뮤니티 생성

책을 좋아하는 두식이는 '독서 모임' 이라는 커뮤니티를 만들게 되었다.

매일 독서를 하고 그중 인상 깊었던 문장 하나를 슬랙 채널에 인증하는 방식 으로 슬랙 커뮤니티를 운영하려고 함.


전개: 커뮤니티의 부흥

독서 취미를 가진 사람들의 입소문으로 인해 새로운 멤버들이 커뮤니티에 점점 정착 되었고 이로인해 공유되던 몇 개의 문장들이 수 백개로 늘어나게 되었다.


위기1: 수동 인증으로 인한 운영 부담

커뮤니티 멤버가 늘어나며 매일 수 백개의 문장을 직접 확인해야 함.

두식이는 이 과정에서의 시간 소모가 크고 본인에게 많은 부담을 주게 됨.

[운영자 두식이의 불만]

"문장을 하나하나 확인하고 인증하는 것이 오래 걸려 부담이 생겨요."


[멤버 a의 불만]

인증이 언제 처리되는지 몰라 제출이 잘 된건지 확인이 어려워요


위기2: 인증 누락과 문의 증가

두식이가 문장을 확인하는 과정에서 일부 문장을 놓치고 누락하는 경우가 발생함.

멤버들이 인증 여부를 문의하는 일이 늘었고. 관리자인 두식이는 그때마다 다시 확인해야만 함.


[운영자 두식이의 불만]

실수로 인증을 누락하는 경우가 있어 멤버들로부터 불만이 제기되고 있어요


[멤버 b의 불만]

분명 제출했는데 인증이 되지 않아서 다시 문의를 해야해요.


위기3: 사라지는 슬랙 메시지

슬랙 무료 플랜은 메시지 보존 기한에 제한이 있어 오래된 메시지는 자동으로 삭제된다.

멤버들은 자신이 공유한 문장을 다시 볼 수 없어 아쉬워했다.


문제 해결 방법 제시

위 시나리오의 문제점들에 대한 해결법을 고민해보자.

  • 1. 수동 인증 확인으로 인한 운영 부담
    • 멤버가 문장 공유 시, 봇이 자동으로 인증 체크
  • 2. 인증 누락과 문의 증가
    • 멤버가 문장 공유 시, 봇이 인증 완료 메시지 전송
  • 3. 사라지는 슬랙 메시지
    • 멤버가 문장 공유 시, 봇이 별도의 저장소에 문장 보관(파일 시스템, 외부 DB ..)

봇 구조

구현하려는 봇의 구조를 간략화하면 그림과 다음과 같다.
image

  • 1. 사용자는 슬랙 워크스페이스를 통해 봇 서버와 소통
  • 2. 봇 서버는 SlackBolt 프레임워크를 통하여 동작
    • Slackbolt로 쉽고 빠른 개발이 가능
  • 3. 봇 서버는 CSV 파일 을 이용하여 데이터 저장
    • 파일 시스템을 이용해 외부 DB 연동 없이 로컬에서 쉽게 개발 가능

봇 생성

슬랙 봇 생성 방법은 대강 다음과 같다.

  • 1. 슬랙 API 사이트 방문 (https://api.slack.com)
  • 2. 앱 생성
    • 'Create New App' 선택
    • 앱 이름과 워크스페이스 지정
    • Socket Mode 활성화 및 기본적인 앱 설정
  • 3. 봇을 슬랙 워크스페이스에 추가
  • 4. 봇을 슬랙 채널에 추가

1. 슬랙 API 사이트 방문하여 앱 생성하기
image


2. 소켓 모드 활성화 및 토큰 이름 설정
image
발급된 토큰은 환경 변수 파일 또는 어딘가에 저장


3. Event Subscriptions 활성화
image


4. 구독할 이벤트 추가하기
image


5. Oauth & Permissions에서 'chat.write' 스코프 추가
image


6. App home에서 앱 디스플레이 이름 만들어주기
image


7. Install App으로 가서 'Install to Workspace'를 눌러 앱을 워크스페이스에 설치
image


8. 채널에 봇 추가

슬랙으로 이동 후 '두식이 봇' 오른쪽 클릭하여 '앱 세부 정보 보기' - '이 앱을 채널에 추가' 클릭
image



[Tutorial] 봇으로 인사하기


아래 기본 코드를 기반으로 기능 구현 챕터가 진행됨.

from dotenv import load_dotenv
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
import os


def environment(env_file_path: str = ".env") -> dict:
    load_dotenv()
    return {
        "SLACK_BOT_TOKEN": os.getenv("SLACK_BOT_TOKEN"),
        "SLACK_APP_TOKEN": os.getenv("SLACK_APP_TOKEN"),
    }


# 토큰 정보
token_infos: dict = environment()

# Install the slack app
app = App(token=token_infos["SLACK_BOT_TOKEN"])


# Add middleware / listeners here
@app.message("안녕")
def say_hello(message, say):
    user = message["user"]
    say(f"Hi there, <@{user}>")


if __name__ == "__main__":
    handler = SocketModeHandler(app, token_infos["SLACK_APP_TOKEN"])
    handler.start()
python main.py

서버를 실행한 후, 슬랙에서 '안녕' 이라고 쳐보면 다음과 같은 응답이 온다.
image


데이터 수집 방법

시나리오를 기준으로 데이터를 어떻게 효율적으로 수집할 것인지 그 방법들을 고민해보자.


  • 키워드 로 수집하는 방법
    • 특정 키워드가 포함된 메시지를 감지하여 데이터 수집
    • Ex. 사용자가 $제출 키워드를 넣으며 해당 메시지 수집
  • 이모지 로 수집하는 방법
    • 사용자가 메시지에 특정 이모지를 추가할 때 데이터 수집
    • Ex. 메시지에 '✅' 이모지를 붙이면 해당 메시지 수집
  • 명령어 & 모달 로 수집하는 방법
    • 사용자가 특정 명령어를 입력하면 모달(팝업)을 통해 데이터를 입력받아 수집
    • Ex. 사용자가 /문장수집 명령어 입력 시, 모달을 띄운 후 '제출' 버튼을 누르면 수집

여기서 우리는 명령어 & 모달 로 수집하는 방법으로 봇을 구현해볼거다.

  • 이유1. 모달은 사용자가 데이터 입력 시 값이 유요한지 여부 검증 가능
  • 이유2. 정해둔 폼으로 데이터를 입력받기 때문에 정형화된 데이터 수집 가능
  • 이유3. 정형화된 데이터는 일관성을 보장하기 때문에 데이터 처리와 분석에 용이

[기능 구현] 문장 수집

명령어 & 모달 의 구현 순서는 다음과 같다.

  • 문장을 제출하는데 사용하는 '/문장제출' 명령어 설정
  • 명령어를 수신 받을 수 있는 command 함수 추가
  • 책 제목, 문장, 생각을 입력할 수 있는 모달 창 띄우기
  • 데이터 입력 및 제출

1. '/문장제출' 커맨드 설정

'Slash Commands' -> 'Create New Command' 클릭하여 새로운 커맨드 생성
image


앱 Reinstall 후 테스트
image


2. 명령어를 수신 받을 수 있는 command 함수 추가

# Add middleware / listeners here
@app.command("/문장제출")
def handle_submit_command(ack, body, client):
    print(body)

3. 책 제목, 문장, 생각을 입력할 수 있는 모달창 띄우기

@app.command("/문장제출")
def handle_submit_modal(ack, body, client):
    ack()
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "submit_view",
            "title": {"type": "plain_text", "text": "제출하기"},
            "submit": {"type": "plain_text", "text": "제출"},
            "close": {"type": "plain_text", "text": "취소"},
            "blocks": [
                {
                    "type": "input",
                    "block_id": "title_block_id",
                    "label": {
                        "type": "plain_text",
                        "text": "책 제목",
                    },
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "input_action_id",
                        "multiline": False,
                        "placeholder": {
                            "type": "plain_text",
                            "text": "책 제목을 입력해주세요",
                        },
                    },
                },
                {
                    "type": "input",
                    "block_id": "sentence_block_id",
                    "label": {
                        "type": "plain_text",
                        "text": "오늘의 문장",
                    },
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "input_action_id",
                        "multiline": True,
                        "placeholder": {
                            "type": "plain_text",
                            "text": "기억에 남는 문장을 입력해주세요.",
                        },
                    },
                },
                {
                    "type": "input",
                    "block_id": "comment_block_id",
                    "label": {
                        "type": "plain_text",
                        "text": "생각 남기기",
                    },
                    "optional": True,
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "input_action_id",
                        "multiline": True,
                        "placeholder": {
                            "type": "plain_text",
                            "text": "생각을 자유롭게 남겨주세요",
                        },
                    },
                },
            ],
        },
    )

테스트 해보면 다음과 같은 모달 창이 정상적으로 뜬다.
image


[기능 구현] 문장 저장

문장 저장 및 완료 메시지 전송

  • 모달 데이터를 전달 받는 view 함수 추가
  • 문장 제출이 가능한 채널인지 검증
  • 제출한 문장이 3글자 이상인지 검증
  • 저장할 데이터 추출
  • 파일 시스템을 이용한 데이터 저장 (CSV)
  • 완료 메시지 가공 및 전송

1. 모달 데이터를 전달 받는 view 함수 추가

image
각 항목에 입력되는 데이터를 view 함수를 구현하여 받아보자.

@app.view("submit_view")
def handle_view_submit_events(ack, body, logger) -> None:
    print(body)
    ack()

프로그램을 띄워보고 body에서 어떤 값들을 넘겨주고 있는지 확인해주도록 하자.


2. 채널 ID(channel_id) 가져오기

메시지를 보내온 채널이 문장 제출이 가능한 채널인지 검증하는 로직을 구현해야 한다.

그러기 위해서는 채널 ID 값이 필요한데 view 함수에 넘어오는 body 값에는 채널 ID가 존재하지 않는다.


채널 ID는 위에서 작성한 /문장제출 커맨드의 함수의 body 값에서 확인 가능한데

한 번 확인해보자.

@app.command("/문장제출")
def handle_submit_modal(ack, body, client):
    print(body)
    ack()

image


이제 해당 값을 view 함수로 넘겨주는 작업이 필요한데, 이 작업은 간단하게 처리가 가능하다.

@app.command("/문장제출")
def handle_submit_modal(ack, body, client):
    ack()
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "submit_view",
            "private_metadata": body["channel_id"],
            ...

handle_submit_modal 함수의 view에 private_metadata 키를 추가하면

view 함수에서 해당 키가 그대로 넘어온다.
image
view 함수에서 print(body)하여 body 값을 읽어온 결과이다.

보면 view 함수에서 private_metadta의 값으로 channel_id를 넘겨주고 있는 것을 볼 수 있다.

위 작업으로 알 수 있는 것은 command 함수에서 view 함수로 임의의 데이터를 넘겨줄 수가 있다는 것이다.


그럼 이제 채널 ID를 view 함수에서 아래와 같이 추출하면 된다.

@app.view("submit_view")
def handle_view_submit_events(ack, body, logger) -> None:
    channel_id: str = body['view']["private_metadata"]

3. 문장 제출이 가능한 채널인지 검증

메시지가 보내진 채널의 ID를 추출하는 로직을 구현하였으니, 이제 채널 검증 로직만 구현해주면 된다.

먼저 '오늘의 문장' 이라는 채널의 세부 정보를 들어가서 해당 채널의 ID를 복사해주자.
image


그러면 이제 간단하다. '오늘의 문장' 채널 ID가 메시지가 보내진 채널의 ID와 일치하는지 검증만 하면 된다.

@app.view("submit_view")
def handle_view_submit_events(ack, body, logger) -> None:
    channel_id: str = body["view"]["private_metadata"]
    target_channel_id: str = "C07TTPD410D"
    
    # 채널 검증
    if channel_id != target_channel_id:
        # 검증 실패 시 에러 메시지 띄우기
        ack(
            response_action="errors",
            errors={"sentence_block_id": "#오늘의 문장 채널에서만 제출할 수 있습니다!"},
        )
        return None
    ack()

채널 ID 검증 화면은 다음과 같다.
image


3. 제출한 문장이 3글자 이상인지 검증

@app.view("submit_view")
def handle_view_submit_events(ack, body, logger) -> None:
    channel_id: str = body["view"]["private_metadata"]
    target_channel_id: str = "C07TTPD410D"

    # 채널 검증
    ...

    # 데이터 검증
    sentence :str = body['view']['state']['values']['sentence_block_id']['input_action_id']['value']
    if len(sentence) < 3:
        # 데이터 검증 실패로 인한 에러 메시지 띄우기
        ack(
            response_action="errors",
            errors={"sentence_block_id": "#오늘의 문장은 세 글자 이상 입력하여야 합니다!"},
        )
        return None
    ack()

데이터 검증 화면은 다음과 같다.
image


4. 저장할 데이터 추출

코드를 작성하기 전에, 먼저 Oauth & Permissions에서 users.read 스코프를 추가해줘야 한다.
image

이제, 저장할 데이터(멤버 이름, 책 제목, 오늘의 문장, 생각, 생성일시(제출일시))를 추출해보자.



handle_view_submit_event() 함수의 파라미터로 client가 추가되었다. (logger 파라미터는 지금 당장 사용되지 않으니 지워도 무방함.)

@app.view("submit_view")
def handle_view_submit_events(ack, body, client) -> None:
    channel_id: str = body["view"]["private_metadata"]
    target_channel_id: str = "C07TTPD410D"

    # 채널 검증
    ...
    # 데이터 검증
    ...

    # 저장 데이터용 사전변수 정의
    data_dict: dict = dict()

    # 저장할 데이터
    # 멤버 이름: 한글 이름을 가져오려면 유저 정보를 따로 불러와야 함.
    # user 인자의 값으로는 body에 user.id 값을 가져와야 함.
    user_id = body['user']['id']
    user_info = client.users_info(user=user_id)
    user_name :str = user_info['user']['real_name']

    # 책 제목
    book_title :str = body['view']['state']['values']['title_block_id']['input_action_id']['value']

    # 오늘의 문장: 3글자 검증할 때 위에서 추출했으므로 pass

    # 생각 의견
    comment :str = body['view']['state']['values']['comment_block_id']['input_action_id']['value']

    # 생성일시(제출일시)
    from datetime import datetime
    created_at :str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    # 데이터 삽입
    data_dict["user_id"] = user_id
    data_dict["userName"] = user_name
    data_dict["bookTitle"] = book_title
    data_dict["comment"] = comment
    data_dict["createdAt"] = created_at
    ack()    

5. 파일 시스템을 이용한 데이터 저장 (CSV)

def create_dir(dir_name: str) -> None:
    """폴더 생성 함수"""
    if not os.path.exists(dir_name):
        os.makedirs(dir_name)

def save_data_to_csv(dir_name: str, data: dict) -> None:
    """데이터를 CSV로 저장하는 함수"""
    file_name: str = os.path.join(dir_name, "contents.csv")
    with open(file_name, "a", newline="", encoding='utf-8') as csvfile:
        writer = csv.writer(csvfile=csvfile)

        if not os.path.getsize(file_name) > 0:
            writer.writerow(
                ["user_id", "user_name", "book_title", "sentence", "comment", "created_at"]
            )

        writer.writerow(
            [
                data["user_id"],
                data["user_name"],
                data["book_title"],
                data["sentence"],
                data["comment"],
                data["created_at"],
            ]
        )

@app.view("submit_view")
def handle_view_submit_events(ack, body, client) -> None:
    channel_id: str = body["view"]["private_metadata"]
    target_channel_id: str = "C07TTPD410D"

    # 채널 검증
    ...
    # 데이터 검증
    ...
    # 데이터 삽입
    ...

    # 데이터 저장용 폴더 생성
    dir_name: str = "data"
    create_dir(dir_name=dir_name)

    # 데이터 저장
    save_data_to_csv(dir_name=dir_name, data=data_dict)        
    ack()

테스트를 위해 아래와 같은 데이터를 입력하고
image


데이터가 정상적으로 저장되는지 확인해보자.
image
정상적으로 저장 되었다.


6. 완료 메시지 가공 및 전송

@app.view("submit_view")
def handle_view_submit_events(ack, body, client) -> None:
    channel_id: str = body["view"]["private_metadata"]
    target_channel_id: str = "C07TTPD410D"

    # 채널 검증
    ...
    
    # 데이터 검증 
    ...

    # 데이터 삽입
    ...

    # 데이터 저장
    ...

    # 완료 메시지 가공 
    text :str = f">>> *<@{user_id}>님이 `{book_title}`에서 뽑은 오늘의 문장*\n\n'{sentence}'\n"
    
    if comment:
        text += f"\n✍️ {comment}\n"
    # 완료 메시지 전송: channel_id 변수의 경우 '채널 검증' 에서 이미 구했었음
    client.chat_postMessage(channel=channel_id, text=text)

    ack()

아래처럼 데이터 입력을 하고 전송을 누르면
image


이런 결과가 채널 메시지로 넘어오게 된다. image


[기능 구현] 문장 조회

멤버 DM 채널로 제출내역 반환

  • 제출 내역을 조회하는데 사용하는 /제출내역 명령어 설정
  • 명령어를 수신 받을 수 있는 command 함수추가
  • 멤버의 DM 채널 ID 가져오기
  • 멤버의 제출내역 필터링
  • 임시 제출내역 파일 생성
  • 멤버 DM 채널로 제출내역 파일 전송
  • 임시 제출내역 파일 삭제

1. /제출내역 커맨드 설정

'Slash Commands' -> 'Create New Command' 클릭하여 새로운 커맨드 생성
image


앱 Reinstall 후 테스트
image


2. 명령어를 수신 받을 수 있는 command 함수 추가

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()

3. 멤버의 DM 채널 ID 가져오기

conversations_open 메소드를 사용하려면 먼저 다음과 같은 스코프를 Oauth & Permissions에서 등록해줘야 한다.
image


이제 멤버의 DM 채널 ID를 가져와보자.

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    # 사용자의 DM 채널 ID 가져오기
    response :dict = client.conversations_open(users=body['user_id'])
    dm_channel_id :str = response['channel']['id']

4. 멤버의 제출내역 필터링

해당 로직의 경우 다음과 같은 순서로 구현하려고 한다.

  • 제출내역 파일이 없는 경우 제출내역이 없다고 메시지 전송하고 종료
    • data/contents.csv 해당 파일이 없는 경우
  • 사용자의 제출내역만 필터링
  • 제출내역이 없다면 제출내역이 없다고 메시지 전송하고 종료
    • data/contents.csv 파일이 있지만, 해당 파일 내에 내역이 없는 경우
@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    # 사용자의 DM 채널 ID 가져오기
    user_id :str = body['user_id']
    response :dict = client.conversations_open(users=user_id)
    dm_channel_id :str = response['channel']['id']

    # 제출내역 파일이 없는 경우 제출내역이 없다고 메시지 전송하고 종료
    if not os.path.exists("data/contents.csv"):
        client.chat_postMessage(channel=dm_channel_id, text=f"제출내역이 없습니다.")
        return

    # 사용자의 제출내역만 필터링
    submission_list :list[dict] = []
    with open("data/contents.csv", 'r', encoding='utf-8') as csvfile:
        reader = csv.DictReader(csvfile)
        fieldnames = reader.fieldnames
        for row in reader:
            if row["user_id"] == user_id:
                submission_list.append(row)

    # 제출내역이 없다면 제출내역이 없다고 메시지 전송하고 종료
    if not submission_list:
        client.chat_postMessage(channel=dm_channel_id, text=f"제출내역이 없습니다.")
        return

테스트 과정은 다음과 같다.


먼저, 프로젝트 폴더 내에 있는 data 폴더를 삭제하고 /제출내역 명령어를 실행해보자.
image
그럼 파일이 존재하지 않아 위처럼 개인 DM으로 '제출내역이 없습니다' 라고 메시지가 올거다.


그리고 필터링 테스트를 위해 다시 제출내역을 아무거나 생성해주자.
image


그리고 user_id를 기준으로 필터링된 제출내역에 대한 리스트를 출력해보자.(출력 후 print문은 삭제하도록 함.)
image
데이터가 1개뿐이지만, 정상적으로 필터링되고 있다.


마지막으로, submission_list가 빈 값(제출내역이 없는 경우)이면 동일하게 메시지 전송 후 종료된다.


5. 임시 제출내역 파일 생성

현재 submission_list 변수에 배열 형태로 사용자의 제출내역이 남아있는 상태임.

이걸 임시 파일(csv)로 만들어서 임시 저장 후 전송할 것임.

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    # 사용자의 DM 채널 ID 가져오기
    ...

    # 제출내역 파일이 없는 경우 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역만 필터링
    ...

    # 제출내역이 없다면 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역을 CSV 파일로 임시 저장 후 전송
    temp_dir :str = "data/temp"
    if not os.path.exists(temp_dir):
        os.mkdir(temp_dir)
    temp_file_path :str = os.path.join(temp_dir,f"{user_id}.csv")
    with open(temp_file_path, 'w', newline='', encoding='utf-8') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames)
        writer.writeheader()
        writer.writerows(submission_list)

6. 멤버 DM 채널로 제출내역 파일 전송

파일을 보내기 위해서 files.write 라는 스코프가 필요하다. Oauth & Permissions에서 추가해주자.


@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    # 사용자의 DM 채널 ID 가져오기
    ...

    # 제출내역 파일이 없는 경우 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역만 필터링
    ...

    # 제출내역이 없다면 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역을 CSV 파일로 임시 저장 후 전송
    ...

    # 제출내역 파일 전송
    client.files_upload_v2(channel=dm_channel_id, file=temp_file_path, initial_comment = f"<*{user_id}> 님의 제출내역 입니다!")

프로그램을 실행하여 /제출내역 명령어를 실행해보면 다음과 같이 메시지가 보내지면 성공이다.
image


7. 임시 제출내역 파일 삭제

마지막으로 임시 파일을 삭제 해주는 로직을 추가하면 끝이다.

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    # 사용자의 DM 채널 ID 가져오기
    ...

    # 제출내역 파일이 없는 경우 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역만 필터링
    ...

    # 제출내역이 없다면 제출내역이 없다고 메시지 전송하고 종료
    ...

    # 사용자의 제출내역을 CSV 파일로 임시 저장 후 전송
    ...

    # 제출내역 파일 전송
    ...

    # 임시로 생성한 csv 파일 삭제
    os.remove(temp_file_path)

[기능 구현] 관리자 커맨드

관리자 커맨드 및 전체 제출내역 반환

  • 관리자 메뉴를 호출하는 /관리자 명령어 설정
  • 명령어를 수신 받을 수 있는 command 함수추가
  • 관리자 검증 (나에게만 표시)
  • 관리자 메뉴 버튼 제공 (나에게만 표시)
  • 버튼 '전체 제출내역 조회'에 대한 액션 함수 추가
  • 관리자 DM 채널로 전체 제출내역 반환

1. /관리자 커맨드 설정

'Slash Commands' -> 'Create New Command' 클릭하여 새로운 커맨드 생성
image


앱 Reinstall 후 테스트
image


2. 명령어를 수신 받을 수 있는 /관리자 커맨드 함수 추가

@app.command("/관리자")
def handle_admin_command(ack, body, client: WebClient) -> None:
    ack()

3. 관리자 검증

@app.command("/관리자")
def handle_admin_command(ack, body, client: WebClient) -> None:
    ack()

    # 관리자 검증: 관리자인지 확인 후 아니라면 메시지 전송 후 종료
    user_id: str = body["user_id"]
    user_infos: dict = client.users_info(user=user_id)
    is_admin: bool = user_infos["user"]["is_admin"]
    if is_admin is not True:
        # 메시지 전송: 자기자신에게만 보이는 메시지
        client.chat_postEphemeral(
            channel=body["channel_id"],
            user=user_id,
            text="관리자만 사용 가능한 명령어입니다.",
        )
        return

4. 관리자 메뉴 버튼 제공

@app.command("/관리자")
def handle_admin_command(ack, body, client: WebClient) -> None:
    ack()

    # 관리자 검증: 관리자인지 확인 후 아니라면 메시지 전송 후 종료
    ...

    # 관리자용 메뉴 버튼 생성
    client.chat_postEphemeral(
        channel=body['channel_id'],
        user=user_id,
        text="관리자 메뉴를 선택해주세요.",
        blocks=[ 
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "전체 제출내역 조회",
                            "emoji": True
                        },
                        "value": "admin_value_1",
                        "action_id": "fetch_all_submissions" # 액션 함수명으로 변경해줘야함.
                    }
                ]
            }

        ]
    )

버튼은 다음과 같이 생성된다.
image
그러나 액션 함수가 명시되지 않아서 버튼 클릭 시 정상 실행은 기대하기 어렵다.


5. 버튼 '전체 제출내역 조회'에 대한 액션 함수 추가

@app.action("fetch_all_submissions")
def handle_some_action(ack, body, client: WebClient) -> None:
    ack()

6. 관리자 DM 채널로 전체 제출내역 반환

@app.action("fetch_all_submissions")
def handle_some_action(ack, body, client: WebClient) -> None:
    ack()

    user_id :str = body['user']['id']

    # 관리자의 DM 채널 ID 가져오기
    response = client.conversations_open(users=user_id)
    dm_channel_id :str = response['channel']['id']

    # 전체 제출내역 불러와서 전송
    file_path :str = "data/contents.csv"
    if not os.path.exists(file_path):
        client.chat_postMessage(channel=dm_channel_id, text="제출내역이 없습니다")
        return
        
    client.files_upload_v2(
        channel=dm_channel_id,
        file=file_path,
        initial_comment="전체 제출내역 입니다."
    )


ack()의 용도

ack()는 슬랙이 서버로 이벤트를 보낸 이후에 서버로부터 수신 확인 응답을 받아야 한다.

ack()는 수신 확인 응답을 받았다는 것을 슬랙에 확인시켜 주는 것이다.

만약 함수에 ack()를 명시하지 않으면
image
다음처럼 정상적인 요청을 보내더라도 연결에 문제가 발생하게 된다.


client 매개변수의 타입힌트 지정

실습을 진행하다보면 client 매개변수의 타입을 지정하지 않았기에 파이썬 자동완성이 되지 않아 불편한 점이 존재한다.

자동완성의 이점을 살리기 위해 커맨드 함수에서 client 매개변수의 타입을 출력해보고 어떤 타입이 반환되는지 확인해보자.

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client) -> None:
    ack()
    print(type(client))

실행해보면 다음과 같이 출력된다.

<class 'slack_sdk.web.client.WebClient'>

위 모듈을 import하고 clinet 매개변수에 타입을 지정해주자.

from slack_sdk import WebClient

@app.command("/제출내역")
def handle_submission_history_command(ack, body, client: WebClient) -> None:
    ack()

출처

[인프런 무료 강의] 커뮤니티에서 바로 써먹는 슬랙 봇 만들기


Loading script...