PHP
Heroku
GoogleAppsScript
Slack
slack-api

Slackのストレージを消費せずにファイルをアップロード ~アップロードされたファイルを即座にGoogle Driveに転送する~

こんにちは、GMAです。

(2018/11/28 20:12 追記)
補足、関連記事を追加し、細かい内容を修正しました。

はじめに

Slack、便利ですよね。PC、スマホ、ブラウザと、どこからでもアクセスできるうえ、仲間内や端末間でちょっとしたデータを受け渡したいときにも便利だったりします。

しかし、SlackのFreeプランにはワークスペース全体のストレージが5GBしかないという壁があります。そして、ひとたびそれをオーバーするとファイルをアップロードするたびに「おいおい、ストレージ足りねえぞ、ファイルをとっとと消しやがれ(意訳)」という風にbotからお叱りを受ける羽目に…。
qiita1.PNG
そこで、今回はGASを用いて、Slackの利便性を損なうことなく、 ストレージを消費しない画像アップロードアプリケーション を実装します。(実際はGoogle Driveのストレージを消費するのですが、 特にG Suiteを利用されている場合は、ストレージ容量が大きい or 無制限なので役に立つかと思います! )

※注意※
本システムはGASだけでも一応動きますが、SlackのEvents APIの仕様の関係で動作が不安定になる場合があります(詳細は「問題点と対策」に後述)。 最終的にはHerokuや別途用意したサーバーを介すことで安定したシステムになります。 Herokuでの実装例については後述しています。またGASの仕様の都合上、 50MB以上のファイルのアップロードには対応していません。 あらかじめご了承ください。

SlackにアップロードされたファイルをGoogle Driveに即座に転送するアプリケーション

仕様

今回のシステムは、SlackとGoogle Driveを連携させることで実現します。プログラムの動作は以下のような感じです。

  1. 画像などのデータをSlackにアップロード
  2. ファイルのアップロードを検知し、GAS (Google Apps Script)のプログラムを起動する
  3. アップロードされたファイルをGoogle Driveにコピーし、共有リンクを作成する
  4. Google Driveの共有リンクをSlackに貼る
  5. Slack上のファイルを削除する

動作イメージとしては以下のような感じです。
2.png

ちなみに、Google DriveアプリをSlackに導入しておけば、共有リンクをSlackに貼った時に画像等がインポートされるので (これはリンク先の画像を表示しているだけなので、Slackのストレージは消費しません)、ほぼこれまで通りの利便性を維持することができます。ただし、 画像等をインポートするには条件があります。 詳細は「問題点と対策」に後述します。

準備

それなりに大変です。まず、 https://api.slack.com/apps にアクセスして新しくアプリを作成しましょう。次にOAuth & Permissionsから、以下のスコープを追加します。

  • admin 他者の発言を削除するために必要です。
  • channels:history ファイルにつけられたコメントを取得するために必要です。
  • chat:write:bot チャンネルでアプリが発言するために必要です。
  • files:read ファイルの取得のために必要です。
  • files:write:user ファイルの削除のために必要です。
  • users:read ファイルをアップロードしたユーザ名を取得するために必要です。

スコープを追加したら、忘れないうちにアプリをワークスペースにインストールしてしまいましょう。

次に、一度Google Apps Script (GAS)の準備に移ります。まず、Google Driveを開いて、スクリプトファイルを作成します。「新規 > その他 > アプリの追加」からGoogle Apps Scriptを選択して追加しましょう。そして、GASのファイルを適当な名前で作成したら、以下のコードを入力します。

fileApp.gs
// ファイル共有時にまずこの関数が呼ばれる
function doPost(e){
  var params = JSON.parse(e.postData.getDataAsString());
  // 初回の認証時のみ必要
  if(params.type === "url_verification"){
    return ContentService.createTextOutput(params.challenge);
  }
  return ContentService.createTextOutput('ok');
}

コードを入力したら保存をしたのち、「公開 > ウェブアプリケーションを導入」を選択しましょう。各項目を適切に入力し、アプリケーションにアクセスできるユーザーは「全員(匿名ユーザーを含む)」にしてください。入力が終わったら導入を選択します。なお、GASはコードを更新するたび、「公開 > ウェブアプリケーションの導入」から更新を行わなければいけないので注意しましょう。

