はじめに
Arduino(ESP32)から次世代SNSネットワーク Nostrにアクセスしてみます。
かつては、Twitterとマイコンを繋いで遊ぶのが主流でしたが、
それと同程度以上のことが色々とできます。
かつてのUserStream並の高速な通信と、サーバー落ちに対する耐性を持ったNostrプロトコルで遊んでみましょう。
Nostrについては、以下を参照ください。
事前準備
- まず、Nostrのアカウント(鍵ペア)を作成してください。
- nostterや、Damusなどアプリを使用して、Timelineなどの見え方を確認しておいてください。
- 必要に応じて、実験用のアカウント(鍵ペア)を作成し、そちらもログインしておいてください。
環境構築
マイコンにはWi-Fi搭載ESP32を積んでいるこちらを使います。(ATOM Lite)
開発環境はこちらを参考に構築してください。
正しくセットアップできているとこうなります。
下記の画像を参考に、No OTAの設定をしておいてください。
この状態で、ライブラリマネージャ(本棚のマーク)をクリックし、nostrを入れてArduino-nostrをインストールしてください。
ArduinoJsonもインストールしておいてください。
リファレンスは下記にあります。
原理の説明
Nostrの通信には、Websocketを使用します。
ほぼすべてのリレーはwss(Encrypted WebSocket connections)を使用しますので、TLS対応している必要があります。
書き込みにはさらに、ビットコインでおなじみのSchnorr署名(BIP 340)と、正確な時刻が必要になります。
これらを自前で実装するのは用意ではないため、Arduino-nostrライブラリを使用しています。
また、電文はJson形式のため、ArduinoJsonも使用します。
このあたりの通信仕様に関する詳細は、下記のnostr仕様を確認してください。
原文
日本語訳版
TLを取得する
ではタイムラインを取得してみましょう。
#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();
}
書込み後、シリアルモニタを起動します。
うまく接続できると、このようになります。
放置しておくと、次々と誰ともなく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住民の声を垂れ流すだけになってしまいます。
なにか有用な使い方はできないか、ということで、緊急地震速報を提供してくれている「なまずくん」を購読してみましょう。
このアカウントのID(公開鍵)は以下になります。
npub1namazu7um9xvgfpax6yrk9tl3segxpgac67jx7cuttzqp7usem9sqavlhz
このnpub表記は、人間にわかりやすくするためのものなので、HEX表記に変換する必要があります。
簡単には、下記のサイトで変換することができます。
変換すると以下のようになります。
9f77d173dcd94cc4243d36883b157f8c3283051dc6bd237b1c5ac400fb90cecb
受信条件に、これを追加したものが以下になります。
#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
書き込む
読み込みだけでは面白くありません。書き込みもやりましょう。
前述した通り、書き込みには、電子署名と正確な時刻が必要になります。
コードは以下です。
#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(<);
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();
}
秘密鍵は外部に漏らさないように注意してください。
可能な限り、常用のアカウント(鍵ペア)ではなく、実験用のアカウント(鍵ペア)を作成して使用することをお勧めします。
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モニターに地震情報を出力しています。
注意
Nostr Assets ProtocolおよびNostrトークンは、Nostrの名前を勝手に使用している無関係の(おそらく詐欺)通貨です。混同しないようにご注意ください。