LINE Messaging API x Google Apps Script x kintone で作る画像データベース

LINEからkintoneに撮影した写真を送って保存する画像データベースを作ってみました。
LINE Messaging API と Google Apps Script、kintoneを組み合わせています。

# 前提知識

* kintoneでアプリが作成できる
* LINE Messaging API の設定ができる
* Google Apps Script の設定ができる

# 動作イメージ

LINEの画面です。
<img width="375" alt="IMG_1247.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/3151e9a8-6e09-90cc-724a-4c2ec2dbdbf7.png">

kintoneの画面です。
<img width="558" alt="スクリーンショット 2020-06-21 14.25.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214523/6bb94d57-a129-f970-f780-f5d432277810.png">


# 概要
LINE Messaging API から送信した画像を ボットサーバー(Google Apps Script(GAS)で作成して公開）で受信します。受信した画像をkintoneにファイルアップロードします。

1. kintoneアプリを作成
2. LINE Messaging APIを設定
3. Google Apps Script でWebアプリを作成
4. Google Apps Script の公開URLをLINE Messaging API のWebhookに設定します

1.と2.、4.は拙稿をご覧ください。

* [LINE と Google Apps Script で作成したBotの情報をkintoneに保存する](https://qiita.com/sy250f/items/e50c4efd5ba2a923e773)
* [LINE Messaging API を使って、リッチメニューを作ってスマホのカメラを起動するまでやってみた](https://qiita.com/sy250f/items/573ad752b6d83da15e2e)

# 3. Google Apps Script でWebアプリを作成

参考にコードを載せておきます。
いつものようにデバッグ用のコードはそのままですし、書き方も動きを確認する程度の雑な実装ですが、ご容赦ください。
また、APIトークンなどは、変数に良しなにセットされている前提です。

```Code.ts
// 環境変数のセット
const scriptProperties = PropertiesService.getScriptProperties();
const DOMAIN = scriptProperties.getProperty('DOMAIN'); // kintone ドメイン名
const APP_ID = scriptProperties.getProperty('APP_ID'); // kintone ユーザーID
const API_TOKEN = scriptProperties.getProperty('API_TOKEN'); // kintone APIトークン
const SHEET_ID = scriptProperties.getProperty('SHEET_ID'); // デバッグ用 Spreadsheet ID
const CHANNEL_ACCESS_TOKEN = scriptProperties.getProperty('CHANNEL_ACCESS_TOKEN'); // LINEチャネルアクセストークン
const lineReplyUrl: string = 'https://api.line.me/v2/bot/message/reply';

function doPost(e: { postData: { contents: string; }; }) {
  appendLogToSpreadsheet('--- debug start', SHEET_ID)
  let lineEvents = []
  const botUserId: string = JSON.parse(e.postData.contents).destination
  lineEvents = JSON.parse(e.postData.contents).events
  appendLogToSpreadsheet(botUserId, SHEET_ID)
  appendLogToSpreadsheet(JSON.stringify(lineEvents), SHEET_ID)
  if (!lineEvents.length) {
    return ContentService.createTextOutput(JSON.stringify({'content': '200 ok'})).setMimeType(ContentService.MimeType.JSON);
  }
  lineEvents.forEach(event => appendLogToSpreadsheet(JSON.stringify(event), SHEET_ID))
  let replyToken: string = ''
  let userMessage: string = ''
  const replyPackageId: string = '11537'
  const replyStickerId: string = '52002740' // LINEスタンプID
  let replyMessage = {}
  let kintoneResult = {}
  lineEvents.forEach( async (event) => {
    if (event.type === 'message') {
      if (event.message.type === 'text') { // テキストメッセージ
        replyToken = event.replyToken
        userMessage = event.message.text
        replyMessage = {
          type: 'text',
          text: userMessage
        }
        replayMessage(lineReplyUrl, CHANNEL_ACCESS_TOKEN, replyToken, replyMessage)
        kintoneResult = await LoggerTokintone (userMessage, event.source.userId, event.message.type,DOMAIN, APP_ID, API_TOKEN)
      } else if (event.message.type === 'sticker') { // LINEスタンプ
        replyToken = event.replyToken
        replyMessage = {
          type: 'sticker',
          packageId: replyPackageId,
          stickerId: replyStickerId
        }
        replayMessage(lineReplyUrl, CHANNEL_ACCESS_TOKEN, replyToken, replyMessage)
        kintoneResult = await LoggerTokintone (`sticker packageId: ${event.message.packageId}, stickerId: ${event.message.stickerId}`, event.source.userId, event.message.type,DOMAIN, APP_ID, API_TOKEN)
      } else if (event.message.type === 'image') { // 画像
        replyToken = event.replyToken
        userMessage = '画像が送信されました'
        replyMessage = {
          type: 'text',
          text: userMessage
        }
        replayMessage(lineReplyUrl, CHANNEL_ACCESS_TOKEN, replyToken, replyMessage)
        const lineResult = await getUserContent(CHANNEL_ACCESS_TOKEN, event.message.id)
        const fileBlob = lineResult.getBlob()
        // kintoneに画像をアップロードする
        const fileKey = await uploadFileTokintone(fileBlob, DOMAIN, API_TOKEN, fileBlob.getName())
        // kintoneにアップロードした画像の情報をkintoneに登録する
        kintoneResult = await LoggerTokintone (event.message.id, event.source.userId, event.message.type, DOMAIN, APP_ID, API_TOKEN)
        appendLogToSpreadsheet('Blob fileKey', SHEET_ID)
        appendLogToSpreadsheet(fileKey, SHEET_ID)
        const attachedResult = await attachedFile(fileKey, DOMAIN, APP_ID, kintoneResult.id, API_TOKEN)
        appendLogToSpreadsheet('attached file', SHEET_ID)
        appendLogToSpreadsheet(JSON.stringify(attachedResult), SHEET_ID)
      } else {
        replyToken = event.replyToken
        userMessage = 'テキスト、画像又はスタンプ以外が送信されました'
        replyMessage = {
          type: 'text',
          text: userMessage
        }
        replayMessage(lineReplyUrl, CHANNEL_ACCESS_TOKEN, replyToken, replyMessage)
        kintoneResult = await LoggerTokintone (userMessage, event.source.userId, '',DOMAIN, APP_ID, API_TOKEN)
      }
    } else {
      replyToken = event.replyToken
      userMessage = 'メッセージ以外が送信されました'
      replyMessage = {
        type: 'text',
        text: userMessage
      }
      replayMessage(lineReplyUrl, CHANNEL_ACCESS_TOKEN, replyToken, replyMessage)
      kintoneResult = await LoggerTokintone (userMessage, event.source.userId, '',DOMAIN, APP_ID, API_TOKEN)
    }
    appendLogToSpreadsheet('result', SHEET_ID)
    appendLogToSpreadsheet(kintoneResult, SHEET_ID)
  })
  if (typeof replyToken === 'undefined') {
    return ContentService.createTextOutput(JSON.stringify({'content': '200 ok'})).setMimeType(ContentService.MimeType.JSON);
  }
  // 200 OKを返す
  return ContentService.createTextOutput(JSON.stringify({'content': '200 ok'})).setMimeType(ContentService.MimeType.JSON);
}

// LINEにメッセージを返信する
function replayMessage(url = 'https://api.line.me/v2/bot/message/reply', channelAccessToken, replyToken, replyMessage) {
  const response =  UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': `Bearer ${channelAccessToken}` ,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [replyMessage],
    }),
  });
  return JSON.parse(response.getContentText())
}

// LINEから送信した画像を取得する
function getUserContent(chanelAccessToken: string, messageId: string) {
  const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`
  const option =   {
    'headers': {
        'Authorization': `Bearer ${chanelAccessToken}`
      },
      'method': 'get',
    }
  return new Promise((resolve, reject) => {
    try {
      const result = UrlFetchApp.fetch(
        url,
        option
      )
      resolve(result)
    } catch (error) {
      reject(error)
    }
  })
}

// Spreadsheetにログを書き出す
function appendLogToSpreadsheet(log: String, sheetId: string, sheetName: string = 'sheet1') {
  const spreadSheet = SpreadsheetApp.openById(sheetId)
  spreadSheet.getSheetByName(sheetName).appendRow(
    [new Date(), log]
  );
  SpreadsheetApp.flush()
}

// kintoneにログを追加する
function LoggerTokintone (message: string, lineId: string, messageType: string, subDomain: string, appId: Number, apiToken: string) {
  const payload = {
    app: appId,
    record: { 
      'messageType': {
        'value': messageType
      },      
      'message': {
        'value': message
      },
      'lineId': {
        'value': lineId
      },
    }
  }
  const option = {
    method: "post",
    contentType: "application/json",
    headers: { "X-Cybozu-API-Token": apiToken },
    muteHttpExceptions: true,
    payload: JSON.stringify(payload)
  };
  appendLogToSpreadsheet('function LoggerTokintone option', SHEET_ID)
  appendLogToSpreadsheet(JSON.stringify(option), SHEET_ID)
  
  // kintoneにレコード追加
  return new Promise((resolve, reject) => {
    try {
      const result = UrlFetchApp.fetch(
        `https://${subDomain}.cybozu.com/k/v1/record.json`,
        option
      )
      appendLogToSpreadsheet('function LoggerTokintone UrlFetch', SHEET_ID)
      appendLogToSpreadsheet(result.getContentText(), SHEET_ID)
      resolve(JSON.parse(result.getContentText()))
    } catch (error) {
      reject(error)
    }
  })
}

// kintoneにアップロードした画像をkintoneのレコードに紐付ける
function attachedFile(fileKey: string, subDomain: string, appId: number, recordId: number, apiToken: string) {
  const data = {
    'app': appId,
    'id': recordId,
    'record': { 
      'image': {
        'value': [
          {'fileKey': fileKey}
        ]
      }
    }
  }
  const option = {
    method: "put",
    contentType: "application/json",
    headers: {"X-Cybozu-API-Token": apiToken},
    muteHttpExceptions: true,
    payload: JSON.stringify(data)
  };
  appendLogToSpreadsheet('function attachedFile option', SHEET_ID)
  appendLogToSpreadsheet(option, SHEET_ID)
  
  // kintoneにレコード追加
  return new Promise((resolve, reject) => {
    try {
      const result = UrlFetchApp.fetch(
        `https://${subDomain}.cybozu.com/k/v1/record.json`,
        option
      )
      appendLogToSpreadsheet('function attachedFile UrlFetch', SHEET_ID)
      appendLogToSpreadsheet(JSON.parse(result.getContentText()), SHEET_ID)
      resolve(JSON.parse(result.getContentText()))
    } catch (error) {
      appendLogToSpreadsheet('function attachedFile UrlFetch', SHEET_ID)
      appendLogToSpreadsheet(error.getContentText(), SHEET_ID)
      reject(error)
    }
  })
}

// 画像をkintoneにアップロードする
function uploadFileTokintone(file, subDomain, apiToken, fileName = 'sample.jpeg') {
  const boundary = 'xxxxxxxxxxxxxxx';
  const blob = file.copyBlob()
  appendLogToSpreadsheet(`${blob.getContentType()}, ${fileName}`, SHEET_ID)

  let data = "";
  data += "--" + boundary + "\r\n"
  data += "Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n";
  data += "Content-Type:" + blob.getContentType() + "\r\n\r\n";
  let payload = Utilities.newBlob(data).getBytes()
    .concat(blob.getBytes())
    .concat(Utilities.newBlob("\r\n--" + boundary + "--").getBytes());
  let options = {
    method: "post",
    contentType: "multipart/form-data; boundary=" + boundary,
    headers: {
      "X-Cybozu-API-Token": apiToken
    },
    muteHttpExceptions: true,
    payload: payload
  };
  appendLogToSpreadsheet('function uploadFile option', SHEET_ID)
  appendLogToSpreadsheet(JSON.stringify(options), SHEET_ID)

  // kintoneにレコード追加
  return new Promise((resolve, reject) => {
    try {
      const result = UrlFetchApp.fetch(
        `https://${subDomain}.cybozu.com/k/v1/file.json`,
        options
      )
      const json = JSON.parse(result.getContentText());
      appendLogToSpreadsheet('file upload result', SHEET_ID)
      appendLogToSpreadsheet(json, SHEET_ID)
      resolve(json.fileKey)
    } catch (error) {
      appendLogToSpreadsheet('file upload error', SHEET_ID)
      appendLogToSpreadsheet(JSON.stringify(error), SHEET_ID)
      reject(error)
    }
  })
}
```

# 参考

GASからkintoneに画像ファイルをアップロードする方法については、下記を参考にさせていただきました。ありがとうございます。
[Google フォームとkintoneを連携させる方法(添付ファイル編)](https://www.ka-net.org/blog/?p=12153)
https://gist.github.com/tanaikech/d595d30a592979bbf0c692d1193d260c

## GAS関連リンク

* Class File | Apps Script https://developers.google.com/apps-script/reference/drive/file
* Class Blob | Apps Script https://developers.google.com/apps-script/reference/base/blob
* URL Fetch Service | Apps Script https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
* Class HTTPResponse | Apps Script https://developers.google.com/apps-script/reference/url-fetch/http-response


# 今後の展開

* kintoneに登録した画像をLINEから取得して表示する