「導入」を選択すると、現在のウェブアプリケーションのURLが表示されると思いますので、これをコピーしてSlackのアプリの設定に戻りましょう。

Event Subscriptionsを選択し、一番上のRequest URLに先程コピーしたURLを貼り付けましょう。認証が自動で行われうまくいけばVerifiedと表示されるはずです。うまくいかない場合は、GASのコードや設定を見直しましょう。

最後に、Subscribe to Workspace Eventsにイベントを追加しましょう。Add Workspace Eventをクリックして、「file_shared」を選択します。これで、ファイル共有時に先程登録したURLにファイルのIDなどの情報が飛びます。

以上で準備は終わりです。

プログラム

PublicチャンネルにアップされたデータをGoogle Driveに移すスクリプトです。 トークンは適宜「ファイル > プロジェクトのプロパティ > スクリプトのプロパティ」に「TOKEN」の名前で登録してください。アップロード先のGoogle DriveのフォルダIDも同様に「FOLDER_ID」の名前で登録する必要があります。 フォルダIDは https://drive.google.com/drive/u/0/folders/ の後の文字列です。なお、GASからファイルをアップロードできるように、適切な共有設定を登録するフォルダに対して行ってください。

fileApp.gs
// ファイル共有時にまずこの関数が呼ばれる
function doPost(e){
  var params = JSON.parse(e.postData.getDataAsString());
  // 初回の認証時のみ必要
  if(params.type === "url_verification"){
    return ContentService.createTextOutput(params.challenge);
  }
  // ファイルをgoogle driveに移す
  if(params.event.type === "file_shared") {
    moveFiles(params);
  }
  return ContentService.createTextOutput('ok');
}

