ソラカメ通知のWebhookを利用してモーション検知結果を解析してkintoneに保管してみた

# はじめに
こちらは [SORACOM Advent Calendar 2024](https://qiita.com/advent-calendar/2024/soracom) 22日目の記事です。

https://qiita.com/advent-calendar/2024/soracom

今年10月2日にソラカメの通知がWebhookに対応しました。

https://blog.soracom.com/ja-jp/2024/10/02/soracam-notification/

リリースから少し経過しましたが、モーション検知を Webhook で AWS Lambda Function URLs に投げて、ChatGPT API で簡単な画像解析をした結果を kintone に保管するまでの一連の処理を試してみました。

![SORACAME13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/e31575d8-5f9f-682b-79ef-54d289b9f033.png)

# ソラカメのモーション検知の通知設定
ソラカメのモーション検知はメールと Webhook での通知が可能です。 
通知はSORACOMコンソールのソラコムクラウドカメラサービス、通知設定から行います。
![SORACAME02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/858d0aa7-e43a-7afd-17f8-4bfa344bfd2b.png)

以下の「デバイスのモーション検知/サウンド検出の通知」を編集して設定します。
![SORACAME03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/d91fa8e6-1b4c-d00d-02d3-8d9cac130f70.png)

## 事前にメール通知を試してみる
以下を参考にメール通知を設定します。

https://users.soracom.io/ja-jp/docs/soracom-cloud-camera-services/notification-for-operator/

通知メッセージの本文は以下のように設定します。

```
ソラカメのモーション検知が反応しました。
詳細は以下です。

イベント種別：{{ event_type }}
オペレーター ID：{{ operator_id }}
デバイス ID：{{ device_id }}
デバイス名：{{ device_name }}
アラート種別：{{ alarm_type }}
```

ソラカメがモーション検知する毎に、以下のメールを受信するようになります。
![SORACAME10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/f3a2faf1-5ce5-b42f-40ac-a508b40137cb.png)
メール通知は簡単に開始できます。

## 本命Webhookを試してみる
次に本命の Webhook を試してみます。
こちらもメール通知同様に以下を参考にします。

https://users.soracom.io/ja-jp/docs/soracom-cloud-camera-services/notification-for-operator/

Webhookは以下のようにメソッド選択や、ヘッダー情報も設定可能です。
![SORACAME07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/fde51cd6-fa3b-9c59-a76c-75eba1c9ea26.png)

Webhookから AWS Lambda Function URLs の URL に以下のJSONをPOSTします。

```
{
    "event_type" : "{{ event_type }}",
    "device_id" : "{{ device_id }}",
    "device_name" : "{{ device_name }}",
    "alarm_type" : "{{ alarm_type }}"
}
```

Lambda側ではイベント情報として以下のような情報を取得できます。

```
{
    "version": "2.0",
    "routeKey": "$default",
    "rawPath": "/",
    "rawQueryString": "",
    "headers": {
        "x-amzn-tls-cipher-suite": "TLS_AES_128_GCM_SHA256",
        "content-length": "145",
        "x-amzn-tls-version": "TLSv1.3",
        "x-amzn-trace-id": "Root=1-67602e1c-69f210b666938bd47c8a30f9",
        "x-forwarded-proto": "https",
        "host": "<** LambdaのURL **>",
        "x-forwarded-port": "443",
        "content-type": "application/json",
        "x-forwarded-for": "<** IPアドレス **>",
　　　　<** ヘッダーを利用したセキュリティ設定は割愛 **>
        "accept": "*/*"
    },
    "requestContext": {
        "accountId": "anonymous",
        "apiId": "<** LambdaのAPI Id **>",
        "domainName": "<** LambdaのURL **>",
        "domainPrefix": "<** LambdaのAPI_Id **>",
        "http": {
            "method": "POST",
            "path": "/",
            "protocol": "HTTP/1.1",
            "sourceIp": "<** IPアドレス **>",
            "userAgent": null
        },
        "requestId": "c3a795ec-0e94-4b66-b232-fc48355acd19",
        "routeKey": "$default",
        "stage": "$default",
        "time": "16/Dec/2024:13:41:49 +0000",
        "timeEpoch": 1734356509011
    },
    "body": "{\n    \"event_type\" : \"event_detected\",\n    \"device_id\" : \"<** ソラカメのdevice_id **>\",\n    \"device_name\" : \"ATOM Cam 2\",\n    \"alarm_type\" : \"motion_detected\"\n}",
    "isBase64Encoded": false
}
```

# Webhookモーション検知結果を解析してkintoneに保管
実際に Webhook のモーション検知の通知を AWS Lambda で受取り、ChatGPT で解析した結果と一緒に kintone に保管する、以下の簡単なしくみを構築してみます。

![処理の流れ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/0e6211a2-f1b1-a243-f0db-5c33f040e65c.png)

## ソラカメモーション検知結果をkintoneに保管
ソラカメのモーション検知画像（検知後2秒の画像）と、ChatGPT でその画像を解析した結果をkintoneの「ソラカメモーション検知」アプリに保管します。
kintone アプリの項目は以下です。

| フィールド名 | フィールドタイプ | 解説 |
|:-----------|:------------|:------------|
| 検知日時 | 日時 |  |
| デバイスID | 文字列（1行） | ソラカメのdevice_id |
| 作成日時 | 作成日時 | kintoneのレコード作成日時 |
| AI解析結果 | 文字列（1行） | 検知画像に人が映っているかの判定（ChatGPT） |
| AI解析タグ | 文字列（1行） | 検知画像に映っている物をタグ付け（ChatGPT） |
| 検知画像 | 添付ファイル | ソラカメの画像 |

## ChatGPT API でモーション検知結果の解析
ソラカメでモーション検知した画像に人が含まれるかなどを簡単に試してみるため、ChatGPT API を利用します。
AWS Lambda から ChatGPT API を利用するために、以下の情報などを参考にアカウントを開設し、カード登録を済ましてクレジットを購入したうえで、APIキーを発行しておきます。

https://www.braze.co.jp/resources/articles/chatgpt-api

## AWS Lambda Function URLs の実装
ソラカメモーション検知の Webhook を受信し、SORACOM API を使って検知した（2秒後の）画像を取得、ChatGPT で画像解析した結果を含めて kintone のアプリにデータを保管する AWS Lambda プログラムを作成します。

開発言語には Node.js 22.x を選択、メモリは 256MB、処理のタイムアウトは 1分0秒 に設定しています。あと、環境変数内に SORACOM API や、kintone API、AWS S3、ChatGPT API に関する設定情報を格納しています。

Lambda の Node.js には OpenAI のライブラリィをインストールしています。

```
npm install openai
```

今回作成したプログラムは以下です。（各種設定は Lambda の環境変数に保管。）

<details><summary>Lambda の nodejsプログラム（全文）</summary>

```
'use strict';

import { OpenAI } from 'openai';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'

// kintoneの環境
const SubDomain = process.env['SubDomain'];
const AppId     = process.env['AppId'];
const Token     = process.env['Token'];

// SORACOMの環境
const SoracomKeyId     = process.env['SoracomKeyId'];
const SoracomKeySecret = process.env['SoracomKeySecret'];

// CahtGPTの環境
const ChatGptKey = process.env['ChatGptKey'];

// リクエスト再送対策用
const AWSClient = new S3Client({ region: process.env['Region'] });
const Bucket = process.env['Bucket'];

export const handler = async (event, context) => {
    
    console.log("event: \n" + JSON.stringify(event, null, 2));
 
    // セキュリティ対策
    ここは非公開（各自で実装ください）

    // SORACOM API 処理の開始
    const jsonObj = JSON.parse(event.body);
    const deviceId = jsonObj.device_id;

    // SORACOM認証
    const auth = await PostSoracomAuth(SoracomKeyId, SoracomKeySecret);

    // ソラカメのイベントを取得
    const cameraEvents = await GetSoracomCameraEvent(auth, deviceId);
    if(cameraEvents.length < 1){
        // SORACOM AUTH LOGOUT
        await PostSoracomLouout(auth);
        return Respons404();
    }

    // 処理済みか確認（処理済みはS3にファイルがある）
    let dt = new Date((cameraEvents[0].time + 0));
    const utcDate = dt.toISOString().split('.')[0] + "Z";
    if(await S3ExistFile(AWSClient, Bucket, deviceId, utcDate)){
        // SORACOM AUTH LOGAUT
        await PostSoracomLouout(auth);
        return Respons400();
    }else{
        // S3にファイルを追加
        await S3PutFile(AWSClient, Bucket, deviceId, utcDate);
    }

    // ソラカメの画像データを取得
    let saveTime = cameraEvents[0].time + 2000;
    await sleep(10000);
    const cameraImage = await PostSoracomCameraImage(auth, deviceId, saveTime);
    await sleep(3000);
    let cameraImageExport = await GetSoracomCameraImageExport(auth, deviceId, cameraImage.exportId);
    if(cameraImageExport.status != "completed"){
        await sleep(3000);
        cameraImageExport = await GetSoracomCameraImageExport(auth, deviceId, cameraImage.exportId);
    }
    const imageUrl = cameraImageExport.url;

    // SORACOM AUTH LOGAUT
    await PostSoracomLouout(auth);

    // 画像データをChatGPTで解析
    const aiRespons = await analyzeOpenAi(ChatGptKey, imageUrl);

    // ソラカメの画像データをkintoneに追加
    const fileKey = await ImageUploadForKintone(SubDomain, Token, imageUrl);

    // kintoneにレコードを追加
    let aiRes = "";
    if(aiRespons.hasPeople == true){
        aiRes = "人を検知しました！";
    }
    const parm = { 
        "検知日時"  : { "value" : utcDate },
        "デバイスID": { "value" : deviceId },
        "AI解析結果": { "value" : aiRes },
        "AI解析タグ": { "value" : aiRespons.hashtag },
        "検知画像"  : { "value" : [{
            "contentType": "image/jpeg", 
            "fileKey" : fileKey
         }] }
    };
    await PostKintoneRecode(SubDomain, AppId, Token, parm); 

    return Respons200();
};

function Respons200(){
    return {
        statusCode:200,
        body: JSON.stringify('OK.'),
    };
}

function Respons400(){
    return {
        statusCode: 400,
        body: JSON.stringify('Bad Request.'),
    };
}

function Respons404(){
    return {
        statusCode: 404,
        body: JSON.stringify('Not Found.'),
    };
}

// SORACOM AUTH
async function PostSoracomAuth(id, key)
{
    const url = "https://api.soracom.io/v1/auth";
    const headers = {
        'Content-type': 'application/json',
    };
    const body = JSON.stringify({ "authKeyId" : id, "authKey" : key });
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const auth = await response.json();
    return auth;
}

// GET SORACAME CAMERA EVENT
async function GetSoracomCameraEvent(auth, deviceId)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/events?limit=1&sort=desc";
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const response = await fetch(url, {
        method: 'get',
        headers: headers
    });
    const events = await response.json();
    console.log(JSON.stringify(events, null, 2));
    return events;
}

// POST SORACOM CAMERA IMAGE
async function PostSoracomCameraImage(auth, deviceId, time)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/images/exports";
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token,
        'Content-type': 'application/json'
    };
    const body = JSON.stringify(
        { "time" : time }
    );
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const imageExport = await response.json();
    console.log(JSON.stringify(imageExport, null, 2));
    return imageExport;
}

// GET SORACAME CAMERA IMAGE EXPORTS
async function GetSoracomCameraImageExport(auth, deviceId, exportId)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/images/exports/"+exportId;
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const response = await fetch(url, {
        method: 'get',
        headers: headers
    });
    const imageExport = await response.json();
    console.log(JSON.stringify(imageExport, null, 2));
    return imageExport;
}

// SORACOM AUTH LOGAUT
async function PostSoracomLouout(auth)
{
    const url = "https://api.soracom.io/v1/auth/logout";
    const headers = {
        'accept': '*/*',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const body = "{}";
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const logout = await response.text();
}

// 画像データをChatGPTで解析
async function analyzeOpenAi(chatGptKey, imageUrl){
    const openai = new OpenAI({apiKey: chatGptKey});
    const prompt = "写真に人が映っているかをJSONフォーマットでtrueかfalseで答えてください.\n"
                    + "また、この写真に何が写っているかを#先頭にしたハッシュタグ付けしたキーワードで10個以内で答えてください.\n"
                    + "ハッシュタグ付けしたキーワードの間は半角スペースで区切ります.\n"
                    + "json フォーマットは以下です.\n"
                    + "{ \"hasPeople\" : trueかfalse, \"hashtag\" : \"ハッシュタグ付けしたキーワード\" }";
    const chatCompletion = await openai.chat.completions.create({
        model: 'gpt-4o',
        messages: [{
            role: 'user',
            content: [{
                type: 'text',
                text: prompt
            },
            {
                type: 'image_url',
                image_url: {
                    url : imageUrl
                }
            }]
        }],
    });
    console.log(chatCompletion);
    let response = chatCompletion.choices[0].message.content.replace(/`/g,"");
    response = response.replace("json","");
    return JSON.parse(response);
}

