電算倶楽部 富山県のコンピュータ社会人サークル

富山県、特に滑川市、富山市、魚津市周辺で活動している社会人サークルです。

SlackとLINEを連携する(2) LINE→Slack

LINEに投稿されたメッセージをSlackにも通知するために、Google Apps Script(以下GAS)で処理します。

f:id:s-densan:20190630212550p:plain
説明対象

同時に、SlackからLINEへ通知する際に必要となるLINEユーザ情報をファイルに貯め込むことも必要です。

GASもjavascriptもほぼ初めてなので、妙な部分があったら優しく教えてください。

長いので要点だけ解説します。

ソースコード

/**
 * @fileoverview LINEボットに対して送られたメッセージをSlackのgeneralチャンネルに転送する。
 * @author smp
 */

// LINEアクセストークン
var LINE_ACCESS_TOKEN = '(秘密)'
// Slack Webhook 用URL
var SLACK_URL = 'https://hooks.slack.com/services/(秘密)';
// GoogleドライブディレクトリID
var GOOGLE_DRIVE_DIR_ID = '(秘密)';
// LINE BOTフォロー時リプライメッセージ
var LINE_FOLLOW_REPLY_MESSAGE = 'LINE Slack連携ユーザに登録しました。Slackの投稿がLINEに届き、LINEの投稿がSlackに届くようになります。';
// LINE返信用APIのURL
var LINE_MESSAGE_REPLY_URL = "https://api.line.me/v2/bot/message/reply";
// LINEプロフィール取得APIのURL
var LINE_PROFILE_URL = 'https://api.line.me/v2/bot/profile';
// LINEユーザ一覧ファイル名
var LINE_USER_LIST_FILENAME = 'LineUserList.json';

/**
 * @summary Postリクエスト処理。
 * LINEボットからPOSTで通知されたタイミングで実行する関数。
 * messageイベントの場合は、イベントに含まれるチャンネル名、ユーザ名、本文をSlackに通知する。
 * followイベントの場合は、ユーザリストにユーザ情報を追加する。
 * leaveイベントの場合は、ユーザリストからユーザ情報を削除する。
 * @param e LINEから通知されるメッセージ
 */
function doPost(e) {

  // 各種情報を取得
  var jsonData = JSON.parse(e.postData.contents);
  var event = jsonData.events[0];
  
  try {
    switch (event.type) {
      case 'message':
        // メッセージ投稿時
        switch (event.message.type) {
          case 'text':
            // ユーザーのメッセージを取得
            // (1)返信用トークン
            var replyToken = event.replyToken;
            // (2)ユーザメッセージ
            var userMessage = event.message.text;
            // (3)ユーザID
            var userId = event.source.userId;
            // (4)タイムスタンプ
            var timestamp = event.timestamp;
            // ユーザIDからユーザ名を取得する。
            var userName = getUserName(userId);
            // Slackにメッセージを送信する。
            pushToSlack(userName, userMessage);
            break;
          default:
            // do nothing
            break;
        }
        break;
      case 'follow':
        // 友だち追加時
        // (1)返信用トークン
        var replyToken = event.replyToken;
        // (2)タイムスタンプ
        var timestamp = event.timestamp;
        // (3)ユーザID
        var userId = event.source.userId;
        // ユーザIDからユーザ名を取得する。
        var userName = getUserName(userId);
        // LINEユーザ一覧ファイルへユーザIDを追加する。
        var addResult = addLineUserId(userId, userName, timestamp);
        if (addResult) {
          // 追加成功時、LINE BOTから返信メッセージを送る。
          reply(LINE_FOLLOW_REPLY_MESSAGE, replyToken);
        }
        break;
      case 'unfollow':
        // ブロック時
        // (1)タイムスタンプ
        var timestamp = event.timestamp;
        // (2)ユーザID
        var userId = event.source.userId;
        // ユーザIDからユーザ名を取得する。
        var userName = getUserName(userId);
        // LINEユーザ一覧ファイルからユーザIDを削除する。
        removeLineUserId(userId);
        break;
      default:
      // do nothing
    }
  } catch (err) {
    console.log(err);
    throw err;
  }
}

/**
 * @name LINEユーザ追加
 * @summary LINEユーザ一覧ファイルにユーザ情報を追加する。
 * @param {string} userId ユーザID
 * @param {string} userName ユーザ名
 * @param {number} timestamp 登録日時(
 * @returns {boolean} 登録したか(true: 登録した、false: 登録しなかった)
 */
