Google Apps Script(GAS)を用いてLINE Botが受信した情報をSlackに送る

どうもオーカワ(@okawa_compass)です。以前LINE Botに関するいくつか記事を書かせて頂きました。

その中の記事の1つです。何故かLINEの記事は毎日結構アクセスがありますね。記事に関係ない質問がよくTwitterに来るのが悩みですが……

LINE Botで使うMessaging APIが知らぬまに大幅に更新されていて、かなり出来る事が増えていたので一通り試してみた次第です。

Messaging APIはかなりの頻度で更新されているので、この記事が古くなるのも時間の問題かもしれないですね(いやだ)。

GASの説明やBotの作り方は面倒でここでは説明しないので、詳しい説明は上記で貼っているリンクを参照してくださーい。

コード

こちらが全体のコードになります。


var CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('CHANNEL_ACCESS_TOKEN');
var SLACK_LEGACY_TOKEN = PropertiesService.getScriptProperties().getProperty('SLACK_LEGACY_TOKEN');

function doPost(e) {
  var line = JSON.parse(e.postData.contents).events[0];
  switch (line.type) {
    case 'follow':
      var profile = get_line_profile(line);
      postSlackMessage('```こいつにフォローされました。\nステータスメッセージ: ' + profile.statusMessage + '```', profile);
      break;
    case 'unfollow':
      var profile = get_line_profile(line);
      postSlackMessage('```こいつにブロックされました。\nステータスメッセージ: ' + profile.statusMessage + '```', profile);
      break;
    case 'join':
      postSlackMessage('Join group: ' + line.source.groupId);
      break;
    case 'leave':
      postSlackMessage('Leave group: ' + line.source.groupId);
      break;
    case 'message':
      var profile = get_line_profile(line);
      switch (line.source.type) {
        case 'user':
          postSlackMessage(get_line_message(line), profile);
          break;
        case 'group':
          postSlackMessage(get_line_message(line), profile);
          break;
        case 'room':
          postSlackMessage(get_line_message(line), profile);
          break;
      }
      break;
    default:
      postSlackMessage(line);
      break;
  }
}

function get_line_message(line) {
  switch (line.message.type) {
    case 'text':
      return line.message.text;
    case 'image':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      postSlackFiles(blob.getAs('image/png').setName('line.png'));
      return file.getUrl();
    case 'video':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'audio':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'file':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'location':
      return '位置情報が送られた。';
    case 'sticker':
      return 'https://stickershop.line-scdn.net/stickershop/v1/sticker/' + line.message.stickerId + '/android/sticker.png';
    default:
      return 0;
  }
}

// Get content of LINE
function get_line_content(message_id) {
  var headers = {
    'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN
  };
  var options = {
    'headers': headers
  };
  var url = 'https://api.line.me/v2/bot/message/' + message_id + '/content';
  var blob = UrlFetchApp.fetch(url, options);
  var imageBlob = blob.getAs('image/png').setName('chart_image.png');
  return blob;
}

// Get profile of LINE
function get_line_profile(line) {
  var headers = {
    'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN
  };
  var options = {
    'headers': headers
  };
  var url;
  switch (line.source.type) {
    case 'user':
      url = 'https://api.line.me/v2/bot/profile/' + line.source.userId;
      break;
    case 'group':
      url = 'https://api.line.me/v2/bot/group/' + line.source.groupId + '/member/' + line.source.userId;
      break;
    case 'room':
      url = 'https://api.line.me/v2/bot/room/' + line.source.groupId + '/member/' + line.source.userId;
      break;
  }
  var response = UrlFetchApp.fetch(url, options);
  var content = JSON.parse(response.getContentText());
  return content; 
}

// Post Slack message.
function postSlackMessage(mes, profile) {
  var slackApp = SlackApp.create(SLACK_LEGACY_TOKEN);
  if (profile != undefined) {
    var options = {
      username: profile.displayName,
      icon_url: profile.pictureUrl
    };
  }
  else {
    var options = {
      username: 'LINE_BOT',
    };
  }
  
  slackApp.postMessage('#random', mes, options);  
}

