1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

LINEに画像を送るとTeamsに転送するチャットボットを作る

Last updated at Posted at 2023-06-01

何を作ったの?

LINEに画像やメッセージを送るとTeamsに転送してくれるチャットボットを作りました。

  • LINEにメッセージを送ると...
    image.png

  • Teamsに転送されます
    image.png

アーキテクチャ

ざっくりの図ですが、こんな感じです
image.png
ユーザがLINEのチャットボットにメッセージを送ると
Lambda関数が呼び出されるようになっています

メッセージが画像の場合には、画像データをS3に保存しておきます

その後、Teamsの受信Webhookを呼んでTeamsにメッセージを送る仕組みです

作成手順

1. LINE BOTの準備

LINE BOT (LINEのチャットボット) を準備します
以下のQiitaの記事が大変わかりやすくまとまっていて、いつも参照させてもらっています

ざっくり手順を書いておくと...

  1. LINE Developer Console にログイン
  2. プロバイダーを作成する
  3. チャネルを作成する --> Messaging API --> アイコンなど各種情報設定 --> 作成
  4. Messaging API設定でチャネルアクセストークンを発行する (メモしておきます)

【注意】

  • Webhookの利用Webhookの再送ON にしてください
  • 応答メッセージ無効 にしてください
    image.png

2. Teamsの受信Webhookの準備

以下の手順にしたがって、Teamsの受信Webhook (Incoming Webhook)を発行してください
受信Webhookとは、Teamsにメッセージを送りたいときに、宛先として利用するURL です
後で書くLambdaのコード中に埋め込んで(環境変数を利用)、
メッセージを送る際のHTTP呼び出しの宛先に利用しています

■ Microsoft Learn
受信 Webhook を作成する

ざっくりの手順を書いておくと...

  1. Webhookを作成したいチャネルを選択し、[••] を選択
  2. コネクタ > Incoming Webhook (受信Webhook) > 追加 > 構成
    image.png
    image.png
  3. 名前、アイコン(任意)を決め 「作成」
    image.png
  4. URLが発行されるのでメモしておく

3. S3 と IAMユーザ の作成

3.1. S3の作成

画像を保存するためのS3バケットを作ります
作り方は、以下の記事の 手順 1. AWS S3 のバケットを作る手順2. 公開設定をする を参照してください

セキュリティ的にかなりオープンですので、気になる方は適切な設定への変更をお願いします

あとで利用するので、バケット名とリージョンをメモしておいてください
image.png

3.2. 上記S3にアクセスするためのIAMユーザを作成する

  1. IAMユーザの作成
    image.png
  2. 許可を設定では、「ポリシーを直接アタッチする」から AmazonS3FullAccess を付与
    image.png
  3. ユーザの作成
    image.png
  4. ユーザリストから作成したユーザをクリックし、「セキュリティ認証情報」> 「アクセスキー」> 「アクセスキーを作成」
    image.png
  5. いくつかの確認ののちアクセスキーが作成される。アクセスキーとシークレットアクセスキーをコピーしておく
    image.png

4. Lambdaの作成

4.1. Lambda関数の準備

LINE BOTから呼び出されるLamba関数を用意します
LINE BOTのWebHookに登録するために、関数URLを有効化します

  1. Lambda --> 関数の作成 (ランタイムは Node.js 18.x を選びます)
    image.png
  2. 詳細設定 > 関数URLを有効化 にチェックを入れ、認証タイプを NONE にする
    image.png
  3. 詳細設定 > オリジン間リソース共有(CORS)にチェックを入れる
    image.png
  4. 関数を作成
    image.png

4.2. Layerの準備

Labmdaで必要なライブラリ群をCloud9(Linux)環境でパッケージし、LambdaにLayerとして登録します

4.2.1 Cloud9環境でZIPを作る

Lambdaで、LINE BOT SDK, AWS SDK, node fetch を利用するので
それらをインストールしたパッケージを作ります
Cloud9と書きましたが、npmとzipが動けばどんな環境でも大丈夫です

$ cd ~
$ cd linebot/nodejs
$ npm install --save @line/bot-sdk node-fetch aws-sdk
$ cd ..
$ zip -r nodejs.zip nodejs/

4.2.2 zipをダウンロードしてAWSのレイヤを作成

  1. Cloud9のファイルツリーから nodejs.zip を右クリックして download
  2. AWS Lambda > レイヤー > レイヤーの作成
    image.png
    image.png
  3. nodejs.zipをアップロード
    image.png
  4. ARNをコピー
  5. Lambda関数にレイヤーを追加 > ARNを指定 > (ARN入力) > 追加
    image.png
    image.png

4.3. 環境変数の準備

これまで準備してきた各リソースの情報を、Labmdaの環境変数に設定します

