From 058467771e9ee05a42654f668dc4dcef09a17606 Mon Sep 17 00:00:00 2001
From: pablo
Date: Mon, 22 Dec 2025 14:14:37 +0200
Subject: [PATCH] feat: libchat C library building and example TUI using it
---
.gitignore | 2 +
.gitmodules | 5 +
Makefile | 22 +
config.nims | 7 +
examples/cbindings/.clang-format | 6 +
examples/cbindings/Makefile | 39 ++
examples/cbindings/README.md | 59 ++
examples/cbindings/cbindings_chat_tui.c | 738 ++++++++++++++++++++++++
library/api/client_api.nim | 156 +++++
library/api/conversation_api.nim | 78 +++
library/api/identity_api.nim | 54 ++
library/declare_lib.nim | 13 +
library/libchat.h | 107 ++++
library/libchat.nim | 98 ++++
library/nim.cfg | 1 +
library/utils.nim | 114 ++++
nim_chat_poc.nimble | 27 +-
17 files changed, 1524 insertions(+), 2 deletions(-)
create mode 100644 config.nims
create mode 100644 examples/cbindings/.clang-format
create mode 100644 examples/cbindings/Makefile
create mode 100644 examples/cbindings/README.md
create mode 100644 examples/cbindings/cbindings_chat_tui.c
create mode 100644 library/api/client_api.nim
create mode 100644 library/api/conversation_api.nim
create mode 100644 library/api/identity_api.nim
create mode 100644 library/declare_lib.nim
create mode 100644 library/libchat.h
create mode 100644 library/libchat.nim
create mode 100644 library/nim.cfg
create mode 100644 library/utils.nim
diff --git a/.gitignore b/.gitignore
index 5a3358e..7fe05ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -91,3 +91,5 @@ result
# App data
.registry/
.savedkeys/
+
+AGENTS.md
diff --git a/.gitmodules b/.gitmodules
index 44e4b4a..f1cc4cf 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -41,3 +41,8 @@
url = https://github.com/mratsim/constantine.git
ignore = untracked
branch = master
+[submodule "vendor/nim-ffi"]
+ path = vendor/nim-ffi
+ url = https://github.com/logos-messaging/nim-ffi/
+ ignore = untracked
+ branch = master
diff --git a/Makefile b/Makefile
index 3ba4ebe..2a13940 100644
--- a/Makefile
+++ b/Makefile
@@ -87,6 +87,28 @@ tui bot_echo pingpong: | build-waku-librln build-waku-nat nim_chat_poc.nims
echo -e $(BUILD_MSG) "build/$@" && \
$(ENV_SCRIPT) nim $@ $(NIM_PARAMS) --path:src nim_chat_poc.nims
+###########
+## Library ##
+###########
+
+# Determine shared library extension based on OS
+ifeq ($(shell uname -s),Darwin)
+ LIBCHAT_EXT := dylib
+else ifeq ($(shell uname -s),Linux)
+ LIBCHAT_EXT := so
+else
+ LIBCHAT_EXT := dll
+endif
+
+LIBCHAT := build/libchat.$(LIBCHAT_EXT)
+
+.PHONY: libchat
+libchat: | build-waku-librln build-waku-nat nim_chat_poc.nims
+ echo -e $(BUILD_MSG) "$(LIBCHAT)" && \
+ $(ENV_SCRIPT) nim libchat $(NIM_PARAMS) --path:src nim_chat_poc.nims && \
+ echo -e "\n\x1B[92mLibrary built successfully:\x1B[39m" && \
+ echo " $(shell pwd)/$(LIBCHAT)"
+
endif
diff --git a/config.nims b/config.nims
new file mode 100644
index 0000000..27a5d0b
--- /dev/null
+++ b/config.nims
@@ -0,0 +1,7 @@
+import std/os
+
+# all vendor subdirectories
+for dir in walkDir(thisDir() / "vendor"):
+ if dir.kind == pcDir:
+ switch("path", dir.path)
+ switch("path", dir.path / "src")
diff --git a/examples/cbindings/.clang-format b/examples/cbindings/.clang-format
new file mode 100644
index 0000000..97c975d
--- /dev/null
+++ b/examples/cbindings/.clang-format
@@ -0,0 +1,6 @@
+BasedOnStyle: LLVM
+IndentWidth: 4
+TabWidth: 4
+UseTab: Never
+BreakBeforeBraces: Attach
+ColumnLimit: 100
diff --git a/examples/cbindings/Makefile b/examples/cbindings/Makefile
new file mode 100644
index 0000000..aa26a01
--- /dev/null
+++ b/examples/cbindings/Makefile
@@ -0,0 +1,39 @@
+# Makefile for cbindings_chat_tui C example
+
+CC = gcc
+CFLAGS = -Wall -Wextra -I../../library -pthread
+LDFLAGS = -L../../build -lchat -lncurses -Wl,-rpath,../../build
+
+BUILD_DIR = ../../build
+TARGET = $(BUILD_DIR)/cbindings_chat_tui
+SRC = cbindings_chat_tui.c
+
+.PHONY: all clean
+
+all: $(TARGET)
+
+$(TARGET): $(SRC)
+ $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
+
+clean:
+ rm -f $(TARGET)
+
+# Run with default settings
+run: $(TARGET)
+ LD_LIBRARY_PATH=$(BUILD_DIR) $(TARGET)
+
+# Run as alice on port 60001
+run_alice: $(TARGET)
+ LD_LIBRARY_PATH=$(BUILD_DIR) $(TARGET) --name=Alice --port=60001
+
+# Run as bob on port 60002
+run_bob: $(TARGET)
+ LD_LIBRARY_PATH=$(BUILD_DIR) $(TARGET) --name=Bob --port=60002
+
+help:
+ @echo "Usage:"
+ @echo " make - Build cbindings_chat_tui"
+ @echo " make run - Build and run with defaults"
+ @echo " make run_alice - Build and run as Alice on port 60001"
+ @echo " make run_bob - Build and run as Bob on port 60002"
+ @echo " make clean - Remove built files"
diff --git a/examples/cbindings/README.md b/examples/cbindings/README.md
new file mode 100644
index 0000000..a2c42d8
--- /dev/null
+++ b/examples/cbindings/README.md
@@ -0,0 +1,59 @@
+# C Bindings Example - Chat TUI
+
+A simple terminal user interface that demonstrates how to use libchat from C.
+
+## Build
+
+1. First, build libchat from the root folder:
+
+ ```bash
+ make libchat
+ ```
+
+2. Then build the C example:
+
+ ```bash
+ cd examples/cbindings
+ make
+ ```
+
+## Run
+
+Terminal 1:
+
+```bash
+make run_alice
+# Runs as Alice on port 60001
+```
+
+Terminal 2:
+
+```bash
+make run_bob
+# Runs as Bob on port 60002
+```
+
+## Workflow
+
+1. Start the application - it automatically uses your inbox conversation
+2. Type `/bundle` to get your IntroBundle JSON (will be copied to clipboard)
+3. In the other terminal type `/join ` to start a conversation
+4. You can send messages from one termnial to the other
+
+## Command Line Options
+
+```text
+--name= Identity name (default: user)
+--port= Waku port (default: random 50000-50200)
+--cluster= Waku cluster ID (default: 42)
+--shard= Waku shard ID (default: 2)
+--peer= Static peer multiaddr to connect to
+--help Show help
+```
+
+## Waku Configuration
+
+- **Cluster ID**: 42
+- **Shard ID**: 2
+- **PubSub Topic**: `/waku/2/rs/42/2`
+- **Port**: Random between 50000-50200 (or specify with --port)
diff --git a/examples/cbindings/cbindings_chat_tui.c b/examples/cbindings/cbindings_chat_tui.c
new file mode 100644
index 0000000..9043f3e
--- /dev/null
+++ b/examples/cbindings/cbindings_chat_tui.c
@@ -0,0 +1,738 @@
+/**
+ * Simple Terminal UI for libchat.c
+ * Commands:
+ * /join - Join a conversation
+ * /bundle - Show your intro bundle
+ * /quit - Exit
+ * - Send message to current conversation
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "libchat.h"
+
+// Constants
+static const int LOG_PANEL_HEIGHT = 6;
+static const int MSG_PANEL_HEIGHT = 12;
+static const int MAX_MESSAGES = 100;
+static const int MAX_LOGS = 50;
+static const size_t MAX_LINE_LEN = 2048;
+static const size_t MAX_INPUT_LEN = 2048;
+
+// Application state structures
+typedef struct {
+ char current_convo[128];
+ char inbox_id[128];
+ char my_name[64];
+ char my_address[128];
+ void *ctx;
+} ChatState;
+
+typedef struct {
+ char (*lines)[2048];
+ int count;
+ int max;
+ pthread_mutex_t mutex;
+} TextBuffer;
+
+typedef struct {
+ WINDOW *log_win, *msg_win, *input_win;
+ WINDOW *log_border, *msg_border;
+ SCREEN *screen;
+ FILE *tty_out, *tty_in;
+} UI;
+
+typedef struct {
+ char buffer[2048];
+ int len;
+ int pos;
+} InputState;
+
+typedef struct {
+ ChatState chat;
+ TextBuffer messages;
+ TextBuffer logs;
+ UI ui;
+ InputState input;
+ FILE *log_file;
+ char log_filename[256];
+ atomic_int running;
+ atomic_int needs_refresh;
+ atomic_int resize_pending;
+} App;
+
+static App g_app;
+
+// Forward declarations
+static void refresh_ui(void);
+static void add_text(TextBuffer *buf, const char *text, const char *prefix);
+static void handle_input(const char *input);
+
+//////////////////////////////////////////////////////////////////////////////
+// Utility functions
+//////////////////////////////////////////////////////////////////////////////
+
+static const char *get_timestamp(void) {
+ static char buf[16];
+ time_t now = time(NULL);
+ struct tm *tm = localtime(&now);
+ snprintf(buf, sizeof(buf), "%02d:%02d:%02d", tm->tm_hour, tm->tm_min, tm->tm_sec);
+ return buf;
+}
+
+// Simple JSON string value extractor
+static int json_extract(const char *json, const char **keys, char **values,
+ size_t *sizes, int n) {
+ int found = 0;
+ for (int i = 0; i < n; i++) {
+ values[i][0] = '\0';
+ // Build search pattern: "key":
+ char pattern[128];
+ snprintf(pattern, sizeof(pattern), "\"%s\":", keys[i]);
+ const char *pos = strstr(json, pattern);
+ if (!pos) continue;
+
+ pos += strlen(pattern);
+ while (*pos == ' ') pos++; // skip whitespace
+ if (*pos != '"') continue; // only handle string values
+ pos++; // skip opening quote
+
+ const char *end = strchr(pos, '"');
+ if (!end) continue;
+
+ size_t len = end - pos;
+ if (len >= sizes[i]) len = sizes[i] - 1;
+ strncpy(values[i], pos, len);
+ values[i][len] = '\0';
+ found++;
+ }
+ return found;
+}
+
+static void string_to_hex(const char *str, char *hex, size_t hex_size) {
+ size_t len = strlen(str);
+ if (len * 2 + 1 > hex_size)
+ len = (hex_size - 1) / 2;
+ for (size_t i = 0; i < len; i++) {
+ snprintf(hex + i * 2, 3, "%02x", (unsigned char)str[i]);
+ }
+ hex[len * 2] = '\0';
+}
+
+static void hex_to_string(const char *hex, char *str, size_t str_size) {
+ size_t hex_len = strlen(hex);
+ size_t len = hex_len / 2;
+ if (len >= str_size)
+ len = str_size - 1;
+ for (size_t i = 0; i < len; i++) {
+ char byte[3] = {hex[i * 2], hex[i * 2 + 1], '\0'};
+ char *end;
+ unsigned long val = strtoul(byte, &end, 16);
+ if (end != byte + 2) {
+ str[i] = '?'; // Invalid hex
+ } else {
+ str[i] = (char)val;
+ }
+ }
+ str[len] = '\0';
+}
+
+static int copy_to_clipboard(const char *data, size_t len) {
+ const char *cmd = NULL;
+#ifdef __APPLE__
+ cmd = "pbcopy";
+#else
+ if (system("which wl-copy >/dev/null 2>&1") == 0)
+ cmd = "wl-copy";
+ else if (system("which xclip >/dev/null 2>&1") == 0)
+ cmd = "xclip -selection clipboard";
+ else if (system("which xsel >/dev/null 2>&1") == 0)
+ cmd = "xsel --clipboard --input";
+#endif
+ if (!cmd) return 0;
+ FILE *pipe = popen(cmd, "w");
+ if (!pipe) return 0;
+ fwrite(data, 1, len, pipe);
+ pclose(pipe);
+ return 1;
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Text buffer operations
+//////////////////////////////////////////////////////////////////////////////
+
+static int textbuf_init(TextBuffer *buf, int max_lines) {
+ buf->lines = calloc(max_lines, MAX_LINE_LEN);
+ if (!buf->lines) return -1;
+ buf->count = 0;
+ buf->max = max_lines;
+ pthread_mutex_init(&buf->mutex, NULL);
+ return 0;
+}
+
+static void textbuf_destroy(TextBuffer *buf) {
+ free(buf->lines);
+ buf->lines = NULL;
+ pthread_mutex_destroy(&buf->mutex);
+}
+
+static void add_text(TextBuffer *buf, const char *text, const char *prefix) {
+ pthread_mutex_lock(&buf->mutex);
+ if (buf->count >= buf->max) {
+ memmove(buf->lines[0], buf->lines[1], (buf->max - 1) * MAX_LINE_LEN);
+ buf->count = buf->max - 1;
+ }
+ if (prefix) {
+ snprintf(buf->lines[buf->count], MAX_LINE_LEN, "[%s] %s", prefix, text);
+ } else {
+ snprintf(buf->lines[buf->count], MAX_LINE_LEN, "%s", text);
+ }
+ buf->count++;
+ pthread_mutex_unlock(&buf->mutex);
+ atomic_store(&g_app.needs_refresh, 1);
+}
+
+static inline void add_message(const char *msg) {
+ add_text(&g_app.messages, msg, NULL);
+}
+
+static inline void add_log(const char *log) {
+ add_text(&g_app.logs, log, get_timestamp());
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// ncurses UI
+//////////////////////////////////////////////////////////////////////////////
+
+static void create_windows(void) {
+ int max_y, max_x;
+ getmaxyx(stdscr, max_y, max_x);
+
+ int log_height = LOG_PANEL_HEIGHT + 2;
+ int msg_height = MSG_PANEL_HEIGHT + 2;
+ int input_height = 3;
+ int available = max_y - input_height;
+
+ if (log_height + msg_height > available) {
+ log_height = available / 3;
+ msg_height = available - log_height;
+ }
+
+ UI *ui = &g_app.ui;
+ ui->log_border = newwin(log_height, max_x, 0, 0);
+ ui->msg_border = newwin(msg_height, max_x, log_height, 0);
+ ui->log_win = derwin(ui->log_border, log_height - 2, max_x - 2, 1, 1);
+ ui->msg_win = derwin(ui->msg_border, msg_height - 2, max_x - 2, 1, 1);
+ ui->input_win = newwin(input_height, max_x, log_height + msg_height, 0);
+
+ scrollok(ui->log_win, TRUE);
+ scrollok(ui->msg_win, TRUE);
+ keypad(ui->input_win, TRUE);
+ nodelay(ui->input_win, TRUE);
+
+ if (has_colors()) {
+ start_color();
+ use_default_colors();
+ init_pair(1, COLOR_CYAN, -1);
+ init_pair(2, COLOR_GREEN, -1);
+ init_pair(3, COLOR_YELLOW, -1);
+ init_pair(4, COLOR_RED, -1);
+ init_pair(5, COLOR_MAGENTA, -1);
+ }
+}
+
+static void destroy_windows(void) {
+ UI *ui = &g_app.ui;
+ if (ui->log_win) delwin(ui->log_win);
+ if (ui->msg_win) delwin(ui->msg_win);
+ if (ui->input_win) delwin(ui->input_win);
+ if (ui->log_border) delwin(ui->log_border);
+ if (ui->msg_border) delwin(ui->msg_border);
+ memset(ui, 0, sizeof(*ui) - sizeof(ui->screen) - sizeof(ui->tty_out) - sizeof(ui->tty_in));
+}
+
+static void draw_borders(void) {
+ UI *ui = &g_app.ui;
+ ChatState *chat = &g_app.chat;
+
+ wattron(ui->log_border, COLOR_PAIR(3) | A_DIM);
+ box(ui->log_border, 0, 0);
+ mvwprintw(ui->log_border, 0, 2, " Logs ");
+ wattroff(ui->log_border, COLOR_PAIR(3) | A_DIM);
+
+ wattron(ui->msg_border, COLOR_PAIR(1) | A_BOLD);
+ box(ui->msg_border, 0, 0);
+ if (chat->current_convo[0]) {
+ mvwprintw(ui->msg_border, 0, 2, " Messages [%s] [%s] ", chat->my_name, chat->current_convo);
+ } else {
+ mvwprintw(ui->msg_border, 0, 2, " Messages [%s] [no conversation] ", chat->my_name);
+ }
+ wattroff(ui->msg_border, COLOR_PAIR(1) | A_BOLD);
+
+ wattron(ui->input_win, COLOR_PAIR(2) | A_BOLD);
+ box(ui->input_win, 0, 0);
+ mvwprintw(ui->input_win, 0, 2, " Input ");
+ wattroff(ui->input_win, COLOR_PAIR(2) | A_BOLD);
+
+ wnoutrefresh(ui->log_border);
+ wnoutrefresh(ui->msg_border);
+}
+
+static void draw_textbuf(WINDOW *win, TextBuffer *buf) {
+ werase(win);
+ pthread_mutex_lock(&buf->mutex);
+
+ int max_y = getmaxy(win);
+ int max_x = getmaxx(win);
+ int total_lines = 0;
+ int *lines_per = alloca(buf->count * sizeof(int));
+
+ for (int i = 0; i < buf->count; i++) {
+ int len = (int)strlen(buf->lines[i]);
+ lines_per[i] = len == 0 ? 1 : (len + max_x - 1) / max_x;
+ total_lines += lines_per[i];
+ }
+
+ int skip = total_lines > max_y ? total_lines - max_y : 0;
+ int start = 0, skipped = 0;
+ while (start < buf->count && skipped + lines_per[start] <= skip) {
+ skipped += lines_per[start++];
+ }
+
+ int row = 0;
+ for (int i = start; i < buf->count && row < max_y; i++) {
+ wmove(win, row, 0);
+ if (buf == &g_app.logs) wattron(win, A_DIM);
+ wprintw(win, "%s", buf->lines[i]);
+ if (buf == &g_app.logs) wattroff(win, A_DIM);
+ row += lines_per[i];
+ }
+
+ pthread_mutex_unlock(&buf->mutex);
+ wnoutrefresh(win);
+}
+
+static void draw_input(void) {
+ UI *ui = &g_app.ui;
+ InputState *inp = &g_app.input;
+ int max_x = getmaxx(ui->input_win);
+
+ mvwhline(ui->input_win, 1, 1, ' ', max_x - 2);
+ wattron(ui->input_win, COLOR_PAIR(2) | A_BOLD);
+ mvwprintw(ui->input_win, 1, 1, "> ");
+ wattroff(ui->input_win, COLOR_PAIR(2) | A_BOLD);
+
+ int available = max_x - 5;
+ int display_start = inp->pos > available - 1 ? inp->pos - available + 1 : 0;
+ mvwprintw(ui->input_win, 1, 3, "%.*s", available, inp->buffer + display_start);
+ wmove(ui->input_win, 1, 3 + inp->pos - display_start);
+ wnoutrefresh(ui->input_win);
+}
+
+static void refresh_ui(void) {
+ if (!atomic_exchange(&g_app.needs_refresh, 0)) return;
+
+ if (atomic_exchange(&g_app.resize_pending, 0)) {
+ endwin();
+ refresh();
+ destroy_windows();
+ create_windows();
+ }
+
+ draw_borders();
+ draw_textbuf(g_app.ui.log_win, &g_app.logs);
+ draw_textbuf(g_app.ui.msg_win, &g_app.messages);
+ draw_input();
+ doupdate();
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Signal handling (async-signal-safe)
+//////////////////////////////////////////////////////////////////////////////
+
+static void handle_sigint(int sig) {
+ (void)sig;
+ atomic_store(&g_app.running, 0);
+}
+
+static void handle_sigwinch(int sig) {
+ (void)sig;
+ atomic_store(&g_app.resize_pending, 1);
+ atomic_store(&g_app.needs_refresh, 1);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// FFI Callbacks
+//////////////////////////////////////////////////////////////////////////////
+
+static void general_callback(int ret, const char *msg, size_t len, void *userData) {
+ (void)userData;
+ char buf[256];
+ if (ret == RET_OK) {
+ snprintf(buf, sizeof(buf), "OK%s%.*s", len > 0 ? ": " : "", (int)(len > 60 ? 60 : len), msg);
+ } else {
+ snprintf(buf, sizeof(buf), "ERR: %.*s", (int)(len > 60 ? 60 : len), msg);
+ }
+ add_log(buf);
+}
+
+static void event_callback(int ret, const char *msg, size_t len, void *userData) {
+ (void)userData;
+ (void)ret;
+ (void)len;
+
+ char event_type[32] = {0};
+ char convo_id[128] = {0};
+ char content[2048] = {0};
+
+ const char *keys[] = {"eventType", "conversationId", "content"};
+ char *values[] = {event_type, convo_id, content};
+ size_t sizes[] = {sizeof(event_type), sizeof(convo_id), sizeof(content)};
+ json_extract(msg, keys, values, sizes, 3);
+
+ if (strcmp(event_type, "new_message") == 0) {
+ char decoded[2048], buf[2048];
+ hex_to_string(content, decoded, sizeof(decoded));
+ snprintf(buf, sizeof(buf), "<- %s", decoded);
+ add_message(buf);
+ } else if (strcmp(event_type, "new_conversation") == 0) {
+ strncpy(g_app.chat.current_convo, convo_id, sizeof(g_app.chat.current_convo) - 1);
+ char buf[256];
+ snprintf(buf, sizeof(buf), "* New conversation: %.32s...", g_app.chat.current_convo);
+ add_message(buf);
+ } else if (strcmp(event_type, "delivery_ack") == 0) {
+ add_log("Delivery acknowledged");
+ }
+
+ char buf[256];
+ snprintf(buf, sizeof(buf), "EVT: %.70s%s", msg, len > 70 ? "..." : "");
+ add_log(buf);
+}
+
+static void bundle_callback(int ret, const char *msg, size_t len, void *userData) {
+ (void)userData;
+ if (ret == RET_OK && len > 0) {
+ char buf[2048];
+ snprintf(buf, sizeof(buf), "%.*s", (int)(len < sizeof(buf) - 1 ? len : sizeof(buf) - 1), msg);
+
+ if (copy_to_clipboard(msg, len)) {
+ add_message("Your IntroBundle (copied to clipboard):");
+ add_log("Bundle copied to clipboard");
+ } else {
+ add_message("Your IntroBundle:");
+ }
+ add_message("");
+ add_message(buf);
+ add_message("");
+ } else {
+ char buf[256];
+ snprintf(buf, sizeof(buf), "Failed to get bundle: %.*s", (int)len, msg);
+ add_message(buf);
+ }
+}
+
+static void identity_callback(int ret, const char *msg, size_t len, void *userData) {
+ (void)userData; (void)len;
+ if (ret == RET_OK) {
+ const char *keys[] = {"name", "address"};
+ char *values[] = {g_app.chat.my_name, g_app.chat.my_address};
+ size_t sizes[] = {sizeof(g_app.chat.my_name), sizeof(g_app.chat.my_address)};
+ json_extract(msg, keys, values, sizes, 2);
+
+ char buf[256];
+ snprintf(buf, sizeof(buf), "Identity: %s (%.24s...)", g_app.chat.my_name, g_app.chat.my_address);
+ add_log(buf);
+ }
+}
+
+static void inbox_callback(int ret, const char *msg, size_t len, void *userData) {
+ (void)userData;
+ if (ret == RET_OK && len > 0) {
+ snprintf(g_app.chat.inbox_id, sizeof(g_app.chat.inbox_id), "%.*s", (int)len, msg);
+ char buf[256];
+ snprintf(buf, sizeof(buf), "Inbox: %.24s...", g_app.chat.inbox_id);
+ add_log(buf);
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Command handling
+//////////////////////////////////////////////////////////////////////////////
+
+static void cmd_join(const char *args) {
+ if (!args || !*args) {
+ add_message("Usage: /join ");
+ return;
+ }
+ char hex_msg[256];
+ string_to_hex("Hello!", hex_msg, sizeof(hex_msg));
+ chat_new_private_conversation(g_app.chat.ctx, general_callback, NULL, args, hex_msg);
+ add_message("* Creating conversation...");
+}
+
+static void cmd_send(const char *message) {
+ if (!g_app.chat.current_convo[0]) {
+ add_message("No active conversation. Use /join or receive an invite.");
+ return;
+ }
+ char hex_msg[4096];
+ string_to_hex(message, hex_msg, sizeof(hex_msg));
+ chat_send_message(g_app.chat.ctx, general_callback, NULL, g_app.chat.current_convo, hex_msg);
+
+ char buf[2048];
+ snprintf(buf, sizeof(buf), "-> You: %s", message);
+ add_message(buf);
+}
+
+static void handle_input(const char *input) {
+ if (!input || !*input) return;
+
+ if (input[0] != '/') {
+ cmd_send(input);
+ return;
+ }
+
+ if (strncmp(input, "/quit", 5) == 0 || strncmp(input, "/q", 2) == 0) {
+ atomic_store(&g_app.running, 0);
+ } else if (strncmp(input, "/join ", 6) == 0) {
+ cmd_join(input + 6);
+ } else if (strncmp(input, "/bundle", 7) == 0) {
+ chat_create_intro_bundle(g_app.chat.ctx, bundle_callback, NULL);
+ } else if (strncmp(input, "/help", 5) == 0) {
+ add_message("Commands:");
+ add_message(" /join - Join conversation with IntroBundle");
+ add_message(" /bundle - Show your IntroBundle");
+ add_message(" /quit - Exit");
+ add_message(" - Send message");
+ } else {
+ char buf[256];
+ snprintf(buf, sizeof(buf), "Unknown command: %s", input);
+ add_message(buf);
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Input processing
+//////////////////////////////////////////////////////////////////////////////
+
+static void process_input_char(int ch) {
+ InputState *inp = &g_app.input;
+
+ switch (ch) {
+ case '\n':
+ case KEY_ENTER:
+ if (inp->len > 0) {
+ inp->buffer[inp->len] = '\0';
+ handle_input(inp->buffer);
+ inp->len = inp->pos = 0;
+ inp->buffer[0] = '\0';
+ }
+ break;
+ case KEY_BACKSPACE:
+ case 127:
+ case 8:
+ if (inp->pos > 0) {
+ memmove(inp->buffer + inp->pos - 1, inp->buffer + inp->pos, inp->len - inp->pos + 1);
+ inp->pos--;
+ inp->len--;
+ }
+ break;
+ case KEY_DC:
+ if (inp->pos < inp->len) {
+ memmove(inp->buffer + inp->pos, inp->buffer + inp->pos + 1, inp->len - inp->pos);
+ inp->len--;
+ }
+ break;
+ case KEY_LEFT:
+ if (inp->pos > 0) inp->pos--;
+ break;
+ case KEY_RIGHT:
+ if (inp->pos < inp->len) inp->pos++;
+ break;
+ default:
+ if (ch >= 32 && ch < 127 && inp->len < (int)MAX_INPUT_LEN - 1) {
+ memmove(inp->buffer + inp->pos + 1, inp->buffer + inp->pos, inp->len - inp->pos + 1);
+ inp->buffer[inp->pos++] = ch;
+ inp->len++;
+ }
+ break;
+ }
+ atomic_store(&g_app.needs_refresh, 1);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Initialization and cleanup
+//////////////////////////////////////////////////////////////////////////////
+
+static int init_logging(const char *name) {
+ time_t now = time(NULL);
+ snprintf(g_app.log_filename, sizeof(g_app.log_filename), "chat_tui_%s_%ld.log", name, (long)now);
+
+ g_app.log_file = fopen(g_app.log_filename, "w");
+ if (!g_app.log_file) {
+ g_app.log_file = fopen("/dev/null", "w");
+ }
+
+ g_app.ui.tty_out = fopen("/dev/tty", "w");
+ g_app.ui.tty_in = fopen("/dev/tty", "r");
+ if (!g_app.ui.tty_out || !g_app.ui.tty_in) {
+ fprintf(stderr, "Error: Could not open /dev/tty\n");
+ return -1;
+ }
+
+ fflush(stdout);
+ fflush(stderr);
+ dup2(fileno(g_app.log_file), STDOUT_FILENO);
+ dup2(fileno(g_app.log_file), STDERR_FILENO);
+ return 0;
+}
+
+static int init_ui(void) {
+ g_app.ui.screen = newterm(NULL, g_app.ui.tty_out, g_app.ui.tty_in);
+ if (!g_app.ui.screen) return -1;
+
+ set_term(g_app.ui.screen);
+ cbreak();
+ noecho();
+ curs_set(1);
+ if (has_colors()) {
+ start_color();
+ use_default_colors();
+ }
+ create_windows();
+ return 0;
+}
+
+static void cleanup(void) {
+ if (g_app.chat.ctx) {
+ chat_stop(g_app.chat.ctx, general_callback, NULL);
+ chat_destroy(g_app.chat.ctx, general_callback, NULL);
+ }
+
+ destroy_windows();
+ if (g_app.ui.screen) {
+ endwin();
+ delscreen(g_app.ui.screen);
+ }
+
+ if (g_app.ui.tty_out) fclose(g_app.ui.tty_out);
+ if (g_app.ui.tty_in) fclose(g_app.ui.tty_in);
+ if (g_app.log_file) fclose(g_app.log_file);
+
+ textbuf_destroy(&g_app.messages);
+ textbuf_destroy(&g_app.logs);
+
+ FILE *tty = fopen("/dev/tty", "w");
+ if (tty) {
+ fprintf(tty, "Goodbye! (Library logs saved to %s)\n", g_app.log_filename);
+ fclose(tty);
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Main
+//////////////////////////////////////////////////////////////////////////////
+
+int main(int argc, char *argv[]) {
+ const char *name = "user";
+ int port = 0, cluster_id = 42, shard_id = 2;
+ const char *peer = NULL;
+
+ for (int i = 1; i < argc; i++) {
+ if (strncmp(argv[i], "--name=", 7) == 0) name = argv[i] + 7;
+ else if (strncmp(argv[i], "--port=", 7) == 0) port = atoi(argv[i] + 7);
+ else if (strncmp(argv[i], "--cluster=", 10) == 0) cluster_id = atoi(argv[i] + 10);
+ else if (strncmp(argv[i], "--shard=", 8) == 0) shard_id = atoi(argv[i] + 8);
+ else if (strncmp(argv[i], "--peer=", 7) == 0) peer = argv[i] + 7;
+ else if (strcmp(argv[i], "--help") == 0) {
+ printf("Usage: %s [options]\n", argv[0]);
+ printf(" --name= Your display name\n");
+ printf(" --port= Listen port (0 for random)\n");
+ printf(" --cluster= Cluster ID (default: 42)\n");
+ printf(" --shard= Shard ID (default: 2)\n");
+ printf(" --peer= Static peer multiaddr\n");
+ return 0;
+ }
+ }
+
+ // Initialize application state
+ memset(&g_app, 0, sizeof(g_app));
+ strncpy(g_app.chat.my_name, name, sizeof(g_app.chat.my_name) - 1);
+ atomic_store(&g_app.running, 1);
+
+ if (textbuf_init(&g_app.messages, MAX_MESSAGES) < 0 ||
+ textbuf_init(&g_app.logs, MAX_LOGS) < 0) {
+ fprintf(stderr, "Failed to allocate buffers\n");
+ return 1;
+ }
+
+ if (init_logging(name) < 0) {
+ textbuf_destroy(&g_app.messages);
+ textbuf_destroy(&g_app.logs);
+ return 1;
+ }
+
+ // Build config and create chat context
+ char config[2048];
+ if (peer) {
+ snprintf(config, sizeof(config),
+ "{\"name\":\"%s\",\"port\":%d,\"clusterId\":%d,\"shardId\":%d,\"staticPeer\":\"%s\"}",
+ name, port, cluster_id, shard_id, peer);
+ } else {
+ snprintf(config, sizeof(config),
+ "{\"name\":\"%s\",\"port\":%d,\"clusterId\":%d,\"shardId\":%d}",
+ name, port, cluster_id, shard_id);
+ }
+
+ g_app.chat.ctx = chat_new(config, general_callback, NULL);
+ if (!g_app.chat.ctx) {
+ fprintf(g_app.log_file, "Failed to create chat context\n");
+ cleanup();
+ return 1;
+ }
+
+ set_event_callback(g_app.chat.ctx, event_callback, NULL);
+
+ if (init_ui() < 0) {
+ fprintf(g_app.log_file, "Failed to initialize ncurses\n");
+ cleanup();
+ return 1;
+ }
+
+ signal(SIGINT, handle_sigint);
+ signal(SIGWINCH, handle_sigwinch);
+
+ add_log("Starting client...");
+ chat_start(g_app.chat.ctx, general_callback, NULL);
+ chat_get_identity(g_app.chat.ctx, identity_callback, NULL);
+ chat_get_default_inbox_id(g_app.chat.ctx, inbox_callback, NULL);
+
+ add_message("Welcome to Chat TUI!");
+ add_message("Type /help for commands, /quit to exit");
+ add_message("");
+
+ atomic_store(&g_app.needs_refresh, 1);
+ refresh_ui();
+
+ // Main loop
+ while (atomic_load(&g_app.running)) {
+ int ch;
+ while ((ch = wgetch(g_app.ui.input_win)) != ERR) {
+ process_input_char(ch);
+ }
+ refresh_ui();
+ usleep(10000);
+ }
+
+ add_log("Shutting down...");
+ cleanup();
+ return 0;
+}
\ No newline at end of file
diff --git a/library/api/client_api.nim b/library/api/client_api.nim
new file mode 100644
index 0000000..0ff2bf2
--- /dev/null
+++ b/library/api/client_api.nim
@@ -0,0 +1,156 @@
+## Client API - FFI bindings for Client lifecycle and operations
+## Uses the {.ffi.} pragma for async request handling
+
+import std/json
+import chronicles
+import chronos
+import ffi
+
+import ../../src/chat
+import ../../src/chat/proto_types
+import ../../src/chat/delivery/waku_client
+import ../../src/chat/identity
+import ../utils
+
+logScope:
+ topics = "chat ffi client"
+
+#################################################
+# Client Creation Request (for chat_new)
+#################################################
+
+type ChatCallbacks* = object
+ onNewMessage*: MessageCallback
+ onNewConversation*: NewConvoCallback
+ onDeliveryAck*: DeliveryAckCallback
+
+proc createChatClient(
+ configJson: cstring, chatCallbacks: ChatCallbacks
+): Future[Result[Client, string]] {.async.} =
+ try:
+ let config = parseJson($configJson)
+
+ # Parse identity name
+ let name = config.getOrDefault("name").getStr("anonymous")
+
+ # Parse Waku configuration or use defaults
+ var wakuCfg = DefaultConfig()
+
+ if config.hasKey("port"):
+ wakuCfg.port = config["port"].getInt().uint16
+
+ if config.hasKey("clusterId"):
+ wakuCfg.clusterId = config["clusterId"].getInt().uint16
+
+ if config.hasKey("shardId"):
+ wakuCfg.shardId = @[config["shardId"].getInt().uint16]
+
+ if config.hasKey("staticPeers"):
+ wakuCfg.staticPeers = @[]
+ for peer in config["staticPeers"]:
+ wakuCfg.staticPeers.add(peer.getStr())
+
+ # Create identity
+ let identity = createIdentity(name)
+
+ # Create Waku client
+ let wakuClient = initWakuClient(wakuCfg)
+
+ # Create Chat client
+ let client = newClient(wakuClient, identity)
+
+ # Register event handlers
+ client.onNewMessage(chatCallbacks.onNewMessage)
+ client.onNewConversation(chatCallbacks.onNewConversation)
+ client.onDeliveryAck(chatCallbacks.onDeliveryAck)
+
+ notice "Chat client created", name = name
+ return ok(client)
+ except CatchableError as e:
+ return err("failed to create client: " & e.msg)
+
+registerReqFFI(CreateClientRequest, ctx: ptr FFIContext[Client]):
+ proc(
+ configJson: cstring, chatCallbacks: ChatCallbacks
+ ): Future[Result[string, string]] {.async.} =
+ ctx[].myLib[] = (await createChatClient(configJson, chatCallbacks)).valueOr:
+ error "CreateClientRequest failed", error = error
+ return err($error)
+ return ok("")
+
+#################################################
+# Client Lifecycle Operations
+#################################################
+
+proc chat_start(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ try:
+ await ctx[].myLib[].start()
+ return ok("")
+ except CatchableError as e:
+ error "chat_start failed", error = e.msg
+ return err("failed to start client: " & e.msg)
+
+proc chat_stop(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ try:
+ await ctx[].myLib[].stop()
+ return ok("")
+ except CatchableError as e:
+ error "chat_stop failed", error = e.msg
+ return err("failed to stop client: " & e.msg)
+
+#################################################
+# Client Info Operations
+#################################################
+
+proc chat_get_id(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ ## Get the client's identifier
+ let clientId = ctx[].myLib[].getId()
+ return ok(clientId)
+
+proc chat_get_default_inbox_id(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ ## Get the default inbox conversation ID
+ let inboxId = ctx[].myLib[].defaultInboxConversationId()
+ return ok(inboxId)
+
+#################################################
+# Conversation List Operations
+#################################################
+
+proc chat_list_conversations(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ ## List all conversations as JSON array
+ let convos = ctx[].myLib[].listConversations()
+ var convoList = newJArray()
+ for convo in convos:
+ convoList.add(%*{"id": convo.id()})
+ return ok($convoList)
+
+proc chat_get_conversation(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer,
+ convoId: cstring
+) {.ffi.} =
+ ## Get a specific conversation by ID
+ let convo = ctx[].myLib[].getConversation($convoId)
+ return ok($(%*{"id": convo.id()}))
+
diff --git a/library/api/conversation_api.nim b/library/api/conversation_api.nim
new file mode 100644
index 0000000..3d8b153
--- /dev/null
+++ b/library/api/conversation_api.nim
@@ -0,0 +1,78 @@
+## Conversation API - FFI bindings for conversation operations
+## Uses the {.ffi.} pragma for async request handling
+
+import std/[json, options]
+import chronicles
+import chronos
+import ffi
+import stew/byteutils
+
+import ../../src/chat
+import ../../src/chat/proto_types
+import ../utils
+
+logScope:
+ topics = "chat ffi conversation"
+
+#################################################
+# Private Conversation Operations
+#################################################
+
+proc chat_new_private_conversation(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer,
+ introBundleJson: cstring,
+ contentHex: cstring
+) {.ffi.} =
+ ## Create a new private conversation with the given IntroBundle
+ ## introBundleJson: JSON string with {"ident": "hex...", "ephemeral": "hex..."}
+ ## contentHex: Initial message content as hex-encoded string
+ try:
+ let bundleJson = parseJson($introBundleJson)
+
+ # Parse IntroBundle from JSON
+ let identBytes = hexToSeqByte(bundleJson["ident"].getStr())
+ let ephemeralBytes = hexToSeqByte(bundleJson["ephemeral"].getStr())
+
+ let introBundle = IntroBundle(
+ ident: identBytes,
+ ephemeral: ephemeralBytes
+ )
+
+ # Convert hex content to bytes
+ let content = hexToSeqByte($contentHex)
+
+ # Create the conversation
+ let errOpt = await ctx[].myLib[].newPrivateConversation(introBundle, content)
+ if errOpt.isSome():
+ return err("failed to create conversation: " & $errOpt.get())
+
+ return ok("")
+ except CatchableError as e:
+ error "chat_new_private_conversation failed", error = e.msg
+ return err("failed to create private conversation: " & e.msg)
+
+#################################################
+# Message Operations
+#################################################
+
+proc chat_send_message(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer,
+ convoId: cstring,
+ contentHex: cstring
+) {.ffi.} =
+ ## Send a message to a conversation
+ ## convoId: Conversation ID string
+ ## contentHex: Message content as hex-encoded string
+ try:
+ let convo = ctx[].myLib[].getConversation($convoId)
+ let content = hexToSeqByte($contentHex)
+
+ let msgId = await convo.sendMessage(content)
+ return ok(msgId)
+ except CatchableError as e:
+ error "chat_send_message failed", error = e.msg
+ return err("failed to send message: " & e.msg)
diff --git a/library/api/identity_api.nim b/library/api/identity_api.nim
new file mode 100644
index 0000000..bb903d8
--- /dev/null
+++ b/library/api/identity_api.nim
@@ -0,0 +1,54 @@
+## Identity API - FFI bindings for identity operations
+## Uses the {.ffi.} pragma for async request handling
+
+import std/json
+import chronicles
+import chronos
+import ffi
+import stew/byteutils
+
+import ../../src/chat
+import ../../src/chat/crypto
+import ../../src/chat/proto_types
+import ../utils
+
+logScope:
+ topics = "chat ffi identity"
+
+#################################################
+# Identity Operations
+#################################################
+
+proc chat_get_identity(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ ## Get the client identity
+ ## Returns JSON string: {"name": "...", "address": "...", "pubkey": "hex..."}
+ let ident = ctx[].myLib[].identity()
+ let identJson = %*{
+ "name": ident.getName(),
+ "address": ident.getAddr(),
+ "pubkey": ident.getPubkey().toHex()
+ }
+ return ok($identJson)
+
+#################################################
+# IntroBundle Operations
+#################################################
+
+proc chat_create_intro_bundle(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.ffi.} =
+ ## Create an IntroBundle for initiating private conversations
+ ## Returns JSON string: {"ident": "hex...", "ephemeral": "hex..."}
+ let bundle = ctx[].myLib[].createIntroBundle()
+ let bundleJson = %*{
+ "ident": bundle.ident.toHex(),
+ "ephemeral": bundle.ephemeral.toHex()
+ }
+ return ok($bundleJson)
+
diff --git a/library/declare_lib.nim b/library/declare_lib.nim
new file mode 100644
index 0000000..ea950cb
--- /dev/null
+++ b/library/declare_lib.nim
@@ -0,0 +1,13 @@
+import ffi
+import ../src/chat/client
+
+declareLibrary("chat")
+
+proc set_event_callback(
+ ctx: ptr FFIContext[Client],
+ callback: FFICallBack,
+ userData: pointer
+) {.dynlib, exportc, cdecl.} =
+ ctx[].eventCallback = cast[pointer](callback)
+ ctx[].eventUserData = userData
+
diff --git a/library/libchat.h b/library/libchat.h
new file mode 100644
index 0000000..a2bf02a
--- /dev/null
+++ b/library/libchat.h
@@ -0,0 +1,107 @@
+// Generated manually
+#ifndef __libchat__
+#define __libchat__
+
+#include
+#include
+
+// The possible returned values for the functions that return int
+#define RET_OK 0
+#define RET_ERR 1
+#define RET_MISSING_CALLBACK 2
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len,
+ void *userData);
+
+//////////////////////////////////////////////////////////////////////////////
+// Client Lifecycle
+//////////////////////////////////////////////////////////////////////////////
+
+// Creates a new instance of the chat client.
+// Sets up the chat client from the given configuration.
+// Returns a pointer to the Context needed by the rest of the API functions.
+// configJson: JSON object with fields:
+// - "name": string - identity name (default: "anonymous")
+// - "port": int - Waku port (optional)
+// - "clusterId": int - Waku cluster ID (optional)
+// - "shardId": int - Waku shard ID (optional)
+// - "staticPeers": array of strings - static peer multiaddrs (optional)
+void *chat_new(const char *configJson, FFICallBack callback, void *userData);
+
+// Start the chat client and begin listening for messages
+int chat_start(void *ctx, FFICallBack callback, void *userData);
+
+// Stop the chat client
+int chat_stop(void *ctx, FFICallBack callback, void *userData);
+
+// Destroys an instance of a chat client created with chat_new
+int chat_destroy(void *ctx, FFICallBack callback, void *userData);
+
+// Sets a callback that will be invoked whenever an event occurs.
+// Events are JSON objects with "eventType" field:
+// - "new_message":
+// {"eventType":"new_message","conversationId":"...","messageId":"...","content":"hex...","timestamp":...}
+// - "new_conversation":
+// {"eventType":"new_conversation","conversationId":"...","conversationType":"private"}
+// - "delivery_ack":
+// {"eventType":"delivery_ack","conversationId":"...","messageId":"..."}
+void set_event_callback(void *ctx, FFICallBack callback, void *userData);
+
+//////////////////////////////////////////////////////////////////////////////
+// Client Info
+//////////////////////////////////////////////////////////////////////////////
+
+// Get the client's identifier
+int chat_get_id(void *ctx, FFICallBack callback, void *userData);
+
+// Get the default inbox conversation ID
+int chat_get_default_inbox_id(void *ctx, FFICallBack callback, void *userData);
+
+//////////////////////////////////////////////////////////////////////////////
+// Conversation Operations
+//////////////////////////////////////////////////////////////////////////////
+
+// List all conversations as JSON array
+// Returns: JSON array of objects with "id" field
+int chat_list_conversations(void *ctx, FFICallBack callback, void *userData);
+
+// Get a specific conversation by ID
+// Returns: JSON object with "id" field
+int chat_get_conversation(void *ctx, FFICallBack callback, void *userData,
+ const char *convoId);
+
+// Create a new private conversation with the given IntroBundle
+// introBundleJson: JSON string with {"ident": "hex...", "ephemeral": "hex..."}
+// contentHex: Initial message content as hex-encoded string
+int chat_new_private_conversation(void *ctx, FFICallBack callback,
+ void *userData, const char *introBundleJson,
+ const char *contentHex);
+
+// Send a message to a conversation
+// convoId: Conversation ID string
+// contentHex: Message content as hex-encoded string
+// Returns: Message ID on success
+int chat_send_message(void *ctx, FFICallBack callback, void *userData,
+ const char *convoId, const char *contentHex);
+
+//////////////////////////////////////////////////////////////////////////////
+// Identity Operations
+//////////////////////////////////////////////////////////////////////////////
+
+// Get the client identity
+// Returns JSON: {"name": "...", "address": "...", "pubkey": "hex..."}
+int chat_get_identity(void *ctx, FFICallBack callback, void *userData);
+
+// Create an IntroBundle for initiating private conversations
+// Returns JSON: {"ident": "hex...", "ephemeral": "hex..."}
+int chat_create_intro_bundle(void *ctx, FFICallBack callback, void *userData);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* __libchat__ */
diff --git a/library/libchat.nim b/library/libchat.nim
new file mode 100644
index 0000000..93844b7
--- /dev/null
+++ b/library/libchat.nim
@@ -0,0 +1,98 @@
+## libchat - C bindings for the Chat SDK
+## Main entry point for the shared library
+##
+## This library exposes the Chat SDK functionality through a C-compatible FFI interface.
+## It uses nim-ffi for thread-safe async request handling.
+
+import std/[json, options]
+import chronicles, chronos, ffi
+import stew/byteutils
+
+import
+ ../src/chat/client,
+ ../src/chat/conversations,
+ ../src/chat/identity,
+ ../src/chat/delivery/waku_client,
+ ../src/chat/proto_types,
+ ./declare_lib,
+ ./utils
+
+logScope:
+ topics = "chat ffi"
+
+################################################################################
+## Include different APIs, i.e. all procs with {.ffi.} pragma
+include
+ ./api/client_api,
+ ./api/conversation_api,
+ ./api/identity_api
+
+################################################################################
+
+proc chat_new(
+ configJson: cstring, callback: FFICallBack, userData: pointer
+): pointer {.dynlib, exportc, cdecl.} =
+ initializeLibrary()
+
+ ## Creates a new instance of the Chat Client.
+ if isNil(callback):
+ echo "error: missing callback in chat_new"
+ return nil
+
+ ## Create the Chat thread that will keep waiting for req from the main thread.
+ var ctx = ffi.createFFIContext[Client]().valueOr:
+ let msg = "Error in createFFIContext: " & $error
+ callback(RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), userData)
+ return nil
+
+ ctx.userData = userData
+
+ proc onNewMessage(ctx: ptr FFIContext[Client]): MessageCallback =
+ return proc(conversation: Conversation, msg: ReceivedMessage): Future[void] {.async.} =
+ callEventCallback(ctx, "onNewMessage"):
+ $newJsonMessageEvent(
+ conversation.id(),
+ "",
+ msg.content.toHex(),
+ msg.timestamp
+ )
+
+ proc onNewConversation(ctx: ptr FFIContext[Client]): NewConvoCallback =
+ return proc(conversation: Conversation): Future[void] {.async.} =
+ callEventCallback(ctx, "onNewConversation"):
+ $newJsonConversationEvent(conversation.id(), "private")
+
+ proc onDeliveryAck(ctx: ptr FFIContext[Client]): DeliveryAckCallback =
+ return proc(conversation: Conversation, msgId: MessageId): Future[void] {.async.} =
+ callEventCallback(ctx, "onDeliveryAck"):
+ $newJsonDeliveryAckEvent(conversation.id(), msgId)
+
+ let chatCallbacks = ChatCallbacks(
+ onNewMessage: onNewMessage(ctx),
+ onNewConversation: onNewConversation(ctx),
+ onDeliveryAck: onDeliveryAck(ctx),
+ )
+
+ ffi.sendRequestToFFIThread(
+ ctx, CreateClientRequest.ffiNewReq(callback, userData, configJson, chatCallbacks)
+ ).isOkOr:
+ let msg = "error in sendRequestToFFIThread: " & $error
+ callback(RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), userData)
+ return nil
+
+ return ctx
+
+proc chat_destroy(
+ ctx: ptr FFIContext[Client], callback: FFICallBack, userData: pointer
+): cint {.dynlib, exportc, cdecl.} =
+ initializeLibrary()
+ checkParams(ctx, callback, userData)
+
+ ffi.destroyFFIContext(ctx).isOkOr:
+ let msg = "libchat error: " & $error
+ callback(RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), userData)
+ return RET_ERR
+
+ callback(RET_OK, nil, 0, userData)
+
+ return RET_OK
diff --git a/library/nim.cfg b/library/nim.cfg
new file mode 100644
index 0000000..3dc6221
--- /dev/null
+++ b/library/nim.cfg
@@ -0,0 +1 @@
+path = "../"
diff --git a/library/utils.nim b/library/utils.nim
new file mode 100644
index 0000000..5c342f7
--- /dev/null
+++ b/library/utils.nim
@@ -0,0 +1,114 @@
+## Utility functions for C-bindings
+## Provides C-string helpers and JSON event serialization
+
+import std/json
+import ffi
+
+# Re-export common FFI types
+export ffi
+
+#################################################
+# C-String Helpers
+#################################################
+
+proc toCString*(s: string): cstring =
+ ## Convert Nim string to C string (caller must manage memory)
+ result = s.cstring
+
+proc fromCString*(cs: cstring): string =
+ ## Convert C string to Nim string (makes a copy)
+ if cs.isNil:
+ result = ""
+ else:
+ result = $cs
+
+proc toBytes*(data: ptr byte, len: csize_t): seq[byte] =
+ ## Convert C byte array to Nim seq[byte]
+ if data.isNil or len == 0:
+ result = @[]
+ else:
+ result = newSeq[byte](len)
+ copyMem(addr result[0], data, len)
+
+#################################################
+# JSON Event Types
+#################################################
+
+type
+ JsonEventType* = enum
+ EventNewMessage = "new_message"
+ EventNewConversation = "new_conversation"
+ EventDeliveryAck = "delivery_ack"
+ EventError = "error"
+
+type
+ JsonMessageEvent* = object
+ eventType*: string
+ conversationId*: string
+ messageId*: string
+ content*: string
+ timestamp*: int64
+
+ JsonConversationEvent* = object
+ eventType*: string
+ conversationId*: string
+ conversationType*: string
+
+ JsonDeliveryAckEvent* = object
+ eventType*: string
+ conversationId*: string
+ messageId*: string
+
+ JsonErrorEvent* = object
+ eventType*: string
+ error*: string
+
+#################################################
+# JSON Event Constructors
+#################################################
+
+proc newJsonMessageEvent*(convoId, msgId, content: string, timestamp: int64): JsonMessageEvent =
+ result = JsonMessageEvent(
+ eventType: $EventNewMessage,
+ conversationId: convoId,
+ messageId: msgId,
+ content: content,
+ timestamp: timestamp
+ )
+
+proc newJsonConversationEvent*(convoId, convoType: string): JsonConversationEvent =
+ result = JsonConversationEvent(
+ eventType: $EventNewConversation,
+ conversationId: convoId,
+ conversationType: convoType
+ )
+
+proc newJsonDeliveryAckEvent*(convoId, msgId: string): JsonDeliveryAckEvent =
+ result = JsonDeliveryAckEvent(
+ eventType: $EventDeliveryAck,
+ conversationId: convoId,
+ messageId: msgId
+ )
+
+proc newJsonErrorEvent*(error: string): JsonErrorEvent =
+ result = JsonErrorEvent(
+ eventType: $EventError,
+ error: error
+ )
+
+#################################################
+# JSON Serialization
+#################################################
+
+proc `$`*(event: JsonMessageEvent): string =
+ $(%*event)
+
+proc `$`*(event: JsonConversationEvent): string =
+ $(%*event)
+
+proc `$`*(event: JsonDeliveryAckEvent): string =
+ $(%*event)
+
+proc `$`*(event: JsonErrorEvent): string =
+ $(%*event)
+
diff --git a/nim_chat_poc.nimble b/nim_chat_poc.nimble
index cc5537e..3d63a0a 100644
--- a/nim_chat_poc.nimble
+++ b/nim_chat_poc.nimble
@@ -21,7 +21,8 @@ requires "nim >= 2.2.4",
"regex",
"web3",
"https://github.com/jazzz/nim-sds#exports",
- "waku"
+ "waku",
+ "ffi"
proc buildBinary(name: string, srcDir = "./", params = "", lang = "c") =
if not dirExists "build":
@@ -33,7 +34,25 @@ proc buildBinary(name: string, srcDir = "./", params = "", lang = "c") =
exec "nim " & lang & " --out:build/" & name & " --mm:refc " & extra_params & " " &
srcDir & name & ".nim"
-
+
+proc buildLibrary(name: string, srcDir = "library/", params = "", lang = "c") =
+ ## Build a shared library (.so on Linux, .dylib on macOS, .dll on Windows)
+ if not dirExists "build":
+ mkDir "build"
+
+ # Determine library extension based on OS
+ let libExt = when defined(macosx): "dylib"
+ elif defined(windows): "dll"
+ else: "so"
+
+ var extra_params = params
+ for i in 2 ..< paramCount():
+ extra_params &= " " & paramStr(i)
+
+ exec "nim " & lang & " --app:lib --out:build/lib" & name & "." & libExt &
+ " --mm:refc --nimMainPrefix:lib" & name & " " & extra_params & " " &
+ srcDir & "lib" & name & ".nim"
+
proc test(name: string, params = "-d:chronicles_log_level=DEBUG", lang = "c") =
buildBinary name, "tests/", params
exec "build/" & name
@@ -56,3 +75,7 @@ task bot_echo, "Build the EchoBot example":
task pingpong, "Build the Pingpong example":
let name = "pingpong"
buildBinary name, "examples/", "-d:chronicles_log_level='INFO' -d:chronicles_disabled_topics='waku node' "
+
+task libchat, "Build the Chat SDK shared library (C bindings)":
+ buildLibrary "chat", "library/",
+ "-d:chronicles_log_level='INFO' -d:chronicles_enabled=on --path:src --path:vendor/nim-ffi"