코드로 우주평화

슬랙 봇이 보낸 메시지를 수정해봅시다 본문

나는 이렇게 논다/글또 슬랙 봇 개발기

슬랙 봇이 보낸 메시지를 수정해봅시다

daco2020 2024. 10. 17. 17:32
반응형

슬랙 봇이 어떤 채널에 메시지를 보냈다고 가정해 봅시다. 그런데 만약, 메시지에 문제가 있어 봇이 보낸 메시지를 수정해야 한다면 어떻게 해야 할까요?

 

아래는 슬랙 봇의 메시지를 수정한 모습입니다.

 

이번 글에서는 슬랙 봇의 메시지를 수정하는 경우와 그 방법에 대하여, 또봇(글또 슬랙 봇)의 구체적인 사례로 설명드리겠습니다.

 

 

메시지 수정이 필요한 상황

제가 속해있는 글또라고 하는 커뮤니티는 개발자들이 글을 쓰고 봇을 통해 제출하는 커뮤니티입니다. 현재 10기의 멤버 수는 640명이죠. 커뮤니티 인원 수가 많다보니 글을 실수로 잘못 제출하거나 불가피하게 블로그 링크가 바뀌어 메시지 수정이 필요한 때가 종종 생깁니다.

 

예를 들어, 다음과 같은 상황입니다.

 

글또 커뮤니티는 글을 제출하면 아래와 같은 형식으로 봇이 채널에 메시지를 보내는데요.

 

앞서 문의 내용처럼 제출한 후에 링크나 글 제목이 바뀌는 경우가 있습니다.

 

이 경우에 저장된 DB 데이터는 쉽게 수정할 수 있습니다만, 봇이 채널에 보낸 메시지는 정적인 데이터이기에 메시지 내용을 수정할 수가 없었습니다. 그래서 기존에는 해당 메시지를 삭제하고 멤버가 다시 제출하거나, 새롭게 바뀐 링크를 스레드에 남기는 식으로 넘어갔습니다.

 

하지만 이와 같은 조치는 몇 가지 문제가 있었습니다.

 

첫 번째는 포인트 시스템에 오류가 발생한다는 점입니다. 이번 글또 10기 부터는 글을 제출하면 또봇이 포인트를 지급하고, 해당 채널에서 몇 번째로 제출하냐에 따라 1등, 2등, 3등 보너스 포인트를 지급합니다. 추가로 한 회차에 글을 여러 번 제출하면 추가 포인트도 지급하죠. 즉, 글을 다시 제출하면 추가 포인트가 잘못 지급되고, 제출 순서도 뒤엉켜 포인트 지급이 꼬이게 됩니다.

 

두 번째는 다른 멤버들이 잘못된 메시지인지 알 수 없다는 것입니다. 만약 스레드에 정상 링크를 남겼다 하더라도 다른 멤버들이 스레드를 일일이 확인하지 않는 한, 이 메시지가 잘못된 것인지 알 수 없습니다. 다른 멤버들이 메시지를 볼 때, '이 분 글은 뭔가 이상하네...' 라며 그냥 넘어갈 수 있는 거죠.


글또의 시스템과 멤버들의 커뮤니티 경험을 위해서라도, 메시지 수정 문제는 가장 우선적으로 해결해야 한다고 생각했습니다.

 

 

관리자 명령어? 서버 API?

앞서의 문제를 해결하기 위해, 메시지 수정 기능을 개발하기로 마음먹었습니다. 그리고 이를 어떤 방식으로 해결할까 고민했죠.

 

우선, 기존 슬랙 봇 개발 방식대로 관리자 명령어에 해당 기능을 추가하는 방법이 있었습니다. 슬랙 안에서 모달 창을 띄우고 데이터를 입력해 처리하도록 이벤트를 발생시키는 거죠.

 

다른 방법으로는 또봇 서버의 API에 직접 요청하여 메시지 수정을 하는 방법이 있었습니다. 또봇은 이미 FastAPI로 '글 검색'이나 '종이비행기(감사 편지를 조회하는 기능)' 등의 API를 제공하고 있습니다. 이와 마찬가지로 메시지 수정 API를 추가로 제공하는 거죠. 

 

저는 후자의 방법을 선택했습니다. 그 이유는 우선 서버 API가 상대적으로 더 쉽고 빠르게 구현이 가능했습니다. 그리고 또봇이 처리하는 이벤트가 이미 많기 때문에 자주 사용하지 않는 기능이라면 슬랙 이벤트보다는 서버 API로 만드는 것이 낫겠다고 판단했습니다.

 

자, 그럼 이제부터 어떻게 메시지를 수정할 수 있는지 구현 과정을 보여드리겠습니다.

 

 

어떻게 수정할 수 있는데?

