import 'dart:io'; import 'dart:convert'; import 'package:intl/intl.dart'; // name of executable String exename = "nostr_console"; String version = "0.0.3"; // well known disposable test private key const String gDefaultPrivateKey = "9d00d99c8dfad84534d3b395280ca3b3e81be5361d69dc0abf8e0fdf5a9d52f9"; const String gDefaultPublicKey = "e8caa2028a7090ffa85f1afee67451b309ba2f9dee655ec8f7e0a02c29388180"; String userPrivateKey = gDefaultPrivateKey; String userPublicKey = gDefaultPublicKey; const int gMinValidTextWidth = 60; // minimum text width acceptable const int gDefaultTextWidth = 120; // default text width int gTextWidth = gDefaultTextWidth; // is changed by --width option const int gSpacesPerDepth = 8; // constant int gNumLeftMarginSpaces = 0;// this number is modified in main String gAlignment = "center"; // is modified in main if --align argument is given const int gapBetweenTopTrees = 1; // after depth of maxDepthAllowed the thread is re-aligned to left by leftShiftThreadBy const int gMinimumDepthAllowed = 2; const int gMaximumDepthAllowed = 12; const int gDefaultMaxDepth = 4; int maxDepthAllowed = gDefaultMaxDepth; const int leftShiftThreadsBy = 2; // 33 yellow, 31 red, 34 blue, 35 magenta. Add 60 for bright versions. const String commentColor = "\x1B[32m"; // green const String notificationColor = "\x1b[36m"; // cyan const String warningColor = "\x1B[31m"; // red const String colorEndMarker = "\x1B[0m"; //String defaultServerUrl = 'wss://relay.damus.io'; String defaultServerUrl = 'wss://nostr-relay.untethr.me'; // dummy account pubkey const String gDummyAccountPubkey = "Non"; // By default the threads that were started in last one day are shown // this can be changed with 'days' command line argument int gNumLastDays = 1; // global user names from kind 0 events, mapped from public key to user name Map gKindONames = {}; // global reactions entry. Map of form // reach Reactor is a list of 2-elements ( first is public id of reactor, second is comment) Map< String, List> > gReactions = {}; // global contact list of each user, including of the logged in user. // maps from pubkey of a user, to the latest contact list of that user, which is the latest kind 3 message // is updated as kind 3 events are received Map< String, List> gContactLists = {}; // bots ignored to reduce spam List gBots = [ "3b57518d02e6acfd5eb7198530b2e351e5a52278fb2499d14b66db2b5791c512", // robosats orderbook "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", // bestofhn "f4161c88558700d23af18d8a6386eb7d7fed769048e1297811dcc34e86858fb2" // bitcoin_bot ]; //const String gDefaultEventsFilename = "events_store_nostr.txt"; String gEventsFilename = ""; // is set in arguments, and if set, then file is read from and written to int gDebug = 0; void printDepth(int d) { for( int i = 0; i < gSpacesPerDepth * d + gNumLeftMarginSpaces; i++) { stdout.write(" "); } } String getNumSpaces(int num) { String s = ""; for( int i = 0; i < num; i++) { s += " "; } return s; } String getNumDashes(int num) { String s = ""; for( int i = 0; i < num; i++) { s += "-"; } return s; } String rightShiftContent(String s, int numSpaces) { String newString = ""; int newlineCounter = 0; String spacesString = getNumSpaces(numSpaces + gNumLeftMarginSpaces); for(int i = 0; i < s.length; i++) { if( s[i] == '\n') { newString += "\n"; newString += spacesString; newlineCounter = 0; } else { if( newlineCounter >= (gTextWidth - numSpaces)) { newString += "\n"; newString += spacesString; newlineCounter = 0; } newString += s[i]; } newlineCounter++; } return newString; } // The contact only stores id and relay of contact. The actual name is stored in a global variable/map class Contact { String id, relay; Contact(this.id, this.relay); @override String toString() { return 'id: $id ( ${getAuthorName(id)}) relay: $relay'; } } class EventData { String id; String pubkey; int createdAt; int kind; String content; List eTagsRest;// rest of e tags List pTags;// list of p tags for kind:1 List> tags; bool isNotification; // whether its to be highlighted using highlight color Set newLikes; // List contactList = []; // used for kind:3 events, which is contact list event String getParent() { if( eTagsRest.isNotEmpty) { return eTagsRest[eTagsRest.length - 1]; } return ""; } EventData(this.id, this.pubkey, this.createdAt, this.kind, this.content, this.eTagsRest, this.pTags, this.contactList, this.tags, this.newLikes, {this.isNotification = false}); factory EventData.fromJson(dynamic json) { List contactList = []; List eTagsRead = []; List pTagsRead = []; List> tagsRead = []; var jsonTags = json['tags']; var numTags = jsonTags.length; // NIP 02: if the event is a contact list type, then populate contactList if(json['kind'] == 3) { for( int i = 0; i < numTags; i++) { var tag = jsonTags[i]; var n = tag.length; String server = defaultServerUrl; if( n >=3 ) { server = tag[2].toString(); if( server == 'wss://nostr.rocks' || server == "wss://nostr.bitcoiner.social") { server = defaultServerUrl; } //server = defaultServerUrl; } Contact c = Contact(tag[1] as String, server); contactList.add(c); } } else { if ( json['kind'] == 1 || json['kind'] == 7) { for( int i = 0; i < numTags; i++) { var tag = jsonTags[i]; //stdout.write(tag); //print(tag.runtimeType); if( tag.isEmpty) { continue; } if( tag[0] == "e") { eTagsRead.add(tag[1]); } else { if( tag[0] == "p") { pTagsRead.add(tag[1]); } } List t = []; t.add(tag[0]); t.add(tag[1]); tagsRead.add(t); // TODO add other tags } } } if(gDebug >= 2 ) { print("----------------------------------------Creating EventData with content: ${json['content']}"); } String checkEventId = "c39c03f70a88207fdecd356cbbb05b508ee28115fba03f55d6c5e852086b4ddf"; if( json['id'] == checkEventId) { if(gDebug >= 1) print("got message: $checkEventId"); } return EventData(json['id'] as String, json['pubkey'] as String, json['created_at'] as int, json['kind'] as int, json['content'].trim() as String, eTagsRead, pTagsRead, contactList, tagsRead, {}); } String expandMentions(String content) { if( tags.isEmpty) { return content; } // just check if there is any square bracket in comment, if not we return String squareBracketStart = "[", squareBracketEnd = "]"; if( !content.contains(squareBracketStart) || !content.contains(squareBracketEnd) ) { return content; } // replace the patterns List placeHolders = ["#[0]", "#[1]", "#[2]", "#[3]", "#[4]", "#[5]", "#[6]", "#[7]" ]; for(int i = 0; i < placeHolders.length; i++) { int index = -1; Pattern p = placeHolders[i]; if( (index = content.indexOf(p)) != -1 ) { if( i >= tags.length) { continue; } if( tags[i].isEmpty || tags[i].length < 2) { continue; } String author = getAuthorName(tags[i][1]); content = "${content.substring(0, index)} @$author${content.substring(index + 4)}"; } } return content; } // prints event data in the format that allows it to be shown in tree form by the Tree class void printEventData(int depth) { int n = 3; String maxN(String v) => v.length > n? v.substring(0,n) : v.substring(0, v.length); void printInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?stdout.write("$commentColor$s$colorEndMarker"):stdout.write(s); DateTime dTime = DateTime.fromMillisecondsSinceEpoch(createdAt *1000); // TODO do it in one call final df1 = DateFormat('hh:mm a'); final df2 = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY); String strDate = df1.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000)); strDate += " ${df2.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000))}"; if( createdAt == 0) { print("debug: createdAt == 0 for event $content"); } content = expandMentions(content); String contentShifted = rightShiftContent(content, gSpacesPerDepth * depth + 10); printDepth(depth); stdout.write("+-------+\n"); printDepth(depth); String name = getAuthorName(pubkey); stdout.write("|Author : $name id: ${maxN(id)} Time: $strDate\n"); printReaction(depth); // only prints if there are any likes/reactions printDepth(depth); stdout.write("|Message: "); if( isNotification) { printInColor(contentShifted, notificationColor); isNotification = false; } else { printInColor(contentShifted, commentColor); } } // looks up global map of reactions, if this event has any reactions, and then prints the reactions // in appropriate color( in case one is a notification, which is stored in member variable) void printReaction(int depth) { if( gReactions.containsKey(id)) { String reactorNames = "|Likes : "; printDepth(depth); //print("All Likes:"); int numReactions = gReactions[id]?.length??0; List> reactors = gReactions[id]??[]; for( int i = 0; i v.length > 3? v.substring(0,3) : v.substring(0, v.length); DateTime dTime = DateTime.fromMillisecondsSinceEpoch(createdAt *1000); if( createdAt == 0) { print("createdAt == 0 for event $content"); } return '\n-------+-------------\nAuthor : ${max3(pubkey)}\nMessage: $content\n\nid : ${max3(id)} Time: $dTime Kind: $kind'; } } // This is mostly a placeholder for EventData. TODO combine both? class Event { String event; String id; EventData eventData; String originalJson; List seenOnRelays; Event(this.event, this.id, this.eventData, this.seenOnRelays, this.originalJson); factory Event.fromJson(String d, String relay) { try { dynamic json = jsonDecode(d); if( json.length < 3) { String e = ""; e = json.length > 1? json[0]: ""; return Event(e,"",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]"); } return Event(json[0] as String, json[1] as String, EventData.fromJson(json[2]), [relay], d ); } on Exception catch(e) { return Event("","",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]"); } } void printEvent(int depth) { eventData.printEventData(depth); //stdout.write("\n$originalJson \n"); } @override String toString() { return '$eventData Seen on: ${seenOnRelays[0]}\n'; } } List getpTags(List events) { List pTags = []; for(int i = 0; i < events.length; i++) { pTags.addAll(events[i].eventData.pTags); } // remove duplicate pTags events Set tempPtags = {}; pTags.retainWhere((x) => tempPtags.add(x)); return pTags; } // If given event is kind 0 event, then populates gKindONames with that info void processKind0Event(Event e) { if( e.eventData.kind != 0) { return; } String content = e.eventData.content; if( content.isEmpty) { return; } try { dynamic json = jsonDecode(content); if(json["name"] != Null) { gKindONames[e.eventData.pubkey] = json["name"]??""; } } catch(ex) { if( gDebug != 0) print("Warning: In processKind0Event: caught exception for content: ${e.eventData.content}"); } } // returns name by looking up global list gKindONames, which is populated by kind 0 events String getAuthorName(String pubkey) { String max3(String v) => v.length > 3? v.substring(0,3) : v.substring(0, v.length); String name = gKindONames[pubkey]??max3(pubkey); return name; } List readEventsFromFile(String filename) { List events = []; final File file = File(filename); // sync read try { List lines = file.readAsLinesSync(); for( int i = 0; i < lines.length; i++ ) { Event e = Event.fromJson(lines[i], ""); events.add(e); } } on Exception catch(err) { print("Cannot open file $gEventsFilename"); } return events; } Event? getContactEvent(List events, String pubkey) { // get the latest kind 3 event for the user, which lists his 'follows' list int latestContactsTime = 0, latestContactIndex = -1; for( int i = 0; i < events.length; i++) { var e = events[i]; if( e.eventData.pubkey == pubkey && e.eventData.kind == 3 && latestContactsTime < e.eventData.createdAt) { latestContactIndex = i; latestContactsTime = e.eventData.createdAt; } } // if contact list was found, get user's feed, and keep the contact list for later use if (latestContactIndex != -1) { return events[latestContactIndex]; } return null; } // for the user userPubkey, returns the relay of its contact contactPubkey String getRelayOfUser(String userPubkey, String contactPubkey) { if(gDebug > 0) print("In getRelayOfUser: Searching relay for contact $contactPubkey" ); String relay = ""; if( userPubkey == "" || contactPubkey == "") { return ""; } if( gContactLists.containsKey(userPubkey)) { List? contacts = gContactLists[userPubkey]; if( contacts != null) { for( int i = 0; i < contacts.length; i++) { if( gDebug > 0) print( contacts[i].toString() ); if( contacts[i].id == contactPubkey) { relay = contacts[i].relay; if(gDebug > 0) print("In getRelayOfUser: found relay $relay for contact $contactPubkey" ); return relay; } } } } // if not found return empty string return relay; }