環境変数名 取得元 手順番号 これはなんですか?
ACCESS_KEY_ID AWS (IAM) 3.2 S3にアクセスするためのIAMユーザのアクセスキー
SECRET_ACCESS_KEY AWS (IAM) 3.2 S3にアクセスするためのIAMユーザのシークレット
BUCKET_NAME AWS (S3) 3.1 S3のバケット名
BUCKET_REGION AWS (S3) 3.1 S3のリージョン
BUCKET_ARN AWS (S3) 3.1 S3のARN (使ってない)
LINE_TOKEN LINE 1 LINEのチャネルアクセストークン
TEAMS_WEBHOOK Teams 2 Teamsの送信WebhookのURL

設定場所は Lambda > 関数 > 作った関数名 の 設定タブ > 環境変数 です
image.png

4.4. コーディング

以下のコードをLambdaに入力します

'use strict';

import line from "@line/bot-sdk";
import fetch from "node-fetch";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";

// [Function] Image file handling
// - get from LINE DATA URL
// - put it on S3
// - return : none
async function saveImageToS3(s3, event) {
  // just in case, check message type
  // - if not image, just ignore
  if (event.message.type !== "image") {
    return;
  }
  
  // load image file from LINE and upload it to S3
  // - get image by LINE URL = https://api-data.line.me/v2/bot/message/${message_id}/content
  // - put on S3 by s3.send and PutObjectCommand
  const message_id = event.message.id;
  //console.log(message_id);
  const res = await fetch(`https://api-data.line.me/v2/bot/message/${message_id}/content`, {
    headers: {
      'Authorization': ` Bearer ${process.env.LINE_TOKEN}`
    }
  });
  if (!res.ok) {
    throw new Error(`Failed to get message content: ${res.status} ${res.statusText}`);
  }
  const buffer = await res.buffer();

  // save to S3
  await s3.send(
    new PutObjectCommand({
      Body: buffer,
      Bucket: process.env.BUCKET_NAME,
      Key: 'TeamsConnector/' + message_id + '.jpg'
    })
  );
}

// [Function] Handle event conveying an image
// - return 
//     0  ... not send message to Teams yet
//     0< ... number of images sent to Teams
//    -1  ... error (duplicated item detected)
async function handleImage(s3, event) {
  const message_id = event.message.id;
  const imageSet = 
    event.message.imageSet !== undefined ?
    event.message.imageSet :
    { 
      id: "dummy" + message_id,
      index: 1,
      total: 1,
    };
  imageSet.message_id = message_id;
  //console.log(imageSet);  
  
  const id   = imageSet.id;
  const path = `tmp/${id}.json`;

  var data;

  // try to read the data from S3
  // # Thanks for https://tmokmss.hatenablog.com/entry/20221207/1670377667
  try {
    const s3object = await s3.send(
      new GetObjectCommand({
        Bucket: process.env.BUCKET_NAME,
        Key: 'TeamsConnector/' + path
      })
    );
    const str = await s3object.Body?.transformToString();
    data = JSON.parse(str);
  } catch (e) {
    console.log("faild : GetObjectCommand : error.code = " + e.code);
    data = {
      files: []
    };
  } 
  //console.log(data);

  // append image to data (if it is new)
  const duplicate = data.files.find(f => f.message_id === message_id);
  if (undefined != duplicate) {
    console.log("***** duplicate item is detected. *****");
    console.log(duplicate);
    return -1;
  }
  data.files.push(imageSet);
  console.log(data);
    
  // seve updated json to S3
  await s3.send(
    new PutObjectCommand({
      Body: JSON.stringify(data),
      Bucket: process.env.BUCKET_NAME,
      Key: 'TeamsConnector/' + path
    })
  );

  // last image reception
  if (imageSet.total == data.files.length) {
    console.log("*** last image of multiple reception ***");

    // message to teams (template)
    var msg2teams = {
      type: "message",
      attachments: [
        {
          contentType: "application/vnd.microsoft.card.adaptive",
          contentUrl: null,
          content: {
            type: "AdaptiveCard",
            $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
            version: "1.2",
            body: [],
          }
        }
      ]
    };

/* Message Card Type (not used now)
    var msg2teams_type2 = {
      "@type": "MessageCard",
      "@context": "http://schema.org/extensions",
      "themeColor": "0078D7",
      "summary": "This is a summary",
      "sections": [
        {
          "activityTitle": "Image from webhook",
          "activitySubtitle": "This is a subtitle",
          "activityImage": image_url,
          "markdown": true,
          "text": "![image](" + image_url + ")",
        },
      ]
    };
*/

    data.files.sort((a, b) => { a.index < b.index });
    data.files.forEach(i => {
      console.log(i);
      const image_url = `https://${process.env.BUCKET_NAME}.s3.${process.env.BUCKET_REGION}.amazonaws.com/TeamsConnector/${i.message_id}.jpg`;
      //console.log(image_url);
      //console.log(msg2teams);
      msg2teams.attachments[0].content.body.push({
        type: "Image",
        url: image_url,
        horizontalAlignment: "left",
        size: "Large",
        msTeams: {
          allowExpand: true
        }
      });
    });
    //console.log(msg2teams);
    
    // send message to Teams
    fetch(process.env.TEAMS_WEBHOOK, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(msg2teams)
    });

    return data.files.length;
  } // end of "if (last_image) ..."

  return 0;
}

