Node.js로 Slack 봇 만들기

Node.js의 slack client 모듈을 사용하여 Slack Bot API 에 접근해서 리시버와 답장 기능을 구현하는 방법이다. 이 글에서는 복잡한 언어처리는 다루지 않고 기본적인 수준의 응답 처리만 구현하였다.

slackbot

요즘 커뮤니티나 사내 메신저로 Slack을 많이 사용하는 것 같다. 카카오톡이나 다른 메신저는 API를 제공하지 않아서 봇을 만들기에 한계가 있는데 Slack에서는 공식적으로 봇을 지원해줘서 재밌는 것을 많이 할 수 있는 것 같다. 직장인들의 최대 고민거리인 점심 뭐먹지 같은 문제를 Slack에 봇 형태로 만들어서 사용하면 많은 고민거리를 줄일 수 있지 않을까싶다.


시나리오 및 라이브러리

$ npm install slack-client request cheerio string-similarity async --save

우선 봇 만들기 전에 필요한 라이브러리를 설치한다. 기본적인 시나리오는 아래와 같다.

  1. 배고픔, 점심식사와 관련된 문장을 감지한다.
  2. 네이버 블로그의 포스팅 중 현재 지역의 맛집을 검색한다.
    • 슬랙에서는 사용자의 현재 위치를 API를 통해 받을 수는 없다. 따라서 채팅방이 주로 사용되는 위치로 검색하였다. 여기서는  어은동 맛집 을 검색하였다.
  3. 검색된 맛집에서 랜덤으로 한가지를 뽑아 제목과 링크를 답변해준다.

라이브러리를 간단히 소개하면…

  • slack-client Slack API에 접근하여 메시지를 감지하고 답장한다.
  • request 웹 상의 데이터를 수집할 때 사용한다.
  • cheerio 데이터를 jQuery 형태로 쉽게 파싱하기 위해 사용한다.
  • string-similarity 모든 문장을 일일이 등록 할 수는 없기 때문에, 특정 문장이 들어왔을때 응답할 목록과 비교하여 유사도를 기반으로 답장하기 위해 사용하였다.

Slack Client

Slack Client 모듈은 크게 RtmClient와 WebClient로 나뉘어 있다. RtmClient는 Real Time Messaging client로 실시간으로 메시지가 오는 것을 감지하고 답장할 수 있다. WebClient는 실시간은 아니지만 RtmClient에서는 지원하지않는 파일첨부 등을 수행 할 수 있다.

var RtmClient = require('slack-client').RtmClient;
var WebClient = require('slack-client').WebClient;
var token = 'Your Access Token';

var web = new WebClient(token);
var rtm = new RtmClient(token, {logLevel: 'error'});
rtm.start();

var RTM_EVENTS = require('slack-client').RTM_EVENTS;
rtm.on(RTM_EVENTS.MESSAGE, function (message) {
    var channel = message.channel;
    var user = message.user;
    var text = message.text;

    if (text == 'hello')
        web.chat.postMessage(channel, 'World!', {username: "noticebot"});
});

기본적인 Slack Client는 위와 같이 선언된다. rtm에 이벤트를 작성하여 메시지가 들어올 경우 리스너로 동작하게 하는 것이다. 위의 token은 Slack의 설정에서 봇을 생성하면 나오는 토큰이다.

스크린샷 2016-05-02 오후 8.37.27

위의 링크로 가서 봇을 생성하고 나면 API Key가 화면에 출력된다. 생성된 API Key를 위의 코드에 입력해주면 접근이 가능해진다. 우선 위의 코드와 같이 작성하고 실행한 후 Slack에서 봇에게 hello를 보내보자.

$ node index.js

실행을 하면 Slack에서 봇의 상태가 온라인으로 바뀌어있는 것을 확인 할 수 있다. 해당 봇을 채널로 초대하여 채널에서 사용할 수도 있고, 다이렉트 메시지로 응답을 받을 수도 있다. 다이렉트 메시지에 hello를 입력하면 bot이 World! 라고 답변할 것이다.


데이터 수집

var request = require('request');
var async = require('async');
var cheerio = require('cheerio');

var baseurl = 'http://section.blog.naver.com/sub/SearchBlog.nhn?type=post&option.keyword=%EC%96%B4%EC%9D%80%EB%8F%99%20%EB%A7%9B%EC%A7%91&term=&option.startDate=&option.endDate=&option.page.currentPage={{page}}&option.orderBy=sim';
var search = function (page, result, end) {
    var url = baseurl.replace('{{page}}', page);

    async.waterfall([
        function (callback) {
            request.get({
                url: url
            }, function (err, res, html) {
                if (err)
                    return callback(err);
                var $ = cheerio.load(html);
                callback(null, $);
            });
        },
        function ($, callback) {
            $('.search_list li h5 a').each(function () {
                result.push({title: $(this).text().trim(), href: $(this).attr('href')});
            });
            callback(null);
        }
    ], function () {
        if (page >= 5) {
            function shuffle(array) {
                var currentIndex = array.length, temporaryValue, randomIndex;
                while (0 !== currentIndex) {
                    randomIndex = Math.floor(Math.random() * currentIndex);
                    currentIndex -= 1;
                    temporaryValue = array[currentIndex];
                    array[currentIndex] = array[randomIndex];
                    array[randomIndex] = temporaryValue;
                }
                return array;
            }

            result = shuffle(result);

            var random_pick = [];
            var idx = 0;
            while (random_pick.length < 1) {
                if (result[idx].title.length > 0) {
                    random_pick.push(result[idx]);
                }
                idx++;
            }

            end(random_pick);
        } else {
            search(page + 1, result, end);
        }
    });
};

