Slackに共有されたファイルをサーバーレスでGoogle Driveにアップロードする仕組みを作るまで

# はじめに
社内の要望を受け、Slackに共有されたファイルをGoogleドライブに簡単にアップロードできるSlackアプリを完全サーバーレスで開発した話です。
「要件を整頓する」「アーキテクチャを考える」「Lambdaを利用して開発する」など…
全てが初めてでしたので、どうか温かい目で読んでいただければと思います。

#作ったもの
### **格納くん**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/65b4ac83-09ed-e4d2-3a0b-ac2351ef5e2a.png)
（弊社の画伯にアイコンを作成いただきました！）
格納くんは社内の資料管理用に作られたSlackアプリによるツールです。

## 使い方
- ファイルが添付されたメッセージのメニューから起動します。
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/0b27c75e-8bcf-7fd5-138f-df64876b36a2.png)

- 開いたフォームでメッセージに添付されたファイルからGoogleドライブにアップロードしたいファイルを選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/ffd115f8-1910-12c5-c34c-b6adce67384a.png)
- アップロードしたいファイルのファイル名や格納先のフォルダを選択して、Googleドライブへアップロードする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/1071d03d-7390-4fb0-0fa3-0e0f29807073.png)
- Slackの元メッセージのスレッドに格納されたファイルの情報がメッセージで送られてくる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/f120591a-bd26-cbf3-2339-2a62a39464c1.png)

- Googleドライブにアップロードされる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/1be3f1c3-cc12-3e43-3813-842781794161.png)
- スプレッドシートにログが出力される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/18ce5881-a3da-903d-bcdd-36f1316c9e63.png)
## 便利ポイント
- Googleドライブへのファイル格納がSlackでの操作のみでできる。
- Slackだけの操作でGoogleドライブへのファイル格納が可能。
→時間がないときはとりあえずファイルだけSlackにあげておくだけでもOK。
- ファイルアップロードした人と資料を格納する人が別の人でもOK。

などなど


# 開発のキッカケ