// SORACAME イベント画像をkintoneにアップ
async function ImageUploadForKintone(subDomain, token, imageUrl){

    // 画像ファイルを取得
    let file = await fetch(imageUrl);
    const buffer = await file .arrayBuffer();
    const blob = await new Blob([buffer], { type: "image/jpeg" });

    // ファイルをkintoneにアップロード
    let fileKey = "";
    if(file.size != 0){
        const formData = new FormData();
        formData.append('file', blob, 'image.jpg');
        let headers = {
            'accept': 'application/json',
            'X-Requested-With': 'XMLHttpRequest',
            'X-Cybozu-API-Token': token,
        };
        const resp = await fetch('https://'+subDomain+'.cybozu.com/k/v1/file.json', {
            method: 'post',
            headers,
            body: formData,
        });
        const respData = await resp.json();
        fileKey = respData.fileKey;
    }
    console.log(fileKey);
    return fileKey;
}

// kintoneにレコードを追加
async function PostKintoneRecode(subDomain, appId, token, parm)
{
    const url = 'https://'+subDomain+'.cybozu.com/k/v1/record.json';
    try {
        const headers = {
            'X-Cybozu-API-Token': token,
            'Content-Type': 'application/json',
        };
        const body = await JSON.stringify({ "app" : appId, "record" : parm });
        const response = await fetch(url, {
            method: 'post',
            body: body,
            headers: headers
        });
        const data = await response.json();
        console.log(data);
        return true;
    } catch (error) {
        console.log(JSON.stringify(error));
        return false;
    }
}