function addLineUserId(userId, userName, timestamp) {
  // 変数初期化
  // (1)ユーザ一覧
  var userList = [];
  // (2)保存するjsonデータを読み込み
  var jsonData = readJson(LINE_USER_LIST_FILENAME)
  // (3)新規登録ユーザ情報
  var newUserData = {
    'userId': userId,
    'userName': userName,
    'timestamp': timestamp
  }
  // 引数チェック
  if (userId == '') {
    return false;
  }

  // ユーザ一覧作成
  // (1)既存ユーザ追加
  if (jsonData.list) {
    for (var i in jsonData.list) {
      userList.push(jsonData.list[i]);
      // 新規ユーザと同じユーザIDが既に登録されていた場合は
      // ユーザを登録せずに終了する。
      if (userId == jsonData.list[i].userId) {
        return false;
      }
    }
  }

  // (2)新規ユーザ追加
  userList.push(newUserData);

  // 書き込むデータ生成
  var newJsonData = {
    list: userList
  }

  // jsonデータ書き込み
  writeJson(LINE_USER_LIST_FILENAME, newJsonData);
  return true;
}

/**
 * @name LINEユーザ削除
 * @summary LINEユーザ一覧ファイルからユーザ情報を削除する。
 * @param {string} userId ユーザID
 * @returns {boolean} 削除したか(true: 削除した、false: 削除しなかった)
 */
function removeLineUserId(userId) {

  // 変数初期化
  // (1)ユーザ一覧
  var userList = [];
  // (2)保存するjsonデータを読み込み
  var jsonData = readJson(LINE_USER_LIST_FILENAME)
  // (3)ユーザを削除したか
  var removedFlg = false;

  // ユーザ一覧作成
  if (jsonData.list) {
    for (var i in jsonData.list) {
      // ユーザIDが削除対象と異なる場合のみ、userListに追加
      if (userId != jsonData.list[i].userId) {
        userList.push(jsonData.list[i]);
      } else {
        // 削除対象のユーザIDがユーザ一覧ファイルに存在した場合、
        // 削除フラグをtrueにする。
        removedFlg = true;
      }
    }
  }
  // ループ内で削除が一度も行われなかった場合、
  // false(削除しなかった)をリターンして処理終了。
  if (!removedFlg) {
    return false;
  }

  // 書き込むデータ生成
  var newJsonData = {
    list: userList
  }

  // jsonデータ保存
  writeJson(LINE_USER_LIST_FILENAME, newJsonData);

  return true;
}

/**
 * @name Google Drive内ファイル取得
 * @summary Google Driveのディレクトリパスとファイル名から、ファイルを取得する。
 * 存在しない場合は新しく作成する。
 * @param {string} dirId ディレクトリID
 * @param {string} fileName ファイル名
 * @returns ファイルオブジェクト
 */
function getGoogleDriveFile(dirId, fileName) {
  var dir = DriveApp.getFolderById(dirId);
  var file = dir.getFilesByName(fileName);
  if (file.hasNext()) {
    // 存在する場合
    return file.next();
  } else {
    // 存在しない場合
    return dir.createFile(fileName, '');
  }
}

/**
 * @name jsonファイル書き込み
 * @summary jsonファイルをGoogle Driveに書き込む。
 * @param {string} jsonPath Google Drive内jsonファイルパス
 * @param {object} jsonData jsonデータ
 */
function writeJson(jsonPath, jsonData) {
  var content = JSON.stringify(jsonData);
  var file = getGoogleDriveFile(GOOGLE_DRIVE_DIR_ID, jsonPath);
  file.setContent(content);
}

/**
 * @name jsonファイル読み込み
 * @summary jsonファイルをGoogle Driveから読み込む。
 * jsonファイルを読み込み、パースして返す。
 * jsonファイルがない場合、新しくjsonファイルを作成する。
 * @param {string} jsonPath Google Drive内jsonファイルパス
 * @returns {object} 読み込んだjsonデータ
 */
function readJson(jsonPath) {
  var file = getGoogleDriveFile(GOOGLE_DRIVE_DIR_ID, jsonPath);
  var content = file.getBlob().getDataAsString();
  if (!content) {
    // ファイルの内容が空の場合
    return {};
  } else {
    var json = JSON.parse(content);
    return json;
  }
}

/**
 * @name LINEユーザ名取得
 * @summary LINEユーザIDからLINEユーザ名を取得する。
 * @description LINEユーザIDからLINEユーザ名を取得する。
 * @param {string} userId LINEユーザID
 * @returns {string} LINEユーザ名
 */
