何を作ったの?
LINEに画像やメッセージを送るとTeamsに転送してくれるチャットボットを作りました。
アーキテクチャ
ざっくりの図ですが、こんな感じです
ユーザがLINEのチャットボットにメッセージを送ると
Lambda関数が呼び出されるようになっています
メッセージが画像の場合には、画像データをS3に保存しておきます
その後、Teamsの受信Webhookを呼んでTeamsにメッセージを送る仕組みです
作成手順
1. LINE BOTの準備
LINE BOT (LINEのチャットボット) を準備します
以下のQiitaの記事が大変わかりやすくまとまっていて、いつも参照させてもらっています
ざっくり手順を書いておくと...
- LINE Developer Console にログイン
- プロバイダーを作成する
- チャネルを作成する --> Messaging API --> アイコンなど各種情報設定 --> 作成
- Messaging API設定でチャネルアクセストークンを発行する (メモしておきます)
【注意】
2. Teamsの受信Webhookの準備
以下の手順にしたがって、Teamsの受信Webhook (Incoming Webhook)を発行してください
受信Webhookとは、Teamsにメッセージを送りたいときに、宛先として利用するURL です
後で書くLambdaのコード中に埋め込んで(環境変数を利用)、
メッセージを送る際のHTTP呼び出しの宛先に利用しています
■ Microsoft Learn
受信 Webhook を作成する
ざっくりの手順を書いておくと...
- Webhookを作成したいチャネルを選択し、[••] を選択
- コネクタ > Incoming Webhook (受信Webhook) > 追加 > 構成
- 名前、アイコン(任意)を決め 「作成」
- URLが発行されるのでメモしておく
3. S3 と IAMユーザ の作成
3.1. S3の作成
画像を保存するためのS3バケットを作ります
作り方は、以下の記事の 手順 1. AWS S3 のバケットを作る と 手順2. 公開設定をする を参照してください
セキュリティ的にかなりオープンですので、気になる方は適切な設定への変更をお願いします
あとで利用するので、バケット名とリージョンをメモしておいてください
3.2. 上記S3にアクセスするためのIAMユーザを作成する
- IAMユーザの作成
- 許可を設定では、「ポリシーを直接アタッチする」から
AmazonS3FullAccess
を付与
- ユーザの作成
- ユーザリストから作成したユーザをクリックし、「セキュリティ認証情報」> 「アクセスキー」> 「アクセスキーを作成」
- いくつかの確認ののちアクセスキーが作成される。アクセスキーとシークレットアクセスキーをコピーしておく
4. Lambdaの作成
4.1. Lambda関数の準備
LINE BOTから呼び出されるLamba関数を用意します
LINE BOTのWebHookに登録するために、関数URLを有効化します
- Lambda --> 関数の作成 (ランタイムは
Node.js 18.x
を選びます)
- 詳細設定 > 関数URLを有効化 にチェックを入れ、認証タイプを NONE にする
- 詳細設定 > オリジン間リソース共有(CORS)にチェックを入れる
- 関数を作成
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のレイヤを作成
- Cloud9のファイルツリーから nodejs.zip を右クリックして download
- AWS Lambda > レイヤー > レイヤーの作成
- nodejs.zipをアップロード
- ARNをコピー
- Lambda関数にレイヤーを追加 > ARNを指定 > (ARN入力) > 追加
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 > 関数 > 作った関数名 の 設定タブ > 環境変数 です
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": "",
},
]
};
*/
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をコピー
- AWSのマネジメントコンソールで作成したLambda関数を開きます
- 関数の概要のページに「関数URL」があるのでコピーします
5.2. LINEのWehHookに設定
以下を参照して、LINE BOTのWebHookにLambdaの関数URLを設定します
■ LINE Developers
WebhookエンドポイントのURLを設定する
以上です
苦労した点
TeamsのJSON記法
ドキュメントや実例が少なくて苦労しました
特に画像の添付方法
コード中に試行錯誤の跡がありますのでご覧ください
LINEの画像送信時のWebhook呼び出し
LINEから複数の画像を送信した際のWebhook呼び出しの挙動がバリエーション豊富で対応に苦労しました
例えば、3枚の画像を送信した場合は、以下のバリエーションがありえます
- 3枚の画像が一回のWebhook呼び出しで送られてくる
- 1枚の画像、2枚の画像と二回のWebhook呼び出しに分割されて送られてくる
- 1枚づつ、三回のWebhook呼び出しで送られてくる
また、送られてくる写真の順番も順不同で(indexが小さいほうから順に来るわけではない)
この対応にも苦労しました
この記事がLINE BOTで画像を扱う人の参考になれば幸いです
EOF