// Post Slack Files.
function postSlackFiles(image) {
  var slackApp = SlackApp.create(SLACK_LEGACY_TOKEN);
  var options = {
    channels: '#random',
  };
  slackApp.filesUpload(image, options);
}

設定

使うには少々設定が必要です。

スクリプトのプロパティを追加

GASで「ファイル」 → 「プロジェクトのプロパティ」 → 「スクリプトのプロパティ」

  • SLACK_LEGACY_TOKEN
  • CHANNEL_ACCESS_TOKEN
    • LINE Botが情報を取得する為に使うトークン
    • LINE Messaging APIのChannel Access Token

ウォーレン・クロマティ

ライブラリの追加

Slackに送信する為のコードを綺麗にする為に必要です。なくても作れますが、今回紹介しているコードでは必要になります。

GASで「リソース」 → 「ライブラリ」

  • ライブラリキー
    • Library Key: M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO
    • Ver: 22

わからない人は以下のリンクを参考にして下さい。Slack App 開発者様のブログです。

公開設定

公開設定をすることによってプログラムが動きます。

「公開」 → 「ウェブアプリケーションとして導入」

  • プロジェクトバージョン
    • 新規作成
  • 次のユーザーとしてアプリケーションを実行
    • 自分
  • アプリケーションにアクセスできるユーザー
    • 全員(匿名ユーザーを含む)

ちなみに、コードを更新した際はプロジェクトバージョンをあげる必要があります(一番最初は必要ありません)。

解説(Slack側)

Slackに関係するプログラムを解説していきます。

メッセージ送信

メッセージを送信する関数はこちら。引数でメッセージ(必須), プロフィール(オプション)を指定します。

引数を2つ指定するとユーザー名, アイコン画像が指定出来ます。

引数が1つだとユーザー名, アイコン画像は指定出来ません。

Slackに投稿するチャンネルは #random 固定です。適宜変更してください。


// Post Slack message.
function postSlackMessage(mes, profile) {
  var slackApp = SlackApp.create(SLACK_LEGACY_TOKEN);
  if (profile != undefined) {
    var options = {
      username: profile.displayName,
      icon_url: profile.pictureUrl
    };
  }
  else {
    var options = {
      username: 'LINE_BOT',
    };
  }
  slackApp.postMessage('#random', mes, options);  
}

引数2つの例

引数1つ

ファイル送信

ファイルを送信する関数はこちら。

ファイルの送信はユーザー名, アイコン画像が指定出来ません。気になる人はママに聞くか、Slack APIの公式のリファレンスを読んでください。

こちらもSlackに投稿するチャンネルは #random 固定です。適宜変更してください。


// Post Slack Files.
function postSlackFiles(image) {
  var slackApp = SlackApp.create(SLACK_LEGACY_TOKEN);
  var options = {
    channels: '#random',
  };
  slackApp.filesUpload(image, options);
}

解説(LINE側)

LINE公式Messaging APIのリファレンスに沿って解説をしていきたいと思います。

プロフィールの取得(関数)

関数が呼び出されたらプロフィール情報を返します。

今回のプログラムだと事あるごとにプロフィールの取得をしています。GASは容量制限があるので、無駄&過剰な通信は避けたい所です。

プロフィールの取得は個人的に週1くらいでいいと思っております。今回のプログラムでは実装していません。本格的に使う方はプロフィールはキャッシュするべきだと思います。

僕は1日に1回か2回ほどしかLINEが来ないので、今のままで運用しています。作るのが面倒だった


