Slackのオンラインユーザーにレビュー依頼するBot

レビュワーを選ぶSlack用のBotをつくりました。
コピペでも多分動きますが、こちらが全ソースです
[Github Oden](https://github.com/s1160054/oden)

# Oden
 - Slack APIを使います
 - レビューチャネルのオンラインのユーザーを、ランダムに２人選びます
 - データをRedis & JSONで保持します
   - これらのデータはSlack上のメッセージで操作できます 
     1. User
          - オンラインユーザーリスト
     2. Skip
         - その日はレビューをスキップする人リスト
     3. Never
         - これから先レビューしない人リスト     
    
## コマンド

Odenにメンションを飛ばすことでコマンドが実行できます

| Cmd | Description| Detail |
|---|---| --- |
| pr | レビュワーを選ぶ | |
| users | ユーザーを表示 | `１時間以内にオンライン` ＆ `skips`でも`nevers`でもないユーザー |
| user+(.*) | ユーザーを追加する | カンマや空白区切りで複数可 |
| user-(.*) | ユーザーをスキップする | その日だけレビューをスキップする<br>カンマや空白区切りで複数可<br>`user-me`で自分をスキップ |
| skips | スキップされたユーザーを表示 |  |
| nevers | 削除されたユーザーを表示 |  |
| never+(.*) | ユーザーを削除 | 永遠にレビューをスキップする<br>カンマや空白区切りで複数可<br>`user-me`で自分を削除 |
| never-(.*) | ユーザーを復活 | レビューを再開する<br>カンマや空白区切りで複数可 |
| config | botの設定を表示する | |

## 環境変数

### Odenが使う環境変数

|Config Variable| Default value | Description|
|---|---|---|
| CHANNEL | "random" | レビュワーのいるチャネル名 |
| SELECT_NUM | 2 | レビューに必要な人数 |
| FETCH_CRON | １０分毎 |  ユーザーのオンラインをチェックする間隔 |
| SKIP_CRON | 毎日０時 | スキップされたユーザーを復活させる間隔 |
| CLEAR_CRON | １時間毎 | オフラインユーザーをユーザーから外す間隔 |
| JSON_PATH | ./db.json | 永続化用JSONファイルのパス |

デフォルト値がはいってるので、そのままでも動きます

### Hubotで使う環境変数

|Config Variable| Description |
|---|---|
| HUBOT_SLACK_BOTNAME | Botの名前 | 
| HUBOT_SLACK_TEAM | Slackのチーム名 |
| HUBOT_HEROKU_KEEPALIVE_URL | `heroku open` |
| HUBOT_SLACK_TOKEN | Apps & Integrations (必須)|
| TZ | Asia/Tokyo |

## Odenをローカルで動かす

    % bin/hubot --adapter slack

You'll see some start up output and a prompt:

    [Tue Nov 15 2016 11:48:00 GMT+0900 (JST)] INFO Logged in as post_twitter of ほげほげチーム
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO Slack client now connected
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO load
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO save
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO 
    [ 'レビュワー: `2人`',
      'チャンネル: `cross-review`',
      'オンラインユーザー追加: `*/10 *   * * *`',
      'オフラインユーザー削除: `0    */1 * * *`',
      'レビュースキップの取消: `0    0   * * *`' ]
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO List:
    [Tue Nov 15 2016 11:48:01 GMT+0900 (JST)] INFO hubot-redis-brain: Using default redis on localhost:6379

# 実装

```coffeescript:oden.coffee
# Description
#  レビュー依頼Bot
#

request = require('request')
cronJob = require('cron').CronJob
fs      = require('fs')

select_num   = process.env.SELECT_NUM  || 2
channel_name = process.env.CHANNEL     || 'random'
super_user   = process.env.SUPER_USER  || 'admin'
fetch_cron   = process.env.FETCH_CRON  || '*/10 *   * * *'
clear_cron   = process.env.CLEAR_CRON  || '0    */1 * * *'
skip_cron    = process.env.SKIP_CRON   || '0    0   * * *'
path         = process.env.JSON_PATH   || './db.json'
token        = process.env.HUBOT_SLACK_TOKEN

module.exports = (robot) ->
  robot.brain.setAutoSave false

  load = ->
    robot.logger.info "load"
    data = JSON.parse fs.readFileSync path, encoding: 'utf-8'
    robot.brain.mergeData data
    robot.brain.setAutoSave true

  save = (data) ->
    robot.logger.info "save"
    fs.writeFileSync path, JSON.stringify data

  robot.brain.on 'loaded', save
  load()

  robot.logger.info config()
  fetch_users(robot)

  # 設定を表示する
  robot.respond /config/, (msg) =>
    msg.send "\n#{config().join('\n')}"

  # レビュワーを選ぶ
  robot.respond /pr/, (msg) =>
    users = get(robot, 'users')
    never_users = get(robot, 'never_users')
    skip_users = get(robot, 'skip_users')
    my_name = msg.message.user.name
    for name in [super_user, my_name] + never_users + skip_users
      skip_idx = users.indexOf(name)
      users.splice(skip_idx, 1) if skip_idx != -1
    if users.length < select_num
      msg.send("アサインできるレビュワーが #{users.length} 名です\n")
      return
    users = random_fetch(users, select_num)
    msg.send("@#{users.join(' @')}")

  # ユーザーをスキップ
  robot.respond /user-(.*)/, (msg) =>
    user = msg.match[1]
    user = msg.message.user.name if /me/.test(user)
    add(robot, 'skip_users', user)
    rm(robot, 'users', user)

  # ユーザーを追加
  robot.respond /user\+(.*)/, (msg) =>
    user = msg.match[1]
    user = msg.message.user.name if /me/.test(user)
    add(robot, 'users', user)
    rm(robot, 'skip_users', user)

  # ユーザーを表示
  robot.respond /users/, (msg) =>
    users = get(robot, 'users')
    msg.send("レビュー可能なユーザー: \n#{users.join(', ')}")

  # スキップリストを表示
  robot.respond /skips/, (msg) =>
    skip_users = get(robot, 'skip_users')
    msg.send("スキップしたユーザー: \n#{skip_users.join(', ')}")

  # ユーザーを復活
  robot.respond /never-(.*)/, (msg) =>
    user = msg.match[1]
    user = msg.message.user.name if /me/.test(user)
    add(robot, 'users', user)
    rm(robot, 'never_users', user)

  # ユーザーを削除
  robot.respond /never\+(.*)/, (msg) =>
    user = msg.match[1]
    user = msg.message.user.name if /me/.test(user)
    add(robot, 'never_users', user)
    rm(robot, 'users', user)

  # 削除されたユーザーを表示
  robot.respond /nevers/, (msg) =>
    never_users = get(robot, 'never_users')
    msg.send("削除されたユーザー: \n#{never_users.join(', ')}")

  # スキップリストをリセット
  new cronJob(skip_cron, () ->
    robot.brain.set('skip_users', [])
    robot.logger.info "skip"
  ).start()

  # ユーザーリストをリセット
  new cronJob(clear_cron, () ->
    robot.brain.set('users', [])
    robot.logger.info "clear"
  ).start()

  # ユーザーリストを更新
  new cronJob(fetch_cron, () ->
    fetch_users(robot)
    robot.logger.info "fetch"
  ).start()

  # 生存確認
  robot.router.get '/', (req, res) ->
    res.send 'pong'

##################################################

# 設定を配列で返す
config = () ->
  ["レビュワー: `#{select_num}人`",
   "チャンネル: `#{channel_name}`",
   "オンラインユーザー追加: `#{fetch_cron}`",
   "オフラインユーザー削除: `#{clear_cron}`",
   "レビュースキップの取消: `#{skip_cron}`",]