// ファイルをgoogle driveに移す
function moveFiles(params){
  // google driveに移したくないファイル形式
  // https://api.slack.com/types/file を参考に適宜追加する
  var notCopyType = ["text", "c"];

  // tokenとアップロード先のGoogle DriveのフォルダIDを取得
  // それぞれ「ファイル > プロジェクトのプロパティ > スクリプトのプロパティ」から登録しておくこと
  var scriptProperties = PropertiesService.getScriptProperties();
  var slackAccessToken = scriptProperties.getProperty("TOKEN");
  var folderId = scriptProperties.getProperty("FOLDER_ID");

  try{          
    // File ID取得
    var fileId = params.event.file_id;
    // ユーザID取得
    var userId = params.event.user_id;
    var userResponse = UrlFetchApp.fetch('https://slack.com/api/users.info?token='+slackAccessToken+'&user='+userId);
    var userInfo = JSON.parse(userResponse.getContentText());
    // アップロード先のフォルダ名に使用する
    var userName = userInfo.user.name;
    // リンクをSlackに貼り直す際のコメントに使用する
    var displayName = userInfo.user.profile.display_name;
    if (displayName === "") {
      displayName = userInfo.user.profile.real_name;
    }

    // File詳細取得
    var fileResponse = UrlFetchApp.fetch('https://slack.com/api/files.info?token='+slackAccessToken+'&file='+fileId);
    var fileInfo = JSON.parse(fileResponse.getContentText());

    // Google Driveのリンクなら無視
    if(fileInfo.file.external_type == 'gdrive'){
      return;
    }

    // 50MB以上なら終了(GASは50MB以上のファイルを一度に扱えません)
    if(fileInfo.file.size > 50000000){
      return;
    }

    // ダウンロード用URL
    var dlUrl = fileInfo.file.url_private;
    // ファイル形式
    var fileType = fileInfo.file.filetype;

    // Google Driveに移したくないファイル形式の場合は何もしない
    for(i in notCopyType) {
      if(fileType == notCopyType[i]){
        return;
      }
    }

    // Slackからファイル取得
    var headers = {
      "Authorization" : "Bearer " + slackAccessToken
    };    
    var params2 = {
      "method":"GET",
      "headers":headers
    };
    var dlData = UrlFetchApp.fetch(dlUrl, params2).getBlob();

    /////////////////////////////////////////////////////
    // Google Driveにファイルをアップロードする処理
    /////////////////////////////////////////////////////

    // フォルダを指定
    var rootFolder = DriveApp.getFolderById(folderId);
    // ユーザ名の入ったフォルダに移動
    var targetFolder = rootFolder.getFoldersByName(userName +"_slackItems");
    // 対象フォルダがない場合は新しく作成
    if(targetFolder.hasNext() == false){
      var targetFolderId = rootFolder.createFolder(userName +"_slackItems");
    } else {
      var targetFolderId = DriveApp.getFolderById(targetFolder.next().getId());
    }

    // Slackからダウンロードしたファイル名の文字化け対策
    dlData.setName(fileInfo.file.name);

    // Google Driveにファイルをアップロード
    var driveFile = targetFolderId.createFile(dlData);

    // 共有設定 (リンクを知っていれば閲覧可)
    driveFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

    // おまじない
    Utilities.sleep(100);

    /////////////////////////////////////////////////////
    // Slackにリンクを貼る処理
    /////////////////////////////////////////////////////

    // ファイルをどのチャンネルにシェアしたか特定する
    var shares = fileInfo.file.shares;

    // publicチャンネルの場合のkeyはpublicだが、
    // この書き方ならprivateチャンネルに共有された場合でも対応可能
    for(key in shares){
      foo=shares[key];
      postType = key;
      break;
    }

    // channel IDを取得
    for(key in foo){
      bar=foo[key];
      channelId = key;
      break;
    }

    // タイムスタンプを取得
    var th_ts = 0.0;
    var ts = 0.0;
    for(key in bar[0]){
      // スレッドへの投稿のタイムスタンプ
      if(key == "thread_ts"){
        th_ts = bar[0][key];
        continue;
      }
      // 通常の投稿のタイムスタンプ
      if(key == "ts"){
        ts = bar[0][key];
      }
    }

    // ポストするメッセージ
    var message = displayName + 'さんが '+ fileInfo.file.name + ' を共有しました!\n' + driveFile.getUrl();

    // ファイルのコメントを取得する
    if(ts != 0.0){
      var historyResponse = UrlFetchApp.fetch('https://slack.com/api/channels.history?token='+slackAccessToken+'&channel='+channelId+'&count=1&latest='+ts+'&oldest='+ts+'&inclusive=true');
      var historyInfo=JSON.parse(historyResponse.getContentText());
      message = message + '\n\n'+historyInfo.messages[0].text;

      // コメント文を消去
      var delparams = {
        'token': slackAccessToken,
        'channel': channelId,
        'ts': ts,
        'as_user': false
      };
      var deloptions = {
        'method': 'POST',
        'payload': delparams
      };
      UrlFetchApp.fetch('https://slack.com/api/chat.delete',deloptions);
    }

    if(th_ts != 0.0){
      // スレッドにリンクを貼り直す
      postText(slackAccessToken, channelId, message, th_ts);
    }else{
      // 通常の発言としてリンクを貼り直す
      postText(slackAccessToken, channelId, message);
    }

    /////////////////////////////////////////////////////
    // Slack上のファイルを削除
    /////////////////////////////////////////////////////

    // 元ファイルを削除
    var params = {
      'token': slackAccessToken,
      'file' : fileId
    };
    var options = {
      'method': 'POST',
      'payload': params
    };
    // 削除実行
    var res = UrlFetchApp.fetch('https://slack.com/api/files.delete',options);
  }catch(e){
    // エラー内容を投稿
    // postText(slackAccessToken, channelId,'Error: '+e);
  }
}

// メッセージをポストする
function postText (token, channel, txt, th_ts) {
  if (th_ts == undefined){
    // 通常のポスト
    var params = {
      'token': token,
      'channel': channel,
      'text': txt,
      'as_user': false
    };
  } else {
    // スレッドへのポスト
    var params = {
      'token': token,
      'channel': channel,
      'text': txt,
      'as_user': false,
       'thread_ts':th_ts
    };
  }
  var options = {
    'method': 'POST',
    'payload': params
  };
  UrlFetchApp.fetch('https://slack.com/api/chat.postMessage',options);
}

問題点と対策

上記で基本的にシステムは完成ですが、いくつか問題点があります。

まず、同じファイルが何度もGoogle Driveにアップロードされてしまうことがあります。これはSlackのEvents APIの仕様で、 3秒以内にサーバからレスポンスがないとEvent通知を再送してしまう のが原因です。GASは非常に動作が遅いので、まずレスポンスを先に返してから処理を行う必要がありますが、調べた限りではGASでそのような実装を行うのは難しいようです。ですので今回はHerokuなどをレスポンス用にはさむことにします。

