clasp + TypeScriptで課題改善botを作った

# なにこれ
この記事は[ギフティ Advent Calendar 2019 - Qiita](https://qiita.com/advent-calendar/2019/giftee) 4日目の記事です。
既存システムの問題点をなんかいい感じに取得して投げてくれるbotを作ったのでまとめます。


# 背景
現在のチームでは既存システムについて営業サイドなどから改善点・問題点としてGoogleスプレッドシートに既定のフォーマットで記載されていきます。
（この運用がそもそも良いものかは別の話として）ざっくばらんに挙がってくる課題のどれが優先的に対応する必要があるのか、どれが簡単に対応できるのかがわからないと対応するのが大変です。
そこで、セルに重要度や工数（作業の大変さ等）から適当に重みをつけた**旬の課題**を出したくなりました。



・・・で、スプレッドシートを操作することができる `Google Apps Script` というものを利用するわけですが、そこの標準ファイル形式である`.gs`ファイル（jsの独自改造？）をブラウザ上で取り扱うには幾らか問題がありました。

- そも論、すごく大変だった。classすらないし
- 手で操作できてしまう。誤削除怖すぎ。
- え、コミット履歴は？

ということで、もっと効果的に作りたいと思い至り今回は[clasp](https://github.com/google/clasp)を利用して開発をしていこうと思います。



# 事前準備
claspをインストール

```sh
# npmの場合
$ npm install -g @google/clasp

# yarnの場合
$ yarn global add @google/clasp
```

初回は認証します。

```sh
$ clasp login
```

以下のようなページが開くので、一番下までスクロールして承認すればOK。
<img width="466" alt="スクリーンショット 2019-12-04 14.19.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/692a34ea-c985-2f5b-6736-d6d9cee35602.png">

色々コマンド弄りたいのでgasライブラリも入れておきます。

```sh
$ yarn add @types/google-apps-script   
```

また、今回typescriptで作るので色々いじれるようにします。

```sh
$ yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier eslint-plugin-prettier
$ yarn run eslint --init
```

# プロジェクトの作成
今回は既存のスプレッドシートに対し色々できるようなスクリプトを作ることがゴールです。
なので以下のようなコマンドを実行します。

```sh
$ clasp create --title "clasp-test" --rootDir "./src"
? Create which script? standalone
Created new standalone script: https://script.google.com/d/***/edit
Warning: files in subfolder are not accounted for unless you set a '.claspignore' file.
Cloned 1 file.
└─ ./src/appsscript.json

$ tree -L 2
.
├── src
│   └── appsscript.json
└── yarn.lock
* 余分なのは消してます
```

そうすると以下のようなファイルが作られるので内容を確認します。

```sh
$ cat .clasp.json
{
    "scriptId": "***",
    "rootDir": "./src"
}

$ cat src/appsscript.json
{
  "timeZone": "America/New_York",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER"
}
```
この時点で https://script.google.com/home を見ると新しくプロジェクトが作成されていることが確認できます。

ここで、一旦プログラムが動くことを確認します。
今回 `src` 配下をルートディレクトリに設定したので、src配下に適当にプログラムを作ります。

```src/index.ts
// 雑にメッセージを出すだけ。
function main() {
    Logger.log("Hello clasp!");
}
```

ありがたいことにclaspは標準でtypescriptをサポートしてくれているので通常のデプロイコマンドにてデプロイできます。

```sh
$ clasp push
```

該当のプロジェクトを見ると次のようにトランスパイルされていることがわかります。

<img width="638" alt="スクリーンショット 2019-12-04 15.27.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/9307e0bd-a69d-f251-5db9-638048db9e6e.png">

この状態で実行してみると、ちゃんと想定通り動いてくれていることがわかります。
<img width="802" alt="スクリーンショット 2019-12-04 15.34.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/12573df3-18f3-a1fd-bd30-331c304f97d0.png">

あとは好きにプログラムを書くだけ。

# スプレッドシートから値を取ってみる
適当に値を入れときます。
<img width="379" alt="スクリーンショット 2019-12-04 15.51.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/f2e1a764-a9cd-b5aa-00a1-c2f8c721790b.png">

で、このスプレッドシートから値を取るわけですが、プログラム上にハードコードはしたくないですよね。

そこで、GASでは秘匿情報をプロパティとして設定できるので、その機能を利用します。
<img width="779" alt="スクリーンショット 2019-12-04 15.40.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/57f32025-2437-1bc6-c193-5d8fca40a144.png">

今回はシートのID（URLの `https://docs.google.com/spreadsheets/d/<これ>/edit#gid=0`）を`SHEET_ID`として保存します。
<img width="797" alt="スクリーンショット 2019-12-04 15.42.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/2b33d65e-af02-d195-c555-9e5e07697025.png">

で、折角なのでクラス分割します。
プロパティを取得し返却するクラスを用意します。

```src/model/properties.ts
export class Properties {
    private sheetID: string;
    constructor() {
        this.sheetID = PropertiesService.getScriptProperties().getProperty('SHEET_ID');
    }
    getSheetID(): string {
        return this.sheetID;
    }
}
```

値を取得して中身を見るようにプログラムを改変します。

```src/index.ts
import { Properties } from "./model/properties";

function main() {
    const p = new Properties();
    this.sheets = SpreadsheetApp.openById(p.getSheetID()).getSheets();
    // とりあえず全部で何行あるかを確認する
    const indexColumns = this.sheets[0].getRange('A:A').getValues();
    const lastRow = indexColumns.filter(String).length;

    // 値が存在する範囲で末端までの全要素を出す
    const values = this.sheets[0].getRange(1, 1, lastRow, 1).getValues();
    Logger.log(values);
}
```

デプロイして挙動の確認。
<img width="760" alt="スクリーンショット 2019-12-04 16.10.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/8f444945-7f42-6ef8-e739-2acae25ef619.png">
<img width="802" alt="スクリーンショット 2019-12-04 16.11.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/7aba9eef-75e9-5129-c059-129166d01d60.png">

にゃお。

# Slackに投げる
中間ロジックは一旦置いておいて、slackに結果を投げます。
slack側に投げるための設定（incoming webhook）などは省略します。

## SlackAppの利用
GAS ⇆ Slackの連携については、[SlackApp](https://github.com/soundTricker/SlackApp)のライブラリを利用します。

一応プロジェクトにて `リソース > ライブラリ` からインポートできます
<img width="559" alt="スクリーンショット 2019-12-04 16.26.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/bb7af8eb-ffde-8d85-e011-3a0e884cda76.png">

READMEにあるように `M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO` を設定すれば使えるようにはなります。
<img width="775" alt="スクリーンショット 2019-12-04 16.26.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/cafa4b76-beba-1122-3976-2b001fa89140.png">
ただ、デプロイのたびに消えてしまって不便なのでプロジェクト側に記載します。



~~ついでに日本にタイムゾーンを揃えときます~~

```sh
$ cat src/appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "libraries": [
      {
        "userSymbol": "SlackApp",
        "libraryId": "M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO",
        "version": "22"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER"
}
```

[SlackApp](https://github.com/soundTricker/SlackApp)のinterfaceを定義してあげることでローカルでも開発しやすくしています。

```src/model/slack.ts
declare let SlackApp: SlackApp;

interface SlackAppAPI {
    postMessage(channelId: string, message: string): void;
}

interface SlackApp {
    create(token: string): SlackAppAPI;
}

export class Slack {
    private slack: SlackAppAPI;
    private channelID: string;

    constructor(accessToken: string, channelID: string) {
        this.slack = SlackApp.create(accessToken);
        this.channelID = channelID;
    }

    post(message): void {
        this.slack.postMessage(this.channelID, message);
    }
}
```

`this.slack.postMessage(this.channelID, message);`
最終的にはpostMessageで投げつけるだけです。

これが使えるように `index.ts` と `properties.ts`を更新します。

```src/index.ts
import { Properties } from "./model/properties";
import { Slack } from './model/slack';

function main() {
    const p = new Properties();
    this.sheets = SpreadsheetApp.openById(p.getSheetID()).getSheets();
    // とりあえず全部で何行あるかを確認する
    const indexColumns = this.sheets[0].getRange('A:A').getValues();
    const lastRow = indexColumns.filter(String).length;

    // 値が存在する範囲で末端までの全要素を出す
    const values = this.sheets[0].getRange(1, 1, lastRow, 1).getValues();

    const slack = new Slack(p.getAccessToken(), p.getChannelID());
    slack.post('今が旬の課題です');
    for (let val of values) {
        slack.post(val[0]);
    }
}
```

slackの機微情報もプロパティに入れておきます。

```src/model/properties.ts
export class Properties {
    private sheetID: string;
    private accessToken: string;
    private channelID: string;
    constructor() {
        this.sheetID = PropertiesService.getScriptProperties().getProperty('SHEET_ID');
        this.accessToken = PropertiesService.getScriptProperties().getProperty('ACCESS_TOKEN');
        this.channelID = PropertiesService.getScriptProperties().getProperty('CHANNEL_ID');
    }
    getSheetID(): string {
        return this.sheetID;
    }
    getAccessToken(): string {
        return this.accessToken;
    }
    getChannelID(): string {
        return this.channelID;
    }
}
```

実行してみると

![スクリーンショット 2019-12-04 17.17.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287363/b759af7e-cc86-ff94-712e-29bb5967ce6d.png)

ねこのもり！

あとは独自のロジックで課題を重み付けして、適当にフォーマットしたものを定期的にslackに投げるだけ！
ここはシートの中身に密接に関わってしまうので今回書きません。

# まとめ
今回の記事の内容をgithubにあげておきました。
https://github.com/mochisuna/clasp-test

あと、社内で発表したので[Speaker Deck](https://speakerdeck.com/mochisuna/techbash-clasp)に資料上ました

参考になれば！