diff --git a/.test_mnemonic b/.test_mnemonic index c42d044..105aafb 100644 --- a/.test_mnemonic +++ b/.test_mnemonic @@ -1 +1,13 @@ -alarm impact educate burden vague honey horn buyer sight vocal age render \ No newline at end of file +alarm impact educate burden vague honey horn buyer sight vocal age render + +index 0 + +{ + "index": 0, + "nsec": "nsec1z2lrfamae2dzax7dmnlhv497uuxe4mw0m3w694upx5x54q6dgttqvzrrwl", + "npub": "npub1j7d7yf47w8k2kseknqjr3045jvm00u0wnt3433kk6vu67d2zamcs8ynuw4", + "npubHex": "979be226be71ecab4336982438beb49336f7f1ee9ae358c6d6d339af3542eef1", + "nsecHex": "12be34f77dca9a2e9bcddcff7654bee70d9aedcfdc5da2d781350d4a834d42d6", + "fipsIpv6": "fd55:b7c6:536e:26ee:a79:6e25:6f05:a85f", + "strDerivationPath": "m/44'/1237'/0'/0/0" +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fdfcaf..77a42a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,9 @@ set(CMAKE_C_EXTENSIONS OFF) add_compile_options(-Wall -Wextra -D_GNU_SOURCE) +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBCURL REQUIRED libcurl) + # nt application sources file(GLOB NT_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c") @@ -42,9 +45,24 @@ target_include_directories(nt PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/resources/nostr_core_lib/cjson ) +if(CMAKE_EXE_LINKER_FLAGS MATCHES "(^| )-static( |$)") + target_include_directories(nt PRIVATE ${LIBCURL_STATIC_INCLUDE_DIRS}) + target_link_directories(nt PRIVATE ${LIBCURL_STATIC_LIBRARY_DIRS}) + target_compile_options(nt PRIVATE ${LIBCURL_STATIC_CFLAGS_OTHER}) + target_link_options(nt PRIVATE ${LIBCURL_STATIC_LDFLAGS_OTHER}) + set(NT_CURL_LIBS ${LIBCURL_STATIC_LIBRARIES}) +else() + target_include_directories(nt PRIVATE ${LIBCURL_INCLUDE_DIRS}) + target_link_directories(nt PRIVATE ${LIBCURL_LIBRARY_DIRS}) + target_compile_options(nt PRIVATE ${LIBCURL_CFLAGS_OTHER}) + target_link_options(nt PRIVATE ${LIBCURL_LDFLAGS_OTHER}) + set(NT_CURL_LIBS ${LIBCURL_LIBRARIES}) +endif() + target_link_libraries(nt PRIVATE nostr_core_lib sqlite3 + ${NT_CURL_LIBS} secp256k1 ssl crypto diff --git a/build.sh b/build.sh index 0751e61..d0b9d3f 100755 --- a/build.sh +++ b/build.sh @@ -21,17 +21,20 @@ fi mkdir -p "$BUILD_DIR" -echo "[INFO] Building MUSL static binary via Docker (builder stage)..." +echo "[INFO] Building MUSL static binary via Docker (output stage)..." docker build \ --platform linux/amd64 \ - --target builder \ -f "$DOCKERFILE" \ - -t nt-musl-builder:latest \ + -t nt-musl-output:latest \ . echo "[INFO] Extracting binary..." -CONTAINER_ID=$(docker create nt-musl-builder:latest) -docker cp "$CONTAINER_ID:/build/nt_static" "$BUILD_DIR/$OUTPUT_NAME" +CONTAINER_ID=$(docker create nt-musl-output:latest /nt_static) +if ! docker cp "$CONTAINER_ID:/nt_static" "$BUILD_DIR/$OUTPUT_NAME"; then + echo "[ERROR] Could not copy /nt_static from output image" + docker rm "$CONTAINER_ID" >/dev/null || true + exit 1 +fi docker rm "$CONTAINER_ID" >/dev/null chmod +x "$BUILD_DIR/$OUTPUT_NAME" diff --git a/include/editor.h b/include/editor.h new file mode 100644 index 0000000..d2145e2 --- /dev/null +++ b/include/editor.h @@ -0,0 +1,8 @@ +#ifndef EDITOR_H +#define EDITOR_H + +/* Launch editor, return malloc'd string with user content, or NULL on failure/empty. + * If initial_content is non-NULL, it is pre-loaded into the editor buffer. */ +char *editor_launch(const char *initial_content); + +#endif /* EDITOR_H */ diff --git a/include/net.h b/include/net.h index d345395..f2d1579 100644 --- a/include/net.h +++ b/include/net.h @@ -69,6 +69,8 @@ int nt_get_first(const char *filter_json, int timeout_ms, char **event_out); +typedef void (*nt_event_callback_t)(const char *event_json, void *userdata); + /* * Same as nt_get_first, but with optional user-facing progress output. */ @@ -80,6 +82,18 @@ int nt_get_first_verbose(const char *filter_json, int verbose, char **event_out); +/* + * Blocking live subscription. Connects to relays, sends REQ with given filter, + * and calls on_event for each incoming EVENT. Returns when user presses any key + * or on timeout/error. + * Returns number of events received, or -1 on total failure. + */ +int nt_live_feed(const char *filter_json, + const char **relay_urls, + int relay_count, + nt_event_callback_t on_event, + void *userdata); + /* * Fetch relay NIP-11 information document. * diff --git a/plans/planning.md b/plans/planning.md index 297599b..4173dbc 100644 --- a/plans/planning.md +++ b/plans/planning.md @@ -363,37 +363,57 @@ Based on [`nostr_core_lib/todo.md`](resources/nostr_core_lib/todo.md), the follo ## Implementation Roadmap ### Phase 1: Foundation (P0 Infrastructure) -- [ ] Set up CMake build with linkage to nostr_core_lib (static lib) -- [ ] Implement TUI layer (`tui.c/h`) with ncurses -- [ ] Implement SQLite layer (`db.c/h`) with full schema -- [ ] Implement event loop (`loop.c/h`) -- [ ] Implement HTTP wrapper (libcurl) +- [x] Set up CMake build with linkage to nostr_core_lib (static lib) — see [`CMakeLists.txt`](CMakeLists.txt) +- [x] Implement TUI layer with ncurses — see [`include/tui.h`](include/tui.h) and [`src/tui.c`](src/tui.c) (raw VT100/termios, not ncurses, but functionally equivalent) +- [x] Implement SQLite layer (`db.c/h`) with partial schema — see [`include/db.h`](include/db.h) and [`src/db.c`](src/db.c) (events / relays / people / local / relay_posts present; full original schema split into regular/replacable/addressable/ephemeral still pending) +- [x] Replace event-loop concept with synchronous per-action network helpers — see [`include/net.h`](include/net.h) (nt_publish, nt_query_sync, nt_get_first all implemented) +- [ ] Implement HTTP wrapper (libcurl) — [`nt_fetch_nip11()`](include/net.h:89) is currently a stub returning -1; needed for NIP-11, nostr.watch, Venice/Ollama ### Phase 2: Core Nostr Client (P0 Menus) -- [ ] [L]ogin (mnemonic + bunker) -- [ ] [P]rofile -- [ ] [R]elays -- [ ] [T]weet -- [ ] [F]ollows -- [ ] [Q]uit +- [x] [L]ogin (mnemonic) — see [`src/menu_login.c`](src/menu_login.c) (existing-account path: seed phrase / nsec / nsecHex / npub; new-account path; loads KIND 0/3/10002/10096/17375/30078) +- [ ] [L]ogin bunker (NIP-46) sub-path — not yet wired +- [x] [P]rofile (view + modify + publish KIND 0) — see [`src/menu_profile.c`](src/menu_profile.c) +- [x] [R]elays (list / add / delete / modify / publish KIND 10002) — see [`src/menu_relays.c`](src/menu_relays.c) (NIP-11 fetch is stubbed; nostr.watch sync and kind-1 acceptance test pending) +- [x] [T]weet (sign + publish KIND 1) — see [`src/menu_tweet.c`](src/menu_tweet.c) (no `vipe` integration yet) +- [x] [F]ollows (list / add / delete / publish KIND 3) — see [`src/menu_follows.c`](src/menu_follows.c) (Profile sub-view from follows list pending) +- [x] [Q]uit +- [x] [K]inds debug dump — implemented in [`src/main.c`](src/main.c:26) ### Phase 3: Reading & Interaction (P1 Menus) -- [ ] Li[v]e Feeds -- [ ] [N]otifications -- [ ] [W]rite (with vipe integration) -- [ ] P[o]sts -- [ ] Direct [M]essage (NIP-17) +- [ ] Li[v]e Feeds — needs a long-running `nt_live_feed()` helper added to [`src/net.c`](src/net.c) +- [ ] [N]otifications — query events with `#p` tag, group/sort +- [ ] [W]rite (with vipe integration) — needs external editor wrapper +- [ ] P[o]sts (browse / edit user notes by kind, NIP-04 decrypt for 30078) +- [ ] Direct [M]essage (NIP-17 send/receive, NIP-04 receive for legacy) ### Phase 4: Advanced Features (P2 Menus) -- [ ] To[d]o -- [ ] D[i]ary -- [ ] [E]cash wallet -- [ ] [A]I integration +- [ ] To[d]o (encrypted KIND 30078, d=todo) +- [ ] D[i]ary (KIND 30024 daily entries via vipe) +- [ ] [E]cash wallet (NIP-60/61, Cashu mint client, QR rendering) +- [ ] [A]I integration (Venice/Ollama via libcurl, prefs in KIND 30078) ### Phase 5: Optional/Experimental (P3 Menus) -- [ ] [C]ircus -- [ ] Bl[o]ssom -- [ ] [DB] maintenance +- [ ] [C]ircus (multi-account derivation + AI-driven posts) +- [ ] Bl[o]ssom (KIND 10096 manage media servers) +- [ ] [DB] maintenance (bulk relay + people + notes refresh) - [ ] [B]rowser launcher -- [ ] [K]inds debug - [ ] HTTPS/WSS companion server + +--- + +## Current Status Snapshot (auto-summary) + +**Done — MVP usable:** Build, TUI, SQLite scaffold, synchronous net helpers, login (seed-based), profile, relays (without NIP-11/test), tweet, follows, kinds dump, quit. + +**Immediate gaps blocking the rest of P0:** +1. **HTTP client** — [`nt_fetch_nip11()`](src/net.c) is a stub. Without it, the relay menu can't show NIP-11 info, can't query nostr.watch, and the AI menu can't talk to Venice/Ollama. This is the single biggest unblocker. +2. **Relay acceptance test** — small wrapper around `nt_publish` to publish a throwaway kind-1 to a single relay with a tight timeout; needed for the `R`elay menu's test feature. +3. **NIP-46 bunker login** — alternate auth path inside [`src/menu_login.c`](src/menu_login.c). + +**Next P1 tickets in recommended order:** +1. **`nt_live_feed(filters, relays, on_event_cb)`** in [`src/net.c`](src/net.c) — blocking subscription that exits on any keypress; foundation for both Live Feeds and Notifications menus. +2. **External editor wrapper** (`fork`/`exec`/`pipe` to `vipe`, return captured stdout) — unlocks `W`rite, `D`iary, and richer `T`weet. +3. **[N]otifications menu** — straight `nt_query_sync` over `#p` filter on read relays, then group/sort. +4. **P[o]sts menu** — query addressable kinds (30023/30024/30078); NIP-04 decrypt for 30078. +5. **Li[v]e Feeds menu** — built on `nt_live_feed`; sub-options for follows/firehose/mentions. +6. **Direct [M]essage** — NIP-17 send via existing `nip017.h`, plus inbox query. diff --git a/src/editor.c b/src/editor.c new file mode 100644 index 0000000..364f554 --- /dev/null +++ b/src/editor.c @@ -0,0 +1,194 @@ +#include "editor.h" + +#include "tui.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int editor_write_all(int fd, const char *data, size_t len) { + size_t off = 0; + + while (off < len) { + ssize_t n = write(fd, data + off, len - off); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += (size_t)n; + } + + return 0; +} + +static char *editor_read_file_malloc(const char *path) { + FILE *fp; + long sz; + char *buf; + size_t nread; + + if (!path) { + return NULL; + } + + fp = fopen(path, "rb"); + if (!fp) { + return NULL; + } + + if (fseek(fp, 0, SEEK_END) != 0) { + fclose(fp); + return NULL; + } + + sz = ftell(fp); + if (sz <= 0) { + fclose(fp); + return NULL; + } + + if (fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + return NULL; + } + + buf = (char *)malloc((size_t)sz + 1U); + if (!buf) { + fclose(fp); + return NULL; + } + + nread = fread(buf, 1U, (size_t)sz, fp); + fclose(fp); + if (nread != (size_t)sz) { + free(buf); + return NULL; + } + + buf[nread] = '\0'; + return buf; +} + +static int editor_run_command(const char *tmp_path, const char *editor_env) { + pid_t pid; + int status = 0; + + if (!tmp_path) { + return -1; + } + + tui_cleanup(); + + pid = fork(); + if (pid < 0) { + tui_init(); + return -1; + } + + if (pid == 0) { + if (editor_env && editor_env[0] != '\0') { + char command[2048]; + char *argv_sh[4]; + + if (snprintf(command, sizeof(command), "%s \"%s\"", editor_env, tmp_path) >= (int)sizeof(command)) { + _exit(127); + } + + argv_sh[0] = "sh"; + argv_sh[1] = "-c"; + argv_sh[2] = command; + argv_sh[3] = NULL; + execvp("sh", argv_sh); + _exit(127); + } + + { + char command[4096]; + char *argv_sh[4]; + + if (snprintf(command, + sizeof(command), + "vipe < \"%s\" > \"%s.out\" && mv \"%s.out\" \"%s\"", + tmp_path, + tmp_path, + tmp_path, + tmp_path) >= (int)sizeof(command)) { + _exit(127); + } + + argv_sh[0] = "sh"; + argv_sh[1] = "-c"; + argv_sh[2] = command; + argv_sh[3] = NULL; + execvp("sh", argv_sh); + } + + { + char *argv_vi[3]; + argv_vi[0] = "vi"; + argv_vi[1] = (char *)tmp_path; + argv_vi[2] = NULL; + execvp("vi", argv_vi); + } + + _exit(127); + } + + while (waitpid(pid, &status, 0) < 0) { + if (errno != EINTR) { + break; + } + } + + tui_init(); + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + return -1; + } + + return 0; +} + +char *editor_launch(const char *initial_content) { + char template_path[] = "/tmp/nt_editor_XXXXXX"; + int fd; + char *out = NULL; + const char *editor_env; + + fd = mkstemp(template_path); + if (fd < 0) { + return NULL; + } + + if (initial_content && initial_content[0] != '\0') { + size_t len = strlen(initial_content); + if (editor_write_all(fd, initial_content, len) != 0) { + close(fd); + unlink(template_path); + return NULL; + } + } + + if (close(fd) != 0) { + unlink(template_path); + return NULL; + } + + editor_env = getenv("EDITOR"); + if (editor_run_command(template_path, editor_env) != 0) { + unlink(template_path); + return NULL; + } + + out = editor_read_file_malloc(template_path); + unlink(template_path); + return out; +} diff --git a/src/main.c b/src/main.c index f7f34d0..24e848f 100644 --- a/src/main.c +++ b/src/main.c @@ -10,8 +10,8 @@ */ #define NT_VERSION_MAJOR 0 #define NT_VERSION_MINOR 0 -#define NT_VERSION_PATCH 1 -#define NT_VERSION "v0.0.1" +#define NT_VERSION_PATCH 2 +#define NT_VERSION "v0.0.2" #include #include @@ -22,6 +22,53 @@ void menu_profile(void); void menu_relays(void); void menu_tweet(void); void menu_follows(void); +void menu_write(void); +void menu_notifications(void); +void menu_posts(void); +void menu_live(void); +void menu_dm(void); +void menu_todo(void); +void menu_diary(void); +void menu_ai(void); +void menu_ecash(void); + +static void menu_show_loaded_events(void) { + int i; + + tui_clear_screen(); + tui_print("LOADED EVENTS\n"); + + tui_print("KIND 0 (metadata content):"); + tui_print("%s", g_state.kind0_json ? g_state.kind0_json : "(not loaded)"); + tui_print(""); + + tui_print("KIND 3 (follows event):"); + tui_print("%s", g_state.kind3_json ? g_state.kind3_json : "(not loaded)"); + tui_print(""); + + tui_print("KIND 10002 (relay list event):"); + tui_print("%s", g_state.kind10002_json ? g_state.kind10002_json : "(not loaded)"); + tui_print(""); + + tui_print("KIND 10096 (blossom event):"); + tui_print("%s", g_state.kind10096_json ? g_state.kind10096_json : "(not loaded)"); + tui_print(""); + + tui_print("KIND 17375 (wallet event):"); + tui_print("%s", g_state.kind17375_json ? g_state.kind17375_json : "(not loaded)"); + tui_print(""); + + tui_print("KIND 30078 events loaded: %d", g_state.kind30078_count); + for (i = 0; i < g_state.kind30078_count; i++) { + tui_print("[%d] %s", i + 1, g_state.kind30078_events[i] ? g_state.kind30078_events[i] : "(null)"); + } + + tui_print("\nPress Enter to return."); + { + char input[8]; + tui_get_line(">", input, (int)sizeof(input)); + } +} void menu_main(void) { char input[64]; @@ -39,6 +86,15 @@ void menu_main(void) { tui_print("^_P^:rofile"); tui_print("^_R^:elays"); tui_print("^_F^:ollows"); + tui_print("^_K^:ind/event dump"); + tui_print("^_N^:otifications"); + tui_print("P^_o^:sts"); + tui_print("Li^_v^:e feeds"); + tui_print("Direct ^_m^:essage"); + tui_print("To^_d^:o"); + tui_print("D^_i^:ary"); + tui_print("^_A^:i"); + tui_print("^_E^:cash"); tui_print("^_Q^:uit."); } @@ -63,8 +119,7 @@ void menu_main(void) { break; case 'w': case 'W': - tui_print("WRITE placeholder"); - tui_get_line(">", input, (int)sizeof(input)); + menu_write(); break; case 't': case 'T': @@ -82,6 +137,42 @@ void menu_main(void) { case 'F': menu_follows(); break; + case 'n': + case 'N': + menu_notifications(); + break; + case 'k': + case 'K': + menu_show_loaded_events(); + break; + case 'o': + case 'O': + menu_posts(); + break; + case 'v': + case 'V': + menu_live(); + break; + case 'm': + case 'M': + menu_dm(); + break; + case 'd': + case 'D': + menu_todo(); + break; + case 'i': + case 'I': + menu_diary(); + break; + case 'a': + case 'A': + menu_ai(); + break; + case 'e': + case 'E': + menu_ecash(); + break; default: break; } diff --git a/src/menu_ai.c b/src/menu_ai.c new file mode 100644 index 0000000..402dc72 --- /dev/null +++ b/src/menu_ai.c @@ -0,0 +1,629 @@ +#include "tui.h" +#include "state.h" +#include "net.h" +#include "publish.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip004.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" + +#include +#include +#include +#include +#include + +typedef struct { + char *endpoint; + char *api_key; + char *model; +} ai_prefs_t; + +typedef struct { + char *data; + size_t len; +} nt_http_buffer_t; + +static char *ai_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + + memcpy(out, s, n + 1U); + return out; +} + +static void ai_set_string(char **dst, const char *src) { + char *next; + + if (!dst) { + return; + } + + next = ai_strdup(src ? src : ""); + if (!next) { + return; + } + + free(*dst); + *dst = next; +} + +static void ai_prefs_init(ai_prefs_t *prefs) { + if (!prefs) { + return; + } + + memset(prefs, 0, sizeof(*prefs)); + prefs->endpoint = ai_strdup("https://api.venice.ai/api/v1/chat/completions"); + prefs->api_key = ai_strdup(""); + prefs->model = ai_strdup("llama-3.3-70b"); +} + +static void ai_prefs_free(ai_prefs_t *prefs) { + if (!prefs) { + return; + } + + free(prefs->endpoint); + free(prefs->api_key); + free(prefs->model); + memset(prefs, 0, sizeof(*prefs)); +} + +static size_t nt_http_write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { + nt_http_buffer_t *buf = (nt_http_buffer_t *)userdata; + size_t total = size * nmemb; + char *next; + + if (!buf || !ptr || total == 0U) { + return 0; + } + + next = (char *)realloc(buf->data, buf->len + total + 1U); + if (!next) { + return 0; + } + + buf->data = next; + memcpy(buf->data + buf->len, ptr, total); + buf->len += total; + buf->data[buf->len] = '\0'; + return total; +} + +static void nt_configure_curl_tls(CURL *curl) { + const char *cafile_env; + const char *capath_env; + static const char *cafile_candidates[] = { + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/cert.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + }; + int i; + + if (!curl) { + return; + } + + cafile_env = getenv("SSL_CERT_FILE"); + if (!cafile_env || cafile_env[0] == '\0') { + cafile_env = getenv("CURL_CA_BUNDLE"); + } + if (cafile_env && cafile_env[0] != '\0') { + curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_env); + return; + } + + capath_env = getenv("SSL_CERT_DIR"); + if (capath_env && capath_env[0] != '\0') { + curl_easy_setopt(curl, CURLOPT_CAPATH, capath_env); + } + + for (i = 0; i < (int)(sizeof(cafile_candidates) / sizeof(cafile_candidates[0])); i++) { + if (access(cafile_candidates[i], R_OK) == 0) { + curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_candidates[i]); + return; + } + } + + if (access("/etc/ssl/certs", R_OK) == 0) { + curl_easy_setopt(curl, CURLOPT_CAPATH, "/etc/ssl/certs"); + } +} + +static int ai_encrypt_for_self(const char *plaintext, char **cipher_out) { + unsigned char priv[32]; + unsigned char pub[32]; + char *out; + size_t out_sz; + + if (!plaintext || !cipher_out) { + return -1; + } + + *cipher_out = NULL; + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return -1; + } + if (nostr_hex_to_bytes(g_state.npub_hex, pub, 32) != 0) { + return -1; + } + + out_sz = (strlen(plaintext) * 4U) + 256U; + out = (char *)malloc(out_sz); + if (!out) { + return -1; + } + + if (nostr_nip04_encrypt(priv, pub, plaintext, out, out_sz) != 0) { + free(out); + return -1; + } + + *cipher_out = out; + return 0; +} + +static char *ai_decrypt_from_pub(const char *sender_pub_hex, const char *ciphertext) { + unsigned char priv[32]; + unsigned char sender_pub[32]; + size_t out_sz; + char *out; + + if (!sender_pub_hex || !ciphertext) { + return NULL; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return NULL; + } + if (nostr_hex_to_bytes(sender_pub_hex, sender_pub, 32) != 0) { + return NULL; + } + + out_sz = strlen(ciphertext) + 4096U; + out = (char *)malloc(out_sz); + if (!out) { + return NULL; + } + + if (nostr_nip04_decrypt(priv, sender_pub, ciphertext, out, out_sz) != 0) { + free(out); + return NULL; + } + + return out; +} + +static cJSON *ai_make_d_tag(const char *dval) { + cJSON *tags = cJSON_CreateArray(); + cJSON *d_tag; + + if (!tags) { + return NULL; + } + + d_tag = cJSON_CreateArray(); + if (!d_tag) { + cJSON_Delete(tags); + return NULL; + } + + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(dval ? dval : "")); + cJSON_AddItemToArray(tags, d_tag); + return tags; +} + +static int ai_load_prefs(ai_prefs_t *prefs) { + const char **read_relays; + int relay_count = 0; + char filter[256]; + char *event_json = NULL; + cJSON *event; + cJSON *content_item; + cJSON *pubkey_item; + char *plain; + cJSON *root; + cJSON *endpoint; + cJSON *api_key; + cJSON *model; + + if (!prefs) { + return -1; + } + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30078],\"#d\":[\"prefs\"],\"limit\":1}", + g_state.npub_hex); + + if (nt_get_first(filter, read_relays, relay_count, 7000, &event_json) != 0) { + return -1; + } + if (!event_json) { + return 0; + } + + event = cJSON_Parse(event_json); + free(event_json); + if (!event) { + return -1; + } + + content_item = cJSON_GetObjectItemCaseSensitive(event, "content"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); + if (!cJSON_IsString(content_item) || !content_item->valuestring || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) { + cJSON_Delete(event); + return -1; + } + + plain = ai_decrypt_from_pub(pubkey_item->valuestring, content_item->valuestring); + cJSON_Delete(event); + if (!plain) { + return -1; + } + + root = cJSON_Parse(plain); + free(plain); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + return -1; + } + + endpoint = cJSON_GetObjectItemCaseSensitive(root, "ai_endpoint"); + api_key = cJSON_GetObjectItemCaseSensitive(root, "ai_api_key"); + model = cJSON_GetObjectItemCaseSensitive(root, "ai_model"); + + if (cJSON_IsString(endpoint) && endpoint->valuestring) { + ai_set_string(&prefs->endpoint, endpoint->valuestring); + } + if (cJSON_IsString(api_key) && api_key->valuestring) { + ai_set_string(&prefs->api_key, api_key->valuestring); + } + if (cJSON_IsString(model) && model->valuestring) { + ai_set_string(&prefs->model, model->valuestring); + } + + cJSON_Delete(root); + return 0; +} + +static int ai_save_prefs(const ai_prefs_t *prefs) { + cJSON *root; + char *json; + char *cipher = NULL; + cJSON *tags; + int posted; + + if (!prefs) { + return -1; + } + + root = cJSON_CreateObject(); + if (!root) { + return -1; + } + + cJSON_AddStringToObject(root, "ai_endpoint", prefs->endpoint ? prefs->endpoint : ""); + cJSON_AddStringToObject(root, "ai_api_key", prefs->api_key ? prefs->api_key : ""); + cJSON_AddStringToObject(root, "ai_model", prefs->model ? prefs->model : ""); + + json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + return -1; + } + + if (ai_encrypt_for_self(json, &cipher) != 0) { + free(json); + return -1; + } + + tags = ai_make_d_tag("prefs"); + if (!tags) { + free(json); + free(cipher); + return -1; + } + + posted = publish_signed_event_with_report("Prefs", 30078, cipher, tags, 8000); + cJSON_Delete(tags); + free(json); + free(cipher); + + return (posted < 0) ? -1 : 0; +} + +static char *ai_http_post(const char *url, const char *auth_header, const char *json_body) { + CURL *curl; + CURLcode rc; + long status = 0; + nt_http_buffer_t body = {0}; + struct curl_slist *headers = NULL; + char curl_err[CURL_ERROR_SIZE] = {0}; + int insecure_retry = 0; + + if (!url || !json_body) { + return NULL; + } + + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + return NULL; + } + + curl = curl_easy_init(); + if (!curl) { + curl_global_cleanup(); + return NULL; + } + + headers = curl_slist_append(headers, "Content-Type: application/json"); + if (auth_header && auth_header[0] != '\0') { + headers = curl_slist_append(headers, auth_header); + } + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(json_body)); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-terminal/0.0.1"); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 5000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 30000L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, nt_http_write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&body); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + nt_configure_curl_tls(curl); + if (headers) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curl_err); + +retry_request: + rc = curl_easy_perform(curl); + if (rc != CURLE_OK) { + if (!insecure_retry && + (rc == CURLE_PEER_FAILED_VERIFICATION || rc == CURLE_SSL_CACERT_BADFILE)) { + insecure_retry = 1; + free(body.data); + body.data = NULL; + body.len = 0; + curl_err[0] = '\0'; + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + goto retry_request; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + free(body.data); + return NULL; + } + + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + if (status < 200 || status >= 300) { + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + free(body.data); + return NULL; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + return body.data; +} + +static char *ai_extract_response(const char *resp_json) { + cJSON *root; + cJSON *choices; + cJSON *choice0; + cJSON *message; + cJSON *content; + char *out; + + if (!resp_json) { + return NULL; + } + + root = cJSON_Parse(resp_json); + if (!root) { + return NULL; + } + + choices = cJSON_GetObjectItemCaseSensitive(root, "choices"); + choice0 = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) : NULL; + message = cJSON_IsObject(choice0) ? cJSON_GetObjectItemCaseSensitive(choice0, "message") : NULL; + content = cJSON_IsObject(message) ? cJSON_GetObjectItemCaseSensitive(message, "content") : NULL; + + if (!cJSON_IsString(content) || !content->valuestring) { + cJSON_Delete(root); + return NULL; + } + + out = ai_strdup(content->valuestring); + cJSON_Delete(root); + return out; +} + +static void ai_chat(const ai_prefs_t *prefs) { + char prompt[4096]; + cJSON *root; + cJSON *messages; + cJSON *msg; + char *body; + char auth[4096]; + char *resp_json; + char *answer; + char hold[16]; + + if (!prefs) { + return; + } + + tui_get_line("prompt >", prompt, (int)sizeof(prompt)); + if (prompt[0] == '\0') { + return; + } + + if (!prefs->api_key || prefs->api_key[0] == '\0') { + tui_print("AI API key is empty. Configure settings first."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + root = cJSON_CreateObject(); + if (!root) { + return; + } + cJSON_AddStringToObject(root, "model", prefs->model ? prefs->model : ""); + + messages = cJSON_CreateArray(); + msg = cJSON_CreateObject(); + if (!messages || !msg) { + cJSON_Delete(messages); + cJSON_Delete(msg); + cJSON_Delete(root); + return; + } + cJSON_AddStringToObject(msg, "role", "user"); + cJSON_AddStringToObject(msg, "content", prompt); + cJSON_AddItemToArray(messages, msg); + cJSON_AddItemToObject(root, "messages", messages); + + body = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!body) { + return; + } + + snprintf(auth, sizeof(auth), "Authorization: Bearer %s", prefs->api_key ? prefs->api_key : ""); + resp_json = ai_http_post(prefs->endpoint ? prefs->endpoint : "", auth, body); + free(body); + + if (!resp_json) { + tui_print("AI request failed."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + answer = ai_extract_response(resp_json); + free(resp_json); + + tui_clear_screen(); + tui_print("AI RESPONSE\n"); + if (answer) { + tui_print("%s", answer); + } else { + tui_print("Failed to parse AI response."); + } + free(answer); + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static void ai_choose_model(ai_prefs_t *prefs) { + char model[256]; + + if (!prefs) { + return; + } + + tui_get_line("model >", model, (int)sizeof(model)); + if (model[0] == '\0') { + return; + } + + ai_set_string(&prefs->model, model); +} + +static void ai_settings(ai_prefs_t *prefs) { + char endpoint[512]; + char key[1024]; + char model[256]; + char hold[16]; + + if (!prefs) { + return; + } + + tui_print("Leave empty to keep current value."); + + tui_print("Current endpoint: %s", prefs->endpoint ? prefs->endpoint : ""); + tui_get_line("new endpoint >", endpoint, (int)sizeof(endpoint)); + if (endpoint[0] != '\0') { + ai_set_string(&prefs->endpoint, endpoint); + } + + tui_print("Current API key: %s", (prefs->api_key && prefs->api_key[0] != '\0') ? "(set)" : "(empty)"); + tui_get_line("new api key >", key, (int)sizeof(key)); + if (key[0] != '\0') { + ai_set_string(&prefs->api_key, key); + } + + tui_print("Current model: %s", prefs->model ? prefs->model : ""); + tui_get_line("new model >", model, (int)sizeof(model)); + if (model[0] != '\0') { + ai_set_string(&prefs->model, model); + } + + if (ai_save_prefs(prefs) != 0) { + tui_print("Failed to save prefs."); + } else { + tui_print("Prefs saved."); + } + + tui_get_line(">", hold, (int)sizeof(hold)); +} + +void menu_ai(void) { + ai_prefs_t prefs; + char input[16]; + + ai_prefs_init(&prefs); + (void)ai_load_prefs(&prefs); + + while (1) { + tui_clear_screen(); + tui_print("AI\n"); + tui_print("Model: %s", prefs.model ? prefs.model : ""); + tui_print("Endpoint: %s", prefs.endpoint ? prefs.endpoint : ""); + tui_print("1 - Chat"); + tui_print("2 - Choose model"); + tui_print("3 - Settings"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + ai_chat(&prefs); + } else if (input[0] == '2') { + ai_choose_model(&prefs); + } else if (input[0] == '3') { + ai_settings(&prefs); + } + } + + ai_prefs_free(&prefs); +} diff --git a/src/menu_diary.c b/src/menu_diary.c new file mode 100644 index 0000000..118e831 --- /dev/null +++ b/src/menu_diary.c @@ -0,0 +1,457 @@ +#include "tui.h" +#include "state.h" +#include "net.h" +#include "publish.h" +#include "editor.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip004.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" + +#include +#include +#include +#include + +typedef struct { + char *date_d; + long long created_at; + char *content; +} diary_entry_t; + +static char *diary_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + memcpy(out, s, n + 1U); + return out; +} + +static void diary_free_entries(diary_entry_t *entries, int count) { + int i; + + if (!entries) { + return; + } + for (i = 0; i < count; i++) { + free(entries[i].date_d); + free(entries[i].content); + } + free(entries); +} + +static int diary_encrypt_for_self(const char *plaintext, char **cipher_out) { + unsigned char priv[32]; + unsigned char pub[32]; + char *out; + size_t out_sz; + + if (!plaintext || !cipher_out) { + return -1; + } + *cipher_out = NULL; + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return -1; + } + if (nostr_hex_to_bytes(g_state.npub_hex, pub, 32) != 0) { + return -1; + } + + out_sz = (strlen(plaintext) * 4U) + 256U; + out = (char *)malloc(out_sz); + if (!out) { + return -1; + } + + if (nostr_nip04_encrypt(priv, pub, plaintext, out, out_sz) != 0) { + free(out); + return -1; + } + + *cipher_out = out; + return 0; +} + +static char *diary_decrypt_from_pub(const char *sender_pub_hex, const char *ciphertext) { + unsigned char priv[32]; + unsigned char sender_pub[32]; + size_t out_sz; + char *out; + + if (!sender_pub_hex || !ciphertext) { + return NULL; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return NULL; + } + if (nostr_hex_to_bytes(sender_pub_hex, sender_pub, 32) != 0) { + return NULL; + } + + out_sz = strlen(ciphertext) + 4096U; + out = (char *)malloc(out_sz); + if (!out) { + return NULL; + } + + if (nostr_nip04_decrypt(priv, sender_pub, ciphertext, out, out_sz) != 0) { + free(out); + return NULL; + } + + return out; +} + +static cJSON *diary_make_d_tag(const char *d_value) { + cJSON *tags = cJSON_CreateArray(); + cJSON *d_tag; + + if (!tags) { + return NULL; + } + + d_tag = cJSON_CreateArray(); + if (!d_tag) { + cJSON_Delete(tags); + return NULL; + } + + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_value ? d_value : "")); + cJSON_AddItemToArray(tags, d_tag); + return tags; +} + +static int diary_get_today_d(char out[16]) { + time_t now; + struct tm tmv; + + if (!out) { + return -1; + } + + now = time(NULL); + memset(&tmv, 0, sizeof(tmv)); + if (!localtime_r(&now, &tmv)) { + return -1; + } + + if (strftime(out, 16, "%Y%m%d", &tmv) == 0) { + return -1; + } + + return 0; +} + +static char *diary_find_d_tag(cJSON *tags) { + int i; + int n; + + if (!cJSON_IsArray(tags)) { + return NULL; + } + + n = cJSON_GetArraySize(tags); + for (i = 0; i < n; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + cJSON *t0; + cJSON *t1; + + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + t0 = cJSON_GetArrayItem(tag, 0); + t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && t0->valuestring && strcmp(t0->valuestring, "d") == 0 && + cJSON_IsString(t1) && t1->valuestring) { + return diary_strdup(t1->valuestring); + } + } + + return NULL; +} + +static char *diary_load_entry_plain(const char *date_d) { + char filter[256]; + const char **read_relays; + int relay_count = 0; + char *event_json = NULL; + cJSON *event; + cJSON *content_item; + cJSON *pubkey_item; + char *plain; + + if (!date_d) { + return NULL; + } + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30024],\"#d\":[\"%s\"],\"limit\":1}", + g_state.npub_hex, + date_d); + + if (nt_get_first(filter, read_relays, relay_count, 7000, &event_json) != 0) { + return NULL; + } + if (!event_json) { + return NULL; + } + + event = cJSON_Parse(event_json); + free(event_json); + if (!event) { + return NULL; + } + + content_item = cJSON_GetObjectItemCaseSensitive(event, "content"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); + if (!cJSON_IsString(content_item) || !content_item->valuestring || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) { + cJSON_Delete(event); + return NULL; + } + + plain = diary_decrypt_from_pub(pubkey_item->valuestring, content_item->valuestring); + cJSON_Delete(event); + return plain; +} + +static int diary_save_entry(const char *date_d, const char *plain) { + cJSON *tags; + char *cipher = NULL; + int posted; + + if (!date_d || !plain) { + return -1; + } + + if (diary_encrypt_for_self(plain, &cipher) != 0) { + tui_print("Failed to encrypt diary entry."); + return -1; + } + + tags = diary_make_d_tag(date_d); + if (!tags) { + tui_print("Failed to build diary tags."); + free(cipher); + return -1; + } + + posted = publish_signed_event_with_report("Diary", 30024, cipher, tags, 8000); + cJSON_Delete(tags); + free(cipher); + + if (posted <= 0) { + tui_print("Diary publish failed (accepted by 0 relays)."); + return -1; + } + + tui_print("Diary saved."); + return 0; +} + +static int diary_push_entry(diary_entry_t **entries, int *count, diary_entry_t *src) { + diary_entry_t *next; + + if (!entries || !count || !src) { + return -1; + } + + next = (diary_entry_t *)realloc(*entries, (size_t)(*count + 1) * sizeof(diary_entry_t)); + if (!next) { + return -1; + } + + *entries = next; + (*entries)[*count] = *src; + (*count)++; + return 0; +} + +static int diary_cmp_desc_date(const void *a, const void *b) { + const diary_entry_t *ea = (const diary_entry_t *)a; + const diary_entry_t *eb = (const diary_entry_t *)b; + + if (!ea->date_d && !eb->date_d) { + return 0; + } + if (!ea->date_d) { + return 1; + } + if (!eb->date_d) { + return -1; + } + + return strcmp(eb->date_d, ea->date_d); +} + +static void diary_browse_past(void) { + const char **read_relays; + int relay_count = 0; + char filter[256]; + char **events = NULL; + int event_count = 0; + diary_entry_t *entries = NULL; + int entries_count = 0; + int i; + char input[64]; + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30024],\"limit\":300}", + g_state.npub_hex); + + if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) { + tui_print("Failed to query diary entries."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + for (i = 0; i < event_count; i++) { + cJSON *ev = cJSON_Parse(events[i]); + cJSON *tags_item; + cJSON *created_item; + cJSON *content_item; + cJSON *pubkey_item; + diary_entry_t e; + + if (!ev) { + continue; + } + + memset(&e, 0, sizeof(e)); + + tags_item = cJSON_GetObjectItemCaseSensitive(ev, "tags"); + created_item = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey"); + + e.date_d = diary_find_d_tag(tags_item); + e.created_at = cJSON_IsNumber(created_item) ? (long long)created_item->valuedouble : 0; + if (cJSON_IsString(content_item) && content_item->valuestring && + cJSON_IsString(pubkey_item) && pubkey_item->valuestring) { + e.content = diary_decrypt_from_pub(pubkey_item->valuestring, content_item->valuestring); + } + if (!e.content) { + e.content = diary_strdup("(decrypt failed)"); + } + + if (!e.date_d || !e.content) { + free(e.date_d); + free(e.content); + cJSON_Delete(ev); + continue; + } + + if (diary_push_entry(&entries, &entries_count, &e) != 0) { + free(e.date_d); + free(e.content); + } + + cJSON_Delete(ev); + } + + for (i = 0; i < event_count; i++) { + free(events[i]); + } + free(events); + + if (entries_count > 1) { + qsort(entries, (size_t)entries_count, sizeof(diary_entry_t), diary_cmp_desc_date); + } + + while (1) { + tui_clear_screen(); + tui_print("PAST DIARY ENTRIES\n"); + for (i = 0; i < entries_count; i++) { + tui_print("%2d - %s", i + 1, entries[i].date_d ? entries[i].date_d : "(no d-tag)"); + } + if (entries_count == 0) { + tui_print("No diary entries found."); + } + tui_print("\nEnter number to view, x to back."); + + tui_get_line(">", input, (int)sizeof(input)); + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + { + int idx = atoi(input) - 1; + if (idx < 0 || idx >= entries_count) { + continue; + } + tui_clear_screen(); + tui_print("DIARY %s\n", entries[idx].date_d ? entries[idx].date_d : ""); + tui_print("%s", entries[idx].content ? entries[idx].content : ""); + tui_get_line(">", input, (int)sizeof(input)); + } + } + + diary_free_entries(entries, entries_count); +} + +void menu_diary(void) { + char today_d[16]; + char *existing = NULL; + char *edited = NULL; + char input[16]; + + if (diary_get_today_d(today_d) != 0) { + tui_print("Failed to compute today's date."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + existing = diary_load_entry_plain(today_d); + + while (1) { + tui_clear_screen(); + tui_print("DIARY\n"); + tui_print("Today: %s", today_d); + tui_print("Existing entry: %s", existing ? "yes" : "no"); + tui_print("1 - Edit today's entry"); + tui_print("2 - Browse past entries"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + edited = editor_launch(existing ? existing : ""); + if (!edited) { + tui_print("Edit canceled."); + tui_get_line(">", input, (int)sizeof(input)); + continue; + } + + if (diary_save_entry(today_d, edited) == 0) { + free(existing); + existing = diary_strdup(edited); + } + free(edited); + tui_get_line(">", input, (int)sizeof(input)); + } else if (input[0] == '2') { + diary_browse_past(); + } + } + + free(existing); +} diff --git a/src/menu_dm.c b/src/menu_dm.c new file mode 100644 index 0000000..bd4c13c --- /dev/null +++ b/src/menu_dm.c @@ -0,0 +1,478 @@ +#include "tui.h" +#include "state.h" +#include "net.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip001.h" +#include "../resources/nostr_core_lib/nostr_core/nip004.h" +#include "../resources/nostr_core_lib/nostr_core/nip017.h" +#include "../resources/nostr_core_lib/nostr_core/nip019.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" + +#include +#include +#include +#include + +typedef struct { + long long created_at; + char *sender_pub; + char *content; + int kind; +} dm_item_t; + +static char *dm_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + + memcpy(out, s, n + 1U); + return out; +} + +static void dm_free_items(dm_item_t *items, int count) { + int i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i].sender_pub); + free(items[i].content); + } + free(items); +} + +static int dm_cmp_desc_created(const void *a, const void *b) { + const dm_item_t *ia = (const dm_item_t *)a; + const dm_item_t *ib = (const dm_item_t *)b; + + if (ia->created_at < ib->created_at) { + return 1; + } + if (ia->created_at > ib->created_at) { + return -1; + } + return 0; +} + +static const char *dm_shorten_hex(const char *hex, char *buf, size_t buf_sz) { + size_t n; + + if (!buf || buf_sz == 0U) { + return ""; + } + + if (!hex || hex[0] == '\0') { + snprintf(buf, buf_sz, "(unknown)"); + return buf; + } + + n = strlen(hex); + if (n <= 12U) { + snprintf(buf, buf_sz, "%s", hex); + } else { + snprintf(buf, buf_sz, "%.8s..%.4s", hex, hex + n - 4U); + } + return buf; +} + +static int dm_push_item(dm_item_t **items, int *count, dm_item_t *src) { + dm_item_t *next; + + if (!items || !count || !src) { + return -1; + } + + next = (dm_item_t *)realloc(*items, (size_t)(*count + 1) * sizeof(dm_item_t)); + if (!next) { + return -1; + } + + *items = next; + (*items)[*count] = *src; + (*count)++; + return 0; +} + +static int dm_parse_recipient_hex(const char *input, char out_hex[65]) { + if (!input || !out_hex) { + return -1; + } + + if (strncmp(input, "npub", 4) == 0) { + unsigned char pub[32]; + if (nostr_decode_npub(input, pub) != 0) { + return -1; + } + nostr_bytes_to_hex(pub, 32, out_hex); + return 0; + } + + if (strlen(input) == 64U) { + unsigned char pub[32]; + if (nostr_hex_to_bytes(input, pub, 32) != 0) { + return -1; + } + snprintf(out_hex, 65, "%s", input); + return 0; + } + + return -1; +} + +static void menu_dm_send(void) { + char who[256]; + char msg[4096]; + char recip_hex[65] = {0}; + const char *recips[1]; + unsigned char priv[32]; + cJSON *rumor = NULL; + cJSON *gift_wraps[8] = {0}; + int wrap_count = 0; + const char **write_relays; + int relay_count = 0; + int i; + int any_ok = 0; + char hold[16]; + + tui_clear_screen(); + tui_print("SEND DM\n"); + + tui_get_line("recipient (npub or hex) >", who, (int)sizeof(who)); + if (who[0] == '\0') { + return; + } + + if (dm_parse_recipient_hex(who, recip_hex) != 0) { + tui_print("Invalid recipient. Use npub or 64-char hex pubkey."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + tui_get_line("message >", msg, (int)sizeof(msg)); + if (msg[0] == '\0') { + return; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + tui_print("Invalid private key in state."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + recips[0] = recip_hex; + rumor = nostr_nip17_create_chat_event(msg, + recips, + 1, + NULL, + NULL, + NULL, + g_state.npub_hex); + if (!rumor) { + tui_print("Failed to build NIP-17 DM rumor."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + wrap_count = nostr_nip17_send_dm(rumor, recips, 1, priv, gift_wraps, 8, 0); + cJSON_Delete(rumor); + if (wrap_count <= 0) { + tui_print("Failed to create NIP-17 gift wrap event(s)."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + write_relays = state_get_write_relays(&relay_count); + tui_print("Publishing %d wrapped DM event(s) to %d relay(s)...", wrap_count, relay_count); + + for (i = 0; i < wrap_count; i++) { + char *event_json; + int posted; + + if (!gift_wraps[i]) { + continue; + } + + event_json = cJSON_PrintUnformatted(gift_wraps[i]); + cJSON_Delete(gift_wraps[i]); + gift_wraps[i] = NULL; + if (!event_json) { + continue; + } + + posted = nt_publish(event_json, write_relays, relay_count, 8000); + free(event_json); + if (posted > 0) { + any_ok = 1; + } + } + + if (any_ok) { + tui_print("DM sent."); + } else { + tui_print("DM publish failed."); + } + + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static char *dm_try_decrypt_kind4(const char *sender_pub_hex, const char *ciphertext) { + unsigned char priv[32]; + unsigned char sender_pub[32]; + size_t out_sz; + char *out; + + if (!sender_pub_hex || !ciphertext) { + return NULL; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return NULL; + } + if (nostr_hex_to_bytes(sender_pub_hex, sender_pub, 32) != 0) { + return NULL; + } + + out_sz = strlen(ciphertext) + 1024U; + out = (char *)malloc(out_sz); + if (!out) { + return NULL; + } + + if (nostr_nip04_decrypt(priv, sender_pub, ciphertext, out, out_sz) != 0) { + free(out); + return NULL; + } + + return out; +} + +static void dm_collect_legacy_kind4(dm_item_t **items, int *items_count) { + const char **read_relays; + int relay_count = 0; + char filter[256]; + char **events = NULL; + int event_count = 0; + int i; + + if (!items || !items_count) { + return; + } + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"#p\":[\"%s\"],\"kinds\":[4],\"limit\":200}", + g_state.npub_hex); + + if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) { + return; + } + + for (i = 0; i < event_count; i++) { + cJSON *ev = cJSON_Parse(events[i]); + cJSON *created_item; + cJSON *pubkey_item; + cJSON *content_item; + dm_item_t it; + char *plain; + + if (!ev) { + continue; + } + + created_item = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey"); + content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + if (!cJSON_IsNumber(created_item) || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring || + !cJSON_IsString(content_item) || !content_item->valuestring) { + cJSON_Delete(ev); + continue; + } + + plain = dm_try_decrypt_kind4(pubkey_item->valuestring, content_item->valuestring); + + memset(&it, 0, sizeof(it)); + it.kind = 4; + it.created_at = (long long)created_item->valuedouble; + it.sender_pub = dm_strdup(pubkey_item->valuestring); + it.content = plain ? plain : dm_strdup("(NIP-04 decrypt failed)"); + + if (!it.sender_pub || !it.content) { + free(it.sender_pub); + free(it.content); + cJSON_Delete(ev); + continue; + } + + if (dm_push_item(items, items_count, &it) != 0) { + free(it.sender_pub); + free(it.content); + } + + cJSON_Delete(ev); + } + + for (i = 0; i < event_count; i++) { + free(events[i]); + } + free(events); +} + +static void dm_collect_nip17_kind1059(dm_item_t **items, int *items_count) { + const char **read_relays; + int relay_count = 0; + char filter[256]; + char **events = NULL; + int event_count = 0; + unsigned char priv[32]; + int i; + + if (!items || !items_count) { + return; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return; + } + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"#p\":[\"%s\"],\"kinds\":[1059],\"limit\":200}", + g_state.npub_hex); + + if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) { + return; + } + + for (i = 0; i < event_count; i++) { + cJSON *gift = cJSON_Parse(events[i]); + cJSON *created_item; + cJSON *pubkey_item; + cJSON *rumor; + cJSON *rumor_content; + cJSON *rumor_pubkey; + dm_item_t it; + + if (!gift) { + continue; + } + + created_item = cJSON_GetObjectItemCaseSensitive(gift, "created_at"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(gift, "pubkey"); + + rumor = nostr_nip17_receive_dm(gift, priv); + memset(&it, 0, sizeof(it)); + it.kind = 1059; + it.created_at = cJSON_IsNumber(created_item) ? (long long)created_item->valuedouble : 0; + + if (rumor) { + rumor_pubkey = cJSON_GetObjectItemCaseSensitive(rumor, "pubkey"); + rumor_content = cJSON_GetObjectItemCaseSensitive(rumor, "content"); + it.sender_pub = (cJSON_IsString(rumor_pubkey) && rumor_pubkey->valuestring) + ? dm_strdup(rumor_pubkey->valuestring) + : dm_strdup((cJSON_IsString(pubkey_item) && pubkey_item->valuestring) + ? pubkey_item->valuestring + : ""); + it.content = (cJSON_IsString(rumor_content) && rumor_content->valuestring) + ? dm_strdup(rumor_content->valuestring) + : dm_strdup("(empty NIP-17 content)"); + cJSON_Delete(rumor); + } else { + it.sender_pub = dm_strdup((cJSON_IsString(pubkey_item) && pubkey_item->valuestring) + ? pubkey_item->valuestring + : ""); + it.content = dm_strdup("(NIP-17 decrypt not available or failed)"); + } + + if (!it.sender_pub || !it.content) { + free(it.sender_pub); + free(it.content); + cJSON_Delete(gift); + continue; + } + + if (dm_push_item(items, items_count, &it) != 0) { + free(it.sender_pub); + free(it.content); + } + + cJSON_Delete(gift); + } + + for (i = 0; i < event_count; i++) { + free(events[i]); + } + free(events); +} + +static void menu_dm_read_inbox(void) { + dm_item_t *items = NULL; + int items_count = 0; + int i; + char hold[16]; + + tui_clear_screen(); + tui_print("READ INBOX\n"); + tui_print("Querying kind 1059 + kind 4..."); + + dm_collect_nip17_kind1059(&items, &items_count); + dm_collect_legacy_kind4(&items, &items_count); + + if (items_count > 1) { + qsort(items, (size_t)items_count, sizeof(dm_item_t), dm_cmp_desc_created); + } + + tui_clear_screen(); + tui_print("INBOX\n"); + + for (i = 0; i < items_count; i++) { + char sender_short[32]; + const char *sender = dm_shorten_hex(items[i].sender_pub, sender_short, sizeof(sender_short)); + tui_print("[%3d] (%d) %s: %s", i + 1, items[i].kind, sender, items[i].content ? items[i].content : ""); + } + + if (items_count == 0) { + tui_print("No DMs found."); + } + + dm_free_items(items, items_count); + tui_get_line(">", hold, (int)sizeof(hold)); +} + +void menu_dm(void) { + char input[16]; + + while (1) { + tui_clear_screen(); + tui_print("DIRECT MESSAGE\n"); + tui_print("1 - Send DM (NIP-17)"); + tui_print("2 - Read inbox (kind 1059 + kind 4)"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + menu_dm_send(); + } else if (input[0] == '2') { + menu_dm_read_inbox(); + } + } +} diff --git a/src/menu_ecash.c b/src/menu_ecash.c new file mode 100644 index 0000000..320a777 --- /dev/null +++ b/src/menu_ecash.c @@ -0,0 +1,803 @@ +#include "tui.h" +#include "state.h" +#include "net.h" +#include "publish.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/cashu_mint.h" +#include "../resources/nostr_core_lib/nostr_core/nip060.h" + +#include +#include +#include +#include + +typedef struct { + char **mints; + int mint_count; + nostr_cashu_proof_t *proofs; + char **proof_mints; + int proof_count; +} ecash_wallet_t; + +static char *ecash_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + memcpy(out, s, n + 1U); + return out; +} + +static int ecash_has_mint(const ecash_wallet_t *w, const char *mint_url) { + int i; + + if (!w || !mint_url) { + return 0; + } + + for (i = 0; i < w->mint_count; i++) { + if (w->mints[i] && strcmp(w->mints[i], mint_url) == 0) { + return 1; + } + } + + return 0; +} + +static int ecash_add_mint(ecash_wallet_t *w, const char *mint_url) { + char **next; + char *copy; + + if (!w || !mint_url || mint_url[0] == '\0') { + return -1; + } + + if (ecash_has_mint(w, mint_url)) { + return 0; + } + + copy = ecash_strdup(mint_url); + if (!copy) { + return -1; + } + + next = (char **)realloc(w->mints, (size_t)(w->mint_count + 1) * sizeof(char *)); + if (!next) { + free(copy); + return -1; + } + + w->mints = next; + w->mints[w->mint_count] = copy; + w->mint_count++; + return 0; +} + +static void ecash_free_proof(nostr_cashu_proof_t *p) { + if (!p) { + return; + } + free(p->secret); + free(p->C); + p->secret = NULL; + p->C = NULL; +} + +static int ecash_copy_proof(nostr_cashu_proof_t *dst, const nostr_cashu_proof_t *src) { + if (!dst || !src) { + return -1; + } + + memset(dst, 0, sizeof(*dst)); + snprintf(dst->id, sizeof(dst->id), "%s", src->id); + dst->amount = src->amount; + dst->secret = ecash_strdup(src->secret ? src->secret : ""); + dst->C = ecash_strdup(src->C ? src->C : ""); + + if (!dst->secret || !dst->C) { + ecash_free_proof(dst); + return -1; + } + + return 0; +} + +static int ecash_push_proof(ecash_wallet_t *w, const char *mint_url, const nostr_cashu_proof_t *proof) { + nostr_cashu_proof_t *next_proofs; + char **next_mints; + nostr_cashu_proof_t copy; + char *mint_copy; + + if (!w || !mint_url || !proof) { + return -1; + } + + if (ecash_copy_proof(©, proof) != 0) { + return -1; + } + + mint_copy = ecash_strdup(mint_url); + if (!mint_copy) { + ecash_free_proof(©); + return -1; + } + + next_proofs = (nostr_cashu_proof_t *)realloc(w->proofs, (size_t)(w->proof_count + 1) * sizeof(nostr_cashu_proof_t)); + if (!next_proofs) { + ecash_free_proof(©); + free(mint_copy); + return -1; + } + + next_mints = (char **)realloc(w->proof_mints, (size_t)(w->proof_count + 1) * sizeof(char *)); + if (!next_mints) { + ecash_free_proof(©); + free(mint_copy); + return -1; + } + + w->proofs = next_proofs; + w->proof_mints = next_mints; + w->proofs[w->proof_count] = copy; + w->proof_mints[w->proof_count] = mint_copy; + w->proof_count++; + return 0; +} + +static void ecash_wallet_free(ecash_wallet_t *w) { + int i; + + if (!w) { + return; + } + + for (i = 0; i < w->mint_count; i++) { + free(w->mints[i]); + } + free(w->mints); + + for (i = 0; i < w->proof_count; i++) { + ecash_free_proof(&w->proofs[i]); + free(w->proof_mints[i]); + } + free(w->proofs); + free(w->proof_mints); + + memset(w, 0, sizeof(*w)); +} + +static char *ecash_extract_wallet_content(const char *event_or_content_json) { + cJSON *root; + cJSON *content; + char *out; + + if (!event_or_content_json || event_or_content_json[0] == '\0') { + return ecash_strdup("{}"); + } + + root = cJSON_Parse(event_or_content_json); + if (!root) { + return ecash_strdup("{}"); + } + + content = cJSON_GetObjectItemCaseSensitive(root, "content"); + if (cJSON_IsString(content) && content->valuestring) { + out = ecash_strdup(content->valuestring); + } else { + out = ecash_strdup(event_or_content_json); + } + + cJSON_Delete(root); + return out ? out : ecash_strdup("{}"); +} + +static int ecash_wallet_from_content_json(const char *content_json, ecash_wallet_t *w) { + cJSON *root; + cJSON *mints; + cJSON *proofs; + int i; + + if (!w) { + return -1; + } + + if (!content_json || content_json[0] == '\0') { + return 0; + } + + root = cJSON_Parse(content_json); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + return -1; + } + + mints = cJSON_GetObjectItemCaseSensitive(root, "mints"); + if (cJSON_IsArray(mints)) { + int n = cJSON_GetArraySize(mints); + for (i = 0; i < n; i++) { + cJSON *mi = cJSON_GetArrayItem(mints, i); + if (cJSON_IsString(mi) && mi->valuestring) { + (void)ecash_add_mint(w, mi->valuestring); + } + } + } + + proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs"); + if (cJSON_IsArray(proofs)) { + int n = cJSON_GetArraySize(proofs); + for (i = 0; i < n; i++) { + cJSON *pi = cJSON_GetArrayItem(proofs, i); + cJSON *mint_item; + cJSON *id_item; + cJSON *amount_item; + cJSON *secret_item; + cJSON *c_item; + nostr_cashu_proof_t p; + const char *mint_url; + + if (!cJSON_IsObject(pi)) { + continue; + } + + mint_item = cJSON_GetObjectItemCaseSensitive(pi, "mint"); + id_item = cJSON_GetObjectItemCaseSensitive(pi, "id"); + amount_item = cJSON_GetObjectItemCaseSensitive(pi, "amount"); + secret_item = cJSON_GetObjectItemCaseSensitive(pi, "secret"); + c_item = cJSON_GetObjectItemCaseSensitive(pi, "C"); + + if (!cJSON_IsString(mint_item) || !mint_item->valuestring || + !cJSON_IsString(id_item) || !id_item->valuestring || + !cJSON_IsNumber(amount_item) || + !cJSON_IsString(secret_item) || !secret_item->valuestring || + !cJSON_IsString(c_item) || !c_item->valuestring) { + continue; + } + + memset(&p, 0, sizeof(p)); + snprintf(p.id, sizeof(p.id), "%s", id_item->valuestring); + p.amount = (uint64_t)amount_item->valuedouble; + p.secret = (char *)secret_item->valuestring; + p.C = (char *)c_item->valuestring; + mint_url = mint_item->valuestring; + + (void)ecash_add_mint(w, mint_url); + (void)ecash_push_proof(w, mint_url, &p); + } + } + + cJSON_Delete(root); + return 0; +} + +static int ecash_wallet_load(ecash_wallet_t *w) { + const char **read_relays; + int relay_count = 0; + char filter[256]; + char *event_json = NULL; + char *content_json; + + if (!w) { + return -1; + } + + memset(w, 0, sizeof(*w)); + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[17375],\"limit\":1}", + g_state.npub_hex); + + if (nt_get_first(filter, read_relays, relay_count, 7000, &event_json) != 0) { + event_json = NULL; + } + + if (!event_json && g_state.kind17375_json && g_state.kind17375_json[0] != '\0') { + event_json = ecash_strdup(g_state.kind17375_json); + } + + content_json = ecash_extract_wallet_content(event_json ? event_json : "{}"); + free(event_json); + if (!content_json) { + return -1; + } + + if (ecash_wallet_from_content_json(content_json, w) != 0) { + free(content_json); + ecash_wallet_free(w); + return -1; + } + + free(content_json); + return 0; +} + +static char *ecash_wallet_to_content_json(const ecash_wallet_t *w) { + cJSON *root; + cJSON *mints; + cJSON *proofs; + int i; + char *out; + + if (!w) { + return NULL; + } + + root = cJSON_CreateObject(); + if (!root) { + return NULL; + } + + mints = cJSON_CreateArray(); + proofs = cJSON_CreateArray(); + if (!mints || !proofs) { + cJSON_Delete(mints); + cJSON_Delete(proofs); + cJSON_Delete(root); + return NULL; + } + + for (i = 0; i < w->mint_count; i++) { + cJSON_AddItemToArray(mints, cJSON_CreateString(w->mints[i] ? w->mints[i] : "")); + } + + for (i = 0; i < w->proof_count; i++) { + cJSON *po = cJSON_CreateObject(); + if (!po) { + continue; + } + + cJSON_AddStringToObject(po, "mint", w->proof_mints[i] ? w->proof_mints[i] : ""); + cJSON_AddStringToObject(po, "id", w->proofs[i].id); + cJSON_AddNumberToObject(po, "amount", (double)w->proofs[i].amount); + cJSON_AddStringToObject(po, "secret", w->proofs[i].secret ? w->proofs[i].secret : ""); + cJSON_AddStringToObject(po, "C", w->proofs[i].C ? w->proofs[i].C : ""); + cJSON_AddItemToArray(proofs, po); + } + + cJSON_AddItemToObject(root, "mints", mints); + cJSON_AddItemToObject(root, "proofs", proofs); + + out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return out; +} + +static int ecash_publish_wallet(const ecash_wallet_t *w) { + char *content; + int posted; + + if (!w) { + return -1; + } + + content = ecash_wallet_to_content_json(w); + if (!content) { + return -1; + } + + posted = publish_signed_event_with_report("Wallet", NOSTR_NIP60_WALLET_KIND, content, NULL, 8000); + free(content); + + return (posted < 0) ? -1 : 0; +} + +static uint64_t ecash_wallet_total(const ecash_wallet_t *w) { + int i; + uint64_t total = 0; + + if (!w) { + return 0; + } + + for (i = 0; i < w->proof_count; i++) { + total += w->proofs[i].amount; + } + + return total; +} + +static void ecash_show_balance(const ecash_wallet_t *w) { + int i; + char hold[16]; + + tui_clear_screen(); + tui_print("ECASH BALANCE\n"); + tui_print("Total: %llu sats", (unsigned long long)ecash_wallet_total(w)); + tui_print(""); + + for (i = 0; i < w->mint_count; i++) { + int j; + uint64_t mint_total = 0; + + for (j = 0; j < w->proof_count; j++) { + if (w->proof_mints[j] && w->mints[i] && strcmp(w->proof_mints[j], w->mints[i]) == 0) { + mint_total += w->proofs[j].amount; + } + } + + tui_print("%2d. %s", i + 1, w->mints[i]); + tui_print(" balance: %llu sats", (unsigned long long)mint_total); + } + + if (w->mint_count == 0) { + tui_print("No mints configured."); + } + + tui_print("\nProof count: %d", w->proof_count); + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static void ecash_add_mint_menu(ecash_wallet_t *w) { + char mint[512]; + char hold[16]; + + tui_get_line("mint url >", mint, (int)sizeof(mint)); + if (mint[0] == '\0') { + return; + } + + if (ecash_add_mint(w, mint) != 0) { + tui_print("Failed to add mint."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + if (ecash_publish_wallet(w) != 0) { + tui_print("Failed to publish wallet metadata."); + } else { + tui_print("Mint added."); + } + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static void ecash_remove_mint_menu(ecash_wallet_t *w) { + char input[64]; + int idx; + int i; + char hold[16]; + + if (!w || w->mint_count <= 0) { + tui_print("No mints to remove."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + for (i = 0; i < w->mint_count; i++) { + tui_print("%2d. %s", i + 1, w->mints[i]); + } + + tui_get_line("remove number >", input, (int)sizeof(input)); + idx = atoi(input) - 1; + if (idx < 0 || idx >= w->mint_count) { + tui_print("Invalid mint number."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + free(w->mints[idx]); + for (i = idx; i < w->mint_count - 1; i++) { + w->mints[i] = w->mints[i + 1]; + } + w->mint_count--; + + if (ecash_publish_wallet(w) != 0) { + tui_print("Failed to publish wallet metadata."); + } else { + tui_print("Mint removed."); + } + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static void ecash_receive_token_menu(ecash_wallet_t *w) { + char token[8192]; + cashu_decoded_token_t decoded; + int i; + char hold[16]; + + if (!w) { + return; + } + + memset(&decoded, 0, sizeof(decoded)); + tui_get_line("paste cashu token >", token, (int)sizeof(token)); + if (token[0] == '\0') { + return; + } + + if (cashu_decode_token(token, &decoded) != 0) { + tui_print("Failed to decode token."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + if (!decoded.mint_url || decoded.proof_count <= 0 || !decoded.proofs) { + cashu_free_decoded_token(&decoded); + tui_print("Token did not contain usable proofs."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + (void)ecash_add_mint(w, decoded.mint_url); + for (i = 0; i < decoded.proof_count; i++) { + (void)ecash_push_proof(w, decoded.mint_url, &decoded.proofs[i]); + } + + cashu_free_decoded_token(&decoded); + + if (ecash_publish_wallet(w) != 0) { + tui_print("Token imported locally, but publish failed."); + } else { + tui_print("Token received and wallet updated."); + } + tui_get_line(">", hold, (int)sizeof(hold)); +} + +static void ecash_remove_proofs_by_global_indices(ecash_wallet_t *w, const int *indices, int nidx) { + char *remove_flags; + int i; + int out_i; + + if (!w || !indices || nidx <= 0 || w->proof_count <= 0) { + return; + } + + remove_flags = (char *)calloc((size_t)w->proof_count, 1U); + if (!remove_flags) { + return; + } + + for (i = 0; i < nidx; i++) { + if (indices[i] >= 0 && indices[i] < w->proof_count) { + remove_flags[indices[i]] = 1; + } + } + + out_i = 0; + for (i = 0; i < w->proof_count; i++) { + if (remove_flags[i]) { + ecash_free_proof(&w->proofs[i]); + free(w->proof_mints[i]); + continue; + } + + if (out_i != i) { + w->proofs[out_i] = w->proofs[i]; + w->proof_mints[out_i] = w->proof_mints[i]; + } + out_i++; + } + + w->proof_count = out_i; + free(remove_flags); +} + +static void ecash_send_token_menu(ecash_wallet_t *w) { + char input[128]; + int mint_idx; + uint64_t target_amount; + int i; + nostr_cashu_proof_t *mint_proofs = NULL; + int *mint_global_indices = NULL; + int mint_proof_count = 0; + int *selected_local = NULL; + int selected_count; + uint64_t selected_total = 0; + cashu_decoded_token_t token; + char *encoded = NULL; + int *selected_global = NULL; + char hold[16]; + + if (!w) { + return; + } + + if (w->mint_count <= 0) { + tui_print("No mints configured."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + for (i = 0; i < w->mint_count; i++) { + tui_print("%2d. %s", i + 1, w->mints[i]); + } + + tui_get_line("mint number >", input, (int)sizeof(input)); + mint_idx = atoi(input) - 1; + if (mint_idx < 0 || mint_idx >= w->mint_count) { + tui_print("Invalid mint number."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + tui_get_line("amount (sats) >", input, (int)sizeof(input)); + target_amount = (uint64_t)strtoull(input, NULL, 10); + if (target_amount == 0) { + tui_print("Invalid amount."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + for (i = 0; i < w->proof_count; i++) { + if (w->proof_mints[i] && strcmp(w->proof_mints[i], w->mints[mint_idx]) == 0) { + nostr_cashu_proof_t *next_proofs; + int *next_idx; + + next_proofs = (nostr_cashu_proof_t *)realloc(mint_proofs, (size_t)(mint_proof_count + 1) * sizeof(nostr_cashu_proof_t)); + if (!next_proofs) { + free(mint_proofs); + free(mint_global_indices); + tui_print("Memory error."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + mint_proofs = next_proofs; + + next_idx = (int *)realloc(mint_global_indices, (size_t)(mint_proof_count + 1) * sizeof(int)); + if (!next_idx) { + free(mint_proofs); + free(mint_global_indices); + tui_print("Memory error."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + mint_global_indices = next_idx; + mint_proofs[mint_proof_count] = w->proofs[i]; + mint_global_indices[mint_proof_count] = i; + mint_proof_count++; + } + } + + if (mint_proof_count <= 0) { + free(mint_proofs); + free(mint_global_indices); + tui_print("No proofs available for selected mint."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + selected_local = (int *)malloc((size_t)mint_proof_count * sizeof(int)); + if (!selected_local) { + free(mint_proofs); + free(mint_global_indices); + tui_print("Memory error."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + selected_count = cashu_mint_select_proofs_for_amount(mint_proofs, + mint_proof_count, + target_amount, + selected_local, + mint_proof_count, + &selected_total); + if (selected_count <= 0) { + free(selected_local); + free(mint_proofs); + free(mint_global_indices); + tui_print("Could not select proofs for that amount."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + memset(&token, 0, sizeof(token)); + token.mint_url = ecash_strdup(w->mints[mint_idx]); + token.proofs = (nostr_cashu_proof_t *)calloc((size_t)selected_count, sizeof(nostr_cashu_proof_t)); + token.proof_count = selected_count; + selected_global = (int *)malloc((size_t)selected_count * sizeof(int)); + + if (!token.mint_url || !token.proofs || !selected_global) { + free(selected_local); + free(mint_proofs); + free(mint_global_indices); + free(selected_global); + cashu_free_decoded_token(&token); + tui_print("Memory error."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + for (i = 0; i < selected_count; i++) { + int local_idx = selected_local[i]; + int global_idx; + + if (local_idx < 0 || local_idx >= mint_proof_count || + ecash_copy_proof(&token.proofs[i], &mint_proofs[local_idx]) != 0) { + free(selected_local); + free(mint_proofs); + free(mint_global_indices); + free(selected_global); + cashu_free_decoded_token(&token); + tui_print("Failed to assemble token proofs."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + global_idx = mint_global_indices[local_idx]; + selected_global[i] = global_idx; + } + + if (cashu_encode_token(&token, CASHU_TOKEN_FORMAT_B, &encoded) != 0 || !encoded) { + free(selected_local); + free(mint_proofs); + free(mint_global_indices); + free(selected_global); + cashu_free_decoded_token(&token); + tui_print("Failed to encode token."); + tui_get_line(">", hold, (int)sizeof(hold)); + return; + } + + ecash_remove_proofs_by_global_indices(w, selected_global, selected_count); + (void)ecash_publish_wallet(w); + + tui_clear_screen(); + tui_print("SEND TOKEN\n"); + tui_print("Requested: %llu sats", (unsigned long long)target_amount); + tui_print("Selected: %llu sats", (unsigned long long)selected_total); + tui_print("Token:"); + tui_print("%s", encoded); + tui_print(""); + tui_print("Note: Mint HTTP swap/melt flows are not yet implemented in this MVP."); + tui_get_line(">", hold, (int)sizeof(hold)); + + free(encoded); + free(selected_local); + free(mint_proofs); + free(mint_global_indices); + free(selected_global); + cashu_free_decoded_token(&token); +} + +void menu_ecash(void) { + ecash_wallet_t wallet; + char input[16]; + + if (ecash_wallet_load(&wallet) != 0) { + memset(&wallet, 0, sizeof(wallet)); + } + + while (1) { + tui_clear_screen(); + tui_print("ECASH WALLET\n"); + tui_print("Mints: %d", wallet.mint_count); + tui_print("Proofs: %d", wallet.proof_count); + tui_print("1 - Balance"); + tui_print("2 - Add mint"); + tui_print("3 - Remove mint"); + tui_print("4 - Receive token"); + tui_print("5 - Send token"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + ecash_show_balance(&wallet); + } else if (input[0] == '2') { + ecash_add_mint_menu(&wallet); + } else if (input[0] == '3') { + ecash_remove_mint_menu(&wallet); + } else if (input[0] == '4') { + ecash_receive_token_menu(&wallet); + } else if (input[0] == '5') { + ecash_send_token_menu(&wallet); + } + } + + ecash_wallet_free(&wallet); +} diff --git a/src/menu_follows.c b/src/menu_follows.c index c777c5b..620873c 100644 --- a/src/menu_follows.c +++ b/src/menu_follows.c @@ -261,7 +261,7 @@ void menu_follows(void) { } tui_get_line(">", input, (int)sizeof(input)); break; - } else if (input[0] == 'x' || input[0] == 'X') { + } else if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { break; } } diff --git a/src/menu_live.c b/src/menu_live.c new file mode 100644 index 0000000..5eb980a --- /dev/null +++ b/src/menu_live.c @@ -0,0 +1,328 @@ +#include "tui.h" +#include "state.h" +#include "net.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" + +#include +#include +#include + +typedef struct { + int term_width; +} live_feed_ctx_t; + +static char *live_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + memcpy(out, s, n + 1U); + return out; +} + +static const char *live_shorten_hex(const char *hex, char *buf, size_t buf_size) { + size_t n; + + if (!buf || buf_size == 0U) { + return ""; + } + buf[0] = '\0'; + + if (!hex || hex[0] == '\0') { + snprintf(buf, buf_size, "(unknown)"); + return buf; + } + + n = strlen(hex); + if (n <= 12U) { + snprintf(buf, buf_size, "%s", hex); + } else { + snprintf(buf, buf_size, "%.8s..%.4s", hex, hex + n - 4U); + } + return buf; +} + +static void live_preview_text(const char *text, int max_len, char *buf, size_t buf_size) { + size_t i; + size_t j = 0; + + if (!buf || buf_size == 0U) { + return; + } + buf[0] = '\0'; + + if (!text || text[0] == '\0' || max_len <= 0) { + snprintf(buf, buf_size, ""); + return; + } + + for (i = 0; text[i] != '\0' && j + 1U < buf_size; i++) { + char ch = text[i]; + if (ch == '\n' || ch == '\r' || ch == '\t') { + ch = ' '; + } + buf[j++] = ch; + if ((int)j >= max_len) { + break; + } + } + buf[j] = '\0'; + + if (text[i] != '\0' && j + 4U < buf_size) { + buf[j++] = '.'; + buf[j++] = '.'; + buf[j++] = '.'; + buf[j] = '\0'; + } +} + +static void live_event_print_cb(const char *event_json, void *userdata) { + cJSON *ev; + cJSON *pubkey_item; + cJSON *content_item; + const char *pubkey = NULL; + const char *content = ""; + char pub_short[32]; + char preview[1024]; + int max_preview = 64; + live_feed_ctx_t *ctx = (live_feed_ctx_t *)userdata; + + if (!event_json) { + return; + } + + ev = cJSON_Parse(event_json); + if (!ev) { + return; + } + + pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey"); + content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + + if (cJSON_IsString(pubkey_item) && pubkey_item->valuestring) { + pubkey = pubkey_item->valuestring; + } + if (cJSON_IsString(content_item) && content_item->valuestring) { + content = content_item->valuestring; + } + + if (ctx && ctx->term_width > 24) { + max_preview = ctx->term_width - 22; + } + if (max_preview > (int)sizeof(preview) - 1) { + max_preview = (int)sizeof(preview) - 1; + } + + live_preview_text(content, max_preview, preview, sizeof(preview)); + tui_print("%s: %s", live_shorten_hex(pubkey, pub_short, sizeof(pub_short)), preview); + + cJSON_Delete(ev); +} + +static int live_extract_follow_authors(char ***authors_out, int *count_out) { + cJSON *kind3; + cJSON *tags; + int i; + int n; + char **authors = NULL; + int count = 0; + + if (!authors_out || !count_out) { + return -1; + } + + *authors_out = NULL; + *count_out = 0; + + if (!g_state.kind3_json) { + return 0; + } + + kind3 = cJSON_Parse(g_state.kind3_json); + if (!kind3) { + return -1; + } + + tags = cJSON_GetObjectItemCaseSensitive(kind3, "tags"); + if (!cJSON_IsArray(tags)) { + cJSON_Delete(kind3); + return 0; + } + + n = cJSON_GetArraySize(tags); + for (i = 0; i < n; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + cJSON *t0; + cJSON *t1; + char **next; + + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + t0 = cJSON_GetArrayItem(tag, 0); + t1 = cJSON_GetArrayItem(tag, 1); + if (!cJSON_IsString(t0) || !t0->valuestring || strcmp(t0->valuestring, "p") != 0 || + !cJSON_IsString(t1) || !t1->valuestring || strlen(t1->valuestring) != 64U) { + continue; + } + + next = (char **)realloc(authors, (size_t)(count + 1) * sizeof(char *)); + if (!next) { + int k; + for (k = 0; k < count; k++) { + free(authors[k]); + } + free(authors); + cJSON_Delete(kind3); + return -1; + } + authors = next; + authors[count] = live_strdup(t1->valuestring); + if (!authors[count]) { + int k; + for (k = 0; k < count; k++) { + free(authors[k]); + } + free(authors); + cJSON_Delete(kind3); + return -1; + } + count++; + } + + cJSON_Delete(kind3); + *authors_out = authors; + *count_out = count; + return 0; +} + +static void live_free_authors(char **authors, int count) { + int i; + + if (!authors) { + return; + } + for (i = 0; i < count; i++) { + free(authors[i]); + } + free(authors); +} + +static void menu_live_run_filter(const char *title, const char *filter_json) { + const char **read_relays; + int relay_count = 0; + live_feed_ctx_t ctx; + int events_received; + char input[8]; + + read_relays = state_get_read_relays(&relay_count); + tui_clear_screen(); + tui_print("%s\n", title); + tui_print("Press any key to stop..."); + + memset(&ctx, 0, sizeof(ctx)); + tui_get_terminal_size(&ctx.term_width, NULL); + + events_received = nt_live_feed(filter_json, + read_relays, + relay_count, + live_event_print_cb, + &ctx); + + tui_print(""); + if (events_received < 0) { + tui_print("Feed stopped (failed to connect to all relays)."); + } else { + tui_print("Feed stopped. %d events received.", events_received); + } + tui_get_line(">", input, (int)sizeof(input)); +} + +void menu_live(void) { + char input[16]; + + while (1) { + tui_clear_screen(); + tui_print("LIVE FEEDS\n"); + tui_print("1 - Follows feed"); + tui_print("2 - Mentions"); + tui_print("3 - Firehose"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + char **authors = NULL; + int authors_count = 0; + cJSON *filter_arr = NULL; + cJSON *filter = NULL; + cJSON *authors_json = NULL; + cJSON *kinds_json = NULL; + char *filter_str = NULL; + int i; + + if (live_extract_follow_authors(&authors, &authors_count) != 0 || authors_count <= 0) { + tui_print("No follows found in kind3 list."); + tui_get_line(">", input, (int)sizeof(input)); + live_free_authors(authors, authors_count); + continue; + } + + filter_arr = cJSON_CreateArray(); + filter = cJSON_CreateObject(); + authors_json = cJSON_CreateArray(); + kinds_json = cJSON_CreateArray(); + if (!filter_arr || !filter || !authors_json || !kinds_json) { + cJSON_Delete(filter_arr); + cJSON_Delete(filter); + cJSON_Delete(authors_json); + cJSON_Delete(kinds_json); + live_free_authors(authors, authors_count); + tui_print("Failed to build follows filter."); + tui_get_line(">", input, (int)sizeof(input)); + continue; + } + + for (i = 0; i < authors_count; i++) { + cJSON_AddItemToArray(authors_json, cJSON_CreateString(authors[i])); + } + cJSON_AddItemToArray(kinds_json, cJSON_CreateNumber(1)); + cJSON_AddItemToObject(filter, "authors", authors_json); + cJSON_AddItemToObject(filter, "kinds", kinds_json); + cJSON_AddItemToArray(filter_arr, filter); + + filter_str = cJSON_PrintUnformatted(filter_arr); + if (filter_str) { + menu_live_run_filter("FOLLOWS FEED", filter_str); + free(filter_str); + } + + cJSON_Delete(filter_arr); + live_free_authors(authors, authors_count); + } else if (input[0] == '2') { + char filter[512]; + snprintf(filter, + sizeof(filter), + "[{\"kinds\":[1],\"#p\":[\"%s\"],\"limit\":500}]", + g_state.npub_hex); + menu_live_run_filter("MENTIONS", filter); + } else if (input[0] == '3') { + char filter[128]; + snprintf(filter, sizeof(filter), "[{\"kinds\":[1],\"limit\":500}]"); + menu_live_run_filter("FIREHOSE", filter); + } + } +} diff --git a/src/menu_notifications.c b/src/menu_notifications.c new file mode 100644 index 0000000..8d81e2d --- /dev/null +++ b/src/menu_notifications.c @@ -0,0 +1,309 @@ +#include "tui.h" +#include "state.h" +#include "net.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" + +#include +#include +#include +#include + +typedef struct { + int kind; + long long created_at; + char *pubkey; + char *event_ref; + char *content; +} notif_item_t; + +static char *notif_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + memcpy(out, s, n + 1U); + return out; +} + +static void notif_free_items(notif_item_t *items, int count) { + int i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i].pubkey); + free(items[i].event_ref); + free(items[i].content); + } + + free(items); +} + +static const char *notif_shorten_hex(const char *hex, char *buf, size_t buf_size) { + size_t n; + + if (!buf || buf_size == 0U) { + return ""; + } + + buf[0] = '\0'; + if (!hex || hex[0] == '\0') { + return "(unknown)"; + } + + n = strlen(hex); + if (n <= 16U) { + snprintf(buf, buf_size, "%s", hex); + return buf; + } + + snprintf(buf, buf_size, "%.8s..%.8s", hex, hex + (n - 8U)); + return buf; +} + +static const char *notif_preview_text(const char *text, char *buf, size_t buf_size) { + size_t i; + size_t j = 0; + + if (!buf || buf_size == 0U) { + return ""; + } + + buf[0] = '\0'; + if (!text || text[0] == '\0') { + snprintf(buf, buf_size, "(no content)"); + return buf; + } + + for (i = 0; text[i] != '\0' && j + 1U < buf_size; i++) { + char ch = text[i]; + if (ch == '\n' || ch == '\r' || ch == '\t') { + ch = ' '; + } + buf[j++] = ch; + if (j >= 120U) { + break; + } + } + buf[j] = '\0'; + + if (text[i] != '\0' && j + 4U < buf_size) { + buf[j++] = '.'; + buf[j++] = '.'; + buf[j++] = '.'; + buf[j] = '\0'; + } + + return buf; +} + +static int notif_push_item(notif_item_t **items, int *count, notif_item_t *src) { + notif_item_t *next; + + if (!items || !count || !src) { + return -1; + } + + next = (notif_item_t *)realloc(*items, (size_t)(*count + 1) * sizeof(notif_item_t)); + if (!next) { + return -1; + } + + *items = next; + (*items)[*count] = *src; + (*count)++; + return 0; +} + +static char *notif_extract_e_tag_ref(cJSON *tags) { + int i; + int n; + + if (!cJSON_IsArray(tags)) { + return NULL; + } + + n = cJSON_GetArraySize(tags); + for (i = 0; i < n; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + cJSON *t0; + cJSON *t1; + + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + t0 = cJSON_GetArrayItem(tag, 0); + t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && t0->valuestring && strcmp(t0->valuestring, "e") == 0 && + cJSON_IsString(t1) && t1->valuestring && t1->valuestring[0] != '\0') { + return notif_strdup(t1->valuestring); + } + } + + return NULL; +} + +static int notif_cmp_desc_created(const void *a, const void *b) { + const notif_item_t *ia = (const notif_item_t *)a; + const notif_item_t *ib = (const notif_item_t *)b; + + if (ia->created_at < ib->created_at) { + return 1; + } + if (ia->created_at > ib->created_at) { + return -1; + } + return 0; +} + +void menu_notifications(void) { + const char **read_relays; + int relay_count = 0; + time_t now; + long long since; + char filter[512]; + char **events = NULL; + int event_count = 0; + notif_item_t *items = NULL; + int items_count = 0; + int reactions = 0; + int replies = 0; + int i; + char input[32]; + + read_relays = state_get_read_relays(&relay_count); + + now = time(NULL); + since = (long long)now - (3LL * 24LL * 60LL * 60LL); + + snprintf(filter, + sizeof(filter), + "{\"#p\":[\"%s\"],\"since\":%lld,\"kinds\":[1,7],\"limit\":300}", + g_state.npub_hex, + since); + + tui_clear_screen(); + tui_print("NOTIFICATIONS\n"); + tui_print("Querying %d read relay(s)...", relay_count); + + if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) { + tui_print("Failed to query notifications (all relays failed)."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + for (i = 0; i < event_count; i++) { + cJSON *ev = cJSON_Parse(events[i]); + cJSON *kind_item; + cJSON *created_item; + cJSON *pubkey_item; + cJSON *content_item; + cJSON *tags_item; + notif_item_t it; + + if (!ev) { + continue; + } + + kind_item = cJSON_GetObjectItemCaseSensitive(ev, "kind"); + created_item = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey"); + content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + tags_item = cJSON_GetObjectItemCaseSensitive(ev, "tags"); + + if (!cJSON_IsNumber(kind_item) || !cJSON_IsNumber(created_item) || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) { + cJSON_Delete(ev); + continue; + } + + memset(&it, 0, sizeof(it)); + it.kind = (int)kind_item->valuedouble; + it.created_at = (long long)created_item->valuedouble; + it.pubkey = notif_strdup(pubkey_item->valuestring); + it.content = (cJSON_IsString(content_item) && content_item->valuestring) + ? notif_strdup(content_item->valuestring) + : notif_strdup(""); + it.event_ref = notif_extract_e_tag_ref(tags_item); + + if (!it.pubkey || !it.content) { + free(it.pubkey); + free(it.content); + free(it.event_ref); + cJSON_Delete(ev); + continue; + } + + if (it.kind == 7) { + reactions++; + } else if (it.kind == 1) { + replies++; + } + + if (it.kind == 7 || it.kind == 1) { + if (notif_push_item(&items, &items_count, &it) != 0) { + free(it.pubkey); + free(it.content); + free(it.event_ref); + } + } else { + free(it.pubkey); + free(it.content); + free(it.event_ref); + } + + cJSON_Delete(ev); + } + + if (items_count > 1) { + qsort(items, (size_t)items_count, sizeof(notif_item_t), notif_cmp_desc_created); + } + + tui_clear_screen(); + tui_print("NOTIFICATIONS\n"); + tui_print("%d reactions, %d replies in last 3 days", reactions, replies); + tui_print(""); + + for (i = 0; i < items_count; i++) { + char pub_short[32]; + + if (items[i].kind == 7) { + char ev_short[32]; + tui_print("♥ %s reacted to %s", + notif_shorten_hex(items[i].pubkey, pub_short, sizeof(pub_short)), + notif_shorten_hex(items[i].event_ref ? items[i].event_ref : "(unknown)", + ev_short, + sizeof(ev_short))); + } else if (items[i].kind == 1) { + char preview[160]; + tui_print("↩ %s replied: %s", + notif_shorten_hex(items[i].pubkey, pub_short, sizeof(pub_short)), + notif_preview_text(items[i].content, preview, sizeof(preview))); + } + } + + if (items_count == 0) { + tui_print("No notifications found."); + } + + for (i = 0; i < event_count; i++) { + free(events[i]); + } + free(events); + + notif_free_items(items, items_count); + + tui_get_line(">", input, (int)sizeof(input)); +} diff --git a/src/menu_posts.c b/src/menu_posts.c new file mode 100644 index 0000000..7b9fae2 --- /dev/null +++ b/src/menu_posts.c @@ -0,0 +1,594 @@ +#include "tui.h" +#include "state.h" +#include "net.h" +#include "publish.h" +#include "editor.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip004.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" + +#include +#include +#include +#include + +typedef struct { + char *event_json; + char *id; + int kind; + long long created_at; + char *pubkey; + char *content; + cJSON *tags; + char *title; + char *dtag; +} post_item_t; + +static char *posts_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + memcpy(out, s, n + 1U); + return out; +} + +static void posts_free_items(post_item_t *items, int count) { + int i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i].event_json); + free(items[i].id); + free(items[i].pubkey); + free(items[i].content); + cJSON_Delete(items[i].tags); + free(items[i].title); + free(items[i].dtag); + } + + free(items); +} + +static int posts_cmp_desc_created(const void *a, const void *b) { + const post_item_t *ia = (const post_item_t *)a; + const post_item_t *ib = (const post_item_t *)b; + + if (ia->created_at < ib->created_at) { + return 1; + } + if (ia->created_at > ib->created_at) { + return -1; + } + return 0; +} + +static char *posts_find_tag_value(cJSON *tags, const char *tag_name) { + int i; + int n; + + if (!cJSON_IsArray(tags) || !tag_name) { + return NULL; + } + + n = cJSON_GetArraySize(tags); + for (i = 0; i < n; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + cJSON *t0; + cJSON *t1; + + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + t0 = cJSON_GetArrayItem(tag, 0); + t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && t0->valuestring && strcmp(t0->valuestring, tag_name) == 0 && + cJSON_IsString(t1) && t1->valuestring) { + return posts_strdup(t1->valuestring); + } + } + + return NULL; +} + +static char *posts_try_decrypt_nip04(const char *sender_pub_hex, const char *ciphertext) { + unsigned char priv[32]; + unsigned char sender_pub[32]; + size_t out_sz; + char *out; + + if (!sender_pub_hex || !ciphertext) { + return NULL; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return NULL; + } + if (nostr_hex_to_bytes(sender_pub_hex, sender_pub, 32) != 0) { + return NULL; + } + + out_sz = strlen(ciphertext) + 1024U; + out = (char *)malloc(out_sz); + if (!out) { + return NULL; + } + + if (nostr_nip04_decrypt(priv, sender_pub, ciphertext, out, out_sz) != 0) { + free(out); + return NULL; + } + + return out; +} + +static int posts_encrypt_nip04_for_self(const char *plaintext, char **cipher_out) { + unsigned char priv[32]; + unsigned char pub[32]; + size_t out_sz; + char *out; + + if (!plaintext || !cipher_out) { + return -1; + } + + *cipher_out = NULL; + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return -1; + } + if (nostr_hex_to_bytes(g_state.npub_hex, pub, 32) != 0) { + return -1; + } + + out_sz = (strlen(plaintext) * 4U) + 256U; + out = (char *)malloc(out_sz); + if (!out) { + return -1; + } + + if (nostr_nip04_encrypt(priv, pub, plaintext, out, out_sz) != 0) { + free(out); + return -1; + } + + *cipher_out = out; + return 0; +} + +static int posts_push_item(post_item_t **items, int *count, post_item_t *src) { + post_item_t *next; + + if (!items || !count || !src) { + return -1; + } + + next = (post_item_t *)realloc(*items, (size_t)(*count + 1) * sizeof(post_item_t)); + if (!next) { + return -1; + } + + *items = next; + (*items)[*count] = *src; + (*count)++; + return 0; +} + +static void posts_format_date(long long created_at, char *buf, size_t buf_sz) { + time_t t; + struct tm tmv; + + if (!buf || buf_sz == 0U) { + return; + } + + if (created_at <= 0) { + snprintf(buf, buf_sz, "(no date)"); + return; + } + + t = (time_t)created_at; + memset(&tmv, 0, sizeof(tmv)); + if (!localtime_r(&t, &tmv)) { + snprintf(buf, buf_sz, "%lld", created_at); + return; + } + + if (strftime(buf, buf_sz, "%Y-%m-%d %H:%M", &tmv) == 0) { + snprintf(buf, buf_sz, "%lld", created_at); + } +} + +static int posts_build_list(const char *filter_json, post_item_t **items_out, int *count_out) { + const char **read_relays; + int relay_count = 0; + char **events = NULL; + int event_count = 0; + post_item_t *items = NULL; + int items_count = 0; + int i; + + if (!filter_json || !items_out || !count_out) { + return -1; + } + + *items_out = NULL; + *count_out = 0; + + read_relays = state_get_read_relays(&relay_count); + if (nt_query_sync(filter_json, read_relays, relay_count, 7000, &events, &event_count) != 0) { + return -1; + } + + for (i = 0; i < event_count; i++) { + cJSON *ev = cJSON_Parse(events[i]); + cJSON *id_item; + cJSON *kind_item; + cJSON *created_item; + cJSON *pubkey_item; + cJSON *content_item; + cJSON *tags_item; + post_item_t it; + + if (!ev) { + continue; + } + + memset(&it, 0, sizeof(it)); + + id_item = cJSON_GetObjectItemCaseSensitive(ev, "id"); + kind_item = cJSON_GetObjectItemCaseSensitive(ev, "kind"); + created_item = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey"); + content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + tags_item = cJSON_GetObjectItemCaseSensitive(ev, "tags"); + + if (!cJSON_IsString(id_item) || !id_item->valuestring || + !cJSON_IsNumber(kind_item) || + !cJSON_IsNumber(created_item) || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) { + cJSON_Delete(ev); + continue; + } + + it.event_json = posts_strdup(events[i]); + it.id = posts_strdup(id_item->valuestring); + it.kind = (int)kind_item->valuedouble; + it.created_at = (long long)created_item->valuedouble; + it.pubkey = posts_strdup(pubkey_item->valuestring); + it.content = (cJSON_IsString(content_item) && content_item->valuestring) + ? posts_strdup(content_item->valuestring) + : posts_strdup(""); + it.tags = cJSON_IsArray(tags_item) ? cJSON_Duplicate(tags_item, 1) : cJSON_CreateArray(); + it.dtag = posts_find_tag_value(it.tags, "d"); + it.title = posts_find_tag_value(it.tags, "title"); + + if (!it.event_json || !it.id || !it.pubkey || !it.content || !it.tags) { + free(it.event_json); + free(it.id); + free(it.pubkey); + free(it.content); + cJSON_Delete(it.tags); + free(it.title); + free(it.dtag); + cJSON_Delete(ev); + continue; + } + + if (posts_push_item(&items, &items_count, &it) != 0) { + free(it.event_json); + free(it.id); + free(it.pubkey); + free(it.content); + cJSON_Delete(it.tags); + free(it.title); + free(it.dtag); + } + + cJSON_Delete(ev); + } + + for (i = 0; i < event_count; i++) { + free(events[i]); + } + free(events); + + if (items_count > 1) { + qsort(items, (size_t)items_count, sizeof(post_item_t), posts_cmp_desc_created); + } + + *items_out = items; + *count_out = items_count; + return 0; +} + +static const char *posts_kind_label(int kind) { + if (kind == 30023) { + return "public"; + } + if (kind == 30024) { + return "private"; + } + if (kind == 30078) { + return "encrypted"; + } + return "post"; +} + +static void posts_view_item(const post_item_t *it) { + char date_buf[64]; + char input[16]; + char *decrypted = NULL; + + if (!it) { + return; + } + + posts_format_date(it->created_at, date_buf, sizeof(date_buf)); + + if (it->kind == 30024 || it->kind == 30078) { + decrypted = posts_try_decrypt_nip04(it->pubkey, it->content); + } + + tui_clear_screen(); + tui_print("POST VIEW\n"); + tui_print("id: %s", it->id); + tui_print("kind: %d (%s)", it->kind, posts_kind_label(it->kind)); + tui_print("date: %s", date_buf); + tui_print("d: %s", (it->dtag && it->dtag[0] != '\0') ? it->dtag : ""); + tui_print("title: %s", (it->title && it->title[0] != '\0') ? it->title : ""); + tui_print(""); + + if (decrypted) { + tui_print("%s", decrypted); + } else if (it->kind == 30024 || it->kind == 30078) { + tui_print("(decrypt failed; showing raw content)"); + tui_print("%s", it->content); + } else { + tui_print("%s", it->content); + } + + free(decrypted); + tui_get_line(">", input, (int)sizeof(input)); +} + +static void posts_delete_item(const post_item_t *it) { + cJSON *tags; + cJSON *e_tag; + char input[16]; + int posted; + + if (!it || !it->id) { + return; + } + + tags = cJSON_CreateArray(); + if (!tags) { + return; + } + + e_tag = cJSON_CreateArray(); + if (!e_tag) { + cJSON_Delete(tags); + return; + } + + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(it->id)); + cJSON_AddItemToArray(tags, e_tag); + + posted = publish_signed_event_with_report("Delete", 5, "", tags, 8000); + cJSON_Delete(tags); + + if (posted < 0) { + tui_print("Delete publish failed."); + } else { + tui_print("Deletion published."); + } + + tui_get_line(">", input, (int)sizeof(input)); +} + +static void posts_edit_item(const post_item_t *it) { + char *initial = NULL; + char *edited = NULL; + char *out_content = NULL; + cJSON *tags_copy = NULL; + int posted; + char input[16]; + + if (!it) { + return; + } + + if (it->kind == 30024 || it->kind == 30078) { + initial = posts_try_decrypt_nip04(it->pubkey, it->content); + if (!initial) { + initial = posts_strdup(""); + } + } else { + initial = posts_strdup(it->content ? it->content : ""); + } + + if (!initial) { + tui_print("Failed to prepare editor content."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + edited = editor_launch(initial); + free(initial); + + if (!edited) { + tui_print("Edit canceled."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + if (it->kind == 30024 || it->kind == 30078) { + if (posts_encrypt_nip04_for_self(edited, &out_content) != 0) { + tui_print("Failed to encrypt updated content."); + free(edited); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + } else { + out_content = posts_strdup(edited); + if (!out_content) { + free(edited); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + } + + tags_copy = it->tags ? cJSON_Duplicate(it->tags, 1) : cJSON_CreateArray(); + if (!tags_copy) { + free(edited); + free(out_content); + tui_print("Failed to duplicate tags."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + posted = publish_signed_event_with_report("Edit post", it->kind, out_content, tags_copy, 8000); + cJSON_Delete(tags_copy); + free(edited); + free(out_content); + + if (posted < 0) { + tui_print("Post update failed."); + } else { + tui_print("Post updated."); + } + tui_get_line(">", input, (int)sizeof(input)); +} + +static void posts_kind_menu(const char *title, const char *filter_json) { + post_item_t *items = NULL; + int items_count = 0; + char input[32]; + + tui_clear_screen(); + tui_print("%s\n", title); + + if (posts_build_list(filter_json, &items, &items_count) != 0) { + tui_print("Failed to query posts (all relays failed)."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + while (1) { + int i; + + tui_clear_screen(); + tui_print("%s\n", title); + for (i = 0; i < items_count; i++) { + char date_buf[64]; + const char *label; + + posts_format_date(items[i].created_at, date_buf, sizeof(date_buf)); + label = (items[i].title && items[i].title[0] != '\0') + ? items[i].title + : ((items[i].dtag && items[i].dtag[0] != '\0') ? items[i].dtag : "(untitled)"); + + tui_print("[%2d] (%d/%s) %s %s", + i + 1, + items[i].kind, + posts_kind_label(items[i].kind), + label, + date_buf); + } + + if (items_count == 0) { + tui_print("No posts found."); + } + + tui_print(""); + tui_print("Commands: v (view), e (edit), d (delete), x (back)"); + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if ((input[0] == 'v' || input[0] == 'V' || + input[0] == 'e' || input[0] == 'E' || + input[0] == 'd' || input[0] == 'D') && input[1] != '\0') { + int idx = atoi(input + 1) - 1; + if (idx < 0 || idx >= items_count) { + continue; + } + + if (input[0] == 'v' || input[0] == 'V') { + posts_view_item(&items[idx]); + } else if (input[0] == 'e' || input[0] == 'E') { + posts_edit_item(&items[idx]); + } else if (input[0] == 'd' || input[0] == 'D') { + posts_delete_item(&items[idx]); + break; + } + } + } + + posts_free_items(items, items_count); +} + +void menu_posts(void) { + char input[32]; + char filter[512]; + + while (1) { + tui_clear_screen(); + tui_print("POSTS\n"); + tui_print("1 - Private blog (kind 30024)"); + tui_print("2 - Public blog (kind 30023)"); + tui_print("3 - Encrypted data (kind 30078)"); + tui_print("4 - All posts"); + tui_print("x - Back"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == '1') { + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30024],\"limit\":200}", + g_state.npub_hex); + posts_kind_menu("PRIVATE BLOG", filter); + } else if (input[0] == '2') { + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30023],\"limit\":200}", + g_state.npub_hex); + posts_kind_menu("PUBLIC BLOG", filter); + } else if (input[0] == '3') { + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30078],\"limit\":200}", + g_state.npub_hex); + posts_kind_menu("ENCRYPTED DATA", filter); + } else if (input[0] == '4') { + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30023,30024,30078],\"limit\":300}", + g_state.npub_hex); + posts_kind_menu("ALL POSTS", filter); + } + } +} diff --git a/src/menu_relays.c b/src/menu_relays.c index 172e256..04805ae 100644 --- a/src/menu_relays.c +++ b/src/menu_relays.c @@ -27,6 +27,74 @@ static int relays_publish_kind10002(cJSON *event_obj) { 8000); } +static void relays_print_nip11_summary(const char *nip11_json) { + cJSON *doc; + cJSON *name; + cJSON *description; + cJSON *software; + cJSON *version; + cJSON *pubkey; + cJSON *contact; + cJSON *supported_nips; + cJSON *limitation; + + if (!nip11_json || nip11_json[0] == '\0') { + tui_print("NIP-11: (empty response)"); + return; + } + + doc = cJSON_Parse(nip11_json); + if (!doc || !cJSON_IsObject(doc)) { + tui_print("NIP-11 (raw): %s", nip11_json); + cJSON_Delete(doc); + return; + } + + name = cJSON_GetObjectItemCaseSensitive(doc, "name"); + description = cJSON_GetObjectItemCaseSensitive(doc, "description"); + software = cJSON_GetObjectItemCaseSensitive(doc, "software"); + version = cJSON_GetObjectItemCaseSensitive(doc, "version"); + pubkey = cJSON_GetObjectItemCaseSensitive(doc, "pubkey"); + contact = cJSON_GetObjectItemCaseSensitive(doc, "contact"); + supported_nips = cJSON_GetObjectItemCaseSensitive(doc, "supported_nips"); + limitation = cJSON_GetObjectItemCaseSensitive(doc, "limitation"); + + tui_print("NIP-11 relay profile:"); + tui_print(" Name: %s", (cJSON_IsString(name) && name->valuestring) ? name->valuestring : "(n/a)"); + tui_print(" Description: %s", (cJSON_IsString(description) && description->valuestring) ? description->valuestring : "(n/a)"); + tui_print(" Software: %s", (cJSON_IsString(software) && software->valuestring) ? software->valuestring : "(n/a)"); + tui_print(" Version: %s", (cJSON_IsString(version) && version->valuestring) ? version->valuestring : "(n/a)"); + tui_print(" Contact: %s", (cJSON_IsString(contact) && contact->valuestring) ? contact->valuestring : "(n/a)"); + tui_print(" Pubkey: %s", (cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "(n/a)"); + + if (cJSON_IsArray(supported_nips)) { + char *nips_json = cJSON_PrintUnformatted(supported_nips); + if (nips_json) { + tui_print(" Supported NIPs: %s", nips_json); + free(nips_json); + } + } + + if (cJSON_IsObject(limitation)) { + cJSON *max_subscriptions = cJSON_GetObjectItemCaseSensitive(limitation, "max_subscriptions"); + cJSON *max_message_length = cJSON_GetObjectItemCaseSensitive(limitation, "max_message_length"); + cJSON *max_limit = cJSON_GetObjectItemCaseSensitive(limitation, "max_limit"); + + tui_print(" Limits:"); + if (cJSON_IsNumber(max_subscriptions)) { + tui_print(" max_subscriptions: %d", max_subscriptions->valueint); + } + if (cJSON_IsNumber(max_message_length)) { + tui_print(" max_message_length: %d", max_message_length->valueint); + } + if (cJSON_IsNumber(max_limit)) { + tui_print(" max_limit: %d", max_limit->valueint); + } + } + + cJSON_Delete(doc); +} + void menu_relays(void) { cJSON *working = NULL; cJSON *tags = NULL; @@ -97,7 +165,7 @@ void menu_relays(void) { tui_print("Gathering relay data ..."); if (nt_fetch_nip11(relay_url, &nip11) == 0 && nip11) { - tui_print("NIP-11: %s", nip11); + relays_print_nip11_summary(nip11); free(nip11); } else { tui_print("NIP-11 fetch failed."); @@ -189,7 +257,7 @@ void menu_relays(void) { } tui_get_line(">", input, (int)sizeof(input)); break; - } else if (input[0] == 'x' || input[0] == 'X') { + } else if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { break; } } diff --git a/src/menu_todo.c b/src/menu_todo.c new file mode 100644 index 0000000..17caa2e --- /dev/null +++ b/src/menu_todo.c @@ -0,0 +1,484 @@ +#include "tui.h" +#include "state.h" +#include "net.h" +#include "publish.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip004.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" + +#include +#include +#include + +typedef struct { + char *text; + int done; +} todo_item_t; + +static char *todo_strdup(const char *s) { + size_t n; + char *out; + + if (!s) { + return NULL; + } + + n = strlen(s); + out = (char *)malloc(n + 1U); + if (!out) { + return NULL; + } + + memcpy(out, s, n + 1U); + return out; +} + +static void todo_free_items(todo_item_t *items, int count) { + int i; + + if (!items) { + return; + } + + for (i = 0; i < count; i++) { + free(items[i].text); + } + free(items); +} + +static int todo_push_item(todo_item_t **items, int *count, const char *text, int done) { + todo_item_t *next; + + if (!items || !count || !text) { + return -1; + } + + next = (todo_item_t *)realloc(*items, (size_t)(*count + 1) * sizeof(todo_item_t)); + if (!next) { + return -1; + } + + *items = next; + (*items)[*count].text = todo_strdup(text); + (*items)[*count].done = done ? 1 : 0; + if (!(*items)[*count].text) { + return -1; + } + + (*count)++; + return 0; +} + +static int todo_parse_index_1based(const char *input, int count) { + int idx; + + if (!input || count <= 0) { + return -1; + } + + idx = atoi(input); + if (idx <= 0 || idx > count) { + return -1; + } + + return idx - 1; +} + +static int todo_encrypt_for_self(const char *plaintext, char **cipher_out) { + unsigned char priv[32]; + unsigned char pub[32]; + size_t out_sz; + char *out; + + if (!plaintext || !cipher_out) { + return -1; + } + + *cipher_out = NULL; + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return -1; + } + if (nostr_hex_to_bytes(g_state.npub_hex, pub, 32) != 0) { + return -1; + } + + out_sz = (strlen(plaintext) * 4U) + 256U; + out = (char *)malloc(out_sz); + if (!out) { + return -1; + } + + if (nostr_nip04_encrypt(priv, pub, plaintext, out, out_sz) != 0) { + free(out); + return -1; + } + + *cipher_out = out; + return 0; +} + +static char *todo_decrypt_from_pub(const char *sender_pub_hex, const char *ciphertext) { + unsigned char priv[32]; + unsigned char sender_pub[32]; + size_t out_sz; + char *out; + + if (!sender_pub_hex || !ciphertext) { + return NULL; + } + + if (nostr_hex_to_bytes(g_state.nsec_hex, priv, 32) != 0) { + return NULL; + } + if (nostr_hex_to_bytes(sender_pub_hex, sender_pub, 32) != 0) { + return NULL; + } + + out_sz = strlen(ciphertext) + 2048U; + out = (char *)malloc(out_sz); + if (!out) { + return NULL; + } + + if (nostr_nip04_decrypt(priv, sender_pub, ciphertext, out, out_sz) != 0) { + free(out); + return NULL; + } + + return out; +} + +static cJSON *todo_make_d_tag(void) { + cJSON *tags = cJSON_CreateArray(); + cJSON *d_tag; + + if (!tags) { + return NULL; + } + + d_tag = cJSON_CreateArray(); + if (!d_tag) { + cJSON_Delete(tags); + return NULL; + } + + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString("todo")); + cJSON_AddItemToArray(tags, d_tag); + return tags; +} + +static char *todo_items_to_json(const todo_item_t *items, int count) { + cJSON *arr = cJSON_CreateArray(); + char *out; + int i; + + if (!arr) { + return NULL; + } + + for (i = 0; i < count; i++) { + cJSON *obj = cJSON_CreateObject(); + if (!obj) { + cJSON_Delete(arr); + return NULL; + } + cJSON_AddStringToObject(obj, "text", items[i].text ? items[i].text : ""); + cJSON_AddBoolToObject(obj, "done", items[i].done ? 1 : 0); + cJSON_AddItemToArray(arr, obj); + } + + out = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return out; +} + +static int todo_items_from_json(const char *json, todo_item_t **items_out, int *count_out) { + cJSON *arr; + int i; + int n; + todo_item_t *items = NULL; + int count = 0; + + if (!items_out || !count_out) { + return -1; + } + + *items_out = NULL; + *count_out = 0; + + if (!json || json[0] == '\0') { + return 0; + } + + arr = cJSON_Parse(json); + if (!arr || !cJSON_IsArray(arr)) { + cJSON_Delete(arr); + return -1; + } + + n = cJSON_GetArraySize(arr); + for (i = 0; i < n; i++) { + cJSON *obj = cJSON_GetArrayItem(arr, i); + cJSON *text; + cJSON *done; + + if (!cJSON_IsObject(obj)) { + continue; + } + + text = cJSON_GetObjectItemCaseSensitive(obj, "text"); + done = cJSON_GetObjectItemCaseSensitive(obj, "done"); + if (!cJSON_IsString(text) || !text->valuestring) { + continue; + } + + if (todo_push_item(&items, &count, text->valuestring, cJSON_IsBool(done) && cJSON_IsTrue(done)) != 0) { + todo_free_items(items, count); + cJSON_Delete(arr); + return -1; + } + } + + cJSON_Delete(arr); + *items_out = items; + *count_out = count; + return 0; +} + +static int todo_load_remote(todo_item_t **items_out, int *count_out) { + char filter[256]; + const char **read_relays; + int relay_count = 0; + char *event_json = NULL; + cJSON *event; + cJSON *content_item; + cJSON *pubkey_item; + char *plain = NULL; + int rc; + + if (!items_out || !count_out) { + return -1; + } + + *items_out = NULL; + *count_out = 0; + + read_relays = state_get_read_relays(&relay_count); + snprintf(filter, + sizeof(filter), + "{\"authors\":[\"%s\"],\"kinds\":[30078],\"#d\":[\"todo\"],\"limit\":1}", + g_state.npub_hex); + + if (nt_get_first(filter, read_relays, relay_count, 7000, &event_json) != 0) { + return -1; + } + + if (!event_json) { + return 0; + } + + event = cJSON_Parse(event_json); + free(event_json); + if (!event) { + return -1; + } + + content_item = cJSON_GetObjectItemCaseSensitive(event, "content"); + pubkey_item = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); + if (!cJSON_IsString(content_item) || !content_item->valuestring || + !cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) { + cJSON_Delete(event); + return -1; + } + + plain = todo_decrypt_from_pub(pubkey_item->valuestring, content_item->valuestring); + cJSON_Delete(event); + if (!plain) { + return -1; + } + + rc = todo_items_from_json(plain, items_out, count_out); + free(plain); + return rc; +} + +static void todo_show_list(const todo_item_t *items, int count) { + int i; + + tui_print("TODO LIST\n"); + for (i = 0; i < count; i++) { + tui_print("%2d. [%c] %s", i + 1, items[i].done ? 'x' : ' ', items[i].text ? items[i].text : ""); + } + if (count == 0) { + tui_print("(empty)"); + } + tui_print(""); +} + +static void todo_add_item(todo_item_t **items, int *count) { + char input[1024]; + + tui_get_line("new item >", input, (int)sizeof(input)); + if (input[0] == '\0') { + return; + } + + if (todo_push_item(items, count, input, 0) != 0) { + tui_print("Failed to add item."); + } +} + +static void todo_toggle_item(todo_item_t *items, int count) { + char input[64]; + int idx; + + if (!items || count <= 0) { + return; + } + + tui_get_line("toggle number >", input, (int)sizeof(input)); + idx = todo_parse_index_1based(input, count); + if (idx < 0) { + tui_print("Invalid number."); + return; + } + + items[idx].done = !items[idx].done; +} + +static void todo_delete_item(todo_item_t *items, int *count) { + char input[64]; + int idx; + int i; + + if (!items || !count || *count <= 0) { + return; + } + + tui_get_line("delete number >", input, (int)sizeof(input)); + idx = todo_parse_index_1based(input, *count); + if (idx < 0) { + tui_print("Invalid number."); + return; + } + + free(items[idx].text); + for (i = idx; i < (*count - 1); i++) { + items[i] = items[i + 1]; + } + (*count)--; +} + +static void todo_reorder_swap(todo_item_t *items, int count) { + char input_a[64]; + char input_b[64]; + int a; + int b; + todo_item_t tmp; + + if (!items || count < 2) { + return; + } + + tui_get_line("first number >", input_a, (int)sizeof(input_a)); + tui_get_line("second number >", input_b, (int)sizeof(input_b)); + + a = todo_parse_index_1based(input_a, count); + b = todo_parse_index_1based(input_b, count); + if (a < 0 || b < 0 || a == b) { + tui_print("Invalid numbers."); + return; + } + + tmp = items[a]; + items[a] = items[b]; + items[b] = tmp; +} + +static int todo_publish(const todo_item_t *items, int count) { + char *json = NULL; + char *cipher = NULL; + cJSON *tags = NULL; + int posted; + + json = todo_items_to_json(items, count); + if (!json) { + tui_print("Failed to serialize todo list."); + return -1; + } + + if (todo_encrypt_for_self(json, &cipher) != 0) { + tui_print("Failed to encrypt todo list."); + free(json); + return -1; + } + + tags = todo_make_d_tag(); + if (!tags) { + tui_print("Failed to build todo tags."); + free(json); + free(cipher); + return -1; + } + + posted = publish_signed_event_with_report("Todo", 30078, cipher, tags, 8000); + cJSON_Delete(tags); + free(json); + free(cipher); + + if (posted < 0) { + tui_print("Todo publish failed."); + return -1; + } + + tui_print("Todo saved."); + return 0; +} + +void menu_todo(void) { + todo_item_t *items = NULL; + int count = 0; + char input[32]; + + if (todo_load_remote(&items, &count) != 0) { + tui_print("Failed to load remote todo list; starting empty."); + items = NULL; + count = 0; + } + + while (1) { + tui_clear_screen(); + todo_show_list(items, count); + tui_print("a - Add item"); + tui_print("c - Complete/toggle item"); + tui_print("d - Delete item"); + tui_print("r - Reorder (swap two)"); + tui_print("p - Post/save changes"); + tui_print("x - Exit without saving"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') { + break; + } + + if (input[0] == 'a' || input[0] == 'A') { + todo_add_item(&items, &count); + } else if (input[0] == 'c' || input[0] == 'C') { + todo_toggle_item(items, count); + } else if (input[0] == 'd' || input[0] == 'D') { + todo_delete_item(items, &count); + } else if (input[0] == 'r' || input[0] == 'R') { + todo_reorder_swap(items, count); + } else if (input[0] == 'p' || input[0] == 'P') { + char hold[8]; + (void)todo_publish(items, count); + tui_get_line(">", hold, (int)sizeof(hold)); + } + } + + todo_free_items(items, count); +} diff --git a/src/menu_write.c b/src/menu_write.c new file mode 100644 index 0000000..03d2573 --- /dev/null +++ b/src/menu_write.c @@ -0,0 +1,131 @@ +#include "tui.h" +#include "publish.h" +#include "editor.h" + +#include "../resources/nostr_core_lib/cjson/cJSON.h" + +#include +#include +#include +#include + +static cJSON *write_make_blog_tags(const char *d_tag_value, const char *title_opt) { + cJSON *tags = cJSON_CreateArray(); + cJSON *d_tag; + + if (!tags) { + return NULL; + } + + d_tag = cJSON_CreateArray(); + if (!d_tag) { + cJSON_Delete(tags); + return NULL; + } + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_value ? d_tag_value : "")); + cJSON_AddItemToArray(tags, d_tag); + + if (title_opt && title_opt[0] != '\0') { + cJSON *title_tag = cJSON_CreateArray(); + if (title_tag) { + cJSON_AddItemToArray(title_tag, cJSON_CreateString("title")); + cJSON_AddItemToArray(title_tag, cJSON_CreateString(title_opt)); + cJSON_AddItemToArray(tags, title_tag); + } + } + + return tags; +} + +void menu_write(void) { + char *content; + char input[64]; + + tui_clear_screen(); + tui_print("WRITE\n"); + tui_print("Launching external editor..."); + + content = editor_launch(NULL); + if (!content) { + tui_print("No content created."); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + tui_clear_screen(); + tui_print("WRITE RESULT\n"); + tui_print("Content length: %d bytes", (int)strlen(content)); + tui_print("\n"); + tui_print("^_T^:weet it"); + tui_print("^_B^:log post (kind 30023)"); + tui_print("^_D^:iary entry (kind 30024)"); + tui_print("^_X^: discard"); + + tui_get_line(">", input, (int)sizeof(input)); + + if (input[0] == 't' || input[0] == 'T') { + int posted = publish_signed_event_with_report("Tweet", 1, content, NULL, 8000); + if (posted < 0) { + tui_print("Tweet publish failed."); + } + } else if (input[0] == 'b' || input[0] == 'B') { + char title[256]; + char slug[256]; + const char *d_val; + cJSON *tags; + int posted; + + tui_get_line("title >", title, (int)sizeof(title)); + tui_get_line("slug (d tag) [empty uses title] >", slug, (int)sizeof(slug)); + + d_val = (slug[0] != '\0') ? slug : title; + tags = write_make_blog_tags(d_val, title); + if (!tags) { + tui_print("Failed to build blog tags."); + } else { + posted = publish_signed_event_with_report("Blog post", 30023, content, tags, 8000); + cJSON_Delete(tags); + if (posted < 0) { + tui_print("Blog publish failed."); + } + } + } else if (input[0] == 'd' || input[0] == 'D') { + char date_d[16]; + time_t now = time(NULL); + struct tm tmv; + cJSON *tags; + int posted; + + memset(&tmv, 0, sizeof(tmv)); + if (!localtime_r(&now, &tmv)) { + tui_print("Failed to get local date."); + free(content); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + if (strftime(date_d, sizeof(date_d), "%Y%m%d", &tmv) == 0) { + tui_print("Failed to format diary date."); + free(content); + tui_get_line(">", input, (int)sizeof(input)); + return; + } + + tags = write_make_blog_tags(date_d, NULL); + if (!tags) { + tui_print("Failed to build diary tags."); + } else { + posted = publish_signed_event_with_report("Diary", 30024, content, tags, 8000); + cJSON_Delete(tags); + if (posted < 0) { + tui_print("Diary publish failed."); + } + } + } else { + tui_print("Discarded."); + } + + free(content); + tui_get_line(">", input, (int)sizeof(input)); +} diff --git a/src/net.c b/src/net.c index c850f01..ac881a6 100644 --- a/src/net.c +++ b/src/net.c @@ -1,14 +1,21 @@ #include "net.h" #include "db.h" +#include "state.h" #include "../resources/nostr_core_lib/cjson/cJSON.h" +#include "../resources/nostr_core_lib/nostr_core/nip042.h" +#include "../resources/nostr_core_lib/nostr_core/utils.h" #include "../resources/nostr_core_lib/nostr_websocket/nostr_websocket_tls.h" +#include +#include +#include #include #include #include #include +#include #define NT_SUB_ID "sub1" #define NT_WS_BUFFER_SIZE 65536 @@ -152,15 +159,139 @@ static char *nt_ws_to_http_url(const char *relay_url) { return out; } -/* Wait for an OK message for a publish and return whether relay accepted. */ +typedef struct { + char *data; + size_t len; +} nt_http_buffer_t; + +static size_t nt_http_write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { + nt_http_buffer_t *buf = (nt_http_buffer_t *)userdata; + size_t total = size * nmemb; + char *next; + + if (!buf || !ptr || total == 0) { + return 0; + } + + next = (char *)realloc(buf->data, buf->len + total + 1U); + if (!next) { + return 0; + } + + buf->data = next; + memcpy(buf->data + buf->len, ptr, total); + buf->len += total; + buf->data[buf->len] = '\0'; + return total; +} + +static void nt_configure_curl_tls(CURL *curl) { + const char *cafile_env; + const char *capath_env; + static const char *cafile_candidates[] = { + "/etc/ssl/certs/ca-certificates.crt", /* Debian/Ubuntu */ + "/etc/ssl/cert.pem", /* Alpine */ + "/etc/pki/tls/certs/ca-bundle.crt", /* RHEL/CentOS */ + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" /* Fedora */ + }; + int i; + + if (!curl) { + return; + } + + /* Respect explicit user overrides first. */ + cafile_env = getenv("SSL_CERT_FILE"); + if (!cafile_env || cafile_env[0] == '\0') { + cafile_env = getenv("CURL_CA_BUNDLE"); + } + if (cafile_env && cafile_env[0] != '\0') { + curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_env); + return; + } + + capath_env = getenv("SSL_CERT_DIR"); + if (capath_env && capath_env[0] != '\0') { + curl_easy_setopt(curl, CURLOPT_CAPATH, capath_env); + } + + for (i = 0; i < (int)(sizeof(cafile_candidates) / sizeof(cafile_candidates[0])); i++) { + if (access(cafile_candidates[i], R_OK) == 0) { + curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_candidates[i]); + return; + } + } + + /* Last resort: common cert directory if bundle file isn't found. */ + if (access("/etc/ssl/certs", R_OK) == 0) { + curl_easy_setopt(curl, CURLOPT_CAPATH, "/etc/ssl/certs"); + } +} + +static int nt_send_nip42_auth(nostr_ws_client_t *client, + const char *relay_url, + const char *challenge, + char **auth_event_id_out) { + unsigned char private_key[32]; + cJSON *auth_event = NULL; + char *auth_message = NULL; + char *auth_event_id = NULL; + int send_rc; + + if (!client || !relay_url || !challenge || !auth_event_id_out) { + return -1; + } + + *auth_event_id_out = NULL; + + if (nostr_hex_to_bytes(g_state.nsec_hex, private_key, sizeof(private_key)) != 0) { + return -1; + } + + auth_event = nostr_nip42_create_auth_event(challenge, relay_url, private_key, 0); + if (!auth_event) { + return -1; + } + + auth_event_id = nt_extract_event_id(cJSON_PrintUnformatted(auth_event)); + if (!auth_event_id) { + cJSON *id = cJSON_GetObjectItemCaseSensitive(auth_event, "id"); + if (cJSON_IsString(id) && id->valuestring) { + auth_event_id = nt_strdup_local(id->valuestring); + } + } + + auth_message = nostr_nip42_create_auth_message(auth_event); + cJSON_Delete(auth_event); + if (!auth_message) { + free(auth_event_id); + return -1; + } + + send_rc = nostr_ws_send_text(client, auth_message); + free(auth_message); + if (send_rc < 0) { + free(auth_event_id); + return -1; + } + + *auth_event_id_out = auth_event_id; + return 0; +} + +/* Wait for an OK message for publish. Handles one NIP-42 AUTH round-trip if challenged. */ static int nt_wait_publish_ok(nostr_ws_client_t *client, + cJSON *event, + const char *relay_url, int timeout_ms, int *posted_out, char **response_out) { char buffer[NT_WS_BUFFER_SIZE]; long long deadline_ms; + int auth_attempted = 0; + char *auth_event_id = NULL; - if (!client || !posted_out || !response_out) { + if (!client || !event || !relay_url || !posted_out || !response_out) { return -1; } @@ -173,10 +304,10 @@ static int nt_wait_publish_ok(nostr_ws_client_t *client, int recv_len; char *message_type = NULL; cJSON *parsed = NULL; - cJSON *ok_item; recv_len = nostr_ws_receive(client, buffer, sizeof(buffer), nt_ms_remaining(deadline_ms)); if (recv_len <= 0) { + free(auth_event_id); return 0; } @@ -184,10 +315,71 @@ static int nt_wait_publish_ok(nostr_ws_client_t *client, continue; } + if (message_type && strcmp(message_type, "AUTH") == 0) { + char challenge[NOSTR_NIP42_MAX_CHALLENGE_LENGTH]; + + if (auth_attempted) { + free(message_type); + cJSON_Delete(parsed); + continue; + } + + memset(challenge, 0, sizeof(challenge)); + if (nostr_nip42_parse_auth_challenge(buffer, challenge, sizeof(challenge)) != NOSTR_SUCCESS || + challenge[0] == '\0') { + free(message_type); + cJSON_Delete(parsed); + continue; + } + + if (nt_send_nip42_auth(client, relay_url, challenge, &auth_event_id) != 0) { + *response_out = nt_strdup_local("auth_send_failed"); + free(message_type); + cJSON_Delete(parsed); + return 0; + } + + auth_attempted = 1; + free(message_type); + cJSON_Delete(parsed); + continue; + } + if (message_type && strcmp(message_type, "OK") == 0) { - ok_item = cJSON_GetArrayItem(parsed, 2); - *posted_out = cJSON_IsBool(ok_item) && cJSON_IsTrue(ok_item); + cJSON *id_item = cJSON_GetArrayItem(parsed, 1); + cJSON *ok_item = cJSON_GetArrayItem(parsed, 2); + const char *ok_id = (cJSON_IsString(id_item) && id_item->valuestring) ? id_item->valuestring : NULL; + int ok = cJSON_IsBool(ok_item) && cJSON_IsTrue(ok_item); + + if (auth_event_id && ok_id && strcmp(auth_event_id, ok_id) == 0) { + if (!ok) { + *posted_out = 0; + *response_out = nt_strdup_local(buffer); + free(auth_event_id); + free(message_type); + cJSON_Delete(parsed); + return 0; + } + + free(auth_event_id); + auth_event_id = NULL; + + if (nostr_relay_send_event(client, event) != NOSTR_WS_SUCCESS) { + *posted_out = 0; + *response_out = nt_strdup_local("send_failed_after_auth"); + free(message_type); + cJSON_Delete(parsed); + return 0; + } + + free(message_type); + cJSON_Delete(parsed); + continue; + } + + *posted_out = ok; *response_out = nt_strdup_local(buffer); + free(auth_event_id); free(message_type); cJSON_Delete(parsed); return 0; @@ -197,6 +389,7 @@ static int nt_wait_publish_ok(nostr_ws_client_t *client, cJSON_Delete(parsed); } + free(auth_event_id); return 0; } @@ -278,7 +471,7 @@ int nt_publish_detailed(const char *event_json, continue; } - (void)nt_wait_publish_ok(client, timeout_ms, &posted, &response); + (void)nt_wait_publish_ok(client, event, relay, timeout_ms, &posted, &response); if (!response) { response = nt_strdup_local("timeout_or_no_ok"); } @@ -338,6 +531,8 @@ void nt_publish_results_free(nt_publish_result_t *results, int result_count) { static int nt_query_one_relay(const char *relay_url, cJSON *filter, int timeout_ms, + int verbose, + const char *phase_label, char ***events, int *event_count, int *event_cap, @@ -364,6 +559,25 @@ static int nt_query_one_relay(const char *relay_url, (void)nostr_ws_set_timeout(client, timeout_ms > 0 ? timeout_ms : 5000); + if (verbose) { + cJSON *req_msg = cJSON_CreateArray(); + if (req_msg) { + cJSON_AddItemToArray(req_msg, cJSON_CreateString("REQ")); + cJSON_AddItemToArray(req_msg, cJSON_CreateString(NT_SUB_ID)); + cJSON_AddItemToArray(req_msg, cJSON_Duplicate(filter, 1)); + { + char *req_json = cJSON_PrintUnformatted(req_msg); + fprintf(stderr, + "%s: request to %s: %s\n", + phase_label ? phase_label : "query", + relay_url ? relay_url : "(unknown relay)", + req_json ? req_json : "(serialize_failed)"); + free(req_json); + } + cJSON_Delete(req_msg); + } + } + if (nostr_relay_send_req(client, NT_SUB_ID, filter) != NOSTR_WS_SUCCESS) { if (reason_out) { *reason_out = "send_req_failed"; @@ -384,6 +598,14 @@ static int nt_query_one_relay(const char *relay_url, break; } + if (verbose) { + fprintf(stderr, + "%s: response from %s: %s\n", + phase_label ? phase_label : "query", + relay_url ? relay_url : "(unknown relay)", + buffer); + } + if (nostr_parse_relay_message(buffer, &message_type, &parsed) != NOSTR_WS_SUCCESS) { continue; } @@ -393,6 +615,16 @@ static int nt_query_one_relay(const char *relay_url, cJSON *id_item = NULL; if (cJSON_IsObject(event_obj)) { + if (verbose) { + char *received_event = cJSON_PrintUnformatted(event_obj); + fprintf(stderr, + "%s: event from %s: %s\n", + phase_label ? phase_label : "query", + relay_url ? relay_url : "(unknown relay)", + received_event ? received_event : "(serialize_failed)"); + free(received_event); + } + id_item = cJSON_GetObjectItemCaseSensitive(event_obj, "id"); if (cJSON_IsString(id_item) && id_item->valuestring) { if (!nt_seen_id(*seen_ids, *seen_count, id_item->valuestring)) { @@ -491,6 +723,8 @@ int nt_query_sync_verbose(const char *filter_json, rc = nt_query_one_relay(relay, filter, timeout_ms, + verbose, + phase_label, &events, &events_n, &events_cap, @@ -629,14 +863,184 @@ int nt_get_first(const char *filter_json, event_out); } +static int nt_set_stdin_nonblocking(int *old_flags_out) { + int flags; + + if (!old_flags_out) { + return -1; + } + + *old_flags_out = -1; + flags = fcntl(STDIN_FILENO, F_GETFL, 0); + if (flags < 0) { + return -1; + } + + *old_flags_out = flags; + if ((flags & O_NONBLOCK) == 0) { + if (fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK) != 0) { + return -1; + } + } + + return 0; +} + +static void nt_restore_stdin_flags(int old_flags) { + if (old_flags >= 0) { + (void)fcntl(STDIN_FILENO, F_SETFL, old_flags); + } +} + +int nt_live_feed(const char *filter_json, + const char **relay_urls, + int relay_count, + nt_event_callback_t on_event, + void *userdata) { + typedef struct { + nostr_ws_client_t *client; + const char *relay; + } nt_live_relay_t; + + cJSON *filter = NULL; + nt_live_relay_t *relays = NULL; + int connected = 0; + int event_count = 0; + int old_stdin_flags = -1; + int i; + int saw_stdin_key = 0; + + if (!filter_json || !relay_urls || relay_count <= 0 || !on_event) { + return -1; + } + + filter = cJSON_Parse(filter_json); + if (!filter) { + return -1; + } + + relays = (nt_live_relay_t *)calloc((size_t)relay_count, sizeof(nt_live_relay_t)); + if (!relays) { + cJSON_Delete(filter); + return -1; + } + + for (i = 0; i < relay_count; i++) { + nostr_ws_client_t *client; + + relays[i].relay = relay_urls[i]; + if (!relay_urls[i] || relay_urls[i][0] == '\0') { + continue; + } + + client = nostr_ws_connect(relay_urls[i]); + if (!client) { + continue; + } + + (void)nostr_ws_set_timeout(client, 1000); + if (nostr_relay_send_req(client, NT_SUB_ID, filter) != NOSTR_WS_SUCCESS) { + nostr_ws_close(client); + continue; + } + + relays[i].client = client; + connected++; + } + + if (connected == 0) { + free(relays); + cJSON_Delete(filter); + return -1; + } + + (void)nt_set_stdin_nonblocking(&old_stdin_flags); + + while (!saw_stdin_key) { + int active = 0; + + { + unsigned char keybuf[8]; + ssize_t read_rc = read(STDIN_FILENO, keybuf, sizeof(keybuf)); + if (read_rc > 0) { + saw_stdin_key = 1; + break; + } + if (read_rc < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + break; + } + } + + for (i = 0; i < relay_count; i++) { + char buffer[NT_WS_BUFFER_SIZE]; + int recv_len; + char *message_type = NULL; + cJSON *parsed = NULL; + + if (!relays[i].client) { + continue; + } + active++; + + recv_len = nostr_ws_receive(relays[i].client, buffer, sizeof(buffer), 100); + if (recv_len <= 0) { + continue; + } + buffer[sizeof(buffer) - 1U] = '\0'; + + if (nostr_parse_relay_message(buffer, &message_type, &parsed) != NOSTR_WS_SUCCESS) { + free(message_type); + cJSON_Delete(parsed); + continue; + } + + if (message_type && strcmp(message_type, "EVENT") == 0) { + cJSON *event_obj = cJSON_GetArrayItem(parsed, 2); + if (cJSON_IsObject(event_obj)) { + char *event_json = cJSON_PrintUnformatted(event_obj); + if (event_json) { + on_event(event_json, userdata); + event_count++; + free(event_json); + } + } + } + + free(message_type); + cJSON_Delete(parsed); + } + + if (active == 0) { + break; + } + } + + for (i = 0; i < relay_count; i++) { + if (relays[i].client) { + (void)nostr_relay_send_close(relays[i].client, NT_SUB_ID); + nostr_ws_close(relays[i].client); + relays[i].client = NULL; + } + } + + nt_restore_stdin_flags(old_stdin_flags); + free(relays); + cJSON_Delete(filter); + return event_count; +} + /* * Fetch relay NIP-11 information. - * - * For MUSL static builds, nt intentionally avoids libcurl to keep link closure - * small and deterministic. NIP-11 fetch is therefore disabled here. */ int nt_fetch_nip11(const char *relay_url, char **info_json_out) { char *normalized_url; + CURL *curl; + CURLcode rc; + long status = 0; + nt_http_buffer_t body = {0}; + struct curl_slist *headers = NULL; + char curl_err[CURL_ERROR_SIZE] = {0}; + int insecure_retry = 0; if (!relay_url || !info_json_out) { fprintf(stderr, "nt_fetch_nip11: invalid arguments\n"); @@ -651,7 +1055,96 @@ int nt_fetch_nip11(const char *relay_url, char **info_json_out) { return -1; } + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + fprintf(stderr, "nt_fetch_nip11: curl_global_init failed\n"); + free(normalized_url); + return -1; + } + + curl = curl_easy_init(); + if (!curl) { + fprintf(stderr, "nt_fetch_nip11: curl_easy_init failed\n"); + free(normalized_url); + curl_global_cleanup(); + return -1; + } + + headers = curl_slist_append(headers, "Accept: application/nostr+json"); + + curl_easy_setopt(curl, CURLOPT_URL, normalized_url); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-terminal/0.0.1"); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 4000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 8000L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, nt_http_write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&body); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + nt_configure_curl_tls(curl); + if (headers) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curl_err); + +retry_request: + rc = curl_easy_perform(curl); + if (rc != CURLE_OK) { + if (!insecure_retry && + (rc == CURLE_PEER_FAILED_VERIFICATION || rc == CURLE_SSL_CACERT_BADFILE)) { + fprintf(stderr, + "nt_fetch_nip11: TLS validation failed for %s (%s). Retrying insecurely.\n", + normalized_url, + (curl_err[0] != '\0') ? curl_err : curl_easy_strerror(rc)); + insecure_retry = 1; + free(body.data); + body.data = NULL; + body.len = 0; + curl_err[0] = '\0'; + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + goto retry_request; + } + + fprintf(stderr, + "nt_fetch_nip11: request failed for %s: %s\n", + normalized_url, + (curl_err[0] != '\0') ? curl_err : curl_easy_strerror(rc)); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + free(normalized_url); + free(body.data); + return -1; + } + + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + if (status < 200 || status >= 300) { + fprintf(stderr, "nt_fetch_nip11: HTTP %ld from %s\n", status, normalized_url); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + free(normalized_url); + free(body.data); + return -1; + } + + if (!body.data || body.len == 0) { + fprintf(stderr, "nt_fetch_nip11: empty response from %s\n", normalized_url); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); + free(normalized_url); + free(body.data); + return -1; + } + + *info_json_out = body.data; + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + curl_global_cleanup(); free(normalized_url); - fprintf(stderr, "nt_fetch_nip11: disabled (no HTTP client linked in static build)\n"); - return -1; + return 0; } diff --git a/src/publish.c b/src/publish.c index 1ccec11..89cbd03 100644 --- a/src/publish.c +++ b/src/publish.c @@ -62,6 +62,8 @@ int publish_signed_event_with_report(const char *label, write_relays = state_get_write_relays(&relay_count); tui_print("%s (kind %d)", label, kind); + tui_print("Event JSON:"); + tui_print("%s", event_json); tui_print("Posting to %d relay(s)...", relay_count); accepted_count = nt_publish_detailed(event_json, diff --git a/src/tui.c b/src/tui.c index 5a1e27d..69fec09 100644 --- a/src/tui.c +++ b/src/tui.c @@ -13,7 +13,7 @@ static struct termios g_original_termios; static bool g_termios_saved = false; static bool g_raw_enabled = false; -/* Write a string while converting ^_...^: segments to bold+underline. */ +/* Write a string while converting ^_...^: segments to underline. */ static void tui_write_with_hotkey_markup(const char *s) { bool in_hotkey = false; @@ -23,7 +23,7 @@ static void tui_write_with_hotkey_markup(const char *s) { for (size_t i = 0; s[i] != '\0';) { if (!in_hotkey && s[i] == '^' && s[i + 1] == '_') { - fputs("\x1b[1;4m", stdout); /* bold + underline */ + fputs("\x1b[4m", stdout); /* underline */ in_hotkey = true; i += 2; continue;