Slack の１回だけ有効な招待リンクを GAS で作る

GAS も Slack API も使うのは初めてなので、初心者向けの記事です。

HTML, CSS, javascript はある程度書ける、くらいを想定しています。

# デモ

Slack 上でキーワード（ここでは `generate_invite_url`）をつぶやくと、 bot が１回限り有効な招待リンクを発行します。

![demo1.png](https://qiita-image-store.s3.amazonaws.com/0/54240/1c0e3983-76b2-63e3-5ea7-8d23e49e18dd.png)

招待される側の人が招待リンクを踏むと、メールアドレスを入力できるフォームが開きます。

![demo2.png](https://qiita-image-store.s3.amazonaws.com/0/54240/b5b196ff-7c8c-6137-bdd2-b7b9312c13b4.png)

メールアドレスを入力して submit すると招待メールが自動で送信されて、それ以降その招待リンクは使えなくなります。

- - -

Slack 上で特定のキーワードをつぶやくことを招待 URL 発行のトリガにしているので、チームのメンバしか招待 URL を発行できません。

# ソース

ここにあります。

https://github.com/zk-phi/slack_inviter

# 概要

通常 Slack のチームに誰かを招待するには、招待メールを送るしかないので、メールアドレスがわかってないといけません。

これだとメール以外で連絡を取っている友人を誘うとき、いちいち今使ってるメールアドレスを教えてもらって手動でメールを送る必要があって面倒なので、招待リンクを作れるようにして自動化しました。

「特定の Google Form にメールアドレスを書き込むと、フォームのマクロで自動的に招待メールが送られる」のような自動化の記事はいろいろ出てきたのですが、それだとちょっとノーガードすぎるなと思ったので (Form のありかさえわかっていれば誰でも何度でも招待してもらえる)、毎回別の招待コードが発行されて、同じコードは１回しか使えないような仕組みを入れてみました。

わざわざお金をかけてサーバー立てるようなものでもないので、Google Spreadsheet ＋ Google Apps Script (GAS) で実装しました。

GAS も Slack API も初めて使ったのですが、思ったより操作が難しかったのでメモも兼ねて記事にします。

# GAS プロジェクトの始め方

Google Apps Script は、単品のプロジェクトとしても、あるいは他の Google Drive 上のファイル (Spreadsheet など) に紐づいたマクロとしても作れるみたいです。

今回は Spreadsheet で招待コードを管理することにしたので、まず Spreadsheet を作り、その Spreadsheet のマクロとして GAS プロジェクトを作ることにします (見かけ上一つのファイルにまとまってくれるので、 Google Drive で扱うとき便利です)。

## 1. Google Drive で Spreadsheet を作成

![screenshot1.png](https://qiita-image-store.s3.amazonaws.com/0/54240/0825bd8c-2cc0-0498-27dc-8b734ff34d28.png)

適当にファイル名を設定します。

## 2. Spreadsheet にマクロを追加

![screenshot2.png](https://qiita-image-store.s3.amazonaws.com/0/54240/3b1afe09-38d4-d3e4-2749-cda692a7bf8a.png)

メニューの `ツール > スクリプトエディタ` を選ぶと、マクロが作成されて編集画面に入ります。

![screenshot3.png](https://qiita-image-store.s3.amazonaws.com/0/54240/a0ca3b19-053e-ab23-271a-bda33f3f2cfa.png)

プロジェクト名も適当に設定しておきます。

## 3. 外部からマクロを起動するための URL を発行する

![screenshot4.png](https://qiita-image-store.s3.amazonaws.com/0/54240/8a3a2daa-172f-d843-8e97-800c752d2fa5.png)

メニューの `公開 > ウェブアプリケーションとして導入` で、

- `次のユーザーとしてアプリケーションを実行` = `自分`
- `アプリケーションにアクセスできるユーザー` = `全員 (匿名ユーザーを含む)`

として、ウェブアプリケーションとして登録します。

これによって、 Google へのログインなしに、だれでも招待リンクを使う準備ができます。

「現在のウェブアプリケーションの URL」がこのマクロを外部から起動するときの URL になるので、メモしておきます。

- - -

`次のユーザーとしてアプリケーションを実行` を `自分` にすると、アプリのユーザーが Google にログインしていようがなかろうが、あたかも自分 (アプリの作者) がスプレッドシートを読み込んだり、編集しているかのように振舞います。これによって、スプレッドシートを非公開にしたままアプリだけを公開することができます。

逆に、スプレッドシートを一部のユーザーだけに公開しておいて、 `次のユーザーとしてアプリケーションを実行` を `ウェブアプリケーションにアクセスしているユーザー` にすると、そのスプレッドシートにアクセス権のある人だけがアプリを利用することができるようになって一種のアクセス制御になります。

# Slack API を叩く準備をする

今回作るアプリでは、GAS から Slack に招待リンクの URL を投稿したり、招待メールを送ったりします。そのために、 Slack API を叩く準備をしておく必要があります。

## 1. Slack API の Legacy Token を作る

<https://api.slack.com/custom-integrations/legacy-tokens> ここから、目当てのチームの Legacy Token を発行します。これが API を叩くときの合言葉になるので、外に漏らさないように気を付けます。

- - -

Legacy Token は Slack API を叩くためのトークンの古い形式で、叩ける API の範囲などが制限されていない最強なトークンみたいです。「セキュアじゃないので新しい形式に移行してね」、的な警告が書かれているので、重要な Slack チーム (仕事で使ってるやつとか) では発行しないほうがいいかもしれません。

## 2. GAS プロジェクトに Legacy Token を登録

上で作った Legacy Token を GAS プロジェクトから利用できるように、「スクリプトのプロパティ」（環境変数的なやつ）に登録します。

![screenshot5.png](https://qiita-image-store.s3.amazonaws.com/0/54240/a4e93244-50e3-789f-9264-7d85f54fc285.png)

`ファイル > プロジェクトのプロパティ > スクリプトのプロパティ` で `SLACK_ACCESS_TOKEN` という名前で保存します。

- - -

後から簡単に差し替えられるようにするために、またソースをどこかで公開するときにうっかり一緒に公開してしまわないように、秘密の情報は環境変数などソースコードとは別の場所で管理します。

# Slack から通知 (webhook) を受け取る準備をする

今回作るアプリは、Slack 上の `generate_invite_url` という投稿に反応して招待 URL を発行するのでした。

Slack には Outgoing Webhook という、特定のキーワードが投稿されたときにそれを通知してくれる機能があるので、それを使う準備をします。

## 1. Slack チームに Outgoing Webhook を追加

Outgoing Webhook 連携 <https://slack.com/apps/A0F7VRG6Q-outgoing-webhooks> を自分の Slack チームに追加します。

![screenshot6.png](https://qiita-image-store.s3.amazonaws.com/0/54240/f1a06f42-4bbd-4f98-fe23-ad09688eea42.png)

Add Configuration から、 `#general` チャンネルでキーワード `generate_invite_url` が投稿されたときに、今作っているマクロの URL が呼び出されるように設定します。

![screenshot7.png](https://qiita-image-store.s3.amazonaws.com/0/54240/783880b6-aee3-4c0a-d29d-0866ac00d0ef.png)

`Token` は Slack が通知と一緒に送信してくる合言葉で、これを使って本物の Slack からの通知であることを確認できます。後で使うので、これも「スクリプトのプロパティ」に登録しておきましょう。

![screenshot8.png](https://qiita-image-store.s3.amazonaws.com/0/54240/a732f6b5-df19-1c3f-a9de-000a7bbfb5b7.png)

# スクリプトを書く

いよいよ Google Apps Script (GAS) でスクリプトを書いていきます。

GAS は「各種 Google アプリの API を簡単に叩ける javascript」、という感じなので、ほぼほぼ javascript のつもりで書いて問題ないようです。

## 1. 文字列ユーティリティ

後で使うので、与えられた文字列の SHA1 ハッシュを計算する関数

```js
function string_sha1 (str) {
  var raw = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_1, str);
  var str = "";
  for (var i = 1; i < raw.length; i++) {
    var byte = (raw[i] < 0 ? raw[i] + 256 : raw[i]).toString(16);
    if (byte.length == 1) str += "0";
    str += byte;
  }
  return str;
}
```

と、任意の長さのランダムな文字列を生成する関数

```js
function random_string (length) {
  var str = "";
  for (var i = 1; i < length ; i++) {
    str += String.fromCharCode(97 + Math.random()*26);
  }
  return str;
}
```

を作っておきます。

- - -

- `Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_1, <文字列>)`
  - `<文字列>` の SHA1 ハッシュを計算して、整数の配列で返します
  - `SHA_1` の部分を変えると MD5 ハッシュとかも計算できます

- `<整数>.toString(16)`
  - `<整数>` を十六進数の文字列に変換します
  - 一桁 (`0`～`F`) になってしまった場合は、前に `0` をつけて二桁に揃えています

- `String.fromCharCode(<整数>)`
  - `<整数>` を文字コードに持つような文字 (列) を返します
  - `97` は `a` の文字コード、 `Math.random()*26` は `0`以上～`26` 未満のランダムな数なので、`a`～`z`のランダムな文字になります

### 動作チェック

GAS の簡単な動作チェック方法です。

ここでは試しに、上で作った `random_string` の動作チェックをしてみます。

#### 1. `random_string` を呼び出して結果をログに記録する関数 `test` を作る

```js
function test () {
  var res = random_string(10); // 10 桁のランダムな文字列
  Logger.log(res);             // ログに記録
}
```

#### 2. 保存 (💾) して、 `関数を選択` で `test` を選んでから実行 (▶)

![screenshot9.png](https://qiita-image-store.s3.amazonaws.com/0/54240/7801415d-b591-490d-6ae0-bff400300a1e.png)

#### 3. `表示 > ログ` で記録されたログを見る

![screenshot10.png](https://qiita-image-store.s3.amazonaws.com/0/54240/cf924e27-1d86-3fb2-3dcd-633e9e87c295.png)

１０桁の適当な文字列が記録されていれば ok です。

## 2. Slack ユーティリティ

Slack にテキストを投稿する関数、招待メールを送る関数を作っていきます。

Slack になにか投稿するためには、 `https://slack.com/api/chat.postMessage` という URL に、トークン（合言葉）、投稿したいテキスト、投稿先のチャンネル名と投稿時の名前を POST してあげれば ok です。

「スクリプトのプロパティ」に保存してあった合言葉は、 `PropertiesService.getScriptProperties().getProperty(<プロパティ名>)` で読み出すことができます。

```js
function slack_post_message (channel, str) {
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');
  var url   = 'https://slack.com/api/chat.postMessage'
  UrlFetchApp.fetch(url, { method: 'post', payload: { token: token, channel: channel, text: str, username: "招待マン" } });
}
```

Slack への招待メールを送るには、同様に `https://<チーム名>.slack.com/api/users.admin.invite` にメールの送り先のアドレスを POST します。チーム名も合言葉と同様に、ソースコード埋め込みではなく「スクリプトのプロパティ」に登録して、 `PropertiesService` で読み出すようにしてみました。

```js
function slack_send_invitation (email) {
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');
  var team  = PropertiesService.getScriptProperties().getProperty('SLACK_TEAM_NAME');
  var url   = 'https://' + team + '.slack.com/api/users.admin.invite'
  UrlFetchApp.fetch(url, { method: 'post', payload: { token: token, email: email, set_active: true } });
}
```

- - -

- `UrlFetchApp.fetch(<url>, { method: <method>, payload: { <payload> ... } })`
  - `<url>` にメソッド `<method>` でアクセスします。 `<payload>` は API に渡すデータです

### 動作テスト

文字列ユーティリティの時と同様に、 Slack に適当な投稿をするテスト用の関数を作ってみて、ちゃんと動くことを確認します。

```js
function test () {
  slack_post_message("#general", "hogehoge");
}
```

## 3. Spreadsheed を使った招待コードの管理

招待コードをスプレッドシートで管理するために、ランダムな招待コードを生成してスプレッドシートに記録しておく関数と

```js
function create_token () {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); // シートを取得
  var token = random_string();
  sheet.appendRow([ string_sha1(token) ]); // シートに新しい行を挿入
  return token;
}
```

スプレッドシートから与えられた招待コードを探して、見つからなかったら `false`  を、見つかったら `true` を返しつつその招待コードをシートから削除する関数

```js
function validate_and_consume_token (token) {
  var sheet  = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var values = sheet.getDataRange().getValues();
  var hashed = string_sha1(token);
  for (var i = 0; i < values.length; i++) {
    if (values[i][0] == hashed) {
      sheet.deleteRow(i + 1);
      return true;
    }
  }
  return false;
}
```

を用意します。念のためスプレッドシートには生の招待コードではなく、上で作った `string_sha1` 関数でハッシュ化したものを保存することにしました (なにかの拍子にうっかりスプレッドシートを大公開してしまっても大丈夫)。

- - -

- `SpreadsheetApp.getActiveSpreadsheet().getActiveSheet()`
  - 現在のシートを取得します
  - スプレッドシートのマクロとして GAS を書いている場合は、そのシートが返ってくるみたいです
  - (個別のプロジェクトとして GAS を書いている場合は、ちゃんとシートのファイル名を指定したりしないといけないっぽいです)

- `.appendRow([<値>, ...])`
  - シートの一番下に新しい行を追加します

- `.getDataRange()`
  - シートの行数、桁数を取得します

- `.getValues()`
  - 指定した範囲のすべてのデータを二次元配列で取得します

- `.geleteRow(<行番号>)`
  - `<行番号>` 行目の行を削除します
  - 配列とは違い、 `1` から始まるみたいなので要注意です

### 動作チェック

同様に、動作チェックしてみます。

```js
function test () {
  var token = create_token(); // 新しい招待コードを発行

  // コードが間違っているので失敗する
  Logger.log(validate_and_consume_token('hogehoge') ? '認証成功' : '認証失敗');

  // 正しいコードなので成功する
  Logger.log(validate_and_consume_token(token) ? '認証成功' : '認証失敗');

  // 正しいコードでも１回使ったら２回目は失敗する
  Logger.log(validate_and_consume_token(token) ? '認証成功' : '認証失敗');
}
```

## 4. API の設計

GAS では、マクロの URL が `GET` と `POST` でアクセスされたときの処理をそれぞれ設定できます。

`GET`, `POST` 以外のメソッドを扱えないようなので、ここでは

- `GET` ... 招待フォームを描画
- `POST`
  - 渡されたデータに `channel_name` がある場合 ... Slack からの通知なので、招待コードを発行
  - 渡されたデータに `channel_name` がない場合 ... 招待フォームの submit を処理

のように `POST` を２つの用途で使い分けることにします。

## 5. GET リクエストの処理 (招待フォームを描画)

### 1. html (のテンプレート) を書く

`ファイル > 新規作成 > html ファイル` → ファイル名を `index.html` で html を作成。

```html
<!DOCTYPE html>
<html>
  <body>
    <h1>welcome!!</h1>
    <form action="<?= app_url ?>" method="POST">
      <p>
        <label>招待コード</label>
        <input name="token" type="text" value="<?= token ?>">
      </p>
      <p>
        <label>招待メールの送り先アドレス</label>
        <input name="email" type="text" placeholder="hoge@hoge.com">
      </p>
      <input type="submit" value="submit">
    </form>
  </body>
</html>
```

ほとんど普通の html ですが、

- `head` に `title` タグ (や `meta` タグ) を書いても意味がない
- 特殊な構文 `<?= ?>` が使える

点が違います。

`<?= ?>` は html に動的にデータを注入するための特別な構文で、ここでは、招待コード (`token`) やフォームの `POST` 先 URL (`app_url`) をサーバー側で埋め込むために使っています (RoR などで web アプリを書いたことがある人は、要するにテンプレートだと思えばいいです)。

`title`, `meta` タグが効かないのは、 GAS のアプリが `iframe` の中で実行されてしまうためです。ページのタイトルはサーバー側で設定します。

### 2. `doGet` 関数で、 1. で作った html を返す

マクロの URL に `GET` リクエストがやってくると、自動的に `doGet` 関数が呼び出され、その戻り値がレスポンスになります。

今回作るアプリでは、上で書いた招待フォームの html を返します。

```js
function doGet (e) {
  var template = HtmlService.createTemplateFromFile("index");
  template.token = e.parameter.token || "";
  template.app_url = PropertiesService.getScriptProperties().getProperty('APP_URL');

  var html = template.evaluate();
  html.setTitle("Slack 招待マン");
  return html;
}
```

アプリの URL (`APP_URL`) も同様にスクリプトのプロパティにしておきました。

- - -

- `HtmlService.createTemplateFromFile(<ファイル名>)`
  - 指定された html ファイルを読み込んでテンプレートのオブジェクトを作ります

- `template.<hogehoge> = fugafuga`
  - テンプレートの穴 (`<?= ?>`) に注入する値を設定します

- `e.parameter.<キー>`
  - リクエストの引数を取得します
  - ここでは、 GET 引数で指定された招待コード (`token`) を自動的に入力します

- `template.evaluate()`
  - テンプレートの穴を実際に埋めて、 html を生成します

- `html.setTitle(<タイトル>)`
  - ページのタイトルを設定します (`title` タグが使えない代わりにここで設定する)

### 動作テスト

実際に招待フォームが表示されることを確認してみます。

![screenshot4.png](https://qiita-image-store.s3.amazonaws.com/0/54240/47eb0762-01ab-7da4-e0e5-0f0c42632086.png)

`公開 > ウェブアプリケーションとして導入` から `プロジェクトバージョン` を `新規作成` に設定してアプリを `更新` し、マクロの URL を開くと、メールアドレスの入力フォームが表示されます。

URL の後ろに `?token=hogehoge` と追加してやると、招待コードが自動で入力されることも確認できます。

![demo2.png](https://qiita-image-store.s3.amazonaws.com/0/54240/2182c5a8-a9b7-0968-abd7-7be935ff5403.png)

`POST` 時の処理をまだ書いていないので、このままフォームを送信してもエラーになります。

- - -

単にマクロを保存しただけではアプリには反映されず、最新版のコードを世に出すにはこの `更新` 作業が必ず必要になります。それまでは旧バージョンのアプリが動き続けています (Web アプリでいう「デプロイ」みたいな感じです)。

逆に言えば、 `更新` さえしなければ雑にコードをいじってアプリを壊してしまっても、壊す前のアプリがちゃんと動き続けていてくれるので大丈夫です。

一応、`ウェブアプリケーションとして導入` のダイアログから `最新のコードをテスト` をクリックするとデプロイせずに最新のコードの動作確認をすることもできるのですが、微妙に URL が変わってややこしいのでここではいったん使いません。

## 6. POST の処理

### 1. `doPost` を書く

`POST` は、 Slack からの通知 (`channel_name` がある場合) と、メールアドレス入力フォームの submit (`channel_name` がない場合) で処理を分けるのでした。

まずはその分岐をするコードを書いておきます。

```js
function doPost(e) {
  if (e.parameter.channel_name) return doGenerateToken(e);
  else return doSendInvitation(e);
}
```

### 2. Slack からの通知を処理する

Slack からの通知 (`channel_name` がある) の場合、新しい招待コードを発行して、それを Slack に投稿するのでした。

Slack は通知 (Outgoing Webhook) 時に合言葉 (token) を送ってくる決まりになっていたので、合言葉が合っているかどうかを確認して、招待コードを生成、 Slack に投稿します。

今までに書いてきたユーティリティを適当に組み合わせれば実装できます。

```js
function doGenerateToken (e) {
  var webhook_token = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_TOKEN');
  if (e.parameter.token != webhook_token) return 0;

  var invite_token  = create_token();
  var app_url = PropertiesService.getScriptProperties().getProperty('APP_URL');
  slack_post_message(
    "#" + e.parameter.channel_name,
    "ほれ☞ " + app_url + "?token=" + invite_token
  );
}
```

### 3. 招待フォームの submit を処理する

招待フォームからメールアドレスと招待コードが submit された場合、招待コードが本物であることを確認して、そのメールアドレス宛に招待メールを送るように Slack にお願いすれば ok です。

せっかくなので招待コードが使われたことを Slack に投稿するようにしました。ちょっとワクワクです。

```js
function doSendInvitation (e) {
    var validated = validate_and_consume_token(e.parameter.token);
    if (!validated) return HtmlService.createTemplateFromFile("error").evaluate();
    slack_send_invitation(e.parameter.email);
    slack_post_message("#general", "招待コード " + e.parameter.token + " が使われました🎉");
    return HtmlService.createTemplateFromFile("done").evaluate();
}
```

招待が成功した場合は `done.html` を、失敗した場合は `error.html` を描画するようにしました。

それぞれこんな感じです：

```html
<!-- done.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Slack に招待するマン</title>
  </head>
  <body>
    <h1>招待メールを送りました、届いてなかったら😤してください</h1>
  </body>
</html>
```

```html
<!-- error.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Slack に招待するマン</title>
  </head>
  <body>
    <h1>招待コードの確認に失敗しました😂発行しなおしてもらってください</h1>
  </body>
</html>
```

### 動作テスト

いよいよアプリが完成したので最後の動作チェックをします。

`公開 > ウェブアプリケーションとして導入` から最新のアプリをデプロイして、 Slack 上でつぶやいてみると、招待 URL が返ってきます。

![demo1.png](https://qiita-image-store.s3.amazonaws.com/0/54240/7aa3f562-0727-e0d6-8de9-1c541cd24b2e.png)

招待 URL を開いて、メールアドレスを入力すると、そのメールアドレス宛に Slack から招待メールが届きます。