📒

【全部GAS】社内で作ったGoogleドキュメント製マニュアル検索Slack BotをDrive APIの全文検索機能を使って実装した話

2023/01/03に公開

作ることになった経緯

  • 社内でGoogleドキュメントでマニュアルを整備することになった
  • Googleドキュメントのマニュアルを特定のフォルダを開かないと見れないのは利便性が悪い
  • Slack Bot × Google系のAPIで何とかしてみようと思った

やったこと(TL:DR;)

  • GASのWebデプロイでSlack Event Subscription のエンドポイントを作成
  • Slack Bot をワークスペースに導入
  • Drive API で対象フォルダ内のファイル検索
  • SlackでBotに検索ワード付きでメンションすると、検索結果を質問者あてにメンションつきで送ってくれる。

開発のポイント

  • エンドポイントはverifyのchallenge適切に捌こう
  • GCP連携してログ書き出ししている場合は、GCP側でもAPIを有効化しよう(これで筆者は30分詰まりました)
  • 検索のクエリでmimeTypeを使ってファイルタイプを指定、全文検索はfullTextで。

この記事で書いてないこと

  • Slack Botの立て方
  • GASでSlack Botが動くようになるまでの流れ(コード読んだら分かるけど、細かく記述しないよ)

このあたりは、以下記事参照して作りました。感謝感激。Special Thanks
https://auto-worker.com/blog/?p=2904 (基本的なGAS×Slack Bot について)
https://arrown-blog.com/gas-slack-bot-mention-collect/#1-2_Event_Subscriptions ( Slack Event Subscriptionのエンドポイント設定周り )


注意点


実際のコード

基本の処理

doPost(e)関数がGASをウェブ上でデプロイする際(ウェブ上でデプロイすると設定次第ですが、パブリックなサーバーを立てられます)のメイン関数です。
引数eでリクエストを受け取り、パースしています。

console.logはGCP経由でログ表示させています。

参考記事

paramsはSlackから送られてくるJSONをパースしたもので、必ず付与されているtypeメンバをswitchで切り分け、url_verificationが指定された場合はchallengeを返し、Event Subscriptionの場合はevent_callbackが指定され独自のdoEvent(event)関数を呼び出します。

末尾のreturn ContentService.createTextOutput()はSlack API側に200を返すための記述です。

// シンタックスハイライトはJavascriptのものを利用しています。
function doPost(e) {
  try {
    const params = JSON.parse(e.postData.getDataAsString()); // PostのJSONデータをObjectにパース
    console.log(params)

    const type = params.type;

    switch (type) {
      case "url_verification":
        return ContentService.createTextOutput(params.challenge);
      case "event_callback":
        doEvent(params.event)
        return ContentService.createTextOutput();
    }
  } catch (err) {
    console.log(err)
  }
}


Drive API を利用した検索機能

DriveApp.getFolderByIdで検索対象のフォルダを指定。
サブフォルダまでは検索しないので、必要であればループさせる必要ありです。

searchFolder.searchFilesがメインの検索を行う関数。
返り値はFileIteratorです。

searchFiles関数の引数について

詳細は全てこちらの公式ドキュメントに...

まとめると、引数となるparamsには検索用のクエリを入れ込んだ、文字列を渡すことになります。
クエリの書式は以下の通りです。
検索条件 条件式 値
検索条件はnamemodifiedTime,writersなど、検索の条件絞り込みが可能です。
条件式は一般的な!=,=だけでなく<containsなどがありますが、検索条件ごとに使える条件式が違うため、詳しくは公式ドキュメント(英語のみ)を読んでください。
値は文字列を指定する場合はシングルクォーテーションで囲むなど指示がありますが、それも検索条件ごとに変わります。
書式は空白を開けて複数重ねることが可能です。

ちなみに検索条件のmimeTypeでGoogleドキュメントのみを指定しています。他にもいろいろ指定可能。公式ドキュメント(英語のみ)を読んでね