참고로 또봇은 SlackBolt라는 라이브러리를 사용하며, 비동기로 동작하기 위해 AsyncApp 과 AsyncWebClient 를 사용하고 있습니다. 

 

여기서 AsyncWebClient 에는 슬랙과 상호작용할 수 있는 다양한 메서드들이 있는데요. 그중에서 chat_update 라는 메서드를 사용하면 특정 메시지의 내용을 수정할 수 있습니다.

# slack_app 이 AsyncApp 으로 선언된 객체이며 slack_app.client 가 AsyncWebClient 입니다.

await slack_app.client.chat_update(
    channel=channel_id,
    ts=ts,
    blocks=blocks,
)

 

chat_update 메서드는 다양한 파라미터를 받지만 이번에 우리가 사용할 파라미터는 channel, ts, blocks 입니다.

 

- channel : channel_id 를 넣습니다. 메시지가 속한 채널 ID를 뜻하죠.

- ts : 수정하고자 하는 메시지의 타임스탬프 넣습니다. 이는 메시지의 ID 라고 보시면 됩니다. 슬랙은 기본적으로 이 ts 를 이용해 메시지를 식별합니다. 

- blocks : 메시지 내용이 담긴, 배열 내 딕셔너리 형태의 데이터를 넣습니다. 이때, 데이터는 수정할 데이터만 담는 게 아니라 수정하지 않는 기존 데이터도 모두 포함해야 합니다.

 

해당 코드를 한 문장으로 설명하자면, '특정 채널의 특정 메시지의 내용을 새롭게 업데이트 한다.' 가 되겠습니다.

 

이로써 메시지는 수정할 수 있지만,,, 수정할 메시지의 채널 id 와 ts 를 사전에 알고 있어야 수정이 가능합니다. 다행히도 또봇은 해당 정보들을 가지고 있습니다. 또봇은 글 제출 메시지를 보낼 때 생성된 ts 와 채널 정보를 DB 에 저장해두고 있죠.

 

그렇다면 이제 또봇 서버의 API로 ts 와 channel_id 를 받아보겠습니다.

 

 

API 파라미터로 필요한 정보 받기

또봇은 FastAPI 를 사용하고 있습니다. FastAPI 를 이용하여 다음과 같이 API를 작성해 봅시다.

 

from fastapi import Depends, Query


@router.post(
    "/contents/{ts}",
    status_code=status.HTTP_200_OK,
)
async def update_content(
    ts: str,
    channel_id: str,
    new_content_url: HttpUrl | None = Query(
        None, description="수정할 링크, 빈 값이면 기존 링크를 사용합니다."
    ),
    new_title: str | None = Query(
        None, description="수정할 제목, 빈 값이면 기존 제목을 사용합니다."
    ),
    user: SimpleUser = Depends(current_user), # 로그인 유저 정보를 가져옵니다.
):

 

먼저, ts 와 channe_id 를 문자열로 받습니다.

 

추가로 new_content_url 과 new_title 파라미터를 받도록 했습니다. 이는 수정할 url 이나 글 제목을 의미합니다. 이 둘을 옵셔널로 받도록 한 이유는 실제 수정 요청 시, 링크만 수정하거나 글 제목만 수정하는 경우가 있을 수 있기 때문입니다.

 

Query 객체를 사용해 파라미터를 표현한 이유는 이렇게 했을 때 openAPI 문서에 친절한 설명을 달아줄 수 있기 때문입니다. 저는 최대한 친절한 문서를 지향하는데요, 해당 코드는 아래처럼 표현됩니다.

 

 

권한이 있는 유저인지 확인하기

API 파라미터에 user 가 있습니다. user 는 현재 로그인 유저를 의미합니다. 만약 해당 API를 아무나 수정할 수 있다면 누군가의 실수나 악의적인 행동으로 메시지들이 엉망이 될 수 있겠죠.

 

그렇기 때문에 user 가 관리자인 경우에만 해당 API를 사용할 수 있도록 합니다.

if user.user_id not in settings.ADMIN_IDS:
    raise HTTPException(status_code=403, detail="수정 권한이 없습니다.")

 

user_id 가 어드민 ids 에 포함되지 않는다면 403 상태 코드와 함께 권한이 없음을 알려주도록 했습니다.

 

 

대상 메시지 가져오기

이제 본격적으로 메시지 본문을 수정해 보겠습니다.

 

위에서 chat_update 메서드의 파라미터를 설명할 때, blocks 는 메시지 내용이라고 언급했는데요. 이 blocks 는 어디서 가져와야 할까요? 

 

메시지에 대한 상세 정보는 conversations_history 메서드를 통해 가져올 수 있습니다. 

message = await slack_app.client.conversations_history(
    channel=channel_id, latest=ts, inclusive=True, limit=1
)

 