// S3のファイル存在確認
async function S3ExistFile(client, bucket, deviceId, utcDate){
    const params = {
        'Bucket': bucket,
        'Key': 'soralog/'+deviceId+'/'+utcDate+'.lck'
    }
    let bool = true;
    const command = new GetObjectCommand(params);
    const data = await client.send(command).catch(e=>{
      bool = false;
      console.log(e);
    });
    return bool
}

// S3にファイルを追加・更新
async function S3PutFile(client, bucket, deviceId, utcDate){
    const params = {
        'Bucket': bucket,
        'Key': 'soralog/'+deviceId+'/'+utcDate+'.lck',
        'Body': 'Already stored'
    }
    let bool = true;
    const command = new PutObjectCommand(params);
    const data = await client.send(command).catch(e=>{
      bool = false;
      console.log(e);
    });
    return bool
}

// SLEEP
const sleep = (time) => new Promise((resolve) => setTimeout(resolve, time));
```

</details>



### SORACOM API の処理
Webhook を受信した後の SORACOM API でモーションを検知した画像（URL）を取得するまでの処理順は以下です。
**1.認証**
**2.引数のデバイスIDから最新のイベントを取得**
**3.イベント開始時間（2秒後）の静止画をエクスポート**
**4.エクスポートの進捗を確認し、静止画のURLを取得**
**5.認証のログアウト**

利用した SORACOM API の詳細と実際のコード例は以下です。

・APIアクセスの認証（ [POST /auth](
https://users.soracom.io/ja-jp/tools/api/reference/#/Auth/auth) )
<details><summary>コード例</summary>

```JavaScript
// SORACOM AUTH
async function PostSoracomAuth(id, key)
{
    const url = "https://api.soracom.io/v1/auth";
    const headers = {
        'Content-type': 'application/json',
    };
    const body = JSON.stringify({ "authKeyId" : id, "authKey" : key });
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const auth = await response.json();
    return auth;
}
```

</details>

・ソラカメのイベントを取得（ [GET /sora_cam/devices/{deviceId}/events?limit=1&sort=desc](
https://users.soracom.io/ja-jp/tools/api/reference/#/SoraCam/listSoraCamDeviceEventsForDevice) ）
<details><summary>コード例</summary>

```JavaScript
// GET SORACAME CAMERA EVENT
async function GetSoracomCameraEvent(auth, deviceId)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/events?limit=1&sort=desc";
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const response = await fetch(url, {
        method: 'get',
        headers: headers
    });
    const events = await response.json();
    console.log(JSON.stringify(events, null, 2));
    return events;
}
```

</details>

・ソラカメの静止画をエクスポート（ [POST /sora_cam/devices/{deviceId}/images/exports](
https://users.soracom.io/ja-jp/tools/api/reference/#/SoraCam/exportSoraCamDeviceRecordedImage) ）
<details><summary>コード例</summary>

```JavaScript
// POST SORACOM CAMERA IMAGE
async function PostSoracomCameraImage(auth, deviceId, time)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/images/exports";
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token,
        'Content-type': 'application/json'
    };
    const body = JSON.stringify(
        { "time" : time }
    );
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const imageExport = await response.json();
    console.log(JSON.stringify(imageExport, null, 2));
    return imageExport;
}
```

</details>

・ソラカメの静止画エクスポート処理の状況を取得（ [GET /sora_cam/devices/{deviceId}/images/exports/{exportId}](https://users.soracom.io/ja-jp/tools/api/reference/#/SoraCam/getSoraCamDeviceExportedImage) ）

<details><summary>コード例</summary>

```JavaScript
// GET SORACAME CAMERA IMAGE EXPORTS
async function GetSoracomCameraImageExport(auth, deviceId, exportId)
{
    const url = "https://api.soracom.io/v1/sora_cam/devices/"+deviceId+"/images/exports/"+exportId;
    const headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const response = await fetch(url, {
        method: 'get',
        headers: headers
    });
    const imageExport = await response.json();
    console.log(JSON.stringify(imageExport, null, 2));
    return imageExport;
}
```

</details>

・APIアクセスのログアウト（ [POST /auth/logout](
https://users.soracom.io/ja-jp/tools/api/reference/#/Auth/logout) )

<details><summary>コード例</summary>

```JavaScript
// SORACOM AUTH LOGAUT
async function PostSoracomLouout(auth)
{
    const url = "https://api.soracom.io/v1/auth/logout";
    const headers = {
        'accept': '*/*',
        'X-Soracom-API-Key': auth.apiKey,
        'X-Soracom-Token'  : auth.token
    };
    const body = "{}";
    const response = await fetch(url, {
        method: 'post',
        body: body,
        headers: headers
    });
    const logout = await response.text();
}
```

</details>

### ChatGPT API の画像解析
SORACOM API で取得した画像（URL）に人が含まれないか、画像に何が写っているか ChatGPT API に問合せ、回答を指定した JSON フォーマットデータで取得します。 
実際のコード例は以下です。

<details><summary>コード例</summary>

```JavaScript
// 画像データをChatGPTで解析
async function analyzeOpenAi(chatGptKey, imageUrl){
    const openai = new OpenAI({apiKey: chatGptKey});
    const prompt = "写真に人が映っているかをJSONフォーマットでtrueかfalseで答えてください.\n"
                    + "また、この写真に何が写っているかを#先頭にしたハッシュタグ付けしたキーワードで10個以内で答えてください.\n"
                    + "ハッシュタグ付けしたキーワードの間は半角スペースで区切ります.\n"
                    + "json フォーマットは以下です.\n"
                    + "{ \"hasPeople\" : trueかfalse, \"hashtag\" : \"ハッシュタグ付けしたキーワード\" }";
    const chatCompletion = await openai.chat.completions.create({
        model: 'gpt-4o',
        messages: [{
            role: 'user',
            content: [{
                type: 'text',
                text: prompt
            },
            {
                type: 'image_url',
                image_url: {
                    url : imageUrl
                }
            }]
        }],
    });
    console.log(chatCompletion);
    let response = chatCompletion.choices[0].message.content.replace(/`/g,"");
    response = response.replace("json","");
    return JSON.parse(response);
}
```

</details>

### kintone へ画像と解析結果の保管
SORACOM API で取得したモーションを検知した画像（URL）と、ChatGPT API の解析結果を　kintone に保管します。 
実際のコード例は以下です。

<details><summary>コード例</summary>

```JavaScript
export const handler = async (event, context) => {

（中略）

   // ソラカメの画像データkintoneに追加
    const fileKey = await ImageUploadForKintone(SubDomain, Token, imageUrl);

   // kintoneにレコードを追加
    let aiRes = "";
    if(aiRespons.hasPeople == true){
        aiRes = "人を検知しました！";
    }
    const parm = { 
        "検知日時"  : { "value" : utcDate },
        "デバイスID": { "value" : deviceId },
        "AI解析結果": { "value" : aiRes },
        "AI解析タグ": { "value" : aiRespons.hashtag },
        "検知画像"  : { "value" : [{
            "contentType": "image/jpeg", 
            "fileKey" : fileKey
         }] }
    };
    await PostKintoneRecode(SubDomain, AppId, Token, parm); 
    
（中略）

// SORACAME イベント画像をkintoneにアップ
async function ImageUploadForKintone(subDomain, token, imageUrl){

    // 画像ファイルを取得
    let file = await fetch(imageUrl);
    const buffer = await file .arrayBuffer();
    const blob = await new Blob([buffer], { type: "image/jpeg" });

    // ファイルをkintoneにアップロード
    let fileKey = "";
    if(file.size != 0){
        const formData = new FormData();
        formData.append('file', blob, 'image.jpg');
        let headers = {
            'accept': 'application/json',
            'X-Requested-With': 'XMLHttpRequest',
            'X-Cybozu-API-Token': token,
        };
        const resp = await fetch('https://'+subDomain+'.cybozu.com/k/v1/file.json', {
            method: 'post',
            headers,
            body: formData,
        });
        const respData = await resp.json();
        fileKey = respData.fileKey;
    }
    console.log(fileKey);
    return fileKey;
}

// kintoneにレコードを追加
async function PostKintoneRecode(subDomain, appId, token, parm)
{
    const url = 'https://'+subDomain+'.cybozu.com/k/v1/record.json';
    try {
        const headers = {
            'X-Cybozu-API-Token': token,
            'Content-Type': 'application/json',
        };
        const body = await JSON.stringify({ "app" : appId, "record" : parm });
        const response = await fetch(url, {
            method: 'post',
            body: body,
            headers: headers
        });
        const data = await response.json();
        console.log(data);
        return true;
    } catch (error) {
        console.log(JSON.stringify(error));
        return false;
    }
}
```

</details>

以上の実装で kintone に以下のようにレコードが追加されました。

![SORACAME12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/9e70c364-39af-4f2e-31c0-6624ffc9b6b7.png)

## ソラカメ通知 Webhook のはまりどころ
今回の試作で特に Webhook から Lambda の処理タイミングではまりどころがありました。こちら参考にされた皆さんが同じ轍を踏まないようにその内容を紹介します。

### 1. Webhook が優秀過ぎて通知が早過ぎる
ソラカメのモーション検知通知の Webhook は優秀で、検知後即座に発火します。ですがソラカメの動画が実際にアーカイブされるのは少し後で、API 経由で静止画として取得可能になるまでには当然タイムラグが発生します。

であれば POST /sora_cam/devices/{deviceId}/images/exports で指定した時間の静止画がまだ確保できない場合はエラーのレスポンスを返してもらえればリクエスト再送等の処理をするのですが、エラーにはならず指定時間より前（たぶん出力可能な最新時刻）の静止画をエクスポートします。

このタイムラグが約8秒前後発生するため、イベント発生2秒後の取得には約10秒処理を意図的にSLEEPさせる必要がありました。

### 2. 静止画エクスポートに少し時間がかかる
SORACOM API で静止画エクスポートの命令後、実際に静止画をダウンロードできるようになるまでには少し時間がかかります。

API のレスポンスに status がありますので、こちらが "completed" になるまでループさせるのが理想的ですが、約4秒以降にエクスポートされるようなので、今回は3秒間を2回（合計6秒）の間処理を待つようにしました。

**上記 1. 2. に対応したコード例は以下です。**

<details><summary>コード例</summary>

```JavaScript
export const handler = async (event, context) => {

（中略）

    // ソラカメの画像データを取得
    let saveTime = cameraEvents[0].time + 2000;
    await sleep(10000);
    const cameraImage = await PostSoracomCameraImage(auth, deviceId, saveTime);
    await sleep(3000);
    let cameraImageExport = await GetSoracomCameraImageExport(auth, deviceId, cameraImage.exportId);
    if(cameraImageExport.status != "completed"){
        await sleep(3000);
        cameraImageExport = await GetSoracomCameraImageExport(auth, deviceId, cameraImage.exportId);
    }
    const imageUrl = cameraImageExport.url;

（中略）

// SLEEP
const sleep = (time) => new Promise((resolve) => setTimeout(resolve, time));
```

</details>

### Webhook のリトライで Lambda が起動を繰り返す
今回最初の実装で、1回のソラカメ通知で複数の kintone レコードが追加される問題に遭遇しました。CloudWatch のログを調査すると、10回も同じ Webhook のリクエストを処理していました。

原因はまだ詳細を調査しきれてなくて推測ですが、Lambda は処理開始から終わるまでステータスコードを返さないため、今回のようなレスポンスを返すまでに時間がかかる場合は Webhook がステータスコードのレスポンスが無いと判断し、最大10回？の再送を繰り返したと推測しています。これは SORACOM 側の仕様かもしれませんが、例えば LINE の Webhook は再送機能を利用するか設定できました。ソラカメ通知の Webhook も是非この設定を追加して欲しいところです。

本来は Webhook を受信する Lambda プログラムは、受信内容を SQS に保管してすぐにレスポンスを返して終了し、実際の処理は SQS をトリガーにした別の Lambda プログラムで実装するのが良いでしょう。ただ、今回は時間的余裕がなくて、以下の図とコード例のように S3 に処理済を判定するファイルを保管することで2回目以降の処理を回避して、kintoneに複数レコードが追加される問題を解決しました。

![処理の流れ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/e08a1b89-2186-c1e3-97e2-6753109ecf27.png)

<details><summary>コード例</summary>

```JavaScript
export const handler = async (event, context) => {

（中略）

    // 処理済みか確認（処理済みはS3にファイルがある）
    let dt = new Date((cameraEvents[0].time + 0));
    const utcDate = dt.toISOString().split('.')[0] + "Z";
    if(await S3ExistFile(AWSClient, Bucket, deviceId, utcDate)){
        // SORACOM AUTH LOGAUT
        await PostSoracomLouout(auth);
        return Respons400();
    }else{
        // S3にファイルを追加
        await S3PutFile(AWSClient, Bucket, deviceId, utcDate);
    }

（中略）

// S3のファイル存在確認
async function S3ExistFile(client, bucket, deviceId, utcDate){
    const params = {
        'Bucket': bucket,
        'Key': 'soralog/'+deviceId+'/'+utcDate+'.lck'
    }
    let bool = true;
    const command = new GetObjectCommand(params);
    const data = await client.send(command).catch(e=>{
      bool = false;
      console.log(e);
    });
    return bool
}

// S3にファイルを追加・更新
async function S3PutFile(client, bucket, deviceId, utcDate){
    const params = {
        'Bucket': bucket,
        'Key': 'soralog/'+deviceId+'/'+utcDate+'.lck',
        'Body': 'Already stored'
    }
    let bool = true;
    const command = new PutObjectCommand(params);
    const data = await client.send(command).catch(e=>{
      bool = false;
      console.log(e);
    });
    return bool
}
```

</details>

# 今後
今後時間が確保できたら、ソラカメ通知から顔が含まれる画像を取得し Amazon Rekognition や、ChatGPT AI で個人特定までの実装を試してみたいですね。また SORACOM Flux などを活用して、全てノーコードでの実装も試してみたいですね。まあ、しばらくはそんな余裕がなさそうですが。（汗）

# まとめ
・ソラカメ通知の Webhook は AWS Lambda と連携して活用できる
・モーション検知画像を ChatGPT API で解析して kintone でデータ活用できる
・しかし Webhook 通知後即座に静止画を取得できる訳ではない
・Webhook 受信から API で静止画出力まで適切に処理を待たせて実行する
・Webhook へ即座にレスポンスしないと10回程度繰り返し実行される
・Lambda は Webhook を受信し SQS に保管するものと、処理を行うものを別にする

# 参考情報
**SORACOM API リファレンス**
https://users.soracom.io/ja-jp/tools/api/reference/
**ソラカメ対応カメラに対するイベント通知を設定する**
https://users.soracom.io/ja-jp/docs/soracom-cloud-camera-services/notification-for-device/
**OpenAI TypeScript and JavaScript API Library**
https://www.npmjs.com/package/openai
**OpenAIのAPIで画像を生成したり、画像から文章が作れるようになったみたい**
https://shironeko.hateblo.jp/entry/2023/11/07/234059