import 'dart:io'; import 'dart:convert'; import 'dart:math'; import 'package:bip340/bip340.dart'; import 'package:intl/intl.dart'; import 'package:nostr_console/tree_ds.dart'; import 'package:translator/translator.dart'; import 'package:crypto/crypto.dart'; import 'package:nostr_console/settings.dart'; import "dart:typed_data"; import 'dart:convert' as convert; import "package:pointycastle/export.dart"; import 'package:kepler/kepler.dart'; import 'package:http/http.dart' as http; String getStrInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?"$commentColor$s$gColorEndMarker":s; void printInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?stdout.write("$commentColor$s$gColorEndMarker"):stdout.write(s); void printWarning(String s) => stdout.supportsAnsiEscapes ?stdout.write("$gWarningColor$s$gColorEndMarker\n"):stdout.write("$s\n"); // translate GoogleTranslator? translator; // initialized in main when argument given const int gNumTranslateDays = 2;// translate for this number of days bool gTranslate = false; // translate flag List nip08PlaceHolders = ["#[0]", "#[1]", "#[2]", "#[3]", "#[4]", "#[5]", "#[6]", "#[7]", "#[8]", "#[9]" ]; // Structure to store kind 0 event meta data, and kind 3 meta data for each user. Will have info from latest // kind 0 event and/or kind 3 event, both with their own time stamps. class UserNameInfo { int? createdAt; String? name, about, picture; int? createdAtKind3; Event ?latestContactEvent; bool nip05Verified; String nip05Id; UserNameInfo(this.createdAt, this.name, this.about, this.picture, this.latestContactEvent, [this.createdAtKind3 = null, this.nip05Verified = false, this.nip05Id = ""]); } /* * global user names from kind 0 events, mapped from public key to a 3 element array of [name, about, picture] * JSON object {name: , about: , picture: } * only has info from latest kind 0 event */ Map gKindONames = {}; // global reactions entry. Map of form // reach Reactor is a list of 2-elements ( first is public id of reactor event, 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 = {}; // returns tags as string that can be used to calculate event has. called from EventData constructor String getStrTagsFromJson(dynamic json) { String str = ""; int i = 0; for( dynamic tag in json ) { if( i != 0) { str += ","; } str += "["; int j = 0; for(dynamic element in tag) { if( j != 0) { str += ","; } str += "\"${element.toString()}\""; j++; } str += "]"; i++; } return str; } bool verifyEvent(dynamic json) { return true; gSpecificDebug = 0; if(gSpecificDebug > 0) print("----\nIn verify event:"); String createdAt = json['created_at'].toString(); String strTags = getStrTagsFromJson(json['tags']); //print("strTags = $strTags"); String id = json['id']; String eventPubkey = json['pubkey']; String strKind = json['kind'].toString(); String content = json['content']; content = unEscapeChars( content); String eventSig = json['sig']; if( false) { String calculatedId = getShaId(eventPubkey, createdAt.toString(), strKind, strTags, content); bool verified = true;//verify( eventPubkey, calculatedId, eventSig); if( !verified && !eventPubkey.startsWith("00")) { if(gSpecificDebug > 0) printWarning("\nwrong sig event\nevent sig = $eventSig\nevent id = $id\ncalculated id = $calculatedId " ); if(gSpecificDebug > 0) print("Event: kind = $strKind\n"); //getShaId(eventPubkey, createdAt.toString(), strKind, strTags, content); //print("$json"); //throw Exception(); } else { if(gSpecificDebug > 0) printInColor("\nverified correct sig for event id $id\n", gCommentColor); } } return true; } class EventData { String id; String pubkey; int createdAt; int kind; String content; List> eTags;// e tags List pTags;// list of p tags for kind:1 List> tags; bool isNotification; // whether its to be highlighted using highlight color String evaluatedContent; // content which has mentions expanded, and which has been translated Set newLikes; // used for notifications, are colored as notifications and then reset List contactList = []; // used for kind:3 events, which is contact list event bool isHidden; // hidden by sending a reaction kind 7 event to this event, by the logged in user bool isDeleted; // deleted by kind 5 event EventData(this.id, this.pubkey, this.createdAt, this.kind, this.content, this.eTags, this.pTags, this.contactList,this.tags, this.newLikes, { this.isNotification = false, this.evaluatedContent = "", this.isHidden = false, this.isDeleted = false }); // returns the immediate kind 1 parent String getParent(Map allEventsMap) { if( eTags.isNotEmpty) { int numRoot = 0, numReply = 0; String rootId = "", replyId = ""; for( int i = 0; i < eTags.length; i++) { String eventId = eTags[i][0]; if( eTags[i].length >= 3) { if( eTags[i][2].toLowerCase() == "root") { numRoot++; rootId = eventId; } else { if( eTags[i][2].toLowerCase() == "reply") { numReply++; replyId = eventId; } } } } if( replyId.length > 0) { if( numReply == 1) { return replyId; } else { // if there are multiply reply's we can't tell which is which, so we return the one at top if( replyId.length > 0) { return replyId; } else { if( rootId.length > 0) { return rootId; } } } } else { if( rootId.length > 0) { //printWarning("returning root id. no reply id found."); return rootId; } } // if reply/root tags don't work, then try to look for parent tag with the deprecated logic from NIP-10 if( gDebug > 0) log.info("using deprecated logic of nip10 for event id : $id"); for( int i = tags.length - 1; i >= 0; i--) { if( tags[i][0] == "e") { String eventId = tags[i][1]; // ignore this e tag if its mentioned in the body of the event String placeholder = nip08PlaceHolders[i]; if( content.contains(placeholder)) { continue; } if( allEventsMap[eventId]?.event.eventData.kind == 1) { String? parentId = allEventsMap[eventId]?.event.eventData.id; if( parentId != null) { return parentId; } } else { // if first e tag ( from end, which is the immediate parent) does not exist in the store, then return that eventID still. // Child comment would get a dummy parent, and called could then fetch that event return eventId; } } } } return ""; } factory EventData.fromJson(dynamic json) { List contactList = []; List> eTagsRead = []; List pTagsRead = []; List> tagsRead = []; var jsonTags = json['tags']; var numTags = jsonTags.length; //print("\n----\nIn fromJson\n"); String sig = json['sig']; if(sig.length == 128) { //print("found sig == 128 bytes"); //if(json['id'] == "15dd45769dd0ccb9c4ca1c69fcd27011d53c4b95c8b7c786265bf7377bc7fdad") { // printInColor("found 15dd45769dd0ccb9c4ca1c69fcd27011d53c4b95c8b7c786265bf7377bc7fdad sig ${json['sig']}", gCommentColor); //} try { verifyEvent(json); } on Exception catch(e) { //printWarning("verify gave exception $e"); throw Exception("in Event constructor: sig verify gave exception"); } } // 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]; if( tag.length < 2) { if( gDebug > 0) print("In event fromjson: invalid p tag of size 1"); continue; } String server = defaultServerUrl; if( tag.length >=3 ) { server = tag[2].toString(); if( server == 'wss://nostr.rocks' || server == "wss://nostr.bitcoiner.social") { server = defaultServerUrl; } } if( tag[0] == "p" && tag[1].length == 64) { Contact c = Contact(tag[1] as String, server); contactList.add(c); } } } else { int eKind = json['kind']; if ( eKind == 1 || eKind == 7 || eKind == 42 || eKind == 5 || eKind == 4 || eKind == 140 || eKind == 141 || eKind == 142) { for( int i = 0; i < numTags; i++) { var tag = jsonTags[i]; if( tag.isEmpty) { continue; } if( tag[0] == "e") { List listTag = []; for(int i = 1; i < tag.length; i ++) { listTag.add(tag[i]); } eTagsRead.add(listTag); } 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 > 0 && json['id'] == gCheckEventId) { print("\n----------------------------------------Creating EventData with content: ${json['content']}"); print("In Event fromJson: got message: $gCheckEventId"); } 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, Map tempChildEventsMap) { if( id == gCheckEventId) { printInColor("in expandMentions: decoding $gCheckEventId\n", redColor); } 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 for(int i = 0; i < nip08PlaceHolders.length && i < tags.length; i++) { int index = -1; Pattern p = nip08PlaceHolders[i]; if( (index = content.indexOf(p)) != -1 ) { String mentionedId = tags[i][1]; if( tags[i].length >= 2) { if( gKindONames.containsKey(mentionedId)) { String author = getAuthorName(mentionedId); content = "${content.substring(0, index)}@$author${content.substring(index + 4)}"; } else { EventData? eventData = tempChildEventsMap[mentionedId]?.event.eventData??null; if( eventData != null) { String quotedAuthor = getAuthorName(eventData.pubkey); String prefixId = mentionedId.substring(0, 3); String quote = ""; content = "${content.substring(0, index)}$quote${content.substring(index + 4)}"; } } } } } return content; } // is called only once for each event received ( or read from file) void translateAndExpandMentions(List directRooms, Map tempChildEventsMap) { if( id == gCheckEventId) { printInColor("in translateAndExpandMensitons: decoding $gCheckEventId\n", redColor); } if (content == "" || evaluatedContent != "") { if( id == gCheckEventId) { printInColor("in translateAndExpandMensitons: returning \n", redColor); } return; } switch(kind) { case 1: case 42: evaluatedContent = expandMentions(content, tempChildEventsMap); if( translator != null && gTranslate && !evaluatedContent.isEnglish()) { if( gDebug > 0) print("found that this comment is non-English: $evaluatedContent"); // only translate for latest events if( DateTime.fromMillisecondsSinceEpoch(createdAt *1000).compareTo( DateTime.now().subtract(Duration(days:gNumTranslateDays)) ) > 0 ) { if( gDebug > 0) print("Sending google request: translating $content"); if( translator != null) { try { translator?.translate(content, to: 'en') .then( (result) => { evaluatedContent = "$evaluatedContent\n\nTranslation: ${result.toString()}" , if( gDebug > 0) print("Google translate returned successfully for one call.")} ) .onError((error, stackTrace) { if( gDebug > 0) print("Translate error = $error\n for content = $content\n"); return {} ; } ); } on Exception catch(err) { if( gDebug >= 0) print("Info: Error in trying to use google translate: $err"); } } } } break; case 4: if( userPrivateKey == ""){ // cant process if private key not given break; } //if( pubkey == userPublicKey ) break; // crashes right now otherwise if(!isValidDirectMessage(this)) { break; } if( id == gCheckEventId) { printInColor("in translateAndExpandMensitons: gonna decrypt \n", redColor); } String? decrypted = decryptDirectMessage(); if( decrypted != null) { evaluatedContent = decrypted; evaluatedContent = expandMentions(evaluatedContent, tempChildEventsMap); } break; } // end switch } // end translateAndExpandMentions // is called only once for each event received ( or read from file) void translateAndExpand14x(List directRooms, List encryptedChannels, Map tempChildEventsMap) { if( id == gCheckEventId) { printInColor("in translateAndExpandMensitons: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor); } if (content == "" || evaluatedContent != "") { if( id == gCheckEventId) { printInColor("in translateAndExpandMensitons: returning \n", redColor); } return; } switch(kind) { case 142: String? decrypted = decryptEncryptedChannelMessage(directRooms, encryptedChannels, tempChildEventsMap); if( decrypted != null) { evaluatedContent = decrypted; evaluatedContent = expandMentions(evaluatedContent, tempChildEventsMap); } break; default: break; } // end switch } // end translateAndExpand14x String? decryptDirectMessage() { int ivIndex = content.indexOf("?iv="); if( ivIndex > 0) { var iv = content.substring( ivIndex + 4, content.length); var enc_str = content.substring(0, ivIndex); String userKey = userPrivateKey ; String otherUserPubKey = "02" + pubkey; if( pubkey == userPublicKey) { // if user themselve is the sender change public key used to decrypt userKey = userPrivateKey; int numPtags = 0; tags.forEach((tag) { if(tag[0] == "p" ) { otherUserPubKey = "02" + tag[1]; numPtags++; } }); // if there are more than one p tags, we don't know who its for if( numPtags != 1) { if( gDebug >= 0) printInColor(" in translateAndExpand: got event $id with number of p tags != one : $numPtags . not decrypting", redColor); return null; } } var decrypted = myPrivateDecrypt( userKey, otherUserPubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic return decrypted; } else { if(gDebug > 0) print("Invalid content for dm, could not get ivIndex: $content"); return null; } } Channel? getChannelForMessage(List? listChannel, String messageId) { if( listChannel == null) { return null; } for(int i = 0; i < listChannel.length; i++) { if( listChannel[i].messageIds.contains(messageId)) { return listChannel[i]; } } return null; } String? decryptEncryptedChannelMessage(List directRooms, List encryptedChannels,Map tempChildEventsMap) { Channel? channel = getChannelForMessage( encryptedChannels, id); if( channel == null) { print("could not find channel"); return null; } if(!channel.participants.contains(userPublicKey)) { return null; } if(!channel.participants.contains(pubkey)) { return null; } if( id == "865c9352de11a3959c06fce5350c5a1b9fa0475d3234078a1bb45d152b370f0b") { // known issue //print("\n\ngoing to decrypt b1ab66ac50f00f3c3bbc91e5b9e03fc8e79e3fdb9f6d5c9ae9777aa6ca3020a2"); //print(channel.participants); return ""; } //print("Going to decrypt event id: $id"); //print("In decryptEncryptedChannelMessage: for event of kind 142 with event id = $id"); int ivIndex = content.indexOf("?iv="); if( ivIndex == -1) { return ""; } var iv = content.substring( ivIndex + 4, content.length); var enc_str = content.substring(0, ivIndex); String channelId = getChannelIdForMessage(); //print("In decryptEncryptedChannelMessage: got channel id $channelId"); List keys = []; keys = getEncryptedChannelKeys(directRooms, tempChildEventsMap, channelId); if( keys.length != 2) { //print("Could not get keys for event id: $id and channelId: $channelId"); return ""; } //print("\nevent id: $id"); //print(keys); String priKey = keys[0]; String pubKey = "02" + keys[1]; var decrypted = myPrivateDecrypt( priKey, pubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic return decrypted; } // only applicable for kind 42/142 event; returns the channel 40/140 id of which the event is part of String getChannelIdForMessage() { if( kind != 42 && kind != 142 && kind!=141) { return ""; } //print("in getChannelIdForMessage tag length = ${tags.length}"); // get first e tag, which should be the channel of which this is part of for( int i = 0; i < tags.length; i++) { List tag = tags[i]; if( tag[0] == 'e') { return tag[1]; } } return ''; } // prints event data in the format that allows it to be shown in tree form by the Tree class void printEventData(int depth, bool topPost) { if( !(kind == 1 || kind == 4 || kind == 42)) { return; // only print kind 1 and 42 and 4 } int n = 4; String maxN(String v) => v.length > n? v.substring(0,n) : v.substring(0, v.length); String name = getAuthorName(pubkey); String strDate = getPrintableDate(createdAt); String tempEvaluatedContent = evaluatedContent; String tempContent = content; if( isHidden) { name = ""; strDate = ""; tempEvaluatedContent = tempContent = "