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:nostr_console/user.dart'; import 'package:nostr_console/utils.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; import 'package:nostr_console/nip_019.dart'; 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 = 1;// translate for this number of days bool gTranslate = false; // translate flag int numEventsTranslated = 0; List nip08PlaceHolders = ["#[0]", "#[1]", "#[2]", "#[3]", "#[4]", "#[5]", "#[6]", "#[7]", "#[8]", "#[9]", "#[10]", "#[11]", "#[12]"]; // 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, lud06, lud16, display_name, website; int? createdAtKind3; Event ?latestContactEvent; bool nip05Verified; String? nip05Id; UserNameInfo(this.createdAt, this.name, this.about, this.picture, this.lud06, this.lud16, this.display_name, this.website, this.nip05Id , this.latestContactEvent, [this.createdAtKind3, this.nip05Verified = false]); } /* * 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 pubkey of reactor event, second is comment) // each eventID -> multiple [ pubkey, comment ] Map< String, List> > gReactions = {}; // for the given eventID returns the pubkeys of reactors Set getReactorPubkeys(String eventId) { Set reactorIds = {}; List>? reactions = gReactions[eventId]; if( reactions != null) { for (var reaction in reactions) { reactorIds.add(reaction[0]);} } return reactorIds; } // 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 = {}; 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 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 ; set of pubkeys that are new likers 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; // first go over all tags and find out at least one reply and root tag, and count their numbers 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; } } } } // then depending on the numbers and values ( of root and replyto) return the parent if( replyId.isNotEmpty) { 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.isNotEmpty) { return replyId; } else { // this is case when there is no reply id . should not actually happen given if conditions if( rootId.isNotEmpty) { return rootId; } } } } else { if( rootId.isNotEmpty) { //printWarning("returning root id. no reply id found."); return rootId; } } // but 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.length > i? nip08PlaceHolders[i]: "INVALIDPLACEHOLDER_SHOULDNOTEXIST"; 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 ""; } List? getTTags() { List? tTags; for( int i = 0; i < tags.length; i++) { List tag = tags[i]; if( tag.length < 2) { continue; } if( tag[0] == 't') { tTags ??= []; tTags.add(tag[1]); } } return tTags; } // returns valueof location tag if present. returns null if that tag is not present. String? getSpecificTag(String tagName) { for( int i = 0; i < tags.length; i++) { List tag = tags[i]; if( tag.length < 2) { continue; } if( tag[0] == tagName) { // return the first value return tag[1]; } } return null; } 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 { //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://offchain.pub") { 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 || eKind == gSecretMessageKind) { 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( tags.isEmpty) { return content; } // just check whether "nostr:" is in comment, because only that indicates a mention; if not we return if( !content.contains("nostr:") ) { return content; } //print("------------------\nin expandMentions: content = $content \n"); String replaceMentions(Match mentionTagMatch) { //print("in replaceMentions\n"); String? mentionTag = mentionTagMatch.group(0); if( mentionTag != null) { //print("mentionTag = $mentionTag"); String strBechId = mentionTag.substring(6, mentionTag.length); String tempType = strBechId.substring(0, 4); if( tempType != "note" && tempType != "npub") { return "nostr:$strBechId"; } //print("Going to decode: $strBechId"); try { Map nsec = bech32Decode(strBechId); String? type = nsec["prefix"]; // type can be "note" or "npub" String? strHex = nsec["data"]; // this is 64 byte hex pubkey or note id if( strHex != null && type != null) { String mentionedId = strHex; //print("strHex = $strHex type = $type"); if( type == "npub") { if( gKindONames.containsKey(mentionedId)) { String? author = getOnlyAuthorName(mentionedId); if( author == null) { return "nostr:$strBechId"; } else { return "@$author"; } } } else { if( type == "note") { EventData? mentionedEventData = tempChildEventsMap[mentionedId]?.event.eventData; if( mentionedEventData != null) { //print("Found note"); String quotedAuthor = getAuthorName(mentionedEventData.pubkey); String prefixId = mentionedId.substring(0, 3); String mentionedContent = mentionedEventData.content; if( mentionedEventData.evaluatedContent != "") { //print("found evaluated content"); mentionedContent = mentionedEventData.evaluatedContent; } else { //print("didnt find evaluated content"); } String quote = ""; //print("evaluatedContent: ${mentionedEventData.evaluatedContent}\n"); return quote; } } else { //print("Could not find event!\n"); } } return "nostr:$strBechId"; } else { //print("Could not parse the given nsec/private key. Exiting."); return mentionTag; } } on Exception { //print("====================Caught exctption."); return "nostr:$strBechId"; } } if( gDebug >= 0) printWarning("In replaceMentions returning nothing"); return ""; } // end replaceMentions() // replace the mentions, if any are found // The Bech32 alphabet contains 32 characters, including lowercase letters a-z and the numbers 0-9, excluding the number 1 and the letters ‘b’, ‘i’, ‘o’ to avoid reader confusion. String mentionStr = "(nostr:(npub1|note1)[a0c-hj-np-z2-9]{58})"; // bech32 RegExp mentionRegExp = RegExp(mentionStr, caseSensitive: false); content = content.replaceAllMapped(mentionRegExp, replaceMentions); return content; } // is called only once for each event received ( or read from file) void translateAndExpandMentions(Map tempChildEventsMap) { if( id == gCheckEventId) { //printInColor("in translateAndExpandMentions: decoding $gCheckEventId\n", redColor); } if (content == "" || evaluatedContent != "") { if( id == gCheckEventId) { //printInColor("in translateAndExpandMentions: returning \n", redColor); } return; } switch(kind) { case 1: case 42: evaluatedContent = expandMentions(content, tempChildEventsMap); if( gShowLnInvoicesAsQr) { evaluatedContent = expandLNInvoices(evaluatedContent); } 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 { numEventsTranslated++; 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; } // end switch return; } // end translateAndExpandMentions // is called only once for each event received ( or read from file) String? TranslateAndDecryptGroupInvite() { if (content == "" || evaluatedContent != "") { return null; } switch(kind) { case gSecretMessageKind: if( userPrivateKey == ""){ // cant process if private key not given return null; } if(!isValidDirectMessage(this, acceptableKind: kind)) { return null; } String? decrypted = decryptDirectMessage(); if( decrypted != null) { evaluatedContent = decrypted; } return id; } // end switch return null; } // end TranslateAndDecryptGroupInvite // is called only once for each event received ( or read from file) void translateAndDecryptKind4(Map tempChildEventsMap) { if( id == gCheckEventId) { printInColor("in translateAndDecryptKind4: decoding $gCheckEventId\n", redColor); } if (content == "" || evaluatedContent != "") { if( id == gCheckEventId) { printInColor("in translateAndDecryptKind4: returning \n", redColor); } return; } switch(kind) { 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); } //log.info("decrypting a message of kind 4"); String? decrypted = decryptDirectMessage(); if( decrypted != null) { evaluatedContent = decrypted; evaluatedContent = expandMentions(evaluatedContent, tempChildEventsMap); } //print("evaluatedContent: $evaluatedContent"); break; } // end switch } // end translateAndExpandMentions // is called only once for each event received ( or read from file) void translateAndDecrypt14x(Set secretMessageIds, List encryptedChannels, Map tempChildEventsMap) { if( id == gCheckEventId) { //printInColor("in translateAndExpand14x: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor); } if (content == "" || evaluatedContent != "") { if( id == gCheckEventId) { //printInColor("in translateAndExpand14x: returning \n", redColor); } return; } if( createdAt < getSecondsDaysAgo(3)) { //print("old 142. not decrypting"); //return; } switch(kind) { case 142: //print("in translateAndDecrypt14x"); Channel? channel = getChannelForMessage( encryptedChannels, id); if( channel == null) { break; } if(!channel.participants.contains(userPublicKey)) { break; } if(!channel.participants.contains(pubkey)) { break; } String? decrypted = decryptEncryptedChannelMessage(secretMessageIds, tempChildEventsMap); if( decrypted != null) { //printWarning("Successfully decrypted kind 142: $id"); evaluatedContent = decrypted; //print("in translateAndDecrypt14x: calling expandMentions"); evaluatedContent = expandMentions(evaluatedContent, tempChildEventsMap); //print("content = $content"); //print(evaluatedContent); } 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 encStr = 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; for (var tag in tags) { 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, encStr, 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(Set secretMessageIds, Map tempChildEventsMap) { if( id == "865c9352de11a3959c06fce5350c5a1b9fa0475d3234078a1bb45d152b370f0b") { // known issue return null; } int ivIndex = content.indexOf("?iv="); if( ivIndex == -1) { return null; } var iv = content.substring( ivIndex + 4, content.length); var encStr = content.substring(0, ivIndex); String channelId = getChannelIdForKind4x(); List keys = []; keys = getEncryptedChannelKeys(secretMessageIds, tempChildEventsMap, channelId); if( keys.length != 2) { //printWarning("\nCould not get keys for event id: $id and channelId: $channelId\n"); //print("keys = $keys\n\n"); return null; } String priKey = keys[0]; String pubKey = "02${keys[1]}"; var decrypted = myPrivateDecrypt( priKey, pubKey, encStr, 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 getChannelIdForKind4x() { if( kind != 42 && kind != 142 && kind!=141) { return ""; } // get first e tag, which should be the channel of which this is part of for( int i = 0; i < eTags.length; i++) { List tag = eTags[i]; if( tag.isNotEmpty) { return tag[0]; } } return ''; } String getChannelIdForTTagRoom(String tagValue) { return "$tagValue #t"; } // only applicable for kind 42/142 event; returns the channel 40/140 id of which the event is part of String getChannelIdForLocationRooms() { String ? location = getSpecificTag("location"); if( kind == 1 && location != null && location != "") { return location + gLocationTagIdSuffix; } 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, Map? tempChildEventsMap, Set? secretMessageIds, List? encryptedChannels) { if( !(kind == 1 || kind == 4 || kind == 42)) { return; // only print kind 1 and 42 and 4 } // will only do decryption if its not been decrypted yet by looking at 'evaluatedContent' if( tempChildEventsMap != null ) { if(kind == 4) { translateAndDecryptKind4( tempChildEventsMap); } else if ([1, 42].contains(kind)) { translateAndExpandMentions(tempChildEventsMap); } else if ([142].contains(kind)) { if( secretMessageIds != null && encryptedChannels != null) { translateAndDecrypt14x( secretMessageIds, encryptedChannels, tempChildEventsMap); } } } int n = gEventLenPrinted; // is 6 String maxN(String v) => v.length > n? v.substring(0,n) : v.substring(0, v.length); String name = getAuthorName(pubkey, maxDisplayLen: gNameLengthInPost); String strDate = getPrintableDate(createdAt); String tempEvaluatedContent = evaluatedContent; String tempContent = content; if( isHidden) { name = ""; strDate = ""; tempEvaluatedContent = tempContent = "