例)編集日が2023年1月1日0:00(GMT+9)のファイルタイトルに「経費精算」を含むファイル
modifiedDate >= 2022-12-31T15:00:00:00-00:00 name contains '経費精算'

function SearchGoogleDocs(query) {
// query引数は検索キーワードです。
  const driveId = PropertiesService.getScriptProperties().getProperty("MANUAL_INDEX_FOLDER")//GASのPropertiesServiceという環境変数を扱える機能経由でマニュアルが含まれたフォルダのIDを指定しています。
  const searchFolder = DriveApp.getFolderById(driveId)
  let files = searchFolder.searchFiles("mimeType = 'application/vnd.google-apps.document' and fullText contains '" + query + "'");
  //ここでの条件は、ファイルタイプがGoogleドキュメントで、タイトル、本文対象の全文検索でqueryワードを含むファイル
  return files
}

メンションされたワードをもとに検索結果を返す機能

  const type = event.type
  const text = event.text
  switch (type) {
    case "app_mention":

eventtypeがあるため、switchで切り分けしています。

      const token = PropertiesService.getScriptProperties().getProperty("SLACK_ACCESS_TOKEN")
      const slackApp = SlackApp.create(token)

tokenをもとにSlackAppのクライアントを作成。

Slack API ラッパーについて

今回はGASのSlackApp(GitHub)を利用しています。ドキュメントも整備されていて素敵なライブラリ。
あんまりリポジトリに対してリンク貼られてなかったので貼っておきます。

メンションされたテキストから検索ワードを取り出す

      let query = text.replace(/<.*>/g, "").replace("\n", "");

Slackでメンションを受け取ると、<@userID> hogefugaのように、メンションの文字が含まれるので正規表現で削除。ついでに改行もエラーの元になるので消しておきましょう。
これでメンション+検索ワードから検索ワードのみを取り出せます。

Drive API File Iteratorを回す

FileIteratorはイテレーター(解説してくれているQiita記事)なので、配列(じゃないけど)の次の対象があるかチェックして、あれば次へなければ終了という流れでwhileを回していきます。
message変数に名前とURLと改行コード\nを渡して終了


      let files = SearchGoogleDocs(query) // さっきの検索関数です。queryは検索ワードです。

        while (files.hasNext()) {
          var file = files.next()
          message += `\n${file.getName()}(${file.getUrl()})`
        }

Slack API のメッセージで@mention

var message = `<@${event.user}> 「${query}」のワードを含むマニュアルは見つかりませんでした。検索ワードを変えてください。`

送りたいメッセージに<@userID>を含めるだけ。

メッセージ送信はslackApp.postMessage

特にこれといったこともないですが、eventオブジェクトのchannelメンバは、メンションされた(正確に言うとイベントが発生した)チャンネルのIDを含んでいるのでこれでチャネル指定してメッセージ送信します。
messageも普通の文字列で大丈夫です。

        slackApp.postMessage(event.channel, message);

コード全文

function doEvent(event) {
  const type = event.type
  const text = event.text
  switch (type) {
    case "app_mention":
      const token = PropertiesService.getScriptProperties().getProperty("SLACK_ACCESS_TOKEN")
      const slackApp = SlackApp.create(token)
      let query = text.replace(/<.*>/g, "").replace("\n", "");
      let files = SearchGoogleDocs(query)

      if (!files.hasNext()) {
        var message = `<@${event.user}> 「${query}」のワードを含むマニュアルは見つかりませんでした。検索ワードを変えてください。`
        slackApp.postMessage(event.channel, message);
      } else {
        var message = `<@${event.user}> 「${query}」のワードを含むマニュアルの検索結果です。`
        message += "\n------------"
        while (files.hasNext()) {
          var file = files.next()
          message += `\n${file.getName()}(${file.getUrl()})`
        }
        slackApp.postMessage(event.channel, message);
      }
  }
}

以上を組み合わせてデプロイすれば完成!


最後に

年明け3日の朝にぶっ続けでコードと記事書いて腕がしびれています。
このあとはGITADORAやるぞ!
読了アリガトウゴザイマス!

Discussion