search(1, [], function (result) {
    console.log(result);
});

데이터 수집을 하는 코드는 위와 같다. async를 사용하여 데이터 처리단위로 flow를 구성하였고, cheerio를 사용하여 css selector로 원하는 정보를 추출하였다. 블로그 검색에서 5페이지까지만 데이터를 찾고, 전체 결과에서 랜덤으로 데이터를 섞어 첫번째 데이터만 출력하게 하였다. 만약 한번에 n개의 추천 리스트를 출력하고 싶다면 while문의 조건에 숫자를 원하는 숫자만큼 바꾸면된다.

[ { title: '[지구iN 추천맛집] 111-7meal / 대전 유성 어은동 / 맛집 / 함박스테이크',
 href: 'http://blog.naver.com/kigamblog?Redirect=Log&logNo=220634460017&from=section' } ]

이 함수의 결과는 위와 같이 JSON Array 형태로 출력되고, 블로그의 제목과 링크만 추출하였다.


Bot 응답 처리

var RTM_EVENTS = require('slack-client').RTM_EVENTS;
rtm.on(RTM_EVENTS.MESSAGE, function (message) {
    var channel = message.channel;
    var user = message.user;
    var text = message.text;

    var detecting = ['배고파', '배고픔', '뭐먹을까', '뭐먹지', '저녁', '점심', '맛집 추천', '식사', '식사 추천', ' 저녁 추천'];
    var matches = stringSimilarity.findBestMatch(text, detecting).bestMatch;
    if (matches.rating < 0.5) return;

    search(function (result) {
        var resp = '<' + result[0].href + '|' + result[0].title + '>';
        web.chat.postMessage(channel, resp, {username: "noticebot"});
    });
});

봇의 응답 처리는 비교적 간단하게 구현하였다. 단어 사전을 만들어 놓고 해당 단어 사전에서 사용자가 보낸 메시지를 전체 비교하여 유사도를 구한 뒤 가장 유사도가 높은 것이 0.5 보다 높으면 검색을하여 추천해주도록 구현하였다.


전체 코드

var stringSimilarity = require('string-similarity');
var RtmClient = require('slack-client').RtmClient;
var WebClient = require('slack-client').WebClient;
var token = 'Your Access Token';

var request = require('request');
var async = require('async');
var cheerio = require('cheerio');

var baseurl = 'http://section.blog.naver.com/sub/SearchBlog.nhn?type=post&option.keyword=%EC%96%B4%EC%9D%80%EB%8F%99%20%EB%A7%9B%EC%A7%91&term=&option.startDate=&option.endDate=&option.page.currentPage={{page}}&option.orderBy=sim';
var search = function (end, page, result) {
    if (!page) page = 1;
    if (!result) result = [];

    var url = baseurl.replace('{{page}}', page);

    async.waterfall([
        function (callback) {
            request.get({
                url: url
            }, function (err, res, html) {
                if (err)
                    return callback(err);
                var $ = cheerio.load(html);
                callback(null, $);
            });
        },
        function ($, callback) {
            $('.search_list li h5 a').each(function () {
                result.push({title: $(this).text().trim(), href: $(this).attr('href')});
            });
            callback(null);
        }
    ], function () {
        if (page >= 5) {
            function shuffle(array) {
                var currentIndex = array.length, temporaryValue, randomIndex;
                while (0 !== currentIndex) {
                    randomIndex = Math.floor(Math.random() * currentIndex);
                    currentIndex -= 1;
                    temporaryValue = array[currentIndex];
                    array[currentIndex] = array[randomIndex];
                    array[randomIndex] = temporaryValue;
                }
                return array;
            }

            result = shuffle(result);

            var random_pick = [];
            var idx = 0;
            while (random_pick.length < 1) {
                if (result[idx].title.length > 0) {
                    random_pick.push(result[idx]);
                }
                idx++;
            }

            end(random_pick);
        } else {
            search(end, page + 1, result);
        }
    });
};

var web = new WebClient(token);
var rtm = new RtmClient(token, {logLevel: 'error'});
rtm.start();

var RTM_EVENTS = require('slack-client').RTM_EVENTS;
rtm.on(RTM_EVENTS.MESSAGE, function (message) {
    var channel = message.channel;
    var user = message.user;
    var text = message.text;

    var detecting = ['배고파', '배고픔', '뭐먹을까', '뭐먹지', '저녁', '점심', '맛집 추천', '식사', '식사 추천', ' 저녁 추천'];
    var matches = stringSimilarity.findBestMatch(text, detecting).bestMatch;
    if (matches.rating < 0.5) return;

    search(function (result) {
        var resp = '<' + result[0].href + '|' + result[0].title + '>';
        web.chat.postMessage(channel, resp, {username: "noticebot"});
    });
});

댓글 남기기