Arduino(ESP32)から次世代SNSプロトコル Nostrにアクセスしてみる

# はじめに
Arduino(ESP32)から次世代SNSネットワーク Nostrにアクセスしてみます。

かつては、Twitterとマイコンを繋いで遊ぶのが主流でしたが、
それと同程度以上のことが色々とできます。

かつてのUserStream並の高速な通信と、サーバー落ちに対する耐性を持ったNostrプロトコルで遊んでみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/32e19e9d-91c5-0c6b-f876-5b31aa031b20.png)

Nostrについては、以下を参照ください。

https://qiita.com/gpsnmeajp/items/2f95d4adf188276146d8

https://zenn.dev/mattn/articles/cf43423178d65c

## 事前準備
+ まず、Nostrのアカウント(鍵ペア)を作成してください。
+ nostterや、Damusなどアプリを使用して、Timelineなどの見え方を確認しておいてください。
+ 必要に応じて、実験用のアカウント(鍵ペア)を作成し、そちらもログインしておいてください。

## 環境構築

マイコンにはWi-Fi搭載ESP32を積んでいるこちらを使います。(ATOM Lite)

https://www.switch-science.com/products/6262

開発環境はこちらを参考に構築してください。

https://docs.m5stack.com/en/quick_start/atom/arduino

正しくセットアップできているとこうなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/f7da4ac9-f80f-8d5e-5633-3583a027e64c.png)

下記の画像を参考に、No OTAの設定をしておいてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/80a4dbc4-2077-72f1-26a6-c522fda7a732.png)

この状態で、ライブラリマネージャ(本棚のマーク)をクリックし、nostrを入れてArduino-nostrをインストールしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/ea67d57a-9a22-026f-983e-70a72cacc55f.png)

ArduinoJsonもインストールしておいてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/fb1779ea-db91-eb6e-5de1-a4b877039c21.png)


リファレンスは下記にあります。

https://www.arduino.cc/reference/en/libraries/nostr/

https://arduinojson.org/

## 原理の説明
Nostrの通信には、Websocketを使用します。
ほぼすべてのリレーはwss(Encrypted WebSocket connections)を使用しますので、TLS対応している必要があります。

書き込みにはさらに、ビットコインでおなじみのSchnorr署名(BIP 340)と、正確な時刻が必要になります。

これらを自前で実装するのは用意ではないため、Arduino-nostrライブラリを使用しています。
また、電文はJson形式のため、ArduinoJsonも使用します。

このあたりの通信仕様に関する詳細は、下記のnostr仕様を確認してください。

原文

https://github.com/nostr-protocol/nips/blob/master/01.md

日本語訳版

https://github.com/nostr-jp/nips-ja/blob/main/01.md

## TLを取得する
ではタイムラインを取得してみましょう。

```cpp
#include <NostrEvent.h>
#include <NostrQueueProcessor.h>
#include <NostrRelayManager.h>
#include <NostrRequestOptions.h>
#include <aes.h>
#include <aes.hpp>

NostrRelayManager nostrRelayManager;
StaticJsonDocument<4096> doc;

// イベント受信時コールバック
void nip01Event(const std::string& key, const char* payload) {
  DeserializationError error = deserializeJson(doc, payload);

  if (error) {
    Serial.print("deserializeJson() failed: ");
    Serial.println(error.c_str());
    return;
  }

  // Jsonパース結果を取得
  JsonObject root_2 = doc[2];
  const char* root_2_content = root_2["content"];
  Serial.print("> 受信した本文: ");
  Serial.print(root_2_content);
  Serial.println();
}

void setup() {
  Serial.begin(115200);

  // 無線LANに接続
  Serial.println("> Wifi Connect");
  WiFi.begin("SSIDをここに入れます", "パスワードをここに入れます");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  // 接続先リレー 一覧(日本語コミュニティで有名なもの)
  // 3個程度が限界らしく、5個入れるとheapエラーで落ちる。
  std::vector<String> relays = {
    "relay-jp.nostr.wirednet.jp",
    "nostr.fediverse.jp",
    "yabu.me",
//    "nrelay-jp.c-stellar.net",
//    "r.kojira.io",
  };
  nostrRelayManager.setRelays(relays);

  // 最低1接続必要とする。10秒経つと最低数に達していなくてもブロードキャストする。
  nostrRelayManager.setMinRelaysAndTimeout(1, 10000);

  // コールバックハンドラを設定
  nostrRelayManager.setEventCallback(1, nip01Event);

  // リレーに接続
  Serial.println("> Relay connect");
  nostrRelayManager.connect();

  delay(1000);

  // Subscribeを実施する
  NostrRequestOptions req{};

  // 受信対象はkind 1
  int kinds[] = { 1 };
  req.kinds = kinds;
  req.kinds_count = 1;

  // 初回受信個数は5
  req.limit = 5;

  // イベントキューに登録
  nostrRelayManager.requestEvents(&req);

  Serial.println("> OK");
}

void loop() {
  // Websocket処理を実施
  nostrRelayManager.loop();

  // キューに溜まっているイベントの処理を実行する
  nostrRelayManager.broadcastEvents();
}

```

