Files
didactyl/src/config.c
T

1341 lines
44 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#define _POSIX_C_SOURCE 200809L
#include "config.h"
#include <ctype.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
/* ---------------------------------------------------------------------------
* jsonc_strip_comments strip JSONC comments from a buffer
*
* Returns a newly malloc'd string containing valid JSON (caller must free).
* The function is careful to skip over JSON string literals so that comment
* characters inside strings are preserved. Handles:
* - single-line comments // (to end of line)
* - block comments (slash-star ... star-slash, may span lines)
* - escaped quotes inside strings \"
* - trailing commas before ] or } are NOT removed (cJSON tolerates them)
*
* Returns NULL on allocation failure.
* ------------------------------------------------------------------------ */
char* jsonc_strip_comments(const char* src, size_t src_len) {
if (!src || src_len == 0) {
char* empty = (char*)malloc(1);
if (empty) empty[0] = '\0';
return empty;
}
char* out = (char*)malloc(src_len + 1);
if (!out) return NULL;
size_t o = 0;
size_t i = 0;
while (i < src_len) {
/* Inside a JSON string literal — copy verbatim until closing quote */
if (src[i] == '"') {
out[o++] = src[i++]; /* opening quote */
while (i < src_len) {
if (src[i] == '\\' && i + 1 < src_len) {
out[o++] = src[i++]; /* backslash */
out[o++] = src[i++]; /* escaped char */
} else if (src[i] == '"') {
out[o++] = src[i++]; /* closing quote */
break;
} else {
out[o++] = src[i++];
}
}
continue;
}
/* Single-line comment // */
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '/') {
/* skip to end of line */
i += 2;
while (i < src_len && src[i] != '\n') {
i++;
}
/* preserve the newline so line numbers stay meaningful */
if (i < src_len) {
out[o++] = src[i++]; /* the \n */
}
continue;
}
/* Block comment (slash-star ... star-slash) */
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '*') {
i += 2;
while (i + 1 < src_len && !(src[i] == '*' && src[i + 1] == '/')) {
/* preserve newlines inside block comments */
if (src[i] == '\n') {
out[o++] = '\n';
}
i++;
}
if (i + 1 < src_len) {
i += 2; /* skip closing */
}
continue;
}
/* Normal character — copy through */
out[o++] = src[i++];
}
out[o] = '\0';
return out;
}
static char g_config_last_error[512] = {0};
static void config_set_error(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(g_config_last_error, sizeof(g_config_last_error), fmt ? fmt : "unknown configuration error", ap);
va_end(ap);
}
const char* config_last_error(void) {
return g_config_last_error[0] != '\0' ? g_config_last_error : "unknown configuration error";
}
static int read_file_to_buffer(const char* path, char** out_buf, size_t* out_len) {
FILE* fp = fopen(path, "rb");
if (!fp) {
return -1;
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return -1;
}
long len = ftell(fp);
if (len < 0) {
fclose(fp);
return -1;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return -1;
}
char* buf = (char*)malloc((size_t)len + 1U);
if (!buf) {
fclose(fp);
return -1;
}
size_t read_len = fread(buf, 1, (size_t)len, fp);
fclose(fp);
if (read_len != (size_t)len) {
free(buf);
return -1;
}
buf[len] = '\0';
*out_buf = buf;
*out_len = (size_t)len;
return 0;
}
static int copy_json_string(cJSON* obj, const char* key, char* dst, size_t dst_size, int required) {
cJSON* item = cJSON_GetObjectItemCaseSensitive(obj, key);
if (!item || !cJSON_IsString(item) || !item->valuestring) {
return required ? -1 : 0;
}
size_t n = strlen(item->valuestring);
if (n >= dst_size) {
return -1;
}
memcpy(dst, item->valuestring, n + 1U);
return 0;
}
static int is_hex64(const char* s) {
if (!s || strlen(s) != 64U) {
return 0;
}
for (size_t i = 0; i < 64U; i++) {
if (!isxdigit((unsigned char)s[i])) {
return 0;
}
}
return 1;
}
static int decode_private_key(const char* nsec_or_hex, unsigned char out_private_key[32]) {
if (!nsec_or_hex || !out_private_key) {
return -1;
}
if (strncmp(nsec_or_hex, "nsec1", 5) == 0) {
return nostr_decode_nsec(nsec_or_hex, out_private_key) == 0 ? 0 : -1;
}
if (is_hex64(nsec_or_hex)) {
return nostr_hex_to_bytes(nsec_or_hex, out_private_key, 32) == 0 ? 0 : -1;
}
return -1;
}
static int decode_pubkey_hex_or_npub(const char* in, char out_hex[65]) {
if (!in || !out_hex) {
return -1;
}
if (is_hex64(in)) {
memcpy(out_hex, in, 65);
return 0;
}
if (strncmp(in, "npub1", 5) == 0) {
unsigned char pubkey[32];
if (nostr_decode_npub(in, pubkey) != 0) {
return -1;
}
nostr_bytes_to_hex(pubkey, 32, out_hex);
return 0;
}
return -1;
}
static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
cJSON* tools = cJSON_GetObjectItemCaseSensitive(root, "tools");
if (!tools || !cJSON_IsObject(tools)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(tools, "enabled");
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(tools, "max_turns");
cJSON* trigger_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "trigger_max_turns");
cJSON* api_default_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "api_default_max_turns");
cJSON* api_max_turns_ceiling = cJSON_GetObjectItemCaseSensitive(tools, "api_max_turns_ceiling");
cJSON* stall_repeat_threshold = cJSON_GetObjectItemCaseSensitive(tools, "stall_repeat_threshold");
cJSON* local_http_fetch_default_timeout_seconds =
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
cJSON* local_http_fetch_max_timeout_seconds =
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_max_timeout_seconds");
if (enabled && cJSON_IsBool(enabled)) {
config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (max_turns && cJSON_IsNumber(max_turns)) {
config->tools.max_turns = (int)max_turns->valuedouble;
}
if (trigger_max_turns && cJSON_IsNumber(trigger_max_turns)) {
config->tools.trigger_max_turns = (int)trigger_max_turns->valuedouble;
}
if (api_default_max_turns && cJSON_IsNumber(api_default_max_turns)) {
config->tools.api_default_max_turns = (int)api_default_max_turns->valuedouble;
}
if (api_max_turns_ceiling && cJSON_IsNumber(api_max_turns_ceiling)) {
config->tools.api_max_turns_ceiling = (int)api_max_turns_ceiling->valuedouble;
}
if (stall_repeat_threshold && cJSON_IsNumber(stall_repeat_threshold)) {
config->tools.stall_repeat_threshold = (int)stall_repeat_threshold->valuedouble;
}
if (local_http_fetch_default_timeout_seconds && cJSON_IsNumber(local_http_fetch_default_timeout_seconds)) {
config->tools.local_http_fetch_default_timeout_seconds =
(int)local_http_fetch_default_timeout_seconds->valuedouble;
}
if (local_http_fetch_max_timeout_seconds && cJSON_IsNumber(local_http_fetch_max_timeout_seconds)) {
config->tools.local_http_fetch_max_timeout_seconds =
(int)local_http_fetch_max_timeout_seconds->valuedouble;
}
cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell");
if (!shell || !cJSON_IsObject(shell)) {
return 0;
}
cJSON* shell_enabled = cJSON_GetObjectItemCaseSensitive(shell, "enabled");
cJSON* timeout_seconds = cJSON_GetObjectItemCaseSensitive(shell, "timeout_seconds");
cJSON* max_output_bytes = cJSON_GetObjectItemCaseSensitive(shell, "max_output_bytes");
if (shell_enabled && cJSON_IsBool(shell_enabled)) {
config->tools.shell.enabled = cJSON_IsTrue(shell_enabled) ? 1 : 0;
}
if (timeout_seconds && cJSON_IsNumber(timeout_seconds)) {
config->tools.shell.timeout_seconds = (int)timeout_seconds->valuedouble;
}
if (max_output_bytes && cJSON_IsNumber(max_output_bytes)) {
config->tools.shell.max_output_bytes = (int)max_output_bytes->valuedouble;
}
if (copy_json_string(shell,
"working_directory",
config->tools.shell.working_directory,
sizeof(config->tools.shell.working_directory),
0) != 0) {
return -1;
}
if (config->tools.max_turns < 1) {
config->tools.max_turns = 8;
}
if (config->tools.trigger_max_turns < 1) {
config->tools.trigger_max_turns = config->tools.max_turns;
}
if (config->tools.api_default_max_turns < 1) {
config->tools.api_default_max_turns = config->tools.max_turns;
}
if (config->tools.api_max_turns_ceiling < 1) {
config->tools.api_max_turns_ceiling = 16;
}
if (config->tools.api_default_max_turns > config->tools.api_max_turns_ceiling) {
config->tools.api_default_max_turns = config->tools.api_max_turns_ceiling;
}
if (config->tools.stall_repeat_threshold < 2) {
config->tools.stall_repeat_threshold = 3;
}
if (config->tools.local_http_fetch_default_timeout_seconds < 1) {
config->tools.local_http_fetch_default_timeout_seconds = 20;
}
if (config->tools.local_http_fetch_max_timeout_seconds < 1) {
config->tools.local_http_fetch_max_timeout_seconds = 120;
}
if (config->tools.local_http_fetch_default_timeout_seconds > config->tools.local_http_fetch_max_timeout_seconds) {
config->tools.local_http_fetch_default_timeout_seconds = config->tools.local_http_fetch_max_timeout_seconds;
}
return 0;
}
static int parse_security_config(cJSON* root, didactyl_config_t* config) {
cJSON* security = cJSON_GetObjectItemCaseSensitive(root, "security");
if (!security || !cJSON_IsObject(security)) {
return 0;
}
cJSON* verify_signatures = cJSON_GetObjectItemCaseSensitive(security, "verify_signatures");
if (verify_signatures && cJSON_IsBool(verify_signatures)) {
config->security.verify_signatures = cJSON_IsTrue(verify_signatures) ? 1 : 0;
}
if (copy_json_string(security,
"stranger_response",
config->security.stranger_response,
sizeof(config->security.stranger_response),
0) != 0) {
return -1;
}
cJSON* tiers = cJSON_GetObjectItemCaseSensitive(security, "tiers");
if (!tiers || !cJSON_IsObject(tiers)) {
return 0;
}
cJSON* admin_tier = cJSON_GetObjectItemCaseSensitive(tiers, "admin");
if (admin_tier && cJSON_IsObject(admin_tier)) {
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(admin_tier, "tools_enabled");
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.admin.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* wot_tier = cJSON_GetObjectItemCaseSensitive(tiers, "wot");
if (wot_tier && cJSON_IsObject(wot_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "enabled");
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "tools_enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.wot.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.wot.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* stranger_tier = cJSON_GetObjectItemCaseSensitive(tiers, "stranger");
if (stranger_tier && cJSON_IsObject(stranger_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(stranger_tier, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.stranger.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
}
return 0;
}
static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
cJSON* admin_context = cJSON_GetObjectItemCaseSensitive(root, "admin_context");
if (!admin_context || !cJSON_IsObject(admin_context)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(admin_context, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->admin_context.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
cJSON* kind_1_limit = cJSON_GetObjectItemCaseSensitive(admin_context, "kind_1_limit");
if (kind_1_limit && cJSON_IsNumber(kind_1_limit)) {
config->admin_context.kind_1_limit = (int)kind_1_limit->valuedouble;
}
cJSON* subscribe_kinds = cJSON_GetObjectItemCaseSensitive(admin_context, "subscribe_kinds");
if (subscribe_kinds && cJSON_IsArray(subscribe_kinds)) {
config->admin_context.track_kind_0 = 0;
config->admin_context.track_kind_3 = 0;
config->admin_context.track_kind_10002 = 0;
config->admin_context.track_kind_1 = 0;
int n = cJSON_GetArraySize(subscribe_kinds);
for (int i = 0; i < n; i++) {
cJSON* k = cJSON_GetArrayItem(subscribe_kinds, i);
if (!k || !cJSON_IsNumber(k)) {
continue;
}
int kind = (int)k->valuedouble;
if (kind == 0) config->admin_context.track_kind_0 = 1;
else if (kind == 3) config->admin_context.track_kind_3 = 1;
else if (kind == 10002) config->admin_context.track_kind_10002 = 1;
else if (kind == 1) config->admin_context.track_kind_1 = 1;
}
}
return 0;
}
static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
cJSON* triggers = cJSON_GetObjectItemCaseSensitive(root, "triggers");
if (!triggers || !cJSON_IsObject(triggers)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(triggers, "enabled");
cJSON* max_active = cJSON_GetObjectItemCaseSensitive(triggers, "max_active");
cJSON* cooldown_seconds = cJSON_GetObjectItemCaseSensitive(triggers, "cooldown_seconds");
cJSON* llm_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "llm_rate_limit_per_minute");
cJSON* template_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "template_rate_limit_per_minute");
if (enabled && cJSON_IsBool(enabled)) {
config->triggers.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (max_active && cJSON_IsNumber(max_active)) {
config->triggers.max_active = (int)max_active->valuedouble;
}
if (cooldown_seconds && cJSON_IsNumber(cooldown_seconds)) {
config->triggers.cooldown_seconds = (int)cooldown_seconds->valuedouble;
}
if (llm_rate_limit && cJSON_IsNumber(llm_rate_limit)) {
config->triggers.llm_rate_limit_per_minute = (int)llm_rate_limit->valuedouble;
}
if (template_rate_limit && cJSON_IsNumber(template_rate_limit)) {
config->triggers.template_rate_limit_per_minute = (int)template_rate_limit->valuedouble;
}
if (config->triggers.max_active < 1) {
config->triggers.max_active = 1;
}
if (config->triggers.cooldown_seconds < 0) {
config->triggers.cooldown_seconds = 0;
}
if (config->triggers.llm_rate_limit_per_minute < 1) {
config->triggers.llm_rate_limit_per_minute = 1;
}
if (config->triggers.template_rate_limit_per_minute < 1) {
config->triggers.template_rate_limit_per_minute = 1;
}
return 0;
}
static int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
if (!root || !config) {
return -1;
}
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (!dm_protocol) {
return 0;
}
if (!cJSON_IsString(dm_protocol) || !dm_protocol->valuestring) {
return -1;
}
if (strcmp(dm_protocol->valuestring, "nip04") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP04;
} else if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
config->dm_protocol = DM_PROTOCOL_BOTH;
} else {
return -1;
}
return 0;
}
static int parse_api_config(cJSON* root, didactyl_config_t* config) {
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
if (!api || !cJSON_IsObject(api)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(api, "enabled");
cJSON* port = cJSON_GetObjectItemCaseSensitive(api, "port");
if (enabled && cJSON_IsBool(enabled)) {
config->api.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (port && cJSON_IsNumber(port)) {
config->api.port = (int)port->valuedouble;
}
if (copy_json_string(api,
"bind_address",
config->api.bind_address,
sizeof(config->api.bind_address),
0) != 0) {
return -1;
}
if (config->api.port < 1 || config->api.port > 65535) {
config->api.port = 8484;
}
if (config->api.bind_address[0] == '\0') {
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
}
return 0;
}
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
return NULL;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, tag_key) == 0) {
return val;
}
}
return NULL;
}
static int set_tag_value_string(cJSON* tags, const char* tag_key, const char* tag_value) {
if (!tags || !cJSON_IsArray(tags) || !tag_key || !tag_value || tag_value[0] == '\0') {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !key->valuestring) {
continue;
}
if (strcmp(key->valuestring, tag_key) == 0) {
if (cJSON_IsString(val)) {
if (!cJSON_SetValuestring(val, tag_value)) {
return -1;
}
return 0;
}
cJSON* new_val = cJSON_CreateString(tag_value);
if (!new_val) {
return -1;
}
cJSON_ReplaceItemInArray(tag, 1, new_val);
return 0;
}
}
cJSON* new_tag = cJSON_CreateArray();
if (!new_tag) {
return -1;
}
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_key));
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_value));
cJSON_AddItemToArray(tags, new_tag);
return 0;
}
static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
if (!item || !tags || !cJSON_IsArray(tags)) {
return 0;
}
if (event_kind != 31123 && event_kind != 31124) {
return 0;
}
cJSON* d_val = find_tag_value_string(tags, "d");
if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) {
return 0;
}
int needs_normalize =
(strcmp(d_val->valuestring, "skill") == 0 || strcmp(d_val->valuestring, "private_skill") == 0);
if (!needs_normalize) {
return 0;
}
const char* d_tag = NULL;
if (!d_tag) {
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
if (content_fields && cJSON_IsObject(content_fields)) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
d_tag = name->valuestring;
}
}
}
if (!d_tag) {
return 0;
}
return set_tag_value_string(tags, "d", d_tag);
}
static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
if (!root || !config) {
return -1;
}
cJSON* def = cJSON_GetObjectItemCaseSensitive(root, "default_skill");
if (!def || !cJSON_IsObject(def)) {
return 0;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(def, "kind");
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(def, "d_tag");
cJSON* content = cJSON_GetObjectItemCaseSensitive(def, "content");
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(def, "content_fields");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(def, "tags");
if (!kind || !cJSON_IsNumber(kind)) {
return -1;
}
int kind_i = (int)kind->valuedouble;
if (kind_i != 31123 && kind_i != 31124) {
return -1;
}
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0') {
return -1;
}
if (strlen(d_tag->valuestring) >= sizeof(config->default_skill.d_tag)) {
return -1;
}
char* content_text = NULL;
if (content_fields) {
if (!cJSON_IsObject(content_fields)) {
return -1;
}
content_text = cJSON_PrintUnformatted(content_fields);
} else {
if (!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
content_text = strdup(content->valuestring);
}
if (!content_text) {
return -1;
}
cJSON* tags_work = NULL;
if (tags) {
if (!cJSON_IsArray(tags)) {
free(content_text);
return -1;
}
tags_work = cJSON_Duplicate(tags, 1);
} else {
tags_work = cJSON_CreateArray();
}
if (!tags_work || !cJSON_IsArray(tags_work)) {
cJSON_Delete(tags_work);
free(content_text);
return -1;
}
if (set_tag_value_string(tags_work, "d", d_tag->valuestring) != 0) {
cJSON_Delete(tags_work);
free(content_text);
return -1;
}
char* tags_json = cJSON_PrintUnformatted(tags_work);
cJSON_Delete(tags_work);
if (!tags_json) {
free(content_text);
return -1;
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
config->default_skill.kind = kind_i;
snprintf(config->default_skill.d_tag, sizeof(config->default_skill.d_tag), "%s", d_tag->valuestring);
config->default_skill.content = content_text;
config->default_skill.tags_json = tags_json;
return 0;
}
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events");
if (!arr || !cJSON_IsArray(arr)) {
return 0;
}
int count = cJSON_GetArraySize(arr);
if (count <= 0) return 0;
config->startup_events = (startup_event_t*)calloc((size_t)count, sizeof(startup_event_t));
if (!config->startup_events) return -1;
config->startup_event_count = count;
for (int i = 0; i < count; i++) {
cJSON* item = cJSON_GetArrayItem(arr, i);
if (!item || !cJSON_IsObject(item)) return -1;
cJSON* kind = cJSON_GetObjectItemCaseSensitive(item, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(item, "content");
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(item, "tags");
if (!kind || !cJSON_IsNumber(kind)) {
return -1;
}
config->startup_events[i].kind = (int)kind->valuedouble;
if (content_fields) {
if (!cJSON_IsObject(content_fields)) {
return -1;
}
config->startup_events[i].content = cJSON_PrintUnformatted(content_fields);
if (!config->startup_events[i].content) {
return -1;
}
} else {
if (!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
config->startup_events[i].content = strdup(content->valuestring);
if (!config->startup_events[i].content) {
return -1;
}
}
if (tags) {
if (!cJSON_IsArray(tags)) return -1;
if (normalize_skill_d_tag(config->startup_events[i].kind, item, tags) != 0) {
return -1;
}
config->startup_events[i].tags_json = cJSON_PrintUnformatted(tags);
if (!config->startup_events[i].tags_json) return -1;
}
}
return 0;
}
static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
if (!config || !config->startup_events || config->startup_event_count <= 0) {
return -1;
}
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10002 || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
int tag_count = cJSON_GetArraySize(tags);
int relay_count = 0;
for (int j = 0; j < tag_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") == 0 && val->valuestring[0] != '\0') {
relay_count++;
}
}
if (relay_count <= 0) {
cJSON_Delete(tags);
continue;
}
config->relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!config->relays) {
cJSON_Delete(tags);
return -1;
}
config->relay_count = relay_count;
int out_i = 0;
for (int j = 0; j < tag_count && out_i < relay_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") == 0 && val->valuestring[0] != '\0') {
config->relays[out_i] = strdup(val->valuestring);
if (!config->relays[out_i]) {
cJSON_Delete(tags);
return -1;
}
out_i++;
}
}
cJSON_Delete(tags);
return 0;
}
return -1;
}
static int set_default_bootstrap_relays(didactyl_config_t* config) {
if (!config) {
return -1;
}
static const char* defaults[] = {
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol"
};
const int relay_count = (int)(sizeof(defaults) / sizeof(defaults[0]));
char** relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!relays) {
return -1;
}
for (int i = 0; i < relay_count; i++) {
relays[i] = strdup(defaults[i]);
if (!relays[i]) {
for (int j = 0; j < i; j++) {
free(relays[j]);
}
free(relays);
return -1;
}
}
if (config->relays) {
for (int i = 0; i < config->relay_count; i++) {
free(config->relays[i]);
}
free(config->relays);
}
config->relays = relays;
config->relay_count = relay_count;
return 0;
}
static int parse_relays(cJSON* root, didactyl_config_t* config) {
(void)root;
if (parse_relays_from_startup_kind10002(config) == 0) {
return 0;
}
return set_default_bootstrap_relays(config);
}
static int startup_event_has_d_tag(const startup_event_t* se, const char* d_tag) {
if (!se || !se->tags_json || !d_tag || d_tag[0] == '\0') {
return 0;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
return 0;
}
int found = 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "d") == 0 && strcmp(v->valuestring, d_tag) == 0) {
found = 1;
break;
}
}
cJSON_Delete(tags);
return found;
}
static int adoption_tags_has_addr(cJSON* tags, const char* addr) {
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
return 0;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "a") == 0 && strcmp(v->valuestring, addr) == 0) {
return 1;
}
}
return 0;
}
static int append_adoption_addr_tag(cJSON* tags, const char* addr) {
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
return -1;
}
cJSON* tag = cJSON_CreateArray();
if (!tag) return -1;
cJSON_AddItemToArray(tag, cJSON_CreateString("a"));
cJSON_AddItemToArray(tag, cJSON_CreateString(addr));
cJSON_AddItemToArray(tags, tag);
return 0;
}
static int append_startup_event(didactyl_config_t* config, int kind, const char* content, const char* tags_json) {
if (!config || !content) {
return -1;
}
int old_count = config->startup_event_count;
startup_event_t* grown = (startup_event_t*)realloc(config->startup_events, (size_t)(old_count + 1) * sizeof(startup_event_t));
if (!grown) {
return -1;
}
config->startup_events = grown;
memset(&config->startup_events[old_count], 0, sizeof(startup_event_t));
config->startup_events[old_count].kind = kind;
config->startup_events[old_count].content = strdup(content);
if (!config->startup_events[old_count].content) {
return -1;
}
if (tags_json && tags_json[0] != '\0') {
config->startup_events[old_count].tags_json = strdup(tags_json);
if (!config->startup_events[old_count].tags_json) {
free(config->startup_events[old_count].content);
config->startup_events[old_count].content = NULL;
return -1;
}
}
config->startup_event_count = old_count + 1;
return 0;
}
int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
if (!config || !config->default_skill.content || config->default_skill.content[0] == '\0' ||
config->default_skill.d_tag[0] == '\0') {
return 0;
}
int has_default_skill_event = 0;
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind == config->default_skill.kind && startup_event_has_d_tag(se, config->default_skill.d_tag)) {
has_default_skill_event = 1;
break;
}
}
if (!has_default_skill_event) {
if (append_startup_event(config,
config->default_skill.kind,
config->default_skill.content,
config->default_skill.tags_json ? config->default_skill.tags_json : "[]") != 0) {
return -1;
}
}
if (config->keys.public_key_hex[0] == '\0') {
return 0;
}
char addr[256];
snprintf(addr,
sizeof(addr),
"%d:%s:%s",
config->default_skill.kind,
config->keys.public_key_hex,
config->default_skill.d_tag);
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10123 || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
if (adoption_tags_has_addr(tags, addr)) {
cJSON_Delete(tags);
return 0;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
}
char* updated = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
if (!updated) {
return -1;
}
free(se->tags_json);
se->tags_json = updated;
return 0;
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
return -1;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
}
cJSON* app = cJSON_CreateArray();
cJSON* scope = cJSON_CreateArray();
if (!app || !scope) {
cJSON_Delete(app);
cJSON_Delete(scope);
cJSON_Delete(tags);
return -1;
}
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
cJSON_AddItemToArray(scope, cJSON_CreateString("scope"));
cJSON_AddItemToArray(scope, cJSON_CreateString(config->default_skill.kind == 31124 ? "private" : "public"));
cJSON_AddItemToArray(tags, app);
cJSON_AddItemToArray(tags, scope);
char* tags_json = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
if (!tags_json) {
return -1;
}
int rc = append_startup_event(config, 10123, "", tags_json);
free(tags_json);
return rc;
}
void config_free(didactyl_config_t* config) {
if (!config) {
return;
}
if (config->relays) {
for (int i = 0; i < config->relay_count; i++) {
free(config->relays[i]);
}
free(config->relays);
}
if (config->startup_events) {
for (int i = 0; i < config->startup_event_count; i++) {
free(config->startup_events[i].content);
free(config->startup_events[i].tags_json);
}
free(config->startup_events);
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
memset(config, 0, sizeof(*config));
}
int config_load(const char* path, didactyl_config_t* config) {
if (!path || !config) {
config_set_error("config_load called with invalid arguments");
return -1;
}
config_set_error("unknown configuration error");
memset(config, 0, sizeof(*config));
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
config->dm_protocol = DM_PROTOCOL_NIP04;
config->tools.enabled = 1;
config->tools.max_turns = 20;
config->tools.trigger_max_turns = 12;
config->tools.api_default_max_turns = 8;
config->tools.api_max_turns_ceiling = 32;
config->tools.stall_repeat_threshold = 3;
config->tools.local_http_fetch_default_timeout_seconds = 20;
config->tools.local_http_fetch_max_timeout_seconds = 120;
config->tools.shell.enabled = 1;
config->tools.shell.timeout_seconds = 30;
config->tools.shell.max_output_bytes = 65536;
strcpy(config->tools.shell.working_directory, ".");
config->security.verify_signatures = 1;
config->security.admin.enabled = 1;
config->security.admin.tools_enabled = 1;
config->security.wot.enabled = 1;
config->security.wot.tools_enabled = 0;
config->security.stranger.enabled = 1;
config->security.stranger.tools_enabled = 0;
config->security.stranger_response[0] = '\0';
config->admin_context.enabled = 1;
config->admin_context.track_kind_0 = 1;
config->admin_context.track_kind_3 = 1;
config->admin_context.track_kind_10002 = 1;
config->admin_context.track_kind_1 = 1;
config->admin_context.kind_1_limit = 10;
config->triggers.enabled = 1;
config->triggers.max_active = 16;
config->triggers.cooldown_seconds = 60;
config->triggers.llm_rate_limit_per_minute = 10;
config->triggers.template_rate_limit_per_minute = 60;
config->api.enabled = 0;
config->api.port = 8484;
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
config->default_skill.kind = 31124;
config->default_skill.d_tag[0] = '\0';
config->default_skill.content = NULL;
config->default_skill.tags_json = NULL;
char* raw_buf = NULL;
size_t raw_len = 0;
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
if (errno == ENOENT) {
if (set_default_bootstrap_relays(config) != 0) {
config_set_error("failed to initialize default bootstrap relays");
return -1;
}
return 0;
}
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
return -1;
}
/* Strip JSONC comments before parsing */
char* json_buf = jsonc_strip_comments(raw_buf, raw_len);
free(raw_buf);
if (!json_buf) {
config_set_error("failed to allocate memory for JSONC comment stripping");
return -1;
}
size_t json_len = strlen(json_buf);
cJSON* root = cJSON_ParseWithLength(json_buf, json_len);
free(json_buf);
if (!root) {
const char* err = cJSON_GetErrorPtr();
config_set_error("invalid JSON in config '%s'%s%s",
path,
err ? " near: " : "",
err ? err : "");
return -1;
}
int rc = -1;
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
if (!keys || !cJSON_IsObject(keys)) {
keys = cJSON_GetObjectItemCaseSensitive(root, "key");
}
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: admin, llm");
goto cleanup;
}
if (!keys || !cJSON_IsObject(keys)) {
config->keys.nsec[0] = '\0';
} else {
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 0) != 0) {
config_set_error("keys.nsec/key.nsec must be a string when provided");
goto cleanup;
}
}
char admin_key_raw[OW_MAX_KEY_LEN] = {0};
if (copy_json_string(admin, "pubkey", admin_key_raw, sizeof(admin_key_raw), 1) != 0) {
config_set_error("admin.pubkey is required and must be a non-empty string");
goto cleanup;
}
if (decode_pubkey_hex_or_npub(admin_key_raw, config->admin.pubkey) != 0) {
config_set_error("admin.pubkey must be npub1... or 64-char hex");
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
config_set_error("invalid startup_events configuration");
goto cleanup;
}
if (parse_default_skill_config(root, config) != 0) {
config_set_error("invalid default_skill configuration");
goto cleanup;
}
if (parse_relays(root, config) != 0) {
config_set_error("relay configuration is invalid and bootstrap fallback failed");
goto cleanup;
}
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
config_set_error("llm.provider must be a string if provided");
goto cleanup;
}
if (config->llm.provider[0] == '\0') {
strcpy(config->llm.provider, "openai");
}
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
config_set_error("llm.api_key is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
config_set_error("llm.model is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
config_set_error("llm.base_url is required and must be a string");
goto cleanup;
}
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm, "temperature");
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
if (parse_dm_protocol_config(root, config) != 0) {
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");
goto cleanup;
}
if (parse_tools_config(root, config) != 0) {
config_set_error("invalid tools configuration");
goto cleanup;
}
if (parse_security_config(root, config) != 0) {
config_set_error("invalid security configuration");
goto cleanup;
}
if (parse_admin_context_config(root, config) != 0) {
config_set_error("invalid admin_context configuration");
goto cleanup;
}
if (parse_triggers_config(root, config) != 0) {
config_set_error("invalid triggers configuration");
goto cleanup;
}
if (parse_api_config(root, config) != 0) {
config_set_error("invalid api configuration");
goto cleanup;
}
if (config->keys.nsec[0] != '\0') {
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.nsec/key.nsec must be valid nsec1... or 64-char hex private key");
goto cleanup;
}
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
config_set_error("failed to derive public key from keys.nsec/key.nsec");
goto cleanup;
}
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
}
rc = 0;
cleanup:
cJSON_Delete(root);
if (rc != 0) {
config_free(config);
}
return rc;
}