相手がタスクを完了するまで鬼リマインドを続けるslackアプリ作った。もう...誰も催促しなくて済む世界にしたいの...

# メンションをつけた相手がタスクを完了するまで指定された周期でお尻を叩き続けるslackアプリ「penpen」を作った

https://penpen.netlify.app/

https://twitter.com/slack_penpen/status/1276800701176311810?s=20


## こんなイメージ

1) slashコマンドで起動します。`/penpen`の後に、リマインダーの内容と、お尻を叩きたい相手を指定します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/49cd7265-df9e-7277-d47d-0963e0742482.png)

2) 以上

## 相手にはこんな風に見えます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/ea082bd6-3a28-d930-718d-4d5d35077c0f.png)

`終わった`をクリックすると、リマインダーの一覧から消えます。ついでにリマインダーを設定した相手に「タスク完了したよ！」と通知してあげます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/b0726705-810b-b934-b16b-76cf17e615f7.png)


使い方はこんな感じです。


### 時間を細かく指定できる
`every 3 hours` や　`every 2 days`　など指定することで、お尻を叩く周期を選べます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/ffb2ae83-bef6-a8e3-43e2-20a3202c3ddf.png)


最短で１時間毎に相手をペンペンできます。


### 何人でも指定できる
1つのリマインダーに複数人を指定する事も可能です。一応、英語対応もしているため、slackの言語設定が日本語以外であれば、英語でお尻を叩いてくれます。


## 作ろうと思った背景


#### ケース１：自分の指示が忘れられる
エンジニア「あの、デザイナーさん、この画面のデザイン確認をお願いします」
デザイナー「はい分かりました」
エンジニア「頼んだやで」

（３時間後）

エンジニア「あのぅ・・・例のデザイン確認・・・」
デザイナー「あっ忘れてました。すぐやります」
エンジニア「頼んだやで」

（１時間後）

エンジニア「あの・・・・・」
デザイナー「あっ」


#### ケース2:相手からの指示を忘れる

エンジニア「うーん、ちょっと持ち帰らせてください」
企画「分かりました。じゃあ明日、結果を教えてください」
エンジニア「はいやで」

（明日）

企画「例の件どうなりました？」
エンジニア「あっ」


#### ケース3:自分から自分への指示を忘れる

エンジニア「あーしまった、ここのログ調査しなきゃいけないんだったー。trelloとか記載するの面倒だし、slackのreminderか、スマホのリマインダーでも設定しておこ」

（明日）

reminder「ログ調査やで」
エンジニア「今飯だから後でやるわ」

（3日後）

エンジニア「あっ」

.
.
.



**...もう、誰かに催促されたり、誰かを催促しなくて済む世界にしたいの...**

ってわけで、使いなれたslackを離れることなく、相手がタスクを完了するまで、**地の果てまで追いかけてお尻を叩き続けるslackアプリ**を作ることに決めました