書込み後、シリアルモニタを起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/2e28be85-8019-b20e-01c1-49f266457f40.png)

うまく接続できると、このようになります。
放置しておくと、次々と誰ともなくNostr上の発言が次々流れていきます。

```
entry 0x400805e4
> Wifi Connect
..> Relay connect
Relays are:
relay-jp.nostr.wirednet.jp
nostr.fediverse.jp
yabu.me
Connecting to relay: relay-jp.nostr.wirednet.jp
Connecting to relay: nostr.fediverse.jp
Connecting to relay: yabu.me
REQ looks like this
["REQ", "SQDBbnkdJvq2Je4Vep6SjWGCTfhrRipE92u9uz634XV6Bxw6pOqhCfd0Ziiaeu6d",{"kinds":[1],"limit":5}]
> OK
Message from relay index:2
[WSc] Disconnected from relay: .
Message from relay index:1
[WSc] Connected to relay: .
Broadcasting event: ["REQ", "SQDBbnkdJvq2Je4Vep6SjWGCTfhrRipE92u9uz634XV6Bxw6pOqhCfd0Ziiaeu6d",{"kinds":[1],"limit":5}]
Message from relay index:1
[WSc]: Received text from relay .: ["EVENT","SQDBbnkdJvq2Je4Vep6SjWGCTfhrRipE92u9uz634XV6Bxw6pOqhCfd0Ziiaeu6d",{"id":"b18f6008b83fa1ab98c111ae0c7a59e29c2844e65c9857831b8c288830068c53","kind":1,"pubkey":"b707d6be7fd9cc9e1aee83e81c3994156cfcf74ded5b09111930fdeeeb5a0c20","created_at":1704281720,"content":"色褪せたカニ","tags":[],"sig":"2be00da6bfb240584a294e2924d748b5820a86169d75b6bafbcadbd2b1dc8b224501e9f6f44c2bdcae1affa6de4a6958d9c8fa7a2e684bdcb23454dea17d1e79"}]
> 受信した本文: 色褪せたカニ
```

## 特定のユーザーの情報を取得する

このままでは、無数のNostr住民の声を垂れ流すだけになってしまいます。
なにか有用な使い方はできないか、ということで、緊急地震速報を提供してくれている「なまずくん」を購読してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/b550f6e9-1b01-fdbe-f2aa-fa32597ac201.png)

https://nostter.app/npub1namazu7um9xvgfpax6yrk9tl3segxpgac67jx7cuttzqp7usem9sqavlhz

このアカウントのID(公開鍵)は以下になります。

```
npub1namazu7um9xvgfpax6yrk9tl3segxpgac67jx7cuttzqp7usem9sqavlhz
```

このnpub表記は、人間にわかりやすくするためのものなので、HEX表記に変換する必要があります。
簡単には、下記のサイトで変換することができます。

https://nostrcheck.me/converter/

変換すると以下のようになります。

```
9f77d173dcd94cc4243d36883b157f8c3283051dc6bd237b1c5ac400fb90cecb
```

受信条件に、これを追加したものが以下になります。

