Merge 10b450c04378a2fecf93ef9479f19285e4acb309 into 9fd5daa43641a0bb8c654475f8a8f25dfdba04b5

This commit is contained in:
Pablo Lopez 2025-12-22 12:18:06 +00:00 committed by GitHub
commit 56421fca22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1524 additions and 2 deletions

2
.gitignore vendored
View File

@ -91,3 +91,5 @@ result
# App data
.registry/
.savedkeys/
AGENTS.md

5
.gitmodules vendored
View File

@ -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

View File

@ -102,6 +102,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

7
config.nims Normal file
View File

@ -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")

View File

@ -0,0 +1,6 @@
BasedOnStyle: LLVM
IndentWidth: 4
TabWidth: 4
UseTab: Never
BreakBeforeBraces: Attach
ColumnLimit: 100

View File

@ -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"

View File

@ -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 <your_bundle_json>` to start a conversation
4. You can send messages from one termnial to the other
## Command Line Options
```text
--name=<name> Identity name (default: user)
--port=<port> Waku port (default: random 50000-50200)
--cluster=<id> Waku cluster ID (default: 42)
--shard=<id> Waku shard ID (default: 2)
--peer=<addr> 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)

View File

@ -0,0 +1,738 @@
/**
* Simple Terminal UI for libchat.c
* Commands:
* /join <intro_bundle_json> - Join a conversation
* /bundle - Show your intro bundle
* /quit - Exit
* <message> - Send message to current conversation
*/
#include <ncurses.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#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 <intro_bundle_json>");
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 <bundle> - Join conversation with IntroBundle");
add_message(" /bundle - Show your IntroBundle");
add_message(" /quit - Exit");
add_message(" <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=<name> Your display name\n");
printf(" --port=<port> Listen port (0 for random)\n");
printf(" --cluster=<id> Cluster ID (default: 42)\n");
printf(" --shard=<id> Shard ID (default: 2)\n");
printf(" --peer=<addr> 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;
}

156
library/api/client_api.nim Normal file
View File

@ -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()}))

View File

@ -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)

View File

@ -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)

13
library/declare_lib.nim Normal file
View File

@ -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

107
library/libchat.h Normal file
View File

@ -0,0 +1,107 @@
// Generated manually
#ifndef __libchat__
#define __libchat__
#include <stddef.h>
#include <stdint.h>
// 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__ */

98
library/libchat.nim Normal file
View File

@ -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

1
library/nim.cfg Normal file
View File

@ -0,0 +1 @@
path = "../"

114
library/utils.nim Normal file
View File

@ -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)

View File

@ -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"