「OK, Google」でTeamSpiritをポチる


皆さんの職場では勤怠管理やってますか？
最近はベンチャーでも勤務実態の把握がどうたらと出勤退勤つけさせたがりますよね。
今の職場もTeamSpiritで管理してるんですが、あれ面倒です。とても面倒です。大事なことなんで何度も言います。
ページ開いてポチッと押すだけと言われればそうなんですが、なんか面倒です。

APIとか公開されてれば適当に自動化するんですがそういうのはないっぽいし、「[パソコン開かずにTeamSpiritに打刻するのがすごいラク | ATOMS by Loftwork](http://atoms.loftwork.jp/20171219_googlehome_teamspirit/)」ではChatter使ってますが、うちではChatterは使ってないのでこれもできない。
というわけで、Seleniumとか使ってなんとかしました。

何番煎じだよって感じですがやった内容をご紹介。

## 1. 全体構成

```
IFTTT -> Integromat -> API Gateway -> Lambda -> ECS
```

IFTTTで「OK,Google 仕事開始/終了」を受け取って、最終的にdocker image化したseleniumでTeamSpiritの出勤・退勤ボタンをポチってます。

## 2. 詳細

### 2.1. IFTTT

Google Assistantに対応してて、「OK, Google」から他の何か呼び出すのが簡単だったのでこいつを採用。

<img width="419" alt="ifttt-googleassistant.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/09912168-b1d9-65a3-bc37-f1d7db3a665e.png">

ここから直接AWSのAPI Gatewayに繋ぎたかったけどそれはできなかった。

API Gatewayで完全公開なAPIにしちゃうと、リンクが知られれば誰でも私の勤怠をつけれちゃうようになるのでAPI keyで制限かけるようにしたいのですが、API Gatewayでそれをやるにはカスタムヘッダ(x-api-key)でkeyを渡すようにしなくてはいけません。

で、問題はIFTTTのWebhooksのアクションではカスタムヘッダを設定できないことです。そこでそれができる[Integromat](https://www.integromat.com/)を一旦通すことにしました。
（※そのうちIFTTTでカスタムヘッダが設定できるようになったらIFTTTだけにしたい。IntegromatはFreeプランあるけど使いすぎると有料になるし。）

<img width="415" alt="ifttt-integromat.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/33cfae88-241c-a2e0-0bf2-db8815a79176.png">

### 2.2. Integromat

[Integromat](https://www.integromat.com/)はIFTTTと同じようなサービスですが、かなり複雑なフローでサービスをつなぎ合わせることができます。途中で複数サービスにブロードキャストさせたり、条件で分岐させたり、ループ作ったりとか色々できます。（複雑にすると途端にフリープランの範囲内では難しくなりますが......）

設定はかなりめんどくさいしドキュメントもあまり親切じゃないのでとっつきにくいですが、慣れるとかなり面白いです。

<img width="567" alt="integromat.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/e45c8a7e-93e0-6529-2090-8b37054d3520.png">

今回は複雑なことをしたいわけではなくて、単にカスタムヘッダーつけてAPI Gateway呼びたいだけですのでフリープランで十分やってけます。

<img width="410" alt="integromat-webhooks.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/393402af-93f2-03aa-aa58-d10936c4b5fa.png">

まずはwebhookつくります。これを作ると呼び出し用のURLが発行されて、このURLへのアクセスを契機に構築したフローを動かせます。

<img width="404" alt="integromat-http.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/97b2be9e-ec29-7485-3394-8d7a9c47cb6f.png">

で、それを受けてAPI Gatewayを呼び出すようにします。
もちろんここでx-api-keyを設定します。webhookで受け取ったパラメータをここでもつかえるので、IFTTTからの呼び出しに「出勤/退勤」のパラメータを設定するようにしておいてAPI Gatewayの呼び出しに使います。

### 2.3. API Gateway

ここからAWSです。

<img width="709" alt="apigateway.png" src="https://qiita-image-store.s3.amazonaws.com/0/88387/c6ceca91-ef46-4d11-8cdd-d0abfe56e84f.png">

API Gatewayがやることは単純にLambdaを呼び出すだけです。
API keyの設定とかは適当にググって調べてやります。

### 2.4. Lambda

LambdaはECSでタスクを実行させるだけです。

```python
import boto3
import json
import logging


logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    req = json.loads(event["body"])
    if "kick_type" not in req:
        logger.warn("no kick_type.")
        return {
            "statusCode": 400,
            "body": "",
        }
    kick_type = req["kick_type"]
    if kick_type not in ("start", "end", "test"):
        logger.warn("kick_type missmatch.")
        return {
            "statusCode": 400,
            "body": "",
        }
    if kick_type == "start":
        entrypoint = "entry"
    elif kick_type == "end":
        entrypoint = "exit"
    else:
        entrypoint = "test"
        
    client = boto3.client('ecs')
    client.run_task(
        cluster="attendance-management-cluster",
        taskDefinition="attendance-management-job-define",
        launchType="FARGATE",
        count=1,
        overrides={
            'containerOverrides': [
                {
                    "name": "attendance-management",
                    "command": [
                        "python3",
                        "/opt/entrypoint/{}.py".format(entrypoint),
                    ],
                },
            ],
        },
        networkConfiguration={
            "awsvpcConfiguration": {
                "subnets": [
                    "subnet-xxxxxxxx",
                ],
                "assignPublicIp": "ENABLED",
            }
        })
    logger.info("kick {}.".format(entrypoint))
    return {
        "statusCode": 200,
        "body": "",
    }
```

ここで[PhantomJS](http://phantomjs.org/)とか[CasperJS](http://casperjs.org/)とか使ってもいいかなと思ったんですが面倒そうだし、node.jsあまり使ったことないというのと、[Fargate](https://aws.amazon.com/jp/fargate/)使ってみたいという欲求もあったのでECSにしました。

### 2.5. ECS

ECSではFargateを使うので常時起動させておくサービスとかはないです。
ECRにselenium同包したdocker image置いといて、それを呼び出される度に起動してます。

#### 2.5.1. Dockerfile

seleniumが公式のdocker imageを出してるのでそれつかいます。
selenium/node-chromeがseluserってユーザー使うようになってるので、pythonやら入れる間はrootに切り替えてます。

```dockerfile
FROM selenium/node-chrome

USER root

RUN apt update -y
RUN apt install -y python3 python3-pip
RUN pip3 install --upgrade pip 
RUN pip3 install selenium

ADD entrypoint /opt/entrypoint/

USER seluser
```

#### 2.5.2. 勤怠管理スクリプト

loginしてhomeに遷移して、そこにあるiframe内の出勤/退勤ボタンをポチるだけです。出勤と退勤の内容はほぼ変わりません。ボタンのidとdisable時のclass名が違うくらいです。
time.sleepがそこら中にでてくるのはご愛嬌。

##### 出勤用

```python:entrypoint/entry.py
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.chrome.options import Options

import os
import time

width = os.getenv("BROWSER_WIDTH", None)
height = os.getenv("BROWSER_HEIGHT", None)
team_id = os.getenv("TEAMSPIRIT_ID", None)
user_id = os.getenv("TEAMSPIRIT_USER_ID", None)
user_pass = os.getenv("TEAMSPIRIT_USER_PASSWORD", None)

login_url = "https://{}.cloudforce.com/".format(team_id)
top_url = "https://{}.cloudforce.com/home/home.jsp".format(team_id)

options = Options()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--window-size={},{}".format(width,height))
driver = webdriver.Chrome(chrome_options=options)

try:
    # login
    driver.get(login_url)
    driver.implicitly_wait(10)
    time.sleep(10)
    uid = driver.find_element_by_id("username")
    password = driver.find_element_by_id("password")
    uid.send_keys(user_id)
    password.send_keys(user_pass)
    driver.find_element_by_id("Login").click()
    time.sleep(10)

    # attendance
    driver.get(top_url)
    time.sleep(10)
    iframe = driver.find_element_by_xpath("//iframe[@title='AtkWorkComponent']")
    driver.switch_to_frame(iframe)
    time.sleep(5)

    in_button = driver.find_element_by_xpath("//div[@id='btnStInput']")
    class_attr = in_button.get_attribute("class")
    if "pw_btnnst_dis" in class_attr:
        print("出勤不可")
    else:
        print("出勤")
        in_button.click()
        time.sleep(10)
except NoSuchElementException:
    print("出勤ボタンなし")

driver.quit()
```

##### 退勤用

```python:entrypoint/exit.py
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.chrome.options import Options

import os
import time

width = os.getenv("BROWSER_WIDTH", None)
height = os.getenv("BROWSER_HEIGHT", None)
team_id = os.getenv("TEAMSPIRIT_ID", None)
user_id = os.getenv("TEAMSPIRIT_USER_ID", None)
user_pass = os.getenv("TEAMSPIRIT_USER_PASSWORD", None)

login_url = "https://{}.cloudforce.com/".format(team_id)
top_url = "https://{}.cloudforce.com/home/home.jsp".format(team_id)

options = Options()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--window-size={},{}".format(width,height))
driver = webdriver.Chrome(chrome_options=options)

try:
    # login
    driver.get(login_url)
    driver.implicitly_wait(10)
    time.sleep(10)
    uid = driver.find_element_by_id("username")
    password = driver.find_element_by_id("password")
    uid.send_keys(user_id)
    password.send_keys(user_pass)
    driver.find_element_by_id("Login").click()
    time.sleep(10)

    # attendance
    driver.get(top_url)
    time.sleep(10)
    iframe = driver.find_element_by_xpath("//iframe[@title='AtkWorkComponent']")
    driver.switch_to_frame(iframe)
    time.sleep(5)

    out_button = driver.find_element_by_xpath("//div[@id='btnEtInput']")
    class_attr = out_button.get_attribute("class")
    if "pw_btnnet_dis" in class_attr:
        print("退勤不可")
    else:
        print("退勤")
        out_button.click()
        time.sleep(10)
except NoSuchElementException:
    print("退勤ボタンなし")

driver.quit()
```

### 2.6. その他

他にAWS CodePipelineとAWS CodeBuildとか使ってます。

コードはここ
https://github.com/bokeneko/attendance-management

## 3. まとめ

色々使ったけどコストは1ヶ月10-20円くらいでいけそう。
それで毎朝晩teamspiritひらいてポチポチやるストレスから解放されるならよし。