Browse Source
Introduce the `relayinfo` package with `NIP-11` utilities, including `Fees`, `Limits`, and `NIPs` structures. Add utility modules for handling numbers, timestamps, and kinds. Integrate functionality for fetching and managing relay information.main
16 changed files with 1541 additions and 2 deletions
@ -0,0 +1,63 @@ |
|||||||
|
package app |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"net/http" |
||||||
|
"sort" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/pkg/protocol/relayinfo" |
||||||
|
"next.orly.dev/pkg/version" |
||||||
|
) |
||||||
|
|
||||||
|
// HandleRelayInfo generates and returns a relay information document in JSON
|
||||||
|
// format based on the server's configuration and supported NIPs.
|
||||||
|
//
|
||||||
|
// # Parameters
|
||||||
|
//
|
||||||
|
// - w: HTTP response writer used to send the generated document.
|
||||||
|
//
|
||||||
|
// - r: HTTP request object containing incoming client request data.
|
||||||
|
//
|
||||||
|
// # Expected Behaviour
|
||||||
|
//
|
||||||
|
// The function constructs a relay information document using either the
|
||||||
|
// Informer interface implementation or predefined server configuration. It
|
||||||
|
// returns this document as a JSON response to the client.
|
||||||
|
func (l *Listener) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { |
||||||
|
r.Header.Set("Content-Type", "application/json") |
||||||
|
log.I.Ln("handling relay information document") |
||||||
|
var info *relayinfo.T |
||||||
|
supportedNIPs := relayinfo.GetList( |
||||||
|
relayinfo.BasicProtocol, |
||||||
|
// relayinfo.Authentication,
|
||||||
|
// relayinfo.EncryptedDirectMessage,
|
||||||
|
// relayinfo.EventDeletion,
|
||||||
|
relayinfo.RelayInformationDocument, |
||||||
|
// relayinfo.GenericTagQueries,
|
||||||
|
// relayinfo.NostrMarketplace,
|
||||||
|
// relayinfo.EventTreatment,
|
||||||
|
// relayinfo.CommandResults,
|
||||||
|
// relayinfo.ParameterizedReplaceableEvents,
|
||||||
|
// relayinfo.ExpirationTimestamp,
|
||||||
|
// relayinfo.ProtectedEvents,
|
||||||
|
// relayinfo.RelayListMetadata,
|
||||||
|
) |
||||||
|
sort.Sort(supportedNIPs) |
||||||
|
log.T.Ln("supported NIPs", supportedNIPs) |
||||||
|
info = &relayinfo.T{ |
||||||
|
Name: l.Config.AppName, |
||||||
|
Description: version.Description, |
||||||
|
Nips: supportedNIPs, |
||||||
|
Software: version.URL, |
||||||
|
Version: version.V, |
||||||
|
Limitation: relayinfo.Limits{ |
||||||
|
// AuthRequired: l.C.AuthRequired,
|
||||||
|
// RestrictedWrites: l.C.AuthRequired,
|
||||||
|
}, |
||||||
|
Icon: "https://cdn.satellite.earth/ac9778868fbf23b63c47c769a74e163377e6ea94d3f0f31711931663d035c4f6.png", |
||||||
|
} |
||||||
|
if err := json.NewEncoder(w).Encode(info); chk.E(err) { |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
package app |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/app/config" |
||||||
|
) |
||||||
|
|
||||||
|
type Listener struct { |
||||||
|
mux *http.ServeMux |
||||||
|
Config *config.C |
||||||
|
} |
||||||
|
|
||||||
|
func (l *Listener) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
||||||
|
log.I.F("path %v header %v", r.URL, r.Header) |
||||||
|
if r.Header.Get("Upgrade") == "websocket" { |
||||||
|
l.HandleWebsocket(w, r) |
||||||
|
} else if r.Header.Get("Accept") == "application/nostr+json" { |
||||||
|
l.HandleRelayInfo(w, r) |
||||||
|
} else { |
||||||
|
http.Error(w, "Upgrade required", http.StatusUpgradeRequired) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (l *Listener) HandleWebsocket(w http.ResponseWriter, r *http.Request) { |
||||||
|
log.I.F("websocket") |
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,424 @@ |
|||||||
|
// Package kind includes a type for convenient handling of event kinds, and a
|
||||||
|
// kind database with reverse lookup for human-readable information about event
|
||||||
|
// kinds.
|
||||||
|
package kind |
||||||
|
|
||||||
|
import ( |
||||||
|
"sync" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"next.orly.dev/pkg/encoders/ints" |
||||||
|
|
||||||
|
"golang.org/x/exp/constraints" |
||||||
|
) |
||||||
|
|
||||||
|
// K - which will be externally referenced as kind.K is the event type in the
|
||||||
|
// nostr protocol, the use of the capital K signifying type, consistent with Go
|
||||||
|
// idiom, the Go standard library, and much, conformant, existing code.
|
||||||
|
type K struct { |
||||||
|
K uint16 |
||||||
|
} |
||||||
|
|
||||||
|
// New creates a new kind.K with a provided integer value. Note that anything larger than 2^16
|
||||||
|
// will be truncated.
|
||||||
|
func New[V constraints.Integer](k V) (ki *K) { return &K{uint16(k)} } |
||||||
|
|
||||||
|
// ToInt returns the value of the kind.K as an int.
|
||||||
|
func (k *K) ToInt() int { |
||||||
|
if k == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return int(k.K) |
||||||
|
} |
||||||
|
|
||||||
|
// ToU16 returns the value of the kind.K as an uint16 (the native form).
|
||||||
|
func (k *K) ToU16() uint16 { |
||||||
|
if k == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return k.K |
||||||
|
} |
||||||
|
|
||||||
|
// ToI32 returns the value of the kind.K as an int32.
|
||||||
|
func (k *K) ToI32() int32 { |
||||||
|
if k == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return int32(k.K) |
||||||
|
} |
||||||
|
|
||||||
|
// ToU64 returns the value of the kind.K as an uint64.
|
||||||
|
func (k *K) ToU64() uint64 { |
||||||
|
if k == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return uint64(k.K) |
||||||
|
} |
||||||
|
|
||||||
|
// Name returns the human readable string describing the semantics of the kind.K.
|
||||||
|
func (k *K) Name() string { return GetString(k) } |
||||||
|
|
||||||
|
// Equal checks if
|
||||||
|
func (k *K) Equal(k2 *K) bool { |
||||||
|
if k == nil || k2 == nil { |
||||||
|
return false |
||||||
|
} |
||||||
|
return k.K == k2.K |
||||||
|
} |
||||||
|
|
||||||
|
var Privileged = []*K{ |
||||||
|
EncryptedDirectMessage, |
||||||
|
GiftWrap, |
||||||
|
GiftWrapWithKind4, |
||||||
|
JWTBinding, |
||||||
|
ApplicationSpecificData, |
||||||
|
Seal, |
||||||
|
PrivateDirectMessage, |
||||||
|
} |
||||||
|
|
||||||
|
// IsPrivileged returns true if the type is the kind of message nobody else than the pubkeys in
|
||||||
|
// the event and p tags of the event are party to.
|
||||||
|
func (k *K) IsPrivileged() (is bool) { |
||||||
|
for i := range Privileged { |
||||||
|
if k.Equal(Privileged[i]) { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Marshal renders the kind.K into bytes containing the ASCII string form of the kind number.
|
||||||
|
func (k *K) Marshal(dst []byte) (b []byte) { return ints.New(k.ToU64()).Marshal(dst) } |
||||||
|
|
||||||
|
// Unmarshal decodes a byte string into a kind.K.
|
||||||
|
func (k *K) Unmarshal(b []byte) (r []byte, err error) { |
||||||
|
n := ints.New(0) |
||||||
|
if r, err = n.Unmarshal(b); chk.T(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
k.K = n.Uint16() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// GetString returns a human-readable identifier for a kind.K.
|
||||||
|
func GetString(t *K) string { |
||||||
|
if t == nil { |
||||||
|
return "" |
||||||
|
} |
||||||
|
MapMx.Lock() |
||||||
|
defer MapMx.Unlock() |
||||||
|
return Map[t.K] |
||||||
|
} |
||||||
|
|
||||||
|
// IsEphemeral returns true if the event kind is an ephemeral event. (not to be
|
||||||
|
// stored)
|
||||||
|
func (k *K) IsEphemeral() bool { |
||||||
|
return k.K >= EphemeralStart.K && k.K < EphemeralEnd.K |
||||||
|
} |
||||||
|
|
||||||
|
// IsReplaceable returns true if the event kind is a replaceable kind - that is,
|
||||||
|
// if the newest version is the one that is in force (eg follow lists, relay
|
||||||
|
// lists, etc.
|
||||||
|
func (k *K) IsReplaceable() bool { |
||||||
|
return k.K == ProfileMetadata.K || k.K == FollowList.K || |
||||||
|
(k.K >= ReplaceableStart.K && k.K < ReplaceableEnd.K) |
||||||
|
} |
||||||
|
|
||||||
|
// IsParameterizedReplaceable is a kind of event that is one of a group of
|
||||||
|
// events that replaces based on matching criteria.
|
||||||
|
func (k *K) IsParameterizedReplaceable() bool { |
||||||
|
return k.K >= ParameterizedReplaceableStart.K && |
||||||
|
k.K < ParameterizedReplaceableEnd.K |
||||||
|
} |
||||||
|
|
||||||
|
// Directory events are events that necessarily need to be readable by anyone in
|
||||||
|
// order to interact with users who have access to the relay, in order to
|
||||||
|
// facilitate other users to find and interact with users on an auth-required
|
||||||
|
// relay.
|
||||||
|
var Directory = []*K{ |
||||||
|
ProfileMetadata, |
||||||
|
FollowList, |
||||||
|
EventDeletion, |
||||||
|
Reporting, |
||||||
|
RelayListMetadata, |
||||||
|
MuteList, |
||||||
|
DMRelaysList, |
||||||
|
} |
||||||
|
|
||||||
|
// IsDirectoryEvent returns whether an event kind is a Directory event, which
|
||||||
|
// should grant permission to read such events without requiring authentication.
|
||||||
|
func (k *K) IsDirectoryEvent() bool { |
||||||
|
for i := range Directory { |
||||||
|
if k.Equal(Directory[i]) { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
var ( |
||||||
|
// ProfileMetadata is an event type that stores user profile data, pet
|
||||||
|
// names, bio, lightning address, etc.
|
||||||
|
ProfileMetadata = &K{0} |
||||||
|
// SetMetadata is a synonym for ProfileMetadata.
|
||||||
|
SetMetadata = &K{0} |
||||||
|
// TextNote is a standard short text note of plain text a la twitter
|
||||||
|
TextNote = &K{1} |
||||||
|
// RecommendServer is an event type that...
|
||||||
|
RecommendServer = &K{2} |
||||||
|
RecommendRelay = &K{2} |
||||||
|
// FollowList an event containing a list of pubkeys of users that should be
|
||||||
|
// shown as follows in a timeline.
|
||||||
|
FollowList = &K{3} |
||||||
|
Follows = &K{3} |
||||||
|
// EncryptedDirectMessage is an event type that...
|
||||||
|
EncryptedDirectMessage = &K{4} |
||||||
|
// Deletion is an event type that...
|
||||||
|
Deletion = &K{5} |
||||||
|
EventDeletion = &K{5} |
||||||
|
// Repost is an event type that...
|
||||||
|
Repost = &K{6} |
||||||
|
// Reaction is an event type that...
|
||||||
|
Reaction = &K{7} |
||||||
|
// BadgeAward is an event type
|
||||||
|
BadgeAward = &K{8} |
||||||
|
// Seal is an event that wraps a PrivateDirectMessage and is placed inside a
|
||||||
|
// GiftWrap or GiftWrapWithKind4
|
||||||
|
Seal = &K{13} |
||||||
|
// PrivateDirectMessage is a nip-17 direct message with a different
|
||||||
|
// construction. It doesn't actually appear as an event a relay might receive
|
||||||
|
// but only as the stringified content of a GiftWrap or GiftWrapWithKind4 inside
|
||||||
|
// a
|
||||||
|
PrivateDirectMessage = &K{14} |
||||||
|
// ReadReceipt is a type of event that marks a list of tagged events (e
|
||||||
|
// tags) as being seen by the client, its distinctive feature is the
|
||||||
|
// "expiration" tag which indicates a time after which the marking expires
|
||||||
|
ReadReceipt = &K{15} |
||||||
|
// GenericRepost is an event type that...
|
||||||
|
GenericRepost = &K{16} |
||||||
|
// ChannelCreation is an event type that...
|
||||||
|
ChannelCreation = &K{40} |
||||||
|
// ChannelMetadata is an event type that...
|
||||||
|
ChannelMetadata = &K{41} |
||||||
|
// ChannelMessage is an event type that...
|
||||||
|
ChannelMessage = &K{42} |
||||||
|
// ChannelHideMessage is an event type that...
|
||||||
|
ChannelHideMessage = &K{43} |
||||||
|
// ChannelMuteUser is an event type that...
|
||||||
|
ChannelMuteUser = &K{44} |
||||||
|
// Bid is an event type that...
|
||||||
|
Bid = &K{1021} |
||||||
|
// BidConfirmation is an event type that...
|
||||||
|
BidConfirmation = &K{1022} |
||||||
|
// OpenTimestamps is an event type that...
|
||||||
|
OpenTimestamps = &K{1040} |
||||||
|
GiftWrap = &K{1059} |
||||||
|
GiftWrapWithKind4 = &K{1060} |
||||||
|
// FileMetadata is an event type that...
|
||||||
|
FileMetadata = &K{1063} |
||||||
|
// LiveChatMessage is an event type that...
|
||||||
|
LiveChatMessage = &K{1311} |
||||||
|
// BitcoinBlock is an event type created for the Nostrocket
|
||||||
|
BitcoinBlock = &K{1517} |
||||||
|
// LiveStream from zap.stream
|
||||||
|
LiveStream = &K{1808} |
||||||
|
// ProblemTracker is an event type used by Nostrocket
|
||||||
|
ProblemTracker = &K{1971} |
||||||
|
// MemoryHole is an event type contains a report about an event (usually
|
||||||
|
// text note or other human readable)
|
||||||
|
MemoryHole = &K{1984} |
||||||
|
Reporting = &K{1984} |
||||||
|
// Label is an event type has L and l tags, namespace and type - NIP-32
|
||||||
|
Label = &K{1985} |
||||||
|
// CommunityPostApproval is an event type that...
|
||||||
|
CommunityPostApproval = &K{4550} |
||||||
|
JobRequestStart = &K{5000} |
||||||
|
JobRequestEnd = &K{5999} |
||||||
|
JobResultStart = &K{6000} |
||||||
|
JobResultEnd = &K{6999} |
||||||
|
JobFeedback = &K{7000} |
||||||
|
ZapGoal = &K{9041} |
||||||
|
// ZapRequest is an event type that...
|
||||||
|
ZapRequest = &K{9734} |
||||||
|
// Zap is an event type that...
|
||||||
|
Zap = &K{9735} |
||||||
|
Highlights = &K{9882} |
||||||
|
// ReplaceableStart is an event type that...
|
||||||
|
ReplaceableStart = &K{10000} |
||||||
|
// MuteList is an event type that...
|
||||||
|
MuteList = &K{10000} |
||||||
|
BlockList = &K{10000} |
||||||
|
// PinList is an event type that...
|
||||||
|
PinList = &K{10001} |
||||||
|
// RelayListMetadata is an event type that...
|
||||||
|
RelayListMetadata = &K{10002} |
||||||
|
BookmarkList = &K{10003} |
||||||
|
CommunitiesList = &K{10004} |
||||||
|
PublicChatsList = &K{10005} |
||||||
|
BlockedRelaysList = &K{10006} |
||||||
|
SearchRelaysList = &K{10007} |
||||||
|
InterestsList = &K{10015} |
||||||
|
UserEmojiList = &K{10030} |
||||||
|
DMRelaysList = &K{10050} |
||||||
|
FileStorageServerList = &K{10096} |
||||||
|
// JWTBinding is an event kind that creates a link between a JWT certificate and a pubkey
|
||||||
|
JWTBinding = &K{13004} |
||||||
|
// NWCWalletServiceInfo is an event type that...
|
||||||
|
NWCWalletServiceInfo = &K{13194} |
||||||
|
WalletServiceInfo = &K{13194} |
||||||
|
// ReplaceableEnd is an event type that...
|
||||||
|
ReplaceableEnd = &K{19999} |
||||||
|
// EphemeralStart is an event type that...
|
||||||
|
EphemeralStart = &K{20000} |
||||||
|
LightningPubRPC = &K{21000} |
||||||
|
// ClientAuthentication is an event type that...
|
||||||
|
ClientAuthentication = &K{22242} |
||||||
|
// NWCWalletRequest is an event type that...
|
||||||
|
NWCWalletRequest = &K{23194} |
||||||
|
WalletRequest = &K{23194} |
||||||
|
// NWCWalletResponse is an event type that...
|
||||||
|
NWCWalletResponse = &K{23195} |
||||||
|
WalletResponse = &K{23195} |
||||||
|
NWCNotification = &K{23196} |
||||||
|
WalletNotificationNip4 = &K{23196} |
||||||
|
WalletNotification = &K{23197} |
||||||
|
// NostrConnect is an event type that...
|
||||||
|
NostrConnect = &K{24133} |
||||||
|
HTTPAuth = &K{27235} |
||||||
|
// EphemeralEnd is an event type that...
|
||||||
|
EphemeralEnd = &K{29999} |
||||||
|
// ParameterizedReplaceableStart is an event type that...
|
||||||
|
ParameterizedReplaceableStart = &K{30000} |
||||||
|
// CategorizedPeopleList is an event type that...
|
||||||
|
CategorizedPeopleList = &K{30000} |
||||||
|
FollowSets = &K{30000} |
||||||
|
// CategorizedBookmarksList is an event type that...
|
||||||
|
CategorizedBookmarksList = &K{30001} |
||||||
|
GenericLists = &K{30001} |
||||||
|
RelaySets = &K{30002} |
||||||
|
BookmarkSets = &K{30003} |
||||||
|
CurationSets = &K{30004} |
||||||
|
// ProfileBadges is an event type that...
|
||||||
|
ProfileBadges = &K{30008} |
||||||
|
// BadgeDefinition is an event type that...
|
||||||
|
BadgeDefinition = &K{30009} |
||||||
|
InterestSets = &K{30015} |
||||||
|
// StallDefinition creates or updates a stall
|
||||||
|
StallDefinition = &K{30017} |
||||||
|
// ProductDefinition creates or updates a product
|
||||||
|
ProductDefinition = &K{30018} |
||||||
|
MarketplaceUIUX = &K{30019} |
||||||
|
ProductSoldAsAuction = &K{30020} |
||||||
|
// Article is an event type that...
|
||||||
|
Article = &K{30023} |
||||||
|
LongFormContent = &K{30023} |
||||||
|
DraftLongFormContent = &K{30024} |
||||||
|
EmojiSets = &K{30030} |
||||||
|
// ApplicationSpecificData is an event type stores data about application
|
||||||
|
// configuration, this, like DMs and giftwraps must be protected by user
|
||||||
|
// auth.
|
||||||
|
ApplicationSpecificData = &K{30078} |
||||||
|
LiveEvent = &K{30311} |
||||||
|
UserStatuses = &K{30315} |
||||||
|
ClassifiedListing = &K{30402} |
||||||
|
DraftClassifiedListing = &K{30403} |
||||||
|
DateBasedCalendarEvent = &K{31922} |
||||||
|
TimeBasedCalendarEvent = &K{31923} |
||||||
|
Calendar = &K{31924} |
||||||
|
CalendarEventRSVP = &K{31925} |
||||||
|
HandlerRecommendation = &K{31989} |
||||||
|
HandlerInformation = &K{31990} |
||||||
|
// WaveLakeTrack which has no spec and uses malformed tags
|
||||||
|
WaveLakeTrack = &K{32123} |
||||||
|
CommunityDefinition = &K{34550} |
||||||
|
ACLEvent = &K{39998} |
||||||
|
// ParameterizedReplaceableEnd is an event type that...
|
||||||
|
ParameterizedReplaceableEnd = &K{39999} |
||||||
|
) |
||||||
|
|
||||||
|
var MapMx sync.Mutex |
||||||
|
var Map = map[uint16]string{ |
||||||
|
ProfileMetadata.K: "ProfileMetadata", |
||||||
|
TextNote.K: "TextNote", |
||||||
|
RecommendRelay.K: "RecommendRelay", |
||||||
|
FollowList.K: "FollowList", |
||||||
|
EncryptedDirectMessage.K: "EncryptedDirectMessage", |
||||||
|
EventDeletion.K: "EventDeletion", |
||||||
|
Repost.K: "Repost", |
||||||
|
Reaction.K: "Reaction", |
||||||
|
BadgeAward.K: "BadgeAward", |
||||||
|
ReadReceipt.K: "ReadReceipt", |
||||||
|
GenericRepost.K: "GenericRepost", |
||||||
|
ChannelCreation.K: "ChannelCreation", |
||||||
|
ChannelMetadata.K: "ChannelMetadata", |
||||||
|
ChannelMessage.K: "ChannelMessage", |
||||||
|
ChannelHideMessage.K: "ChannelHideMessage", |
||||||
|
ChannelMuteUser.K: "ChannelMuteUser", |
||||||
|
Bid.K: "Bid", |
||||||
|
BidConfirmation.K: "BidConfirmation", |
||||||
|
OpenTimestamps.K: "OpenTimestamps", |
||||||
|
FileMetadata.K: "FileMetadata", |
||||||
|
LiveChatMessage.K: "LiveChatMessage", |
||||||
|
ProblemTracker.K: "ProblemTracker", |
||||||
|
Reporting.K: "Reporting", |
||||||
|
Label.K: "Label", |
||||||
|
CommunityPostApproval.K: "CommunityPostApproval", |
||||||
|
JobRequestStart.K: "JobRequestStart", |
||||||
|
JobRequestEnd.K: "JobRequestEnd", |
||||||
|
JobResultStart.K: "JobResultStart", |
||||||
|
JobResultEnd.K: "JobResultEnd", |
||||||
|
JobFeedback.K: "JobFeedback", |
||||||
|
ZapGoal.K: "ZapGoal", |
||||||
|
ZapRequest.K: "ZapRequest", |
||||||
|
Zap.K: "Zap", |
||||||
|
Highlights.K: "Highlights", |
||||||
|
BlockList.K: "BlockList", |
||||||
|
PinList.K: "PinList", |
||||||
|
RelayListMetadata.K: "RelayListMetadata", |
||||||
|
BookmarkList.K: "BookmarkList", |
||||||
|
CommunitiesList.K: "CommunitiesList", |
||||||
|
PublicChatsList.K: "PublicChatsList", |
||||||
|
BlockedRelaysList.K: "BlockedRelaysList", |
||||||
|
SearchRelaysList.K: "SearchRelaysList", |
||||||
|
InterestsList.K: "InterestsList", |
||||||
|
UserEmojiList.K: "UserEmojiList", |
||||||
|
DMRelaysList.K: "DMRelaysList", |
||||||
|
FileStorageServerList.K: "FileStorageServerList", |
||||||
|
NWCWalletServiceInfo.K: "NWCWalletServiceInfo", |
||||||
|
LightningPubRPC.K: "LightningPubRPC", |
||||||
|
ClientAuthentication.K: "ClientAuthentication", |
||||||
|
WalletRequest.K: "WalletRequest", |
||||||
|
WalletResponse.K: "WalletResponse", |
||||||
|
WalletNotificationNip4.K: "WalletNotificationNip4", |
||||||
|
WalletNotification.K: "WalletNotification", |
||||||
|
NostrConnect.K: "NostrConnect", |
||||||
|
HTTPAuth.K: "HTTPAuth", |
||||||
|
FollowSets.K: "FollowSets", |
||||||
|
GenericLists.K: "GenericLists", |
||||||
|
RelaySets.K: "RelaySets", |
||||||
|
BookmarkSets.K: "BookmarkSets", |
||||||
|
CurationSets.K: "CurationSets", |
||||||
|
ProfileBadges.K: "ProfileBadges", |
||||||
|
BadgeDefinition.K: "BadgeDefinition", |
||||||
|
InterestSets.K: "InterestSets", |
||||||
|
StallDefinition.K: "StallDefinition", |
||||||
|
ProductDefinition.K: "ProductDefinition", |
||||||
|
MarketplaceUIUX.K: "MarketplaceUIUX", |
||||||
|
ProductSoldAsAuction.K: "ProductSoldAsAuction", |
||||||
|
LongFormContent.K: "LongFormContent", |
||||||
|
DraftLongFormContent.K: "DraftLongFormContent", |
||||||
|
EmojiSets.K: "EmojiSets", |
||||||
|
ApplicationSpecificData.K: "ApplicationSpecificData", |
||||||
|
ParameterizedReplaceableEnd.K: "ParameterizedReplaceableEnd", |
||||||
|
LiveEvent.K: "LiveEvent", |
||||||
|
UserStatuses.K: "UserStatuses", |
||||||
|
ClassifiedListing.K: "ClassifiedListing", |
||||||
|
DraftClassifiedListing.K: "DraftClassifiedListing", |
||||||
|
DateBasedCalendarEvent.K: "DateBasedCalendarEvent", |
||||||
|
TimeBasedCalendarEvent.K: "TimeBasedCalendarEvent", |
||||||
|
Calendar.K: "Calendar", |
||||||
|
CalendarEventRSVP.K: "CalendarEventRSVP", |
||||||
|
HandlerRecommendation.K: "HandlerRecommendation", |
||||||
|
HandlerInformation.K: "HandlerInformation", |
||||||
|
CommunityDefinition.K: "CommunityDefinition", |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
package kind |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
|
||||||
|
"lukechampine.com/frand" |
||||||
|
) |
||||||
|
|
||||||
|
func TestMarshalUnmarshal(t *testing.T) { |
||||||
|
var err error |
||||||
|
k := make([]*K, 1000000) |
||||||
|
for i := range k { |
||||||
|
k[i] = New(uint16(frand.Intn(65535))) |
||||||
|
} |
||||||
|
mk := make([][]byte, len(k)) |
||||||
|
for i := range mk { |
||||||
|
mk[i] = make([]byte, 0, 5) // 16 bits max 65535 = 5 characters
|
||||||
|
} |
||||||
|
for i := range k { |
||||||
|
mk[i] = k[i].Marshal(mk[i]) |
||||||
|
} |
||||||
|
k2 := make([]*K, len(k)) |
||||||
|
for i := range k2 { |
||||||
|
k2[i] = New(0) |
||||||
|
} |
||||||
|
for i := range k2 { |
||||||
|
var r []byte |
||||||
|
if r, err = k2[i].Unmarshal(mk[i]); chk.E(err) { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
if len(r) != 0 { |
||||||
|
t.Fatalf("remainder after unmarshal: '%s'", r) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,150 @@ |
|||||||
|
// Package kinds is a set of helpers for dealing with lists of kind numbers
|
||||||
|
// including comparisons and encoding.
|
||||||
|
package kind |
||||||
|
|
||||||
|
import ( |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/errorf" |
||||||
|
"next.orly.dev/pkg/encoders/ints" |
||||||
|
) |
||||||
|
|
||||||
|
// S is an array of kind.K, used in filter.K and filter.S for searches.
|
||||||
|
type S struct { |
||||||
|
K []*K |
||||||
|
} |
||||||
|
|
||||||
|
// NewS creates a new kinds.S, if no parameter is given it just creates an empty zero kinds.S.
|
||||||
|
func NewS(k ...*K) *S { return &S{k} } |
||||||
|
|
||||||
|
// NewWithCap creates a new empty kinds.S with a given slice capacity.
|
||||||
|
func NewWithCap(c int) *S { return &S{make([]*K, 0, c)} } |
||||||
|
|
||||||
|
// FromIntSlice converts a []int into a kinds.S.
|
||||||
|
func FromIntSlice(is []int) (k *S) { |
||||||
|
k = &S{} |
||||||
|
for i := range is { |
||||||
|
k.K = append(k.K, New(uint16(is[i]))) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Len returns the number of elements in a kinds.S.
|
||||||
|
func (k *S) Len() (l int) { |
||||||
|
if k == nil { |
||||||
|
return |
||||||
|
} |
||||||
|
return len(k.K) |
||||||
|
} |
||||||
|
|
||||||
|
// Less returns which of two elements of a kinds.S is lower.
|
||||||
|
func (k *S) Less(i, j int) bool { return k.K[i].K < k.K[j].K } |
||||||
|
|
||||||
|
// Swap switches the position of two kinds.S elements.
|
||||||
|
func (k *S) Swap(i, j int) { |
||||||
|
k.K[i].K, k.K[j].K = k.K[j].K, k.K[i].K |
||||||
|
} |
||||||
|
|
||||||
|
// ToUint16 returns a []uint16 version of the kinds.S.
|
||||||
|
func (k *S) ToUint16() (o []uint16) { |
||||||
|
for i := range k.K { |
||||||
|
o = append(o, k.K[i].ToU16()) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Clone makes a new kind.K with the same members.
|
||||||
|
func (k *S) Clone() (c *S) { |
||||||
|
c = &S{K: make([]*K, len(k.K))} |
||||||
|
for i := range k.K { |
||||||
|
c.K[i] = k.K[i] |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Contains returns true if the provided element is found in the kinds.S.
|
||||||
|
//
|
||||||
|
// Note that the request must use the typed kind.K or convert the number thus.
|
||||||
|
// Even if a custom number is found, this codebase does not have the logic to
|
||||||
|
// deal with the kind so such a search is pointless and for which reason static
|
||||||
|
// typing always wins. No mistakes possible with known quantities.
|
||||||
|
func (k *S) Contains(s *K) bool { |
||||||
|
for i := range k.K { |
||||||
|
if k.K[i].Equal(s) { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Equals checks that the provided kind.K matches.
|
||||||
|
func (k *S) Equals(t1 *S) bool { |
||||||
|
if len(k.K) != len(t1.K) { |
||||||
|
return false |
||||||
|
} |
||||||
|
for i := range k.K { |
||||||
|
if k.K[i] != t1.K[i] { |
||||||
|
return false |
||||||
|
} |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
// Marshal renders the kinds.S into a JSON array of integers.
|
||||||
|
func (k *S) Marshal(dst []byte) (b []byte) { |
||||||
|
b = dst |
||||||
|
b = append(b, '[') |
||||||
|
for i := range k.K { |
||||||
|
b = k.K[i].Marshal(b) |
||||||
|
if i != len(k.K)-1 { |
||||||
|
b = append(b, ',') |
||||||
|
} |
||||||
|
} |
||||||
|
b = append(b, ']') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Unmarshal decodes a provided JSON array of integers into a kinds.S.
|
||||||
|
func (k *S) Unmarshal(b []byte) (r []byte, err error) { |
||||||
|
r = b |
||||||
|
var openedBracket bool |
||||||
|
for ; len(r) > 0; r = r[1:] { |
||||||
|
if !openedBracket && r[0] == '[' { |
||||||
|
openedBracket = true |
||||||
|
continue |
||||||
|
} else if openedBracket { |
||||||
|
if r[0] == ']' { |
||||||
|
// done
|
||||||
|
return |
||||||
|
} else if r[0] == ',' { |
||||||
|
continue |
||||||
|
} |
||||||
|
kk := ints.New(0) |
||||||
|
if r, err = kk.Unmarshal(r); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
k.K = append(k.K, New(kk.Uint16())) |
||||||
|
if r[0] == ']' { |
||||||
|
r = r[1:] |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if !openedBracket { |
||||||
|
return nil, errorf.E( |
||||||
|
"kinds: failed to unmarshal\n%s\n%s\n%s", k, |
||||||
|
b, r, |
||||||
|
) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// IsPrivileged returns true if any of the elements of a kinds.S are privileged (ie, they should
|
||||||
|
// be privacy protected).
|
||||||
|
func (k *S) IsPrivileged() (priv bool) { |
||||||
|
for i := range k.K { |
||||||
|
if k.K[i].IsPrivileged() { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
package kind |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lukechampine.com/frand" |
||||||
|
) |
||||||
|
|
||||||
|
func TestUnmarshalKindsArray(t *testing.T) { |
||||||
|
k := &S{make([]*K, 100)} |
||||||
|
for i := range k.K { |
||||||
|
k.K[i] = New(uint16(frand.Intn(65535))) |
||||||
|
} |
||||||
|
var dst []byte |
||||||
|
var err error |
||||||
|
if dst = k.Marshal(dst); chk.E(err) { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
k2 := &S{} |
||||||
|
var rem []byte |
||||||
|
if rem, err = k2.Unmarshal(dst); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if len(rem) > 0 { |
||||||
|
t.Fatalf("failed to unmarshal, remnant afterwards '%s'", rem) |
||||||
|
} |
||||||
|
for i := range k.K { |
||||||
|
if *k.K[i] != *k2.K[i] { |
||||||
|
t.Fatalf( |
||||||
|
"failed to unmarshal at element %d; got %x, expected %x", |
||||||
|
i, k.K[i], k2.K[i], |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
// Package timestamp is a set of helpers for working with timestamps including
|
||||||
|
// encoding and conversion to various integer forms, from time.Time and varints.
|
||||||
|
package timestamp |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/binary" |
||||||
|
"time" |
||||||
|
"unsafe" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/errorf" |
||||||
|
"next.orly.dev/pkg/encoders/ints" |
||||||
|
) |
||||||
|
|
||||||
|
// T is a convenience type for UNIX 64 bit timestamps of 1 second
|
||||||
|
// precision.
|
||||||
|
type T struct{ V int64 } |
||||||
|
|
||||||
|
// New creates a new timestamp.T, as zero or optionally from the first variadic
|
||||||
|
// parameter as int64.
|
||||||
|
func New(x ...int64) (t *T) { |
||||||
|
t = &T{} |
||||||
|
if len(x) > 0 { |
||||||
|
t.V = x[0] |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Now returns the current UNIX timestamp of the current second.
|
||||||
|
func Now() *T { |
||||||
|
tt := T{time.Now().Unix()} |
||||||
|
return &tt |
||||||
|
} |
||||||
|
|
||||||
|
// U64 returns the current UNIX timestamp of the current second as uint64.
|
||||||
|
func (t *T) U64() uint64 { |
||||||
|
if t == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return uint64(t.V) |
||||||
|
} |
||||||
|
|
||||||
|
// I64 returns the current UNIX timestamp of the current second as int64.
|
||||||
|
func (t *T) I64() int64 { |
||||||
|
if t == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return t.V |
||||||
|
} |
||||||
|
|
||||||
|
// Time converts a timestamp.Time value into a canonical UNIX 64 bit 1 second
|
||||||
|
// precision timestamp.
|
||||||
|
func (t *T) Time() time.Time { return time.Unix(t.V, 0) } |
||||||
|
|
||||||
|
// Int returns the timestamp as an int.
|
||||||
|
func (t *T) Int() int { |
||||||
|
if t == nil { |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return int(t.V) |
||||||
|
} |
||||||
|
|
||||||
|
// Bytes returns a timestamp as an 8 byte thing.
|
||||||
|
func (t *T) Bytes() (b []byte) { |
||||||
|
b = make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(b, uint64(t.V)) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// FromTime returns a T from a time.Time
|
||||||
|
func FromTime(t time.Time) *T { return &T{t.Unix()} } |
||||||
|
|
||||||
|
// FromUnix converts from a standard int64 unix timestamp.
|
||||||
|
func FromUnix(t int64) *T { return &T{t} } |
||||||
|
|
||||||
|
func (t *T) FromInt(i int) { *t = T{int64(i)} } |
||||||
|
|
||||||
|
// FromBytes converts from a string of raw bytes.
|
||||||
|
func FromBytes(b []byte) *T { return &T{int64(binary.BigEndian.Uint64(b))} } |
||||||
|
|
||||||
|
// FromVarint decodes a varint and returns the remainder of the bytes and the encoded
|
||||||
|
// timestamp.T.
|
||||||
|
func FromVarint(b []byte) (t *T, rem []byte, err error) { |
||||||
|
n, read := binary.Varint(b) |
||||||
|
if read < 1 { |
||||||
|
err = errorf.E("failed to decode varint timestamp %v", b) |
||||||
|
return |
||||||
|
} |
||||||
|
t = &T{n} |
||||||
|
rem = b[:read] |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// String renders a timestamp.T as a string.
|
||||||
|
func (t *T) String() (s string) { |
||||||
|
b := make([]byte, 0, 20) |
||||||
|
tt := ints.New(t.U64()) |
||||||
|
b = tt.Marshal(b) |
||||||
|
return unsafe.String(&b[0], len(b)) |
||||||
|
} |
||||||
|
|
||||||
|
// Marshal a timestamp.T into bytes and append to a provided byte slice.
|
||||||
|
func (t *T) Marshal(dst []byte) (b []byte) { return ints.New(t.U64()).Marshal(dst) } |
||||||
|
|
||||||
|
// Unmarshal a byte slice with an encoded timestamp.T value and append it to a provided byte
|
||||||
|
// slice.
|
||||||
|
func (t *T) Unmarshal(b []byte) (r []byte, err error) { |
||||||
|
n := ints.New(0) |
||||||
|
if r, err = n.Unmarshal(b); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
*t = T{n.Int64()} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// MarshalJSON marshals a timestamp.T using the json MarshalJSON interface.
|
||||||
|
func (t *T) MarshalJSON() ([]byte, error) { |
||||||
|
return ints.New(t.U64()).Marshal(nil), nil |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
package relayinfo |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"io" |
||||||
|
"net/http" |
||||||
|
"time" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/errorf" |
||||||
|
"next.orly.dev/pkg/utils/normalize" |
||||||
|
) |
||||||
|
|
||||||
|
// Fetch fetches the NIP-11 Info.
|
||||||
|
func Fetch(c context.Context, u []byte) (info *T, err error) { |
||||||
|
if _, ok := c.Deadline(); !ok { |
||||||
|
// if no timeout is set, force it to 7 seconds
|
||||||
|
var cancel context.CancelFunc |
||||||
|
c, cancel = context.WithTimeout(c, 7*time.Second) |
||||||
|
defer cancel() |
||||||
|
} |
||||||
|
u = normalize.URL(u) |
||||||
|
var req *http.Request |
||||||
|
if req, err = http.NewRequestWithContext( |
||||||
|
c, http.MethodGet, string(u), nil, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
// add the NIP-11 header
|
||||||
|
req.Header.Add("Accept", "application/nostr+json") |
||||||
|
// send the response
|
||||||
|
var resp *http.Response |
||||||
|
if resp, err = http.DefaultClient.Do(req); chk.E(err) { |
||||||
|
err = errorf.E("request failed: %w", err) |
||||||
|
return |
||||||
|
} |
||||||
|
defer chk.E(resp.Body.Close()) |
||||||
|
var b []byte |
||||||
|
if b, err = io.ReadAll(resp.Body); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
info = &T{} |
||||||
|
if err = json.Unmarshal(b, info); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
package relayinfo |
||||||
|
|
||||||
|
import "testing" |
||||||
|
|
||||||
|
func TestAddSupportedNIP(t *testing.T) { |
||||||
|
info := NewInfo(nil) |
||||||
|
info.AddNIPs(12, 12, 13, 1, 12, 44, 2, 13, 2, 13, 0, 17, 19, 1, 18) |
||||||
|
for i, v := range []int{0, 1, 2, 12, 13, 17, 18, 19, 44} { |
||||||
|
if !info.HasNIP(v) { |
||||||
|
t.Errorf("expected info.nips[%d] to equal %v, got %v", |
||||||
|
i, v, info.Nips) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
package relayinfo |
||||||
|
|
||||||
|
// AddSupportedNIP appends a supported NIP number to a RelayInfo.
|
||||||
|
func (ri *T) AddSupportedNIP(n int) { |
||||||
|
idx, exists := ri.Nips.HasNumber(n) |
||||||
|
if exists { |
||||||
|
return |
||||||
|
} |
||||||
|
ri.Nips = append(ri.Nips, -1) |
||||||
|
copy(ri.Nips[idx+1:], ri.Nips[idx:]) |
||||||
|
ri.Nips[idx] = n |
||||||
|
} |
||||||
|
|
||||||
|
// Admission is the cost of opening an account with a relay.
|
||||||
|
type Admission struct { |
||||||
|
Amount int `json:"amount"` |
||||||
|
Unit string `json:"unit"` |
||||||
|
} |
||||||
|
|
||||||
|
// Subscription is the cost of keeping an account open for a specified period of time.
|
||||||
|
type Subscription struct { |
||||||
|
Amount int `json:"amount"` |
||||||
|
Unit string `json:"unit"` |
||||||
|
Period int `json:"period"` |
||||||
|
} |
||||||
|
|
||||||
|
// Publication is the cost and restrictions on storing events on a relay.
|
||||||
|
type Publication []struct { |
||||||
|
Kinds []int `json:"kinds"` |
||||||
|
Amount int `json:"amount"` |
||||||
|
Unit string `json:"unit"` |
||||||
|
} |
||||||
|
|
||||||
|
// Fees defines the fee structure used for a paid relay.
|
||||||
|
type Fees struct { |
||||||
|
Admission []Admission `json:"admission,omitempty"` |
||||||
|
Subscription []Subscription `json:"subscription,omitempty"` |
||||||
|
Publication []Publication `json:"publication,omitempty"` |
||||||
|
} |
||||||
@ -0,0 +1,358 @@ |
|||||||
|
package relayinfo |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"os" |
||||||
|
"sort" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/pkg/encoders/kind" |
||||||
|
"next.orly.dev/pkg/encoders/timestamp" |
||||||
|
"next.orly.dev/pkg/utils/number" |
||||||
|
) |
||||||
|
|
||||||
|
// NIP is a number and description of a nostr "improvement" possibility.
|
||||||
|
type NIP struct { |
||||||
|
Description string |
||||||
|
Number int |
||||||
|
} |
||||||
|
|
||||||
|
// N returns the number of a nostr "improvement" possibility.
|
||||||
|
func (n NIP) N() int { return n.Number } |
||||||
|
|
||||||
|
// GetList converts a NIP into a number.List of simple numbers, sorted in
|
||||||
|
// ascending order.
|
||||||
|
func GetList(items ...NIP) (n number.List) { |
||||||
|
for _, item := range items { |
||||||
|
n = append(n, item.N()) |
||||||
|
} |
||||||
|
sort.Sort(n) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// this is the list of all nips and their titles for use in the supported_nips
|
||||||
|
// field
|
||||||
|
var ( |
||||||
|
BasicProtocol = NIP{"Basic protocol flow description", 1} |
||||||
|
NIP1 = BasicProtocol |
||||||
|
FollowList = NIP{"Follow List", 2} |
||||||
|
NIP2 = FollowList |
||||||
|
OpenTimestampsAttestations = NIP{ |
||||||
|
"OpenTimestamps Attestations for Events", 3, |
||||||
|
} |
||||||
|
NIP3 = OpenTimestampsAttestations |
||||||
|
EncryptedDirectMessage = NIP{ |
||||||
|
"Direct Message deprecated in favor of NIP-44", 4, |
||||||
|
} |
||||||
|
NIP4 = EncryptedDirectMessage |
||||||
|
MappingNostrKeysToDNS = NIP{ |
||||||
|
"Mapping Nostr keys to DNS-based identifiers", 5, |
||||||
|
} |
||||||
|
NIP5 = MappingNostrKeysToDNS |
||||||
|
HandlingMentions = NIP{ |
||||||
|
"Handling Mentions deprecated in favor of NIP-27", 8, |
||||||
|
} |
||||||
|
NIP8 = HandlingMentions |
||||||
|
EventDeletion = NIP{"Event Deletion", 9} |
||||||
|
NIP9 = EventDeletion |
||||||
|
RelayInformationDocument = NIP{"Client Information Document", 11} |
||||||
|
NIP11 = RelayInformationDocument |
||||||
|
GenericTagQueries = NIP{"Generic Tag Queries", 12} |
||||||
|
NIP12 = GenericTagQueries |
||||||
|
SubjectTag = NIP{"Subject tag in text events", 14} |
||||||
|
NIP14 = SubjectTag |
||||||
|
NostrMarketplace = NIP{ |
||||||
|
"Nostr Marketplace (for resilient marketplaces)", 15, |
||||||
|
} |
||||||
|
NIP15 = NostrMarketplace |
||||||
|
EventTreatment = NIP{"EVent Treatment", 16} |
||||||
|
NIP16 = EventTreatment |
||||||
|
Reposts = NIP{"Reposts", 18} |
||||||
|
NIP18 = Reposts |
||||||
|
Bech32EncodedEntities = NIP{"bech32-encoded entities", 19} |
||||||
|
NIP19 = Bech32EncodedEntities |
||||||
|
CommandResults = NIP{"Command Results", 20} |
||||||
|
NIP20 = CommandResults |
||||||
|
NostrURIScheme = NIP{"nostr: URI scheme", 21} |
||||||
|
NIP21 = NostrURIScheme |
||||||
|
Comment = NIP{"Comment", 22} |
||||||
|
NIP22 = Comment |
||||||
|
LongFormContent = NIP{"Long-form Content", 23} |
||||||
|
NIP23 = LongFormContent |
||||||
|
ExtraMetadata = NIP{"Extra metadata fields and tags", 24} |
||||||
|
NIP24 = ExtraMetadata |
||||||
|
Reactions = NIP{"Reactions", 25} |
||||||
|
NIP25 = Reactions |
||||||
|
DelegatedEventSigning = NIP{"Delegated Event Signing", 26} |
||||||
|
NIP26 = DelegatedEventSigning |
||||||
|
TextNoteReferences = NIP{"Text Note References", 27} |
||||||
|
NIP27 = TextNoteReferences |
||||||
|
PublicChat = NIP{"Public Chat", 28} |
||||||
|
NIP28 = PublicChat |
||||||
|
CustomEmoji = NIP{"Custom Emoji", 30} |
||||||
|
NIP30 = CustomEmoji |
||||||
|
Labeling = NIP{"Labeling", 32} |
||||||
|
NIP32 = Labeling |
||||||
|
ParameterizedReplaceableEvents = NIP{"Parameterized Replaceable Events", 33} |
||||||
|
NIP33 = ParameterizedReplaceableEvents |
||||||
|
SensitiveContent = NIP{"Sensitive Content", 36} |
||||||
|
NIP36 = SensitiveContent |
||||||
|
UserStatuses = NIP{"User Statuses", 38} |
||||||
|
NIP38 = UserStatuses |
||||||
|
ExternalIdentitiesInProfiles = NIP{"External Identities in Profiles", 39} |
||||||
|
NIP39 = ExternalIdentitiesInProfiles |
||||||
|
ExpirationTimestamp = NIP{"Expiration Timestamp", 40} |
||||||
|
NIP40 = ExpirationTimestamp |
||||||
|
Authentication = NIP{ |
||||||
|
"Authentication of clients to relays", 42, |
||||||
|
} |
||||||
|
NIP42 = Authentication |
||||||
|
VersionedEncryption = NIP{"Versioned Encryption", 44} |
||||||
|
NIP44 = VersionedEncryption |
||||||
|
CountingResults = NIP{"Counting results", 45} |
||||||
|
NIP45 = CountingResults |
||||||
|
NostrConnect = NIP{"Nostr Connect", 46} |
||||||
|
NIP46 = NostrConnect |
||||||
|
WalletConnect = NIP{"Wallet Connect", 47} |
||||||
|
NIP47 = WalletConnect |
||||||
|
ProxyTags = NIP{"Proxy Tags", 48} |
||||||
|
NIP48 = ProxyTags |
||||||
|
SearchCapability = NIP{"Search Capability", 50} |
||||||
|
NIP50 = SearchCapability |
||||||
|
Lists = NIP{"Lists", 51} |
||||||
|
NIP51 = Lists |
||||||
|
CalendarEvents = NIP{"Calendar Events", 52} |
||||||
|
NIP52 = CalendarEvents |
||||||
|
LiveActivities = NIP{"Live Activities", 53} |
||||||
|
NIP53 = LiveActivities |
||||||
|
Reporting = NIP{"Reporting", 56} |
||||||
|
NIP56 = Reporting |
||||||
|
LightningZaps = NIP{"Lightning Zaps", 57} |
||||||
|
NIP57 = LightningZaps |
||||||
|
Badges = NIP{"Badges", 58} |
||||||
|
NIP58 = Badges |
||||||
|
RelayListMetadata = NIP{"Client List Metadata", 65} |
||||||
|
NIP65 = RelayListMetadata |
||||||
|
ProtectedEvents = NIP{"Protected Events", 70} |
||||||
|
NIP70 = ProtectedEvents |
||||||
|
ModeratedCommunities = NIP{"Moderated Communities", 72} |
||||||
|
NIP72 = ModeratedCommunities |
||||||
|
ZapGoals = NIP{"Zap Goals", 75} |
||||||
|
NIP75 = ZapGoals |
||||||
|
ApplicationSpecificData = NIP{"Application-specific data", 78} |
||||||
|
NIP78 = ApplicationSpecificData |
||||||
|
Highlights = NIP{"Highlights", 84} |
||||||
|
NIP84 = Highlights |
||||||
|
RecommendedApplicationHandlers = NIP{"Recommended Application Handlers", 89} |
||||||
|
NIP89 = RecommendedApplicationHandlers |
||||||
|
DataVendingMachines = NIP{"Data Vending Machines", 90} |
||||||
|
NIP90 = DataVendingMachines |
||||||
|
FileMetadata = NIP{"File Metadata", 94} |
||||||
|
NIP94 = FileMetadata |
||||||
|
HTTPFileStorageIntegration = NIP{"HTTP File Storage Integration", 96} |
||||||
|
NIP96 = HTTPFileStorageIntegration |
||||||
|
HTTPAuth = NIP{"HTTP IsAuthed", 98} |
||||||
|
NIP98 = HTTPAuth |
||||||
|
ClassifiedListings = NIP{"Classified Listings", 99} |
||||||
|
NIP99 = ClassifiedListings |
||||||
|
) |
||||||
|
|
||||||
|
var NIPMap = map[int]NIP{ |
||||||
|
1: NIP1, 2: NIP2, 3: NIP3, 4: NIP4, 5: NIP5, 8: NIP8, 9: NIP9, |
||||||
|
11: NIP11, 12: NIP12, 14: NIP14, 15: NIP15, 16: NIP16, 18: NIP18, 19: NIP19, |
||||||
|
20: NIP20, |
||||||
|
21: NIP21, 22: NIP22, 23: NIP23, 24: NIP24, 25: NIP25, 26: NIP26, 27: NIP27, |
||||||
|
28: NIP28, |
||||||
|
30: NIP30, 32: NIP32, 33: NIP33, 36: NIP36, 38: NIP38, 39: NIP39, 40: NIP40, |
||||||
|
42: NIP42, |
||||||
|
44: NIP44, 45: NIP45, 46: NIP46, 47: NIP47, 48: NIP48, 50: NIP50, 51: NIP51, |
||||||
|
52: NIP52, |
||||||
|
53: NIP53, 56: NIP56, 57: NIP57, 58: NIP58, 65: NIP65, 72: NIP72, 75: NIP75, |
||||||
|
78: NIP78, |
||||||
|
84: NIP84, 89: NIP89, 90: NIP90, 94: NIP94, 96: NIP96, 98: NIP98, 99: NIP99, |
||||||
|
} |
||||||
|
|
||||||
|
// Limits are rules about what is acceptable for events and filters on a relay.
|
||||||
|
type Limits struct { |
||||||
|
// MaxMessageLength is the maximum number of bytes for incoming JSON that
|
||||||
|
// the relay will attempt to decode and act upon. When you send large
|
||||||
|
// subscriptions, you will be limited by this value. It also effectively
|
||||||
|
// limits the maximum size of any event. Value is calculated from [ to ] and
|
||||||
|
// is after UTF-8 serialization (so some Unicode characters will cost 2-3
|
||||||
|
// bytes). It is equal to the maximum size of the WebSocket message frame.
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty"` |
||||||
|
// MaxSubscriptions is the total number of subscriptions that may be active
|
||||||
|
// on a single websocket connection to this relay. It's possible that
|
||||||
|
// authenticated clients with a (paid) relationship to the relay may have
|
||||||
|
// higher limits.
|
||||||
|
MaxSubscriptions int `json:"max_subscriptions,omitempty"` |
||||||
|
// MaxFilter is the maximum number of filter values in each subscription.
|
||||||
|
// Must be one or higher.
|
||||||
|
MaxFilters int `json:"max_filters,omitempty"` |
||||||
|
// MaxLimit is the relay server will clamp each filter's limit value to this
|
||||||
|
// number. This means the client won't be able to get more than this number
|
||||||
|
// of events from a single subscription filter. This clamping is typically
|
||||||
|
// done silently by the relay, but with this number, you can know that there
|
||||||
|
// are additional results if you narrowed your filter's time range or other
|
||||||
|
// parameters.
|
||||||
|
MaxLimit int `json:"max_limit,omitempty"` |
||||||
|
// MaxSubidLength is the maximum length of subscription id as a string.
|
||||||
|
MaxSubidLength int `json:"max_subid_length,omitempty"` |
||||||
|
// MaxEventTags in any event, this is the maximum number of elements in the
|
||||||
|
// tags list.
|
||||||
|
MaxEventTags int `json:"max_event_tags,omitempty"` |
||||||
|
// MaxContentLength maximum number of characters in the content field of any
|
||||||
|
// event. This is a count of Unicode characters. After serializing into JSON
|
||||||
|
// it may be larger (in bytes), and is still subject to the
|
||||||
|
// max_message_length, if defined.
|
||||||
|
MaxContentLength int `json:"max_content_length,omitempty"` |
||||||
|
// MinPowDifficulty new events will require at least this difficulty of PoW,
|
||||||
|
// based on NIP-13, or they will be rejected by this server.
|
||||||
|
MinPowDifficulty int `json:"min_pow_difficulty,omitempty"` |
||||||
|
// AuthRequired means the relay requires NIP-42 authentication to happen
|
||||||
|
// before a new connection may perform any other action. Even if set to
|
||||||
|
// False, authentication may be required for specific actions.
|
||||||
|
AuthRequired bool `json:"auth_required"` |
||||||
|
// PaymentRequired this relay requires payment before a new connection may
|
||||||
|
// perform any action.
|
||||||
|
PaymentRequired bool `json:"payment_required"` |
||||||
|
// RestrictedWrites means this relay requires some kind of condition to be
|
||||||
|
// fulfilled to accept events (not necessarily, but including
|
||||||
|
// payment_required and min_pow_difficulty). This should only be set to true
|
||||||
|
// when users are expected to know the relay policy before trying to write
|
||||||
|
// to it -- like belonging to a special pubkey-based whitelist or writing
|
||||||
|
// only events of a specific niche kind or content. Normal anti-spam
|
||||||
|
// heuristics, for example, do not qualify.q
|
||||||
|
RestrictedWrites bool `json:"restricted_writes"` |
||||||
|
Oldest *timestamp.T `json:"created_at_lower_limit,omitempty"` |
||||||
|
Newest *timestamp.T `json:"created_at_upper_limit,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
// Payment is an amount and currency unit name.
|
||||||
|
type Payment struct { |
||||||
|
Amount int `json:"amount"` |
||||||
|
Unit string `json:"unit"` |
||||||
|
} |
||||||
|
|
||||||
|
// Sub is a subscription, with the Payment and the period it yields.
|
||||||
|
type Sub struct { |
||||||
|
Payment |
||||||
|
Period int `json:"period"` |
||||||
|
} |
||||||
|
|
||||||
|
// Pub is a limitation for what you can store on the relay as a kinds.S and the
|
||||||
|
// cost (for???).
|
||||||
|
type Pub struct { |
||||||
|
Kinds kind.S `json:"kinds"` |
||||||
|
Payment |
||||||
|
} |
||||||
|
|
||||||
|
// T is the relay information document.
|
||||||
|
type T struct { |
||||||
|
Name string `json:"name"` |
||||||
|
Description string `json:"description,omitempty"` |
||||||
|
PubKey string `json:"pubkey,omitempty"` |
||||||
|
Contact string `json:"contact,omitempty"` |
||||||
|
Nips number.List `json:"supported_nips"` |
||||||
|
Software string `json:"software"` |
||||||
|
Version string `json:"version"` |
||||||
|
Limitation Limits `json:"limitation,omitempty"` |
||||||
|
Retention any `json:"retention,omitempty"` |
||||||
|
RelayCountries []string `json:"relay_countries,omitempty"` |
||||||
|
LanguageTags []string `json:"language_tags,omitempty"` |
||||||
|
Tags []string `json:"tags,omitempty"` |
||||||
|
PostingPolicy string `json:"posting_policy,omitempty"` |
||||||
|
PaymentsURL string `json:"payments_url,omitempty"` |
||||||
|
Fees *Fees `json:"fees,omitempty"` |
||||||
|
Icon string `json:"icon"` |
||||||
|
sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
// NewInfo populates the nips map, and if an Info structure is provided, it is
|
||||||
|
// used and its nips map is populated if it isn't already.
|
||||||
|
func NewInfo(inf *T) (info *T) { |
||||||
|
if inf != nil { |
||||||
|
info = inf |
||||||
|
} else { |
||||||
|
info = &T{ |
||||||
|
Limitation: Limits{ |
||||||
|
MaxLimit: 500, |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Clone replicates a relayinfo.T.
|
||||||
|
// todo: this could be done better, but i don't think it's in use.
|
||||||
|
func (ri *T) Clone() (r2 *T, err error) { |
||||||
|
r2 = new(T) |
||||||
|
var b []byte |
||||||
|
if b, err = json.Marshal(ri); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if err = json.Unmarshal(b, r2); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// AddNIPs adds one or more numbers to the list of NIPs.
|
||||||
|
func (ri *T) AddNIPs(n ...int) { |
||||||
|
ri.Lock() |
||||||
|
for _, num := range n { |
||||||
|
ri.Nips = append(ri.Nips, num) |
||||||
|
} |
||||||
|
ri.Unlock() |
||||||
|
} |
||||||
|
|
||||||
|
// HasNIP returns true if the given number is found in the list.
|
||||||
|
func (ri *T) HasNIP(n int) (ok bool) { |
||||||
|
ri.Lock() |
||||||
|
for i := range ri.Nips { |
||||||
|
if ri.Nips[i] == n { |
||||||
|
ok = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
ri.Unlock() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Save the relayinfo.T to a given file as JSON.
|
||||||
|
func (ri *T) Save(filename string) (err error) { |
||||||
|
if ri == nil { |
||||||
|
err = errors.New("cannot save nil relay info document") |
||||||
|
log.E.Ln(err) |
||||||
|
return |
||||||
|
} |
||||||
|
var b []byte |
||||||
|
if b, err = json.MarshalIndent(ri, "", " "); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if err = os.WriteFile(filename, b, 0600); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Load a given file and decode the JSON relayinfo.T encoded in it.
|
||||||
|
func (ri *T) Load(filename string) (err error) { |
||||||
|
if ri == nil { |
||||||
|
err = errors.New("cannot load into nil config") |
||||||
|
log.E.Ln(err) |
||||||
|
return |
||||||
|
} |
||||||
|
var b []byte |
||||||
|
if b, err = os.ReadFile(filename); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
// log.S.ToSliceOfBytes("realy information document\n%s", string(b))
|
||||||
|
if err = json.Unmarshal(b, ri); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,143 @@ |
|||||||
|
// Package normalize is a set of tools for cleaning up URL s and formatting
|
||||||
|
// nostr OK and CLOSED messages.
|
||||||
|
package normalize |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"fmt" |
||||||
|
"net/url" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/pkg/encoders/ints" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
hp = bytes.HasPrefix |
||||||
|
WS = []byte("ws://") |
||||||
|
WSS = []byte("wss://") |
||||||
|
HTTP = []byte("http://") |
||||||
|
HTTPS = []byte("https://") |
||||||
|
) |
||||||
|
|
||||||
|
// URL normalizes the URL
|
||||||
|
//
|
||||||
|
// - Adds wss:// to addresses without a port, or with 443 that have no protocol
|
||||||
|
// prefix
|
||||||
|
//
|
||||||
|
// - Adds ws:// to addresses with any other port
|
||||||
|
//
|
||||||
|
// - Converts http/s to ws/s
|
||||||
|
func URL[V string | []byte](v V) (b []byte) { |
||||||
|
u := []byte(v) |
||||||
|
if len(u) == 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
u = bytes.TrimSpace(u) |
||||||
|
u = bytes.ToLower(u) |
||||||
|
// if the address has a port number, we can probably assume it is insecure
|
||||||
|
// websocket as most public or production relays have a domain name and a
|
||||||
|
// well-known port 80 or 443 and thus no port number.
|
||||||
|
//
|
||||||
|
// if a protocol prefix is present, we assume it is already complete.
|
||||||
|
// Converting http/s to websocket-equivalent will be done later anyway.
|
||||||
|
if bytes.Contains(u, []byte(":")) && |
||||||
|
!(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) { |
||||||
|
|
||||||
|
split := bytes.Split(u, []byte(":")) |
||||||
|
if len(split) != 2 { |
||||||
|
log.D.F("Error: more than one ':' in URL: '%s'", u) |
||||||
|
// this is a malformed URL if it has more than one ":", return empty
|
||||||
|
// since this function does not return an error explicitly.
|
||||||
|
return |
||||||
|
} |
||||||
|
p := ints.New(0) |
||||||
|
_, err := p.Unmarshal(split[1]) |
||||||
|
if chk.E(err) { |
||||||
|
log.D.F("Error normalizing URL '%s': %s", u, err) |
||||||
|
// again, without an error, we must return nil
|
||||||
|
return |
||||||
|
} |
||||||
|
if p.Uint64() > 65535 { |
||||||
|
log.D.F( |
||||||
|
"Port on address %d: greater than maximum 65535", |
||||||
|
p.Uint64(), |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
// if the port is explicitly set to 443 we assume it is wss:// and drop
|
||||||
|
// the port.
|
||||||
|
if p.Uint16() == 443 { |
||||||
|
u = append(WSS, split[0]...) |
||||||
|
} else { |
||||||
|
u = append(WSS, u...) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// if the prefix isn't specified as http/s or websocket, assume secure
|
||||||
|
// websocket and add wss prefix (this is the most common).
|
||||||
|
if !(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) { |
||||||
|
u = append(WSS, u...) |
||||||
|
} |
||||||
|
var err error |
||||||
|
var p *url.URL |
||||||
|
if p, err = url.Parse(string(u)); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
// convert http/s to ws/s
|
||||||
|
switch p.Scheme { |
||||||
|
case "https": |
||||||
|
p.Scheme = "wss" |
||||||
|
case "http": |
||||||
|
p.Scheme = "ws" |
||||||
|
} |
||||||
|
// remove trailing path slash
|
||||||
|
p.Path = string(bytes.TrimRight([]byte(p.Path), "/")) |
||||||
|
return []byte(p.String()) |
||||||
|
} |
||||||
|
|
||||||
|
// Msg constructs a properly formatted message with a machine-readable prefix
|
||||||
|
// for OK and CLOSED envelopes.
|
||||||
|
func Msg(prefix Reason, format string, params ...any) []byte { |
||||||
|
if len(prefix) < 1 { |
||||||
|
prefix = Error |
||||||
|
} |
||||||
|
return []byte(fmt.Sprintf(prefix.S()+": "+format, params...)) |
||||||
|
} |
||||||
|
|
||||||
|
// Reason is the machine-readable prefix before the colon in an OK or CLOSED
|
||||||
|
// envelope message. Below are the most common kinds that are mentioned in
|
||||||
|
// NIP-01.
|
||||||
|
type Reason []byte |
||||||
|
|
||||||
|
var ( |
||||||
|
AuthRequired = Reason("auth-required") |
||||||
|
PoW = Reason("pow") |
||||||
|
Duplicate = Reason("duplicate") |
||||||
|
Blocked = Reason("blocked") |
||||||
|
RateLimited = Reason("rate-limited") |
||||||
|
Invalid = Reason("invalid") |
||||||
|
Error = Reason("error") |
||||||
|
Unsupported = Reason("unsupported") |
||||||
|
Restricted = Reason("restricted") |
||||||
|
) |
||||||
|
|
||||||
|
// S returns the Reason as a string
|
||||||
|
func (r Reason) S() string { return string(r) } |
||||||
|
|
||||||
|
// B returns the Reason as a byte slice.
|
||||||
|
func (r Reason) B() []byte { return r } |
||||||
|
|
||||||
|
// IsPrefix returns whether a text contains the same Reason prefix.
|
||||||
|
func (r Reason) IsPrefix(reason []byte) bool { |
||||||
|
return bytes.HasPrefix( |
||||||
|
reason, r.B(), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// F allows creation of a full Reason text with a printf style format.
|
||||||
|
func (r Reason) F(format string, params ...any) []byte { |
||||||
|
return Msg( |
||||||
|
r, format, params..., |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
package normalize |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestURL(t *testing.T) { |
||||||
|
fmt.Println(URL([]byte(""))) |
||||||
|
fmt.Println(URL([]byte("wss://x.com/y"))) |
||||||
|
fmt.Println(URL([]byte("wss://x.com/y/"))) |
||||||
|
fmt.Println(URL([]byte("http://x.com/y"))) |
||||||
|
fmt.Println(URL(URL([]byte("http://x.com/y")))) |
||||||
|
fmt.Println(URL([]byte("wss://x.com"))) |
||||||
|
fmt.Println(URL([]byte("wss://x.com/"))) |
||||||
|
fmt.Println(URL(URL(URL([]byte("wss://x.com/"))))) |
||||||
|
fmt.Println(URL([]byte("x.com"))) |
||||||
|
fmt.Println(URL([]byte("x.com/"))) |
||||||
|
fmt.Println(URL([]byte("x.com////"))) |
||||||
|
fmt.Println(URL([]byte("x.com/?x=23"))) |
||||||
|
|
||||||
|
// Output:
|
||||||
|
//
|
||||||
|
// wss://x.com/y
|
||||||
|
// wss://x.com/y
|
||||||
|
// ws://x.com/y
|
||||||
|
// ws://x.com/y
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com
|
||||||
|
// wss://x.com?x=23
|
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
// Package number implements a simple number list, used with relayinfo package
|
||||||
|
// for NIP support lists.
|
||||||
|
package number |
||||||
|
|
||||||
|
import "fmt" |
||||||
|
|
||||||
|
// List is a simple list of numbers with a sort implementation and number match.
|
||||||
|
type List []int |
||||||
|
|
||||||
|
func (l List) Len() int { return len(l) } |
||||||
|
func (l List) Less(i, j int) bool { return l[i] < l[j] } |
||||||
|
func (l List) Swap(i, j int) { l[i], l[j] = l[j], l[i] } |
||||||
|
|
||||||
|
// HasNumber returns true if the list contains a given number
|
||||||
|
func (l List) HasNumber(n int) (idx int, has bool) { |
||||||
|
for idx = range l { |
||||||
|
if l[idx] == n { |
||||||
|
has = true |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// String outputs a number.List as a minified JSON array.
|
||||||
|
func (l List) String() (s string) { |
||||||
|
s += "[" |
||||||
|
for i := range l { |
||||||
|
if i > 0 { |
||||||
|
s += "," |
||||||
|
} |
||||||
|
s += fmt.Sprint(l[i]) |
||||||
|
} |
||||||
|
s += "]" |
||||||
|
return |
||||||
|
} |
||||||
Loading…
Reference in new issue