From 1d66a3a97e092a6e04d85132d0f7243ef5f4be48 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 9 Mar 2026 20:58:52 -0400 Subject: [PATCH] v0.0.61 - feat: add webhook cron and chain trigger types --- README.md | 4 +- docs/API.md | 39 +++ docs/SKILLS.md | 60 +++- plans/new_trigger_types.md | 689 +++++++++++++++++++++++++++++++++++++ src/agent.c | 8 + src/http_api.c | 183 ++++++++++ src/main.h | 4 +- src/nostr_handler.c | 8 +- src/tools.c | 18 +- src/trigger_manager.c | 436 ++++++++++++++++++++++- src/trigger_manager.h | 25 ++ 11 files changed, 1440 insertions(+), 34 deletions(-) create mode 100644 plans/new_trigger_types.md diff --git a/README.md b/README.md index c9aabba..c4bb592 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fa Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.0.60 +## Current Status — v0.0.61 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.60 — Remove forced final trigger DM, add skill_edit tool, and complete d_tag terminology migration +> Last release update: v0.0.61 — feat: add webhook cron and chain trigger types - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/docs/API.md b/docs/API.md index 87821e3..b8c7de1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -190,6 +190,45 @@ Returns the context broken into labeled, individually-sized parts. Useful for un --- +### POST /api/trigger/:d_tag + +Fires a webhook-triggered skill by `d_tag`. + +**Path params:** + +| Param | Required | Description | +|---|---|---| +| `d_tag` | yes | Skill `d` tag of a skill configured with `trigger=webhook` | + +**Request body:** + +Optional JSON payload; if present it is passed into the synthetic trigger event as `payload`. + +```json +{ + "source": "external-cron", + "note": "run maintenance sweep" +} +``` + +**Response:** + +```json +{ + "success": true, + "d_tag": "maintenance-sweep", + "fired": true +} +``` + +**Notes:** + +- Returns `404` if no skill exists for `d_tag` or no active trigger is registered. +- Returns `400` if the matched skill is not a `webhook` trigger or is disabled. +- Trigger execution uses the same trigger cooldown policy as other trigger types. + +--- + ### POST /api/prompt/run-simple Submit a system prompt and user message for a simple LLM call with no tools. Useful for quick prompt iteration. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 9471ff6..5af24c8 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -204,14 +204,14 @@ sequenceDiagram ## Triggered Skills -A triggered skill has a Nostr subscription filter attached. When matching events arrive, the skill executes automatically. +A triggered skill has a trigger source attached. Didactyl supports `nostr-subscription`, `webhook`, `cron`, and `chain` trigger types. ### Trigger Tags | Tag | Required | Description | |---|---|---| -| `trigger` | Yes | Trigger type: `nostr-subscription` | -| `filter` | Yes | JSON-encoded Nostr subscription filter | +| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` | +| `filter` | Yes | Type-specific filter (Nostr JSON filter, webhook placeholder payload, cron expression, or source skill `d` tag) | | `action` | No | `template` or `llm` (default: `llm`) | | `enabled` | No | Whether active (default: `true`) | @@ -251,6 +251,44 @@ The skill content defines the execution context. The triggering event is availab } ``` +### Trigger Types + +#### `nostr-subscription` + +`filter` is a JSON-encoded Nostr subscription filter. + +```json +["trigger", "nostr-subscription"], +["filter", "{\"#p\":[\"\"],\"kinds\":[1]}"] +``` + +#### `webhook` + +`filter` is required for schema compatibility and can be a simple placeholder like `{}`; webhook firing happens via HTTP. + +```json +["trigger", "webhook"], +["filter", "{}"] +``` + +#### `cron` + +`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`. + +```json +["trigger", "cron"], +["filter", "0 * * * *"] +``` + +#### `chain` + +`filter` is the source skill `d` tag to chain from. + +```json +["trigger", "chain"], +["filter", "source-skill-d-tag"] +``` + ### Trigger Lifecycle ```mermaid @@ -264,14 +302,23 @@ flowchart TD subgraph Activation STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123] LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags] - FIND_TRIGGERS --> SUBSCRIBE[Create Nostr subscriptions for each filter] + FIND_TRIGGERS --> REGISTER[Register trigger by type] + REGISTER --> NOSTR_SUB[nostr-subscription: create Nostr subscriptions] + REGISTER --> CRON_REG[cron: keep expression for poll loop] + REGISTER --> WEBHOOK_REG[webhook: route via /api/trigger/:d_tag] + REGISTER --> CHAIN_REG[chain: wait for source skill completion] end subgraph Execution - EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill] + EVENT_IN[Matching Nostr event] --> LOOKUP[Find associated skill] + WEBHOOK_IN[POST /api/trigger/:d_tag] --> LOOKUP + CRON_TICK[cron poll match] --> LOOKUP LOOKUP --> CHECK_TYPE{Action type?} CHECK_TYPE -->|template| INTERPOLATE[Interpolate + execute prefix] CHECK_TYPE -->|llm| RESOLVE[Resolve LLM + assemble context + run] + RESOLVE --> CHAIN_CHECK[Check chain triggers] + INTERPOLATE --> CHAIN_CHECK + CHAIN_CHECK --> CHAIN_FIRE[Fire matching chain triggers] end PUBLISHED --> LOAD_SKILLS @@ -351,9 +398,6 @@ A spelling checker runs with no soul — purely functional, minimal context, che | Extension | Description | |---|---| -| `cron` triggers | Time-based triggers | -| `webhook` triggers | HTTP webhook triggers | -| `chain` triggers | Output of one skill triggers another | | Skill composition | Pipeline multiple skills | | Agent-to-agent sharing | Discover and adopt skills across agents | | Trigger marketplace | Popular triggers rise via adoption count | diff --git a/plans/new_trigger_types.md b/plans/new_trigger_types.md new file mode 100644 index 0000000..c958ee1 --- /dev/null +++ b/plans/new_trigger_types.md @@ -0,0 +1,689 @@ +# Plan: Implement Webhook, Cron, and Chain Trigger Types + +## Overview + +Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates. + +## Current Architecture + +```mermaid +flowchart LR + subgraph Current Flow + NS[Nostr Event] --> SUB[Subscription Callback] + SUB --> MF[maybe_fire_trigger_locked] + MF --> EA{Action Type?} + EA -->|template| TA[execute_template_action] + EA -->|llm| LA[execute_llm_action] + LA --> AOT[agent_on_trigger] + end +``` + +All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`. + +## Target Architecture + +```mermaid +flowchart TD + subgraph Trigger Sources + NS[Nostr Subscription] + WH[Webhook HTTP POST] + CR[Cron Timer] + CH[Chain - Post-Execution] + end + + subgraph Trigger Manager + NS --> MF[maybe_fire_trigger] + WH --> MF + CR --> MF + CH --> MF + end + + subgraph Dispatch + MF --> EA{Action Type?} + EA -->|template| TA[execute_template_action] + EA -->|llm| LA[execute_llm_action] + LA --> AOT[agent_on_trigger] + AOT -->|on completion| CHK[Check chain triggers] + CHK -->|match| CH + end +``` + +--- + +## Phase 1: Foundation — Type System and API Changes + +### 1.1 Add trigger type enum to `trigger_manager.h` + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15) + +Add a new enum after the existing `trigger_action_type_t` at line 18: + +```c +typedef enum { + TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0, + TRIGGER_TYPE_WEBHOOK, + TRIGGER_TYPE_CRON, + TRIGGER_TYPE_CHAIN +} trigger_type_t; +``` + +### 1.2 Extend `active_trigger_t` struct + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20) + +Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25): + +```c +trigger_type_t trigger_type; // discriminator: which trigger source +time_t last_cron_fire; // cron: last time this trigger fired +char cron_expr[64]; // cron: parsed 5-field cron expression +``` + +The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types. + +### 1.3 Add trigger type string conversion helpers + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations: + +```c +trigger_type_t trigger_type_from_string(const char *s); +const char* trigger_type_to_string(trigger_type_t t); +``` + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`: + +```c +trigger_type_t trigger_type_from_string(const char *s) { + if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION; + if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK; + if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON; + if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN; + return TRIGGER_TYPE_NOSTR_SUBSCRIPTION; +} + +const char* trigger_type_to_string(trigger_type_t t) { + switch (t) { + case TRIGGER_TYPE_WEBHOOK: return "webhook"; + case TRIGGER_TYPE_CRON: return "cron"; + case TRIGGER_TYPE_CHAIN: return "chain"; + default: return "nostr-subscription"; + } +} +``` + +### 1.4 Update `trigger_manager_add()` signature + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569) + +Add `const char* trigger_type_str` parameter: + +```c +int trigger_manager_add(trigger_manager_t* mgr, + const char* skill_d_tag, + const char* content, + const char* filter_json, + trigger_action_type_t action_type, + const char* trigger_type_str, // NEW + int enabled); +``` + +Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments: + +```c +t->trigger_type = trigger_type_from_string(trigger_type_str); + +// For cron triggers, copy the filter as the cron expression +if (t->trigger_type == TRIGGER_TYPE_CRON) { + snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json); + t->last_cron_fire = 0; +} + +// Only create Nostr subscription for nostr-subscription type +if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) { + if (register_trigger_subscription_locked(mgr, t) != 0) { + pthread_mutex_unlock(&mgr->mutex); + DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag); + memset(t, 0, sizeof(*t)); + return -1; + } +} +``` + +### 1.5 Update `trigger_manager_update()` signature + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661) + +Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683): + +```c +t->trigger_type = trigger_type_from_string(trigger_type_str); + +if (t->trigger_type == TRIGGER_TYPE_CRON) { + snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json); +} + +// Only (re)subscribe for nostr-subscription type +if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) { + if (register_trigger_subscription_locked(mgr, t) != 0) { ... } +} else { + // Close any existing subscription if type changed + close_trigger_subscription_locked(t); +} +``` + +### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()` + +There are 5 call sites that need the new `trigger_type_str` parameter: + +| Call Site | File | Line | Current `trigger_type_str` value | +|---|---|---|---| +| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags | +| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags | +| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available | +| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available | +| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available | +| `trigger_manager_add()` → `trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller | +| `trigger_manager_update()` → `trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller | + +### 1.7 Update loading functions to accept all trigger types + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482) + +In `trigger_manager_load_from_skills()`, change the filter at line 482 from: +```c +if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') { +``` +To: +```c +if (trigger_s && filter_s && filter_s[0] != '\0' && + (strcmp(trigger_s, "nostr-subscription") == 0 || + strcmp(trigger_s, "webhook") == 0 || + strcmp(trigger_s, "cron") == 0 || + strcmp(trigger_s, "chain") == 0)) { +``` + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547) + +Same change in `trigger_manager_load_from_startup_events()` at line 547. + +Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder. + +### 1.8 Update `trigger_manager_status_json()` + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761) + +After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add: + +```c +cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type)); +``` + +### 1.9 Update `tools.c` validation + +**File:** [`src/tools.c`](src/tools.c:3603) + +In `execute_skill_create()`, change line 3603 from: +```c +if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) { +``` +To: +```c +if (trigger_str && + strcmp(trigger_str, "nostr-subscription") != 0 && + strcmp(trigger_str, "webhook") != 0 && + strcmp(trigger_str, "cron") != 0 && + strcmp(trigger_str, "chain") != 0) { +``` + +Update the error message to list valid types. + +**File:** [`src/tools.c`](src/tools.c:4177) + +In `execute_skill_edit()`, change line 4177 from: +```c +if (strcmp(merged_trigger, "nostr-subscription") != 0) { +``` +To the same multi-type check. + +Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string. + +--- + +## Phase 2: Webhook Trigger + +### 2.1 Add `POST /api/trigger/:d_tag` route handler + +**File:** [`src/http_api.c`](src/http_api.c:1154) + +Add a new handler function before `http_handler()`: + +```c +static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) { + if (!g_api_ctx.trigger_manager) { + reply_error(c, 503, "trigger manager unavailable"); + return; + } + + // Look up the trigger by d_tag + // Need a new function: trigger_manager_find_by_d_tag() that returns a copy + active_trigger_t trigger_copy; + if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) { + reply_error(c, 404, "no trigger found for d_tag"); + return; + } + + if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) { + reply_error(c, 400, "trigger is not a webhook type"); + return; + } + + if (!trigger_copy.enabled) { + reply_error(c, 400, "trigger is disabled"); + return; + } + + // Parse optional JSON body as the webhook payload + cJSON* payload = parse_body_json(hm); + if (!payload) { + reply_error(c, 400, "invalid JSON body"); + return; + } + + // Build synthetic triggering event + cJSON* event = cJSON_CreateObject(); + cJSON_AddStringToObject(event, "type", "webhook"); + cJSON_AddStringToObject(event, "d_tag", d_tag); + cJSON_AddNumberToObject(event, "created_at", (double)time(NULL)); + cJSON_AddItemToObject(event, "payload", payload); + + // Fire the trigger - dispatch based on action type + if (trigger_copy.action_type == TRIGGER_ACTION_LLM) { + agent_on_trigger(trigger_copy.skill_d_tag, + trigger_copy.skill_content, + event, + "webhook"); + } + // Template actions could also be supported here + + cJSON_Delete(event); + + // Return success immediately + cJSON* root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "success", 1); + cJSON_AddStringToObject(root, "d_tag", d_tag); + cJSON_AddStringToObject(root, "status", "fired"); + reply_json(c, 200, root); + cJSON_Delete(root); +} +``` + +### 2.2 Add route to `http_handler()` + +**File:** [`src/http_api.c`](src/http_api.c:1201) + +Before the 404 fallthrough at line 1206, add: + +```c +if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) { + // Extract d_tag from URI: /api/trigger/{d_tag} + struct mg_str uri = hm->uri; + const char* prefix = "/api/trigger/"; + size_t prefix_len = strlen(prefix); + if (uri.len > prefix_len) { + char d_tag[TRIGGER_SKILL_D_TAG_MAX]; + size_t tag_len = uri.len - prefix_len; + if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1; + memcpy(d_tag, uri.buf + prefix_len, tag_len); + d_tag[tag_len] = '\0'; + handle_trigger_webhook(c, hm, d_tag); + return; + } + reply_error(c, 400, "missing d_tag in trigger URL"); + return; +} +``` + +### 2.3 Add `trigger_manager_find()` function + +**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631) + +New function to look up a trigger by d_tag and return a copy: + +```c +// Declaration +int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out); + +// Implementation +int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) { + if (!mgr || !skill_d_tag || !out) return -1; + pthread_mutex_lock(&mgr->mutex); + int idx = find_trigger_index_locked(mgr, skill_d_tag); + if (idx < 0) { + pthread_mutex_unlock(&mgr->mutex); + return -1; + } + *out = mgr->triggers[idx]; + // Clear pointer fields in copy to prevent double-free + out->subscription = NULL; + out->subscription_ctx = NULL; + pthread_mutex_unlock(&mgr->mutex); + return 0; +} +``` + +### 2.4 Webhook cooldown + +The webhook handler should respect the same cooldown as other triggers. Either: +- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or +- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function + +Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic. + +```c +int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source); +``` + +--- + +## Phase 3: Cron Trigger + +### 3.1 Cron expression parser + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c) + +Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week` + +```c +// Returns 1 if the cron expression matches the given time, 0 otherwise +static int cron_matches(const char* expr, const struct tm* tm); + +// Helper: check if a single field matches a value +// Supports: *, specific number, comma-separated list, ranges with -, step with / +static int cron_field_matches(const char* field, int value, int min, int max); +``` + +The parser needs to handle: +- `*` — match any +- `5` — match exact value +- `1,15` — match list +- `1-5` — match range +- `*/15` — match step +- `1-5/2` — match range with step + +This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`. + +### 3.2 Implement `trigger_manager_poll()` for cron + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718) + +Replace the no-op `trigger_manager_poll()` with cron checking logic: + +```c +int trigger_manager_poll(trigger_manager_t* mgr) { + if (!mgr) return 0; + + time_t now = time(NULL); + + // Only check once per minute (cron resolution is 1 minute) + if (now - mgr->last_poll_at < 60) return 0; + mgr->last_poll_at = now; + + struct tm tm_now; + localtime_r(&now, &tm_now); + + pthread_mutex_lock(&mgr->mutex); + + for (int i = 0; i < mgr->count; i++) { + active_trigger_t* t = &mgr->triggers[i]; + if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue; + if (t->cron_expr[0] == '\0') continue; + + if (!cron_matches(t->cron_expr, &tm_now)) continue; + + // Prevent double-fire within same minute + struct tm tm_last; + localtime_r(&t->last_cron_fire, &tm_last); + if (t->last_cron_fire > 0 && + tm_last.tm_min == tm_now.tm_min && + tm_last.tm_hour == tm_now.tm_hour && + tm_last.tm_mday == tm_now.tm_mday) { + continue; + } + + t->last_cron_fire = now; + t->last_fired = now; + + // Copy trigger data before unlocking + active_trigger_t trigger_copy = *t; + trigger_copy.subscription = NULL; + trigger_copy.subscription_ctx = NULL; + pthread_mutex_unlock(&mgr->mutex); + + // Build synthetic cron event + cJSON* event = cJSON_CreateObject(); + cJSON_AddStringToObject(event, "type", "cron"); + cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag); + cJSON_AddNumberToObject(event, "created_at", (double)now); + cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr); + + if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) { + execute_template_action(mgr, &trigger_copy, event, "cron"); + } else { + execute_llm_action(&trigger_copy, event, "cron"); + } + + cJSON_Delete(event); + + // Re-lock and continue scanning + pthread_mutex_lock(&mgr->mutex); + } + + pthread_mutex_unlock(&mgr->mutex); + return 0; +} +``` + +### 3.3 Cron expression in skill tags + +For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter: +- `["trigger", "cron"]` +- `["filter", "0 * * * *"]` — fires every hour at minute 0 +- `["filter", "*/5 * * * *"]` — fires every 5 minutes + +This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type. + +--- + +## Phase 4: Chain Trigger + +### 4.1 Post-execution hook in `agent_on_trigger()` + +**File:** [`src/agent.c`](src/agent.c:1913) + +After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check: + +```c +// After the tool loop, check for chain triggers +// Need access to trigger_manager — use agent_get_trigger_manager() or global +trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages); +``` + +This requires: +1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12) +2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag + +### 4.2 Add `trigger_manager_fire_chains()` function + +**File:** [`src/trigger_manager.c`](src/trigger_manager.c) + +```c +void trigger_manager_fire_chains(trigger_manager_t* mgr, + const char* source_d_tag, + cJSON* source_output) { + if (!mgr || !source_d_tag) return; + + pthread_mutex_lock(&mgr->mutex); + + for (int i = 0; i < mgr->count; i++) { + active_trigger_t* t = &mgr->triggers[i]; + if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue; + + // For chain triggers, filter_json contains the source skill d_tag + if (strcmp(t->filter_json, source_d_tag) != 0) continue; + + // Cooldown check + time_t now = time(NULL); + int cooldown = mgr->cfg->triggers.cooldown_seconds; + if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue; + + t->last_fired = now; + + active_trigger_t trigger_copy = *t; + trigger_copy.subscription = NULL; + trigger_copy.subscription_ctx = NULL; + pthread_mutex_unlock(&mgr->mutex); + + // Build synthetic chain event + cJSON* event = cJSON_CreateObject(); + cJSON_AddStringToObject(event, "type", "chain"); + cJSON_AddStringToObject(event, "source_d_tag", source_d_tag); + cJSON_AddNumberToObject(event, "created_at", (double)now); + if (source_output) { + cJSON* output_copy = cJSON_Duplicate(source_output, 1); + if (output_copy) { + cJSON_AddItemToObject(event, "source_output", output_copy); + } + } + + if (trigger_copy.action_type == TRIGGER_ACTION_LLM) { + execute_llm_action(&trigger_copy, event, "chain"); + } + // Note: template actions could also be supported + + cJSON_Delete(event); + + pthread_mutex_lock(&mgr->mutex); + } + + pthread_mutex_unlock(&mgr->mutex); +} +``` + +### 4.3 Chain trigger recursion protection + +To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter: + +- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()` +- Increment before firing, decrement after +- Refuse to fire if depth exceeds a limit (e.g., 5) + +### 4.4 Chain trigger skill tags + +For chain triggers, the `filter` tag contains the source skill's d_tag: +- `["trigger", "chain"]` +- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes + +### 4.5 What to pass as chain context + +The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits. + +**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`. + +--- + +## Phase 5: Documentation + +### 5.1 Update `docs/SKILLS.md` + +**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209) + +Expand the Trigger Tags table to show all four types and their filter semantics: + +| Tag | Required | Description | +|---|---|---| +| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` | +| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag | +| `action` | No | `template` or `llm` — default: `llm` | +| `enabled` | No | Whether active — default: `true` | + +Add sections for each new trigger type with examples. + +Update the Future Extensions table to mark webhook, cron, and chain as implemented. + +### 5.2 Update `docs/API.md` + +**File:** [`docs/API.md`](docs/API.md:49) + +Add documentation for the new webhook endpoint: + +``` +### POST /api/trigger/:d_tag + +Fire a webhook trigger by skill d_tag. + +**URL Parameters:** +- `d_tag` — The skill's d_tag identifier + +**Request Body:** Optional JSON payload passed as context to the skill + +**Response:** +{ + "success": true, + "d_tag": "my-webhook-skill", + "status": "fired" +} +``` + +--- + +## Phase 6: Build and Push + +1. Run `make -j` and fix any compilation warnings/errors +2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'` +3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"` + +--- + +## Files Modified Summary + +| File | Changes | +|---|---| +| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations | +| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function | +| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites | +| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry | +| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` | +| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged | +| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types | +| [`docs/API.md`](docs/API.md) | Document webhook endpoint | + +--- + +## Implementation Checklist + +- [ ] Add `trigger_type_t` enum to `trigger_manager.h` +- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr` +- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations +- [ ] Add `trigger_manager_find()` declaration and implementation +- [ ] Add `trigger_manager_fire_chains()` declaration and implementation +- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter +- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter +- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration +- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription +- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types +- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types +- [ ] Update `trigger_manager_status_json()` to include type field +- [ ] Update `execute_skill_create()` validation at line 3603 +- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678 +- [ ] Update `execute_skill_edit()` validation at line 4177 +- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224 +- [ ] Add `handle_trigger_webhook()` handler to `http_api.c` +- [ ] Add webhook route to `http_handler()` in `http_api.c` +- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c` +- [ ] Implement cron polling in `trigger_manager_poll()` +- [ ] Add chain trigger post-execution hook in `agent_on_trigger()` +- [ ] Add chain depth recursion protection +- [ ] Update `docs/SKILLS.md` with all three new trigger types +- [ ] Update `docs/API.md` with webhook endpoint +- [ ] Build with `make -j` and verify clean compilation +- [ ] Push with `./increment_and_push.sh` diff --git a/src/agent.c b/src/agent.c index b707e99..edf2756 100644 --- a/src/agent.c +++ b/src/agent.c @@ -13,6 +13,7 @@ #include "llm.h" #include "nostr_handler.h" +#include "trigger_manager.h" #include "tools.h" #include "prompt_template.h" #include "cjson/cJSON.h" @@ -2040,6 +2041,13 @@ void agent_on_trigger(const char* skill_d_tag, llm_response_free(&resp); } + if (g_trigger_manager) { + (void)trigger_manager_fire_chains(g_trigger_manager, + skill_d_tag, + triggering_event, + relay_url && relay_url[0] != '\0' ? relay_url : "trigger"); + } + cJSON_Delete(messages); free(tools_json); } diff --git a/src/http_api.c b/src/http_api.c index 8b1fbbd..654b268 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -5,6 +5,7 @@ #include #include #include +#include #include "agent.h" #include "llm.h" @@ -80,6 +81,179 @@ static int estimate_tokens_from_chars(int chars) { return (chars + 3) / 4; } +static int uri_extract_after_prefix(const struct mg_str* uri, + const char* prefix, + char* out, + size_t out_sz) { + if (!uri || !prefix || !out || out_sz == 0) { + return -1; + } + out[0] = '\0'; + + size_t prefix_len = strlen(prefix); + if (uri->len <= prefix_len || strncmp(uri->buf, prefix, prefix_len) != 0) { + return -1; + } + + size_t rem = uri->len - prefix_len; + if (rem == 0 || rem >= out_sz) { + return -1; + } + + memcpy(out, uri->buf + prefix_len, rem); + out[rem] = '\0'; + return 0; +} + +static cJSON* find_skill_event_by_d_tag(const char* d_tag) { + if (!d_tag || d_tag[0] == '\0') { + return NULL; + } + + char* events_json = nostr_handler_get_self_skill_events_json(); + if (!events_json) { + return NULL; + } + + cJSON* events = cJSON_Parse(events_json); + free(events_json); + if (!events || !cJSON_IsArray(events)) { + cJSON_Delete(events); + return NULL; + } + + cJSON* found = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON* ev = cJSON_GetArrayItem(events, i); + cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL; + if (!tags || !cJSON_IsArray(tags)) { + continue; + } + + int tn = cJSON_GetArraySize(tags); + for (int ti = 0; ti < tn; ti++) { + cJSON* tag = cJSON_GetArrayItem(tags, ti); + 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, "d") == 0 && strcmp(val->valuestring, d_tag) == 0) { + found = cJSON_Duplicate(ev, 1); + break; + } + } + + if (found) { + break; + } + } + + cJSON_Delete(events); + return found; +} + +static const char* find_tag_string_local(cJSON* tags, const char* key) { + if (!tags || !cJSON_IsArray(tags) || !key || key[0] == '\0') { + 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* k = cJSON_GetArrayItem(tag, 0); + cJSON* v = cJSON_GetArrayItem(tag, 1); + if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) { + continue; + } + if (strcmp(k->valuestring, key) == 0) { + return v->valuestring; + } + } + + return NULL; +} + +static void handle_trigger_webhook(struct mg_connection* c, + const struct mg_http_message* hm, + const char* d_tag) { + if (!g_api_ctx.trigger_manager) { + reply_error(c, 503, "trigger manager unavailable"); + return; + } + + cJSON* skill_ev = find_skill_event_by_d_tag(d_tag); + if (!skill_ev) { + reply_error(c, 404, "skill not found by d_tag"); + return; + } + + cJSON* tags = cJSON_GetObjectItemCaseSensitive(skill_ev, "tags"); + const char* trigger_type = find_tag_string_local(tags, "trigger"); + if (!trigger_type || strcmp(trigger_type, "webhook") != 0) { + cJSON_Delete(skill_ev); + reply_error(c, 400, "skill trigger type is not webhook"); + return; + } + + const char* enabled = find_tag_string_local(tags, "enabled"); + if (enabled && (strcmp(enabled, "false") == 0 || strcmp(enabled, "0") == 0)) { + cJSON_Delete(skill_ev); + reply_error(c, 400, "webhook trigger is disabled"); + return; + } + + cJSON* payload = parse_body_json(hm); + if (!payload || !cJSON_IsObject(payload)) { + cJSON_Delete(payload); + cJSON_Delete(skill_ev); + reply_error(c, 400, "invalid JSON body"); + return; + } + + cJSON* event = cJSON_CreateObject(); + if (!event) { + cJSON_Delete(payload); + cJSON_Delete(skill_ev); + reply_error(c, 500, "oom"); + return; + } + + cJSON_AddStringToObject(event, "type", "webhook"); + cJSON_AddStringToObject(event, "skill_d_tag", d_tag); + cJSON_AddNumberToObject(event, "created_at", (double)time(NULL)); + cJSON_AddItemToObject(event, "payload", payload); + + int fired = trigger_manager_fire(g_api_ctx.trigger_manager, d_tag, event, "webhook"); + + cJSON_Delete(event); + cJSON_Delete(skill_ev); + + if (fired < 0) { + reply_error(c, 404, "active trigger not found for d_tag"); + return; + } + + cJSON* out = cJSON_CreateObject(); + if (!out) { + reply_error(c, 500, "oom"); + return; + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "d_tag", d_tag); + cJSON_AddBoolToObject(out, "fired", fired > 0 ? 1 : 0); + reply_json(c, 200, out); + cJSON_Delete(out); +} + static char* maybe_model_override_begin(cJSON* body, llm_config_t* out_old_cfg, int* out_overridden) { if (!body || !out_old_cfg || !out_overridden) return NULL; *out_overridden = 0; @@ -1202,6 +1376,15 @@ static void http_handler(struct mg_connection* c, int ev, void* ev_data) { handle_prompt_compare(c, hm); return; } + if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) { + char d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0}; + if (uri_extract_after_prefix(&hm->uri, "/api/trigger/", d_tag, sizeof(d_tag)) != 0) { + reply_error(c, 400, "missing or invalid d_tag"); + return; + } + handle_trigger_webhook(c, hm, d_tag); + return; + } reply_error(c, 404, "not found"); } diff --git a/src/main.h b/src/main.h index 829335a..7aa3a66 100644 --- a/src/main.h +++ b/src/main.h @@ -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 60 -#define DIDACTYL_VERSION "v0.0.60" +#define DIDACTYL_VERSION_PATCH 61 +#define DIDACTYL_VERSION "v0.0.61" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 9c9c537..f11f9ec 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -488,8 +488,13 @@ static void register_trigger_from_self_skill_event(cJSON* event) { const char* action = find_tag_value_local(tags, "action"); const char* enabled = find_tag_value_local(tags, "enabled"); + int trigger_supported = trigger && + (strcmp(trigger, "nostr-subscription") == 0 || + strcmp(trigger, "webhook") == 0 || + strcmp(trigger, "cron") == 0 || + strcmp(trigger, "chain") == 0); if (!d_tag || d_tag[0] == '\0' || - !trigger || strcmp(trigger, "nostr-subscription") != 0 || + !trigger_supported || !filter || filter[0] == '\0') { return; } @@ -504,6 +509,7 @@ static void register_trigger_from_self_skill_event(cJSON* event) { content->valuestring, filter, action_type, + trigger, is_enabled); if (rc == 0) { DEBUG_INFO("[didactyl] live self-skill trigger registered d_tag=%s action=%s enabled=%d", diff --git a/src/tools.c b/src/tools.c index a7040c2..6e38c87 100644 --- a/src/tools.c +++ b/src/tools.c @@ -3600,10 +3600,14 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) { : "llm"; int enabled_int = (!enabled || cJSON_IsTrue(enabled)) ? 1 : 0; - if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) { + if (trigger_str && + strcmp(trigger_str, "nostr-subscription") != 0 && + strcmp(trigger_str, "webhook") != 0 && + strcmp(trigger_str, "cron") != 0 && + strcmp(trigger_str, "chain") != 0) { cJSON_Delete(tags); cJSON_Delete(args); - return json_error("skill_create trigger must be nostr-subscription when provided"); + return json_error("skill_create trigger must be one of: nostr-subscription, webhook, cron, chain"); } if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) { cJSON_Delete(tags); @@ -3680,6 +3684,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) { content->valuestring, filter_str, at, + trigger_str, enabled_int) == 0) { trigger_registered = 1; } @@ -4174,11 +4179,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) { remove_tag_key_all(tags_out, "enabled"); if (merged_trigger && merged_filter) { - if (strcmp(merged_trigger, "nostr-subscription") != 0) { + if (strcmp(merged_trigger, "nostr-subscription") != 0 && + strcmp(merged_trigger, "webhook") != 0 && + strcmp(merged_trigger, "cron") != 0 && + strcmp(merged_trigger, "chain") != 0) { cJSON_Delete(args); cJSON_Delete(events); cJSON_Delete(tags_out); - return json_error("skill_edit trigger must be nostr-subscription"); + return json_error("skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain"); } if (add_string_tag(tags_out, "trigger", merged_trigger) != 0 || add_string_tag(tags_out, "filter", merged_filter) != 0 || @@ -4215,12 +4223,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) { out_content, merged_filter, at, + merged_trigger, merged_enabled) == 0 || trigger_manager_add(ctx->trigger_manager, d->valuestring, out_content, merged_filter, at, + merged_trigger, merged_enabled) == 0) { trigger_registered = 1; } diff --git a/src/trigger_manager.c b/src/trigger_manager.c index 9b8c314..ecbf67a 100644 --- a/src/trigger_manager.c +++ b/src/trigger_manager.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "agent.h" #include "cjson/cJSON.h" @@ -21,6 +22,216 @@ static int clamp_enabled(int enabled) { return enabled ? 1 : 0; } +trigger_type_t trigger_type_from_string(const char* s) { + if (!s) { + return TRIGGER_TYPE_NOSTR_SUBSCRIPTION; + } + if (strcmp(s, "webhook") == 0) { + return TRIGGER_TYPE_WEBHOOK; + } + if (strcmp(s, "cron") == 0) { + return TRIGGER_TYPE_CRON; + } + if (strcmp(s, "chain") == 0) { + return TRIGGER_TYPE_CHAIN; + } + return TRIGGER_TYPE_NOSTR_SUBSCRIPTION; +} + +const char* trigger_type_to_string(trigger_type_t t) { + switch (t) { + case TRIGGER_TYPE_WEBHOOK: + return "webhook"; + case TRIGGER_TYPE_CRON: + return "cron"; + case TRIGGER_TYPE_CHAIN: + return "chain"; + case TRIGGER_TYPE_NOSTR_SUBSCRIPTION: + default: + return "nostr-subscription"; + } +} + +static int is_int_in_csv_list(const char* text, int value) { + if (!text || text[0] == '\0') { + return 0; + } + + const char* p = text; + while (*p) { + while (*p == ' ') p++; + char* endptr = NULL; + long n = strtol(p, &endptr, 10); + if (endptr == p) { + return 0; + } + if ((int)n == value) { + return 1; + } + p = endptr; + while (*p == ' ') p++; + if (*p == ',') { + p++; + continue; + } + if (*p == '\0') { + break; + } + return 0; + } + + return 0; +} + +static int cron_field_token_matches(const char* token, int value, int min_v, int max_v) { + if (!token || token[0] == '\0') { + return 0; + } + + if (strcmp(token, "*") == 0) { + return 1; + } + + const char* slash = strchr(token, '/'); + int step = 0; + char base[64]; + if (slash) { + size_t base_len = (size_t)(slash - token); + if (base_len == 0 || base_len >= sizeof(base)) { + return 0; + } + memcpy(base, token, base_len); + base[base_len] = '\0'; + + char* endptr = NULL; + long step_l = strtol(slash + 1, &endptr, 10); + if (endptr == slash + 1 || *endptr != '\0' || step_l <= 0 || step_l > 1024) { + return 0; + } + step = (int)step_l; + } else { + snprintf(base, sizeof(base), "%s", token); + } + + int range_start = min_v; + int range_end = max_v; + + if (strcmp(base, "*") == 0) { + range_start = min_v; + range_end = max_v; + } else { + const char* dash = strchr(base, '-'); + if (dash) { + char left[32]; + char right[32]; + size_t left_len = (size_t)(dash - base); + size_t right_len = strlen(dash + 1); + if (left_len == 0 || right_len == 0 || left_len >= sizeof(left) || right_len >= sizeof(right)) { + return 0; + } + memcpy(left, base, left_len); + left[left_len] = '\0'; + memcpy(right, dash + 1, right_len + 1U); + + char* e1 = NULL; + char* e2 = NULL; + long a = strtol(left, &e1, 10); + long b = strtol(right, &e2, 10); + if (!e1 || *e1 != '\0' || !e2 || *e2 != '\0') { + return 0; + } + range_start = (int)a; + range_end = (int)b; + } else if (strchr(base, ',')) { + if (!is_int_in_csv_list(base, value)) { + return 0; + } + if (step <= 1) { + return 1; + } + return ((value - min_v) % step) == 0; + } else { + char* e = NULL; + long n = strtol(base, &e, 10); + if (!e || *e != '\0') { + return 0; + } + range_start = (int)n; + range_end = (int)n; + } + } + + if (range_start < min_v || range_end > max_v || range_start > range_end) { + return 0; + } + if (value < range_start || value > range_end) { + return 0; + } + if (step > 1 && ((value - range_start) % step) != 0) { + return 0; + } + return 1; +} + +static int cron_field_matches(const char* field, int value, int min_v, int max_v) { + if (!field || field[0] == '\0') { + return 0; + } + + char tmp[128]; + snprintf(tmp, sizeof(tmp), "%s", field); + + char* saveptr = NULL; + char* token = strtok_r(tmp, ",", &saveptr); + while (token) { + while (*token == ' ') token++; + if (cron_field_token_matches(token, value, min_v, max_v)) { + return 1; + } + token = strtok_r(NULL, ",", &saveptr); + } + + return 0; +} + +static int cron_matches_now(const char* expr, time_t now_ts) { + if (!expr || expr[0] == '\0') { + return 0; + } + + char buf[TRIGGER_FILTER_JSON_MAX]; + snprintf(buf, sizeof(buf), "%s", expr); + + char* fields[5] = {0}; + int nf = 0; + char* saveptr = NULL; + char* tok = strtok_r(buf, " \t", &saveptr); + while (tok && nf < 5) { + fields[nf++] = tok; + tok = strtok_r(NULL, " \t", &saveptr); + } + if (nf != 5 || tok != NULL) { + return 0; + } + + struct tm tm_now; + localtime_r(&now_ts, &tm_now); + + int minute = tm_now.tm_min; + int hour = tm_now.tm_hour; + int mday = tm_now.tm_mday; + int month = tm_now.tm_mon + 1; + int wday = tm_now.tm_wday; + + if (!cron_field_matches(fields[0], minute, 0, 59)) return 0; + if (!cron_field_matches(fields[1], hour, 0, 23)) return 0; + if (!cron_field_matches(fields[2], mday, 1, 31)) return 0; + if (!cron_field_matches(fields[3], month, 1, 12)) return 0; + if (!cron_field_matches(fields[4], wday, 0, 6)) return 0; + + return 1; +} + static int ensure_capacity(trigger_manager_t* mgr, int needed) { if (!mgr || needed <= 0) { return -1; @@ -253,15 +464,7 @@ static void on_trigger_subscription_event(cJSON* event, const char* relay_url, v return; } - trigger_manager_t* mgr = ctx->mgr; - pthread_mutex_lock(&mgr->mutex); - - int idx = find_trigger_index_locked(mgr, ctx->skill_d_tag); - if (idx >= 0) { - (void)maybe_fire_trigger_locked(mgr, idx, event, relay_url); - } - - pthread_mutex_unlock(&mgr->mutex); + (void)trigger_manager_fire(ctx->mgr, ctx->skill_d_tag, event, relay_url); } static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_trigger_t* t) { @@ -271,6 +474,10 @@ static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_t close_trigger_subscription_locked(t); + if (t->trigger_type != TRIGGER_TYPE_NOSTR_SUBSCRIPTION) { + return 0; + } + if (!t->enabled || t->filter_json[0] == '\0') { return 0; } @@ -479,10 +686,15 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) { const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm"; const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true"; - if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') { + int trigger_supported = trigger_s && + (strcmp(trigger_s, "nostr-subscription") == 0 || + strcmp(trigger_s, "webhook") == 0 || + strcmp(trigger_s, "cron") == 0 || + strcmp(trigger_s, "chain") == 0); + if (trigger_supported && filter_s && filter_s[0] != '\0') { trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM; int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1; - if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, at, is_enabled) == 0) { + if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, at, trigger_s, is_enabled) == 0) { loaded++; } } @@ -544,7 +756,12 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) { continue; } - if (!trigger_s || strcmp(trigger_s, "nostr-subscription") != 0 || !filter_s || filter_s[0] == '\0') { + int trigger_supported = trigger_s && + (strcmp(trigger_s, "nostr-subscription") == 0 || + strcmp(trigger_s, "webhook") == 0 || + strcmp(trigger_s, "cron") == 0 || + strcmp(trigger_s, "chain") == 0); + if (!trigger_supported || !filter_s || filter_s[0] == '\0') { cJSON_Delete(tags); continue; } @@ -552,7 +769,7 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) { trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM; int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1; - if (trigger_manager_add(mgr, d_tag, ev->content, filter_s, at, is_enabled) == 0) { + if (trigger_manager_add(mgr, d_tag, ev->content, filter_s, at, trigger_s, is_enabled) == 0) { loaded++; DEBUG_INFO("[didactyl] startup trigger registered d_tag=%s action=%s enabled=%d", d_tag, at == TRIGGER_ACTION_TEMPLATE ? "template" : "llm", is_enabled); } else { @@ -571,6 +788,7 @@ int trigger_manager_add(trigger_manager_t* mgr, const char* content, const char* filter_json, trigger_action_type_t action_type, + const char* trigger_type_str, int enabled) { if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) { return -1; @@ -585,7 +803,7 @@ int trigger_manager_add(trigger_manager_t* mgr, int existing = find_trigger_index_locked(mgr, skill_d_tag); if (existing >= 0) { pthread_mutex_unlock(&mgr->mutex); - return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, enabled); + return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled); } int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE; @@ -608,8 +826,13 @@ int trigger_manager_add(trigger_manager_t* mgr, snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json); t->action_type = action_type; t->enabled = clamp_enabled(enabled); + t->trigger_type = trigger_type_from_string(trigger_type_str); t->last_fired = 0; t->last_seen_created_at = 0; + t->last_cron_fire = 0; + if (t->trigger_type == TRIGGER_TYPE_CRON) { + snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json); + } t->subscription = NULL; t->subscription_ctx = NULL; @@ -658,11 +881,124 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag) { return 0; } +int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) { + if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !out) { + return -1; + } + + pthread_mutex_lock(&mgr->mutex); + int idx = find_trigger_index_locked(mgr, skill_d_tag); + if (idx < 0) { + pthread_mutex_unlock(&mgr->mutex); + return -1; + } + + *out = mgr->triggers[idx]; + out->subscription = NULL; + out->subscription_ctx = NULL; + pthread_mutex_unlock(&mgr->mutex); + return 0; +} + +int trigger_manager_fire(trigger_manager_t* mgr, + const char* skill_d_tag, + cJSON* event, + const char* source_label) { + if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !event) { + return -1; + } + + pthread_mutex_lock(&mgr->mutex); + int idx = find_trigger_index_locked(mgr, skill_d_tag); + if (idx < 0) { + pthread_mutex_unlock(&mgr->mutex); + return -1; + } + + int fired = maybe_fire_trigger_locked(mgr, idx, event, source_label); + pthread_mutex_unlock(&mgr->mutex); + return fired; +} + +int trigger_manager_fire_chains(trigger_manager_t* mgr, + const char* source_skill_d_tag, + cJSON* source_event, + const char* source_label) { + if (!mgr || !source_skill_d_tag || source_skill_d_tag[0] == '\0') { + return -1; + } + + static __thread int s_chain_depth = 0; + if (s_chain_depth >= 5) { + DEBUG_WARN("[didactyl] chain trigger depth limit reached for source=%s", source_skill_d_tag); + return 0; + } + + int fired_count = 0; + s_chain_depth++; + + pthread_mutex_lock(&mgr->mutex); + int count_snapshot = mgr->count; + for (int i = 0; i < count_snapshot; i++) { + active_trigger_t* t = &mgr->triggers[i]; + if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) { + continue; + } + if (t->filter_json[0] == '\0' || strcmp(t->filter_json, source_skill_d_tag) != 0) { + continue; + } + + time_t now = time(NULL); + int cooldown = mgr->cfg ? mgr->cfg->triggers.cooldown_seconds : 0; + if (cooldown < 0) cooldown = 0; + if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) { + continue; + } + + t->last_fired = now; + + active_trigger_t trigger_copy = *t; + trigger_copy.subscription = NULL; + trigger_copy.subscription_ctx = NULL; + + pthread_mutex_unlock(&mgr->mutex); + + cJSON* event = cJSON_CreateObject(); + if (event) { + cJSON_AddStringToObject(event, "type", "chain"); + cJSON_AddStringToObject(event, "source_d_tag", source_skill_d_tag); + cJSON_AddStringToObject(event, "source_label", source_label ? source_label : "chain"); + cJSON_AddNumberToObject(event, "created_at", (double)now); + if (source_event) { + cJSON* dup = cJSON_Duplicate(source_event, 1); + if (dup) { + cJSON_AddItemToObject(event, "source_event", dup); + } + } + + if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) { + execute_template_action(mgr, &trigger_copy, event, "chain"); + } else { + execute_llm_action(&trigger_copy, event, "chain"); + } + cJSON_Delete(event); + fired_count++; + } + + pthread_mutex_lock(&mgr->mutex); + } + pthread_mutex_unlock(&mgr->mutex); + + s_chain_depth--; + return fired_count; +} + int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag, const char* content, const char* filter_json, trigger_action_type_t action_type, + const char* trigger_type_str, int enabled) { if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) { return -1; @@ -677,7 +1013,7 @@ int trigger_manager_update(trigger_manager_t* mgr, int idx = find_trigger_index_locked(mgr, skill_d_tag); if (idx < 0) { pthread_mutex_unlock(&mgr->mutex); - return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, enabled); + return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled); } active_trigger_t* t = &mgr->triggers[idx]; @@ -685,6 +1021,12 @@ int trigger_manager_update(trigger_manager_t* mgr, snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json); t->action_type = action_type; t->enabled = clamp_enabled(enabled); + t->trigger_type = trigger_type_from_string(trigger_type_str); + if (t->trigger_type == TRIGGER_TYPE_CRON) { + snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json); + } else { + t->cron_expr[0] = '\0'; + } if (register_trigger_subscription_locked(mgr, t) != 0) { pthread_mutex_unlock(&mgr->mutex); @@ -716,8 +1058,67 @@ int trigger_manager_active_count(trigger_manager_t* mgr) { } int trigger_manager_poll(trigger_manager_t* mgr) { - (void)mgr; - return 0; + if (!mgr) { + return -1; + } + + time_t now = time(NULL); + if (now <= 0) { + return 0; + } + + if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) { + return 0; + } + mgr->last_poll_at = now; + + int fired = 0; + + pthread_mutex_lock(&mgr->mutex); + int count_snapshot = mgr->count; + for (int i = 0; i < count_snapshot; i++) { + active_trigger_t* t = &mgr->triggers[i]; + if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) { + continue; + } + + const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json; + if (!cron_matches_now(expr, now)) { + continue; + } + + if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) { + continue; + } + + t->last_cron_fire = now; + + active_trigger_t trigger_copy = *t; + trigger_copy.subscription = NULL; + trigger_copy.subscription_ctx = NULL; + + pthread_mutex_unlock(&mgr->mutex); + + cJSON* event = cJSON_CreateObject(); + if (event) { + cJSON_AddStringToObject(event, "type", "cron"); + cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag); + cJSON_AddStringToObject(event, "cron_expr", expr); + cJSON_AddNumberToObject(event, "created_at", (double)now); + if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) { + execute_template_action(mgr, &trigger_copy, event, "cron"); + } else { + execute_llm_action(&trigger_copy, event, "cron"); + } + cJSON_Delete(event); + fired++; + } + + pthread_mutex_lock(&mgr->mutex); + } + pthread_mutex_unlock(&mgr->mutex); + + return fired; } char* trigger_manager_status_json(trigger_manager_t* mgr) { @@ -760,6 +1161,7 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) { cJSON_AddStringToObject(item, "skill_d_tag", t->skill_d_tag); cJSON_AddStringToObject(item, "filter_json", t->filter_json); + cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type)); cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm"); cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0); cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 1 : 0); diff --git a/src/trigger_manager.h b/src/trigger_manager.h index 8b6e792..182d303 100644 --- a/src/trigger_manager.h +++ b/src/trigger_manager.h @@ -5,6 +5,7 @@ #include #include "config.h" +#include "cjson/cJSON.h" #include "../nostr_core_lib/nostr_core/nostr_core.h" #define TRIGGER_DEFAULT_MAX_ACTIVE 16 @@ -17,14 +18,24 @@ typedef enum { TRIGGER_ACTION_TEMPLATE = 1 } trigger_action_type_t; +typedef enum { + TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0, + TRIGGER_TYPE_WEBHOOK = 1, + TRIGGER_TYPE_CRON = 2, + TRIGGER_TYPE_CHAIN = 3 +} trigger_type_t; + typedef struct { char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX]; char skill_content[TRIGGER_SKILL_CONTENT_MAX]; char filter_json[TRIGGER_FILTER_JSON_MAX]; trigger_action_type_t action_type; int enabled; + trigger_type_t trigger_type; time_t last_fired; time_t last_seen_created_at; + time_t last_cron_fire; + char cron_expr[64]; nostr_pool_subscription_t* subscription; void* subscription_ctx; } active_trigger_t; @@ -41,19 +52,33 @@ typedef struct trigger_manager { int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg); int trigger_manager_load_from_skills(trigger_manager_t* mgr); int trigger_manager_load_from_startup_events(trigger_manager_t* mgr); +trigger_type_t trigger_type_from_string(const char* s); +const char* trigger_type_to_string(trigger_type_t t); + int trigger_manager_add(trigger_manager_t* mgr, const char* skill_d_tag, const char* content, const char* filter_json, trigger_action_type_t action_type, + const char* trigger_type_str, int enabled); int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag); +int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out); int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag, const char* content, const char* filter_json, trigger_action_type_t action_type, + const char* trigger_type_str, int enabled); +int trigger_manager_fire(trigger_manager_t* mgr, + const char* skill_d_tag, + cJSON* event, + const char* source_label); +int trigger_manager_fire_chains(trigger_manager_t* mgr, + const char* source_skill_d_tag, + cJSON* source_event, + const char* source_label); int trigger_manager_active_count(trigger_manager_t* mgr); int trigger_manager_poll(trigger_manager_t* mgr); char* trigger_manager_status_json(trigger_manager_t* mgr);