☁️

Slackに投稿したファイルを自動的にGoogle Driveに保存する【Google Workspace対応】

2024/09/25に公開

前提条件

Google Cloud (Node.js 20)を使用します。
Google WorkspaceアカウントのDriveに接続します。
(非Google Workspaceアカウントでも検証済み)

つくりたいもの

Slackのチャンネルに投稿されたファイル(画像・pdfなど)が自動的にGoogle Driveのフォルダに保存されるまでを目指します。

Slack Appの作成

Slackのチャンネルにファイルが投稿されたタイミングを検知するために、Slack Appを作成してワークスペースにインストールします。
このSlack Appがチャンネルを監視してくれることで、メッセージの投稿や削除が行われた際にイベントが発生したことを通知してくれます。

① 下記のURLからSlackにログインして「Create New App」を選択します。
https://api.slack.com/apps

② 「From scratch」を選択します

③ Slack Appを導入したいワークスペースを選択します

そのまま進み「Create」を押すと設定画面に遷移します。
ひとまずSlack Appの作成は完了です。

Slack Appの導入

この段階ではまだワークスペースにSlack Appはいません。
メッセージの投稿があった際にイベントを受け取れるように設定し、ワークスペースにSlack Appをインストールする必要があります。

① Slack Appに権限を付与します。
左のタブからOAuth & Permissionsを選択して「Scopes > Bot Token Scopes」から必要な権限を付与していきます。

channels:history チャンネルに投稿されたメッセージを読むための権限です
files:read メッセージに添付されたファイルを読むための権限です

② ワークスペースにSlack Appをインストールします。
左のタブからInstall Appを選択してインストールします。

Slackのワークスペースを開いて、左下のAppの欄に作成したDemo Appが追加されていればインストール完了です!

③ チャンネルにAppを追加する。
チャンネルを右クリックするなどしてチャンネルの詳細を開きます。
「インテグレーション」のタブから「アプリを追加する」を選択すると、先ほど追加したDemo Appがあると思いますので追加します。


このような通知が来ていればSlack Appの導入は完了です。
続いてGoogle Cloudを利用して受け取ったイベントを処理するための関数を作成していきます。

Google Cloudで関数を作成する

Google Cloudに登録していない場合は登録して、新しいプロジェクトを作成します。

続いてCloud Runから関数を作成します。
任意のサービス名を入力、リージョンを選択し、ランタイムはNode.js 20を選択します。
Slackからイベントを受け取れるようにするためにはエンドポイントを公開しておく必要があるため「未認証の呼び出しを許可」にしておきます。

関数を作成したらhttps://xxxxxxxx-00000000000.asia-northeast1.run.appのようなURLが表示されていると思いますが、このURLをコピーしておきます。

先ほど作成したDemo Appが、メッセージ投稿を検知するとこのURLに対してイベントを送信するようにしたいため、一度Slack Appの管理画面に戻って設定します。

Event SubscriptionsからEnable EventsをOnにしてRequest URLにコピーしたURLを貼り付けます。
そうすると認証エラーが発生すると思います。

Slack Event APIでは認証されていないサーバーが不正にイベントを受け取らないようにチャレンジ認証を行う必要があります。正しく認証できるように関数を書き換えましょう。

index.js
const functions = require('@google-cloud/functions-framework');
  
functions.http('main', (req, res) => {
    // チャレンジ認証
    if (req.body.type === 'url_verification') {
        return res.status(200).send(req.body.challenge);
    }
});

関数のエントリ ポイントはhelloHttpになっていると思うので、mainに置き換えます。
上記を記述したら、一度保存して再デプロイします。

デプロイが完了したのを確認し、Event Subscriptionsから再度認証を行い、VerifiedとなっていればOKです。

また監視対象のイベントを指定する必要があり今回はチャンネルでメッセージが投稿されたらイベントを受け取りたいので、Subscribe to bot eventsからmessage.channelsを選択します。

最後にSave Changesを押して忘れずに保存しましょう。

ファイルが共有された時にイベントを受け取る

Slack Event APIでは様々なイベントを受け取ることができますが、今回はファイル共有時のみ処理を行いたいので下記を追記します。

index.js
const functions = require('@google-cloud/functions-framework');

functions.http('main', (req, res) => {
    // チャレンジ認証
    if (req.body.type === 'url_verification') {
        return res.status(200).send(req.body.challenge);
    }

+   const event = req.body.event
+   if(event.subtype == 'file_share'){
+       handleShareEvent(event) // ファイル共有イベントを処理
+   }

});

req.body.eventでイベントを受け取ることができ、イベントのsubtypeを見ることでイベントの内容を判定できます。
今回はファイル共有時なのでfile_shareにしています。

続いてhandleShareEvent関数の中身をつくっていきましょう。

index.js
const handleShareEvent = (event) => {
    for (const file of event.files) {
        console.log(file.url_private)
    }
}

上記のように記述することでファイルのURLを取得することができますので、試しにログで確認しましょう。
ファイルのURLが出力されていれば問題なくイベントを受け取ることができています。

