-
코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ③ 슬랙으로 메세지, view 보내기KOTLIN 2022. 10. 22. 14:30
이번에는 슬랙앱을 통해서 사용자에게 메세지, view를 보내는 방법을 확인하자.
참고한 사이트는 다음과 같다.
↓
api 공식 문서 확인하기 → https://api.slack.com/methods
chat.postEphemeral 확인하기 → https://api.slack.com/methods/chat.postEphemeral#text_usage
이번엔 간단하게 메세지를 보내는 방법을 확인하자.
메세지 보내기
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
block kit 설명 공식 문서 → https://api.slack.com/block-kit/building#adding_blocks
block 예시 공식 문서 → https://slack.dev/java-slack-sdk/guides/composing-messages#block-kit-kotlin-dsl
block kit 모양 살펴보기 → https://app.slack.com/block-kit-builder
block 종류 살펴보기 → https://api.slack.com/reference/block-kit/block-elements#button
webhook 예시 살펴보기 → https://github.com/plusmobileapps/kotlin-slackbot/tree/master/src
kotlin block 살펴보기 → https://github.com/slackapi/bolt-js/issues/332
view 필드 확인하기 → https://api.slack.com/reference/surfaces/views
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을 발생시키도록 할 수 있다. ✨
'KOTLIN' 카테고리의 다른 글
📕 코틀린 동시성 프로그래밍 - Ch.1) Hello, Concurrent World! (0) 2022.11.08 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ④ 구글 스프레드 시트 사용하기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ② 슬랙으로 요청받기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ① 슬랙앱 생성 및 사용 설정하기 (0) 2022.10.22 코틀린 기초 문법 ④ ( + DSL 학습중 ⌛️) (1) 2022.10.18