ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ③ 슬랙으로 메세지, view 보내기
    KOTLIN 2022. 10. 22. 14:30

     

     

    이번에는 슬랙앱을 통해서 사용자에게 메세지, view를 보내는 방법을 확인하자.

    참고한 사이트는 다음과 같다.

    api 공식 문서 확인하기 → https://api.slack.com/methods

     

    Web API methods | Slack

     

    api.slack.com

    chat.postEphemeral 확인하기 → https://api.slack.com/methods/chat.postEphemeral#text_usage

     

    chat.postEphemeral API method

    Sends an ephemeral message to a user in a channel.

    api.slack.com

     

     

     

    이번엔 간단하게 메세지를 보내는 방법을 확인하자.

     

     

    메세지 보내기

    1) 채널에 메세지 보내기

    • 단순하게 채널에 공개적으로 메세지 보내기
    • 보내고자 하는 해당 채널아이디만을 넘겨주면 된다.
    runCatching {
        client.chatPostMessage {
            it.token(token)
                .channel(channelId)
                .mrkdwn(true)
                .text(text)
        }
    }.onFailure { e ->
        e.printStackTrace()
    }
    • 채널에 공개 메세지로 발송이 된 것을 확인할 수 있다.

     

     

     

     

    2) 채널의 특정 사용자에게 메세지 보내기

    • 보내고자 하는 채널을 같지만, client.chatPostEphemeral을 이용하여 요청이 들어온 사용자에게만 보이도록 메세지를 보낸다.
    runCatching {
        client.chatPostEphemeral {
            it.token(token)
                .channel(channelId)
                .text(text)
                .user(onlyToUserId)
        }
    }.onFailure { e ->
        e.printStackTrace()
    }
    • 나에게만 표시를 확인할 수 있다.

     

     

     

     

    3) 사용자에게 DM 보내기

    • 채널에 메세지 보내는 것과 동일하지만, 여기서 channel을 사용자의 아이디로 설정하여 DM을 보내도록 한다.
    runCatching {
        client.chatPostMessage {
            it.token(token)
                .channel(userId)
                .mrkdwn(true)
                .text(text)
        }
    }.onFailure { e ->
        e.printStackTrace()
    }
    • 요청이 DM으로 들어오는 것을 확인할 수 있다.

     

     

     

     

     

    사용자에게 view 반환하기

    이번엔 슬랙을 이용해서 사용자에게 단순 메세지가 아닌 modal(view)를 보여주도록 해보자.

    참고한 사이트는 다음과 같다.

     

    modal 설명 공식 문서 → https://api.slack.com/surfaces/modals/using

     

    Using modals in Slack apps

    How to compose, create, and update modals.

    api.slack.com

    block kit 설명 공식 문서 → https://api.slack.com/block-kit/building#adding_blocks

     

    Building with Block Kit

    String the atoms together into molecules and inject them into messages and modals.

    api.slack.com

    block 예시 공식 문서 → https://slack.dev/java-slack-sdk/guides/composing-messages#block-kit-kotlin-dsl

     

    Composing Messages | Slack SDK for Java

    Composing Messages This section shows how to build Slack messages using slack-api-client library. If you’re not familiar with chat.postMessage API yet, read this page before trying the samples here. Also, before jumping into Java code, we recommend devel

    slack.dev

    block kit 모양 살펴보기 → https://app.slack.com/block-kit-builder

     

    Slack

    nav.top { position: relative; } #page_contents > h1 { width: 920px; margin-right: auto; margin-left: auto; } h2, .align_margin { padding-left: 50px; } .card { width: 920px; margin: 0 auto; .card { width: 880px; } } .linux_col { display: none; } .platform_i

    app.slack.com

    block 종류 살펴보기 → https://api.slack.com/reference/block-kit/block-elements#button

     

    Reference: Block elements

    A comprehensive breakdown of elements that add images or interactivity to blocks.

    api.slack.com

    webhook 예시 살펴보기 → https://github.com/plusmobileapps/kotlin-slackbot/tree/master/src

     

    GitHub - plusmobileapps/kotlin-slackbot

    Contribute to plusmobileapps/kotlin-slackbot development by creating an account on GitHub.

    github.com

    kotlin block 살펴보기 → https://github.com/slackapi/bolt-js/issues/332

     

    send to messages for slack app from block action · Issue #332 · slackapi/bolt-js

    HI, I'm using your slack bolt api and I need to post a message from block action (button) , so at the payload of block action i don't have any info about channel and now i need to post a me...

    github.com

    view 필드 확인하기 → https://api.slack.com/reference/surfaces/views

     

    Reference: Defining view objects

    All the fields you need to know about for Home tabs, modals, and other views.

    api.slack.com

     

     

     

     

    Modal View 반환하기

    • 사용자에게 modal을 이용해서 view가 보이도록 설정하자.
    • 반환할 context(ctx).viewOpen()를 이용하여 modal이 보이도록 설정할 수 있다.
        fun sendRegisterRestaurantForm(ctx: SlashCommandContext) {
            ctx.client().viewsOpen { r ->
                r.triggerId(ctx.triggerId).view(Views.view { view ->
                    view
                        .callbackId("register_restaurant")
                        .type("modal")
                        .notifyOnClose(true)
                        .title(Views.viewTitle { it.type("plain_text").text("맛집 등록하기").emoji(true) })
                        .submit(Views.viewSubmit { it.type("plain_text").text("Submit").emoji(true) })
                        .close(Views.viewClose { it.type("plain_text").text("Cancel").emoji(true) })
    //                        .privateMetadata("{\"response_url\":\"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz\"}")
                        .blocks(asBlocks(
                            input {
                                it.blockId("restaurant-name-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-name-action")
                                            .placeholder(PlainTextObject("맛집 이름을 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("이름").emoji(true) })
                            },
                            section {
                                it.blockId("restaurant-category-block")
                                    .text(markdownText("*음식 종류*"))
                                    .accessory(BlockElements.staticSelect { staticSelect ->
                                        staticSelect
                                            .actionId("restaurant-category-action")
                                            .placeholder(plainText("카테고리 선택"))
                                            .initialOption(
                                                BlockCompositions.option(
                                                    plainText(Category.KOREAN.category),
                                                    Category.KOREAN.category
                                                )
                                            )
                                            .options(
                                                BlockCompositions.asOptions(
                                                    BlockCompositions.option(
                                                        plainText(Category.KOREAN.category),
                                                        Category.KOREAN.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.WESTERN.category),
                                                        Category.WESTERN.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.CHINESE.category),
                                                        Category.CHINESE.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.JAPANESE.category),
                                                        Category.JAPANESE.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.ASIAN.category),
                                                        Category.ASIAN.category
                                                    )
                                                )
                                            )
                                    })
                            },
                            section {
                                it.blockId("restaurant-flavor-block")
                                    .text(markdownText("*전체적인 맛*"))
                                    .accessory(BlockElements.staticSelect { staticSelect ->
                                        staticSelect
                                            .actionId("restaurant-flavor-action")
                                            .placeholder(plainText("맛 선택"))
                                            .initialOption(
                                                BlockCompositions.option(
                                                    plainText(Flavor.TASTY.flavor),
                                                    Flavor.TASTY.flavor
                                                )
                                            )
                                            .options(
                                                BlockCompositions.asOptions(
                                                    BlockCompositions.option(
                                                        plainText(Flavor.TASTY.flavor),
                                                        Flavor.TASTY.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.PLAIN.flavor),
                                                        Flavor.PLAIN.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.SPICY.flavor),
                                                        Flavor.SPICY.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.SWEET.flavor),
                                                        Flavor.SWEET.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.TASTELESS.flavor),
                                                        Flavor.TASTELESS.flavor
                                                    )
                                                )
                                            )
                                    })
                            },
                            input {
                                it.blockId("restaurant-main-menu-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-main-menu-action")
                                            .placeholder(PlainTextObject("맛집의 메인 메뉴를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("메인 메뉴").emoji(true) })
                            },
                            input {
                                it.blockId("restaurant-location-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-location-action")
                                            .placeholder(PlainTextObject("맛집의 주소를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("주소").emoji(true) })
                            },
                            input {
                                it.blockId("restaurant-link-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-link-action")
                                            .placeholder(PlainTextObject("맛집의 네이버 지도 링크를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("네이버 지도 주소").emoji(true) })
                            },
                            input { input ->
                                input
                                    .blockId("agenda-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("agenda-action").multiline(true)
                                    })
                                    .label(plainText { pt -> pt.text("Detailed Agenda").emoji(true) })
                            }
                        ))
                })
            }
        }

     

     

     

    • modal 추가 예제 확인하기
    View buildView() {
            return view(view -> view
                    .callbackId("meeting-arrangement")
                    .type("modal")
                    .notifyOnClose(true)
                    .title(viewTitle(title -> title.type("plain_text").text("Meeting Arrangement").emoji(true)))
                    .submit(viewSubmit(submit -> submit.type("plain_text").text("Submit").emoji(true)))
                    .close(viewClose(close -> close.type("plain_text").text("Cancel").emoji(true)))
                    .privateMetadata("{\"response_url\":\"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz\"}")
                    .blocks(asBlocks(
                            section(section -> section
                                    .blockId("category-block")
                                    .text(markdownText("Select a category of the meeting!"))
                                    .accessory(staticSelect(staticSelect -> staticSelect
                                            .actionId("category-selection-action")
                                            .placeholder(plainText("Select a category"))
                                            .options(asOptions(
                                                    option(plainText("Customer"), "customer"),
                                                    option(plainText("Partner"), "partner"),
                                                    option(plainText("Internal"), "internal")
                                            ))
                                    ))
                            ),
                            input(input -> input
                                    .blockId("agenda-block")
                                    .element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true)))
                                    .label(plainText(pt -> pt.text("Detailed Agenda").emoji(true)))
                            )
                    ))
            );
        }
    
        @Test
        public void example() {
            View view = buildView();
            assertNotNull(view);
            assertEquals(2, view.getBlocks().size());
        }
    
        private static final Gson gson = GsonFactory.createSnakeCase();
    
        static View buildViewByCategory(String categoryId, String privateMetadata) {
            Map<String, String> metadata = gson.fromJson(privateMetadata, Map.class);
            metadata.put("categoryId", categoryId);
            String updatedPrivateMetadata = gson.toJson(metadata);
            return view(view -> view
                    .callbackId("meeting-arrangement")
                    .type("modal")
                    .notifyOnClose(true)
                    .title(viewTitle(title -> title.type("plain_text").text("Meeting Arrangement").emoji(true)))
                    .submit(viewSubmit(submit -> submit.type("plain_text").text("Submit").emoji(true)))
                    .close(viewClose(close -> close.type("plain_text").text("Cancel").emoji(true)))
                    .privateMetadata(updatedPrivateMetadata)
                    .blocks(asBlocks(
                            section(section -> section
                                    .blockId("category-block")
                                    .text(markdownText("You've selected \"" + categoryId + "\""))
                            ),
                            input(input -> input
                                    .blockId("agenda-block")
                                    .element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true)))
                                    .label(plainText(pt -> pt.text("Detailed Agenda").emoji(true)))
                            )
                    ))
            );
        }
    
        @Test
        public void boltApp() {
            app.command("/doc-test", (req, ctx) -> {
                ctx.client().viewsOpen(r -> r.triggerId(ctx.getTriggerId()).view(buildView()));
                return ctx.ack();
            });
    
            app.blockAction("category-selection-action", (req, ctx) -> {
                String categoryId = req.getPayload().getActions().get(0).getSelectedOption().getValue();
                View currentView = req.getPayload().getView();
                String privateMetadata = currentView.getPrivateMetadata();
                View viewForTheCategory = buildViewByCategory(categoryId, privateMetadata);
                ViewsUpdateResponse viewsUpdateResp = ctx.client().viewsUpdate(r -> r
                        .viewId(currentView.getId())
                        .hash(currentView.getHash())
                        .view(viewForTheCategory)
                );
                return ctx.ack();
            });
    
            // when a user clicks "Submit"
            app.viewSubmission("meeting-arrangement", (req, ctx) -> {
                String privateMetadata = req.getPayload().getView().getPrivateMetadata();
                Map<String, Map<String, ViewState.Value>> stateValues = req.getPayload().getView().getState().getValues();
                String agenda = stateValues.get("agenda-block").get("agenda-action").getValue();
                Map<String, String> errors = new HashMap<>();
                if (agenda.length() <= 10) {
                    errors.put("agenda-block", "Agenda needs to be longer than 10 characters.");
                }
                if (!errors.isEmpty()) {
                    return ctx.ack(r -> r.responseAction("errors").errors(errors));
                } else {
                    // TODO: may store the stateValues and privateMetadata
    
                    // Responding with an empty body means closing the modal now.
                    // If your app has next steps, respond with other response_action and a modal view.
                    return ctx.ack();
                }
            });
    
            View renewedView = null;
            View newViewInStack = null;
            app.viewSubmission("meeting-arrangement", (req, ctx) -> {
                ctx.ack(r -> r.responseAction("update").view(renewedView));
                return ctx.ack(r -> r.responseAction("push").view(newViewInStack));
            });
    
            // when a user clicks "Cancel"
            // "notify_on_close": true is required
            app.viewClosed("meeting-arrangement", (req, ctx) -> {
                // Do some cleanup tasks
                return ctx.ack();
            });
        }

     

     

     

     

     

     

     

     

     

    실제 프로젝트 코드에 적용해보기

    1) 특정 사용자에게 DM 보내기 

    // 특정 유저에게 DM 메세지 보내기
    fun sendToUserSimpleMessage(channelId: String, token: String, userId: String) {
        runCatching {
            client.chatPostMessage {
                it.token(token)
                    .channel(userId)
                    .text("hi there $user")
            }
        }.onFailure { e ->
            e.printStackTrace()
        }
    }

     

     

     

    • 메세지 보내기
    // 전 메세지 보내기
        fun sendWelcomeMessage(channelId: String, token: String) {
            runCatching {
                client.chatPostMessage {
                    it.token(token)
                        .channel(channelId)
                        .blocks {
                            section {
                                markdownText(
                                    "*안녕하세요! *\uD83D\uDC4B\uD83C\uDFFC \n" +
                                            "즐거운 식사를 위한 *맛식당봇* 입니다. \uD83C\uDF55\n" +
                                            "간단한 사용법을 알려드릴게요! "
                                )
                            }
                            divider()
                            section {
                                markdownText(
                                    "*\uD83C\uDF5A 맛집 찾아보기*\n\n" +
                                            "*/random* : 맛집을 랜덤으로 추천받을 수 있어요! \n" +
                                            "*/category* : 카테고리별로 맛집을 추천받을 수 있어요!\n" +
                                            "*/flavor* : 원하는 맛별로 맛집을 추천받을 수 있어요! \n" +
                                            "*/category&flavor* : 카테고리와 맛별로 맛집을 추천받을 수 있어요! \n" +
                                            "*/restaurant \${맛집 이름}* : 이름을 이용해서 맛집 정보를 조회할 수 있어요! \n" +
                                            "*@Restaurant* : 맛식당봇 사용법을 다시한번 안내 받을 수 있어요!"
                                )
                            }
                            divider()
                            section {
                                markdownText(
                                    "*\uD83C\uDF5A 맛집 등록 및 평가하기*\n\n" +
                                            "*/register* : 원하는 맛집을 추가로 등록할 수 있어요!\n" +
                                            "*/rating \${맛집 이름}* : 맛집을 평가 할 수 있어요!"
                                )
                            }
                            divider()
                            section {
                                markdownText(
                                    "*\uD83C\uDF5A 맛집 삭제하기*\n\n" +
                                            "*/delete \${맛집 이름}* : 더이상 운영하지 않는 맛집을 식당리스트에서 삭제할 수 있어요!"
                                )
                            }
                        }
                }
            }.onFailure { e ->
                e.printStackTrace()
            }
        }

     

     

     

     

    • 버튼 옵션 메세지 보내기
    // 버튼 메세지
        fun sendOriginalButtonMessage(channelId: String, token: String) {
            runCatching {
                client.chatPostMessage {
                    it.token(token)
                        .channel(channelId)
                        .text("click_button")
                        .blocks {
                            section {
                                markdownText("*Please select a restaurant:*")
                            }
                            divider()
                            actions {
                                button {
                                    text("Farmhouse", emoji = true)
                                    value("v1")
                                    actionId("action_farmhouse")
                                }
                                button {
                                    text("Kin Khao", emoji = true)
                                    value("v2")
                                }
                            }
                        }
                }
            }.onFailure { e ->
                e.printStackTrace()
            }
    
        }

     

     

     

     

    • 리팩토링시 다음과 같이 메서드로 빼내어서 이용도 가능하다
    return listOf<LayoutBlock>(Blocks.section { section -> section.text(markdownText(sectionText)) },
                Blocks.divider(), Blocks.actions { actions ->
                    actions
                        .elements(
                            buttonElements(
                                listOf(
                                    SlackButton("승인", "승인", "primary", "action_approve"),
                                    SlackButton("거부", "거부", "danger", "action_reject")
                                    )
                            )
                        )
                })

     

     

     

    • modal 반환하기
    // Register restaurant modal 모습
        fun sendRegisterRestaurantForm(ctx: SlashCommandContext) {
            ctx.client().viewsOpen { r ->
                r.triggerId(ctx.triggerId).view(Views.view { view ->
                    view
                        .callbackId("register_restaurant")
                        .type("modal")
                        .notifyOnClose(true)
                        .title(Views.viewTitle { it.type("plain_text").text("맛집 등록하기").emoji(true) })
                        .submit(Views.viewSubmit { it.type("plain_text").text("Submit").emoji(true) })
                        .close(Views.viewClose { it.type("plain_text").text("Cancel").emoji(true) })
    //                        .privateMetadata("{\"response_url\":\"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz\"}")
                        .blocks(asBlocks(
                            input {
                                it.blockId("restaurant-name-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-name-action")
                                            .placeholder(PlainTextObject("맛집 이름을 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("이름").emoji(true) })
                            },
                            section {
                                it.blockId("restaurant-category-block")
                                    .text(markdownText("*음식 종류*"))
                                    .accessory(BlockElements.staticSelect { staticSelect ->
                                        staticSelect
                                            .actionId("restaurant-category-action")
                                            .placeholder(plainText("카테고리 선택"))
                                            .initialOption(
                                                BlockCompositions.option(
                                                    plainText(Category.KOREAN.category),
                                                    Category.KOREAN.category
                                                )
                                            )
                                            .options(
                                                BlockCompositions.asOptions(
                                                    BlockCompositions.option(
                                                        plainText(Category.KOREAN.category),
                                                        Category.KOREAN.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.WESTERN.category),
                                                        Category.WESTERN.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.CHINESE.category),
                                                        Category.CHINESE.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.JAPANESE.category),
                                                        Category.JAPANESE.category
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Category.ASIAN.category),
                                                        Category.ASIAN.category
                                                    )
                                                )
                                            )
                                    })
                            },
                            section {
                                it.blockId("restaurant-flavor-block")
                                    .text(markdownText("*전체적인 맛*"))
                                    .accessory(BlockElements.staticSelect { staticSelect ->
                                        staticSelect
                                            .actionId("restaurant-flavor-action")
                                            .placeholder(plainText("맛 선택"))
                                            .initialOption(
                                                BlockCompositions.option(
                                                    plainText(Flavor.TASTY.flavor),
                                                    Flavor.TASTY.flavor
                                                )
                                            )
                                            .options(
                                                BlockCompositions.asOptions(
                                                    BlockCompositions.option(
                                                        plainText(Flavor.TASTY.flavor),
                                                        Flavor.TASTY.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.PLAIN.flavor),
                                                        Flavor.PLAIN.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.SPICY.flavor),
                                                        Flavor.SPICY.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.SWEET.flavor),
                                                        Flavor.SWEET.flavor
                                                    ),
                                                    BlockCompositions.option(
                                                        plainText(Flavor.TASTELESS.flavor),
                                                        Flavor.TASTELESS.flavor
                                                    )
                                                )
                                            )
                                    })
                            },
                            input {
                                it.blockId("restaurant-main-menu-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-main-menu-action")
                                            .placeholder(PlainTextObject("맛집의 메인 메뉴를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("메인 메뉴").emoji(true) })
                            },
                            input {
                                it.blockId("restaurant-location-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-location-action")
                                            .placeholder(PlainTextObject("맛집의 주소를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("주소").emoji(true) })
                            },
                            input {
                                it.blockId("restaurant-link-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("restaurant-link-action")
                                            .placeholder(PlainTextObject("맛집의 네이버 지도 링크를 입력해주세요.", true))
                                    })
                                    .label(plainText { pt -> pt.text("네이버 지도 주소").emoji(true) })
                            },
                            input { input ->
                                input
                                    .blockId("agenda-block")
                                    .element(BlockElements.plainTextInput { pti ->
                                        pti.actionId("agenda-action").multiline(true)
                                    })
                                    .label(plainText { pt -> pt.text("Detailed Agenda").emoji(true) })
                            }
                        ))
                })
            }
        }

     

     

     

     

     

     

     

    이제 슬랙봇에서 사용자에게 메세지를 보내고 view를 보내어 사용자가 그에 따른 action을 발생시키도록 할 수 있다. ✨

     

Designed by Tistory.