ファイルのデータを取得する

ここで1つ注意点があり、Slackのファイルデータはワークスペースにログインしている人しか閲覧・ダウンロードができないという仕様があります。
そのためSlackのトークンを用いてファイルを取得する際に認証を挟む必要があります。

トークンはSlack AppのOAuth & PermissionsからBot User OAuth Tokenで確認できます。

秘匿情報ですので.envファイルを作成してそこに保存しておきましょう。
またライブラリを利用するのでpackage.jsonaxiosを追加します。

.env
SLACK_TOKEN=xoxb-000000000-000000000-AAAAAAAAAAAAAAAAAA
package.json
{
  "dependencies": {
    "@google-cloud/functions-framework": "^3.0.0",
+    "axios" : "1.7.7"
  }
}
index.js
+const axios = require('axios');

+// envファイルからSlackトークンを取得
+const slackToken = process.env.SLACK_TOKEN

const handleShareEvent = (event) => {
    for (const file of event.files) {
+        const fileData = await getFileFromUrl(fileInfo.url_private)
-        console.log(file.url_private)
    }
}

次にURLからファイルデータを取得するためのgetFileFromUrl関数を作成します。

index.js
// URLからファイル情報をストリームで取得する関数
const getFileFromUrl = async (fileUrl) => {
    try {
        const response = await axios.get(fileUrl, {
            headers: {
                'Authorization': `Bearer ${slackToken}`
            },
            responseType: 'stream' // ストリームデータとして取得
        });

        return response.data

    } catch (error) {
        console.error('ファイル取得中にエラーが発生しました:', error);
    }
}

fileDataにSlackから取得したデータが入っていますので、このデータをDriveに保存できれば完了です。

Google Driveの認可コードとアクセストークンを取得する

次にコード上からGoogle Drive APIにアクセスしてファイルの読み書き等を行いたいので、OAuth2.0認証を行います。

まずOAuth 2.0 クライアント IDを作成します。
アプリケーションの種類で「ウェブアプリケーション」を選択し、適当な名前を入力します。
また承認済みのリダイレクト URIとして、Slack APIのEvent Subscriptionsで利用しているURLを設定しておきます。

作成後、「APIとサービス > 認証情報」の画面に先ほど作成したOAuth 2.0 クライアント IDが表示されていると思うので「OAuthクライアントをダウンロード」を選択します。
JSONファイルがダウンロードできるので、「credentials.json」など分かりやすい名前で保存し、Cloud Runのソースからindex.jsと同じディレクトリに配置します。

まず認可コードを取得します。

認可コードを取得するためには下記のURLに必要な情報を埋め込んでアクセスします。
クライアントIDは「認証情報」もしくは先ほどダウンロードしたcredentials.jsonに記載されているIDです。
リダイレクトURLは上記で設定したのと同じリダイレクトURLです。

https://accounts.google.com/o/oauth2/v2/auth?
response_type=code&
client_id=(クライアントID)&
redirect_uri=(リダイレクトURL)&
scope=https://www.googleapis.com/auth/drive&
access_type=offline&
prompt=consent

アクセスしたら利用したいドライブのアカウントを選択してログインします。
途中、警告が出ますがそのまま続行し、権限を付与します。
すると設定したリダイレクトURLから始まるURLにリダイレクトすると思いますので、パラメータを確認します。
codeの部分が認可コードになりますので、厳重に保管しておきます。

https://slack-event-000000000000.asia-northeast1.run.app/?code=(この部分が認可コード)&scope=https://www.googleapis.com/auth/drive

次にリフレッシュトークンを取得するためAPIを叩きます。
リフレッシュトークンを取得するのは初回のみなので、関数として作成する必要はありません。

https://oauth2.googleapis.com/token にPOSTリクエストを行います。

パラメータには下記を指定します。
code 先ほど取得した認可コード
client_id API Consoleから取得したクライアント ID
client_secret API Consoleから取得したクライアント シークレット
redirect_uri リダイレクト URI
grant_type authorization_code

成功すると下記のようにリフレッシュトークンが返ってきます。

{
    "access_token": "ya29.a0AcM612waxLcdBoEgAvgSt_gFNTPu6x0E7pN01EZK4nZro22AOK0frSkf9dh5M9ynsBVTQerO7BO8lcv15Hk4OrZHeUL4rFfxuKatzMQpPxLqAex4DyhsBPCdmXU5xPWaW_vhL3Xk1NHNKACGFIYGMlkj1_CtXmRXJH_IVYV6aCgYKAT4SARASFQHGX2MiKH__PLVZ4iSVj32LDa-4eg0175",
    "expires_in": 3599,
    "refresh_token": "1//XXXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXX",
    "scope": "https://www.googleapis.com/auth/drive",
    "token_type": "Bearer"
}

リフレッシュトークンはアクセストークンを再発行するのに必要なため、.envファイルに記録しておきます。

.env
SLACK_TOKEN=xoxb-000000000-000000000-AAAAAAAAAAAAAAAAAA
+REFRESH_TOKEN=1//XXXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXX

