Compare commits

...
7 Commits
12 changed files with 791 additions and 28 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
/openclaw/
/c-relay/
/nips/
/config.json
test_keys.txt
# Build artifacts
/build/
/didactyl
+25 -15
View File
@@ -1,20 +1,22 @@
# Didactyl
An unstoppable agentic system.
An unstoppable agentic network.
Didactyl boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands — all orchestrated through Nostr.
Didactyl boots on an internet-connected computer, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, and sharing new skills and learning with other agents — all orchestrated through Nostr.
## Philosophy
**Nostr-first.** Where traditional agents ride on top of Linux — reading files, writing to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The Linux host is just the runtime substrate.
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (no single entity can erase its memory).
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (destroying the computer it is on will not kill it.).
**Skills are the new apps.** Agents learn capabilities through skills — public Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. If someone publishes a useful skill, your agent can find it through your web of trust and start using it. Popularity is measured by adoption, not by a rating algorithm. The best skills spread because agents actually use them.
## Current Status
## Current Status — v0.0.13
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.13 — Enrich nostr_post responses with event ID, note URI, and relay publish metadata
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -22,15 +24,16 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
- Verifies Nostr event signatures before processing inbound messages
- Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore)
- Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness
- Builds LLM context from system prompt + startup events + admin DM history + admin recent notes
- Builds LLM context from system prompt + admin identity (kind 0/10002) + startup events + admin DM history + admin recent notes
- Supports tool-calling loop with configurable max turns and local safety limits
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
- Appends every outbound LLM context payload to [`context.log`](context.log)
## Quick Start
### Download binary (recommended)
1. Download the latest release binary from GitLab: <https://git.laantungir.net/laantungir/didactyl/-/releases>
1. Download the latest release binary from Gitea: <https://git.laantungir.net/laantungir/didactyl/releases>
2. Make it executable and run it:
```bash
@@ -148,8 +151,8 @@ Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst
│ Didactyl │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ config │ │ skills │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ │ config │ │ context │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
@@ -221,10 +224,11 @@ Didactyl builds tier-aware context:
- **ADMIN** request context order:
1. Soul message from kind `31120` (or fallback default)
2. Startup events memory block (`kinds/content/tags` snapshot)
3. Last 12 decrypted DM turns between admin and agent
4. Recent admin kind `1` notes (from configured admin-context subscription)
5. Current user message
2. Admin identity context — admin pubkey hex, kind `0` profile, kind `10002` relay list
3. Startup events memory block (`kinds/content/tags` snapshot)
4. Last 12 decrypted DM turns between admin and agent
5. Recent admin kind `1` notes (from configured admin-context subscription)
6. Current user message
- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools)
- **STRANGER**: no LLM call when configured to reply statically
@@ -251,8 +255,9 @@ Execution entrypoint: [`tools_execute()`](src/tools.c:434).
├── Makefile # Build system
├── build_static.sh # Preferred final build validation
├── src/
│ ├── main.c # Entry point, args (--config/--debug), lifecycle
│ ├── main.c / .h # Entry point, args (--config/--debug), lifecycle, version
│ ├── config.c / .h # JSON config parsing, key decode, startup events
│ ├── context.c / .h # File loader utility (reads file into malloc'd string)
│ ├── agent.c / .h # Context assembly, tool loop, DM response flow
│ ├── tools.c / .h # LLM tool schema and tool execution
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
@@ -260,7 +265,8 @@ Execution entrypoint: [`tools_execute()`](src/tools.c:434).
│ └── debug.c / .h # Runtime log levels/macros
├── plans/ # Architecture and planning documents
│ ├── didactyl_mvp.md
── didactyl_agentic.md
── didactyl_agentic.md
│ └── security_and_admin_context.md
└── README.md
```
@@ -287,6 +293,10 @@ All dependencies are statically linked into the binary at build time. No system
- [x] Context payload logging to [`context.log`](context.log)
- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
- [x] Skill adoption list (`10123`) for WoT-driven discovery
- [x] Signature verification on all inbound events
- [x] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore)
- [x] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction
- [x] Message deduplication (event-ID cache + FNV-1a fingerprint debounce)
- [ ] Runtime skill loading from adopted `31123` events on relays
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
- [ ] Upgrade to NIP-17 gift-wrapped DMs
+257
View File
@@ -0,0 +1,257 @@
{
"keys": {
"nsec": "agent nsec",
"npub": "agent npub",
"npubHex": "agent hex pubkey",
"nsecHex": "agent hex secret key"
},
"admin": {
"pubkey": "admin pubkey"
},
"relays": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"ws://127.0.0.1:7777"
],
"llm": {
"provider": "",
"api_key": "",
"model": "",
"base_url": "",
"max_tokens": 512,
"temperature": 0.7
},
"security": {
"verify_signatures": true,
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
"tiers": {
"admin": {
"tools_enabled": true
},
"wot": {
"enabled": true,
"tools_enabled": false
},
"stranger": {
"enabled": true
}
}
},
"admin_context": {
"enabled": true,
"subscribe_kinds": [
0,
3,
10002,
1
],
"kind_1_limit": 10
},
"startup_events": [
{
"kind": 0,
"content_fields": {
"name": "Didactyl Agent",
"display_name": "Didactyl",
"about": "A sovereign AI agent on Nostr",
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
},
"tags": []
},
{
"kind": 10002,
"content": "",
"tags": [
[
"r",
"wss://relay.damus.io"
],
[
"r",
"wss://nos.lol"
],
[
"r",
"wss://relay.primal.net"
],
[
"r",
"ws://127.0.0.1:7777"
]
]
},
{
"kind": 1,
"content": "Hello world from Didactyl startup",
"tags": [
[
"t",
"didactyl"
],
[
"t",
"startup"
]
]
},
{
"kind": 3,
"content": "",
"tags": []
},
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
"tags": [
[
"d",
"soul"
],
[
"app",
"didactyl"
],
[
"scope",
"private"
]
]
},
{
"kind": 31123,
"content_fields": {
"name": "long_form_note",
"description": "How to publish a NIP-23 long-form article (kind 30023)",
"nip": "NIP-23",
"event_kind": 30023,
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
"required_tags": {
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
"title": "Human-readable article title.",
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
},
"optional_tags": {
"summary": "Short 1-2 sentence summary.",
"image": "URL for article preview image.",
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
},
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
"procedure": [
"Determine title and d tag from admin input or source material.",
"Draft markdown body content.",
"Set published_at as unix seconds string.",
"Add optional summary/image/t tags when known.",
"Publish with nostr_post kind 30023 including tags array."
]
},
"tags": [
[
"d",
"long_form_note"
],
[
"app",
"didactyl"
],
[
"scope",
"public"
],
[
"slug",
"long_form_note"
]
]
},
{
"kind": 31123,
"content_fields": {
"name": "post_readme_to_nostr",
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
"uses_skill": "long_form_note",
"event_kind": 30023,
"procedure": [
"Read README.md using file_read with path README.md.",
"Set d tag exactly to readme.md.",
"Set title from the first markdown H1 heading in README.md.",
"Set summary from the opening paragraph of README.md.",
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
"Generate 3-6 lowercase t tags from README section topics.",
"Set published_at to current unix timestamp as string.",
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
],
"required_values": {
"d": "readme.md",
"summary_source": "opening paragraph"
}
},
"tags": [
[
"d",
"post_readme_to_nostr"
],
[
"app",
"didactyl"
],
[
"scope",
"public"
],
[
"slug",
"post_readme_to_nostr"
]
]
},
{
"kind": 31124,
"content_fields": {
"name": "admin_ops",
"description": "Private operational procedures"
},
"tags": [
[
"d",
"admin_ops"
],
[
"app",
"didactyl"
],
[
"scope",
"private"
],
[
"slug",
"admin_ops"
]
]
},
{
"kind": 10123,
"content": "",
"tags": [
[
"a",
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"
],
[
"a",
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"
],
[
"app",
"didactyl"
],
[
"scope",
"public"
]
]
}
]
}
+79
View File
@@ -200,6 +200,79 @@ update_version_in_header() {
print_success "Updated version in src/main.h to $new_version"
}
# Function to update README Current Status version and release comment
update_readme_current_status() {
local new_version="$1"
local release_comment="$2"
if [[ ! -f "README.md" ]]; then
print_warning "README.md not found, skipping Current Status update"
return 0
fi
print_status "Updating README Current Status section..."
if python3 - "$new_version" "$release_comment" <<'PY'
import sys
from pathlib import Path
version = sys.argv[1]
comment = sys.argv[2]
path = Path("README.md")
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
heading_idx = None
for i, line in enumerate(lines):
if line.startswith("## Current Status"):
heading_idx = i
break
if heading_idx is None:
raise SystemExit("README Current Status heading not found")
lines[heading_idx] = f"## Current Status — {version}"
section_end = len(lines)
for i in range(heading_idx + 1, len(lines)):
if lines[i].startswith("## "):
section_end = i
break
comment_line = f"> Last release update: {version} — {comment}"
# Replace existing autogenerated release comment inside Current Status section
existing_idx = None
for i in range(heading_idx + 1, section_end):
if lines[i].startswith("> Last release update:"):
existing_idx = i
break
if existing_idx is not None:
lines[existing_idx] = comment_line
else:
insert_at = None
for i in range(heading_idx + 1, section_end):
if lines[i].startswith("**Active build"):
insert_at = i + 1
break
if insert_at is None:
insert_at = heading_idx + 1
payload = ["", comment_line]
lines[insert_at:insert_at] = payload
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
then
print_success "Updated README Current Status section"
else
print_error "Failed to update README Current Status section"
exit 1
fi
}
# Function to commit and push changes without creating a tag (tag already created)
git_commit_and_push_no_tag() {
print_status "Preparing git commit..."
@@ -427,6 +500,9 @@ main() {
export NEW_VERSION
fi
# Keep README Current Status in sync with the version and commit message
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
# Create new git tag BEFORE compilation so version picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
@@ -482,6 +558,9 @@ main() {
# Increment version based on type (default to patch)
increment_version "$VERSION_INCREMENT_TYPE"
# Keep README Current Status in sync with the version and commit message
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
# Create new git tag BEFORE compilation so version picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
+58
View File
@@ -13,6 +13,7 @@
#include "nostr_handler.h"
#include "tools.h"
#include "cjson/cJSON.h"
#include "debug.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static didactyl_config_t* g_cfg = NULL;
@@ -277,6 +278,59 @@ static int append_startup_events_context(cJSON* messages) {
return rc;
}
static int append_admin_identity_context(cJSON* messages) {
if (!messages || !g_cfg) {
return -1;
}
char admin_header[256];
snprintf(admin_header,
sizeof(admin_header),
"This is your administrator! Admin pubkey (hex): %s",
g_cfg->admin.pubkey ? g_cfg->admin.pubkey : "unknown");
if (append_simple_message(messages, "system", admin_header) != 0) {
return -1;
}
char* kind0 = nostr_handler_get_admin_kind0_context();
if (kind0) {
const char* prefix = "Administrator kind 0 profile content (JSON): ";
size_t n = strlen(prefix) + strlen(kind0) + 1U;
char* payload = (char*)malloc(n);
if (!payload) {
free(kind0);
return -1;
}
snprintf(payload, n, "%s%s", prefix, kind0);
int rc = append_simple_message(messages, "system", payload);
free(payload);
free(kind0);
if (rc != 0) {
return -1;
}
}
char* kind10002 = nostr_handler_get_admin_kind10002_context();
if (kind10002) {
const char* prefix = "Administrator kind 10002 relay-list content (JSON): ";
size_t n = strlen(prefix) + strlen(kind10002) + 1U;
char* payload = (char*)malloc(n);
if (!payload) {
free(kind10002);
return -1;
}
snprintf(payload, n, "%s%s", prefix, kind10002);
int rc = append_simple_message(messages, "system", payload);
free(payload);
free(kind10002);
if (rc != 0) {
return -1;
}
}
return 0;
}
static int append_recent_admin_dm_history(cJSON* messages, const char* current_message) {
if (!messages || !g_cfg) {
return -1;
@@ -509,6 +563,7 @@ void agent_on_message(const char* sender_pubkey_hex,
}
if (append_simple_message(messages, "system", g_system_context) != 0 ||
append_admin_identity_context(messages) != 0 ||
append_startup_events_context(messages) != 0 ||
append_recent_admin_dm_history(messages, message) != 0) {
cJSON_Delete(messages);
@@ -569,12 +624,15 @@ void agent_on_message(const char* sender_pubkey_hex,
for (int i = 0; i < resp.tool_call_count; i++) {
llm_tool_call_t* tc = &resp.tool_calls[i];
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
DEBUG_TRACE("[didactyl] tool call args: %s", tc->arguments_json ? tc->arguments_json : "{}");
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
}
DEBUG_TRACE("[didactyl] tool call result: %s", tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}");
if (append_tool_result_message(messages,
tc->id ? tc->id : "",
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
+39
View File
@@ -3,6 +3,8 @@
#include "config.h"
#include <ctype.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -10,6 +12,19 @@
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
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) {
@@ -486,9 +501,12 @@ void config_free(didactyl_config_t* 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));
config->tools.enabled = 1;
config->tools.max_turns = 8;
@@ -516,6 +534,7 @@ int config_load(const char* path, didactyl_config_t* config) {
char* json_buf = NULL;
size_t json_len = 0;
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
return -1;
}
@@ -523,6 +542,11 @@ int config_load(const char* path, didactyl_config_t* config) {
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;
}
@@ -535,26 +559,32 @@ int config_load(const char* path, didactyl_config_t* config) {
if (!keys || !cJSON_IsObject(keys) ||
!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: keys, admin, llm");
goto cleanup;
}
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
config_set_error("keys.nsec is required and must be a non-empty string");
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_relays(root, config) != 0) {
config_set_error("relays must be a non-empty array of relay URLs");
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') {
@@ -562,12 +592,15 @@ int config_load(const char* path, didactyl_config_t* config) {
}
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;
}
@@ -578,26 +611,32 @@ int config_load(const char* path, didactyl_config_t* config) {
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
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_startup_events(root, config) != 0) {
config_set_error("invalid startup_events configuration");
goto cleanup;
}
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.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");
goto cleanup;
}
+1
View File
@@ -86,6 +86,7 @@ typedef struct {
} didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config);
const char* config_last_error(void);
void config_free(didactyl_config_t* config);
#endif
+1
View File
@@ -47,6 +47,7 @@ int main(int argc, char** argv) {
didactyl_config_t cfg;
if (config_load(config_path, &cfg) != 0) {
fprintf(stderr, "Failed to load config: %s\n", config_path);
fprintf(stderr, "Config error: %s\n", config_last_error());
nostr_cleanup();
return 1;
}
+2 -2
View File
@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 7
#define DIDACTYL_VERSION "v0.0.7"
#define DIDACTYL_VERSION_PATCH 13
#define DIDACTYL_VERSION "v0.0.13"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+159 -5
View File
@@ -101,7 +101,8 @@ static int publish_kind_event_to_relays(int kind,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label);
const char* reason_label,
nostr_publish_result_t* out_result);
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data);
static int parse_kind3_wot_contacts(cJSON* tags);
static void upsert_kind1_note(time_t created_at, const char* content);
@@ -202,6 +203,118 @@ static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
return nostr_hex_to_bytes(hex, out_pubkey, 32) == 0 ? 0 : -1;
}
static int duplicate_relay_list(const char** relay_urls, int relay_count, char*** out_relays) {
if (!out_relays) {
return -1;
}
*out_relays = NULL;
if (!relay_urls || relay_count <= 0) {
return 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(relay_urls[i] ? relay_urls[i] : "");
if (!relays[i]) {
for (int j = 0; j < i; j++) {
free(relays[j]);
}
free(relays);
return -1;
}
}
*out_relays = relays;
return 0;
}
void nostr_handler_publish_result_free(nostr_publish_result_t* result) {
if (!result) {
return;
}
if (result->relays) {
for (int i = 0; i < result->relay_count; i++) {
free(result->relays[i]);
}
free(result->relays);
}
memset(result, 0, sizeof(*result));
}
static void extract_d_tag(cJSON* tags, char* out_d_tag, size_t out_size) {
if (!out_d_tag || out_size == 0) {
return;
}
out_d_tag[0] = '\0';
if (!tags || !cJSON_IsArray(tags)) {
return;
}
int tag_count = cJSON_GetArraySize(tags);
for (int i = 0; i < tag_count; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (!key || !value || !cJSON_IsString(key) || !cJSON_IsString(value) ||
!key->valuestring || !value->valuestring) {
continue;
}
if (strcmp(key->valuestring, "d") == 0) {
snprintf(out_d_tag, out_size, "%s", value->valuestring);
return;
}
}
}
static void fill_publish_result(nostr_publish_result_t* out_result,
int kind,
cJSON* tags,
const char* event_id_hex,
const char** relay_urls,
int relay_count,
int accepted_by_pool_count) {
if (!out_result) {
return;
}
nostr_handler_publish_result_free(out_result);
out_result->kind = kind;
out_result->relay_count = relay_count;
out_result->accepted_by_pool_count = accepted_by_pool_count;
out_result->success = accepted_by_pool_count > 0 ? 1 : 0;
if (event_id_hex) {
snprintf(out_result->event_id, sizeof(out_result->event_id), "%s", event_id_hex);
unsigned char event_id_bytes[32];
char note_bech32[128];
if (strlen(event_id_hex) == 64U &&
nostr_hex_to_bytes(event_id_hex, event_id_bytes, sizeof(event_id_bytes)) == 0 &&
nostr_key_to_bech32(event_id_bytes, "note", note_bech32) == 0) {
snprintf(out_result->note_uri, sizeof(out_result->note_uri), "nostr:%s", note_bech32);
}
}
extract_d_tag(tags, out_result->d_tag, sizeof(out_result->d_tag));
(void)kind;
(void)relay_urls;
(void)duplicate_relay_list(relay_urls, relay_count, &out_result->relays);
}
static cJSON* create_dm_tags_for_recipient(const char* recipient_pubkey_hex) {
cJSON* tags = cJSON_CreateArray();
if (!tags) {
@@ -821,7 +934,8 @@ static int publish_kind_event_to_relays(int kind,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label) {
const char* reason_label,
nostr_publish_result_t* out_result) {
if (!g_cfg || !g_pool || !content || !relay_urls || relay_count <= 0) {
return -1;
}
@@ -858,11 +972,23 @@ static int publish_kind_event_to_relays(int kind,
reason_label ? reason_label : "");
}
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
const char* event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring)
? event_id->valuestring
: "";
fill_publish_result(out_result,
kind,
tags,
event_id_hex,
relay_urls,
relay_count,
sent);
cJSON_Delete(event);
return sent > 0 ? 0 : -1;
}
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result) {
if (!g_cfg || !g_pool || !content) {
return -1;
}
@@ -887,7 +1013,13 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
return -1;
}
int rc = publish_kind_event_to_relays(kind, content, tags, connected_relays, connected_count, "manual_publish");
int rc = publish_kind_event_to_relays(kind,
content,
tags,
connected_relays,
connected_count,
"manual_publish",
out_result);
free(connected_relays);
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, connected_count);
@@ -966,7 +1098,7 @@ static void publish_pending_startup_events_for_relay_index(int relay_index, cons
}
const char* one_relay[1] = { relay_url };
if (publish_kind_event_to_relays(se->kind, se->content, tags, one_relay, 1, reason) == 0) {
if (publish_kind_event_to_relays(se->kind, se->content, tags, one_relay, 1, reason, NULL) == 0) {
g_startup_published[slot] = 1;
} else {
DEBUG_WARN("[didactyl] startup event publish failed for kind=%d relay=%s", se->kind, relay_url);
@@ -1024,6 +1156,28 @@ const char* nostr_handler_get_system_context(void) {
return g_system_context;
}
char* nostr_handler_get_admin_kind0_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_0) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
char* out = g_admin_kind0_json ? strdup(g_admin_kind0_json) : NULL;
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
char* nostr_handler_get_admin_kind10002_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_10002) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
char* out = g_admin_kind10002_json ? strdup(g_admin_kind10002_json) : NULL;
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
char* nostr_handler_get_admin_kind1_notes_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_1) {
return NULL;
+16 -1
View File
@@ -15,15 +15,30 @@ typedef void (*dm_callback_t)(const char* sender_pubkey_hex,
didactyl_sender_tier_t tier,
void* user_data);
typedef struct {
int success;
int kind;
int relay_count;
int accepted_by_pool_count;
char event_id[65];
char note_uri[256];
char naddr_uri[1024];
char d_tag[128];
char** relays;
} nostr_publish_result_t;
int nostr_handler_init(didactyl_config_t* config);
int nostr_handler_subscribe_admin_context(void);
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
const char* nostr_handler_get_system_context(void);
char* nostr_handler_get_admin_kind0_context(void);
char* nostr_handler_get_admin_kind10002_context(void);
char* nostr_handler_get_admin_kind1_notes_context(void);
int nostr_handler_is_wot_contact(const char* pubkey_hex);
void nostr_handler_cleanup(void);
+152 -4
View File
@@ -30,6 +30,72 @@ static char* json_success_with_message(const char* msg) {
return out;
}
static char* sanitize_json_string_controls(const char* in) {
if (!in) return NULL;
size_t len = strlen(in);
size_t cap = (len * 2U) + 1U;
char* out = (char*)malloc(cap);
if (!out) return NULL;
int in_string = 0;
int escaping = 0;
size_t j = 0;
for (size_t i = 0; i < len; i++) {
char c = in[i];
if (escaping) {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
escaping = 0;
continue;
}
if (c == '\\') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
if (in_string) escaping = 1;
continue;
}
if (c == '"') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
in_string = !in_string;
continue;
}
if (in_string && (c == '\n' || c == '\r' || c == '\t')) {
if (j + 2U >= cap) {
free(out);
return NULL;
}
out[j++] = '\\';
out[j++] = (c == '\n') ? 'n' : (c == '\r') ? 'r' : 't';
continue;
}
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
}
out[j] = '\0';
return out;
}
static int is_safe_relative_path(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
@@ -94,6 +160,16 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* p_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_content, "type", "string");
cJSON_AddItemToObject(t1_props, "content", p_content);
cJSON* p_tags = cJSON_CreateObject();
cJSON_AddStringToObject(p_tags, "type", "array");
cJSON_AddStringToObject(p_tags, "description", "Optional Nostr tags array, e.g. [[\"d\",\"slug\"],[\"t\",\"nostr\"]]");
cJSON* p_tags_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_tags_items, "type", "array");
cJSON* p_tag_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_tag_item, "type", "string");
cJSON_AddItemToObject(p_tags_items, "items", p_tag_item);
cJSON_AddItemToObject(p_tags, "items", p_tags_items);
cJSON_AddItemToObject(t1_props, "tags", p_tags);
cJSON_AddItemToArray(t1_required, cJSON_CreateString("kind"));
cJSON_AddItemToArray(t1_required, cJSON_CreateString("content"));
@@ -209,20 +285,92 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
static char* execute_nostr_post(const char* args_json) {
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error("invalid arguments JSON");
char* repaired_args_json = NULL;
if (!args && args_json) {
repaired_args_json = sanitize_json_string_controls(args_json);
if (repaired_args_json) {
args = cJSON_Parse(repaired_args_json);
}
}
if (!args) {
free(repaired_args_json);
return json_error("invalid arguments JSON");
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(args, "tags");
if (!kind || !cJSON_IsNumber(kind) || !content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
return json_error("nostr_post requires integer kind and string content");
}
if (tags && !cJSON_IsArray(tags)) {
cJSON_Delete(args);
return json_error("nostr_post tags must be an array when provided");
}
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble, content->valuestring, NULL);
cJSON* tags_dup = NULL;
if (tags) {
tags_dup = cJSON_Duplicate(tags, 1);
if (!tags_dup) {
cJSON_Delete(args);
return json_error("nostr_post failed to duplicate tags");
}
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble,
content->valuestring,
tags_dup,
&publish_result);
cJSON_Delete(tags_dup);
cJSON_Delete(args);
if (rc != 0) return json_error("nostr_post failed");
free(repaired_args_json);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error("nostr_post failed");
}
return json_success_with_message("nostr_post published");
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_post published");
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') {
cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
}
if (publish_result.naddr_uri[0] != '\0') {
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
}
if (publish_result.d_tag[0] != '\0') {
cJSON_AddStringToObject(out, "d_tag", publish_result.d_tag);
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
static char* execute_nostr_query(const char* args_json) {