カギの閉め忘れを見張ってくれるLINEボットを作った【SESAME 4】

## 作った背景
私自身が忘れっぽい性格なので、たまに自宅のカギを掛けずに出かけてしまうことがあります。
これを解決するため、カギの状態を監視して開いたままのときはLINE通知するアプリケーションを作ってみました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">家のカギを開け放してると母ちゃんに怒られるクソボットを作りました😇 <a href="https://t.co/xoIkgLObfw">pic.twitter.com/xoIkgLObfw</a></p>&mdash; かおなが (@kaonaga9) <a href="https://twitter.com/kaonaga9/status/1510510461955223555?ref_src=twsrc%5Etfw">April 3, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 成果物イメージ
一定時間ごとに自宅のカギの状態を監視して、開いていれば施錠を促すLINEを送ります。
Node.jsのアプリケーションをHerokuにデプロイします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/65dfef60-25fe-8fe7-2db4-966563dfa902.png)



## 用意したもの
- [SESAME 4](https://jp.candyhouse.co/products/sesame4)
- [Wi-Fiモジュール](https://jp.candyhouse.co/products/new-wifi)
    インターネット上から自宅のセサミにアクセスするために必要です。

## SESAMEをAPIから操作する方法
SESAMEではWeb APIが無料で提供されていて、自作プログラムから施錠解錠することができます。
詳しい使い方やセットアップの方法は以下の記事にまとめてますのでご参照ください。

https://qiita.com/kaonaga9/items/fb44d8e0b0aa93aab484

## Node(Express)でWebサーバーを構築
**index.js**
```javascript:index.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 5000;

const sesame = require('./sesame.js');
const line = require('./line.js')

app.use(express.json())
app.use(express.urlencoded({
  extended: true
}))

app.get('/', (req, res) => {
  res.json({
    message: "Application running..."
  });
});

// 家のSESAMEの開閉状態を取得する
app.get('/status', async (req, res) => {
  const { data } = await sesame.get_status();
  res.json(data);
});

// カギが開いてればLINE通知する
app.get('/remindme', async (req, res) => {
  const { data } = await sesame.get_status();
  if (data.CHSesame2Status == 'unlocked') {
    const result = await line.notify();
    return res.json({
      message: "Notification sended!"
    })
  };
  res.json({
    message: "The key is locked"
  })
});

// Webhook
app.post('/webhook', async (req, res) => {
  // Signature検証
  if (!line.validateSignature(req.body, req.headers['x-line-signature'])) { 
    return res.status(401).json({
      message: "Invalid signature received"
    })
  }
  // postbackイベントを処理する
  if (req.body.events.length > 0 && req.body.events[0].type == "postback") {
    const result = await sesame.lock_cmd();
  }
  res.sendStatus(200);
})

app.listen(PORT, () => {
  console.log(`Listening on ${PORT}!`);
});
```

**sesame.js**
```javascript:sesame.js
const axios = require('axios');
const aesCmac = require('node-aes-cmac').aesCmac;

const sesame_id = process.env.SESAME_UUID;
const sesame_api_key = process.env.SESAME_API_KEY;
const key_secret_hex = process.env.KEY_SECRET_HEX;

exports.get_status = async () => {
  const result = await axios({
    method: 'get',
    url: `https://app.candyhouse.co/api/sesame2/${sesame_id}`,
    headers: { 'x-api-key': sesame_api_key }
  })
  return result;
};

exports.lock_cmd = async () => {
  const cmd = 82;
  const history = "Locked via LINE bot";
  const base64_history = Buffer.from(history).toString('base64');
  const sign = generateRandomTag(key_secret_hex)
  const result = await axios({
    method: 'post',
    url: `https://app.candyhouse.co/api/sesame2/${sesame_id}/cmd`,
    headers: { 'x-api-key': sesame_api_key },
    data: {
      cmd: cmd,
      history: base64_history,
      sign: sign
    }
  })
  return result;
};

exports.unlock_cmd = async () => {
  const cmd = 83;
  const history = "Unlocked via LINE bot";
  const base64_history = Buffer.from(history).toString('base64');
  const sign = generateRandomTag(key_secret_hex);
  const result = await axios({
    method: 'post',
    url: `https://app.candyhouse.co/api/sesame2/${sesame_id}/cmd`,
    headers: { 'x-api-key': sesame_api_key },
    data: {
      cmd: cmd,
      history: base64_history,
      sign: sign
    }
  })
  return result;
};