## 構成
- インフラ：heroku, Cloud Scheduler, Pub/Sub, Cloud Functions
- バックエンド：node.js, TypeScript, express, bolt
- フロント:nuxt.js, netlify
    - 蛇足：最近nuxtが[真のSSG](https://nuxtjs.org/blog/going-full-static/)に近づいた

## 制作期間
- 約3日（16時間）
    - 環境構築: 1時間
    - API: 13時間
        - 単体テスト: ５時間
        - 機能実装: 8時間
    - FE: １時間
    - slackの設定: 1時間
 
## 仕組み
- [bolt](https://slack.dev/bolt-js/concepts)を起動

最初のうちboltは「これslashコマンドと普通のRESTエンドポイントどうやって共存させるんだろう」と若干ハマりましたが、こんな感じでexpressReceiverを取得してあげれば解決します

```
import { App, ExpressReceiver } from '@slack/bolt'

const receiver = new ExpressReceiver({ signingSecret: config.SLACK_SIGNING_SECRET })
const app = new App({
  token: config.SLACK_BOT_TOKEN,
  receiver
})

receiver.router.get('/task', getTaskAndUpdatedSendAtHandler(app, conn)) // 普通のREST
app.command(config.SLASH_COMMAND_NEW_TASK, slashPostTaskHandler(app, conn)) // slashコマンド
app.action(ACTIONS.buttonClick, deleteTaskHandler(app, conn)) // ボタンクリック時などのaction

app.start(config.PORT)
```

boltを使っておくとslackの会話内容をlistenして、例えば「これお願いします」みたいなワードに反応してpenpenが発言する、みたいな機能が簡単に作れるので、今後の対話性を見据えてboltを採用しました。



### タスクを投稿する
- slackのslashCommandを待ち受ける
slashCommandは基本的に全てAPIの/slack/eventsエンドポイントで受け取る形になりますが、前述のboltを使うことで`app.command('hoge')`とすれば/hogeに反応するようになります。

- 発言を解析
`AI`が発言を解析...していません。地道に正規表現でマッチしてます。 `every 12 hours` なら１２時間ごと、 `every 3 months`なら3ヶ月ごとなど、リマインダーの周期を決定します

- タスクを保存
[TypeORM](https://typeorm.io/)を使っています。


### oauth認証
ユーザーからのアクション（slashコマンドやボタン押下）に対して応答するだけであれば、イベントが発火した時に引数に含まれる[say()](https://slack.dev/bolt-js/concepts)を呼び出すだけで事足りますが、自分から積極的に発言していく`ウェイ`タイプのbotは[oauth](https://api.slack.com/authentication/oauth-v2)認証が必要です。これを準備していきましょう

ちなみにslackには現状2つのoauth認証フローがあって（非常にややこしい）

こちらが先代： https://api.slack.com/legacy/oauth
こちらが現役: https://api.slack.com/authentication/oauth-v2

間違ったドキュメントを読み解かないよう気をつけましょう！


- slackApp作成後、管理画面で「配信を管理する」を押すと、埋め込み可能なボタンが取得できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/0326345f-5060-790a-0678-ee1b9f6a5962.png)

- このボタンをLPなどに設置しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/b454f924-89ed-6a0f-3deb-59bee44f8772.png)

- このボタンをクリックしたユーザはslackのoauth認証画面に遷移し、そちらで認証後、こちらが指定したリダイレクト先に帰ってきます。その際urlのクエリに`code`, `state`が付与されているので、これを取得します。
    - nuxtはSSGの場合asyncDataなどでqueryにアクセスできないため、mounted等で[アクセスせざるを得ない](https://github.com/nuxt/nuxt.js/issues/5935#issuecomment-628008195)ようです。（時間を惜しんで、ちゃんと調べていないけど）

- `code`, `state` を自分が用意したAPIエンドポイントに渡します

- API側で`https://slack.com/api/oauth.v2.access`に対して、先ほど取得した`code`,`clientId`,`clientSecret`などを含めて送信します。
    - この時、`application/json`と`application/x-www-form-urlencoded`どちらを指定するか次第で、若干必要な情報が変わってきますので[ご注意ください](https://api.slack.com/authentication/oauth-v2#using)

- さっき俺は`state`が必要だと言ったな？[あれは嘘だ](https://dic.pixiv.net/a/%E3%81%82%E3%82%8C%E3%81%AF%E5%98%98%E3%81%A0)
    - と言うのは大げさですが、stateは不正防止のために自分が照合するだけなので、無視しても不具合は起きません。一応penpenは照合しています。

- `https://slack.com/api/oauth.v2.access`からのレスポンスにslackの`teamId`や`teamName`や`accessToken`が含まれているので、これらをDBに保存しましょう。accessTokenは今後boltの`postMessage`を実行する時など、ユーザのslackワークスペース上でやり取りするのに使います


### タスクをリマインドする

- Cloud Scheduler -> Pub/Sub -> Cloud Functionsの流れで定期的にAPIを叩いて、タスクの送信（リマインド）を実行

- Slackの[blockKit](https://app.slack.com/block-kit-builder)でメッセージを組み立てる
    - 「終わった！」ボタンを押した時に「どのタスクを」「誰が終了したのか」把握するため、[ボタン](https://api.slack.com/reference/block-kit/block-elements#button)のvalueに各種idを詰めておきます
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246648/e39edc5c-ae13-917f-d4e2-833f9cd85ed0.png)

- メッセージを送信
    - あとはboltの[app.client.chat.postMessage()](https://api.slack.com/methods/chat.postMessage)で送信しましょう。前述のoauth認証で取得したアクセストークンを使用します


### 完了したタスクをslackメッセージから削除する
- boltの[client.chat.update](https://api.slack.com/methods/chat.update)を使います
- ここは少し注意が必要かもしれません。過去のslackメッセージを削除する場合、以下の情報が必要です
    - channelId
    - timestamp
    - accessToken
- `タイムスタンプ`が無いと、updateはメッセージを特定できないようです。なんかmessageIdとか無いの？と思ったんですが、タイムスタンプを使うみたいですね。まぁ充分なのかな。

- ボタンをクリックした時にはメッセージのblockKitが丸ごと送られてくるので、そこから自分が削除したいメッセージを探し出して、更新します
    - blockKitが複雑なメッセージだとTypeScriptでparseするのが大変そうですが、そこは適宜に`筋肉(any)`で誤魔化しました

### 自分に割り振られたタスクの一覧を取得する
- `/penpen-list`のスラッシュコマンドをフックに、自分宛のタスクを一覧化して送信する機能がありますが、これはまぁ・・・`/penpen`とほぼ変わらないので割愛します

### 単体テスト
- 個人開発なので誰もレビュワーがおらず、不具合が怖いので一応テストはカバレッジ6割ぐらい書いてます。
- 何の役に立つかわかりませんが、参考までにjestでboltをモックアウトする所だけ書いておきます

```
import { App } from '@slack/bolt'
jest.mock('@slack/bolt', () => {
  return {
    App: jest.fn().mockImplementation(() => {
      return {
        client: {
          chat: {
            postMessage: jest.fn(),
            update: jest.fn()
          }
        }
      }
    })
  }
})

const app = new App() // あとはモック化されてるので煮るなり焼くなりご自由に
```

- ちなみにjestの場合はnode_modulesと同じ階層に`__mocks__/@slack/bolt.js`を作って、その中でモックを定義しておけば[自動的にモックされる](https://jestjs.io/docs/ja/manual-mocks#node-%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%AE%E3%83%A2%E3%83%83%E3%82%AF)ので、明示的にjest.mock()を呼ぶ必要もなく、毎度モックをテストファイル毎に定義し直す必要もなく、簡単です

## 以上
以上が大体のpenpenの構成です。

- タスク漏れを防ぎたい
- 周りを何度も催促して疲れている

そんな方は、ぜひ使ってみてください。

（そして、もし壊れていたら、そっと教えてください...）



https://penpen.netlify.app/