SORACOM Beam から Cloudflare Workers 経由で Slack へ POST する

(2022-05-14 23:10 追記: KV を使ってみたので、最後に追記します)

# はじめに

前々から Cloudflare Workers が気になっていたので、SORACOM と繋げられるか試してみました。本当は R2 や D1 と絡めてみたかったのですが、贅沢は言わずにまずは Workers だけで試してみます。

# 事前準備

SORACOM のデバイスは [SORACOM LTE-M Button for Enterprise](https://soracom.jp/store/5206/) を使いました。Slack のアカウントも準備しておきます。

# 手順

## Slack の Incoming Webhook の準備

Slack のドキュメント [Slack での Incoming Webhook の利用 | Slack](https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8) を参考に、Incoming Webhook を準備します。

## Cloudflare Workers のセットアップ

まずは、https://www.cloudflare.com/ja-jp/ からサインアップをします。

Cloudflare workers を作っていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/91bfe4e5-fc46-2f94-3b10-c1b8ebc05f24.png)

サブドメインを決めます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/e33f409a-3548-ad77-df40-0e1950e5fb1d.png)

無料プランを使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/472184be-77f7-f5b5-02dd-b240d853c358.png)

サインアップ時にメール認証をまだしていなかったら認証します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/fd9f1b08-5c9d-af5e-bbb6-785bdee2ccfe.png)

できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/935b2e3e-f331-acb8-53cc-d3c121223d2f.png)