function getUserName(userId) {
  try {
    var url = LINE_PROFILE_URL + '/' + userId;
    var response = UrlFetchApp.fetch(url, {
      'headers': {
        'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN
      }
    });
    return JSON.parse(response.getContentText()).displayName;
  } catch (err) {
    // LINE APIのエラー発生時
    console.log(err);
    return '';
  }
}

/**
 * @name Slackにメッセージ通知
 * @summary Slackにメッセージを送る。
 * @param {string} userName Slackに表示されるユーザ名
 * @param {string} text Slackに表示される本文
 * @param {string} channel チャンネル名 先頭の#は不要
 * @param {string} emojiIcon 絵文字アイコン名 例:':chart_with_upwards_trend:'
 * @returns fetchの結果
 */
function pushToSlack(userName, text, channel, emojiIcon) {
  // payload作成
  var payload = {
    'username': 'Lineからの通知 ' + userName,
    'text': text,
  };
  if (channel) {
    payload['channel'] = '#' + channel;
  }
  if (emojiIcon) {
    payload['icon_emoji'] = emojiIcon;
  }

  var options = {
    'method': 'post',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload),
  };
  // メッセージ送信
  var response = UrlFetchApp.fetch(SLACK_URL, options);
  return response;
}

/**
 * @name LINE BOT返信
 * @summary LINE BOTへの投稿に対して、LINE BOTに返信させる。
 * @param {string} text 返信するメッセージ
 * @param {string} replayToken LINEから通知される返信用トークン
 * @returns fetchの結果
 */
function reply(text, replyToken) {

  // ヘッダデータ作成。
  var headers = {
    "Content-Type": "application/json; charset=UTF-8",
    'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN,
  };

  // POSTリクエストデータ作成。
  var postData = {
    "replyToken": replyToken,
    "messages": [
      {
        'type': 'text',
        'text': text,
      }
    ]
  };

  // 送信データを作成。
  var options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };

  var response = UrlFetchApp.fetch(LINE_MESSAGE_REPLY_URL, options);
  return response;
}

Postリクエスト受信と制御

doPost関数は特殊な関数で、GASをウェブアプリケーションとして公開した際に発行されるURLへPostリクエストを発行すると実行されます。

引数eにはLINEのアクション情報がjson形式で格納されています。詳しくはLINE Messaging APIのマニュアルを参照してください。

https://developers.line.biz/ja/reference/messaging-api/

e.postData.contentsをJSON.parseでパースして、イベント情報を取り出します。 全体の構成は以下のようになります。

function doPost(e) {

  // 各種情報を取得
  var jsonData = JSON.parse(e.postData.contents);
  var event = jsonData.events[0];
  
  try {
    switch (event.type) {
      case 'message':
        // メッセージ投稿時
        switch (event.message.type) {
          case 'text':
          ...
          default:
            // do nothing
            break;
        }
        break;
      case 'follow':
        ...
        break;
      case 'unfollow':
        ...
        break;
      default:
      // do nothing
    }
  } ...
}

switch caseで、event.typeの内容により場合分けをします。

# event.type LINEでの操作 GASで実装する処理
1 message BOTにメッセージ投稿 Slack Incomming Webhook に対してメッセージ送信
2 follow BOTを友だち追加/ブロック解除 LINEユーザ一覧ファイルにユーザ情報を追加
3 unfollow BOTをブロック LINEユーザ一覧ファイルからユーザ情報を削除

Slackへ通知

pushToSlack関数で実装してあります。 引数のchannelとemojiIconは設定できるので一応つけてますが、今回は使用していません。

function pushToSlack(userName, text, channel, emojiIcon) {
  // payload作成
  var payload = {
    'username': 'Lineからの通知 ' + userName,
    'text': text,
  };
  if (channel) {
    payload['channel'] = '#' + channel;
  }
  if (emojiIcon) {
    payload['icon_emoji'] = emojiIcon;
  }

  var options = {
    'method': 'post',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload),
  };
  // メッセージ送信
  var response = UrlFetchApp.fetch(SLACK_URL, options);
  return response;
}

定数SLACK_URLには、Slack Incomming Webhookを作成した際のURLを設定しておいてください。 主に実施していることはoptionsを作ることです。 methodcontentTypeは固定です。 payloadにはJson形式でSlackに投稿するメッセージを設定します。usernametextがあればいいと思います。 仕様はSlackのSlack Incomming Webhookの設定画面に記載されています。