// [Function] handler
// - main function (entry point) of this Labmda
export const handler = async(event) => {
  /*
  console.log(process.env.BUCKET_NAME);
  console.log(process.env.BUCKET_REGION);
  console.log(process.env.ACCESS_KEY_ID);
  console.log(process.env.SECRET_ACCESS_KEY);
  console.log(process.env.LINE_TOKEN);
  console.log(process.env.TEAMS_WEBHOOK);
  */
  
  const client = new line.Client({
    channelAccessToken: process.env.LINE_TOKEN,
  });

  const reqBody = JSON.parse(event.body);
  console.log(reqBody);
  reqBody.events.forEach(e => {
    console.log(e);  
  });
  
  let message = {};

  // text message handling
  if (reqBody.events[0].message.type == "text") {
    if (reqBody.events[0].deliveryContext.isRedelivery) {
      // reply message for LINE
      message = {
        type: "text",
        text: "このメッセージが自動再送されましたが、無視します : " + reqBody.events[0].message.text,
      };
    } 
    else {
      // Send to Teams
      fetch(process.env.TEAMS_WEBHOOK, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          'text': reqBody.events[0].message.text
        })
      });
  
      // reply message for LINE
      message = {
        type: "text",
        text: reqBody.events[0].message.text,
      };
    }
  }

  // image message handling
  else if (reqBody.events[0].message.type == "image") {
    // reply message for LINE (default)
    message = {
      type: "text",
      text: "画像を追加しました"
    };

    // S3 client
    const s3 = new S3Client({
      region: process.env.BUCKET_REGION,
      credentials: {
        accessKeyId: process.env.ACCESS_KEY_ID,
        secretAccessKey: process.env.SECRET_ACCESS_KEY
      },
    });

    var ret = 0;
    for (let i = 0; i < reqBody.events.length; ++i) {
      // save image on S3
      await saveImageToS3(s3, reqBody.events[i]);
      
      // handle image
      ret = await handleImage(s3, reqBody.events[i]);
    }
    if (0 < ret) {
      message = {
        type: "text",
        text: "画像を " + ret + " 枚、Teamsに転送しました"
      };
    } else if (-1 == ret) {
      message = {
        type: "text",
        text: "重複アイテムを検知しました。LINEの仕様なので無視してください"
      };
    }
  } 

  // other message handling
  else {
    // reply message for LINE
    message = {
      type: "text",
      text: "テキストを入力するか、画像を送ってください"
    };
  }

  // reply message to LINE
  const toToken = reqBody.events[0].source.userId;
  await client.pushMessage(toToken, message).catch((err) => {
    console.error(err);
  });

  // response of Lambda  
  const response = {
      statusCode: 200,
      body: JSON.stringify(message),
  };
  return response;
};

5. LINEのWebHookにLabmd関数URLを設定

5.1. Lambdaから関数URLをコピー

  1. AWSのマネジメントコンソールで作成したLambda関数を開きます
  2. 関数の概要のページに「関数URL」があるのでコピーします

5.2. LINEのWehHookに設定

以下を参照して、LINE BOTのWebHookにLambdaの関数URLを設定します

■ LINE Developers
WebhookエンドポイントのURLを設定する

以上です

苦労した点

TeamsのJSON記法

ドキュメントや実例が少なくて苦労しました
特に画像の添付方法
コード中に試行錯誤の跡がありますのでご覧ください

LINEの画像送信時のWebhook呼び出し

LINEから複数の画像を送信した際のWebhook呼び出しの挙動がバリエーション豊富で対応に苦労しました
例えば、3枚の画像を送信した場合は、以下のバリエーションがありえます

  1. 3枚の画像が一回のWebhook呼び出しで送られてくる
  2. 1枚の画像、2枚の画像と二回のWebhook呼び出しに分割されて送られてくる
  3. 1枚づつ、三回のWebhook呼び出しで送られてくる

また、送られてくる写真の順番も順不同で(indexが小さいほうから順に来るわけではない)
この対応にも苦労しました

この記事がLINE BOTで画像を扱う人の参考になれば幸いです

EOF

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?