AWS
mail
nodejs
lambda
kintone

kintone に保管したセンサ値を AWS Lambda で15分に1回チェックし警告をメールで送信

処理内容

・15分毎に AWS Lambda を起動
・kintone アプリから警告用の設定を取得
・kintone アプリからセンサ値を取得
・センサ値が警告レベルなら警告メールを送信する
・kintone アプリの警告状況を更新

Lambdaコード

index.js
'use strict';

var aws     = require('aws-sdk');
var ses     = new aws.SES({apiVersion: '2010-12-01', region: 'ap-northeast-1' });

var request = require('sync-request');
var requestAsinc = require('request');

var domain     = "cybozu.com";
var subdomain  = "SUBDOMAIN";
var path       = "/k/v1/records.json";
var protocol   = "https://";
var setAppId   = "KINTONE_SETTING_APP_ID";
var setToken   = "SETTING_KINTONE_APP_TOKEN";
var dataAppId  = "KINTONE_DATA_APP_ID";
var dataToken  = "DATA_KINTONE_APP_TOKEN";

var to       = ['TO_MAIL_ADDRESS'];  // AWS SES でverify済みのメールアドレス(送信先)
var from     = 'FROM_MAIL_ADDRESS';  // AWS SES でverify済みのメールアドレス(送信元)

exports.handler = function(event, context) {
    // 15 分前を算出
    require('date-utils');
    var dt = new Date();
    var dtAgo = new Date();
    dtAgo.setTime(dt.getTime() - (15 * 60 * 1000));

    // kintone アプリからデータチェック用設定情報を取得
    var url            = protocol+ subdomain + '.' + domain + path;
    var query          = "Status" + 'in ("有効") order by PlaceId asc limit ' + count;
    var settingRecodes = funcGetKintoneRecodes(request, setAppId, url, setToken, query);

    // kintone アプリから現在のデータを取得
    var query       = "DateTime" + ' < "' + dtAgo.toFormat("YYYY-MM-DDTHH24:MI:SSZ") + '"' + ' order by PlaceId asc, DateTime desc limit 50';
    var dataRecodes = funcGetKintoneRecodes(request, dataAppId, url, dataToken, query);

    // 取得したデータのチェック
    var errorData = funcCheckData(settingRecodes, dataRecodes);
    if(errorData.length > 0){
        funcSendMails(ses, to, from, errorData);
        funcPutRecodes(request, url, setAppid, setToken, errorData);
    }
};

// データチェック処理
function funcCheckData(settingRecodes, dataRecodes)
{
    var recodes = [];
    if (settingRecodes.length == 0 || dataRecodes.length == 0){
        return recodes;
    }

    // 計測場所毎のデータに集約
    var dataPlaceId = "";
    var uniqueData = [];
    dataRecodes.forEach(function(record){
        if(dataPlaceId != dataRecodes['PlaceId']['value']){
            uniqueData[dataRecodes['PlaceId']['value']]['DateTime']          = dataRecodes['DateTime']['value'];
            uniqueData[dataRecodes['PlaceId']['value']]['Temperature']       = dataRecodes['Temperature']['value'];
            uniqueData[dataRecodes['PlaceId']['value']]['Humidity']          = dataRecodes['Humidity']['value'];
            uniqueData[dataRecodes['PlaceId']['value']]['CO2']               = dataRecodes['CO2']['value'];
            uniqueData[dataRecodes['PlaceId']['value']]['SaturationDeficit'] = dataRecodes['SaturationDeficit']['value'];
            dataPlaceId = dataRecodes['PlaceId']['value'];
        }
    });

    var i = 0;
    settingRecodes.forEach(function(record){
        // 場所毎に集約したデータを設定値と突き合わせ
        var settingPlaceId = record['PlaceId']['value'];
        if(uniqueData[settingPlaceId] != null){
            var isNormal = true;
            var item = "";
            switch (record['DataType']['value']){
                case "温度":
                    item = "Temperature";
                    break;
                case "湿度":
                    item = "Humidity";
                    break;
                case "飽差":
                    item = "SaturationDeficit";
                    break;
                case "CO2濃度":
                    item = "CO2";
                    break;
            }

            // 警告対象外(通常値)かどうか判断
            if(record['Condition']['value'] == "以上" 
              && record['Value']['value'] <= uniqueData[settingPlaceId][item]){
                isNormal = false;
            }
            if(record['Condition']['value'] == "以下" 
              && record['Value']['value'] >= uniqueData[settingPlaceId][item]){
                isNormal = false;
            }
        }

        // 警告を通知
        if(!isNormal && (record['StartDateTime']['value'] == "" || (record['StartDateTime']['value'] != "" &&  record['EndDateTime']['value'] != ""))){
            recodes[i]['Normal']   = isNormal;
            recodes[i]['Subject']  = "<警告発生>" + record['Title']['value'];
            recodes[i]['Body']     = "場所:" + record['Title']['PlaceName'] + "\n";
            recodes[i]['Body']    += "設定:" + record['DataType']['value'] + "が " + record['Value']['value'] + " " + record['Condition']['value'] + " 現在値( " + uniqueData[settingPlaceId][item] + ")";
            recodes[i]['Body']    += "\n" + record['Message']['value'];
            recodes[i]['No']       = record['Title']['レコード番号'];
            recodes[i]['DateTime'] = uniqueData[settingPlaceId]['DateTime'];
            i++;
        // 警告を解除
        }else if(isNormal && record['StartDateTime']['value'] != "" && record['EndDateTime']['value'] != ""){
            recodes[i]['Normal']   = isNormal;
            recodes[i]['Subject']  = "<警告解除>" + record['Title']['value'];
            recodes[i]['Body']     = "場所:" + record['Title']['PlaceName'] + "\n";
            recodes[i]['Body']    += "設定:" + record['DataType']['value'] + "が " + record['Value']['value'] + " " + record['Condition']['value'] + " 現在値( " + uniqueData[settingPlaceId][item] + ")";
            recodes[i]['No']       = record['Title']['レコード番号'];
            recodes[i]['DateTime'] = uniqueData[settingPlaceId]['DateTime'];
            i++;
        }
    });
    return recodes;
}

