[WeatherBot][Spring] 3. Slack bot 언급(mention)한 계정에 맞춰서 인사하기
이 글은 제가 혼자 공부하고 구현하면서 알게 된 내용들을 잊지 않기 위해 작성한 글입니다! 잘못된 내용이 있을 수 있고,
✅ 구현 할 내용
지금까지는 우리가 Slack bot을 언급(@나무늘봇)하면, 안녕하세요!라는 문장을 내뱉는 것까지 구현했다.
하지만.. 뭔가 부족하다는 생각이 든다. 조금 더 인터렉티브한 봇이 되었으면 한다!
조금 더 자연스러운 챗봇(!!)이 되기 위해 사용자가 멘션만 했거나(@나무늘봇) 혹은 멘션에 인삿말(@나무늘봇 안녕)이 포함되어 있을 때
{닉네임}님 안녕하세요!!라는 문장을 전송하게끔 해보려고 한다.
서비스 단에서 어떻게 처리하는 것이 좋을까? {닉네임}님 안녕하세요!!라는 인삿말을 보내는 조건에 멘션이 무조건 포함되어 있기 때문에
1. app_mention이라는 event가 발생했는지 확인하고
2. 메세지에 인삿말이 포함되어 있는지 확인
3. 포함되어 있는 경우 display_name 확인 후, {닉네임}님 안녕하세요!!라는 인삿말 전송
4. 포함되어 있지 않은 경우 "무슨 말인지 잘 모르겠어요😅"라는 문장 전송
위와 같은 플로우로 진행하면 될 것 같다!
✅ Slack Client API docs 확인
Slack API docs에서 user info라는 내용으로 검색을 하면 users.info라는 문서를 찾을 수 있다. 해당 문서의 Reference docs를 보면 어떤 method를 써야하고, 어떤 scope가 필요한지와 함께 sample code가 적혀있다.
밑으로 더 내려서 Example responses를 보면 Common successful response를 확인할 수 있다.
user.profile를 보면 display_name을 확인할 수 있는데, 이것이 슬랙 채널에서 보여지는 사용자의 닉네임이다. 이 닉네임을 통해 인삿말을 전달하면 될 것이다!
{
"ok": true,
"user": {
"id": "W012A3CDE",
"team_id": "T012AB3C4",
"name": "spengler",
"deleted": false,
"color": "9f69e7",
"real_name": "Egon Spengler",
...
"profile": {
"avatar_hash": "ge3b51ca72de",
"status_text": "Print is dead",
"status_emoji": ":books:",
"real_name": "Egon Spengler",
"display_name": "spengler",
...
},
...
}
}
✅ event의 유형이 app_mention인지 확인하기
Test Code를 작성하면서 기능을 구현해보자. TDD는 일단 제일 작은 기능부터 테스트를 시작하며,
작동하지 않는(테스트를 통과하지 않는) 테스트 코드를 먼저 작성하고 -> 테스트 코드만 통과할 정도의 프로덕션 코드 작성 -> 리팩토링
의 단계를 거친다. Junit5를 이용하여 테스트 코드를 작성해보자.
이전 포스트에서의 sendMessageByWebhook()에서는 .send()의 파라미터에 payload를 String 형태로 직접 지정해주었다.
상황에 맞는 답변을 전달하기 위해서 payload를 반환하는 메서드 getPayloadByType()를 통해 커스터마이즈된 payload를 받도록 하자.
Event Subscription을 사용하기 때문에 app_mention 말고도 다양한 event들을 사용할 수 있다.
따라서 getPayloadByType 메서드에서 event type을 확인하고 그에 맞는 payload를 String 형태로 받을 수 있게끔 하려고 한다.
다만, payload는 text의 내용에 따라 동적으로 결정되어야 하는데 payload를 동적으로 반환할 메서드를 만들지 않았기 때문에
임시로 "HEY님 안녕하세요!"를 반환하도록 하고 테스트 코드를 작성하자.
이후에 payload의 text 부분을 동적으로 생성하는 customizeMsgByCondition() 메서드를 만들면 다시 수정하자.
@Test
@DisplayName("event의 종류가 app_mention일 때 실행되어야 한다.")
fun shouldRun_when_eventType_app_mention() {
//given
val eventValue = getEventValue(EventType.APP_MENTION.type, "");
//when
val payload = slackService.getPayLoadByType(eventValue);
//then
assertThat(payload).isEqualTo("{\"text\":\"HEY님 안녕하세요!\"}");
}
fun getPayLoadByType(eventMap: Map<String, String>) : String{
val eventType = eventMap["type"];
var payload = "";
when (eventType) {
EventType.APP_MENTION.type -> {
// payload 내용 추후에 변경
payload = "HEY님 안녕하세요!";
};
}
return "{\"text\":\"$payload\"}";
}
✅ Slack user의 display_name 받아오기
SlackService 클래스에는 getSlackDisplayName(userId:String)라는 메서드가 없기 때문에 해당 테스트 코드는 당연히 실패할 것이다. SlackService 클래스에 getSlackDisplayName 메서드를 생성하고 함수를 정의해주자.
getSlackDisplayName에서는 Slack user ID를 parameter로 받아서 Slack Client API인 usersInfo를 사용해서 사용자의 닉네임을 받아와야 한다.
@SpringBootTest
@Transactional
class SlackServiceTest {
@Autowired lateinit var slackService: SlackService;
@Test
@DisplayName("api를 통해 user의 정보를 받아올 수 있다.")
fun canExtract_userInfo_ByAPI() {
//given
//Slack에서 멤버 ID 복사를 통해 얻을 수 있다
val userId = "UXXXXXX";
//when
val username:String = slackService.getSlackDisplayName(userId);
//then
assertThat(username).isEqualTo("HEY");
}
}
fun getSlackDisplayName(userId: String): String {
// 사용자 정보를 요청하기 위한 Request 객체 -> usersInfo의 parameter
val userReq:UsersInfoRequest = UsersInfoRequest.builder()
.user(userId)
.token(botToken)
.build();
val result = runCatching {
Slack.getInstance().methods().usersInfo(userReq);
}
return result.fold(
onSuccess = { userInfo ->
val info = userInfo.user;
// user의 정보가 있을 때에만 displayName return
// 정보가 없을 때에는 예외 처리
info?.let {
return info.profile.displayName;
} ?: throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND);
},
onFailure = {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND);
}
);
}
✅ 메세지 내용이 only mention이거나, 인삿말(안녕, hello, 하이 등)이 포함되어있는지 확인
EventType이 app_mention인 경우에 실행되어 적절한 인삿말을 반환하는 메서드를 작성해보자.
event가 일어났을 때 전달되는 정보들 중, text에는 사용자가 작성한 내용이 들어있다.
<@U05MOOOO> 안녕? 과 같은 형태로 전달이 되는데, <@OOOOO> 이후에 텍스트가 없거나, 인삿말이 포함되어 있는지 여부를 확인해보면 될 것이다.
1. isGreetingCondition()을 통해 인사해야하는 조건인지 확인
<@U05MMBQ2AKD> 안녕과 같은 형태로 전달이 되기 때문에 split을 통해 언급과 직접 입력한 텍스트 두 개의 부분으로 나뉠 것이다.
직접 입력한 텍스트가 없는 경우와
인삿말 문자열이 담긴 배열을 하나 만들고, 직접 입력한 텍스트에 내용이 들어있는 경우에 true를 반환하면 될 것이다.
@Test
@DisplayName("멘션만 있거나 인삿말이 포함되어 있는 경우에는 true를 반환해야 한다.")
fun shouldReturn_True() {
//given
val text1 = "<@U05MMBQ2AKD>";
val text2 = "<@U05MMBQ2AKD> 안녕";
//when
val res1 = slackService.isGreetingCondition(text1);
val res2 = slackService.isGreetingCondition(text2);
//then
assertThat(res1).isTrue();
assertThat(res2).isTrue();
}
fun isGreetingCondition(text: String): Boolean {
val split = text.split(" ").filter { it.isNotEmpty() };
if (split.size == 1
|| greetings.any { greeting -> text.contains(greeting, ignoreCase = true) }) {
return true;
}
else {
return false;
}
}
2. customizeMsgByCondition()
이제 필요한 메서드들은 모두 다 만들었다. 만들었던 isGreetingCondition()과 getSlackDisplayName() 메서드를 이용해보자.
customizeMsgByCondition() 메서드를 만들고, 해당 메서드 안에서 멘션만 했는지 혹은 멘션과 함께 인삿말을 보냈는지 확인해보자.
Slack event에 대한 정보(eventValue)가 전달되면,
1) event 정보에서 text와 user에 대한 정보를 추출
2) Slack bot이 인사를 해야 하는 조건인지 확인
3-1) 인사를 해야하는 조건이라면 Slack Client 라이브러리를 이용하여 사용자의 displayName 추출 후 인삿말 출력
3-2) 인사를 하는 조건이 아니라면 "무슨 말인지 잘 모르겠어요" 출력
/**
* 포스팅에서는 customizeMsgByCondition에 대한 테스트 코드를 하나만 작성했지만,
* 다양한 상황에 대해 테스트 코드를 작성해야 합니다. (실제 코드에는 테스트 코드가 여러 개 있습니다)
* ex) 멘션만 있는 경우에 ()님 안녕하세요!를 출력해야 한다/인삿말이 아닌 경우 인삿말을 출력하지 않아야 한다.
*/
@Test
@DisplayName("slack event 내용에 인삿말이 있으면 ()님 안녕하세요!를 출력해야 한다.")
fun should_SayHello_when_IncludeHello() {
//given
val eventValue = getEventValue(EventType.APP_MENTION.type, "안녕");
//when
val greeting:String? = slackService.customizeMsgByCondition(eventValue);
//then
greeting?.let {
assertThat(greeting).isEqualTo("HEY님 안녕하세요!");
}
}
fun customizeMsgByCondition(eventValue: Map<String, String>): String {
val text = requireNotNull(eventValue["text"]) {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND)
};
val userId = requireNotNull(eventValue["user"]) {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND)
}
if (isGreetingCondition(text)) {
val userDisName = getSlackDisplayName(userId);
return "$userDisName" + "님 안녕하세요!";
} else {
return "무슨 말인지 잘 모르겠어요😅";
}
}
✅ 전체 코드
@Service
@Transactional(readOnly = true)
class SlackService(private val weatherService: WeatherService) {
@Value(value = "\${slack.bot-token}")
lateinit var botToken:String;
@Value("\${slack.webhook-url}")
lateinit var webhookUrl:String;
// slack bot의 message 전송
fun sendMessageByWebhook(eventMap: Map<String, String>){
val slackInst = Slack.getInstance();
val payload = getPayLoadByType(eventMap);
runCatching {
slackInst.send(webhookUrl, payload);
}
.onFailure {
err -> Logger.log.error(err.message);
throw BusinessException(ErrorCode.SLACK_MESSAGE_DONT_SEND);
}
}
// Event type에 따라 Slack 메시지 Payload 설정
fun getPayLoadByType(eventMap: Map<String, String>) : String{
val eventType = eventMap["type"];
var payload = "";
when (eventType) {
EventType.APP_MENTION.type -> {
payload = customizeMsgByCondition(eventMap)
};
}
return "{\"text\":\"$payload\"}";
}
// app_mention일 때 조건에 따라서 다른 메세지 전송
fun customizeMsgByCondition(eventValue: Map<String, String>): String {
val text = requireNotNull(eventValue["text"]) {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND)
};
val userId = requireNotNull(eventValue["user"]) {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND)
}
if (isGreetingCondition(text)) {
val userDisName = getSlackDisplayName(userId);
return "$userDisName" + "님 안녕하세요!";
} else {
return "무슨 말인지 잘 모르겠어요😅";
}
}
// slack api를 통해 요청한 사용자의 Profile display name를 받아옴
fun getSlackDisplayName(userId: String): String {
val userReq:UsersInfoRequest = UsersInfoRequest.builder()
.user(userId)
.token(botToken)
.build();
val result = runCatching {
Slack.getInstance().methods().usersInfo(userReq);
}
return result.fold(
onSuccess = { userInfo ->
val info = userInfo.user;
info?.let {
return info.profile.displayName;
} ?: throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND);
},
onFailure = {
throw BusinessException(ErrorCode.DATA_ERROR_NOT_FOUND);
}
);
}
fun isGreetingCondition(text: String): Boolean {
val greetings = listOf("안녕", "하이", "헬로", "반갑", "hello", "Hello");
val split = text.split(" ").filter { it.isNotEmpty() };
if (split.size == 1
|| greetings.any { greeting -> text.contains(greeting, ignoreCase = true) }) {
return true;
}
else {
return false;
}
}
}