Chatwork APIで弊社の闇を暴いてみた

[記事のアプリケーションはgithubに公開中](https://github.com/hermit255/co-worker-counter)

会社が発表しないサイレント退職者をChatworkから調べるアプリケーションを作った話をまとめました。
(※運用によって利用者様が所属する企業とトラブルが起きても当方は責任を負いかねます)

Slack、スクレイピング、エクセル文書でもデータ取得方法をアレンジすれば流用できるはず

## 発端
- 弊社には200人超の社員が在籍しているのだけど、顔見知りだった社員がいつの間にか辞めていることも割とあり退職者発表みたいなものも無いのですごくモヤモヤしていた
- 課題を技術で解決してこそエンジニアだ!
- 前職も気が付いたら人が辞めていたので、同じ悩みを持つ人がいるはず　→　満たされる承認欲求
- ~~連休なのに予定がなくてヒマ~~

## 機能的な目標
- 自社のChatwork連絡先をもとに毎日の在籍者変動を監視する
- 監視結果をマイチャンネルに投稿する
- 年間、月間など一定期間でのまとめレポートも見られるようにする
- 個人情報やAPIキーの取り扱いに気を付ける

## 使用技術
- ホスト環境: Windows10 + Ubuntu20.4 on WSL2 (Dockerが使えればホストOSは関係ない)
- Docker
- Python(Django)

## 成果
期間内での新入社員/退職者の表示と、日々の社内メンバー変動把握ができた

#### ↓レポート画面
![report.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156466/546a0bf8-ab93-c6d6-2436-7674e9320e24.png)
#### ↓Chatwork通知結果画面
![myroom.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156466/8a0568e5-3d73-0592-7956-d798d5f3d9c8.png)

## 作業過程
### 環境構築
- `docker-compose` コマンドさえ使える環境なら他に必要なものは [githubに公開してあるプロジェクト](https://github.com/hermit255/co-worker-counter) に含まれてます

- アプリケーションの動作に必要な Chatwork API のトークン(+ バッチの通知を利用するなら通知先のroom_idも)は各自でご用意ください
[APIトークンにの取得方法(公式)](https://developer.chatwork.com/ja/)

### Docker環境構築

```dockerfile:Dockerfile
FROM python:3
ENV PYTHONUNBUFFERED 1
ENV PYTHONIOENCODING utf-8
RUN mkdir /script /app
WORKDIR /script
COPY requirements.txt /script/
RUN apt update && apt install -y cron vim
RUN pip install -r requirements.txt
WORKDIR /app
```

```txt:requirements.txt
Django>=3.0,<4.0
psycopg2-binary>=2.8
django-environ
requests
python-dateutil
```

```yml:docker-compose.yml
version: '3'

services:
  app:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    environment:
      - CHATWORK_API_TOKEN=${CHATWORK_API_TOKEN}
      - ROOM_ID=${ROOM_ID}
      - TZ=Asia/Tokyo
    volumes:
      - ./app:/app
      - ./export_environment.sh:/script/export_environment.sh
      - ./crontab:/script/crontab
    ports:
      - "8000:8000"
```

### 主な処理説明

- 自アカウントのトークン使って連絡先メンバー一覧を取得
- 取得時期の違う一覧データを照らし合わせることで加入メンバー/脱退メンバーを明らかにする
- APIから過去データを参照することはできないので、比較対象となるデータはDBに格納する
- `get_diff()` に2つの日付を与えると連絡先メンバーの差分を返すように設計
- レポートを表示する機能を持つ`show()`では１カ月単位過去半年間のメンバー変動レポートを表示する(月単位でのレポートなので、ホットな退職者情報は後述のバッチで対応する)
- バッチ処理では同じくレポートと同じ手法で、日々日々で変動したメンバーを速報としてChatworkに投稿する

##### レポート画面処理 + チャットワーク連絡先一覧の取得と保存、データ比較メソッド

```python:app/chatwork/views.py
from django.shortcuts import render
from django.http import HttpResponse
from chatwork.models import Account
import requests
from datetime import date
from dateutil.relativedelta import relativedelta
from django.db.models import Count
import environ
env = environ.Env(DEBUG=(bool, False))

# Create your views here.

def show(request):
    diff_list = list()
    for i in range(6):
        end = (date.today() - relativedelta(months=i)).isoformat()
        start = (date.today() - relativedelta(months=(i+1))).isoformat()
        diff_list.append(get_diff(start, end))

    params = dict(d1=diff_list[0], d2=diff_list[1], d3=diff_list[2], d4=diff_list[3], d5=diff_list[4], d6=diff_list[5])
    return render(request, 'chatwork/show.html', params)

def get_diff(start, end):
    if not Account.objects.filter(date=date.today().isoformat()):
        base = 'https://api.chatwork.com/v2/'
        end_point = 'contacts'
        api_token = env('CHATWORK_API_TOKEN')
        headers = {'X-ChatWorkToken': api_token, 'Content-Type': 'application/x-www-form-urlencoded'}
        res = requests.get(base + end_point, headers=headers)
        for contact in res.json():
            data = dict(account_id=contact['account_id'], name=contact['name'][:2], department=contact['department'], date=date.today().isoformat())
            Account.objects.update_or_create(**data)
    query = Account.objects.filter(date__gte=start, date__lte=end).values('date').annotate(Count('date'))
    if len(query) < 2:
        return dict(period='no comparable data found during ' + start + ' ~ ' + end, added=list(), dropped=list())
    latest = query.order_by('-date')[0]['date'].isoformat()
    oldest = query.order_by('date')[0]['date'].isoformat()
    period = oldest + '~' + latest

    data_latest = Account.objects.filter(date=latest) or list()
    data_oldest = Account.objects.filter(date=oldest) or list()

    ids_latest = data_latest.values_list('account_id', flat=True) if data_latest else list()
    ids_oldest = data_oldest.values_list('account_id', flat=True) if data_oldest else list()

    added = Account.objects.filter(date=latest).filter(account_id__in=ids_latest).exclude(account_id__in=ids_oldest)
    dropped = Account.objects.filter(date=oldest).filter(account_id__in=ids_oldest).exclude(account_id__in=ids_latest)

    return dict(period=period, added=added, dropped=dropped)
```

##### バッチ処理(当日と前日データの差分を自分のマイチャットに送信する)

```python:app/chatwork/management/commands/contact_daily.py
from django.core.management.base import BaseCommand

from chatwork.models import Account
from chatwork.views import get_diff

from datetime import date
from dateutil.relativedelta import relativedelta
import environ
import requests
env = environ.Env(DEBUG=(bool, False))

class Command(BaseCommand):
    def handle(self, *args, **options):
        today = date.today().isoformat()
        yesterday = (date.today() - relativedelta(days=1)).isoformat()
        data = get_diff(yesterday, today)
        report_title = data['period']
        report_added = 'added: ' + '(' + str(len(data['added'])) + ')' + ' / '.join(list(d.name for d in data['added']))
        report_dropped = 'dropped: ' + '(' + str(len(data['dropped'])) + ')' + ' / '.join(list(d.name for d in data['dropped']))
        report = """
{report_title}
{report_added}
{report_dropped}
        """.format(report_title=report_title, report_added=report_added, report_dropped=report_dropped).strip()

        base = 'https://api.chatwork.com/v2/'
        room_id = env('ROOM_ID')
        end_point = 'rooms/' + room_id + '/messages'
        api_token = env('CHATWORK_API_TOKEN')
        headers = {'X-ChatWorkToken': api_token, 'Content-Type': 'application/x-www-form-urlencoded'}
        payload = dict(body=report, self_unread=1)
        res = requests.post(base + end_point, headers=headers, params=payload)
```

### その他
- ユニットテストも簡易だが実装
- 個人情報のお漏らしは洒落にならないので、APIから取得した氏名は先頭の二文字だけDB保存する仕様にした
- 社用アカウントのAPIトークンが流出するのも絶対NGなので、環境変数として渡す仕組みにした
- バッチ処理は毎日実行するのでcron経由だが、環境変数がユーザーと独立しているのでアレコレの対策が必要だった
- Django のModels を使ったDB取得やイテレートの扱いなど不便に感じるところも多かった。LaravelのQueryBuilderやCollectionクラスは他社のコードでも理解しやすいという点も含め流石だと再認識

## 最後に
休日に雑な思いつきで作ったアプリなので粗は多いですが、ご意見いただけると大変励みになりますので、コメントお寄せください。

- 詳細な解説追加要望
- 改善案のご指導
- その他技術に関する質問など