bot用フレームワークなしでslack botを作る

# はじめに  
以前slack botを作ろうとbotkitを用いて作成していたのですが、herokuで稼働した時にちょっとした問題があったのでbotkitなしでslack botを作った際の知見を共有したいと思います。
*記事書くのは初めてなので拙いと思います。

# botkitとherokuの問題点と解決策  
問題は2つ
1. botkit内でexpressを使用しているため、1つのプロセス内に他の機能を盛り込めないこと
2. herokuの無料枠では1つのプロジェクトにつき1つのプロセスしか動作できないこと

この2つがいい感じに噛み合ってしまい、slack botの機能と外部に提供するAPI機能の両立ができないのです。
この問題の解決策として、機能を追加するための利便性とbot稼働とAPIサーバを両立するためにはbotフレームワークなしで自作するという考えに至りました。
自分でexpressを立てればbot以外の機能を盛り込みつつ、1つのプロセスで動かすことができるからです。

# 環境  
Node.js (v11.11.0)
ESMAScript2015

Babelは導入するのが面倒臭かったので使わず、拡張子をmjsにして動作させています。
参考にした記事: [Node.js(v8.5.0以降)でBabelやwebpackを利用せずにimport/exportを利用する](https://qiita.com/Kaoru_Yamamoto/items/bcca9b63220e23b69399)

# まずは公式ページでbotを作成  
[https://api.slack.com/](https://api.slack.com/)
ここからStart Building → Create New Appでslack botを作成します。
![start_building.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192285/eb1e4b95-1408-4cc5-1c6a-8252d5952bb5.png)  
今回のbot名は偶々目に入った可愛いeevee(イーブイ)にしました。
ワークスペースは共用の場だと色々と迷惑になるので自分専用のワークスペースです。

このままではワークスペースに登録するbotがないのでBotUSerページのAdd a Bot Userで追加します。

# Oauthを通す  
[https://api.slack.com/authentication/oauth-v2](https://api.slack.com/authentication/oauth-v2) のページ中程にあるフロー画像の通りに進めていきます。
そのために先ずはRedirect URLを設定します。
![oauth_permission.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192285/26917130-fc87-9ecc-ee27-94c5ebccb883.png)
Oauth&PermissionsページのAdd New Redirect URLに`http://localhost:8000/oauth`と設定します。
また後々Real Time Messaging APIを使ってWebSocketを通すので、その時用にScopesに`Admin, identify ,bot`の3つを追加しておきます。

以下oauth認証まで行うコードです。
access_tokenが取れればOKです。
CLIENT_IDやCLIENT_SECRETは.evnファイルに記述し、dotenvを使うことで環境変数としています。

```js

// 環境変数用
import dotenv from 'dotenv';
dotenv.config();

import request from "request";
import express from "express";
import bodyParser from 'body-parser';

const app = express();

// port番号設定
app.set('port', (process.env.PORT || 8000));

// サーバ起動時
app.listen(app.get('port'), function () {
  console.log('server launched');
});

// bot oauth認証用
app.get('/oauth', function (req, res) {
  res.header('Content-Type', 'text/plain;charset=utf-8');

  let code = req.query.code;
  if(code){
    res.send('oauth setting is collect');
    oauth(code);
  }
  else{
    res.send('oauth setting is wrong');
  }
});

function oauth(code) {
  request.post({
    url: 'https://slack.com/api/oauth.v2.access',
    form: {
      code: code,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET
    }
  }, function (err, res, body) {
    let access_token = JSON.parse(body).access_token;
    console.log(access_token);
  });
}
```

コードの実行はterminalに`node --experimental-modules main.mjs`で動きます。
これでダメな場合は `node --experimental-modules --es-module-specifier-resolution=node main.mjs`とすれば動くことがあります。
実行するためにだらだらと書く必要がありますが、Babelの設定が要らないので環境導入でつまずく心配がないです。

次にoauthフロー画像の下にあるURLをブラウザに打ち込みます。
`https://slack.com/oauth/v2/authorize?scope=incoming-webhook,commands&client_id=xxxxxxx.xxxxxxxxxxx`
xxxxxxx.xxxxxxxxxxxは自身のCLIENT_IDに書き換えてください。
![oauth_redirect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192285/8e1e7e37-3412-9349-2d0a-9785abaca1e1.png)
URLをブラウザに打ち込んでこのような画面が出てきたらOKです。
適当にチャンネルを選択してAllowを押し、エラーがなければterminalにaccess_tokenが出るのでメモっておきましょう。
もしメモり忘れてもOauth&PermissionsページのOAuth Tokens & Redirect URLsに記述されています。xoxbの方です。

# RTMを用いてWebSocketを通す  
RTMの詳しいことはこちらに書いてあります。[https://api.slack.com/rtm](https://api.slack.com/rtm)
メモっておいたaccess_tokenも.envファイルに書き込んでおきます。

```js

// サーバ起動時
app.listen(app.get('port'), function () {
  console.log('server launched');
  // 新しく追加
  rtmStart();
});

// Real Time Messageing APIの開始
function rtmStart() {
  request.get({
    url:'https://slack.com/api/rtm.start',
    qs: {
      token: process.env.ACCESS_TOKEN
    }
  }, function (err, res, body) {
    let websocketURL = JSON.parse(body).url;
    socket(websocketURL);
  });
}

import WebSocket from 'ws';
let ws = null;

function socket(url) {
  ws = new WebSocket(url);

  // websocketが繋がった時
  ws.on('open', function () {
    console.log('Open WebSocket');
  });

  // botが居るチャンネルで何か書き込まれた時
  ws.on('message', function (data) {
    data = JSON.parse(data);
    // slackに書き込んだ言葉を表示する
    console.log(data.text);
  });

  // websocketが閉じた時
  ws.on('close', function (data) {
    console.log('Close WebSocket');
  });
}
```

これでWebSocketが通りました。
botが居るチャンネルで何か打つとその言葉がterminalに表示されるます。
また、最初に必ず`data.type`がhelloだけを持つdataが通るので`undefind`も出ているはずです。

# slack botに機能追加  
これでようやく準備が終わり、思うがままにslack botに機能を追加できます。
`ws.on('message', function (data)`内の`console.log(data.text);`の下に`mainProccess`関数を呼んであげるように追記します。

```js

// ここがslackからデータを受け取るメイン処理
function mainProcess(data) {
  // 接続時にhelloが通るので
  if (data.type === 'hello')
    return;

  // ハウリングするのでbotには反応しない
  if (data.bot_id !== undefined)
    return;

  let type = data.type;
  if (type === 'message') {
    // subtypeを持っているmessage typeには反応しない
    if (data.subtype !== undefined)
      return;

    let text = data.text;
    let is_mention = text.match(/<@(.*)/);
    let mention_user = undefined;
    let mention_text = undefined;
    // メッセージではなくファイルならfile_idが存在する
    let file_id = data.files !== undefined ? data.files[0].id : undefined;

    // メンションが付いているなら文章本体だけを抜き出す
    if (is_mention) {
      let tmp = text.split('<@')[1];
      mention_user = tmp.split('> ')[0];
      mention_text = text.split('> ')[1];
    }

    // Message型はuser_id, text, channel_id, ts, is_mention, mention_user, mention_text, file_idのプロパティを持つ
    let message = new Message(data.user, text, data.channel, data.ts, is_mention, mention_user, mention_text, file_id);
    switchProcess(message);
  }
}

// メンション有り無しやコマンドに応じた分岐処理
function switchProcess(message) {
  // botにメンションなら
  if (message.mention_user === process.env.BOT_ID) {
    // 記事を表示
    if (message.mention_text === 'help')
      api.deleteMessage(message.channel_id, message.ts);
      api.postEphemeral(message.channel_id, 'Qiita記事のページだよ\nhttps://qiita.com/KessaPassa/items/a82b972b7fdedd2e13f7', message.user_id);
  }
}

// メッセージ削除
function deleteMessage(channel, ts, time = 30 * 1000) {
  setTimeout(function () {
    request.post({
      url: 'https://slack.com/api/chat.delete',
      form: {
        token: process.env.ACCESS_TOKEN,
        channel: channel,
        ts: ts,
        as_user: true
      }
    }, function (err, res, body) {
      if (err) throw err;
    });
  }, time);
}

// 普通にメッセージを送る
function postMessage(channel, text) {
  request.post({
    url: 'https://slack.com/api/chat.postMessage',
    form: {
      token: process.env.ACCESS_TOKEN,
      channel: channel,
      text: text
    }
  }, function (err, res, body) {
    if (err) throw err;
  });
}
```
`@eevee help`とbotにメンションをつけてhelpとするとこのQiita記事のURLが返事として返ってきます。

因みにMessage型の中身はこちらになります。

```js
const _user_id_message = Symbol();
const _text_message = Symbol();
const _channel_id_message = Symbol();
const _ts_message = Symbol();
const _is_mention_message = Symbol();
const _mentionUser_message = Symbol();
const _mentionText_message = Symbol();
const _file_id = Symbol();

export class Message {
  constructor(user_id, text, channel_id, ts, is_mention, mention_user, mention_text, file_id) {
    this[_user_id_message] = user_id;
    this[_text_message] = text;
    this[_channel_id_message] = channel_id;
    this[_ts_message] = ts;
    this[_is_mention_message] = is_mention;
    this[_mentionUser_message] = mention_user;
    this[_mentionText_message] = mention_text;
    this[_file_id] = file_id;
  }

  get user_id() {
    return this[_user_id_message];
  }

  get text() {
    return this[_text_message];
  }

  get channel_id() {
    return this[_channel_id_message];
  }

  get ts() {
    return this[_ts_message];
  }

  get is_mention() {
    return this[_is_mention_message];
  }

  get mention_user() {
    return this[_mentionUser_message];
  }

  get mention_text() {
    return this[_mentionText_message];
  }

  get file_id() {
    return this[_file_id];
  }
}
```

# 最後に  
oauth認証してWebSocktを通せば後はただのプログラミングなのでslack botに色々な機能を持たせることができます。
是非`switchProccess`関数に色んな処理を追加して自分好みのslackにカスタマイズしてみてください！

この記事を書くに当たって、検証しつつ部分部分を取り除いた形になるのでわかりにくいところがあるかと思います。
ちょっとoauth関係が古いですが現在も弊研究室で稼働しているbotの中身がgithubで公開しているのでそちらを参考にしてみてください。
分報やチャンネル毎にメモ機能など便利なコマンドを追加しています。
[https://github.com/KessaPassa/slackbot-extensions](https://github.com/KessaPassa/slackbot-extensions)