# ユーザーを更新する
fetch_users = (robot) ->
  users = get(robot, 'users')
  robot.logger.info "List: #{users.join(', ')}"
  groups_list = "https://slack.com/api/groups.list?token=#{token}&pretty=1"
  request.get groups_list, (error, response, body) =>
    data = JSON.parse(body)
    target_channel = null
    for group in data.groups
      target_channel = group if group.name == channel_name
    if target_channel
      user_ids = target_channel.members.sort -> Math.random()
      for user_id in user_ids
        check_online(robot, user_id)
    else
      channels_list = "https://slack.com/api/channels.list?token=#{token}&pretty=1"
      request.get channels_list, (error, response, body) =>
        data = JSON.parse(body)
        for channel in data.channels
          target_channel = channel if channel.name == channel_name
        user_ids = target_channel.members.sort -> Math.random()
        for user_id in user_ids
            check_online(robot, user_id)

# ユーザーを追加する
check_online = (robot, user_id) ->
  do (user_id) ->
    users_getPresence = "https://slack.com/api/users.getPresence?token=#{token}&user=#{user_id}&pretty=1"
    request.get users_getPresence, (error, response, body) =>
      data = JSON.parse(body)
      if (data.presence == "active")
        users_info = "https://slack.com/api/users.info?token=#{token}&user=#{user_id}&pretty=1"
        request.get users_info, (error, response, body) =>
          data = JSON.parse(body)
          return if data.user.is_bot
          user_name = data.user.name
          never_users = get(robot, 'never_users')
          skip_users = get(robot, 'skip_users')
          return if (never_users + skip_users).indexOf(user_name) != -1
          users = get(robot, 'users')
          robot.logger.info "Add:  #{user_name}" if users.indexOf(user_name) == -1
          users.push(user_name)
          robot.brain.set('users', uniq(users))

get = (robot, key) ->
    return (robot.brain.get(key) || []).slice(0)

rm = (robot, key, value) ->
    values = value.split(/[　・\s,、@]+/)
    arr = get(robot, key)
    for value in values
      skip_idx = arr.indexOf(value)
      arr.splice(skip_idx, 1) if skip_idx != -1
    robot.brain.set(key, arr)
    console.log "#{key} #{robot.brain.get(key, arr)}"
    return arr

add = (robot, key, value) ->
    values = value.split(/[　・\s,、@]+/)
    arr = get(robot, key)
    arr = arr.concat(values)
    arr = uniq(arr)
    arr.splice arr.indexOf(''), 1 if arr.indexOf('') != -1
    robot.brain.set(key, arr)
    console.log "#{key} #{robot.brain.get(key, arr)}"
    return arr

uniq = (ar) ->
  if ar.length == 0
    return []
  res = {}
  res[ar[key]] = ar[key] for key in [0..ar.length-1]
  value for key, value of res

random_fetch = (array, num) ->
  a = array
  t = []
  r = []
  l = a.length
  n = if num < l then num else l
  while n-- > 0
    i = Math.random() * l | 0
    r[n] = t[i] or a[i]
    --l
    t[i] = t[l] or a[l]
  r
```