LINEユーザ一覧ファイルにユーザ追加

主にaddLineUserId関数で実装しています。

LINEユーザ一覧ファイル(LineUserList.json)は以下のようなフォーマットにしました。 LINEユーザIDの一覧さえあればどのようなフォーマットでもいいです。

{
  "list":[
    {
      "userId":"Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "userName":"太郎",
      "timestamp":1561459938495
    },
    {
      "userId":"Uyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
      "userName":"次郎",
      "timestamp":1561460491870
    }
  ]
}

addLineUserId関数でユーザ情報を追加します。 1. readJson関数でLINEユーザ一覧ファイルから既存ユーザ情報(jsonData)を読み込み 1. 既存ユーザ情報(jsonData)と新規登録ユーザ情報(newUserData)を加えた新ユーザ情報(newJsonData)を作成 1. writeJson関数で新ユーザ情報(newJsonData)をLINEユーザ一覧ファイルに書き込み

function addLineUserId(userId, userName, timestamp) {
  // 変数初期化
  // (1)ユーザ一覧
  var userList = [];
  // (2)保存するjsonデータを読み込み
  var jsonData = readJson(LINE_USER_LIST_FILENAME)
  // (3)新規登録ユーザ情報
  var newUserData = {
    'userId': userId,
    'userName': userName,
    'timestamp': timestamp
  }
  // 引数チェック
  if (userId == '') {
    return false;
  }

  // ユーザ一覧作成
  // (1)既存ユーザ追加
  if (jsonData.list) {
    for (var i in jsonData.list) {
      userList.push(jsonData.list[i]);
      // 新規ユーザと同じユーザIDが既に登録されていた場合は
      // ユーザを登録せずに終了する。
      if (userId == jsonData.list[i].userId) {
        return false;
      }
    }
  }

  // (2)新規ユーザ追加
  userList.push(newUserData);

  // 書き込むデータ生成
  var newJsonData = {
    list: userList
  }

  // jsonデータ書き込み
  writeJson(LINE_USER_LIST_FILENAME, newJsonData);
  return true;
}

LINEユーザ一覧ファイルからユーザ削除

主にremoveLineUserId関数で実装しています。 addLineUserIdとほぼ同じ処理になります。

ユーザ一覧作成時に、引数のuserIdと同一のIDが存在した場合は追加処理をスキップします。

function removeLineUserId(userId) {

  // 変数初期化
  // (1)ユーザ一覧
  var userList = [];
  // (2)保存するjsonデータを読み込み
  var jsonData = readJson(LINE_USER_LIST_FILENAME)
  // (3)ユーザを削除したか
  var removedFlg = false;

  // ユーザ一覧作成
  if (jsonData.list) {
    for (var i in jsonData.list) {
      // ユーザIDが削除対象と異なる場合のみ、userListに追加
      if (userId != jsonData.list[i].userId) {
        userList.push(jsonData.list[i]);
      } else {
        // 削除対象のユーザIDがユーザ一覧ファイルに存在した場合、
        // 削除フラグをtrueにする。
        removedFlg = true;
      }
    }
  }
  // ループ内で削除が一度も行われなかった場合、
  // false(削除しなかった)をリターンして処理終了。
  if (!removedFlg) {
    return false;
  }

  // 書き込むデータ生成
  var newJsonData = {
    list: userList
  }

  // jsonデータ保存
  writeJson(LINE_USER_LIST_FILENAME, newJsonData);

  return true;
}

確認

先にLINEの設定をしておきます。

メニューバーから「公開」「ウェブアプリケーションとして導入」を選択し、ウェブアプリケーションとして公開します。

LINE Developersの設定を開き、「メッセージ送受信設定/Webhook URL」にウェブアプリケーションのURLを設定しておきます。

あとはLINE BOTをフォローして、メッセージを通知してSlackに表示されればOKです。

手こずったこと

  • GAS(javascript)ではswitch文でcaseのたびにbreakが必要。慣れてる人にとっては普通でしょうが、最近のプログラミング言語では珍しくなりましたね。ハマりました。
  • ログが確認しづらくデバッグに手間取りました。console.log()で画面から確認できるものの、画面に反映されるまでに時間がかかる…?ような気がします。
  • デバッグ作業でSlackに投稿されてしまいました(すぐ消しました)。Slackにテスト用チャンネルを用意して、SLACK_URLを切り替えることで対処しました。

まだ続きます