## とある相談
以前、Slackのワークフローで[こんな~~くだらない~~もの(長濱ねるからメッセージが届く申請依頼ワークフロー)](https://qiita.com/kasshiiii/items/a8bdb6bdbfa55be12b8f)を作っていたからでしょうか。社内で相談を受けました。

**Slackに共有されたファイルをGoogle Driveに簡単にアップロードする仕組みがほしい**

1. Slackでワークフローのようなフォームに必要事項(アップロード先のフォルダ、ファイル名など)を入力し、送信。
2. フォームの入力内容に応じて、ファイルをGoogle Driveにアップロードする。
3. 同時にGoogle Driveにアップロードされたファイルをスプレッドシートなどに一覧化する。

こんなものが欲しいとのこと。

なるほど…これは色々と考えることがありそう…

気合を入れ、大事に検討していきました。

# 使用する技術・機構の検討
まず、ユーザーの操作ごとにポイントとなる検討事項を整頓しました。
>1．Slackでワークフローのようなフォームに必要事項(アップロード先のフォルダ、ファイル名など)を入力し、送信。

- トリガーをどうするか
- フォーム表示をどうするか

>2．フォームの入力内容に応じて、ファイルをGoogle Driveにアップロードする。

- フォームの入力内容をどう受け取るか
- SlackのファイルをダウンロードしてGoogle Driveにアップロードするのにどの技術や機構を使うか

以上のような点に留意して、使用する技術・機構の検討をしていきました。

## トリガー
Event APIを利用して、Slackにファイルがアップロードされたのをトリガーにフォームを表示しようと考えました。
しかし、以下のような懸念が出てきました。

- ファイルアップロードされるたびにフォームが表示されるのは、ユーザーからするとさすがに鬱陶しい
- ファイルアップロードのイベントを感知して動くEvent APIはチャンネルごとに設定する必要があり、チャンネルが増えるごとに設定することになるため、先々の運用がしんどそう

### メッセージアクション
どうしよう…と思いつつ調べていたら、この記事を見つけました。
[Slack メッセージ・アクション API を使ってディスカバラブルなアプリを作ろう](https://qiita.com/tomomi_slack/items/21fedcc6ce07aa44a670)
**メッセージアクション**といって、メッセージのメニューをクリックすることでそのアクションをトリガーにしたEvent APIのようです。

**Slackのメッセージ右側から開くことができるメニュー**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/0a5c6d42-856f-2347-c350-daaa118314f2.png)



- ファイルが添付されたメッセージごとにフォームを表示させることができる
- メッセージアクションはチャンネルに対しての設定ではなく、アプリに対しての設定でワークスペース全体で使える

ということで、今回の要件をぴったり満たすのでトリガーはメッセージアクションを採用しました！

## Slack API views.openメソッドによるフォーム(モーダル)表示
メッセージアクションをトリガーに表示するSlackでフォーム(モーダル)を表示させる技術として、Slack APIの[**views.open**](https://api.slack.com/methods/views.open)メソッドを利用するとフォームが表示できるようなので、こちらを使うことにしました。
(公式ドキュメント上ではフォームのことをモーダルという表現なので、以降モーダルと表現します)

### Block KitでイケてるUIが作れる！
views.* メソッドで表示することができるモーダルですが、これまでSlack APIで用意されていたフォーム表示のAPIに[dialog.open](https://api.slack.com/methods/dialog.open)というメソッドがありました。
しかし、2019年から2020年にかけてのSlack APIの仕様変更に伴って、dialog.* メソッドに代わって出てきたメソッドがviews.* メソッドとなります。

views.* メソッドは、従来のdialog.* メソッドに比べて入力や表示のバリエーションが増えています。
今回はモーダルで使用しましたが、メッセージやアプリのホームタブの表示などにも活用できます。
詳しくは、[Slack APIの公式ドキュメント](https://api.slack.com/block-kit)や参考にさせていただいた記事を是非ご覧ください。

今回参考にさせていただいた記事はこちら
[Block KitでリッチなSlackアプリを作る -乗換経路案内での実例-](https://qiita.com/navitime_tech/items/85de33072486e7d323a5)
[Block Kit Builder を使ってインタラクティブな Slack アプリをプロトタイピングしよう](https://qiita.com/seratch/items/628751be65de9eb23a80)

## Slackとのやり取りとGoogle Driveにアップロードする機構
弊社ではこれまで社内でツールを作るとなるとEC2でたてたサーバーでツールを開発しており、今回もその流れにのって、開発しようと思ったのですが、
サーバーレスに興味があり自身の成長も期待できると思い、
今回は、**サーバーレスでの開発に挑戦しました！**
※個人的に強めの思いがあり、今回のツールで一番こだわったポイントです。

# アーキテクチャ

![Raven Architecture-ページ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/b643e047-4a3b-e479-cf64-098075ec90ed.png)

検討を重ね、最終的なアーキテクチャは上のようになりました。

### SQS
SQSで処理制御しているのが、今回のアーキテクチャの大きなポイントです。
SQSを通して処理を制御することで、Slack上で複数のユーザーが同時にツールを動かしたときに競合することないようにしました。
そのため、LambdaがSlackとのやり取り用、Google Driveへのアップロード用の2つに分けることにしました。

### API Gateway/Lambda(Slackとのやり取り) 
後述しますが、今回はSlackとインタラクティブなやり取りをすることになり、Slackからのリクエストを受けるエンドポイントが必要となっため、API Gatewayを使用しました。

こちらのLambdaでは、以下のような処理を行っています。

- メッセージアクションを受けて1つ目のモーダル表示
- 1つ目のモーダルでのアクション(block_actions)を受けて、2つ目のモーダルを表示
- モダールの回答を受けて、
- その他、ダイレクトメッセージやファイル添付のないメッセージからのメッセージアクション時のモーダル表示(使用不可の旨)

### Lambda(Google Driveへのアップロード)
SQSからのメッセージを受けて、Slackから対象のファイルをダウンロードして、Google Driveへアップロードする部分です。


# UXとSlack APIによるモーダル表示
アーキテクチャ以上に躓いたのが、Slack APIでのモーダル表示とUXの部分です。
## 複数のファイルが添付されたメッセージはどうする？
アーキテクチャを考えていた段階では1つのファイルが添付されたメッセージしか想定してなかったのですが、よくよく考えてみればSlackに複数ファイル添付することありますよね…(気づくの遅いですね)

そこで、モーダル内でファイル選択できるようすることにしました。
![image (17).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/f7e50a63-c45f-042e-2040-45ff15c86f66.png)

## モーダルが更新されない…
ユーザーの入力工数を省く観点から実装したかったのが、
ファイル選択されるとデフォルトで元のファイル名がフォームの入力欄に入力されるという仕組みです。

views.openメソッドで開いた先に載せた画像のモーダルを、[views.update](https://api.slack.com/methods/views.update)メソッドでファイル名の入力欄のだけを変えたモーダルに更新しようとしたところ、モーダルにまったく変化がありませんでした。

その後、views.updateメソッドであれこれと試したいたところ、更新される場合とされない場合がありました。


- **views.updateメソッドで更新されない場合(テキストボックス内のデフォルト文字列追加)**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/fd8141aa-7537-f99e-4fc6-4daa49395eb5.png)

- **views.updateメソッドで更新される場合(モーダルの項目追加)**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/96f75d91-cddc-33ec-6905-48239989b6e6.png)


views.updateメソッドでモーダルの更新が適用されるのは、**「表示されるモーダルのBlockの構成が変わったときのみ」**ということがわかりました。。(さあ困った…)

## UXを考え直す
views.updateメソッドに代わって、views.openで開いたモーダルの上にもう一つ別のモーダルを載せる[views.push](https://api.slack.com/methods/views.push)メソッドの使用を検討し、ファイル選択のモーダルをだけを先に行ってその後にファイル名や格納するフォルダの選択のモーダルを表示させることにしました。

![プレゼンテーション1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322392/1948bc33-d169-5dfa-d695-28ab019c44e6.png)

こうすることで、ファイル選択のUXは残しつつ、デフォルトでファイル名が入っている状態のモーダルを表示することができます。

#実装
実装したコードの一部です。
Python初心者の拙いコードですので、ある程度目を瞑っていただけると大変ありがたいです。。
## ファイル選択を受けて、ファイル情報を入力するモーダルを表示する

```py
slack_client = WebClient(token=os.environ['SLACK_TOKEN']) 
if type == 'block_actions': #views.push
    # ファイル選択が行われた場合
    if body['actions'][0]['action_id'] == 'select_file' and body['actions'][0]['block_id'] == 'upload_file':
        modal_template_open = open('modal_template.json', 'r') #modal_template.jsonというモーダルのテンプレをLambda内にもっておく
        modal_template = json.load(modal_template_open)
        
        # 受け取ったファイルの情報から元ファイルのファイル名
        defalt_file_name = os.path.basename(body['actions'][0]['selected_option']['text']['text']).split('.', 1)[0]
        
        # 表示するモダールの構築
        view = {}
        view['type'] = body['view']['type']
        view['callback_id'] = body['view']['callback_id']
        view['title'] = body['view']['title']
        view['submit'] = modal_template['submit']
        view['close'] = modal_template['reselectViewClose']
        
        # ...省略(モーダルの中身の構築)
        
        data = json.dumps(view, ensure_ascii=False)
        
        response = slack_client.views_push(
            trigger_id=body['trigger_id'],
            view=data
            )
        
```
- 選択されたファイルの元のファイル名を取得し、pushで表示されるモーダルにデフォルトのファイル名として表示するようにしています。

## モーダルの入力内容を受けて、SQSに諸情報を送る

```py
if type == 'view_submission':
    submission_dict = {}
    submission_dict['state'] = body['view']['state']['values']
    submission_dict['private_metadata'] = body['view']['private_metadata']
    private_metadata_dict = json.loads(submission_dict['private_metadata'])
    private_metadata_dict['user_name']=body['user']['name']
    submission_dict['private_metadata'] = json.dumps(private_metadata_dict)
    
    # private_metadataを加えた情報をSQSへ
    submission = json.dumps(submission_dict)
    
    sqs = boto3.client('sqs')
    
    #SQSへメッセージ送信
    response = sqs.send_message(QueueUrl=os.environ['QUEUE_URL'], MessageBody=submission)
    
    #response_action clearで開いたmodalを全て閉じる
    return {
        'isBase64Encoded': False,
        'statusCode': 200,
        'headers': {},
        'body': '{"response_action": "clear"}'
    }
```
- `private_metadata`はSlackAPIで用意されているフィールドで、後続の処理で必要になる情報(今回の場合はユーザー情報やファイルの情報など)を裏側でずっと持ちつつ渡していくのに使えます。
- views.openとviews.pushで2つ開いたモーダルを閉じるのに、Slackアプリに対して200番ので`{"response_action": "clear"}`を返す必要があります。 [参考ドキュメント](https://api.slack.com/surfaces/modals/using#closing_views)


## Googleドライブへのファイルアップロード
SQSからメッセージを受けるLambdaの一部になります。

### SlackからLambdaの一時領域(`/tmp`)にファイルをダウンロード
Lambdaには`/tmp`ディレクトリが用意されています。今回はダウンロードしてアップロードするまでのファイルの一時保存で`/tmp`ディレクトリを使用しています。
[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)によると、`/tmp`ディレクトリの容量は512 MBとあるので容量の大きい動画ファイルだと対応しきれないものが出てくるかもしれないですが、大体のファイルは対応できるであろうということで…

```python
#download
name,ext = ['ファイル名','拡張子']
url_private_download = 'Slackにアップロードされたファイルのダウンロードリンク'
save_file_path = "/tmp/" + str(math.floor(time.time())) + name + ext
headers = {'Authorization': 'Bearer ' + os.environ['SLACK_OATH_TOKEN'] }

response = requests.get(
    url_private_download,
    headers=headers
)
```
### Googleドライブへアップロード
今回は、GCPのサービスアカウントを利用してGoogleドライブへファイルをアップロードしています。
また、今回は仕様としてファイルに「タグ」をつける意味合いでGoogleドライブに格納するファイルの説明にタグとなる文字列をいれていくこととしています。(`tag_str`がその文字列)

```py
from googleapiclient.discovery import build 
from googleapiclient.http import MediaFileUpload 
from oauth2client.service_account import ServiceAccountCredentials
def uploadFileToGoogleDrive(fileName,ext,localFilePath,drive_folder_id,file_tags):
    try:
        mimeType = mimetypes.guess_type(localFilePath)[0]
        
        tag_str = ""
        for tag in file_tags:
            tag_str += " "+tag
        
        service = getGoogleDriveService()
        file_metadata = {"name": fileName, "mimeType": mimeType, "parents": [drive_folder_id],"description":tag_str } 
        media = MediaFileUpload(localFilePath, mimetype=mimeType , resumable=True) 
        file = service.files().create(body=file_metadata, media_body=media, fields='id').execute()
        
    except Exception as e:
        logger.exception(e)

def getGoogleDriveService():
    scope = ['https://www.googleapis.com/auth/drive.file'] 
    keyFile = 'utilserviceaccounts-×××××××××.json' # GCPのサービスアカウントのキーとなるJSONをLambda内に置く
    credentials = ServiceAccountCredentials.from_json_keyfile_name(keyFile, scopes=scope)

    return build("drive", "v3", credentials=credentials, cache_discovery=False)
```
# 環境整備とバージョン管理(今後の課題)
格納くんは既に社内で運用をはじめており依頼を受け改修しているのですが、現状だと本番(実際に運用している環境)しか作っておらず、改修中は利用を控えていただくように都度周知して開発した私がタイムアタックのように改修作業をしてリリースしています。そのため、現在は本番と別に開発用の環境を作りつつバージョン管理(特にLambda)を行いスムーズにリリースができるように整備を進めています。整備についても、記事を書いてみたいと思います。

# おわりに
完全サーバーレスでここまでできるとは、正直思ってませんでした。。
サーバーレスが世の中的に流行っているのも頷けるなと思いました。
今回の経験を活かし、もっと便利なSlackアプリをサーバーレスで開発していきたいです！

# 使用技術
- AWS: API Gateway、Lambda、SQS
- GCP: Google Drive API、Google Sheets API
- Slack API
- 言語: Python 3.7(Lambdaにて)