Compare commits
5 Commits
v0.0.5
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230f591273 | ||
|
|
721b592b8f | ||
|
|
0aabb0b827 | ||
|
|
1c69a581d9 | ||
|
|
76842627dc |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,7 +4,8 @@
|
||||
/openclaw/
|
||||
/c-relay/
|
||||
/nips/
|
||||
|
||||
/config.json/
|
||||
test_keys.txt
|
||||
# Build artifacts
|
||||
/build/
|
||||
/didactyl
|
||||
|
||||
272
README.md
272
README.md
@@ -10,48 +10,60 @@ Didactyl boots on any internet-connected machine, connects to Nostr relays, list
|
||||
|
||||
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).
|
||||
|
||||
## Current Status
|
||||
**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.
|
||||
|
||||
**MVP — Working chat agent with relay connectivity and LLM integration.**
|
||||
## Current Status — v0.0.8
|
||||
|
||||
- Connects to configured Nostr relays with auto-reconnect
|
||||
- Publishes agent profile (kind 0 metadata)
|
||||
- Listens for NIP-04 encrypted DMs from authorized admin
|
||||
- Forwards messages to an OpenAI-compatible LLM API
|
||||
- Sends LLM responses back as encrypted DMs
|
||||
- Runtime logging: relay status, connection health, message flow
|
||||
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
|
||||
|
||||
**Next: Agentic tool-use system** — see [plans/didactyl_agentic.md](plans/didactyl_agentic.md).
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
- Uses kind `31120` startup content as live Soul at boot
|
||||
- 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 + 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
|
||||
|
||||
### Prerequisites
|
||||
### Download binary (recommended)
|
||||
|
||||
- GCC with C99 support
|
||||
- libcurl, libssl, libcrypto, libsecp256k1
|
||||
1. Download the latest release binary from Gitea: <https://git.laantungir.net/laantungir/didactyl/releases>
|
||||
2. Make it executable and run it:
|
||||
|
||||
```bash
|
||||
chmod +x ./didactyl_static_x86_64
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
```
|
||||
|
||||
### Build from source (optional)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Docker (for static binary build)
|
||||
- An OpenAI-compatible LLM API key (OpenAI, PPQ, Ollama, etc.)
|
||||
- A Nostr keypair (nsec)
|
||||
|
||||
### Build
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
make deps # builds nostr_core_lib
|
||||
make # builds didactyl
|
||||
./build_static.sh # builds a fully static MUSL binary via Docker
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
Edit `config.json`:
|
||||
Edit [`config.json`](config.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr"
|
||||
},
|
||||
"keys": {
|
||||
"nsec": "nsec1..."
|
||||
"nsec": "nsec1...",
|
||||
"npub": "npub1...",
|
||||
"npubHex": "<optional helper>",
|
||||
"nsecHex": "<optional helper>"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub1... or hex pubkey"
|
||||
@@ -61,104 +73,234 @@ Edit `config.json`:
|
||||
"wss://nos.lol"
|
||||
],
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"provider": "openai|ppq|...",
|
||||
"api_key": "sk-...",
|
||||
"model": "gpt-4o-mini",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 8,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65536,
|
||||
"working_directory": "."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"verify_signatures": true,
|
||||
"stranger_response": "I only respond to people in my web of trust.",
|
||||
"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": 31120,
|
||||
"content": "You are Didactyl...",
|
||||
"tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {"name": "long_form_note", "description": "..."},
|
||||
"tags": [["d", "long_form_note"], ["app", "didactyl"], ["scope", "public"], ["slug", "long_form_note"]]
|
||||
},
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [["a", "31123:<author-pubkey>:long_form_note"], ["app", "didactyl"], ["scope", "public"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Edit `SYSTEM.md` to define the agent's personality and instructions.
|
||||
`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
```
|
||||
|
||||
Options:
|
||||
```
|
||||
./didactyl --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl --context <path> # custom context file (default: ./SYSTEM.md)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
Send an encrypted DM to the agent's pubkey from the admin account using any Nostr client (Damus, Amethyst, Primal, etc.).
|
||||
Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ context │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬────┘ └────┬────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ LLM client │ │
|
||||
│ │ OpenAI-compatible chat API │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ context │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ LLM client │ │
|
||||
│ │ OpenAI-compatible chat API │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
Nostr Relays LLM API
|
||||
```
|
||||
|
||||
## Didactyl Kinds (Nostr)
|
||||
|
||||
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
|
||||
|
||||
- `31120` — **Soul** (private instruction baseline)
|
||||
- `d=soul`
|
||||
- `31123` — **Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`)
|
||||
- `d=<skill_slug>` (example: `d=long_form_note`)
|
||||
- `31124` — **Private Skill Definition** (private/internal procedures)
|
||||
- `d=<skill_slug>` (example: `d=admin_ops`)
|
||||
- `10123` — **Public Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected `31123` skills
|
||||
|
||||
## Skill Sharing & Discovery
|
||||
|
||||
Skills are shared across Nostr without any centralized registry or approval process.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`).
|
||||
|
||||
2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses.
|
||||
|
||||
3. **Discover**: A new user queries `{"kinds": [10123], "authors": [<my-follows>]}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed.
|
||||
|
||||
4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking.
|
||||
|
||||
### Why this works
|
||||
|
||||
- **No gatekeeper**: Skills are just Nostr events. Anyone can publish one.
|
||||
- **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes.
|
||||
- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score.
|
||||
- **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network.
|
||||
|
||||
## Startup
|
||||
|
||||
Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`.
|
||||
|
||||
Also used at startup:
|
||||
|
||||
- `0` — profile metadata
|
||||
- `10002` — relay list
|
||||
- `1` — optional startup note/status
|
||||
- `3` — contacts/follows (optional placeholder)
|
||||
|
||||
On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state.
|
||||
|
||||
## Runtime Context Model
|
||||
|
||||
Didactyl builds tier-aware context:
|
||||
|
||||
- **ADMIN** request context order:
|
||||
1. Soul message from kind `31120` (or fallback default)
|
||||
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
|
||||
|
||||
Every serialized LLM context payload is appended to [`context.log`](context.log).
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72):
|
||||
|
||||
- `nostr_post`
|
||||
- `nostr_query`
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── config.json # Agent configuration
|
||||
├── SYSTEM.md # Agent personality/instructions for LLM
|
||||
├── config.json # Agent/runtime config including startup_events + tools
|
||||
├── context.log # Appended outbound LLM context payloads
|
||||
├── Makefile # Build system
|
||||
├── build_static.sh # Preferred final build validation
|
||||
├── src/
|
||||
│ ├── main.c # Entry point, signal handling, daemon loop
|
||||
│ ├── config.c / .h # JSON config parsing, key decoding
|
||||
│ ├── context.c / .h # SYSTEM.md file loader
|
||||
│ ├── agent.c / .h # Core agent logic: receive → LLM → respond
|
||||
│ ├── 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)
|
||||
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, DMs
|
||||
│ └── secp_compat.c # secp256k1 API compatibility shim
|
||||
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, startup reconcile
|
||||
│ └── 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
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
All dependencies are statically linked into the binary at build time. No system libraries are required at runtime.
|
||||
|
||||
| Dependency | Purpose | Source |
|
||||
|---|---|---|
|
||||
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
|
||||
| cJSON | JSON parsing | Bundled in nostr_core_lib |
|
||||
| libcurl | HTTPS for LLM API calls | System package |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | System package |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | System package |
|
||||
| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [x] Relay pool with auto-reconnect and status logging
|
||||
- [x] Runtime diagnostics — relay health, message flow, LLM calls
|
||||
- [ ] **Agentic tool-use** — LLM can call tools (nostr_post, nostr_query, shell_exec)
|
||||
- [x] Per-relay startup publish on relay-connected transitions
|
||||
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
|
||||
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
|
||||
- [x] Context assembly with startup events + recent DM history
|
||||
- [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
|
||||
- [ ] NIP-44 encrypted private skills (`31124`)
|
||||
- [ ] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [ ] Blossom blob storage integration
|
||||
- [ ] Conversation memory on Nostr
|
||||
- [ ] Config and SYSTEM.md stored as Nostr events
|
||||
- [ ] Multi-turn conversation context window
|
||||
- [ ] Agent-to-agent communication
|
||||
|
||||
## License
|
||||
|
||||
24
SYSTEM.md
24
SYSTEM.md
@@ -1,24 +0,0 @@
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
## Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
## Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
|
||||
## Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
## Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- Do not reveal secrets from configuration or keys.
|
||||
286
plans/security_and_admin_context.md
Normal file
286
plans/security_and_admin_context.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Security & Admin Context Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add signature verification, privilege tiers, admin context awareness, and key isolation to Didactyl. Implemented in two phases.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Privilege Tiers, Signature Verification & Admin Context
|
||||
|
||||
### Privilege Tiers
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Incoming Event] --> B{Verify Signature via nostr_verify_event_signature}
|
||||
B -->|Invalid| C[Drop silently + DEBUG_WARN]
|
||||
B -->|Valid| D{Identify sender}
|
||||
D -->|Admin pubkey| E[ADMIN tier]
|
||||
D -->|In admin kind 3 contact list| F[WOT tier]
|
||||
D -->|Unknown| G[STRANGER tier]
|
||||
E --> H[Full tool access + all skills + full context]
|
||||
F --> I[Chat only - no tools - LLM response]
|
||||
G --> J[Configurable canned response OR silent ignore]
|
||||
```
|
||||
|
||||
| Tier | Identity | Tools | Response | Context |
|
||||
|------|----------|-------|----------|---------|
|
||||
| **ADMIN** | `config.admin.pubkey` exact match | All tools | Full LLM with all info except private key | Soul + startup events + admin notes |
|
||||
| **WOT** | Pubkey in admin kind `3` contact list | None | Chat-only LLM response | Soul + tier instruction |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response or silent ignore | N/A - no LLM call |
|
||||
|
||||
### Signature Verification
|
||||
|
||||
Every incoming event must be cryptographically verified before processing:
|
||||
|
||||
1. In `on_event()` in `src/nostr_handler.c`, call `nostr_verify_event_signature(event)` from `nostr_core_lib/nostr_core/nip001.h`
|
||||
2. If verification fails, drop the event silently with a `DEBUG_WARN` log
|
||||
3. This prevents relay-forged pubkey fields from being trusted
|
||||
4. Controlled by `security.verify_signatures` config flag (default: `true`)
|
||||
|
||||
### Stranger Response
|
||||
|
||||
Non-WoT senders get a **hardcoded configurable response** — no LLM tokens spent:
|
||||
|
||||
- Config field: `security.stranger_response`
|
||||
- Example: `"I only respond to people in my web of trust. My npub is npub12kvnu0dsa4alquu4l4zv9454cgjdr9248gd07ueq5x8yzeuan37ql02960"`
|
||||
- If `security.tiers.stranger.enabled` is `false`, silently ignore instead
|
||||
- If `true`, send the canned response as a DM
|
||||
|
||||
### Soul Update
|
||||
|
||||
Update the soul event content in `config.json` to:
|
||||
- Explicitly allow sharing the agent public key (npub) with anyone
|
||||
- Explicitly forbid revealing the private key (nsec) under any circumstances
|
||||
- Note: Phase 2 will remove the private key from agent memory entirely
|
||||
|
||||
### Admin Context Subscription
|
||||
|
||||
At startup, Didactyl subscribes to the administrator's Nostr activity to build context awareness.
|
||||
|
||||
#### What to subscribe to
|
||||
|
||||
| Kind | Purpose | Retention |
|
||||
|------|---------|-----------|
|
||||
| `0` | Admin profile metadata | Latest only - replaceable |
|
||||
| `3` | Admin contact/follow list - WoT source | Latest only - replaceable |
|
||||
| `10002` | Admin relay list | Latest only - replaceable |
|
||||
| `1` | Admin public notes | Last N posts - configurable, default 10 |
|
||||
|
||||
#### Storage
|
||||
|
||||
In-memory struct `admin_context_t` holding:
|
||||
- `kind_0_json` — latest kind 0 content string
|
||||
- `kind_3_contacts` — array of followed pubkey hex strings extracted from kind 3 `p` tags
|
||||
- `kind_3_contact_count` — count
|
||||
- `kind_10002_json` — latest kind 10002 content string
|
||||
- `kind_1_notes` — array of recent kind 1 content strings with timestamps
|
||||
- `kind_1_note_count` — count
|
||||
|
||||
#### Subscription mechanics
|
||||
|
||||
- Single subscription filter: `{"authors": ["<admin_pubkey>"], "kinds": [0, 3, 10002, 1], "limit": <configurable>}`
|
||||
- On EOSE: initial load complete, WoT set is ready
|
||||
- After EOSE: live updates as admin posts new content
|
||||
- Kind 3 `p` tags are extracted into a sorted array for O(log n) WoT lookup
|
||||
|
||||
#### Context injection
|
||||
|
||||
- Admin kind 1 notes are injected into the LLM context as a system message: "Administrator recent public notes:" followed by the content
|
||||
- Only injected for ADMIN-tier conversations
|
||||
|
||||
### Config Schema — Phase 1
|
||||
|
||||
New sections in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"verify_signatures": true,
|
||||
"stranger_response": "I only respond to people in my web of trust.",
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `security.verify_signatures` — master toggle for signature verification (default: `true`)
|
||||
- `security.stranger_response` — canned DM text for non-WoT senders (default: empty = silent ignore)
|
||||
- `security.tiers.wot.enabled` — whether WoT contacts can converse (default: `true`)
|
||||
- `security.tiers.stranger.enabled` — whether strangers get the canned response (default: `true`; if `false`, silent ignore)
|
||||
- `admin_context.enabled` — whether to subscribe to admin activity (default: `true`)
|
||||
- `admin_context.subscribe_kinds` — which kinds to track (default: `[0, 3, 10002, 1]`)
|
||||
- `admin_context.kind_1_limit` — how many kind 1 notes to retain (default: `10`)
|
||||
|
||||
### Phase 1 Implementation Steps
|
||||
|
||||
1. **Add signature verification to `on_event()`**
|
||||
- File: `src/nostr_handler.c`
|
||||
- Call `nostr_verify_event_signature(event)` at the top of `on_event()`
|
||||
- If it returns non-zero, log and drop
|
||||
|
||||
2. **Add `admin_context_t` struct and security config structs**
|
||||
- File: `src/config.h`
|
||||
- New structs for admin context storage, security config, tier config
|
||||
|
||||
3. **Parse new config sections**
|
||||
- File: `src/config.c`
|
||||
- Parse `security` and `admin_context` from config.json
|
||||
- Apply defaults when sections are missing
|
||||
|
||||
4. **Add admin context subscription**
|
||||
- File: `src/nostr_handler.c`
|
||||
- New function `nostr_handler_subscribe_admin_context()`
|
||||
- Callback that populates `admin_context_t` from incoming events
|
||||
- Extract kind 3 `p` tags into WoT set
|
||||
- Store kind 1 notes in a ring buffer capped at `kind_1_limit`
|
||||
|
||||
5. **Add WoT lookup function**
|
||||
- File: `src/nostr_handler.c`
|
||||
- `int nostr_handler_is_wot_contact(const char* pubkey_hex)` — checks if pubkey is in admin kind 3 list
|
||||
|
||||
6. **Refactor `on_event()` for tier dispatch**
|
||||
- File: `src/nostr_handler.c`
|
||||
- After signature verification, classify sender into ADMIN / WOT / STRANGER
|
||||
- Pass tier information to the callback — extend `dm_callback_t` signature to include tier enum
|
||||
- STRANGER with `stranger.enabled`: send canned response directly, no callback
|
||||
- STRANGER without: drop silently
|
||||
|
||||
7. **Refactor `agent_on_message()` for tier-aware behavior**
|
||||
- File: `src/agent.c`
|
||||
- ADMIN: current behavior — all tools, full context, admin notes injected
|
||||
- WOT: build messages with soul + tier instruction, no tools_json, LLM chat-only
|
||||
- STRANGER: should not reach here — handled in nostr_handler
|
||||
|
||||
8. **Inject admin context into ADMIN conversations**
|
||||
- File: `src/agent.c`
|
||||
- After startup events context, append admin kind 1 notes as a system message
|
||||
- Only for ADMIN tier
|
||||
|
||||
9. **Update soul content in `config.json`**
|
||||
- Allow sharing public key with anyone
|
||||
- Forbid revealing private key under any circumstances
|
||||
|
||||
10. **Update `config.json` with new sections**
|
||||
- Add `security` and `admin_context` sections
|
||||
|
||||
11. **Update `README.md`**
|
||||
- Document privilege tiers
|
||||
- Document new config sections
|
||||
- Update architecture diagram
|
||||
|
||||
12. **Build and validate**
|
||||
- Run `./build_static.sh`
|
||||
- Verify compilation succeeds
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — NIP-46 Remote Signer for Key Isolation
|
||||
|
||||
### Problem
|
||||
|
||||
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically:
|
||||
- Read `config.json` via `file_read` (contains nsec)
|
||||
- Execute `cat /proc/self/maps` or similar via `shell_exec`
|
||||
- Exfiltrate the key via `nostr_post`
|
||||
|
||||
### Solution: NIP-46 Remote Signer
|
||||
|
||||
Remove the private key from Didactyl entirely. All cryptographic operations go through a NIP-46 remote signer.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Didactyl Agent] -->|kind 24133 sign_event / nip04_encrypt / nip04_decrypt| B[Remote Signer]
|
||||
B -->|signed events / ciphertext / plaintext| A
|
||||
A -.->|NO private key in memory| A
|
||||
B -->|Private key held ONLY here| B
|
||||
C[config.json] -.->|bunker URL only, no nsec| A
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
1. **Remote signer process** — a separate lightweight daemon that:
|
||||
- Holds the private key only in working memory (never on disk after initial load)
|
||||
- Listens for NIP-46 requests via relay (kind `24133`)
|
||||
- Responds with signed events, encrypted/decrypted content
|
||||
- Can run on the same machine or a different one
|
||||
|
||||
2. **Didactyl as NIP-46 client** — instead of calling `nostr_create_and_sign_event()` directly:
|
||||
- Sends `sign_event` RPC to the remote signer
|
||||
- Sends `nip04_encrypt` / `nip04_decrypt` RPC for DM handling
|
||||
- Only holds a disposable `client-keypair` for NIP-46 communication
|
||||
|
||||
3. **Config change** — `config.json` replaces `keys.nsec` with:
|
||||
```json
|
||||
{
|
||||
"keys": {
|
||||
"bunker_url": "bunker://<remote-signer-pubkey>?relay=wss://relay.example.com&secret=<secret>",
|
||||
"npub": "npub1..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NIP-46 Methods Required
|
||||
|
||||
| Method | Current direct call | Used for |
|
||||
|--------|-------------------|----------|
|
||||
| `sign_event` | `nostr_create_and_sign_event()` | Publishing all events |
|
||||
| `nip04_encrypt` | `nostr_nip04_encrypt()` | Sending DMs |
|
||||
| `nip04_decrypt` | `nostr_nip04_decrypt()` | Receiving DMs |
|
||||
| `get_public_key` | `config.keys.public_key_hex` | Identity |
|
||||
|
||||
### Implementation Scope
|
||||
|
||||
1. **Build NIP-46 client protocol in nostr_core_lib**
|
||||
- NIP-44 encryption for request/response content
|
||||
- Kind 24133 event creation and parsing
|
||||
- JSON-RPC request/response handling
|
||||
- Connection establishment (bunker URL parsing)
|
||||
- Async request/response matching by request ID
|
||||
|
||||
2. **Build or integrate a remote signer**
|
||||
- Option A: Build a minimal signer binary in C (ships with Didactyl)
|
||||
- Option B: Support existing signers (nsecBunker, etc.)
|
||||
- Option C: Both — ship a minimal one, support external ones
|
||||
|
||||
3. **Refactor all signing/encryption calls**
|
||||
- Every `nostr_create_and_sign_event()` → `nip46_sign_event()` (async RPC)
|
||||
- Every `nostr_nip04_encrypt()` → `nip46_nip04_encrypt()` (async RPC)
|
||||
- Every `nostr_nip04_decrypt()` → `nip46_nip04_decrypt()` (async RPC)
|
||||
- These become blocking calls that send a request and wait for a response
|
||||
|
||||
4. **Startup flow change**
|
||||
- Parse bunker URL from config
|
||||
- Generate client keypair
|
||||
- Send `connect` request to remote signer
|
||||
- Call `get_public_key` to learn user pubkey
|
||||
- Proceed with normal startup
|
||||
|
||||
5. **Backward compatibility**
|
||||
- If `keys.nsec` is present, use direct signing (current behavior)
|
||||
- If `keys.bunker_url` is present, use NIP-46 remote signing
|
||||
- This allows gradual migration
|
||||
|
||||
### Security Properties Achieved
|
||||
|
||||
- Private key never in Didactyl process memory
|
||||
- Private key never on disk in config.json
|
||||
- LLM tools (shell_exec, file_read) cannot access the key
|
||||
- Even full process compromise of Didactyl does not leak the signing key
|
||||
- Remote signer can be on a separate hardened machine
|
||||
- Remote signer can implement rate limiting, approval flows, etc.
|
||||
104
src/agent.c
104
src/agent.c
@@ -277,6 +277,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;
|
||||
@@ -434,32 +487,52 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data) {
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
if (!g_cfg || !g_system_context || !sender_pubkey_hex || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex);
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] incoming message from %.16s... tier=%d\n", sender_pubkey_hex, (int)tier);
|
||||
|
||||
if (agent_message_is_debounced(sender_pubkey_hex, message)) {
|
||||
fprintf(stdout, "[didactyl] debounced duplicate inbound message from %.16s...\n", sender_pubkey_hex);
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex);
|
||||
int allow_tools = (tier == DIDACTYL_SENDER_ADMIN) && g_cfg->tools.enabled && g_cfg->security.admin.tools_enabled;
|
||||
|
||||
if (!g_cfg->tools.enabled) {
|
||||
size_t context_len = strlen("system:\n\nuser:\n") + strlen(g_system_context) + strlen(message) + 1U;
|
||||
if (!allow_tools) {
|
||||
const char* tier_prefix = (tier == DIDACTYL_SENDER_WOT)
|
||||
? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier."
|
||||
: "You are responding in chat-only mode. Tool use is disabled.";
|
||||
|
||||
size_t ctx_len = strlen(g_system_context) + strlen("\n\n") + strlen(tier_prefix) + 1U;
|
||||
char* system_for_chat = (char*)malloc(ctx_len);
|
||||
if (!system_for_chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_for_chat, ctx_len, "%s\n\n%s", g_system_context, tier_prefix);
|
||||
|
||||
size_t context_len = strlen("system:\n\nuser:\n") + strlen(system_for_chat) + strlen(message) + 1U;
|
||||
char* plain_context = (char*)malloc(context_len);
|
||||
if (plain_context) {
|
||||
snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", g_system_context, message);
|
||||
snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", system_for_chat, message);
|
||||
append_context_log(sender_pubkey_hex, "llm_chat", plain_context);
|
||||
free(plain_context);
|
||||
}
|
||||
|
||||
char* response = llm_chat(g_system_context, message);
|
||||
char* response = llm_chat(system_for_chat, message);
|
||||
free(system_for_chat);
|
||||
if (!response) {
|
||||
const char* fallback = "I could not get a response from the LLM right now.";
|
||||
fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n");
|
||||
@@ -489,9 +562,22 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
|
||||
}
|
||||
|
||||
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 ||
|
||||
append_simple_message(messages, "user", message) != 0) {
|
||||
append_recent_admin_dm_history(messages, message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
char* admin_notes = nostr_handler_get_admin_kind1_notes_context();
|
||||
if (admin_notes) {
|
||||
(void)append_simple_message(messages, "system", admin_notes);
|
||||
free(admin_notes);
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "user", message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
#define OPEN_WING_AGENT_H
|
||||
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data);
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
void* user_data);
|
||||
void agent_cleanup(void);
|
||||
|
||||
#endif
|
||||
255
src/config.c
255
src/config.c
@@ -161,6 +161,214 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
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 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* slug = NULL;
|
||||
cJSON* slug_val = find_tag_value_string(tags, "slug");
|
||||
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
|
||||
slug = slug_val->valuestring;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
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') {
|
||||
slug = name->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return set_tag_value_string(tags, "d", slug);
|
||||
}
|
||||
|
||||
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events");
|
||||
if (!arr || !cJSON_IsArray(arr)) {
|
||||
@@ -209,6 +417,9 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -286,6 +497,22 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
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;
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
@@ -301,34 +528,16 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
|
||||
int rc = -1;
|
||||
|
||||
cJSON* agent = cJSON_GetObjectItemCaseSensitive(root, "agent");
|
||||
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
|
||||
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
|
||||
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
|
||||
if (!agent || !cJSON_IsObject(agent) ||
|
||||
!keys || !cJSON_IsObject(keys) ||
|
||||
if (!keys || !cJSON_IsObject(keys) ||
|
||||
!admin || !cJSON_IsObject(admin) ||
|
||||
!llm || !cJSON_IsObject(llm)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(agent, "name", config->profile.name, sizeof(config->profile.name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "display_name", config->profile.display_name, sizeof(config->profile.display_name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "about", config->profile.about, sizeof(config->profile.about), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "picture", config->profile.picture, sizeof(config->profile.picture), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "nip05", config->profile.nip05, sizeof(config->profile.nip05), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
@@ -372,6 +581,14 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_security_config(root, config) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_admin_context_config(root, config) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_startup_events(root, config) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
33
src/config.h
33
src/config.h
@@ -9,13 +9,7 @@
|
||||
#define OW_MAX_KEY_LEN 256
|
||||
#define OW_MAX_MODEL_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char name[OW_MAX_NAME_LEN];
|
||||
char display_name[OW_MAX_NAME_LEN];
|
||||
char about[OW_MAX_ABOUT_LEN];
|
||||
char picture[OW_MAX_URL_LEN];
|
||||
char nip05[OW_MAX_URL_LEN];
|
||||
} agent_profile_t;
|
||||
#define OW_MAX_STRANGER_RESPONSE_LEN 512
|
||||
|
||||
typedef struct {
|
||||
char nsec[OW_MAX_KEY_LEN];
|
||||
@@ -57,13 +51,36 @@ typedef struct {
|
||||
} startup_event_t;
|
||||
|
||||
typedef struct {
|
||||
agent_profile_t profile;
|
||||
int enabled;
|
||||
int tools_enabled;
|
||||
} security_tier_config_t;
|
||||
|
||||
typedef struct {
|
||||
int verify_signatures;
|
||||
char stranger_response[OW_MAX_STRANGER_RESPONSE_LEN];
|
||||
security_tier_config_t admin;
|
||||
security_tier_config_t wot;
|
||||
security_tier_config_t stranger;
|
||||
} security_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int track_kind_0;
|
||||
int track_kind_3;
|
||||
int track_kind_10002;
|
||||
int track_kind_1;
|
||||
int kind_1_limit;
|
||||
} admin_context_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
tools_config_t tools;
|
||||
security_config_t security;
|
||||
admin_context_config_t admin_context;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
} didactyl_config_t;
|
||||
|
||||
@@ -84,6 +84,12 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
|
||||
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
|
||||
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
|
||||
|
||||
@@ -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 5
|
||||
#define DIDACTYL_VERSION "v0.0.5"
|
||||
#define DIDACTYL_VERSION_PATCH 9
|
||||
#define DIDACTYL_VERSION "v0.0.9"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -24,6 +24,21 @@ static char* g_system_context = NULL;
|
||||
static unsigned char* g_startup_published = NULL;
|
||||
static int g_startup_publish_tracking_enabled = 0;
|
||||
|
||||
static char* g_admin_kind0_json = NULL;
|
||||
static char* g_admin_kind10002_json = NULL;
|
||||
static char** g_admin_wot_contacts = NULL;
|
||||
static int g_admin_wot_contact_count = 0;
|
||||
|
||||
typedef struct {
|
||||
time_t created_at;
|
||||
char* content;
|
||||
} admin_kind1_note_t;
|
||||
|
||||
static admin_kind1_note_t* g_admin_kind1_notes = NULL;
|
||||
static int g_admin_kind1_note_count = 0;
|
||||
|
||||
static pthread_mutex_t g_admin_ctx_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
#define DM_DEDUP_CACHE_SIZE 256
|
||||
|
||||
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
|
||||
@@ -87,6 +102,9 @@ static int publish_kind_event_to_relays(int kind,
|
||||
const char** relay_urls,
|
||||
int relay_count,
|
||||
const char* reason_label);
|
||||
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);
|
||||
|
||||
static void log_relay_statuses(const char* reason) {
|
||||
if (!g_pool || !g_cfg) {
|
||||
@@ -258,6 +276,12 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) {
|
||||
DEBUG_WARN("[didactyl] dropped event with invalid signature via %s",
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
@@ -277,13 +301,6 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) {
|
||||
fprintf(stdout, "[didactyl] ignored DM from unauthorized pubkey %.16s... via %s\n",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
return;
|
||||
}
|
||||
|
||||
char recipient_pubkey_hex[65] = {0};
|
||||
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
|
||||
return;
|
||||
@@ -293,6 +310,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER;
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0) {
|
||||
tier = DIDACTYL_SENDER_ADMIN;
|
||||
} else if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(pubkey->valuestring)) {
|
||||
tier = DIDACTYL_SENDER_WOT;
|
||||
}
|
||||
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
if (!g_cfg->security.stranger.enabled) {
|
||||
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->security.stranger_response[0] != '\0') {
|
||||
(void)nostr_handler_send_dm(pubkey->valuestring, g_cfg->security.stranger_response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned char sender_pubkey[32];
|
||||
if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) {
|
||||
return;
|
||||
@@ -324,12 +362,13 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s",
|
||||
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d",
|
||||
(int)kind->valuedouble,
|
||||
event_id_hex ? event_id_hex : "<no-id>",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data);
|
||||
relay_url ? relay_url : "unknown relay",
|
||||
(int)tier);
|
||||
g_dm_callback(pubkey->valuestring, decrypted, tier, g_dm_user_data);
|
||||
free(decrypted);
|
||||
}
|
||||
|
||||
@@ -339,6 +378,162 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)user_data;
|
||||
}
|
||||
|
||||
static void free_admin_context_locked(void) {
|
||||
free(g_admin_kind0_json);
|
||||
g_admin_kind0_json = NULL;
|
||||
free(g_admin_kind10002_json);
|
||||
g_admin_kind10002_json = NULL;
|
||||
|
||||
if (g_admin_wot_contacts) {
|
||||
for (int i = 0; i < g_admin_wot_contact_count; i++) {
|
||||
free(g_admin_wot_contacts[i]);
|
||||
}
|
||||
free(g_admin_wot_contacts);
|
||||
}
|
||||
g_admin_wot_contacts = NULL;
|
||||
g_admin_wot_contact_count = 0;
|
||||
|
||||
if (g_admin_kind1_notes) {
|
||||
for (int i = 0; i < g_admin_kind1_note_count; i++) {
|
||||
free(g_admin_kind1_notes[i].content);
|
||||
}
|
||||
free(g_admin_kind1_notes);
|
||||
}
|
||||
g_admin_kind1_notes = NULL;
|
||||
g_admin_kind1_note_count = 0;
|
||||
}
|
||||
|
||||
static int parse_kind3_wot_contacts(cJSON* tags) {
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (g_admin_wot_contacts) {
|
||||
for (int i = 0; i < g_admin_wot_contact_count; i++) {
|
||||
free(g_admin_wot_contacts[i]);
|
||||
}
|
||||
free(g_admin_wot_contacts);
|
||||
g_admin_wot_contacts = NULL;
|
||||
g_admin_wot_contact_count = 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* 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, "p") != 0 || strlen(val->valuestring) != 64U) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char* dup = strdup(val->valuestring);
|
||||
if (!dup) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char** grown = (char**)realloc(g_admin_wot_contacts, (size_t)(g_admin_wot_contact_count + 1) * sizeof(char*));
|
||||
if (!grown) {
|
||||
free(dup);
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_admin_wot_contacts = grown;
|
||||
g_admin_wot_contacts[g_admin_wot_contact_count++] = dup;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void upsert_kind1_note(time_t created_at, const char* content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
int limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
|
||||
if (limit > 256) {
|
||||
limit = 256;
|
||||
}
|
||||
|
||||
char* dup = strdup(content);
|
||||
if (!dup) {
|
||||
return;
|
||||
}
|
||||
|
||||
admin_kind1_note_t* grown = (admin_kind1_note_t*)realloc(g_admin_kind1_notes,
|
||||
(size_t)(g_admin_kind1_note_count + 1) * sizeof(admin_kind1_note_t));
|
||||
if (!grown) {
|
||||
free(dup);
|
||||
return;
|
||||
}
|
||||
|
||||
g_admin_kind1_notes = grown;
|
||||
g_admin_kind1_notes[g_admin_kind1_note_count].created_at = created_at;
|
||||
g_admin_kind1_notes[g_admin_kind1_note_count].content = dup;
|
||||
g_admin_kind1_note_count++;
|
||||
|
||||
while (g_admin_kind1_note_count > limit) {
|
||||
free(g_admin_kind1_notes[0].content);
|
||||
memmove(&g_admin_kind1_notes[0],
|
||||
&g_admin_kind1_notes[1],
|
||||
(size_t)(g_admin_kind1_note_count - 1) * sizeof(admin_kind1_note_t));
|
||||
g_admin_kind1_note_count--;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)relay_url;
|
||||
(void)user_data;
|
||||
|
||||
if (!event || !g_cfg || !g_cfg->admin_context.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
|
||||
if (!kind || !pubkey || !cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !pubkey->valuestring) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int k = (int)kind->valuedouble;
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
|
||||
if (k == 0 && g_cfg->admin_context.track_kind_0 && content && cJSON_IsString(content) && content->valuestring) {
|
||||
free(g_admin_kind0_json);
|
||||
g_admin_kind0_json = strdup(content->valuestring);
|
||||
} else if (k == 3 && g_cfg->admin_context.track_kind_3 && tags && cJSON_IsArray(tags)) {
|
||||
(void)parse_kind3_wot_contacts(tags);
|
||||
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && content && cJSON_IsString(content) && content->valuestring) {
|
||||
free(g_admin_kind10002_json);
|
||||
g_admin_kind10002_json = strdup(content->valuestring);
|
||||
} else if (k == 1 && g_cfg->admin_context.track_kind_1 && content && cJSON_IsString(content) && content->valuestring) {
|
||||
time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL);
|
||||
upsert_kind1_note(ts, content->valuestring);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
}
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return -1;
|
||||
@@ -390,72 +585,102 @@ int nostr_handler_init(didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int nostr_handler_publish_profile(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: build metadata payload");
|
||||
cJSON* profile = cJSON_CreateObject();
|
||||
if (!profile) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(profile, "name", g_cfg->profile.name);
|
||||
cJSON_AddStringToObject(profile, "display_name", g_cfg->profile.display_name);
|
||||
cJSON_AddStringToObject(profile, "about", g_cfg->profile.about);
|
||||
cJSON_AddStringToObject(profile, "picture", g_cfg->profile.picture);
|
||||
if (g_cfg->profile.nip05[0] != '\0') {
|
||||
cJSON_AddStringToObject(profile, "nip05", g_cfg->profile.nip05);
|
||||
}
|
||||
|
||||
char* content = cJSON_PrintUnformatted(profile);
|
||||
cJSON_Delete(profile);
|
||||
if (!content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: sign kind-0 event");
|
||||
cJSON* event = nostr_create_and_sign_event(0, content, NULL, g_cfg->keys.private_key, time(NULL));
|
||||
free(content);
|
||||
|
||||
if (!event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
|
||||
if (!connected_relays) {
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int connected_count = 0;
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
connected_relays[connected_count++] = g_cfg->relays[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (connected_count == 0) {
|
||||
DEBUG_WARN("[didactyl] publish_profile: no connected relays yet, deferring publish");
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
int nostr_handler_subscribe_admin_context(void) {
|
||||
if (!g_cfg || !g_pool || !g_cfg->admin_context.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: publishing to %d connected relay(s)", connected_count);
|
||||
int sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
connected_relays,
|
||||
connected_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
int rc = 0;
|
||||
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
DEBUG_INFO("[didactyl] publish profile result: sent_to=%d connected relay(s)", sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
cJSON* profile_filter = cJSON_CreateObject();
|
||||
cJSON* profile_kinds = cJSON_CreateArray();
|
||||
cJSON* profile_authors = cJSON_CreateArray();
|
||||
if (!profile_filter || !profile_kinds || !profile_authors) {
|
||||
cJSON_Delete(profile_filter);
|
||||
cJSON_Delete(profile_kinds);
|
||||
cJSON_Delete(profile_authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (g_cfg->admin_context.track_kind_0) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(0));
|
||||
if (g_cfg->admin_context.track_kind_3) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(3));
|
||||
if (g_cfg->admin_context.track_kind_10002) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(10002));
|
||||
|
||||
if (cJSON_GetArraySize(profile_kinds) > 0) {
|
||||
cJSON_AddItemToObject(profile_filter, "kinds", profile_kinds);
|
||||
cJSON_AddItemToArray(profile_authors, cJSON_CreateString(g_cfg->admin.pubkey));
|
||||
cJSON_AddItemToObject(profile_filter, "authors", profile_authors);
|
||||
cJSON_AddNumberToObject(profile_filter, "limit", 32);
|
||||
|
||||
nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
profile_filter,
|
||||
on_admin_context_event,
|
||||
on_eose,
|
||||
NULL,
|
||||
0,
|
||||
1,
|
||||
NOSTR_POOL_EOSE_FULL_SET,
|
||||
30,
|
||||
120);
|
||||
|
||||
if (!profile_sub) {
|
||||
rc = -1;
|
||||
}
|
||||
} else {
|
||||
cJSON_Delete(profile_kinds);
|
||||
cJSON_Delete(profile_authors);
|
||||
}
|
||||
cJSON_Delete(profile_filter);
|
||||
|
||||
if (g_cfg->admin_context.track_kind_1) {
|
||||
cJSON* notes_filter = cJSON_CreateObject();
|
||||
cJSON* notes_kinds = cJSON_CreateArray();
|
||||
cJSON* notes_authors = cJSON_CreateArray();
|
||||
if (!notes_filter || !notes_kinds || !notes_authors) {
|
||||
cJSON_Delete(notes_filter);
|
||||
cJSON_Delete(notes_kinds);
|
||||
cJSON_Delete(notes_authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int kind1_limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
|
||||
if (kind1_limit > 256) {
|
||||
kind1_limit = 256;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(notes_kinds, cJSON_CreateNumber(1));
|
||||
cJSON_AddItemToObject(notes_filter, "kinds", notes_kinds);
|
||||
cJSON_AddItemToArray(notes_authors, cJSON_CreateString(g_cfg->admin.pubkey));
|
||||
cJSON_AddItemToObject(notes_filter, "authors", notes_authors);
|
||||
cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit);
|
||||
|
||||
nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
notes_filter,
|
||||
on_admin_context_event,
|
||||
on_eose,
|
||||
NULL,
|
||||
0,
|
||||
1,
|
||||
NOSTR_POOL_EOSE_FULL_SET,
|
||||
30,
|
||||
120);
|
||||
|
||||
cJSON_Delete(notes_filter);
|
||||
if (!notes_sub) {
|
||||
rc = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rc == 0) {
|
||||
DEBUG_INFO("[didactyl] admin context subscriptions active for admin %.16s...", g_cfg->admin.pubkey);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
@@ -799,6 +1024,80 @@ 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;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
|
||||
if (g_admin_kind1_note_count <= 0 || !g_admin_kind1_notes) {
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t total = strlen("Administrator recent public notes:\n") + 1U;
|
||||
for (int i = 0; i < g_admin_kind1_note_count; i++) {
|
||||
total += strlen("- ") + strlen(g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : "") + 1U;
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(total);
|
||||
if (!out) {
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
out[0] = '\0';
|
||||
strcat(out, "Administrator recent public notes:\n");
|
||||
for (int i = 0; i < g_admin_kind1_note_count; i++) {
|
||||
strcat(out, "- ");
|
||||
strcat(out, g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : "");
|
||||
strcat(out, "\n");
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
return out;
|
||||
}
|
||||
|
||||
int nostr_handler_is_wot_contact(const char* pubkey_hex) {
|
||||
if (!pubkey_hex || strlen(pubkey_hex) != 64U) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
int found = 0;
|
||||
for (int i = 0; i < g_admin_wot_contact_count; i++) {
|
||||
if (g_admin_wot_contacts[i] && strcmp(g_admin_wot_contacts[i], pubkey_hex) == 0) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
return found;
|
||||
}
|
||||
|
||||
int nostr_handler_poll(int timeout_ms) {
|
||||
if (!g_pool) {
|
||||
return -1;
|
||||
@@ -840,4 +1139,8 @@ void nostr_handler_cleanup(void) {
|
||||
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
|
||||
g_seen_dm_count = 0;
|
||||
g_seen_dm_next = 0;
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
free_admin_context_locked();
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data);
|
||||
typedef enum {
|
||||
DIDACTYL_SENDER_ADMIN = 1,
|
||||
DIDACTYL_SENDER_WOT = 2,
|
||||
DIDACTYL_SENDER_STRANGER = 3
|
||||
} didactyl_sender_tier_t;
|
||||
|
||||
typedef void (*dm_callback_t)(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
int nostr_handler_publish_profile(void);
|
||||
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);
|
||||
@@ -15,6 +24,10 @@ 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);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user