- channel=channel_id : 특정 채널의 메시지를 조회합니다.

- latest=ts : 특정 타임스탬프를 기준으로 메시지를 조회합니다.

- inclusive=True : 지정한 타임스탬프 ts를 포함하여 메시지를 가져옵니다.

- limit=1 : 가장 최신의 한 개 메시지만 가져옵니다.

 

이렇게 하면 우리가 수정하고자 하는 메시지를 가져올 수 있습니다.

 

응답 값이 할당된 message 변수는 다음의 형태를 가집니다. 

 

뭔가 굉장히 많습니다만, 메시지를 수정하기 위해 눈여겨봐야 하는 부분은 빨간색으로 표시해 두었습니다.

 

메시지 내용을 수정하기 위해서 messages 배열의 첫 번째 딕셔너리의 blocks 를 가져오겠습니다. (첫 번째 딕셔너리인 이유는 우리가 앞서 1개의 메시지만 가져왔기 때문입니다.)

blocks = message["messages"][0]["blocks"]
attachments = message["messages"][0].get("attachments", [])

 

attachments 는 메시지에 붙은 '미리 보기'나 '첨부 파일'을 의미하는데요. 만약 값이 없는 경우에는 빈 배열을 가질 수 있도록 하겠습니다.

 

우리가 수정할 내용은 위 이미지에서 빨간색 박스로 표시된 '링크'와 '글 제목' 부분입니다. 참고로 슬랙에서는 글자에 링크를 담을 때 <링크|텍스트> 형태로 표현합니다.

 

이를 수정하기 위해 정규표현식을 사용하겠습니다. 

section_text = html.unescape(blocks[0]["text"]["text"])
pattern = r"<(https?://[^|]+)\|([^>]+)>"
match = re.search(pattern, section_text)
if not match:
    raise HTTPException(
        status_code=400, detail="메시지 패턴을 찾지 못했습니다."
)

 

html.unescape 를 사용해서 section_text 를 가져오는 이유는, 경우에 따라 '<'는 '&lt;', '>'는 '&gt;'로 변환되는데, 이 경우 정규표현식에서 원하는 결과를 얻지 못하기 때문에 원래의 기호로 복원하는 html.unescape를 사용합니다.

 

만약, 해당 메시지에 동일한 패턴이 없는 경우에는 더 이상 수정이 진행되지 않도록 400 상태 코드를 반환하도록 합니다.

 

 

수정할 새 메시지 만들기

이제 기존 메시지를 가져왔으니 새로운 메시지를 할당해 봅시다.

 

API 파라미터로 받은 new_content_url 과 new_title 값이 있는지를 기준으로 content_urltitle 변수를 할당합니다.

content_url = new_content_url or match.group(1)
title = new_title or match.group(2)

 

만약 수정할 값이 없다면 기존 값인 match.group 의 값들을 할당합니다. 이 코드 덕분에 '수정할 링크'와 '수정할 글 제목'을 매번 둘 다 보낼 필요 없이 수정할 항목만 선택적으로 요청할 수 있는 거죠.

 

그다음으로 기존 section_text 을 수정합니다.

replace_text = f"<{content_url}|{title}>"
updated_text = re.sub(pattern, replace_text, section_text)

 

새로 할당된 content_url 과 title 변수를 이용하여 replace_text 를 만들었습니다. 이를 다시 section_text 에 반영하여 최종적으로 updated_text 를 만듭니다.

 

blocks[0]["text"]["text"] = updated_text

 

이렇게 만든 updated_text 변수를 기존 blocks 의 text 값으로 할당하면 메시지의 blocks 수정이 끝납니다.

 

 

메시지 수정과 API 응답 반환하기

이제 마지막입니다. 맨 처음 작성했던 chat_update 코드를 다시 가져와 봅시다.

await slack_app.client.chat_update(
    channel=channel_id,
    ts=ts,
    blocks=blocks,
    attachments=(
        [] if new_content_url else attachments
    ),  # 링크가 수정된다면 미리보기 첨부파일을 삭제 합니다.
)

 

새롭게 수정한 blocks 를 보내기 때문에 이제 메시지가 바뀐 내용으로 보여질 것입니다.

 

추가로, 맨 처음 작성한 코드와 다르게 attachments 파라미터를 추가로 넣고 있는데요. 이 attachments 에는 글 링크에 대한 미리 보기 정보가 담겨있죠.

 

여기서 문제는, 글 링크를 수정할 때 이전 링크의 미리 보기 데이터가 attachments 에 그대로 유지된다는 점입니다. 그렇기 때문에 new_content_url 가 있다면(글 링크를 수정한다면) 빈 배열을 attachments 에 넣어 이전 링크의 미리 보기 데이터를 지워줍니다.

 