// 警告メールを送信
function funcSendMails(ses, to, from, errorData)
{
    errorData.forEach(function(record){
        funcSendMail(ses, to, from, record['Subject'], record['Body']);
    });
}

// 警告状況をkintoneの設定ファイルに反映
function funcPutRecodes(request, url, appid, token, errorData)
{
    errorData.forEach(function(record){
        var id = record['No'];
        var json;
        if(record['Normal']){
            json = { "EndDateTime": record['DateTime'] };
        }else{
            json = { "StartDateTime": record['DateTime'] };
        }
        funcPutKintoneRecode(request, url, appid, token, id, json)
    });
}

// メールの送信
function funcSendMail(ses, to, from, subject, body)
{
    var eParams = {
        Destination: {
            ToAddresses: to
        },
        Message: {
            Body: {
                Text: {
                    Data: body,
                    Charset: 'iso-2022-jp'
                }
            },
            Subject: {
                Data: subject,
                Charset: 'iso-2022-jp'
            }
        },
        Source: from
    };

    var email = ses.sendEmail(eParams, function(err, data){
        if(err){
            context.fail(new Error('Mail error occured.'));
        } else {
            context.succeed('Mail sucsess.');
        }
    });
}

// kintone のデータを取得する
function funcGetKintoneRecodes(request, appId, url, token, query)
{
    if(count > 500) count = 500;
    var appUrl = url + '?app=' + appId + '&query=' + encodeURI(query);
    var respons = request('GET', appUrl, {
        'headers': {
            'X-Cybozu-API-Token': token
        }
    });
    return JSON.parse(respons.getBody());
}

// kintone のデータを更新する
function funcPutKintoneRecode(request, url, appid, token, id, json)
{
    var options = {
        url: url,
        method: 'PUT',
        headers: {
            'Content-type': 'application/json',
            'X-Cybozu-API-Token': token
        },
        body: { app : appid, id : id, record: json },
        json: true
    };
    console.log(options);

    request.put(options, function (err, res, body) {
        console.log('response' + res.statusCode);
        if (!err && res.statusCode === 200) {
            console.log('response SUCCESS!!');
            console.log(body);
        } else {
            console.log('response error: ' + res.statusCode);
            console.log(body);
        }
        console.log('response end');
    });
}

参照情報

AWS Lambda ( https://aws.amazon.com/jp/lambda/ )
AWS SES ( https://aws.amazon.com/jp/ses/ )
【Python3】AWS 「API Gateway」「Lambda」「SES」を使って、RaspberryPiでのセンサー検知をメール通知してみた ( http://raspi.seesaa.net/article/431653483.html )
AWS SES+Lambda で作る、ドメインまるっとメール転送 ( http://iseebi.hatenablog.com/entry/2016/05/05/123815 )
sync-request ( http://designetwork.hatenablog.com/entry/2016/11/19/sync-request-post-node-js )
then-request ( https://www.npmjs.com/package/then-request )