次に CLI をセットアップしました (参考: [Wrangler (command line) · Cloudflare Workers docs](https://developers.cloudflare.com/workers/wrangler/))。NodeJS が使える環境で、以下のコマンドでインストールします。

```shell
npm install -g wrangler
```

ログインは以下のコマンドで。

```shell
wrangler login
```

別途ブラウザを開いて OAuth してくれるのがありがたいです。

![setup-cli1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/f64bc161-20ca-30c4-e922-612bd64184b3.png)

初期化します。英語ですが、対話形式でセットアップしてくれるのが良かったです。TypeScript のセットアップもしてくれました。

```shell
wrangler init my-worker
```

ローカルで起動します。

```shell
cd my-worker
wrangler dev src/index.ts
```

以後、index.ts ファイルを編集・保存すると自動で更新してくれるのも良かったです。テストの実行は localhost 宛に curl してできます。何もいじっていない状態で、`Hello World` がちゃんと出てきました。`console.log()` のデバッグ結果なんかも `wrangler dev` しているウィンドウで出てきます。

```shell
$ curl localhost:8787
Hello World
```

デプロイも簡単です。

```shell
wrangler publish
```

## Cloudflare Workers から Slack への POST

Cloudflare はかなりサンプルが充実していまして、ここから必要なものだけ拾っていけばやりたいことは実現できました。

- [Examples · Cloudflare Workers docs](https://developers.cloudflare.com/workers/examples/)

今回は「ヘッダ認証」「POST で JSON を受け取り」「Slack へ POST」をしたかったので、以下のサンプルを参考にしました。

- [Auth with headers · Cloudflare Workers docs](https://developers.cloudflare.com/workers/examples/auth-with-headers/)
- [Read POST · Cloudflare Workers docs](https://developers.cloudflare.com/workers/examples/read-post/)
- [Post JSON · Cloudflare Workers docs](https://developers.cloudflare.com/workers/examples/post-json/)

書き方 (特に TypeScript が久しぶりだったので...) が正しいかはさておき、こんなプログラムを書きました。

```typescript
const PRESHARED_AUTH_HEADER_KEY = 'X-Custom-PSK';
const PRESHARED_AUTH_HEADER_VALUE = 'mypresharedkey';
const SLACK_URL = '###Slack の Incoming Webhook の準備」で控えた URL###';

export default {
  async fetch(request: Request): Promise<Response> {
    const requestHeaders = request.headers;

    if (requestHeaders.get(PRESHARED_AUTH_HEADER_KEY) != PRESHARED_AUTH_HEADER_VALUE) {
      return new Response("Invalid key", {
        status: 403
      });
    }

    if (request.method != 'POST') {
      return new Response("Method Not Allowed", {
        status: 405
      });
    }

    if (requestHeaders.get('content-type') != 'application/json') {
      return new Response("Unsupported Media Type", {
        status: 415
      });
    }

    const requestInput = JSON.stringify(await request.json());

    const message = JSON.stringify({
      "text": requestInput
    });

    const requestInit: RequestInit = {
      body: message,
      method: 'POST',
      headers: {
        'content-type': 'application/json;charset=UTF-8',
      },
    };

    const url = SLACK_URL;
    const response = await fetch(url, requestInit);

    return new Response(await response.text())

  },
};
```

実際に Slack へ投稿されるか、まずはローカルで検証します。

```shell
curl -X POST -d '{"foo":"bar"}' http://localhost:8787 -H 'X-Custom-PSK:mypresharedkey' -H 'content-type: application/json'
```

成功すると、こんな感じで出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/5796df30-8826-c9c0-99ff-b0e0cb1f7913.png)

`wrangler publish` でデプロイして、`https://{プロジェクト名}.{サブドメイン}.workers.dev` へのリクエストでも成功するか確認します。

```shell
curl -X POST -d '{"hoge":"fuga"}' https://{プロジェクト名}.{サブドメイン}.workers.dev -H 'X-Custom-PSK:mypresharedkey' -H 'content-type: application/json'
```

## SORACOM のセットアップ

今回は、[SORACOM LTE-M Button for Enterprise](https://soracom.jp/store/5206/) (Enterprise Button) を使いました。このデバイスは UDP で SORACOM へデータを送るので、SORACOM Beam の UDP → HTTP/HTTPS エントリポイントを使います。デバイスや SORACOM Beam のセットアップ詳細は、ドキュメントを参照してください。

- [SORACOM LTE-M Button for Enterprise ユーザーガイド | SORACOM LTE-M Button シリーズ | ソラコムユーザーサイト - SORACOM Users](https://users.soracom.io/ja-jp/guides/soracom-lte-m-button-series/lte-m-button-enterprise/)
- [エントリポイントリファレンス: UDP → HTTP/HTTPS エントリポイント | SORACOM Beam | ソラコムユーザーサイト - SORACOM Users](https://users.soracom.io/ja-jp/docs/beam/udp-http/)

まず、Enterprise Button を適当なグループへ所属させます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/2c1a2776-902b-1751-00f1-da7e8796152a.png)

次に、グループ名をクリックして、グループ設定画面にて「SORACOM Air for セルラー設定 > バイナリーパーサー」と「SORACOM Beam 設定」を編集・保存します。

「SORACOM Air for セルラー設定 > バイナリーパーサー」ではのフォーマットでは `@button` とカスタムフォーマットを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/aa8dac29-8855-1d83-14a7-97b8ea567970.png)

「SORACOM Beam 設定」では「設定を追加する」をクリックして、以下のように設定します。



| 設定項目 | 設定内容 | 備考 |
|:-:|:-:|:-:|
| プロトコル | HTTPS | - |
| 転送先 - ホスト名 | https://{プロジェクト名}.{サブドメイン}.workers.dev | Cloudflare の設定 |
| ポート番号 | 443 | - |
| パス | 空欄 | - |
| カスタムヘッダ - アクション | 追加 | - |
| カスタムヘッダ - ヘッダ名 | X-Custom-PSK | Cloudflare の設定に合わせます |
| カスタムヘッダ - 値 | mypresharedkey | Cloudflare の設定に合わせます |

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/0ac9524b-4b47-a593-c36f-e4c4aeacab21.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/83b1f0d7-9350-ccd3-29eb-473171f7ab32.png)

## SORACOM LTE-M Button for Enterprise からデータを送信

では試してみましょう...

https://twitter.com/n_mikuni/status/1525378627361447937?s=20&t=aFfOSh_ua3kSrKfBDDOYxQ

きました！これだけだと Cloudflare から来ているかわからないのが残念ですが、できました。

# まとめ

Cloudflare Workers、個人的には Amazon API Gateway + Lambda という感じでとても気に入りました。さらっと書けるので、Slack や LINE などへの投稿はこれで良いかも。次は KV、 R2 や D1 との組み合わせもやっていきたいと思います。

# 追記 - KV を使ってみた

KV を使ってみました。別記事に起こすほどではなかったので追記します。ただ、ちょっと使い方が正しいかは怪しいです。。本当は `env` を使わず、[こちらのドキュメント](https://developers.cloudflare.com/workers/platform/environment-variables/) を参照しつつ `KVNamespace` 型を定義するだけでいけると思ったのですが、想定したように動作しなかったのでいったん `env` を使っています。

まず、KV を作成します。`--preview` はローカルテスト用です。

```shell
$ wrangler kv:namespace create "TEST_KV"
:namespace create "TEST_KV" --preview ⛅️ wrangler 2.0.5 
-------------------
▲ [WARNING] No configured name present, using `worker` as a prefix for the title


🌀 Creating namespace with title "worker-TEST_KV"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
{ binding = "TEST_KV", id = "XXXXXXXXXXXXXXXXXXXXXXXXXXX" }
$ wrangler kv:namespace create "TEST_KV" --preview
 ⛅️ wrangler 2.0.5 
-------------------
▲ [WARNING] No configured name present, using `worker` as a prefix for the title


🌀 Creating namespace with title "worker-TEST_KV_preview"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
{ binding = "TEST_KV", preview_id = "YYYYYYYYYYYYYYYYYYYYYYYYYYYY" }
```

得られた `id` と `preview_id` を `wrangler.toml` に追記します。`binding` の名前は任意で良いです。

```toml
kv_namespaces = [
  { binding = "KV", id = "XXXXXXXXXXXXXXXXXXXXXXXXXXX", preview_id = "YYYYYYYYYYYYYYYYYYYYYYYYYYYY" }
]
```

KV に Key, Value ペアを追加していきます。先ほど得られた `id` と `preview_id` を使って CLI でやるのが楽です。今回は最初の例でグローバル変数にしていたものを KV に入れていきます。

```shell
id="XXXXXXXXXXXXXXXXXXXXXXXXXXX"
wrangler kv:key put PRESHARED_KEY "mypresharedkey" --namespace-id ${id}
wrangler kv:key put PRESHARED_KEY_HEADER "X-Custom-PSK" --namespace-id ${id}
wrangler kv:key put SLACK_URL "###Slack の Incoming Webhook の準備」で控えた URL###" --namespace-id ${id}

id="YYYYYYYYYYYYYYYYYYYYYYYYYYYY"
wrangler kv:key put PRESHARED_KEY "mypresharedkey" --namespace-id ${id}
wrangler kv:key put PRESHARED_KEY_HEADER "X-Custom-PSK" --namespace-id ${id}
wrangler kv:key put SLACK_URL "###Slack の Incoming Webhook の準備」で控えた URL###" --namespace-id ${id}
```

プログラムも、KV を使うように修正します。せっかくなので、ボタンをクリックされた回数をカウントアップするようにしてみました。

```typescript
export default {
  async fetch(request: Request, env): Promise<Response> {
    // Get parameters from KV
    const preSharedKey: string = await env.KV.get("PRESHARED_KEY");
    const preSharedKeyHeader: string = await env.KV.get("PRESHARED_KEY_HEADER");
    const slackUrl: string = await env.KV.get("SLACK_URL");

    // Confirm KV value is not null
    if (!(preSharedKey && preSharedKeyHeader && slackUrl)) {
      return new Response("Internal Server Error", {
        status: 500,
      });
    }

    const oldCount = await env.KV.get("COUNT");
    let newCount: Number;
    if (!oldCount) {
      newCount = 1;
    } else {
      newCount = Number(oldCount) + 1;
    }
    await env.KV.put("COUNT", newCount);

    // Authenticate with header
    const requestHeaders = request.headers;
    if (requestHeaders.get(preSharedKeyHeader) != preSharedKey) {
      return new Response("Invalid key", {
        status: 403,
      });
    }

    // Only allow POST request
    if (request.method != "POST") {
      return new Response("Method Not Allowed", {
        status: 405,
      });
    }

    // Only allow "application/json" content-type
    if (requestHeaders.get("content-type") != "application/json") {
      return new Response("Unsupported Media Type", {
        status: 415,
      });
    }

    // Post data to Slack using data from client
    const requestInput = JSON.stringify(await request.json());

    const message = JSON.stringify({
      text: "Click count is " + newCount + ". Value is " + requestInput,
    });

    const requestInit: RequestInit = {
      body: message,
      method: "POST",
      headers: {
        "content-type": "application/json;charset=UTF-8",
      },
    };

    const response = await fetch(slackUrl, requestInit);

    return new Response(await response.text());
  },
};

```

最後にデプロイすれば、KV を使うように変更されます。図のように、カウントアップすることが確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/228049fb-6aac-ae2e-41d3-e38f37017c91.png)

ただドキュメントを読んだところ、「一般的に、書き込みは比較的頻繁には行わず、読み取りは迅速かつ頻繁に行う必要があるユースケースに向いている。(DeepL 訳)」なので、複数デバイスからのデータアップロードで同じ KV を書き換えるとかはやめた方が良さそうです。

- [How KV works · Cloudflare Workers docs](https://developers.cloudflare.com/workers/learning/how-kv-works/)

とはいえ、やはりシンプルに使える良さがあるので、SORACOM との絡め方も今後考えていきたいと思います。