qiita5.png

Herokuで実装する場合のPHPのコードは以下の通りです。

index.php
<?php
  // 認証用
  //$data = file_get_contents('php://input');
  //var_dump($data);

  // 先に応答を返す
  echo('ok');
  // 強制的に接続を切る
  fastcgi_finish_request();

  // 応答後、処理を続行する
  // 受け取ったパラメータをGASに投げるだけ
  $data = file_get_contents('php://input');

  // ここにGASのURLを入れる
  $url = "https://script.google.com/macros/s/XXXXXXXXXXXXX/exec";

  // POSTで送信
  $ch = curl_init();
  curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
  curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
  curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_URL, $url);
  curl_exec($ch);

  exit;
?>

また、Google DriveのアプリをSlackに追加すると、Google Driveのリンクを貼った際に画像をインポートして表示することができますが、Botにリンクを貼らせる場合はGoogle Driveのインポート設定が不可能なので、わざわざリンクURLを踏まないと画像を見れないという欠点があります。これについては、リンクを貼るためのアカウントを用意し、アプリのCollaborators(共同開発者)に指定してトークンを生成し直すか、以下のレガシートークンを利用した方法を使うしかないようです。

レガシートークンを利用する

あくまで「レガシー」なので、利用するのはあまりよろしくないのですが、上記コード中のslackAccessTokenとしてこれを使うと、以下のようなことが可能になります。

  • プライベートチャンネルやDMにアップされたファイルもGoogle Driveにアップロードできる
  • ファイルをアップした本人の発言としてリンクを貼り直すことが可能になる

Slackのレガシートークンは https://api.slack.com/custom-integrations/legacy-tokens から取得できます。

応用等

あまりに記事が長くなるので、詳細は割愛しますが、最終的に自分の利用しているワークスペースでは以下のような仕様にしています。

  • ファイルをアップすると、基本的にはチームドライブにファイルが移され、BotがSlackにリンクを貼る
  • ユーザごとにアップロード先のGoogle Driveのフォルダやレガシートークンを登録できる
  • フォルダやトークンを登録していると、チームドライブではなく、登録したフォルダに移され、ファイルをアップロードしたユーザ本人の発言としてSlackにリンクを貼り直す
  • 50MB以上のファイルをアップロードした場合は、ファイルを削除し、手動でドライブにデータをアップしてもらうように通知

フォルダやトークンは、ユーザプロパティに「ユーザID_FOLDERID」「ユーザID_TOKEN」といった名前で記録しています。それゆえ、GASのプログラムの管理者は原理上、各ユーザがなんのファイルをアップしたか知ることができてしまうほか、トークンも知り得てしまうので、ワークスペースの利用者はその点だけ注意して利用する必要があります。ですが、このようにすることで、Slackは勿論、チームドライブも汚さない素敵なシステムができあがります。

まとめ

GASとHerokuを用いて、Slackのファイルを即座にGoogle Driveに移してくれるシステムを実装しました。5GBの制限を気にせずに良くなるので、気楽にSlackでファイルが共有できるようになりますね!

補足

記事内のコードでは、ファイルに添えられたコメントを取得する際に、Slack APIのchannels.historyメソッドを使用していますが、プライベートチャンネルやダイレクトメッセージなどにも対応させたい場合は、groups.historyやim.historyを適宜使い分ける必要があります。
それ以前に、 自分が関わっていないプライベートチャンネルやダイレクトメッセージの中身(勿論ファイルも含む)は基本的に取得できないので、本気でプライベートチャンネルやダイレクトメッセージなどにも対応させたいのであれば、「応用等」で記載したような手段を駆使して、アプリがファイル操作可能な状況を無理矢理作り出さないといけませんが…。
APIについて詳しくは https://api.slack.com/methods をご覧ください。

関連記事

Slackにアップしたファイルを自動でS3にアップするBOTを作ってみた -ファイルはS3にどんどんしまっちゃおうねぇ~-
アイデアを練る際の参考にさせていただきました。こちらはストレージとしてAmazon S3を利用されていますね。