이렇게 하면 잘못된 미리 보기가 표시되지 않고, 정상적인 최종 메시지가 보여지게 됩니다.

 

 

그럼 이제 테스트를 해볼까요?

 

링크 부분의 '글 제목'이 정상적으로 수정된 것을 볼 수 있습니다. 위의 이미지는 글 제목만 수정한 경우입니다. 링크는 수정하지 않았기 때문에 미리보기는 그대로 보입니다.

 

 

만약 링크도 함께 수정한다면 어떻게 될까요? 

 

해당 이미지처럼 미리 보기가 제거된 결과를 볼 수 있습니다. 🤭

 

 

자, 이제 수정은 끝마쳤으니 서버 API 응답도 구현해 줍시다.

permalink_res = await slack_app.client.chat_getPermalink(
    channel=channel_id,
    message_ts=ts,
)

return {"permalink": permalink_res["permalink"]}

 

단순히 'success' 라는 성공 메시지를 반환할 수도 있지만, 사용자 입장에서 수정된 결과물로 빠르게 이동할 수 있도록 permalink 를 반환하겠습니다.

 

permalink 는 "Permanent Link"의 줄임말로 여기서는 슬랙 메시지의 고정된 URL을 의미합니다. 위 코드의 결과는 아래와 같습니다. 

{
  "permalink": "https://..."
}

 

이 응답을 받은 사용자는 permalink 를 통해 수정된 메시지로 바로 이동할 수 있습니다.

 

이로써 '메시지 수정'에 대한 작업이 끝났습니다. 😉

 

 

전체 코드와 개발 후기

전체 코드에는 SlackAPI 의 에러 핸들링을 위하여 SlackApiError 예외 처리를 추가했습니다.

@router.post(
    "/contents/{ts}",
    status_code=status.HTTP_200_OK,
)
async def update_content(
    ts: str,
    channel_id: str,
    new_content_url: HttpUrl | None = Query(
        None, description="수정할 링크, 빈 값이면 기존 링크를 사용합니다."
    ),
    new_title: str | None = Query(
        None, description="수정할 제목, 빈 값이면 기존 제목을 사용합니다."
    ),
    user: SimpleUser = Depends(current_user),
):
    if user.user_id not in settings.ADMIN_IDS:
        raise HTTPException(status_code=403, detail="수정 권한이 없습니다.")

    try:
        message = await slack_app.client.conversations_history(
            channel=channel_id, latest=ts, inclusive=True, limit=1
        )

        blocks = message["messages"][0]["blocks"]
        attachments = message["messages"][0].get("attachments", [])
        section_text = html.unescape(blocks[0]["text"]["text"])

        pattern = r"<(https?://[^|]+)\|([^>]+)>"
        match = re.search(pattern, section_text)
        if not match:
            raise HTTPException(
                status_code=400, detail="메시지 패턴을 찾지 못했습니다."
            )

        content_url = new_content_url or match.group(1)
        title = new_title or match.group(2)
        replace_text = f"<{content_url}|{title}>"
        updated_text = re.sub(pattern, replace_text, section_text)

        blocks[0]["text"]["text"] = updated_text

        await slack_app.client.chat_update(
            channel=channel_id,
            ts=ts,
            blocks=blocks,
            attachments=(
                [] if new_content_url else attachments
            ),  # 링크가 수정된다면 미리보기 첨부파일을 삭제 합니다.
        )

        permalink_res = await slack_app.client.chat_getPermalink(
            channel=channel_id,
            message_ts=ts,
        )

        return {"permalink": permalink_res["permalink"]}

    except SlackApiError as e:
        raise HTTPException(status_code=409, detail=str(e))

 

사실 메시지 수정 자체는 그렇게 어려운 작업은 아닙니다만, 저의 사례를 설명하고 작업 과정을 구체적으로 보여드리고자 글이 길어졌습니다.

 

특히, attachments 의 미리 보기 부분은 처음 이 기능을 기획할 때에는 염두하지 않았던 터라 저도 작업 중에 문제를 발견하고 추가했습니다. 

 

저처럼 슬랙 봇의 메시지를 수정하려는 분들이 계시다면 비슷한 과정을 겪으실 텐데, 이 글을 통해 시행착오를 줄이신다면 좋겠습니다. 

 

 

마지막, 커뮤니티 멤버 반응

메시지 수정 기능을 적용한 후에 수정 문의를 주셨던 멤버들에게 알림을 드렸습니다. 

 

다행히도 멤버분들이 만족해 주시고 긍정적인 반응을 해주셔서 저도 뿌듯하고 보람찬 경험이었습니다. 

 

이상으로 글을 마칩니다! 🤗

반응형