```cpp
#include <NostrEvent.h>
#include <NostrQueueProcessor.h>
#include <NostrRelayManager.h>
#include <NostrRequestOptions.h>
#include <aes.h>
#include <aes.hpp>

NostrRelayManager nostrRelayManager;
StaticJsonDocument<4096> doc;

// イベント受信時コールバック
void nip01Event(const std::string& key, const char* payload) {
  DeserializationError error = deserializeJson(doc, payload);

  if (error) {
    Serial.print("deserializeJson() failed: ");
    Serial.println(error.c_str());
    return;
  }

  // Jsonパース結果を取得
  JsonObject root_2 = doc[2];
  const char* root_2_content = root_2["content"];
  Serial.print("> 受信した本文: ");
  Serial.print(root_2_content);
  Serial.println();
}

void setup() {
  Serial.begin(115200);

  // 無線LANに接続
  Serial.println("> Wifi Connect");
  WiFi.begin("SSIDをここに入れます", "パスワードをここに入れます");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  // 接続先リレー 一覧(日本語コミュニティで有名なもの)
  // 3個程度が限界らしく、5個入れるとheapエラーで落ちる。
  std::vector<String> relays = {
    "relay-jp.nostr.wirednet.jp",
    "nostr.fediverse.jp",
    "yabu.me",
//    "nrelay-jp.c-stellar.net",
//    "r.kojira.io",
  };
  nostrRelayManager.setRelays(relays);

  // 最低1接続必要とする。10秒経つと最低数に達していなくてもブロードキャストする。
  nostrRelayManager.setMinRelaysAndTimeout(1, 10000);

  // コールバックハンドラを設定
  nostrRelayManager.setEventCallback(1, nip01Event);

  // リレーに接続
  Serial.println("> Relay connect");
  nostrRelayManager.connect();

  delay(1000);

  // Subscribeを実施する
  NostrRequestOptions req{};

  // 受信対象はkind 1
  int kinds[] = { 1 };
  req.kinds = kinds;
  req.kinds_count = 1;

  // 発信者を絞る (※※※追加箇所※※※)
  String authors[] = { "9f77d173dcd94cc4243d36883b157f8c3283051dc6bd237b1c5ac400fb90cecb" };
  req.authors = authors;
  req.authors_count = 1;

  // 初回受信個数は1
  req.limit = 1;

  // イベントキューに登録
  nostrRelayManager.requestEvents(&req);

  Serial.println("> OK");
}

void loop() {
  // Websocket処理を実施
  nostrRelayManager.loop();

  // キューに溜まっているイベントの処理を実行する
  nostrRelayManager.broadcastEvents();
}

```

以下のような出力が得られます。
地震が発生するとリアルタイムに追加で出力されるはずです。

```
Message from relay index:1
[WSc]: Received text from relay .: ["EVENT","fNuynsc3TYVA89uKJXv6MANmeWbKjl9EzJHHTnP0qINUE7pP9JKktD6vswErbWbe",{"id":"9e7cbbded06479eec4d07f97b30474ccfed17d4cf478dc5d719d7d880c89a9da","kind":1,"pubkey":"9f77d173dcd94cc4243d36883b157f8c3283051dc6bd237b1c5ac400fb90cecb","created_at":1703892336,"content":"**緊急地震速報（予報）** 第1報\n30日08時25分ごろ、地震がありました。\n震源地は宮古島近海（北緯25.2度、東経125.6度）で震源の深さは約20km、地震の規模（マグニチュード）は3.6、この地震による最大震度は震度2と推定されます。\nhttps://earthquake.tenki.jp/bousai/earthquake/detail/2023/12/30/2023-12-30-08-25-25.html","tags":[],"sig":"1292b4a5db6c6b05182eab9e6b5f65d2a63506e9fcc09b6b59762d2b4a1a0eb852c7507acdb2a16e364281cb8a50c45c3319591cc98e83ed7a3bdf74b8b78f14"}]
> 受信した本文: **緊急地震速報（予報）** 第1報
30日08時25分ごろ、地震がありました。
震源地は宮古島近海（北緯25.2度、東経125.6度）で震源の深さは約20km、地震の規模（マグニチュード）は3.6、この地震による最大震度は震度2と推定されます。
https://earthquake.tenki.jp/bousai/earthquake/detail/2023/12/30/2023-12-30-08-25-25.html
```