function generateRandomTag(secret) {
  const key = Buffer.from(secret, 'hex');
  const date = Math.floor(Date.now() / 1000);
  const dateDate = Buffer.allocUnsafe(4);
  dateDate.writeUInt32LE(date);
  const message = Buffer.from(dateDate.slice(1, 4));
  return aesCmac(key, message);
}
```

**line.js**
```javascript:line.js
const line = require('@line/bot-sdk');

const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};
const client = new line.Client(config);
const userId = process.env.LINE_USER_ID;

// Webhookの署名検証
exports.validateSignature = (body, signature) => {
  return line.validateSignature(Buffer.from(JSON.stringify(body)), config.channelSecret, signature);
}

// プッシュ通知を送る
exports.notify = async () => { 
  const message = {
    type: "flex",
    altText: "カギが開いています",
    contents: flexContents
  };
  await client.pushMessage(userId, message)
    .catch((err) => {
      console.log(err);
    });
}

// フレックスメッセージ
const flexContents = {
  type: "bubble",
  body: {
    type: "box",
    layout: "vertical",
    contents: [
      {
        type: "text",
        text: "カギあけっぱなし！",
        weight: "bold",
        size: "md"
      }
    ]
  },
  footer: {
    type: "box",
    layout: "vertical",
    spacing: "sm",
    contents: [
      {
        type: "button",
        style: "primary",
        height: "sm",
        action: {
          type: "postback",
          label: "LOCK",
          data: "lock"
        }
      },
      {
        type: "box",
        layout: "vertical",
        contents: [],
        margin: "sm"
      }
    ],
    flex: 0
  }
}
```

## Herokuにデプロイする
アプリケーションをHerokuにデプロイします。
[GitHub](https://github.com/kota-imai/sesame-linebot-reminder)のREADMEに "Deploy to Heroku" ボタンを設置したので、Herokuでホスティングする場合はご活用ください。


## 環境変数を設定
以下の6項目を環境変数にセットします。ローカル検証時は.envファイルで設定してもokです
- セサミ用
```SESAME_UUID``` ... SESAMEデバイスを一意にするキー
```SESAME_API_KEY```... SESAMEのWeb APIキー
```KEY_SECRET_HEX```... SESAMEのシークレットキー
-> [APIを使ってSesame4を操作する](https://qiita.com/kaonaga9/items/fb44d8e0b0aa93aab484#web-api-%E3%82%AD%E3%83%BC%E3%81%AE%E5%8F%96%E5%BE%97)

- LINE用
```CHANNEL_ACCESS_TOKEN```... チャネルアクセストークン。LINE Developersから取得
```CHANNEL_SECRET```... チャネルシークレット。LINE Developersから取得
```LINE_USER_ID```... チャネル内での自分のユーザーID。LINE Developersから取得
-> [アクセストークンの発行・確認方法](https://poster.ooo/howto/check-access-token/)

## LINE公式アカウントのWebhookを有効化する
フレックスメッセージにPostbackのボタンを設置しているため、Webhookの設定が必要です。
Postbackイベントを受け取るとアプリケーションはSESAMEに施錠コマンドを送ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/39e97550-caf4-e5f2-610b-865f4efb0202.png)

## スケジューラーにジョブを追加
Herokuスケジューラーで、一時間ごとに ```{アプリケーションURL}/remindme``` のエンドポイントにcurlコマンドを実行する設定をします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/765aebdc-0792-3569-c476-71958d3af364.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/00e5f408-dde0-10b7-aa96-ad5d764f1eb2.png)

設定は以上です。

## まとめ
使い始めて一週間くらい経ちましたが、Herokuの無料プランで問題なく運用できています。
少し修正してリッチメニューからカギを操作できるようにしても面白そうです。

SESAMEを使い始めてからカギを持ち歩くことがなくなり、QOLが上がりました。
迷われている方はぜひ買ってみてください！


## ソースコード
ソースコードはGitHubに公開してます。

https://github.com/kota-imai/sesame-linebot-reminder

## 参考ページ
- [SESAME Web API ドキュメント](https://doc.candyhouse.co/ja/SesameAPI)
- [Messaging APIリファレンス | LINE Developers](https://developers.line.biz/ja/reference/messaging-api/#send-push-message)
- [Flex Messageを送信する | LINE Developers](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/#sending-messages-with-the-messaging-api)