-
코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ② 슬랙으로 요청받기KOTLIN 2022. 10. 22. 14:28
이번엔 슬랙으로 들어오는 요청을 확인하는 방법을 알아보자.
간단한 실행 구조 확인하기
간단하게 요청을 받고 메세지를 보내는 구조는 다음과 같다.
// 간단하게 메세지 보내보기 val client = Slack.getInstance().methods() runCatching{ client.chatPostMessage{ it.token("oAuth 토큰") .channel("앱 등록 채널") .text("보낼 메세지") } }.onFailure{e-> e.printStackTrace() } // app config를 이용하여 주석 요청 확인하기 val config = AppConfig.builder() .signingSecret("signing secret") .singleTeamBotToken("oAuth 토큰") .build() // /hello 주석으로 요청이 들어오면 world를 반환한다. val app = App(config) app.command("/hello"){_, _-> Response.ok("world") } app.event() val server = SlackAppServer(app) server.start()
SlackConfig 생성하기
이제 slack을 사용하기 위해 SlackConfig에 슬랙 사용을 위한 설정을 정의해준다.
앞에선 프로젝트의 main함수에서 슬랙앱이 실행되도록 했지만, 이를 @Bean으로 설정되어 실행되도록 해보자.
여기서 appPath는 해당 경로로 들어오는 모든 요청을 slack이 확인할 수 있도록 설정하는 것이다.
- 이 프로젝트는 /slack/events를 통해 들어오는 요청을 처리한다.
@Configuration class SlackAppConfig( @Value("\${slack.bot.token}") var token: String, @Value("\${slack.signing.secret}") var secret: String ) { @Bean fun app(): App { val config = AppConfig.builder() .signingSecret(secret) .singleTeamBotToken(token) .appPath("/slack/events") .build() return App(config) } }
InitializingSlackServer 컴포넌트 생성
슬랙 서비스를 이용하면서 restaurantService라는 다른 빈을 사용하기 때문에 빈 초기화 후 생성된 app 및 다른 서비스를 이용하여 슬랙 서버를 실행하도록 구성하였다.
- 만약 다른 서비스를 사용하지 않고 app 빈만 사용하도록 구성하는 경우에는 config에서 모두 실행하도록 구성해도 되긴 한다.
이를 위해 @PostConstruct 어노테이션을 사용하도록 구성하였다.
@Component class InitializingSlackServer( @Value("\${slack.bot.token}") var token: String, @Value("\${slack.monitor.channel.token}") var channelId: String, ) { @Autowired private lateinit var app: App @Autowired private lateinit var slackService: SlackService @PostConstruct fun initializingSlackApp() { app.event(MessageChannelJoinEvent::class.java) { _, ctx -> slackService.sendWelcomeMessage(channelId, token, null) ctx.ack() } val server = SlackAppServer(app) server.start() } }
요청 받기
slackbot은 command, event, interactive_action등 다양한 타입으로 들어온 요청을 받을 수 있다.
이 부분을 공부하면서 참고한 사이트는 다음과 같다.
↓
예제 참고 공식 문서 -> https://github.com/slackapi/java-slack-sdk/tree/main/bolt-kotlin-examples/src/main/kotlin/examples/docs
참고 블로그 (자바) → https://techblog.lotteon.com/slack-bot-상호-작용-66596c262616
참고 블로그 → https://helloworld.kurly.com/blog/slack_block_kit/#interactivity--shortcuts-설정
참고 블로그 → https://yozm.wishket.com/magazine/detail/1480/
👏 모든 이벤트 테스트 예제 확인하기 → https://github.com/slackapi/java-slack-sdk/tree/v1.0.6/bolt/src/test/java/test_locally/docs
참고 영상 (파이썬으로 만든 슬랙봇 플레이리스트) → https://www.youtube.com/watch?v=KJ5bFv-IRFM&list=PLzMcBGfZo4-kqyzTzJWCV6lyK-ZMYECDc&index=1
참고 깃헙 (파이썬) → https://github.com/techwithtim/Slack-Bot/blob/main/bot.py
1) commands 요청 받기
먼저 commands 요청을 받기 위한 설정을 해보자.
Slash Commands → Create New Command → 주석 정보 등록
- 여기서 URL은 서버의 url과 앞서 config에서 설정해준 appPath와 동일하도록 구성하였다.
- 단, URL은 외부에서도 접속이 가능하도록 하여야 한다.( localhost면 안된다. )
- ngrok http 3000(해당포트)를 이용하여 진행하였다.
이제 commend로 들어오는 요청을 받는 예시를 확인해보자.
해당 채널에서 /hello 주석을 통해 보내는 요청은 다음과 같이 메세지를 통한 응답 hello 를 확인할 수 있다.
fun helloCommand(app: App) { app.command("/hello") { _, _ -> Response.ok("hello") } }
2) Event 요청 받기
공식 문서에서 event 요청 받기 내용을 확인할 수 있다.
먼저 슬랙앱으로 들어오는 이벤트를 수신하기 위한 설정을 해보자.
Event Subscriptions → Enable Events 켜주기 → <https://프로젝트 url/[appPath]> 입력 → save
추가로 요청을 받을 이벤트를 정의해주자.
공식 사이트에서 이벤트 종류 확인이 가능하다.
Event Subscriptions → subscribe to bot events → Add Bot User Event → save
- 간단 설명
- app_mention : @<앱 이름>통해 들어오는 이벤트 확인
- message_channels : 앱이 등록된 채널에 들어오는 메세지 이벤트 확인 가능
- app_home_opened : 다른 채널에 갔다가 다시 해당 채널이 오픈되는 경우의 이벤트 확인
- reaction_added : 메세지에 이모지 리액션이 달리거나 반응이 추가되는 경우의 이벤트 확인
예시로 app_mention 이벤트를 확인해보자.
- @Restaurant_bot 을 하자 hello를 응답하는 것을 확인할 수 있다.
fun appMentionAction(app: App) { app.event(AppMentionEvent::class.java) { req, ctx -> Response.ok("hello") } }
추가로 다른 이벤트의 예시도 확인해보자.
- MessageChannelJoinEvent : 채널에 입장했을때 이벤트 발생
fun welcomeToChannelAction(app: App) { app.event(MessageChannelJoinEvent::class.java){_, ctx-> Response.ok("hello") } }
- AppHomeOpenedEvent : 채널에 입장했을때 이벤트 발생
fun appHomeOpenedEvent(app: App) { app.event(AppHomeOpenedEvent::class.java){event, ctx-> // Build a Home tab view val now = ZonedDateTime.now() val appHomeView = view{ it.type("home") .blocks(asBlocks( section{section->section.text(markdownText{mt->mt.text(":wave: Hello, App Home! (Last updated: ${now})")})}, image{img->img.imageUrl("https://www.example.com/foo.png")} )) } // Update the App Home for the given user val res = ctx.client().viewsPublish{ it.userId(event.event.user) .hash(event.event.view?.hash) // To protect against possible race conditions .view(appHomeView) } ctx.ack() } }
- ReactionAddedEvent : 메세지에 리액션이 달렸을 때 이벤트 발생
app.event(ReactionAddedEvent::class.java){payload, ctx-> val event = payload.event println(payload) println(event.reaction) if (event.reaction == "white_check_mark") { val message = ctx.client().chatPostMessage{ it.channel(event.item.channel) .threadTs(event.item.ts) .text("<@${event.user}> Thank you! We greatly appreciate your efforts :two_hearts:") } if (!message.isOk) { ctx.logger.error("chat.postMessage failed: ${message.error}") } } ctx.ack() }
3) Interactive Action 요청 받기
공식 문서 와 interactive message 참고 공식 문서 , api 참고 공식 문서 에서 내용을 확인할 수 있다.
이번엔 생성된 webhook action 요청을 받기 위한 설정을 해주자.
Incoming Webhooks → Activate Incoming Webhooks on으로 설정 → Add New Webhook to Workspace →요청받을 채널들을 추가
InterActivity & Shorcuts → RequestURL에 <https://<프로젝트 주소>/[appPath]> 작성 → save
버튼 예제 확인하기
블로그글을 참고하였다.
- 먼저 app_mention이벤트를 발생시키면 안에 버튼을 포함한 메세지를 반환하도록 설정한다.
- 버튼을 클릭하면 action이 발생되도록 actionId를 설정한다.
// appMention 이벤트를 감지 app.event(AppMentionEvent::class.java){req, ctx-> val userId = req.event.user 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") // 버튼에 actionId 설정 } button { text("Kin Khao", emoji = true) value("v2") } } } } }.onFailure { e -> e.printStackTrace() } ctx.ack() }
- 이제 버튼 block 액션(action_farmhouse)이벤트가 발생하면 이를 감지하고 요청을 처리하도록 설정 할 수 있다.
app.blockAction("action_farmhouse") { req: BlockActionRequest, ctx: ActionContext -> // do something ctx.ack() }
버튼 추가예제
app.blockAction("button-action") { req, ctx -> val value = req.payload.actions[0].value if (req.payload.responseUrl != null) { ctx.respond("You've sent ${value} by clicking the button!") } ctx.ack() } val allOptions = listOf( Option(plainText("Schedule", true), "schedule"), Option(plainText("Budget", true), "budget"), Option(plainText("Assignment", true), "assignment") ) // when a user enters some word in "Topics" app.blockSuggestion("topics-action") { req, ctx -> val keyword = req.payload.value val options = allOptions.filter { (it.text as PlainTextObject).text.contains(keyword) } ctx.ack { it.options(if (options.isEmpty()) allOptions else options) } } // when a user chooses an item from the "Topics" app.blockAction("topics-action") { req, ctx -> ctx.ack() }
옵션 테스트 예제
@Test public void example() { // when a user clicks a button in the actions block app.blockAction("button-action", (req, ctx) -> { String value = req.getPayload().getActions().get(0).getValue(); // "button's value" if (req.getPayload().getResponseUrl() != null) { // Post a message to the same channel if it's a block in a message ctx.respond("You've sent " + value + " by clicking the button!"); } return ctx.ack(); }); final List<Option> allOptions = Arrays.asList( new Option(plainText("Schedule", true), "schedule"), new Option(plainText("Budget", true), "budget"), new Option(plainText("Assignment", true), "assignment") ); // when a user enters some word in "Topics" app.blockSuggestion("topics-action", (req, ctx) -> { String keyword = req.getPayload().getValue(); List<Option> options = allOptions.stream() .filter(o -> ((PlainTextObject) o.getText()).getText().contains(keyword)) .collect(toList()); return ctx.ack(r -> r.options(options.isEmpty() ? allOptions : options)); }); // when a user chooses an item from the "Topics" app.blockAction("topics-action", (req, ctx) -> { return ctx.ack(); }); }
옵션을 이용한 action 예제 확인하기
app.command("/meeting") { req, ctx -> // Build a view using string interpolation val commandArg = req.payload.text val modalView = """ { "type": "modal", "callback_id": "meeting-arrangement", "notify_on_close": true, "title": { "type": "plain_text", "text": "Meeting Arrangement" }, "submit": { "type": "plain_text", "text": "Submit" }, "close": { "type": "plain_text", "text": "Cancel" }, "private_metadata": "${commandArg}" "blocks": [ { "type": "input", "block_id": "agenda-block", "element": { "action_id": "agenda-action", "type": "plain_text_input", "multiline": true }, "label": { "type": "plain_text", "text": "Detailed Agenda" } } ] } """.trimIndent() val res = ctx.client().viewsOpen { it .triggerId(ctx.triggerId) .viewAsString(modalView) } if (res.isOk) ctx.ack() else Response.builder().statusCode(500).body(res.error).build() } fun buildViewByCategory(categoryId: String, privateMetadata: String): View? { return null // TODO } app.blockAction("category-selection-action") { req, ctx -> val currentView = req.payload.view val privateMetadata = currentView.privateMetadata val stateValues = currentView.state.values val categoryId = stateValues["category-block"]!!["category-selection-action"]!!.selectedOption.value val viewForTheCategory = buildViewByCategory(categoryId, privateMetadata) val viewsUpdateResp = ctx.client().viewsUpdate { it .viewId(currentView.id) .hash(currentView.hash) .view(viewForTheCategory) } ctx.ack() } // when a user clicks "Submit" app.viewSubmission("meeting-arrangement") { req, ctx -> val privateMetadata = req.payload.view.privateMetadata val stateValues = req.payload.view.state.values val agenda = stateValues["agenda-block"]!!["agenda-action"]!!.value val errors = mutableMapOf<String, String>() if (agenda.length <= 10) { errors["agenda-block"] = "Agenda needs to be longer than 10 characters." } if (errors.isNotEmpty()) { ctx.ack { it.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. ctx.ack() } } val renewedView: View? = null val newViewInStack: View? = null app.viewSubmission("meeting-arrangement") { req, ctx -> ctx.ack { it.responseAction("update").view(renewedView) } ctx.ack { it.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 ctx.ack() } }
이제 슬랙으로 들어오는 사용자의 요청을 슬랙봇이 확인할 수 있다! ✨
'KOTLIN' 카테고리의 다른 글
코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ④ 구글 스프레드 시트 사용하기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ③ 슬랙으로 메세지, view 보내기 (0) 2022.10.22 코프링(코틀린 + 스프링부트) + 구글 스프레드 시트로 슬랙봇 만들기 - ① 슬랙앱 생성 및 사용 설정하기 (0) 2022.10.22 코틀린 기초 문법 ④ ( + DSL 학습중 ⌛️) (1) 2022.10.18 코틀린 기초 문법 ③ (0) 2022.09.28