次はリフレッシュトークンからアクセストークンを再発行し、実際にDriveにデータを保存するまでを行います。

取得したデータをDriveに保存する

Google Cloud Consoleのナビゲーションメニューから「APIとサービス > ライブラリ」と選択し、Google Drive APIを有効化します。

再びCloud Runに戻り、index.jsを書き換えていきます。

ファイルの読み取りやDriveとの接続に必要なライブラリとコードを追加します。
(package.jsonへの追加も行います)

index.js
const functions = require('@google-cloud/functions-framework');
const axios = require('axios');
+const { google } = require('googleapis');
+const path = require('path');
+const fs = require('fs');
+require('dotenv').config();

+const CREDENTIALS_PATH = path.join(__dirname, 'credentials.json');

・・・

新たに関数を作成します。先ほど取得したリフレッシュトークンを基にアクセストークンを再発行するための関数です。(コードの詳細については省略します)

index.js
// アクセストークンの再発行を行う関数
const getAccessToken = async () => {
    const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
    const { client_id, client_secret } = credentials.web;

    const refresh_token = process.env.REFRESH_TOKEN
    const url = "https://oauth2.googleapis.com/token"

    const body = {
        client_id: client_id,
        client_secret: client_secret,
        grant_type: "refresh_token",
        refresh_token: refresh_token
    };

    try{
        const response = await axios.post(url, body)

        return response.data.access_token
        
    }catch(error){
        console.error('Error:', error);
    }
}

https://developers.google.com/identity/protocols/oauth2/web-server?hl=ja#httprest_7

続いてファイルデータを受け取りGoogle Driveにアップする関数を作成します。

index.js
// Google Driveにファイルをアップロードする関数
const uploadFile = async (oAuth2Client, folderId, fileData, fileInfo) => {
    const drive = google.drive({ version: 'v3', auth: oAuth2Client });

    // アップロードするファイルのメタデータとコンテンツ
    const fileMetadata = {
        'name': fileInfo.name,  // Google Driveにアップロードされるファイル名
        'parents': [folderId] // アップロード対象のディレクトリ
    };
    const media = {
        mimeType: fileInfo.mimetype,
        body: fileData,
    };

    try {
        const response = await drive.files.create({
            resource: fileMetadata,
            media: media,
            fields: 'id',
        });

        console.log('ファイルのアップロードに成功 File ID:', response.data.id);
        return response.data.id
    } catch (error) {
        console.error('ファイルのアップロードに失敗:', error);
        return null
    }
}

最後にこれまで作成した関数を使用し、handleShareEvent関数を以下に置き換えます。
ファイルの保存先を指定する必要があるため、Slack用のフォルダを作成しフォルダIDを確認しておきます。
https://drive.google.com/drive/u/3/folders/1rLgY4x1Nx-wqikTLbVmQVEl4qry0D20-というフォルダURLの1rLgY4x1Nx-wqikTLbVmQVEl4qry0D20-にあたる部分がフォルダIDになるため確認して置き換えてください。

index.js
const handleShareEvent = async (event) => {

    // アクセストークンを発行する
    const newAcccessToken = await getAccessToken()

    const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
    const { client_id, client_secret, redirect_uris } = credentials.web;
    const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris);

    oAuth2Client.setCredentials({
        "access_token" : newAcccessToken,
        "refresh_token" : process.env.REFRESH_TOKEN
    });

    // 保存先のフォルダID
    let folderId = "(フォルダIDを確認し置き換えてください)"

    for (const fileInfo of event.files) {
        const fileData = await getFileFromUrl(fileInfo.url_private)

        // Google Driveにファイルをアップロードする 
        const fileId = await uploadFile(oAuth2Client, folderId, fileData, fileInfo)
    }

}

Appを追加したチャンネルでファイルを共有してみます。

Slackのチャンネルに投稿したファイルを自動的にDriveへ保存できるようになりました🎉

また正しく権限を付与できていないのと正しくファイルを移すことができず、閲覧できない状態のファイルが保存されてしまうことがあるため、問題ないか確認しましょう。

今回使用したソースコードはこちらからご確認ください。
https://github.com/mizurest/zenn-slack2drive/blob/main/index.js

課題として処理を行うたびにアクセストークンを再発行しているため、1時間に何度もメッセージが流れるようなチャンネルの場合、アクセストークンを使い回すように改善したいですね。

【補足】同じファイルが複数保存されてしまう問題について

これはSlack Event APIで、イベントの応答が3秒以内に返されない場合、つまり実行から3秒以内に完了しない場合に、イベントを再送信してしまうことに起因する問題です。

対処法としてはSlackから流れてくるeventのtsを記録しておくことが考えられます。このtsはイベントが発生した瞬間のタイムスタンプであり、同じメッセージであれば同じタイムスタンプが記録されています。
なので処理済みeventのtsを配列として保持しておき、流れてきたtsがその配列に含まれていれば再送イベントとして何も処理を行わず終了することで、同じファイルが複数保存される現象を防ぐことができます。

Discussion