// Get profile of LINE
function get_line_profile(line) {
  var headers = {
    'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN
  };
  var options = {
    'headers': headers
  };
  var url;
  switch (line.source.type) {
    case 'user':
      url = 'https://api.line.me/v2/bot/profile/' + line.source.userId;
      break;
    case 'group':
      url = 'https://api.line.me/v2/bot/group/' + line.source.groupId + '/member/' + line.source.userId;
      break;
    case 'room':
      url = 'https://api.line.me/v2/bot/room/' + line.source.groupId + '/member/' + line.source.userId;
      break;
  }
  var response = UrlFetchApp.fetch(url, options);
  var content = JSON.parse(response.getContentText());
  return content; 
}

 

フォローイベント

アカウントが友だち追加またはブロック解除されたことを示すイベントです。フォローイベントには応答できます。
フォローイベントより引用

フォローされたら適当な文章とステータスメッセージをSlackに送ります。ステータスメッセージはLINEの一言です。


    case 'follow':
      var profile = get_line_profile(line);
      postSlackMessage('```こいつにフォローされました。\nステータスメッセージ: ' + profile.statusMessage + '```', profile);
      break;

フォローイベント

フォロー解除イベント

アカウントがブロックされたことを示すイベントです。
フォロー解除イベントより引用

ブロックされたら適当な文章とステータスメッセージをSlackに送ります。ステータスメッセージはLINEの一言です。


    case 'unfollow':
      var profile = get_line_profile(line);
      postSlackMessage('```こいつにブロックされました。\nステータスメッセージ: ' + profile.statusMessage + '```', profile);
      break;

フォロー解除イベント

追記: 2017/12/7

アカウントによってエラーが起こるようです。ディベロッパーのアカウントだとエラーは起きません。

問題はprofile取得で、ブロックされているユーザーのprofile取得が出来ません。出来ないのが正解な気もするので、ディベロッパーで出来ること自体がエラーではないかと思います。現在確認中です。

参加イベント

アカウントがグループまたはトークルームに参加したことを示すイベントです。参加イベントには応答できます。
参加イベントより引用

グループまたはトークルームに参加したら適当な文章とグループIDをSlackに送ります。


    case 'join':
      postSlackMessage('Join group: ' + line.source.groupId);
      break;

参加イベント

退出イベント

アカウントがグループから退出させられたことを示すイベントです。
フォロー解除イベントより引用

グループまたはトークルームに退出させられたら適当な文章とグループIDをSlackに送ります。


    case 'leave':
      postSlackMessage('Leave group: ' + line.source.groupId);
      break;

退出イベント

メッセージイベント

送信されたメッセージを含むイベントオブジェクトです。 メッセージのタイプに対応するメッセージオブジェクトが、messageフィールドに含まれます。メッセージイベントには応答できます。
メッセージイベントより引用

送信元(どこからメッセージが来たか)を判別します。ユーザー, グループ, トークルームと分ける事が出来ます。

今回のプログラムでは送信元が違う場合でも同じ動作をします(作るのが面倒だった)。

送信元で動作を変えたい方はこの辺りを書き換えるといいと思います。


    case 'message':
      var profile = get_line_profile(line);
      switch (line.source.type) {
        case 'user':
          postSlackMessage(get_line_message(line), profile);
          break;
        case 'group':
          postSlackMessage(get_line_message(line), profile);
          break;
        case 'room':
          postSlackMessage(get_line_message(line), profile);
          break;
      }
      break;

メッセージタイプ

以下の関数でメッセージタイプの動作をわけます。メッセージタイプはテキスト, 画像, 動画, 音声, ファイル, 位置情報, スタンプがあります。


function get_line_message(line) {
  switch (line.message.type) {
    case 'text':
      return line.message.text;
    case 'image':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      postSlackFiles(blob.getAs('image/png').setName('line.png'));
      return file.getUrl();
    case 'video':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'audio':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'file':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();
    case 'location':
      return '位置情報が送られた。';
    case 'sticker':
      return 'https://stickershop.line-scdn.net/stickershop/v1/sticker/' + line.message.stickerId + '/android/sticker.png';
    default:
      return 0;
  }
}

