依頼したら勝手に遠隔ナンパしといてくれるslack botの開発

## 経緯

ある日出会い系アプリ中毒に陥っている大学の友達からある依頼をされました。

**「 tinder自動化してくんね？ 」**

この一言がタイトルに書いたbot誕生の経緯です。

## 機能要求

#### 1. 指定したスポットでLIKEしたい
通常tinderでは現在地から近いユーザーがリコメンドされます。
しかし、課金ユーザーに限っては現在位置以外のスポットを自由に登録してそれを基準位置に設定することが可能です。
つまり、どんな地方の田舎者でも課金すれば渋谷や表参道、日本を飛び出してニューヨークなんかでもマッチングすることができます。

実際にそれを実装している人もたくさんいそうですね。
[【Python】TinderのAPIを使って、青山大学周辺で遠隔ナンパする](https://qiita.com/kkdmgs110/items/5f68ebef4afe230f75de)

#### 2. ある程度プロフィール画像でフィルタリングしたい

依頼者は自動化と言ってもちゃんと選別したいらしいです。欲の塊ですね。
~~まあ頼まれなくてもそうするつもりでしたが~~

最低限として、「プロフィール画像に顔が写ってない人は除外する」とかは顔認識系が充実しているので実装も容易そうです。


#### 3. Slackから自動スワイプを発火したい

今回の最大の肝です。**Slackからbotに依頼する形式**でTinderの自動スワイプを行いたいそうです。依頼者は一体どこまで怠慢なんでしょう？ というのも...

**「tinder自動スワイプ」**でググると如何に世の男性が同じことを考えてるのかがわかると思いますが、紹介されている記事ではローカルでpythonスクリプトを実行する形式がほとんどです。
頻繁に自動スワイプしたい人からするとこの点がかなりのネックになります。

tinderの自動化では当然tinderのAPIを利用するわけですが、そのためにはFacebookのアクセストークンが必要で、そのトークンを取得するのに結構手間がかかります。
さらに残酷なことにこのアクセストークンの有効期限が2時間なんですね。こればっかりはどうしようもなく、ローカルで頻繁に実行するのはかなりめんどくさいというわけです。

また、依頼者と共に利用しているSlackのワークスペースには筆者がhubotで作成したbotがいたので、そいつに自動スワイプの機能を追加すればいいじゃんという発想に至りました。

<img width="534" alt="スクリーンショット 2018-12-21 11.44.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/240875/2b217b95-3c91-441a-d13d-17dd9d85139f.png">

↑課題の提出締切をリマインドしてくれるなどの割と便利なbotです。
(ちなみに「けびん」というネーミングと癖のある口調は実在する同級生がモデルになってます。)

以上3つの要求をまとめると以下のような完成イメージになるのかなと考えました。

![bot.png](https://qiita-image-store.s3.amazonaws.com/0/240875/285f3206-686e-e7a4-0f72-187ee683a20a.png)


## 実装

先に完成したbotの流れをまとめるとこんな感じです。

![bot2.png](https://qiita-image-store.s3.amazonaws.com/0/240875/ec6b06de-d23e-aac0-a7c4-e5daa3e3948e.png)

### 自動スワイプのスクリプト

まずメインとなる自動スワイプのスクリプトです。
tinderAPIを利用している事例ではPythonの[pynder](https://github.com/charliewolf/pynder)というパッケージを利用している例が多いですが、普段から使い慣れているnodeで書きたかったのでnpmパッケージ探したところ、案の定[tinder-client](https://github.com/jaebradley/tinder-client)というのがありました。

```javascript
(async () => {
  const {TinderClient, GENDERS, GENDER_SEARCH_OPTIONS} = require('tinder-client')
  const Crypt = require('./util/crypt') // 暗号化モジュール
  const TINDER_NUM = process.env.TINDER_NUM || 10

  // hubotから発火する際にユーザーIDとアクセストークンが環境変数として渡される
  if(!(process.env.FB_USER_ID && process.env.FB_ACCESS_TOKEN)) {
    console.log('undefined parameter')
    return
  }

  // tinderAPIの認証
  const tinder_client = await TinderClient.create({
    facebookUserId: Crypt.decrypt(process.env.FB_USER_ID),
    facebookToken: Crypt.decrypt(process.env.FB_ACCESS_TOKEN)
  })

  // tinderユーザー設定更新
  await tinder_client.updateProfile({
    userGender: GENDERS.male,
    searchPreferences: {
      maximumAge: 30,
      minimumAge: 18,
      genderPreference: GENDER_SEARCH_OPTIONS.female,
      maximumRangeInKilometers: 30
    }
  })

  // 以下お好みのロジックでスワイプ
  let count = 0
  while(count < TINDER_NUM) {
    // リコメンドを取得し0人だったら終了
    let recommendations = await tinder_client.getRecommendations()
    if(recommendations.results.length === 0) break
    // リコメンドのセットをループ(約15人ずつくらい)
    for(let girl of recommendations.results) {
      await tinder_client.like(girl._id)
      if(++count >= TINDER_NUM) break
    }
  }

  // 以下結果通知等の処理
})
```

上記コードは説明のため簡潔化したものになります。実際はちゃんと機能要求①②に対応しました。

**①指定したスポットでLIKEしたい**については、tinder-clientに用意されているメソッド[changeLocation](https://github.com/jaebradley/tinder-client#changelocation)に緯度経度を渡せばAPIで基準位置を設定可能(課金ユーザーに限る)です。しかし、毎回緯度経度を調べるのは面倒なのでaxiosと[Geocoding API](https://www.geocoding.jp/api/)を利用しました。
これでSlackからは地名やスポット名を投げればうまいことやってくれるようにしました。"青学"のような略称でもOKなのでお手軽です。

**②プロフィール画像でフィルタリングしたい**については、[Face++](https://www.faceplusplus.com/)を利用しました。顔が写っているかが判定できるだけでなく、100点満点のスコアまで出してくれるんで比較的顔のレベルが高い人だけを選んでLIKEすることも可能です。もちろん当てにならないこともありますが、機械学習で顔のスコアが導き出されてしまうなんて恐ろしい世の中ですね...

具体的な使い方は以下の記事が参考になります。
[写真を送ると顔を検出して、顔面偏差値まで教えてくれるFace++APIのご紹介](https://qiita.com/markey/items/ac1d4f11aedc1e35626d)

結果通知に関しては[@slack/client](https://www.npmjs.com/package/@slack/client)を利用するなりしてお好みでどうぞ。

### Slackで依頼をリッスン → Travisのビルド発火

botはhubotでの実装が前提となっているのでcoffeescriptになってます。
redisにFacebookのユーザーIDとアクセストークンが保存されていることを想定しています。

```coffeescript:tinder.coffee

request = require('request')
# xxxはgithubユーザ名とリポジトリ名
TRAVIS_CI_API_URL = 'https://api.travis-ci.com/repo/xxx%2Fxxx/requests'

module.exports = (robot) ->
  robot.respond /((.+)で)?(めっちゃ)?((かわいい)|(可愛い))子((\d*)人)?ナンパしてきて/, (res) ->
    defalut_target = { location: '渋谷', border: 60, num: 100 }
    user_id = res.message.user.id

    # redisから依頼者のfacebookユーザーIDとアクセストークンを取得
    tinder_users = robot.brain.get('tinder_users') or {}
    unless tinder_users[user_id]
      res.reply "お前のTinderは未登録だぞ"
      return

    fb_uid = tinder_users[user_id].fb_uid
    fb_token = tinder_users[user_id].fb_token

    unless fb_uid and fb_token
      res.reply "まだ準備中だわ"
      return

    # 依頼文からパラメータ抽出
    location = res.match[2] || defalut_target.location
    border = res.match[3] ? 75 : defalut_target.border
    num = res.match[8] || defalut_target.num

    # TravisCI APIオプション
    options =
      url: TRAVIS_CI_API_URL
      method: 'POST',
      headers:
        'Content-Type': 'application/json'
        'Accept': 'application/json'
        'Travis-API-Version': '3'
        'Authorization': "token #{process.env.TRAVIS_TOKEN}"
      json: true
      body:
        request:
          message: 'tinder automation'
          branch: 'master'
          config:
            # 環境変数をパラメータとして利用する
            env:
              REQUESTER_ID: user_id       # 結果通知用として依頼者のslackユーザーIDも渡す
              TINDER_NUM: num             # 人数
              TINDER_LOCATION: location   # 場所
              TINDER_BORDER: border       # フィルタリングするレベル 
              FB_USER_ID: fb_uid          # facebookユーザーID
              FB_ACCESS_TOKEN: fb_token   # facebookアクセストークン

    # TravisCIにpostして発火
    request.post options, (error, response, body) ->
      if !error and response.statusCode >= 200
        res.reply "仕方ねえな。ちょっと待ってろ"
      else
        res.reply "わりぃ。今やる気ねえわ"
```

これで**Slackから自動スワイプを発火したい**という要求を実現できました。

ただしこれでは2時間経過したらアクセストークンが無効になってしまうので、トークンを更新してredisに保存しなければなりません。


### アクセストークン更新処理

ちなみに実際に手動でアクセストークンを取得する場合は[こちらの手順になります](https://workout-engineer.com/python-tinder-api/#Facebook_Access_Token)。
やってみると意外とめんどくさい手順ですが、あくまでブラウザ操作なのでpuppeteerで自動化できます。

```javascript:TinderAuth.js
const puppeteer = require('puppeteer')
const axios = require('axios')
const FB_AUTHENTICATION_URL = 'https://www.facebook.com/dialog/oauth?client_id=464891386855067&redirect_uri=fb464891386855067://authorize/&&scope=user_birthday,user_photos,user_education_history,email,user_relationship_details,user_friends,user_work_history,user_likes&response_type=token'

module.exports = class TinderAuth {
  constructor(email, password) {
    this.email = email
    this.password = password
  }

  getAccessToken() {
    return new Promise(async (resolve, reject) => {
      // headless chrome起動 & セットアップ
      const params = process.env.CI ? {args: ['--no-sandbox', '--disable-setuid-sandbox']} : {headless: false, slowMo: 50}
      const browser = await puppeteer.launch(params)
      const page = await browser.newPage()

      // facebook login
      try {
        await page.goto(FB_AUTHENTICATION_URL)
        await page.waitForSelector('#email')
        await page.type('#email', this.email)
        await page.type('#pass', this.password)
        await page.click('#loginbutton')
        await page.waitForSelector('button[name="__CONFIRM__"]')
      } catch(e) {
        console.log(e)
        reject(new Error('Puppeteer browsing error in login phase'))
      }

      // AccessTokenのレスポンスをリッスン
      page.on('response', async response => {
        const urlRegex = /\/v[0-9]\.[0-9]\/dialog\/oauth\/(confirm|read)/
        if (response.url().match(urlRegex)) {
          try {
            const body = await response.text()
            const [, token] = body.match(/access_token=(.+?)&/)
            this.access_token = token
            await browser.close()
            resolve(token)
          } catch(e) {
            console.log(e)
            reject(new Error('Puppeteer browsing error in confirm response phase'))
          }
        }
      })

      // ログイン成功すれば確認ダイアログのOKをクリック
      try {
        await page.click('body')
        await page.click('button[name="__CONFIRM__"]')
      } catch(e) {
        console.log(e)
        reject(new Error('Failure to login'))
      }
    })
  }

  async getUserId() {
    if(!this.access_token) return false
    try {
      let {data: {id: uid}} = await axios.get(`https://graph.facebook.com/me?access_token=${this.access_token}`)
      return uid
    } catch(e) {
      console.log(e)
      throw new Error('invalid access token or API error')
    }
  }
}
```

```javascript:index.js
const axios = require('axios')
const TinderAuth = require('./TinderAuth')
const Crypt = require('./util/crypt') // 独自の暗号化モジュール
const HUBOT_API_URL = 'https://xxxxx.xxxx/xxxxx'

(async () => {
  // ユーザーデータ(暗号化されたもの)をHubotから取得
  let response = await axios.get(HUBOT_API_URL)
  let users = response.data

  // 全員分アクセストークンを取得
  for(uid in users) {
    try {
      let email = Crypt.decrypt(users[uid].email)
      let password = Crypt.decrypt(users[uid].password)
      const tinderauth = new TinderAuth(email, password)
      let access_token = await tinderauth.getAccessToken()
      let user_id = await tinderauth.getUserId()
      users[uid].fb_token = Crypt.encrypt(access_token)
      users[uid].fb_uid = Crypt.encrypt(user_id)
    } catch(e) {
      console.log(e)
      continue
    }
  }

  // Hubotに投げてredisのデータを更新
  await axios.post(HUBOT_API_URL, users)
  console.log('Succeeded to update access token')
})
```

hubotにはexpressが組み込まれているので、GETとPOSTでredisのユーザーデータを管理できるようにしています。あとはこれを2時間毎に実行できればOKです。

Puppeteer(headless chrome)が動作して、スケジューリングできて、無料枠で利用できるという条件で考えると以下のような候補があります。

- CloudWatch + Lambda 
- Google Apps Script + Cloud Functions
- EC2、GAE

オンプレでも大丈夫ですがヘッドレスブラウザの設定がめんどくさそうです。

理想は自動スワイプと同じTravisでやりたかったのですが、Travisのcronの最低単位がdailyだったので断念しました。

逆になぜ自動スワイプがTravisなのかというと、、、

- Slackで複数ユーザーから依頼される → 非同期(キューイング)で呼び出す必要がある
- スワイプ人数が数百人以上の場合がある → スクリプトの実行時間がLambda等の1回の実行時間制限を超えてしまう
- 月間でめちゃくちゃ利用するやつがいる → 月間のビルド時間制限があるCIとかは使えない

という理由です。Travisはpublicリポジトリであれば無料枠でもビルド時間無制限なのでありがたいです。


## 完成したbot

slack APIの [Attaching content ](https://api.slack.com/docs/message-attachments) や [Interactive messages](https://api.slack.com/interactive-messages) を利用していろいろとカスタマイズした結果こんな感じになりました。

![res.jpg](https://qiita-image-store.s3.amazonaws.com/0/240875/b6a963e1-b299-d903-639f-c505029447a6.jpeg)

[ボタン](https://api.slack.com/docs/message-attachments#attaching_buttons_to_messages)を設置することで別のslackユーザーもslackからLikeやSuper Likeできるようになりました。

友人のふざけたアイデアから想像以上に凝ったbotが出来上がりましたが、作っててかなりおもしろかったです。
もっと面白いアイデアあればぜひコメントで教えてください！！