Cloud Storageにアップロードされたファイルをslackのボットユーザーで通知するデモ関数

# 概要

GCPのCloud Storageにファイルがアップロードされたことをslackのボットユーザーで通知するCloud Functionsのデモ関数です。

**環境**

* Windows 10 Professional
* Node.js 6.11.5
* Google Cloud SDK 202.0.0

**参考**

* [slack API - Bot Users] (https://api.slack.com/bot-users)
* [slack API - An introduction to messages] (https://api.slack.com/docs/messages)

# 事前準備

## slack ボットユーザーの作成

slackのApp管理画面のカスタムインテグレーションよりBotsを設定します。
設定するとAPI TOKENが発行されるので控えておきます。

![s3.png](https://qiita-image-store.s3.amazonaws.com/0/22772/4bf27413-a035-1353-8549-8e0b7712b1af.png)

### ボットユーザーを招待

ワークスペースにボットユーザーを招待します。

```text
/invite @rubybot
```

## Google Cloud SDKのインストール

関数のデプロイにGoogle Cloud SDKのgcloudというコマンドラインツールを使用するのでインストールします。
インストールおよび初期設定については省略します。

## 依存モジュールのインストール

ストレージのオブジェクトを操作するために[@google-cloud/storage] (https://www.npmjs.com/package/@google-cloud/storage)を利用します。

```text
> npm install @google-cloud/storage --save
```

Google Cloud RuntimeConfig APIを利用するために[@google-cloud/rcloadenv] (https://www.npmjs.com/package/@google-cloud/rcloadenv)を利用します。
このAPIを使うことでボットユーザーのAPI TOKENをプログラム外部で管理できるようになります。

```text
> npm install @google-cloud/rcloadenv --save
```

slack APIのchat.postMessageを呼び出すために[Request-Promise] (https://www.npmjs.com/package/request-promise)を利用します。

```text
> npm install request request-promise --save
```

## バケットの作成

動作確認用にCloud Storageに"my_bucket_dpqyvp50"というバケットを作成します。

![b1.png](https://qiita-image-store.s3.amazonaws.com/0/22772/80ece593-bfd4-9c64-6ce2-0ba9bc127bb8.png)

## API TOKENの登録

メッセージの送信にボットユーザーのAPI TOKENを使いますが、下記に引用した通り取扱いには注意が必要です。

> ボットユーザートークンをアプリケーションと共有する際は十分注意してください。パブリックコードレポジトリでは、ボットユーザートークンを公開しないでください。[トークンの安全面に関するヒントを確認する。] (https://api.slack.com/docs/oauth-safety)

ソースコード中にAPI TOKENを書き込まなくても済むように、runtime-configという機能を利用します。

変数を管理するリソースを作成します。この例では"dev-config"という名前にしています。
”runtimeconfig.googleapis.com”APIが必要なので有効にします。

```text
> gcloud beta runtime-config configs create dev-config
API [runtimeconfig.googleapis.com] not enabled on project
[************]. Would you like to enable and retry?  (y/N)?  y

Enabling service runtimeconfig.googleapis.com on project ************...
```

リソースにAPI TOKENを登録します。

```text
> gcloud beta runtime-config configs variables set slack-bot-api-token {BOT_API_TOKEN} --config-name dev-config --is-text
```

登録されている変数の一覧を確認するには

```text
> gcloud beta runtime-config configs variables list --config-name dev-config
```

変数の値を確認するには

```text
> gcloud beta runtime-config configs variables get-value slack-bot-api-token --config-name dev-config
```

変数を削除するには

```text
> gcloud beta runtime-config configs variables unset slack-bot-api-token --config-name dev-config
```

# トリガー関数の実装

```json:config.json
{
  "PROJECT_ID": "************",
  "CONFIG_NAME": "dev-config",
  "SLACK_BOT_API_TOKEN": "slack-bot-api-token",
  "SLACK_API": {
    "CHAT_POST_MESSAGE": "https://slack.com/api/chat.postMessage"
  }
}
```

Cloud Storageにファイルがアップロードされたイベントで実行される関数の本体です。
関数名を”noticeUploadfile”としました。

```js:index.js
'use strict';
const config = require('./config.json');
const rp = require('request-promise');
const storage = require('@google-cloud/storage')();
const rcloadenv = require('@google-cloud/rcloadenv');

const STORAGE_BASE_URL = 'https://storage.googleapis.com';
const ALLOWED_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/gif'];

exports.noticeUploadfile = (event) => {
  const data = event.data;
  const context = event.context;

  if (context.eventType !== 'google.storage.object.finalize') {
    return Promise.resolve();
  }
  if (data.resourceState === 'not_exists') {
    return Promise.reject(new Error('This is a deletion event.'));
  } else if (!data.name) {
    return Promise.reject(new Error('This is a deploy event.'));
  }

  return storage.bucket(data.bucket).file(data.name)
    // https://cloud.google.com/nodejs/docs/reference/storage/1.6.x/File#makePublic
    // makePublic(callback) returns Promise containing MakeFilePublicResponse
    .makePublic()
    .then(res => {
      return rcloadenv.getVariables(config.CONFIG_NAME);
    })
    .then(variables => {
      const botApiToken = getApiToken(variables);
      const attachments = [createAttachment(data)];
      console.log(attachments);
      return sendMessage(botApiToken, attachments);
    })
    .catch(err => {
      console.error(err);
      return new Error(err);
    });
};

function getApiToken(variables) {
  const varName = `projects/${config.PROJECT_ID}/configs/${config.CONFIG_NAME}/variables/${config.SLACK_BOT_API_TOKEN}`;
  for (let variable of variables) {
    if (variable.name === varName && variable.text) {
      return variable.text;
    }
  }
  throw new Error('missing slack bot api token');
}

function createAttachment(data) {
  const bucketName = data.bucket;
  const fileName = data.name;
  const contentType = data.contentType;
  const size = Math.floor(data.size / 1024);
  const storageClass = data.storageClass;
  const attachment = {
    fallback: `"${fileName}"が${bucketName}にアップロードされました - URL: ${STORAGE_BASE_URL}/${bucketName}/${fileName}`,
    title: `${bucketName}/${fileName}`,
    title_link: `${STORAGE_BASE_URL}/${bucketName}/${fileName}`,
    pretext: `"${fileName}"が${bucketName}にアップロードされました`,
    text: `*Content-Type*: ${contentType}\n *File-Size*: ${size} KB\n *Storage-Class*: ${storageClass}`,
    color: '#F35A00'
  };
  // The thumbnail's longest dimension will be scaled down to 75px while maintaining the aspect ratio of the image.
  // The filesize of the image must also be less than 500 KB.
  if (ALLOWED_CONTENT_TYPES.includes(contentType) && size < 500) {
    attachment['thumb_url'] = `${STORAGE_BASE_URL}/${bucketName}/${fileName}`;
  }
  return attachment;
}

function sendMessage(botApiToken, attachments) {
  const headers = {
    'Content-type': 'application/json'
  };
  const params = {
    token: botApiToken,
    channel: '#general',
    attachments: JSON.stringify(attachments),
    as_user: true
  };
  const options = {
    method: 'POST',
    uri: config.SLACK_API.CHAT_POST_MESSAGE,
    headers: headers,
    qs: params,
    json: true
  }
  return rp(options);
}
```

**注意点**

slackのメッセージにアップロードした画像ファイルへのリンクを記載するために、makePublicメソッドを実行して公開リンクを有効にしています。

```js
storage.bucket(data.bucket).file(data.name).makePublic()
```

管理画面で確認すると"公開リンク"にチェックが付いていることがわかります。

![b3.png](https://qiita-image-store.s3.amazonaws.com/0/22772/922ee716-be6b-8560-39ba-ba3f5eaba540.png)

公開リンクが有効なファイルは誰からでもアクセスが可能になるため注意が必要です。

## デプロイ

```text
> gcloud beta functions deploy noticeUploadfile --trigger-resource my_bucket_dpqyvp50 --trigger-event google.storage.object.finalize --memory=128MB
```

## 動作確認

Cloud Storageの管理画面から画像ファイルをアップロードしてslackにメッセージがポストされることを確認します。

この例では、foster.jpgという50KBの画像ファイルをバケットにアップロードして

![b2.png](https://qiita-image-store.s3.amazonaws.com/0/22772/24b3181e-4a6e-573e-734e-1be46bfba7a4.png)

slackにメッセージが送られてくることを確認します。
また、リンクをクリックすると画像ファイルがブラウザで表示されます。

![s.png](https://qiita-image-store.s3.amazonaws.com/0/22772/91aa2788-c0a8-78cd-900e-48eae942bc00.png)

このメッセージのフォーマットは下記のattachmentで決まります。

```js:attachment
const attachment = {
  fallback: `"${fileName}"が${bucketName}にアップロードされました - URL: ${storageBaseUrl}/${bucketName}/${fileName}`,
  title: `${bucketName}/${fileName}`,
  title_link: `${storageBaseUrl}/${bucketName}/${fileName}`,
  pretext: `"${fileName}"が${bucketName}にアップロードされました`,
  text: `*Content-Type*: ${contentType}\n *File-Size*: ${size} KB\n *Storage-Class*: ${storageClass}`,
  color: '#F35A00',
  thumb_url: `${storageBaseUrl}/${bucketName}/${fileName}`
};
```

ボットのユーザー名やアイコンを変更するには、`as_user`をfalseにして`username`と`icon_emoji`を任意の値を設定します。

```js:params
const params = {
  token: config.SLACK_TOKEN,
  channel: '#general',
  attachments: JSON.stringify(attachments),
  as_user: false,
  username: 'osenbei',
  icon_emoji: ':rice_cracker:'
};
```

![s2.png](https://qiita-image-store.s3.amazonaws.com/0/22772/2c5b9e7a-f633-bb0c-a184-9317dc9ede8b.png)

# Cloud Functions for Firebaseについて

当初はCloud Functions for Firebaseで実装を進めていたのですが、FirebaseのSparkプラン(無料プラン)では、Cloud FunctionsのネットワークはGoogle専用(外部ネットワークとの通信ができない)という制限(2018/05現在)のため断念しました。

![firebase2.png](https://qiita-image-store.s3.amazonaws.com/0/22772/e66e9537-b429-c482-342d-0f3ecb50bf27.png)