テキストメッセージ

送信元から送られたテキストを含むメッセージオブジェクトです。
テキストメッセージより引用

テキストメッセージが送られたら、そのままSlackに送ります。

スタンプとメッセージが混ざっている場合(あのカラフルな文字)でも動作します。知らない間に対応出来るようになってました(すごい)。

絵文字も対応していますが、Slackで対応している絵文字がない場合はどうなるかわかりません(全てを検証していないので)。


    case 'text':
      return line.message.text;

LINE側

Slack側

画像メッセージ

送信元から送られた画像を含むメッセージオブジェクトです。バイナリの画像データはcontentエンドポイントから取得できます。
画像メッセージより引用

画像メッセージが送られたら、画像をpng型に変換してSlackに送ります。

その他に、Googleドライブのホームディレクトリに画像を保存して、そのURLをSlackに送ります。


    case 'image':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      postSlackFiles(blob.getAs('image/png').setName('line.png'));
      return file.getUrl();

LINE側

Slack側

なんでアイコンが2つなんだろう」と思った君は、Slack側の解説をもう一度よく見てみよう。

動画メッセージ

送信元から送られた動画を含むメッセージオブジェクトです。バイナリの動画データはcontentエンドポイントから取得できます。
動画メッセージより引用

動画メッセージが送られてきたらGoogleドライブのホームディレクトリに保存して、そのURLをSlackに送ります。


    case 'video':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();

音声メッセージ

送信元から送られた音声を含むメッセージオブジェクトです。バイナリの音声データはcontentエンドポイントから取得できます。
音声メッセージより引用

音声メッセージが送られてきたらGoogleドライブのホームディレクトリに保存して、そのURLをSlackに送ります。


    case 'audio':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();

ファイルメッセージ

送信元から送られたファイルを含むメッセージオブジェクトです。バイナリデータはcontentエンドポイントから取得できます。
ファイルメッセージより引用

ファイルメッセージが送られてきたらGoogleドライブのホームディレクトリに保存して、そのURLをSlackに送ります。


    case 'file':
      var blob = get_line_content(line.message.id);
      var file = DriveApp.createFile(blob);
      return file.getUrl();

位置情報メッセージ

送信元から送られた位置情報データを含むメッセージオブジェクトです。
位置情報メッセージより引用

位置情報が送られてきたら、適当な文章をSlackに送ります。

位置情報メッセージからはタイトルや住所, 緯度, 経度が取得できますが、使い所がわからなかったので、適当な文章を返しています。


    case 'location':
      return '位置情報が送られた。';

スタンプメッセージ

送信元から送られたスタンプデータを含むメッセージオブジェクトです。 LINEの基本的なスタンプとスタンプIDについては、スタンプリストを参照してください。
スタンプメッセージより引用

スタンプメッセージが送られてきたら、スタンプのURLをSlackに送ります。

全部のスタンプを試していないので、全てが動くとは限りません。


    case 'sticker':
      return 'https://stickershop.line-scdn.net/stickershop/v1/sticker/' + line.message.stickerId + '/android/sticker.png';

LINE側

Slack側

感想

GASで実装してみましたが、Herokuとかの方がもうちょいスッキリ書ける気がします。

GASは常に容量制限の恐怖があるので、GASで書かなくてもいいかなと。今回GASで実装するメリットとしては、Googleドライブにファイルを保存出来る所位ですかね?

昔と違って、WebhookURLに自己証明書が使えますから、GASで実装するメリットはかなり減ったと思います。じゃあなんでGASで実装したんだよ。

次回予告?

今回は受信するだけだったので、Slackから送信する方法も書こうと思っております。コードはある程度書けているので、気が向いたら書きます(この記事書くだけで疲れたもぉぉぉん)。

この記事がはてぶ新着とか、沢山拡散されたら気が向くと思いますので、気になる人は是非拡散お願いします。

書きました。