## 書き込む
読み込みだけでは面白くありません。書き込みもやりましょう。
前述した通り、書き込みには、電子署名と正確な時刻が必要になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/00e0149a-f3a6-bb39-3ad3-fd8168c0b7d3.png)

コードは以下です。

```cpp
#include <NostrEvent.h>
#include <NostrQueueProcessor.h>
#include <NostrRelayManager.h>
#include <NostrRequestOptions.h>
#include <aes.h>
#include <aes.hpp>

NostrEvent nostr;
NostrRelayManager nostrRelayManager;

void setup() {
  Serial.begin(115200);

  // 無線LANに接続
  Serial.println("> Wifi Connect");
  WiFi.begin("SSIDをここに入れます", "パスワードをここに入れます");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  // 接続先リレー 一覧(日本語コミュニティで有名なもの)
  // 3個程度が限界らしく、5個入れるとheapエラーで落ちる。
  std::vector<String> relays = {
    "relay-jp.nostr.wirednet.jp",
    "nostr.fediverse.jp",
    "yabu.me",
//    "nrelay-jp.c-stellar.net",
//    "r.kojira.io",
  };
  nostrRelayManager.setRelays(relays);

  // 最低1接続必要とする。10秒経つと最低数に達していなくてもブロードキャストする。
  nostrRelayManager.setMinRelaysAndTimeout(1, 10000);

  // リレーに接続
  Serial.println("> Relay connect");
  nostrRelayManager.connect();

  delay(1000);

  // 時刻調整を実施(UTC)
  configTime(0, 0, "ntp.nict.jp");
  struct tm lt;
  getLocalTime(&lt); 

  Serial.println("> OK");

  // ----------------------------
  // 投稿を実施
  const char* nsec = "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"; // あなたの秘密鍵HEX
  const char* npub = "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"; // あなたの公開鍵HEX
  const char* message = "こんにちは。これはEPS32からの投稿テストです。"; // 本文
  time_t t;
  time(&t); // 現在時刻

  String s = nostr.getNote(nsec, npub, t, message); // 署名処理を実行
  nostrRelayManager.enqueueMessage(s.c_str()); // 処理キューに押し込む
  // ----------------------------

  Serial.println("> Sent");
}

void loop() {
  // Websocket処理を実施
  nostrRelayManager.loop();

  // キューに溜まっているイベントの処理を実行する
  nostrRelayManager.broadcastEvents();
}
```

:::note warn
**秘密鍵は外部に漏らさないように注意してください。**
可能な限り、常用のアカウント(鍵ペア)ではなく、実験用のアカウント(鍵ペア)を作成して使用することをお勧めします。
:::

:::note alert
**NostrにはAPI制限が明確になく、それ故に送信処理を無限ループにすると、大量の投稿がリレーに向かって送信され、Timelineを埋め尽くします。**
(rate-limited: slow down there chiefの応答が帰る場合もあります。)

そのまま放置した場合、接続中のリレーから秘密鍵あるいはIPアドレスごとBANされることになります。

Nostrの性質上、海外含めた多数のリレーが使用できるため、BANの影響は少ないですが、日本リレーからBANされた場合、日本語コミュニティとの交流に支障をきたすことになりますので、ご注意ください。

必要に応じて、自前で試験用のリレーを立てるなど、影響を少なくする方法を検討してください。

また、リレーごとのポリシーについては、各リレーが提供するNIP-11情報(リレーメタ情報)を参照してください。対応しているアプリケーションではGUIで確認することができます。
:::

## おわりに
TwitterのAPIが色々と不便になり、結構な時間が経ちました。
MastodonのAPIで遊ぶなど、代替手段は色々とありますが、
その1つとしてnostrで遊んでみるのも楽しいです。

汎用クライアントとして使用するには、kind 0のプロフィール情報や、kind 3フォローリストなどを扱う必要があり、そこをやり始めると少々手間ではあります。
一方でWebsocketの速報性もあり、楽しく利用できる使い方もあると思いますので、是非試してみてください。

冒頭の写真は、M5GFXとM5AtomDisplayを使用して、HDMIモニターに地震情報を出力しています。

:::note warn
注意
**Nostr Assets Protocol**および**Nostrトークン**は、Nostrの名前を勝手に使用している無関係の(おそらく詐欺)通貨です。混同しないようにご注意ください。
:::