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

# 何を作ったの？

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

* LINEにメッセージを送ると...
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/76ec730c-5a93-1499-62ad-c3498530f1ea.png)

* Teamsに転送されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/a8e427ac-df25-caef-afc7-37bced47b208.png)

# アーキテクチャ

ざっくりの図ですが、こんな感じです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/1caff913-9d0d-5220-15cf-a4d117e1b756.png)
ユーザがLINEのチャットボットにメッセージを送ると
Lambda関数が呼び出されるようになっています

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

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

# 作成手順

## 1. LINE BOTの準備

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

https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d

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

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

【注意】
* **Webhookの利用** と **Webhookの再送** を **ON** にしてください
* **応答メッセージ** は **無効** にしてください
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/5427672a-8e8f-b801-9c45-93920415bf1f.png)

## 2. Teamsの受信Webhookの準備

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

■ Microsoft Learn
[受信 Webhook を作成する](https://learn.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook?tabs=dotnet)

ざっくりの手順を書いておくと...
1. Webhookを作成したいチャネルを選択し、[••] を選択
1. コネクタ > Incoming Webhook (受信Webhook) > 追加 > 構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/c8821589-d654-4aeb-1027-1bf862ebe4f0.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/e9d05b4a-305e-4ed1-0525-7978a253ab38.png)
1. 名前、アイコン(任意)を決め 「作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/f24999ed-4444-e788-3963-9707849d9d48.png)
1. URLが発行されるのでメモしておく

## 3. S3 と IAMユーザ の作成

### 3.1. S3の作成

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

https://qiita.com/Tomonobu3110/items/edbad4d1f0704b8f2b48

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

あとで利用するので、バケット名とリージョンをメモしておいてください
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/5ff60024-9c57-fc0e-b7be-57c4493f2026.png)

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

1. IAMユーザの作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/07db803a-1054-b10b-93cd-e07f8929fec0.png)
1. 許可を設定では、「ポリシーを直接アタッチする」から ```AmazonS3FullAccess``` を付与
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/15d5f71a-87b8-6443-337c-266e8deeed95.png)
1. ユーザの作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/c63f2771-5890-65a4-83ca-fb59fe90240c.png)
1. ユーザリストから作成したユーザをクリックし、「セキュリティ認証情報」> 「アクセスキー」> 「アクセスキーを作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/7d84885a-1aaf-165b-3a55-ce525ad0280b.png)
1. いくつかの確認ののちアクセスキーが作成される。アクセスキーとシークレットアクセスキーをコピーしておく
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/06a774b6-7e25-e16d-38ba-ce4aeb138856.png)

## 4. Lambdaの作成

### 4.1. Lambda関数の準備

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

1) Lambda --> 関数の作成 (ランタイムは ```Node.js 18.x``` を選びます)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/3ac970d4-3a19-e399-3dd7-7bde900243a1.png)
2) 詳細設定 > 関数URLを有効化 にチェックを入れ、認証タイプを NONE にする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/359568aa-e5e3-0b1c-be5f-43273a27291a.png)
3) 詳細設定 > オリジン間リソース共有(CORS)にチェックを入れる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/5b9ed108-beaf-292c-100d-b19ce8ce40b9.png)
4) 関数を作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/79c6c81f-3f48-3089-b72d-edbf12319bd9.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が動けばどんな環境でも大丈夫です

```shell
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/04a2825c-1411-3252-cdf2-1f4d12dc9e78.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/f10ea73a-3baa-109a-246b-3737d5346b8f.png)
3) nodejs.zipをアップロード
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/264a29cd-fcf4-e5d0-61e6-27f1da4a5785.png)
4) ARNをコピー
5) Lambda関数にレイヤーを追加 > ARNを指定 > (ARN入力) > 追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/bbe18712-6580-51dd-34b5-b190f1dd71e3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/af457fa2-3ae8-ada9-ce0c-78795c596995.png)

### 4.3. 環境変数の準備

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

| 環境変数名 | 取得元 | 手順番号 | これはなんですか？ |
|:-----|:-----|:-----|:-----|
| ACCESS_KEY_ID | AWS (IAM) | [3.2](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#32-%E4%B8%8A%E8%A8%98s3%E3%81%AB%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AEiam%E3%83%A6%E3%83%BC%E3%82%B6%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B) | S3にアクセスするためのIAMユーザのアクセスキー |
| SECRET_ACCESS_KEY | AWS (IAM) | [3.2](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#32-%E4%B8%8A%E8%A8%98s3%E3%81%AB%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AEiam%E3%83%A6%E3%83%BC%E3%82%B6%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B) | S3にアクセスするためのIAMユーザのシークレット |
| BUCKET_NAME | AWS (S3) | [3.1](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#31-s3%E3%81%AE%E4%BD%9C%E6%88%90) | S3のバケット名 |
| BUCKET_REGION | AWS (S3) | [3.1](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#31-s3%E3%81%AE%E4%BD%9C%E6%88%90) | S3のリージョン |
| BUCKET_ARN | AWS (S3) | [3.1](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#31-s3%E3%81%AE%E4%BD%9C%E6%88%90) | S3のARN (使ってない) |
| LINE_TOKEN | LINE | [1](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#1-line-bot%E3%81%AE%E6%BA%96%E5%82%99) | LINEのチャネルアクセストークン |
| TEAMS_WEBHOOK	| Teams | [2](https://qiita.com/Tomonobu3110/items/c47db053a5b1e813ce6a#2-teams%E3%81%AE%E5%8F%97%E4%BF%A1webhook%E3%81%AE%E6%BA%96%E5%82%99) | Teamsの送信WebhookのURL |

設定場所は Lambda > 関数 > 作った関数名 の 設定タブ > 環境変数 です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102797/c9c68831-21ba-313a-52f5-9cc4ef39b5ae.png)

### 4.4. コーディング

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

```javascript
'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関数を開きます
1. 関数の概要のページに「関数URL」があるのでコピーします

### 5.2. LINEのWehHookに設定

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

■ LINE Developers
[WebhookエンドポイントのURLを設定する](https://developers.line.biz/ja/reference/messaging-api/#set-webhook-endpoint-url)

以上です

# 苦労した点

## TeamsのJSON記法

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

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

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

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

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

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

EOF