workspace with decoupled nostr package
This commit is contained in:
458
packages/nostr/src/Connection.ts
Normal file
458
packages/nostr/src/Connection.ts
Normal file
@ -0,0 +1,458 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { Subscriptions } from "./Subscriptions";
|
||||
import { default as NEvent } from "./Event";
|
||||
import { DefaultConnectTimeout } from "./Const";
|
||||
import { ConnectionStats } from "./ConnectionStats";
|
||||
import { RawEvent, RawReqFilter, TaggedRawEvent, u256 } from "./index";
|
||||
import { RelayInfo } from "./RelayInfo";
|
||||
import Nips from "./Nips";
|
||||
import { System } from "./System";
|
||||
import { unwrap } from "./Util";
|
||||
|
||||
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
||||
|
||||
/**
|
||||
* Relay settings
|
||||
*/
|
||||
export type RelaySettings = {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot of connection stats
|
||||
*/
|
||||
export type StateSnapshot = {
|
||||
connected: boolean;
|
||||
disconnects: number;
|
||||
avgLatency: number;
|
||||
events: {
|
||||
received: number;
|
||||
send: number;
|
||||
};
|
||||
info?: RelayInfo;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export default class Connection {
|
||||
Id: string;
|
||||
Address: string;
|
||||
Socket: WebSocket | null;
|
||||
Pending: Array<RawReqFilter>;
|
||||
Subscriptions: Map<string, Subscriptions>;
|
||||
Settings: RelaySettings;
|
||||
Info?: RelayInfo;
|
||||
ConnectTimeout: number;
|
||||
Stats: ConnectionStats;
|
||||
StateHooks: Map<string, CustomHook>;
|
||||
HasStateChange: boolean;
|
||||
CurrentState: StateSnapshot;
|
||||
LastState: Readonly<StateSnapshot>;
|
||||
IsClosed: boolean;
|
||||
ReconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
||||
AwaitingAuth: Map<string, boolean>;
|
||||
Authed: boolean;
|
||||
|
||||
constructor(addr: string, options: RelaySettings) {
|
||||
this.Id = uuid();
|
||||
this.Address = addr;
|
||||
this.Socket = null;
|
||||
this.Pending = [];
|
||||
this.Subscriptions = new Map();
|
||||
this.Settings = options;
|
||||
this.ConnectTimeout = DefaultConnectTimeout;
|
||||
this.Stats = new ConnectionStats();
|
||||
this.StateHooks = new Map();
|
||||
this.HasStateChange = true;
|
||||
this.CurrentState = {
|
||||
connected: false,
|
||||
disconnects: 0,
|
||||
avgLatency: 0,
|
||||
events: {
|
||||
received: 0,
|
||||
send: 0,
|
||||
},
|
||||
} as StateSnapshot;
|
||||
this.LastState = Object.freeze({ ...this.CurrentState });
|
||||
this.IsClosed = false;
|
||||
this.ReconnectTimer = null;
|
||||
this.EventsCallback = new Map();
|
||||
this.AwaitingAuth = new Map();
|
||||
this.Authed = false;
|
||||
this.Connect();
|
||||
}
|
||||
|
||||
async Connect() {
|
||||
try {
|
||||
if (this.Info === undefined) {
|
||||
const u = new URL(this.Address);
|
||||
const rsp = await fetch(`https://${u.host}`, {
|
||||
headers: {
|
||||
accept: "application/nostr+json",
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const data = await rsp.json();
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v === "unset" || v === "") {
|
||||
data[k] = undefined;
|
||||
}
|
||||
}
|
||||
this.Info = data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not load relay information", e);
|
||||
}
|
||||
|
||||
if (this.IsClosed) {
|
||||
this._UpdateState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.IsClosed = false;
|
||||
this.Socket = new WebSocket(this.Address);
|
||||
this.Socket.onopen = () => this.OnOpen();
|
||||
this.Socket.onmessage = (e) => this.OnMessage(e);
|
||||
this.Socket.onerror = (e) => this.OnError(e);
|
||||
this.Socket.onclose = (e) => this.OnClose(e);
|
||||
}
|
||||
|
||||
Close() {
|
||||
this.IsClosed = true;
|
||||
if (this.ReconnectTimer !== null) {
|
||||
clearTimeout(this.ReconnectTimer);
|
||||
this.ReconnectTimer = null;
|
||||
}
|
||||
this.Socket?.close();
|
||||
this._UpdateState();
|
||||
}
|
||||
|
||||
OnOpen() {
|
||||
this.ConnectTimeout = DefaultConnectTimeout;
|
||||
this._InitSubscriptions();
|
||||
console.log(`[${this.Address}] Open!`);
|
||||
}
|
||||
|
||||
OnClose(e: CloseEvent) {
|
||||
if (!this.IsClosed) {
|
||||
this.ConnectTimeout = this.ConnectTimeout * 2;
|
||||
console.log(
|
||||
`[${this.Address}] Closed (${e.reason}), trying again in ${(
|
||||
this.ConnectTimeout / 1000
|
||||
)
|
||||
.toFixed(0)
|
||||
.toLocaleString()} sec`
|
||||
);
|
||||
this.ReconnectTimer = setTimeout(() => {
|
||||
this.Connect();
|
||||
}, this.ConnectTimeout);
|
||||
this.Stats.Disconnects++;
|
||||
} else {
|
||||
console.log(`[${this.Address}] Closed!`);
|
||||
this.ReconnectTimer = null;
|
||||
}
|
||||
this._UpdateState();
|
||||
}
|
||||
|
||||
OnMessage(e: MessageEvent) {
|
||||
if (e.data.length > 0) {
|
||||
const msg = JSON.parse(e.data);
|
||||
const tag = msg[0];
|
||||
switch (tag) {
|
||||
case "AUTH": {
|
||||
this._OnAuthAsync(msg[1]);
|
||||
this.Stats.EventsReceived++;
|
||||
this._UpdateState();
|
||||
break;
|
||||
}
|
||||
case "EVENT": {
|
||||
this._OnEvent(msg[1], msg[2]);
|
||||
this.Stats.EventsReceived++;
|
||||
this._UpdateState();
|
||||
break;
|
||||
}
|
||||
case "EOSE": {
|
||||
this._OnEnd(msg[1]);
|
||||
break;
|
||||
}
|
||||
case "OK": {
|
||||
// feedback to broadcast call
|
||||
console.debug(`${this.Address} OK: `, msg);
|
||||
const id = msg[1];
|
||||
if (this.EventsCallback.has(id)) {
|
||||
const cb = unwrap(this.EventsCallback.get(id));
|
||||
this.EventsCallback.delete(id);
|
||||
cb(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "NOTICE": {
|
||||
console.warn(`[${this.Address}] NOTICE: ${msg[1]}`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn(`Unknown tag: ${tag}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnError(e: Event) {
|
||||
console.error(e);
|
||||
this._UpdateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event on this connection
|
||||
*/
|
||||
SendEvent(e: NEvent) {
|
||||
if (!this.Settings.write) {
|
||||
return;
|
||||
}
|
||||
const req = ["EVENT", e.ToObject()];
|
||||
this._SendJson(req);
|
||||
this.Stats.EventsSent++;
|
||||
this._UpdateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event on this connection and wait for OK response
|
||||
*/
|
||||
async SendAsync(e: NEvent, timeout = 5000) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!this.Settings.write) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
resolve();
|
||||
}, timeout);
|
||||
this.EventsCallback.set(e.Id, () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
|
||||
const req = ["EVENT", e.ToObject()];
|
||||
this._SendJson(req);
|
||||
this.Stats.EventsSent++;
|
||||
this._UpdateState();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to data from this connection
|
||||
*/
|
||||
AddSubscription(sub: Subscriptions) {
|
||||
if (!this.Settings.read) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check relay supports search
|
||||
if (sub.Search && !this.SupportsNip(Nips.Search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.Subscriptions.has(sub.Id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._SendSubscription(sub);
|
||||
this.Subscriptions.set(sub.Id, sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription
|
||||
*/
|
||||
RemoveSubscription(subId: string) {
|
||||
if (this.Subscriptions.has(subId)) {
|
||||
const req = ["CLOSE", subId];
|
||||
this._SendJson(req);
|
||||
this.Subscriptions.delete(subId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook status for connection
|
||||
*/
|
||||
StatusHook(fnHook: CustomHook) {
|
||||
const id = uuid();
|
||||
this.StateHooks.set(id, fnHook);
|
||||
return () => {
|
||||
this.StateHooks.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state of this connection
|
||||
*/
|
||||
GetState() {
|
||||
if (this.HasStateChange) {
|
||||
this.LastState = Object.freeze({ ...this.CurrentState });
|
||||
this.HasStateChange = false;
|
||||
}
|
||||
return this.LastState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using relay document to determine if this relay supports a feature
|
||||
*/
|
||||
SupportsNip(n: number) {
|
||||
return this.Info?.supported_nips?.some((a) => a === n) ?? false;
|
||||
}
|
||||
|
||||
_UpdateState() {
|
||||
this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN;
|
||||
this.CurrentState.events.received = this.Stats.EventsReceived;
|
||||
this.CurrentState.events.send = this.Stats.EventsSent;
|
||||
this.CurrentState.avgLatency =
|
||||
this.Stats.Latency.length > 0
|
||||
? this.Stats.Latency.reduce((acc, v) => acc + v, 0) /
|
||||
this.Stats.Latency.length
|
||||
: 0;
|
||||
this.CurrentState.disconnects = this.Stats.Disconnects;
|
||||
this.CurrentState.info = this.Info;
|
||||
this.CurrentState.id = this.Id;
|
||||
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
|
||||
this.HasStateChange = true;
|
||||
this._NotifyState();
|
||||
}
|
||||
|
||||
_NotifyState() {
|
||||
const state = this.GetState();
|
||||
for (const [, h] of this.StateHooks) {
|
||||
h(state);
|
||||
}
|
||||
}
|
||||
|
||||
_InitSubscriptions() {
|
||||
// send pending
|
||||
for (const p of this.Pending) {
|
||||
this._SendJson(p);
|
||||
}
|
||||
this.Pending = [];
|
||||
|
||||
for (const [, s] of this.Subscriptions) {
|
||||
this._SendSubscription(s);
|
||||
}
|
||||
this._UpdateState();
|
||||
}
|
||||
|
||||
_SendSubscription(sub: Subscriptions) {
|
||||
if (!this.Authed && this.AwaitingAuth.size > 0) {
|
||||
this.Pending.push(sub.ToObject());
|
||||
return;
|
||||
}
|
||||
|
||||
let req = ["REQ", sub.Id, sub.ToObject()];
|
||||
if (sub.OrSubs.length > 0) {
|
||||
req = [...req, ...sub.OrSubs.map((o) => o.ToObject())];
|
||||
}
|
||||
sub.Started.set(this.Address, new Date().getTime());
|
||||
this._SendJson(req);
|
||||
}
|
||||
|
||||
_SendJson(obj: object) {
|
||||
if (this.Socket?.readyState !== WebSocket.OPEN) {
|
||||
this.Pending.push(obj);
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(obj);
|
||||
this.Socket.send(json);
|
||||
}
|
||||
|
||||
_OnEvent(subId: string, ev: RawEvent) {
|
||||
if (this.Subscriptions.has(subId)) {
|
||||
//this._VerifySig(ev);
|
||||
const tagged: TaggedRawEvent = {
|
||||
...ev,
|
||||
relays: [this.Address],
|
||||
};
|
||||
this.Subscriptions.get(subId)?.OnEvent(tagged);
|
||||
} else {
|
||||
// console.warn(`No subscription for event! ${subId}`);
|
||||
// ignored for now, track as "dropped event" with connection stats
|
||||
}
|
||||
}
|
||||
|
||||
async _OnAuthAsync(challenge: string): Promise<void> {
|
||||
const authCleanup = () => {
|
||||
this.AwaitingAuth.delete(challenge);
|
||||
};
|
||||
this.AwaitingAuth.set(challenge, true);
|
||||
const authEvent = await System.nip42Auth(challenge, this.Address);
|
||||
return new Promise((resolve) => {
|
||||
if (!authEvent) {
|
||||
authCleanup();
|
||||
return Promise.reject("no event");
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
authCleanup();
|
||||
resolve();
|
||||
}, 10_000);
|
||||
|
||||
this.EventsCallback.set(authEvent.Id, (msg: boolean[]) => {
|
||||
clearTimeout(t);
|
||||
authCleanup();
|
||||
if (msg.length > 3 && msg[2] === true) {
|
||||
this.Authed = true;
|
||||
this._InitSubscriptions();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
const req = ["AUTH", authEvent.ToObject()];
|
||||
this._SendJson(req);
|
||||
this.Stats.EventsSent++;
|
||||
this._UpdateState();
|
||||
});
|
||||
}
|
||||
|
||||
_OnEnd(subId: string) {
|
||||
const sub = this.Subscriptions.get(subId);
|
||||
if (sub) {
|
||||
const now = new Date().getTime();
|
||||
const started = sub.Started.get(this.Address);
|
||||
sub.Finished.set(this.Address, now);
|
||||
if (started) {
|
||||
const responseTime = now - started;
|
||||
if (responseTime > 10_000) {
|
||||
console.warn(
|
||||
`[${this.Address}][${subId}] Slow response time ${(
|
||||
responseTime / 1000
|
||||
).toFixed(1)} seconds`
|
||||
);
|
||||
}
|
||||
this.Stats.Latency.push(responseTime);
|
||||
} else {
|
||||
console.warn("No started timestamp!");
|
||||
}
|
||||
sub.OnEnd(this);
|
||||
this._UpdateState();
|
||||
} else {
|
||||
console.warn(`No subscription for end! ${subId}`);
|
||||
}
|
||||
}
|
||||
|
||||
_VerifySig(ev: RawEvent) {
|
||||
const payload = [0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content];
|
||||
|
||||
const payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||
if (secp.utils.sha256Sync === undefined) {
|
||||
throw "Cannot verify event, no sync sha256 method";
|
||||
}
|
||||
const data = secp.utils.sha256Sync(payloadData);
|
||||
const hash = secp.utils.bytesToHex(data);
|
||||
if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) {
|
||||
throw "Sig verify failed";
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
}
|
43
packages/nostr/src/ConnectionStats.ts
Normal file
43
packages/nostr/src/ConnectionStats.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Stats class for tracking metrics per connection
|
||||
*/
|
||||
export class ConnectionStats {
|
||||
/**
|
||||
* Last n records of how long between REQ->EOSE
|
||||
*/
|
||||
Latency: number[];
|
||||
|
||||
/**
|
||||
* Total number of REQ's sent on this connection
|
||||
*/
|
||||
Subs: number;
|
||||
|
||||
/**
|
||||
* Count of REQ which took too long and where abandoned
|
||||
*/
|
||||
SubsTimeout: number;
|
||||
|
||||
/**
|
||||
* Total number of EVENT messages received
|
||||
*/
|
||||
EventsReceived: number;
|
||||
|
||||
/**
|
||||
* Total number of EVENT messages sent
|
||||
*/
|
||||
EventsSent: number;
|
||||
|
||||
/**
|
||||
* Total number of times this connection was lost
|
||||
*/
|
||||
Disconnects: number;
|
||||
|
||||
constructor() {
|
||||
this.Latency = [];
|
||||
this.Subs = 0;
|
||||
this.SubsTimeout = 0;
|
||||
this.EventsReceived = 0;
|
||||
this.EventsSent = 0;
|
||||
this.Disconnects = 0;
|
||||
}
|
||||
}
|
149
packages/nostr/src/Const.ts
Normal file
149
packages/nostr/src/Const.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { RelaySettings } from "./Connection";
|
||||
|
||||
/**
|
||||
* Add-on api for snort features
|
||||
*/
|
||||
export const ApiHost = "https://api.snort.social";
|
||||
|
||||
/**
|
||||
* LibreTranslate endpoint
|
||||
*/
|
||||
export const TranslateHost = "https://translate.snort.social";
|
||||
|
||||
/**
|
||||
* Void.cat file upload service url
|
||||
*/
|
||||
export const VoidCatHost = "https://void.cat";
|
||||
|
||||
/**
|
||||
* Kierans pubkey
|
||||
*/
|
||||
export const KieranPubKey =
|
||||
"npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
|
||||
|
||||
/**
|
||||
* Official snort account
|
||||
*/
|
||||
export const SnortPubKey =
|
||||
"npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||
|
||||
/**
|
||||
* Websocket re-connect timeout
|
||||
*/
|
||||
export const DefaultConnectTimeout = 2000;
|
||||
|
||||
/**
|
||||
* How long profile cache should be considered valid for
|
||||
*/
|
||||
export const ProfileCacheExpire = 1_000 * 60 * 5;
|
||||
|
||||
/**
|
||||
* Default bootstrap relays
|
||||
*/
|
||||
export const DefaultRelays = new Map<string, RelaySettings>([
|
||||
["wss://relay.snort.social", { read: true, write: true }],
|
||||
["wss://eden.nostr.land", { read: true, write: true }],
|
||||
["wss://atlas.nostr.land", { read: true, write: true }],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Default search relays
|
||||
*/
|
||||
export const SearchRelays = new Map<string, RelaySettings>([
|
||||
["wss://relay.nostr.band", { read: true, write: false }],
|
||||
]);
|
||||
|
||||
/**
|
||||
* List of recommended follows for new users
|
||||
*/
|
||||
export const RecommendedFollows = [
|
||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
|
||||
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
|
||||
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
|
||||
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
|
||||
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
|
||||
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
|
||||
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
|
||||
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
|
||||
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
|
||||
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
|
||||
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
|
||||
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
|
||||
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
|
||||
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
|
||||
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
|
||||
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
|
||||
];
|
||||
|
||||
/**
|
||||
* Regex to match email address
|
||||
*/
|
||||
export const EmailRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
/**
|
||||
* Generic URL regex
|
||||
*/
|
||||
export const UrlRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
|
||||
|
||||
/**
|
||||
* Extract file extensions regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const FileExtensionRegex = /\.([\w]+)$/i;
|
||||
|
||||
/**
|
||||
* Extract note reactions regex
|
||||
*/
|
||||
export const MentionRegex = /(#\[\d+\])/gi;
|
||||
|
||||
/**
|
||||
* Simple lightning invoice regex
|
||||
*/
|
||||
export const InvoiceRegex = /(lnbc\w+)/i;
|
||||
|
||||
/**
|
||||
* YouTube URL regex
|
||||
*/
|
||||
export const YoutubeUrlRegex =
|
||||
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
|
||||
|
||||
/**
|
||||
* Tweet Regex
|
||||
*/
|
||||
export const TweetUrlRegex =
|
||||
/https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
|
||||
|
||||
/**
|
||||
* Hashtag regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/;
|
||||
|
||||
/**
|
||||
* Tidal share link regex
|
||||
*/
|
||||
export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
|
||||
|
||||
/**
|
||||
* SoundCloud regex
|
||||
*/
|
||||
export const SoundCloudRegex =
|
||||
/soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
/**
|
||||
* Mixcloud regex
|
||||
*/
|
||||
|
||||
export const MixCloudRegex =
|
||||
/mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
export const SpotifyRegex =
|
||||
/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
|
214
packages/nostr/src/Event.ts
Normal file
214
packages/nostr/src/Event.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import * as base64 from "@protobufjs/base64";
|
||||
import { HexKey, RawEvent, TaggedRawEvent } from "./index";
|
||||
import EventKind from "./EventKind";
|
||||
import Tag from "./Tag";
|
||||
import Thread from "./Thread";
|
||||
|
||||
export default class Event {
|
||||
/**
|
||||
* The original event
|
||||
*/
|
||||
Original: TaggedRawEvent | null;
|
||||
|
||||
/**
|
||||
* Id of the event
|
||||
*/
|
||||
Id: string;
|
||||
|
||||
/**
|
||||
* Pub key of the creator
|
||||
*/
|
||||
PubKey: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the event was created
|
||||
*/
|
||||
CreatedAt: number;
|
||||
|
||||
/**
|
||||
* The type of event
|
||||
*/
|
||||
Kind: EventKind;
|
||||
|
||||
/**
|
||||
* A list of metadata tags
|
||||
*/
|
||||
Tags: Array<Tag>;
|
||||
|
||||
/**
|
||||
* Content of the event
|
||||
*/
|
||||
Content: string;
|
||||
|
||||
/**
|
||||
* Signature of this event from the creator
|
||||
*/
|
||||
Signature: string;
|
||||
|
||||
/**
|
||||
* Thread information for this event
|
||||
*/
|
||||
Thread: Thread | null;
|
||||
|
||||
constructor(e?: TaggedRawEvent) {
|
||||
this.Original = e ?? null;
|
||||
this.Id = e?.id ?? "";
|
||||
this.PubKey = e?.pubkey ?? "";
|
||||
this.CreatedAt = e?.created_at ?? Math.floor(new Date().getTime() / 1000);
|
||||
this.Kind = e?.kind ?? EventKind.Unknown;
|
||||
this.Tags = e?.tags.map((a, i) => new Tag(a, i)) ?? [];
|
||||
this.Content = e?.content ?? "";
|
||||
this.Signature = e?.sig ?? "";
|
||||
this.Thread = Thread.ExtractThread(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pub key of the creator of this event NIP-26
|
||||
*/
|
||||
get RootPubKey() {
|
||||
const delegation = this.Tags.find((a) => a.Key === "delegation");
|
||||
if (delegation?.PubKey) {
|
||||
return delegation.PubKey;
|
||||
}
|
||||
return this.PubKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign this message with a private key
|
||||
*/
|
||||
async Sign(key: HexKey) {
|
||||
this.Id = await this.CreateId();
|
||||
|
||||
const sig = await secp.schnorr.sign(this.Id, key);
|
||||
this.Signature = secp.utils.bytesToHex(sig);
|
||||
if (!(await this.Verify())) {
|
||||
throw "Signing failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the signature of this message
|
||||
* @returns True if valid signature
|
||||
*/
|
||||
async Verify() {
|
||||
const id = await this.CreateId();
|
||||
const result = await secp.schnorr.verify(this.Signature, id, this.PubKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
async CreateId() {
|
||||
const payload = [
|
||||
0,
|
||||
this.PubKey,
|
||||
this.CreatedAt,
|
||||
this.Kind,
|
||||
this.Tags.map((a) => a.ToObject()).filter((a) => a !== null),
|
||||
this.Content,
|
||||
];
|
||||
|
||||
const payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||
const data = await secp.utils.sha256(payloadData);
|
||||
const hash = secp.utils.bytesToHex(data);
|
||||
if (this.Id !== "" && hash !== this.Id) {
|
||||
console.debug(payload);
|
||||
throw "ID doesnt match!";
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
ToObject(): RawEvent {
|
||||
return {
|
||||
id: this.Id,
|
||||
pubkey: this.PubKey,
|
||||
created_at: this.CreatedAt,
|
||||
kind: this.Kind,
|
||||
tags: <string[][]>this.Tags.sort((a, b) => a.Index - b.Index)
|
||||
.map((a) => a.ToObject())
|
||||
.filter((a) => a !== null),
|
||||
content: this.Content,
|
||||
sig: this.Signature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event for a specific pubkey
|
||||
*/
|
||||
static ForPubKey(pubKey: HexKey) {
|
||||
const ev = new Event();
|
||||
ev.PubKey = pubKey;
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the given message content
|
||||
*/
|
||||
async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) {
|
||||
const key = await this._GetDmSharedKey(pubkey, privkey);
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const data = new TextEncoder().encode(content);
|
||||
const result = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-CBC",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
const uData = new Uint8Array(result);
|
||||
return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(
|
||||
iv,
|
||||
0,
|
||||
16
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the message content in place
|
||||
*/
|
||||
async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
|
||||
this.Content = await this.EncryptData(this.Content, pubkey, privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of the message
|
||||
*/
|
||||
async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
|
||||
const key = await this._GetDmSharedKey(pubkey, privkey);
|
||||
const cSplit = cyphertext.split("?iv=");
|
||||
const data = new Uint8Array(base64.length(cSplit[0]));
|
||||
base64.decode(cSplit[0], data, 0);
|
||||
|
||||
const iv = new Uint8Array(base64.length(cSplit[1]));
|
||||
base64.decode(cSplit[1], iv, 0);
|
||||
|
||||
const result = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-CBC",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
return new TextDecoder().decode(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of this message in place
|
||||
*/
|
||||
async DecryptDm(privkey: HexKey, pubkey: HexKey) {
|
||||
this.Content = await this.DecryptData(this.Content, privkey, pubkey);
|
||||
}
|
||||
|
||||
async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
|
||||
const sharedPoint = secp.getSharedSecret(privkey, "02" + pubkey);
|
||||
const sharedX = sharedPoint.slice(1, 33);
|
||||
return await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedX,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
}
|
20
packages/nostr/src/EventKind.ts
Normal file
20
packages/nostr/src/EventKind.ts
Normal file
@ -0,0 +1,20 @@
|
||||
enum EventKind {
|
||||
Unknown = -1,
|
||||
SetMetadata = 0,
|
||||
TextNote = 1,
|
||||
RecommendServer = 2,
|
||||
ContactList = 3, // NIP-02
|
||||
DirectMessage = 4, // NIP-04
|
||||
Deletion = 5, // NIP-09
|
||||
Repost = 6, // NIP-18
|
||||
Reaction = 7, // NIP-25
|
||||
Relays = 10002, // NIP-65
|
||||
Auth = 22242, // NIP-42
|
||||
PubkeyLists = 30000, // NIP-51a
|
||||
NoteLists = 30001, // NIP-51b
|
||||
TagLists = 30002, // NIP-51c
|
||||
ZapRequest = 9734, // NIP 57
|
||||
ZapReceipt = 9735, // NIP 57
|
||||
}
|
||||
|
||||
export default EventKind;
|
59
packages/nostr/src/Links.ts
Normal file
59
packages/nostr/src/Links.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { bech32 } from "bech32";
|
||||
|
||||
export enum NostrPrefix {
|
||||
PublicKey = "npub",
|
||||
PrivateKey = "nsec",
|
||||
Note = "note",
|
||||
|
||||
// TLV prefixes
|
||||
Profile = "nprofile",
|
||||
Event = "nevent",
|
||||
Relay = "nrelay",
|
||||
}
|
||||
|
||||
export interface TLVEntry {
|
||||
type: number;
|
||||
length: number;
|
||||
value: string; // hex encoded data
|
||||
}
|
||||
|
||||
export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) {
|
||||
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const buf = secp.utils.hexToBytes(hex);
|
||||
|
||||
const tl0 = [0, buf.length, ...buf];
|
||||
const tl1 =
|
||||
relays
|
||||
?.map(a => {
|
||||
const data = enc.encode(a);
|
||||
return [1, data.length, ...data];
|
||||
})
|
||||
.flat() ?? [];
|
||||
|
||||
return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1]));
|
||||
}
|
||||
|
||||
export function decodeTLV(str: string) {
|
||||
const decoded = bech32.decode(str);
|
||||
const data = bech32.fromWords(decoded.words);
|
||||
|
||||
const entries: TLVEntry[] = [];
|
||||
let x = 0;
|
||||
while (x < data.length) {
|
||||
const t = data[x];
|
||||
const l = data[x + 1];
|
||||
const v = data.slice(x + 2, x + 2 + l);
|
||||
entries.push({
|
||||
type: t,
|
||||
length: l,
|
||||
value: secp.utils.bytesToHex(new Uint8Array(v)),
|
||||
});
|
||||
x += 2 + l;
|
||||
}
|
||||
return entries;
|
||||
}
|
5
packages/nostr/src/Nips.ts
Normal file
5
packages/nostr/src/Nips.ts
Normal file
@ -0,0 +1,5 @@
|
||||
enum Nips {
|
||||
Search = 50,
|
||||
}
|
||||
|
||||
export default Nips;
|
12
packages/nostr/src/RelayInfo.ts
Normal file
12
packages/nostr/src/RelayInfo.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface RelayInfo {
|
||||
name?: string;
|
||||
description?: string;
|
||||
pubkey?: string;
|
||||
contact?: string;
|
||||
supported_nips?: number[];
|
||||
software?: string;
|
||||
version?: string;
|
||||
limitation?: {
|
||||
payment_required: boolean;
|
||||
};
|
||||
}
|
176
packages/nostr/src/Subscriptions.ts
Normal file
176
packages/nostr/src/Subscriptions.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { TaggedRawEvent, RawReqFilter, u256 } from "./index";
|
||||
import Connection from "./Connection";
|
||||
import EventKind from "./EventKind";
|
||||
|
||||
export type NEventHandler = (e: TaggedRawEvent) => void;
|
||||
export type OnEndHandler = (c: Connection) => void;
|
||||
|
||||
export class Subscriptions {
|
||||
/**
|
||||
* A unique id for this subscription filter
|
||||
*/
|
||||
Id: u256;
|
||||
|
||||
/**
|
||||
* a list of event ids or prefixes
|
||||
*/
|
||||
Ids?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
|
||||
*/
|
||||
Authors?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a list of a kind numbers
|
||||
*/
|
||||
Kinds?: Set<EventKind>;
|
||||
|
||||
/**
|
||||
* a list of event ids that are referenced in an "e" tag
|
||||
*/
|
||||
ETags?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a list of pubkeys that are referenced in a "p" tag
|
||||
*/
|
||||
PTags?: Set<u256>;
|
||||
|
||||
/**
|
||||
* A list of "t" tags to search
|
||||
*/
|
||||
HashTags?: Set<string>;
|
||||
|
||||
/**
|
||||
* A litst of "d" tags to search
|
||||
*/
|
||||
DTags?: Set<string>;
|
||||
|
||||
/**
|
||||
* A litst of "r" tags to search
|
||||
*/
|
||||
RTags?: Set<string>;
|
||||
|
||||
/**
|
||||
* A list of search terms
|
||||
*/
|
||||
Search?: string;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be newer than this to pass
|
||||
*/
|
||||
Since?: number;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be older than this to pass
|
||||
*/
|
||||
Until?: number;
|
||||
|
||||
/**
|
||||
* maximum number of events to be returned in the initial query
|
||||
*/
|
||||
Limit?: number;
|
||||
|
||||
/**
|
||||
* Handler function for this event
|
||||
*/
|
||||
OnEvent: NEventHandler;
|
||||
|
||||
/**
|
||||
* End of data event
|
||||
*/
|
||||
OnEnd: OnEndHandler;
|
||||
|
||||
/**
|
||||
* Collection of OR sub scriptions linked to this
|
||||
*/
|
||||
OrSubs: Array<Subscriptions>;
|
||||
|
||||
/**
|
||||
* Start time for this subscription
|
||||
*/
|
||||
Started: Map<string, number>;
|
||||
|
||||
/**
|
||||
* End time for this subscription
|
||||
*/
|
||||
Finished: Map<string, number>;
|
||||
|
||||
constructor(sub?: RawReqFilter) {
|
||||
this.Id = uuid();
|
||||
this.Ids = sub?.ids ? new Set(sub.ids) : undefined;
|
||||
this.Authors = sub?.authors ? new Set(sub.authors) : undefined;
|
||||
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
|
||||
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
|
||||
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
|
||||
this.DTags = sub?.["#d"] ? new Set(["#d"]) : undefined;
|
||||
this.RTags = sub?.["#r"] ? new Set(["#r"]) : undefined;
|
||||
this.Search = sub?.search ?? undefined;
|
||||
this.Since = sub?.since ?? undefined;
|
||||
this.Until = sub?.until ?? undefined;
|
||||
this.Limit = sub?.limit ?? undefined;
|
||||
this.OnEvent = () => {
|
||||
console.warn(`No event handler was set on subscription: ${this.Id}`);
|
||||
};
|
||||
this.OnEnd = () => undefined;
|
||||
this.OrSubs = [];
|
||||
this.Started = new Map<string, number>();
|
||||
this.Finished = new Map<string, number>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR filter subscriptions
|
||||
*/
|
||||
AddSubscription(sub: Subscriptions) {
|
||||
this.OrSubs.push(sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* If all relays have responded with EOSE
|
||||
*/
|
||||
IsFinished() {
|
||||
return this.Started.size === this.Finished.size;
|
||||
}
|
||||
|
||||
ToObject(): RawReqFilter {
|
||||
const ret: RawReqFilter = {};
|
||||
if (this.Ids) {
|
||||
ret.ids = Array.from(this.Ids);
|
||||
}
|
||||
if (this.Authors) {
|
||||
ret.authors = Array.from(this.Authors);
|
||||
}
|
||||
if (this.Kinds) {
|
||||
ret.kinds = Array.from(this.Kinds);
|
||||
}
|
||||
if (this.ETags) {
|
||||
ret["#e"] = Array.from(this.ETags);
|
||||
}
|
||||
if (this.PTags) {
|
||||
ret["#p"] = Array.from(this.PTags);
|
||||
}
|
||||
if (this.HashTags) {
|
||||
ret["#t"] = Array.from(this.HashTags);
|
||||
}
|
||||
if (this.DTags) {
|
||||
ret["#d"] = Array.from(this.DTags);
|
||||
}
|
||||
if (this.RTags) {
|
||||
ret["#r"] = Array.from(this.RTags);
|
||||
}
|
||||
if (this.Search) {
|
||||
ret.search = this.Search;
|
||||
}
|
||||
if (this.Since !== null) {
|
||||
ret.since = this.Since;
|
||||
}
|
||||
if (this.Until !== null) {
|
||||
ret.until = this.Until;
|
||||
}
|
||||
if (this.Limit !== null) {
|
||||
ret.limit = this.Limit;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
289
packages/nostr/src/System.ts
Normal file
289
packages/nostr/src/System.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { HexKey, TaggedRawEvent, UserMetadata } from "./index";
|
||||
import { ProfileCacheExpire } from "./Const";
|
||||
import Connection, { RelaySettings } from "./Connection";
|
||||
import Event from "./Event";
|
||||
import EventKind from "./EventKind";
|
||||
import { Subscriptions } from "./Subscriptions";
|
||||
import { hexToBech32, unwrap } from "./Util";
|
||||
|
||||
// TODO This interface is repeated in State/Users, revisit this.
|
||||
export interface MetadataCache extends UserMetadata {
|
||||
/**
|
||||
* When the object was saved in cache
|
||||
*/
|
||||
loaded: number;
|
||||
|
||||
/**
|
||||
* When the source metadata event was created
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The pubkey of the owner of this metadata
|
||||
*/
|
||||
pubkey: HexKey;
|
||||
|
||||
/**
|
||||
* The bech32 encoded pubkey
|
||||
*/
|
||||
npub: string;
|
||||
}
|
||||
|
||||
// TODO This interface is repeated in State/Users, revisit this.
|
||||
export interface UsersDb {
|
||||
isAvailable(): Promise<boolean>;
|
||||
query(str: string): Promise<MetadataCache[]>;
|
||||
find(key: HexKey): Promise<MetadataCache | undefined>;
|
||||
add(user: MetadataCache): Promise<void>;
|
||||
put(user: MetadataCache): Promise<void>;
|
||||
bulkAdd(users: MetadataCache[]): Promise<void>;
|
||||
bulkGet(keys: HexKey[]): Promise<MetadataCache[]>;
|
||||
bulkPut(users: MetadataCache[]): Promise<void>;
|
||||
update(key: HexKey, fields: Record<string, string | number>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages nostr content retrieval system
|
||||
*/
|
||||
export class NostrSystem {
|
||||
/**
|
||||
* All currently connected websockets
|
||||
*/
|
||||
Sockets: Map<string, Connection>;
|
||||
|
||||
/**
|
||||
* All active subscriptions
|
||||
*/
|
||||
Subscriptions: Map<string, Subscriptions>;
|
||||
|
||||
/**
|
||||
* Pending subscriptions to send when sockets become open
|
||||
*/
|
||||
PendingSubscriptions: Subscriptions[];
|
||||
|
||||
/**
|
||||
* List of pubkeys to fetch metadata for
|
||||
*/
|
||||
WantsMetadata: Set<HexKey>;
|
||||
|
||||
/**
|
||||
* User db store
|
||||
*/
|
||||
UserDb?: UsersDb;
|
||||
|
||||
constructor() {
|
||||
this.Sockets = new Map();
|
||||
this.Subscriptions = new Map();
|
||||
this.PendingSubscriptions = [];
|
||||
this.WantsMetadata = new Set();
|
||||
this._FetchMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a NOSTR relay if not already connected
|
||||
*/
|
||||
ConnectToRelay(address: string, options: RelaySettings) {
|
||||
try {
|
||||
if (!this.Sockets.has(address)) {
|
||||
const c = new Connection(address, options);
|
||||
this.Sockets.set(address, c);
|
||||
for (const [, s] of this.Subscriptions) {
|
||||
c.AddSubscription(s);
|
||||
}
|
||||
} else {
|
||||
// update settings if already connected
|
||||
unwrap(this.Sockets.get(address)).Settings = options;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a relay
|
||||
*/
|
||||
DisconnectRelay(address: string) {
|
||||
const c = this.Sockets.get(address);
|
||||
if (c) {
|
||||
this.Sockets.delete(address);
|
||||
c.Close();
|
||||
}
|
||||
}
|
||||
|
||||
AddSubscriptionToRelay(sub: Subscriptions, relay: string) {
|
||||
this.Sockets.get(relay)?.AddSubscription(sub);
|
||||
}
|
||||
|
||||
AddSubscription(sub: Subscriptions) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.AddSubscription(sub);
|
||||
}
|
||||
this.Subscriptions.set(sub.Id, sub);
|
||||
}
|
||||
|
||||
RemoveSubscription(subId: string) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.RemoveSubscription(subId);
|
||||
}
|
||||
this.Subscriptions.delete(subId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send events to writable relays
|
||||
*/
|
||||
BroadcastEvent(ev: Event) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.SendEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an event to a relay then disconnect
|
||||
*/
|
||||
async WriteOnceToRelay(address: string, ev: Event) {
|
||||
const c = new Connection(address, { write: true, read: false });
|
||||
await c.SendAsync(ev);
|
||||
c.Close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request profile metadata for a set of pubkeys
|
||||
*/
|
||||
TrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||
for (const p of Array.isArray(pk) ? pk : [pk]) {
|
||||
if (p.length > 0) {
|
||||
this.WantsMetadata.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking metadata for a set of pubkeys
|
||||
*/
|
||||
UntrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||
for (const p of Array.isArray(pk) ? pk : [pk]) {
|
||||
if (p.length > 0) {
|
||||
this.WantsMetadata.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request/Response pattern
|
||||
*/
|
||||
RequestSubscription(sub: Subscriptions) {
|
||||
return new Promise<TaggedRawEvent[]>((resolve) => {
|
||||
const events: TaggedRawEvent[] = [];
|
||||
|
||||
// force timeout returning current results
|
||||
const timeout = setTimeout(() => {
|
||||
this.RemoveSubscription(sub.Id);
|
||||
resolve(events);
|
||||
}, 10_000);
|
||||
|
||||
const onEventPassthrough = sub.OnEvent;
|
||||
sub.OnEvent = (ev) => {
|
||||
if (typeof onEventPassthrough === "function") {
|
||||
onEventPassthrough(ev);
|
||||
}
|
||||
if (!events.some((a) => a.id === ev.id)) {
|
||||
events.push(ev);
|
||||
} else {
|
||||
const existing = events.find((a) => a.id === ev.id);
|
||||
if (existing) {
|
||||
for (const v of ev.relays) {
|
||||
existing.relays.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
sub.OnEnd = (c) => {
|
||||
c.RemoveSubscription(sub.Id);
|
||||
if (sub.IsFinished()) {
|
||||
clearInterval(timeout);
|
||||
console.debug(`[${sub.Id}] Finished`);
|
||||
resolve(events);
|
||||
}
|
||||
};
|
||||
this.AddSubscription(sub);
|
||||
});
|
||||
}
|
||||
|
||||
async _FetchMetadata() {
|
||||
if (this.UserDb) {
|
||||
const missing = new Set<HexKey>();
|
||||
const meta = await this.UserDb.bulkGet(Array.from(this.WantsMetadata));
|
||||
const expire = new Date().getTime() - ProfileCacheExpire;
|
||||
for (const pk of this.WantsMetadata) {
|
||||
const m = meta.find((a) => a?.pubkey === pk);
|
||||
if (!m || m.loaded < expire) {
|
||||
missing.add(pk);
|
||||
// cap 100 missing profiles
|
||||
if (missing.size >= 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
console.debug("Wants profiles: ", missing);
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = `profiles:${sub.Id.slice(0, 8)}`;
|
||||
sub.Kinds = new Set([EventKind.SetMetadata]);
|
||||
sub.Authors = missing;
|
||||
sub.OnEvent = async (e) => {
|
||||
const profile = mapEventToProfile(e);
|
||||
const userDb = unwrap(this.UserDb);
|
||||
if (profile) {
|
||||
const existing = await userDb.find(profile.pubkey);
|
||||
if ((existing?.created ?? 0) < profile.created) {
|
||||
await userDb.put(profile);
|
||||
} else if (existing) {
|
||||
await userDb.update(profile.pubkey, {
|
||||
loaded: profile.loaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const results = await this.RequestSubscription(sub);
|
||||
const couldNotFetch = Array.from(missing).filter(
|
||||
(a) => !results.some((b) => b.pubkey === a)
|
||||
);
|
||||
console.debug("No profiles: ", couldNotFetch);
|
||||
if (couldNotFetch.length > 0) {
|
||||
const updates = couldNotFetch
|
||||
.map((a) => {
|
||||
return {
|
||||
pubkey: a,
|
||||
loaded: new Date().getTime(),
|
||||
};
|
||||
})
|
||||
.map((a) => unwrap(this.UserDb).update(a.pubkey, a));
|
||||
await Promise.all(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(() => this._FetchMetadata(), 500);
|
||||
}
|
||||
|
||||
nip42Auth: (challenge: string, relay: string) => Promise<Event | undefined> =
|
||||
async () => undefined;
|
||||
}
|
||||
|
||||
function mapEventToProfile(ev: TaggedRawEvent) {
|
||||
try {
|
||||
const data: UserMetadata = JSON.parse(ev.content);
|
||||
return {
|
||||
pubkey: ev.pubkey,
|
||||
npub: hexToBech32("npub", ev.pubkey),
|
||||
created: ev.created_at,
|
||||
loaded: new Date().getTime(),
|
||||
...data,
|
||||
} as MetadataCache;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON", ev, e);
|
||||
}
|
||||
}
|
||||
|
||||
export const System = new NostrSystem();
|
78
packages/nostr/src/Tag.ts
Normal file
78
packages/nostr/src/Tag.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { HexKey, u256 } from "./index";
|
||||
import { unwrap } from "./Util";
|
||||
|
||||
export default class Tag {
|
||||
Original: string[];
|
||||
Key: string;
|
||||
Event?: u256;
|
||||
PubKey?: HexKey;
|
||||
Relay?: string;
|
||||
Marker?: string;
|
||||
Hashtag?: string;
|
||||
DTag?: string;
|
||||
Index: number;
|
||||
Invalid: boolean;
|
||||
|
||||
constructor(tag: string[], index: number) {
|
||||
this.Original = tag;
|
||||
this.Key = tag[0];
|
||||
this.Index = index;
|
||||
this.Invalid = false;
|
||||
|
||||
switch (this.Key) {
|
||||
case "e": {
|
||||
// ["e", <event-id>, <relay-url>, <marker>]
|
||||
this.Event = tag[1];
|
||||
this.Relay = tag.length > 2 ? tag[2] : undefined;
|
||||
this.Marker = tag.length > 3 ? tag[3] : undefined;
|
||||
if (!this.Event) {
|
||||
this.Invalid = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "p": {
|
||||
// ["p", <pubkey>]
|
||||
this.PubKey = tag[1];
|
||||
if (!this.PubKey) {
|
||||
this.Invalid = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "d": {
|
||||
this.DTag = tag[1];
|
||||
break;
|
||||
}
|
||||
case "t": {
|
||||
this.Hashtag = tag[1];
|
||||
break;
|
||||
}
|
||||
case "delegation": {
|
||||
this.PubKey = tag[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToObject(): string[] | null {
|
||||
switch (this.Key) {
|
||||
case "e": {
|
||||
let ret = ["e", this.Event, this.Relay, this.Marker];
|
||||
const trimEnd = ret.reverse().findIndex((a) => a !== undefined);
|
||||
ret = ret.reverse().slice(0, ret.length - trimEnd);
|
||||
return <string[]>ret;
|
||||
}
|
||||
case "p": {
|
||||
return this.PubKey ? ["p", this.PubKey] : null;
|
||||
}
|
||||
case "t": {
|
||||
return ["t", unwrap(this.Hashtag)];
|
||||
}
|
||||
case "d": {
|
||||
return ["d", unwrap(this.DTag)];
|
||||
}
|
||||
default: {
|
||||
return this.Original;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
56
packages/nostr/src/Thread.ts
Normal file
56
packages/nostr/src/Thread.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { u256 } from "./index";
|
||||
import { default as NEvent } from "./Event";
|
||||
import EventKind from "./EventKind";
|
||||
import Tag from "./Tag";
|
||||
|
||||
export default class Thread {
|
||||
Root?: Tag;
|
||||
ReplyTo?: Tag;
|
||||
Mentions: Array<Tag>;
|
||||
PubKeys: Array<u256>;
|
||||
|
||||
constructor() {
|
||||
this.Mentions = [];
|
||||
this.PubKeys = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thread information from an Event
|
||||
* @param ev Event to extract thread from
|
||||
*/
|
||||
static ExtractThread(ev: NEvent) {
|
||||
const isThread = ev.Tags.some((a) => a.Key === "e");
|
||||
if (!isThread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldWriteMarkers = ev.Kind === EventKind.TextNote;
|
||||
const ret = new Thread();
|
||||
const eTags = ev.Tags.filter((a) => a.Key === "e");
|
||||
const marked = eTags.some((a) => a.Marker !== undefined);
|
||||
if (!marked) {
|
||||
ret.Root = eTags[0];
|
||||
ret.Root.Marker = shouldWriteMarkers ? "root" : undefined;
|
||||
if (eTags.length > 1) {
|
||||
ret.ReplyTo = eTags[1];
|
||||
ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : undefined;
|
||||
}
|
||||
if (eTags.length > 2) {
|
||||
ret.Mentions = eTags.slice(2);
|
||||
if (shouldWriteMarkers) {
|
||||
ret.Mentions.forEach((a) => (a.Marker = "mention"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const root = eTags.find((a) => a.Marker === "root");
|
||||
const reply = eTags.find((a) => a.Marker === "reply");
|
||||
ret.Root = root;
|
||||
ret.ReplyTo = reply;
|
||||
ret.Mentions = eTags.filter((a) => a.Marker === "mention");
|
||||
}
|
||||
ret.PubKeys = Array.from(
|
||||
new Set(ev.Tags.filter((a) => a.Key === "p").map((a) => <u256>a.PubKey))
|
||||
);
|
||||
return ret;
|
||||
}
|
||||
}
|
26
packages/nostr/src/Util.ts
Normal file
26
packages/nostr/src/Util.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { bech32 } from "bech32";
|
||||
|
||||
export function unwrap<T>(v: T | undefined | null): T {
|
||||
if (v === undefined || v === null) {
|
||||
throw new Error("missing value");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex to bech32
|
||||
*/
|
||||
export function hexToBech32(hrp: string, hex?: string) {
|
||||
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = secp.utils.hexToBytes(hex);
|
||||
return bech32.encode(hrp, bech32.toWords(buf));
|
||||
} catch (e) {
|
||||
console.warn("Invalid hex", hex, e);
|
||||
return "";
|
||||
}
|
||||
}
|
86
packages/nostr/src/index.ts
Normal file
86
packages/nostr/src/index.ts
Normal file
@ -0,0 +1,86 @@
|
||||
export * from "./System";
|
||||
export * from "./Connection";
|
||||
export { default as EventKind } from "./EventKind";
|
||||
export { Subscriptions } from "./Subscriptions";
|
||||
export { default as Event } from "./Event";
|
||||
export { default as Tag } from "./Tag";
|
||||
|
||||
export type RawEvent = {
|
||||
id: u256;
|
||||
pubkey: HexKey;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig: string;
|
||||
};
|
||||
|
||||
export interface TaggedRawEvent extends RawEvent {
|
||||
/**
|
||||
* A list of relays this event was seen on
|
||||
*/
|
||||
relays: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic raw key as hex
|
||||
*/
|
||||
export type HexKey = string;
|
||||
|
||||
/**
|
||||
* Optional HexKey
|
||||
*/
|
||||
export type MaybeHexKey = HexKey | undefined;
|
||||
|
||||
/**
|
||||
* A 256bit hex id
|
||||
*/
|
||||
export type u256 = string;
|
||||
|
||||
/**
|
||||
* Raw REQ filter object
|
||||
*/
|
||||
export type RawReqFilter = {
|
||||
ids?: u256[];
|
||||
authors?: u256[];
|
||||
kinds?: number[];
|
||||
"#e"?: u256[];
|
||||
"#p"?: u256[];
|
||||
"#t"?: string[];
|
||||
"#d"?: string[];
|
||||
"#r"?: string[];
|
||||
search?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Medatadata event content
|
||||
*/
|
||||
export type UserMetadata = {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
website?: string;
|
||||
banner?: string;
|
||||
nip05?: string;
|
||||
lud06?: string;
|
||||
lud16?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* NIP-51 list types
|
||||
*/
|
||||
export enum Lists {
|
||||
Muted = "mute",
|
||||
Pinned = "pin",
|
||||
Bookmarked = "bookmark",
|
||||
Followed = "follow",
|
||||
}
|
||||
|
||||
export interface FullRelaySettings {
|
||||
url: string;
|
||||
settings: { read: boolean; write: boolean };
|
||||
}
|
Reference in New Issue
Block a user