diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc6064..1ffdc76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- notify templates now expand nested event fields — dotted paths like `{{ event.context.deviceMac }}` and array indexes like `{{ event.list.0 }}` resolve instead of rendering literally. +- `channel: file` notify actions reject relative paths (lint code `notify-relative-path`, also enforced at runtime); `~` is not expanded. +- LLM rule-suggest prompt now embeds the `trigger` and `triggerWebhook` schema defs so `--trigger webhook` flows no longer produce dangling-`$ref` outputs. + ## [3.3.2] - 2026-04-26 ### Fixed diff --git a/README.md b/README.md index a84c3e6..f5c88a9 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,24 @@ Supported conditions: `time_between` (quiet hours) and `device_state` `~/.switchbot/audit.log`. `rules run` is long-running; use `daemon start` / `daemon reload` for the managed background mode. +**Actions** — each rule's `then` array accepts two action types: + +- `type: command` (default, no `type` field required) — sends a device command, e.g. `devices command turnOn` +- `type: notify` — delivers a payload to an external channel after the rule fires: + - `channel: webhook` — HTTP POST to a URL (only `http://` and `https://` schemes are accepted; `rules lint` rejects others) + - `channel: file` — appends a JSONL line to a local file. `to` must be an absolute path; relative or `~`-prefixed paths are rejected by `rules lint` (code `notify-relative-path`) and at runtime + - `channel: openclaw` — HTTP POST to an OpenClaw endpoint (same protocol restriction) + - Optional `template` field supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Nested fields use dot paths, e.g. `{{ event.context.deviceMac }}`; arrays index numerically, e.g. `{{ event.list.0 }}` + +```yaml +then: + - command: devices command AC_001 turnOn + - type: notify + channel: webhook + to: https://your.host/hook + template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' +``` + ```bash # 1. Author rules under `automation.rules`. See examples/policies/automation.yaml # for a walkthrough covering the three trigger sources. @@ -308,8 +326,29 @@ switchbot rules last-fired -n 20 # 20 most recent fire entries switchbot rules conflicts # opposing actions, high-frequency MQTT, # destructive commands, quiet-hours gaps switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean + +# 8. Scaffold a new rule from natural language (heuristic or LLM-backed). +switchbot rules suggest --intent "turn off AC at 11pm" +switchbot rules suggest --intent "if door opens and temp below 20 turn on heater" \ + --llm auto # routes complex intents to LLM automatically +switchbot rules suggest --intent "..." --llm openai # explicit backend +# Set OPENAI_API_KEY or ANTHROPIC_API_KEY; auto mode falls back to heuristic on failure ``` +`rules suggest` enforces several guardrails on LLM output so a model can't quietly arm +something unsafe: + +- **`dry_run` is forced to `true`** on every LLM-generated rule. Review the output and + flip it yourself before running the engine without `--dry-run`. +- **Explicit overrides always win.** If you pass `--trigger`, the LLM's answer must match; + a mismatch fails fast. Within the same trigger, mismatched `--event` / `--schedule` / + `--days` / `--webhook-path` are rewritten to your value with a warning. +- **`--llm` is enum-validated at the CLI** (`auto | openai | anthropic`) — junk values + exit non-zero instead of falling through. +- **Notify URLs must be `http://` or `https://`.** `rules lint` and the runtime both + reject `file://`, `ftp://`, etc., so a generated webhook can't smuggle in a non-HTTP + scheme. + When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add. Webhook trigger token management: @@ -842,8 +881,12 @@ Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`, `account_overview`, `plan_suggest`, `plan_run`, `audit_query`, `audit_stats`, `policy_diff`, `policy_validate`, `policy_new`, -`policy_migrate`) plus a `switchbot://events` resource for real-time -shadow updates. +`policy_migrate`, `rules_suggest`, `rule_notifications`) plus a +`switchbot://events` resource for real-time shadow updates. +`rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`) +to generate YAML for complex intents via an LLM backend. +`rule_notifications` returns `rule-notify` audit entries, filterable by rule +name, time range, channel, and result. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard). ### `doctor` — self-check @@ -853,7 +896,7 @@ switchbot doctor switchbot doctor --json ``` -Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). Use this to diagnose connectivity or config issues before running automation. +Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, notify-connectivity) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. `--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: @@ -1165,8 +1208,16 @@ src/ │ ├── audit-query.ts # Audit log filtering + aggregation │ ├── conflict-analyzer.ts # Static conflict detection (opposing actions, │ │ # high-freq MQTT, destructive cmds, quiet-hours gaps) -│ ├── suggest.ts # Heuristic-based rule YAML generation -│ └── types.ts # Shared rule/trigger/condition/action types +│ ├── suggest.ts # Heuristic + LLM-backed rule YAML generation +│ ├── notify.ts # notify action executor (webhook / file / openclaw) +│ └── types.ts # Shared rule/trigger/condition/action types (CommandAction | NotifyAction) +├── llm/ +│ ├── index.ts # createLLMProvider factory + LLM_AUTO_THRESHOLD +│ ├── complexity.ts # Intent complexity scorer (0–10) for auto-routing +│ ├── rule-prompt.ts # System prompt builder (embeds v0.2 schema snippet) +│ └── providers/ +│ ├── openai.ts # OpenAI-compatible provider (uses Node.js https) +│ └── anthropic.ts # Anthropic provider ├── status-sync/ │ └── manager.ts # Spawn/stop logic, state file, OpenClaw bridge ├── lib/ diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index f421b08..0b9d975 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Phase 1 through Phase 4 -> **Status as of 2026-04-23:** Phase 1 complete, Phase 2 complete, +> **Status as of 2026-05-06:** Phase 1 complete, Phase 2 complete, > Phase 3A complete (keychain + install library + built-in CLI install > command), Phase 3B tracked in the separate companion skill repo, > Phase 4 shipped at v0.2 (rules engine with MQTT + cron + @@ -9,6 +9,8 @@ > v2.14.0 extends MCP with `plan_run`, `audit_query`, `audit_stats`, > and `policy_diff`; v2.15.0 flips `policy new` default schema to v0.2 > and starts the v0.1 deprecation window. +> Tracks θ (notify actions) and η (LLM-backed rule suggestion) +> shipped in v3.0. > Note: Track γ is a runtime capability increment on the v0.2 rule > model, not a separate policy schema version. @@ -181,6 +183,19 @@ the skill's `manifest.json` `roadmap` block, which points back here. - **Track ε — cross-OS CI matrix for keychain *(shipped, v2.11.0)*.** GitHub Actions matrix: macOS (temp keychain), Linux (D-Bus + gnome-keyring), Windows (native Credential Manager). +- **Track θ — notify actions *(shipped, v3.0)*.** + New `type: notify` action alongside `type: command` in the v0.2 + schema. Rules can POST to webhooks, append JSONL to a file, or push + to OpenClaw after firing, closing the feedback loop for AI agents. + `lintRules` validates URL syntax and required fields; engine dispatches + to `executeNotifyAction`; audit gains `rule-notify` kind; MCP gains + `rule_notifications` tool; `doctor` gains `notify-connectivity` check. +- **Track η — LLM-backed rule suggestion *(shipped, v3.0)*.** + `rules suggest --llm ` routes complex intents (complexity + score ≥ 4) to OpenAI or Anthropic, falls back to heuristic with a + warning on provider failure. `rules_suggest` MCP tool gains a `llm` + parameter. All LLM calls are written to the audit log as + `kind: llm-suggest` with backend, model, and latency fields. ## Next execution queue (ordered) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 062589c..527737d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -922,6 +922,41 @@ function checkReleaseNotes(): Check { }; } +function checkNotifyConnectivity(): Check { + const policyPath = resolvePolicyPath(); + let loaded: { data: unknown }; + try { + loaded = loadPolicyFile(policyPath); + } catch (err) { + if (err instanceof PolicyFileNotFoundError) { + return { name: 'notify-connectivity', status: 'ok', detail: { present: false, message: 'no policy file configured' } }; + } + return { name: 'notify-connectivity', status: 'ok', detail: { present: false, message: 'policy file could not be loaded' } }; + } + + const policy = loaded.data as { automation?: { rules?: Array<{ then?: Array<{ type?: string; channel?: string; to?: string }> }> } } | null; + const rules = policy?.automation?.rules ?? []; + const webhookUrls: string[] = []; + for (const rule of rules) { + for (const action of rule.then ?? []) { + if (action.type === 'notify' && (action.channel === 'webhook' || action.channel === 'openclaw') && action.to) { + webhookUrls.push(action.to); + } + } + } + + if (webhookUrls.length === 0) { + return { name: 'notify-connectivity', status: 'ok', detail: { webhookCount: 0, message: 'no webhook notify actions in policy' } }; + } + + return { + name: 'notify-connectivity', + status: 'ok', + detail: { webhookCount: webhookUrls.length, message: `${webhookUrls.length} webhook URL(s) configured (live probe not run — use --probe to test connectivity)` }, + }; +} + + interface CheckDef { name: string; description: string; @@ -955,6 +990,7 @@ const CHECK_REGISTRY: CheckDef[] = [ { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() }, { name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() }, { name: 'health', description: 'health endpoint availability (daemon --healthz-port)', run: () => checkHealthEndpoint() }, + { name: 'notify-connectivity', description: 'webhook URLs from notify actions in policy.yaml', run: () => checkNotifyConnectivity() }, ]; interface FixResult { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 69ab563..65bfc93 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1874,6 +1874,51 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, }, ); + // ---- rule_notifications --------------------------------------------------- + server.registerTool( + 'rule_notifications', + { + title: 'Query rule notification delivery history', + description: + 'Returns audit entries for notify actions (kind: rule-notify). ' + + 'Useful for confirming whether a notification rule fired and whether delivery succeeded. ' + + 'Filter by rule name, time range, result, or channel.', + _meta: { agentSafetyTier: 'read' }, + inputSchema: z.object({ + file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'), + rule: z.string().optional().describe('Filter by rule name (exact match).'), + since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'), + from: z.string().optional().describe('Range start (ISO-8601).'), + to: z.string().optional().describe('Range end (ISO-8601).'), + result: z.enum(['ok', 'error']).optional().describe('Filter by delivery result.'), + channel: z.enum(['webhook', 'openclaw', 'file']).optional().describe('Filter by notify channel.'), + limit: z.number().int().min(1).max(500).default(100).describe('Max entries to return (newest first).'), + }).strict(), + outputSchema: { + entries: z.array(z.unknown()).describe('Matching audit entries, newest first.'), + total: z.number().int().describe('Count after filtering.'), + }, + }, + ({ file, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => { + const filePath = file ?? DEFAULT_AUDIT_LOG_FILE; + let entries = readAudit(filePath).filter(e => e.kind === 'rule-notify'); + if (ruleName) entries = entries.filter(e => e.rule?.name === ruleName); + if (resultFilter) entries = entries.filter(e => e.result === resultFilter); + if (channel) entries = entries.filter(e => e.notifyChannel === channel); + try { + entries = filterAuditEntries(entries, { since, from, to }); + } catch (err) { + return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid filter options'); + } + const bounded = entries.slice(-limit).reverse(); + const out = { entries: bounded, total: entries.length }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(out, null, 2) }], + structuredContent: out, + }; + }, + ); + // ---- rules_suggest -------------------------------------------------------- server.registerTool( 'rules_suggest', @@ -1881,7 +1926,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, title: 'Draft a SwitchBot automation rule from intent', description: 'Generate a candidate automation rule YAML from a natural language intent. ' + - 'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' + + 'Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). ' + 'Always emits dry_run: true — the rule must be reviewed before arming. ' + 'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.', _meta: { agentSafetyTier: 'read' }, @@ -1893,6 +1938,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'), days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'), webhook_path: z.string().optional().describe('Webhook path override (default /action).'), + llm: z.enum(['auto', 'openai', 'anthropic']).optional().describe('LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.'), }).strict(), outputSchema: { rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'), @@ -1900,13 +1946,13 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'), }, }, - ({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => { + async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => { const devices = (device_ids ?? []).map((id) => { const cached = getCachedDevice(id); return { id, name: cached?.name, type: cached?.type }; }); try { - const { rule, ruleYaml, warnings } = suggestRule({ + const { rule, ruleYaml, warnings } = await suggestRule({ intent, trigger, devices, @@ -1914,6 +1960,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, schedule, days, webhookPath: webhook_path, + llm, }); return { content: [{ type: 'text' as const, text: ruleYaml }], @@ -2039,6 +2086,7 @@ export function registerMcpCommand(program: Command): void { - plan_run validate + execute a Plan JSON document - audit_query filter audit log entries by time/device/rule/result - audit_stats aggregate audit counts by kind/result/device/rule + - rule_notifications query rule notify action delivery history - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM) - policy_add_rule append a rule into automation.rules[] in policy.yaml diff --git a/src/commands/rules.ts b/src/commands/rules.ts index d5b9bcc..6a0ccc0 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { isJsonMode, printJson, exitWithError, printTable, handleError } from '../utils/output.js'; +import { isJsonMode, printJson, exitWithError, printTable, handleError, UsageError } from '../utils/output.js'; import { loadPolicyFile, resolvePolicyPath, @@ -24,6 +24,7 @@ import { fetchMqttCredential } from '../mqtt/credential.js'; import { SwitchBotMqttClient } from '../mqtt/client.js'; import { WebhookTokenStore } from '../rules/webhook-token.js'; import { suggestRule } from '../rules/suggest.js'; +import type { LLMBackend } from '../llm/index.js'; import { getCachedDevice } from '../devices/cache.js'; import { getDefaultPidFilePaths, @@ -621,7 +622,7 @@ function registerWebhookShowToken(rules: Command): void { function registerSuggest(rules: Command): void { rules .command('suggest') - .description('Generate a candidate rule YAML from intent + devices (heuristic, no LLM)') + .description('Generate a candidate rule YAML from intent + devices') .requiredOption('--intent ', 'Natural language description of the automation') .option('--trigger ', 'mqtt | cron | webhook (inferred from intent if omitted)') .option( @@ -635,8 +636,9 @@ function registerSuggest(rules: Command): void { .option('--days ', 'Weekday filter, comma-separated (e.g. mon,tue,wed,thu,fri)') .option('--webhook-path ', 'Webhook path override (default: /action)') .option('--out ', 'Write YAML to file instead of stdout') + .option('--llm ', 'LLM backend for suggestion: auto | openai | anthropic') .action( - (opts: { + async (opts: { intent: string; trigger?: string; device: string[]; @@ -645,15 +647,19 @@ function registerSuggest(rules: Command): void { days?: string; webhookPath?: string; out?: string; + llm?: string; }) => { try { + if (opts.llm !== undefined && !['auto', 'openai', 'anthropic'].includes(opts.llm)) { + throw new UsageError(`--llm must be one of: auto, openai, anthropic (got "${opts.llm}")`); + } const trigger = opts.trigger as 'mqtt' | 'cron' | 'webhook' | undefined; const days = opts.days ? opts.days.split(',').map((d) => d.trim()) : undefined; const devices = opts.device.map((ref) => { const cached = getCachedDevice(ref); return { id: ref, name: cached?.name, type: cached?.type }; }); - const { rule, ruleYaml, warnings } = suggestRule({ + const { rule, ruleYaml, warnings } = await suggestRule({ intent: opts.intent, trigger, devices, @@ -661,6 +667,7 @@ function registerSuggest(rules: Command): void { schedule: opts.schedule, days, webhookPath: opts.webhookPath, + llm: opts.llm as LLMBackend | undefined, }); for (const w of warnings) process.stderr.write(`warning: ${w}\n`); if (opts.out) { @@ -871,7 +878,10 @@ function registerExplain(rules: Command): void { console.log(`conditions: ${detail.conditions.length === 0 ? '(none)' : JSON.stringify(detail.conditions)}`); console.log(`actions: ${detail.actions.length}`); for (const a of detail.actions) { - console.log(` - ${a.command}${a.device ? ` [${a.device}]` : ''}${a.on_error ? ` on_error=${a.on_error}` : ''}`); + const label = 'command' in a && a.command + ? `${a.command}${a.device ? ` [${a.device}]` : ''}${a.on_error ? ` on_error=${a.on_error}` : ''}` + : `type=notify channel=${(a as import('../rules/types.js').NotifyAction).channel} to=${(a as import('../rules/types.js').NotifyAction).to}`; + console.log(` - ${label}`); } if (detail.cooldown) console.log(`cooldown: ${detail.cooldown}`); if (detail.hysteresis) console.log(`hysteresis: ${detail.hysteresis}`); diff --git a/src/llm/complexity.ts b/src/llm/complexity.ts new file mode 100644 index 0000000..33eaa49 --- /dev/null +++ b/src/llm/complexity.ts @@ -0,0 +1,26 @@ +/** + * Scores how "complex" a natural-language automation intent is, 0–10. + * Simple intents (< 4) are handled by the keyword heuristic; + * complex ones (>= 4) benefit from an LLM. + */ + +const SIGNALS: Array<{ pattern: RegExp; weight: number }> = [ + { pattern: /\band\b/i, weight: 1 }, + { pattern: /\bbut\b|\bexcept\b|\bunless\b/i, weight: 1.5 }, + { pattern: /\bif\b/i, weight: 1 }, + { pattern: /\balready\b|\bskip\b/i, weight: 1.5 }, + { pattern: /\bbetween\s+\d/i, weight: 2 }, + { pattern: /\bfor\s+\d+\s*(min|hour|second)/i, weight: 1.5 }, + { pattern: /\bpast\s+\d+\s*(min|hour)/i, weight: 2 }, + { pattern: /\bweekday|monday|tuesday|wednesday|thursday|friday/i, weight: 1 }, + { pattern: /\btemperature\b.*\bhumidity\b|\bhumidity\b.*\btemperature\b/i, weight: 2 }, + { pattern: /\b(above|below|greater|less)\s+\d/i, weight: 1 }, +]; + +export function scoreIntentComplexity(intent: string): number { + let score = 0; + for (const { pattern, weight } of SIGNALS) { + if (pattern.test(intent)) score += weight; + } + return Math.min(10, score); +} diff --git a/src/llm/index.ts b/src/llm/index.ts new file mode 100644 index 0000000..8ace362 --- /dev/null +++ b/src/llm/index.ts @@ -0,0 +1,16 @@ +import { OpenAIProvider } from './providers/openai.js'; +import { AnthropicProvider } from './providers/anthropic.js'; +import type { LLMProvider, LLMProviderOptions } from './provider.js'; + +export type LLMBackend = 'openai' | 'anthropic' | 'local' | 'auto'; + +export { scoreIntentComplexity } from './complexity.js'; +export type { LLMProvider, LLMProviderOptions }; + +export const LLM_AUTO_THRESHOLD = 4; + +export function createLLMProvider(backend: Exclude, opts: LLMProviderOptions = {}): LLMProvider { + if (backend === 'openai' || backend === 'local') return new OpenAIProvider(opts); + if (backend === 'anthropic') return new AnthropicProvider(opts); + throw new Error(`Unknown LLM backend: ${backend}`); +} diff --git a/src/llm/provider.ts b/src/llm/provider.ts new file mode 100644 index 0000000..799c963 --- /dev/null +++ b/src/llm/provider.ts @@ -0,0 +1,12 @@ +export interface LLMProvider { + readonly name: string; + readonly model: string; + generateYaml(systemPrompt: string, userIntent: string): Promise; +} + +export interface LLMProviderOptions { + model?: string; + baseUrl?: string; + timeoutMs?: number; + maxTokens?: number; +} diff --git a/src/llm/providers/anthropic.ts b/src/llm/providers/anthropic.ts new file mode 100644 index 0000000..b5c20b5 --- /dev/null +++ b/src/llm/providers/anthropic.ts @@ -0,0 +1,67 @@ +import https from 'node:https'; +import type { LLMProvider, LLMProviderOptions } from '../provider.js'; + +export class AnthropicProvider implements LLMProvider { + readonly name = 'anthropic'; + readonly model: string; + private readonly apiKey: string; + private readonly timeoutMs: number; + private readonly maxTokens: number; + + constructor(opts: LLMProviderOptions = {}) { + const key = process.env.ANTHROPIC_API_KEY ?? process.env.LLM_API_KEY; + if (!key) throw new Error('ANTHROPIC_API_KEY or LLM_API_KEY environment variable required for anthropic backend'); + this.apiKey = key; + this.model = opts.model ?? 'claude-haiku-4-5-20251001'; + this.timeoutMs = opts.timeoutMs ?? 30_000; + this.maxTokens = opts.maxTokens ?? 2048; + } + + async generateYaml(systemPrompt: string, userIntent: string): Promise { + const body = JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + system: systemPrompt, + messages: [{ role: 'user', content: userIntent }], + }); + + const responseBody = await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.anthropic.com', + port: 443, + path: '/v1/messages', + method: 'POST', + headers: { + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: this.timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf-8'); + if (res.statusCode !== undefined && res.statusCode >= 400) { + reject(new Error(`Anthropic API error ${res.statusCode}: ${text.slice(0, 200)}`)); + } else { + resolve(text); + } + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('LLM request timeout'))); + req.write(body); + req.end(); + }); + + const json = JSON.parse(responseBody) as { content: Array<{ type: string; text: string }> }; + const content = json.content?.find(c => c.type === 'text')?.text; + if (!content) throw new Error('Anthropic returned empty content'); + return content.replace(/^```ya?ml\n?/i, '').replace(/\n?```\s*$/i, '').trim(); + } +} diff --git a/src/llm/providers/openai.ts b/src/llm/providers/openai.ts new file mode 100644 index 0000000..3a6ae1c --- /dev/null +++ b/src/llm/providers/openai.ts @@ -0,0 +1,74 @@ +import https from 'node:https'; +import http from 'node:http'; +import type { LLMProvider, LLMProviderOptions } from '../provider.js'; + +export class OpenAIProvider implements LLMProvider { + readonly name = 'openai'; + readonly model: string; + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly timeoutMs: number; + private readonly maxTokens: number; + + constructor(opts: LLMProviderOptions = {}) { + const key = process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY; + if (!key) throw new Error('OPENAI_API_KEY or LLM_API_KEY environment variable required for openai backend'); + this.apiKey = key; + this.model = opts.model ?? 'gpt-4o-mini'; + this.baseUrl = opts.baseUrl ?? 'https://api.openai.com'; + this.timeoutMs = opts.timeoutMs ?? 30_000; + this.maxTokens = opts.maxTokens ?? 2048; + } + + async generateYaml(systemPrompt: string, userIntent: string): Promise { + const body = JSON.stringify({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userIntent }, + ], + max_tokens: this.maxTokens, + temperature: 0, + }); + + const parsed = new URL(`${this.baseUrl}/v1/chat/completions`); + const isHttps = parsed.protocol === 'https:'; + const responseBody = await new Promise((resolve, reject) => { + const req = (isHttps ? https : http).request( + { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname, + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: this.timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf-8'); + if (res.statusCode !== undefined && res.statusCode >= 400) { + reject(new Error(`OpenAI API error ${res.statusCode}: ${text.slice(0, 200)}`)); + } else { + resolve(text); + } + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('LLM request timeout'))); + req.write(body); + req.end(); + }); + + const json = JSON.parse(responseBody) as { choices: Array<{ message: { content: string } }> }; + const content = json.choices?.[0]?.message?.content; + if (!content) throw new Error('OpenAI returned empty content'); + return content.replace(/^```ya?ml\n?/i, '').replace(/\n?```\s*$/i, '').trim(); + } +} diff --git a/src/llm/rule-prompt.ts b/src/llm/rule-prompt.ts new file mode 100644 index 0000000..ee05e1f --- /dev/null +++ b/src/llm/rule-prompt.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadSchemaSnippet(): string { + const schemaPath = join(__dirname, '../policy/schema/v0.2.json'); + const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')) as Record; + const defs = schema.$defs as Record; + return JSON.stringify({ + rule: defs?.rule, + commandAction: defs?.commandAction, + notifyAction: defs?.notifyAction, + condition: defs?.condition, + trigger: defs?.trigger, + triggerMqtt: defs?.triggerMqtt, + triggerCron: defs?.triggerCron, + triggerWebhook: defs?.triggerWebhook, + }, null, 2); +} + +export interface DeviceSummary { + id: string; + name?: string; + type?: string; +} + +export function buildRuleSuggestSystemPrompt(devices: DeviceSummary[], aliases: Record): string { + // NOTE: tests/llm/rule-prompt.test.ts extracts the embedded schema snippet + // by string-slicing between "JSON Schema:\n" and "\n\nRules:" — keep those + // two delimiters intact (or update that test) when reformatting this prompt. + return `You are a SwitchBot automation rule generator. + +Output ONLY a valid YAML object for a single rule conforming to this JSON Schema: +${loadSchemaSnippet()} + +Rules: +- Output ONLY YAML. No explanation, no markdown fences, no surrounding text. +- Always include dry_run: true. +- MQTT trigger events: device.shadow, motion.detected, motion.cleared, contact.opened, contact.closed, button.pressed. +- For cron use standard 5-field cron in local timezone. +- Webhook trigger uses { source: webhook, path: "/your-path" } where path matches ^/[a-z0-9/_-]+$. +- Device commands: "devices command ". Valid verbs: turnOn, turnOff, open, close, lock, unlock, press, pause. +- Set on_error: continue unless the intent explicitly requires stopping. +- If you cannot generate a valid rule, output: # ERROR: + +Available devices: +${devices.length > 0 ? devices.map(d => `- id: ${d.id} name: ${d.name ?? '(unnamed)'} type: ${d.type ?? 'unknown'}`).join('\n') : '(none provided)'} + +Aliases: +${Object.keys(aliases).length > 0 ? Object.entries(aliases).map(([k, v]) => `- ${k}: ${v}`).join('\n') : '(none defined)'}`; +} diff --git a/src/policy/schema/v0.2.json b/src/policy/schema/v0.2.json index 16a8ced..15b82fe 100644 --- a/src/policy/schema/v0.2.json +++ b/src/policy/schema/v0.2.json @@ -304,10 +304,21 @@ }, "action": { + "oneOf": [ + { "$ref": "#/$defs/commandAction" }, + { "$ref": "#/$defs/notifyAction" } + ] + }, + + "commandAction": { "type": "object", "additionalProperties": false, "required": ["command"], "properties": { + "type": { + "enum": ["command"], + "description": "Optional discriminator. Omit for backwards compatibility." + }, "command": { "type": "string", "description": "A CLI invocation fragment, e.g. `devices command turnOn`. The engine prepends `switchbot` and appends `--audit-log`." @@ -326,6 +337,38 @@ "description": "If this action fails, should the rule keep executing its remaining `then[]` entries?" } } + }, + + "notifyAction": { + "type": "object", + "additionalProperties": false, + "required": ["type", "channel", "to"], + "properties": { + "type": { "const": "notify" }, + "channel": { + "enum": ["webhook", "openclaw", "file"], + "description": "Delivery channel." + }, + "to": { + "type": "string", + "minLength": 1, + "description": "URL for webhook/openclaw; absolute file path for file channel." + }, + "template": { + "type": "string", + "description": "Template string with {{ rule.name }}, {{ event.* }}, {{ device.id }} substitutions." + }, + "on_failure": { + "enum": ["log", "retry", "ignore"], + "default": "log", + "description": "What to do when delivery fails." + }, + "on_error": { + "enum": ["continue", "stop"], + "default": "continue", + "description": "If this action fails, should the rule keep executing its remaining `then[]` entries?" + } + } } } } diff --git a/src/rules/action.ts b/src/rules/action.ts index 92b4087..7809a29 100644 --- a/src/rules/action.ts +++ b/src/rules/action.ts @@ -21,6 +21,7 @@ import type { AxiosInstance } from 'axios'; import { executeCommand } from '../lib/devices.js'; import { writeAudit } from '../utils/audit.js'; import { isDestructiveCommand } from './destructive.js'; +import { isCommandAction } from './types.js'; import type { Action, Rule } from './types.js'; export interface RuleActionContext { @@ -102,6 +103,9 @@ export async function executeRuleAction( action: Action, ctx: RuleActionContext, ): Promise { + if (!isCommandAction(action)) { + return { ok: false, error: 'notify-actions-require-executeNotifyAction', blocked: true }; + } const parsed = parseRuleCommand(action.command); if (!parsed) { writeAudit({ @@ -258,7 +262,7 @@ export async function executeRuleAction( * Prefers `action.device` over the deviceId embedded in the command string. * Use resolveActionDevice() when alias resolution is needed. */ -export function extractDeviceIdFromAction(action: { command: string; device?: string }): string | null { +export function extractDeviceIdFromAction(action: { command?: string; device?: string }): string | null { if (action.device) return action.device; const m = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? ''); return m ? m[1] : null; diff --git a/src/rules/conflict-analyzer.ts b/src/rules/conflict-analyzer.ts index 5e5294a..2f8decd 100644 --- a/src/rules/conflict-analyzer.ts +++ b/src/rules/conflict-analyzer.ts @@ -24,7 +24,7 @@ */ import type { Rule, Condition } from './types.js'; -import { isTimeBetween, isAllCondition, isAnyCondition, isNotCondition } from './types.js'; +import { isTimeBetween, isAllCondition, isAnyCondition, isNotCondition, isCommandAction } from './types.js'; import { parseMaxPerMs } from './throttle.js'; import { isDestructiveCommand } from './destructive.js'; import { extractDeviceIdFromAction } from './action.js'; @@ -139,6 +139,7 @@ export function analyzeConflicts(rules: Rule[], quietHours?: QuietHours | null): for (const actionA of a.then) { for (const actionB of b.then) { + if (!isCommandAction(actionA) || !isCommandAction(actionB)) continue; const deviceA = extractDeviceIdFromAction(actionA); const deviceB = extractDeviceIdFromAction(actionB); // Skip if devices can't be compared. @@ -191,7 +192,9 @@ export function analyzeConflicts(rules: Rule[], quietHours?: QuietHours | null): // surface early with clear guidance). for (const rule of active) { for (let i = 0; i < rule.then.length; i++) { - const verb = extractCommandVerb(rule.then[i].command); + const action = rule.then[i]; + if (!isCommandAction(action)) continue; + const verb = extractCommandVerb(action.command); if (isDestructiveCommand(verb)) { findings.push({ severity: 'error', diff --git a/src/rules/engine.ts b/src/rules/engine.ts index f549b4d..9f0436c 100644 --- a/src/rules/engine.ts +++ b/src/rules/engine.ts @@ -19,6 +19,7 @@ */ import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import type { AxiosInstance } from 'axios'; import type { SwitchBotMqttClient } from '../mqtt/client.js'; import type { MqttCredential } from '../mqtt/credential.js'; @@ -41,7 +42,10 @@ import { isCronTrigger, isMqttTrigger, isWebhookTrigger, + isCommandAction, + isNotifyAction, } from './types.js'; +import { executeNotifyAction } from './notify.js'; import { Cron } from 'croner'; import { writeAudit } from '../utils/audit.js'; @@ -119,7 +123,9 @@ export function lintRules(automation: AutomationBlock | null | undefined): LintR // Destructive guard for (let i = 0; i < r.then.length; i++) { - if (isDestructiveCommand(r.then[i].command)) { + const action = r.then[i]; + if (!isCommandAction(action)) continue; + if (isDestructiveCommand(action.command)) { issues.push({ rule: r.name, severity: 'error', @@ -129,6 +135,48 @@ export function lintRules(automation: AutomationBlock | null | undefined): LintR } } + // Notify action validation + for (let i = 0; i < r.then.length; i++) { + const action = r.then[i]; + if (!isNotifyAction(action)) continue; + const to = (action as { to?: string }).to; + if (!to || to.trim().length === 0) { + issues.push({ + rule: r.name, + severity: 'error', + code: 'notify-missing-to', + message: `then[${i}] notify action is missing required field "to".`, + }); + } else if (action.channel === 'webhook' || action.channel === 'openclaw') { + let parsedUrl: URL | undefined; + try { parsedUrl = new URL(to); } catch { /* parsedUrl stays undefined */ } + if (!parsedUrl) { + issues.push({ + rule: r.name, + severity: 'error', + code: 'notify-invalid-url', + message: `then[${i}] notify action "to" field "${to}" is not a valid URL.`, + }); + } else if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + issues.push({ + rule: r.name, + severity: 'error', + code: 'notify-unsupported-protocol', + message: `then[${i}] notify URL "${to}" uses unsupported protocol "${parsedUrl.protocol}" — only http: and https: are allowed.`, + }); + } + } else if (action.channel === 'file') { + if (!path.isAbsolute(to)) { + issues.push({ + rule: r.name, + severity: 'error', + code: 'notify-relative-path', + message: `then[${i}] notify file path "${to}" must be absolute (e.g. /var/log/switchbot/notify.jsonl on POSIX or C:\\path\\notify.jsonl on Windows). Tilde (~) is not expanded.`, + }); + } + } + } + // Throttle expression if (r.throttle) { try { @@ -714,7 +762,7 @@ export class RulesEngine { t: event.t.toISOString(), kind: 'rule-fire', deviceId: event.deviceId ?? 'unknown', - command: rule.then[0]?.command ?? '', + command: rule.then[0] && isCommandAction(rule.then[0]) ? rule.then[0].command : '', parameter: null, commandType: 'command', dryRun: true, @@ -749,7 +797,7 @@ export class RulesEngine { t: event.t.toISOString(), kind: 'rule-throttled', deviceId: event.deviceId ?? 'unknown', - command: rule.then[0]?.command ?? '', + command: rule.then[0] && isCommandAction(rule.then[0]) ? rule.then[0].command : '', parameter: null, commandType: 'command', dryRun: true, @@ -781,7 +829,7 @@ export class RulesEngine { this.hysteresisFirstSeen.set(hysteresisKey, now); writeAudit({ t: event.t.toISOString(), kind: 'rule-throttled', - deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '', + deviceId: event.deviceId ?? 'unknown', command: rule.then[0] && isCommandAction(rule.then[0]) ? rule.then[0].command : '', parameter: null, commandType: 'command', dryRun: true, result: 'ok', rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — first observation, waiting ${hysteresisMs}ms for stability` }, }); @@ -791,7 +839,7 @@ export class RulesEngine { if (now - firstSeen < hysteresisMs) { writeAudit({ t: event.t.toISOString(), kind: 'rule-throttled', - deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '', + deviceId: event.deviceId ?? 'unknown', command: rule.then[0] && isCommandAction(rule.then[0]) ? rule.then[0].command : '', parameter: null, commandType: 'command', dryRun: true, result: 'ok', rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — stable for ${now - firstSeen}ms of required ${hysteresisMs}ms` }, }); @@ -809,7 +857,7 @@ export class RulesEngine { this.stats.throttled++; writeAudit({ t: event.t.toISOString(), kind: 'rule-throttled', - deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '', + deviceId: event.deviceId ?? 'unknown', command: rule.then[0] && isCommandAction(rule.then[0]) ? rule.then[0].command : '', parameter: null, commandType: 'command', dryRun: true, result: 'ok', rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `maxFiringsPerHour — ${countCheck.count}/${countCheck.max} in last hour` }, }); @@ -821,10 +869,11 @@ export class RulesEngine { // suppressIfAlreadyDesired: skip if device's live state already matches the command outcome. if (rule.suppressIfAlreadyDesired) { const firstAction = rule.then[0]; - const parsed = firstAction ? parseRuleCommand(firstAction.command) : null; - const verb = parsed?.verb ?? firstAction?.command; - if ((verb === 'turnOn' || verb === 'turnOff') && (firstAction?.device || event.deviceId)) { - const targetId = firstAction?.device ? (this.aliases[firstAction.device] ?? firstAction.device) : event.deviceId!; + const firstCmd = firstAction && isCommandAction(firstAction) ? firstAction : null; + const parsed = firstCmd ? parseRuleCommand(firstCmd.command) : null; + const verb = parsed?.verb ?? firstCmd?.command; + if ((verb === 'turnOn' || verb === 'turnOff') && (firstCmd?.device || event.deviceId)) { + const targetId = firstCmd?.device ? (this.aliases[firstCmd.device] ?? firstCmd.device) : event.deviceId!; try { const deviceStatus = await fetchStatus(targetId); const powerState = deviceStatus['powerState'] as string | undefined; @@ -847,14 +896,26 @@ export class RulesEngine { let fired = false; let allDry = true; for (const action of rule.then) { - const result = await executeRuleAction(action, { - rule, - fireId, - aliases: this.aliases, - httpClient: this.opts.httpClient, - globalDryRun: this.opts.globalDryRun, - skipApiCall: this.opts.skipApiCall, - }); + let result: { ok: boolean; error?: string; blocked?: boolean; dryRun?: boolean; deviceId?: string }; + if (isNotifyAction(action)) { + const nr = await executeNotifyAction(action, { + rule, + fireId, + eventPayload: event.payload, + deviceId: event.deviceId, + globalDryRun: this.opts.globalDryRun, + }); + result = { ok: nr.ok, dryRun: nr.dryRun, error: nr.error }; + } else { + result = await executeRuleAction(action, { + rule, + fireId, + aliases: this.aliases, + httpClient: this.opts.httpClient, + globalDryRun: this.opts.globalDryRun, + skipApiCall: this.opts.skipApiCall, + }); + } if (result.blocked) { this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'blocked', deviceId: result.deviceId, reason: result.error }); if ((action.on_error ?? 'continue') === 'stop') break; diff --git a/src/rules/notify.ts b/src/rules/notify.ts new file mode 100644 index 0000000..99929c1 --- /dev/null +++ b/src/rules/notify.ts @@ -0,0 +1,228 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import https from 'node:https'; +import { writeAudit } from '../utils/audit.js'; +import type { NotifyAction, Rule } from './types.js'; + +export interface NotifyContext { + rule: Rule; + fireId: string; + eventPayload?: unknown; + deviceId?: string; + globalDryRun?: boolean; +} + +export interface NotifyResult { + ok: boolean; + channel: string; + latencyMs: number; + dryRun?: boolean; + error?: string; +} + +export function renderNotifyTemplate(template: string, vars: Record): string { + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key: string) => { + const val = vars[key]; + return val !== undefined ? String(val) : `{{ ${key} }}`; + }); +} + +function flattenForTemplate(prefix: string, value: unknown, out: Record, depth = 0): void { + if (depth > 6) return; + if (value === null) { + out[prefix] = null; + return; + } + if (value instanceof Date) { + out[prefix] = value.toISOString(); + return; + } + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') { + out[prefix] = value; + return; + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + flattenForTemplate(`${prefix}.${i}`, value[i], out, depth + 1); + } + return; + } + if (t === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + flattenForTemplate(`${prefix}.${k}`, v, out, depth + 1); + } + } + // undefined / function / symbol / bigint — skip silently +} + +function buildTemplateVars(action: NotifyAction, ctx: NotifyContext): Record { + const vars: Record = { + 'rule.name': ctx.rule.name, + 'rule.fired_at': new Date().toISOString(), + 'device.id': ctx.deviceId ?? '', + 'action.channel': action.channel, + 'action.to': action.to, + 'fireId': ctx.fireId, + }; + if (ctx.eventPayload !== undefined && ctx.eventPayload !== null) { + flattenForTemplate('event', ctx.eventPayload, vars); + } + return vars; +} + +function buildDefaultBody(action: NotifyAction, ctx: NotifyContext): string { + return JSON.stringify({ + rule: ctx.rule.name, + fireId: ctx.fireId, + deviceId: ctx.deviceId, + channel: action.channel, + firedAt: new Date().toISOString(), + payload: ctx.eventPayload, + }); +} + +async function sendWebhook(url: string, body: string): Promise { + return new Promise((resolve, reject) => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + reject(new Error(`invalid URL: ${url}`)); + return; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`unsupported protocol "${parsed.protocol}" — only http: and https: are allowed`)); + return; + } + const isHttps = parsed.protocol === 'https:'; + const options = { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'User-Agent': 'switchbot-cli/notify', + }, + }; + + const req = (isHttps ? https : http).request(options, (res) => { + res.resume(); + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolve(); + } else { + reject(new Error(`HTTP ${status}`)); + } + }); + + req.on('error', reject); + req.setTimeout(10_000, () => { + req.destroy(new Error('webhook request timed out')); + }); + req.write(body); + req.end(); + }); +} + +function appendToFile(filePath: string, body: string): void { + if (!path.isAbsolute(filePath)) { + throw new Error( + `notify file path must be absolute, got "${filePath}" — use e.g. /var/log/switchbot/notify.jsonl on POSIX or C:\\path\\notify.jsonl on Windows. Tilde (~) is not expanded.`, + ); + } + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(filePath, body + '\n', 'utf-8'); +} + +export async function executeNotifyAction( + action: NotifyAction, + ctx: NotifyContext, +): Promise { + const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true; + const start = Date.now(); + const vars = buildTemplateVars(action, ctx); + const body = action.template + ? renderNotifyTemplate(action.template, vars) + : buildDefaultBody(action, ctx); + + if (dryRun) { + const latencyMs = Date.now() - start; + writeAudit({ + t: new Date().toISOString(), + kind: 'rule-notify', + deviceId: ctx.deviceId ?? '', + command: `notify:${action.channel}`, + parameter: action.to, + commandType: 'command', + dryRun: true, + result: 'ok', + notifyChannel: action.channel, + notifyLatencyMs: latencyMs, + rule: { + name: ctx.rule.name, + triggerSource: ctx.rule.when.source, + fireId: ctx.fireId, + }, + }); + return { ok: true, channel: action.channel, latencyMs, dryRun: true }; + } + + let error: string | undefined; + try { + if (action.channel === 'webhook' || action.channel === 'openclaw') { + await sendWebhook(action.to, body); + } else { + appendToFile(action.to, body); + } + } catch (err) { + error = err instanceof Error ? err.message : String(err); + if ((action.on_failure ?? 'log') === 'retry') { + await new Promise(r => setTimeout(r, 1000)); + try { + if (action.channel === 'webhook' || action.channel === 'openclaw') { + await sendWebhook(action.to, body); + } else { + appendToFile(action.to, body); + } + error = undefined; + } catch (err2) { + error = err2 instanceof Error ? err2.message : String(err2); + } + } + } + + const latencyMs = Date.now() - start; + const ok = error === undefined; + + // `parameter` carries the raw `action.to` even when the path was rejected + // as relative — keeping the unsanitised value is intentional for forensics + // (operators need to see what bad input slipped past lint). + writeAudit({ + t: new Date().toISOString(), + kind: 'rule-notify', + deviceId: ctx.deviceId ?? '', + command: `notify:${action.channel}`, + parameter: action.to, + commandType: 'command', + dryRun: false, + result: ok ? 'ok' : 'error', + error, + notifyChannel: action.channel, + notifyLatencyMs: latencyMs, + rule: { + name: ctx.rule.name, + triggerSource: ctx.rule.when.source, + fireId: ctx.fireId, + }, + }); + + return { ok, channel: action.channel, latencyMs, dryRun: false, error }; +} diff --git a/src/rules/suggest.ts b/src/rules/suggest.ts index 12b340a..2879384 100644 --- a/src/rules/suggest.ts +++ b/src/rules/suggest.ts @@ -1,7 +1,9 @@ -import { stringify as yamlStringify } from 'yaml'; +import { stringify as yamlStringify, parse as yamlParse, parseDocument, LineCounter, type Document } from 'yaml'; import { containsCjk, inferCommandFromIntent } from '../lib/command-keywords.js'; import { UsageError } from '../utils/output.js'; +import { writeAudit } from '../utils/audit.js'; import type { Rule, MqttTrigger, CronTrigger, WebhookTrigger, Action } from './types.js'; +import type { LLMBackend } from '../llm/index.js'; export interface SuggestRuleOptions { intent: string; @@ -11,6 +13,8 @@ export interface SuggestRuleOptions { schedule?: string; days?: string[]; webhookPath?: string; + llm?: LLMBackend; + aliases?: Record; } export interface SuggestRuleResult { @@ -76,7 +80,187 @@ function inferCommand(intent: string, warnings: string[]): string { return 'turnOn'; } -export function suggestRule(opts: SuggestRuleOptions): SuggestRuleResult { +export async function suggestRule(opts: SuggestRuleOptions): Promise { + // LLM path + if (opts.llm && opts.llm !== 'auto') { + return suggestRuleWithLlm(opts, opts.llm as Exclude); + } + if (opts.llm === 'auto') { + const { scoreIntentComplexity, LLM_AUTO_THRESHOLD } = await import('../llm/index.js'); + if (scoreIntentComplexity(opts.intent) >= LLM_AUTO_THRESHOLD) { + try { + return await suggestRuleWithLlm(opts, detectLlmBackend()); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const heuristic = suggestRuleHeuristic(opts); + heuristic.warnings.unshift(`LLM backend failed (${msg}), fell back to heuristic`); + return heuristic; + } + } + const heuristic = suggestRuleHeuristic(opts); + heuristic.warnings.unshift( + 'intent complexity below LLM threshold; used heuristic. Pass --llm openai|anthropic to force LLM.', + ); + return heuristic; + } + return suggestRuleHeuristic(opts); +} + +function detectLlmBackend(): Exclude { + if (process.env.ANTHROPIC_API_KEY) return 'anthropic'; + if (process.env.OPENAI_API_KEY) return 'openai'; + if (process.env.LLM_API_KEY) return 'openai'; + throw new UsageError('No LLM API key found. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or LLM_API_KEY to use --llm auto.'); +} + +async function suggestRuleWithLlm(opts: SuggestRuleOptions, backend: Exclude): Promise { + const { createLLMProvider } = await import('../llm/index.js'); + const { buildRuleSuggestSystemPrompt } = await import('../llm/rule-prompt.js'); + const { lintRules } = await import('./engine.js'); + const { validateLoadedPolicy } = await import('../policy/validate.js'); + + const provider = createLLMProvider(backend); + const systemPrompt = buildRuleSuggestSystemPrompt(opts.devices ?? [], opts.aliases ?? {}); + + let userMessage = opts.intent; + if (opts.trigger) userMessage += `\nConstraint — trigger type: ${opts.trigger}`; + if (opts.event) userMessage += `\nConstraint — trigger event: ${opts.event}`; + if (opts.schedule) userMessage += `\nConstraint — cron schedule: ${opts.schedule}`; + if (opts.days?.length) userMessage += `\nConstraint — days filter: ${opts.days.join(', ')}`; + if (opts.webhookPath) userMessage += `\nConstraint — webhook path: ${opts.webhookPath}`; + + const start = Date.now(); + let auditError: string | undefined; + let result: SuggestRuleResult | undefined; + let outcomeError: unknown; + + try { + const rawYaml = await provider.generateYaml(systemPrompt, userMessage); + + if (rawYaml.trimStart().startsWith('# ERROR:')) { + throw new UsageError(`LLM could not generate rule: ${rawYaml.replace(/^#\s*ERROR:\s*/i, '').trim()}`); + } + + const parsed = yamlParse(rawYaml); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('when' in parsed) || + !('then' in parsed) + ) { + throw new UsageError('LLM returned unexpected output structure (expected a rule object with when/then fields)'); + } + const ruleObj = parsed as Rule; + const warnings: string[] = []; + + // Enforce explicit caller constraints — fail on conflicting trigger source, + // override mismatched fields within the same trigger. + if (opts.trigger && ruleObj.when?.source !== opts.trigger) { + throw new UsageError( + `LLM ignored --trigger ${opts.trigger}, returned ${ruleObj.when?.source ?? 'unknown'}`, + ); + } + if (opts.trigger === 'mqtt' && ruleObj.when.source === 'mqtt') { + if (opts.event && (ruleObj.when as MqttTrigger).event !== opts.event) { + warnings.push( + `LLM returned event "${(ruleObj.when as MqttTrigger).event}" but --event "${opts.event}" was specified; overriding to caller's value.`, + ); + (ruleObj.when as MqttTrigger).event = opts.event; + } + } + if (opts.trigger === 'cron' && ruleObj.when.source === 'cron') { + const cronWhen = ruleObj.when as CronTrigger; + if (opts.schedule && cronWhen.schedule !== opts.schedule) { + warnings.push( + `LLM returned schedule "${cronWhen.schedule}" but --schedule "${opts.schedule}" was specified; overriding to caller's value.`, + ); + cronWhen.schedule = opts.schedule; + } + if (opts.days && opts.days.length > 0) { + const llmDays = (cronWhen.days ?? []).slice().sort(); + const wantedDays = opts.days.slice().sort(); + const sameDays = llmDays.length === wantedDays.length && llmDays.every((d, i) => d === wantedDays[i]); + if (!sameDays) { + warnings.push( + `LLM returned days "${llmDays.join(',') || '(none)'}" but --days "${opts.days.join(',')}" was specified; overriding to caller's value.`, + ); + cronWhen.days = opts.days as never; + } + } + } + if (opts.trigger === 'webhook' && ruleObj.when.source === 'webhook') { + const webhookWhen = ruleObj.when as WebhookTrigger; + if (opts.webhookPath && webhookWhen.path !== opts.webhookPath) { + warnings.push( + `LLM returned webhook path "${webhookWhen.path}" but --webhook-path "${opts.webhookPath}" was specified; overriding to caller's value.`, + ); + webhookWhen.path = opts.webhookPath; + } + } + + // Force dry_run:true regardless of LLM output — never auto-arm a generated rule. + if (ruleObj.dry_run !== true) { + warnings.push( + 'LLM proposed dry_run:false or omitted; forced to dry_run:true for safety. Review before arming.', + ); + } + const rule: Rule = { ...ruleObj, dry_run: true }; + + // Schema validation — wrap the single rule into a minimal v0.2 policy + // and run it through the same Ajv validator as `policy validate`. + // This catches structural issues (wrong types, missing required fields, + // unknown enum values) that lintRules cannot easily detect. + const wrapped = { version: '0.2', automation: { enabled: true, rules: [rule] } }; + const wrappedYaml = yamlStringify(wrapped, { lineWidth: 0 }); + const lc = new LineCounter(); + const probeDoc = parseDocument(wrappedYaml, { lineCounter: lc, keepSourceTokens: true }) as Document.Parsed; + const schemaValidation = validateLoadedPolicy({ + path: '', + source: wrappedYaml, + doc: probeDoc, + lineCounter: lc, + data: probeDoc.toJS({ maxAliasCount: 100 }), + }); + if (!schemaValidation.valid) { + const firstErr = schemaValidation.errors[0]?.message ?? 'unknown schema error'; + throw new UsageError(`LLM-generated rule failed policy schema: ${firstErr}`); + } + + const lintResult = lintRules({ enabled: true, rules: [rule] }); + if (!lintResult.valid) { + const errors = lintResult.rules[0]?.issues.filter(i => i.severity === 'error').map(i => i.message).join('; '); + throw new UsageError(`LLM-generated rule failed lint: ${errors}`); + } + + const ruleYaml = yamlStringify(rule, { lineWidth: 0 }); + result = { rule, ruleYaml, warnings }; + } catch (e) { + outcomeError = e; + auditError = e instanceof Error ? e.message : String(e); + } + + const latencyMs = Date.now() - start; + writeAudit({ + t: new Date().toISOString(), + kind: 'llm-suggest', + deviceId: '', + command: `llm-suggest:${backend}`, + parameter: opts.intent.slice(0, 200), + commandType: 'command', + dryRun: false, + result: outcomeError ? 'error' : 'ok', + error: auditError, + llmBackend: backend, + llmModel: provider.model, + llmLatencyMs: latencyMs, + }); + + if (outcomeError) throw outcomeError; + return result!; +} + +function suggestRuleHeuristic(opts: SuggestRuleOptions): SuggestRuleResult { const warnings: string[] = []; const cjkIntent = containsCjk(opts.intent); diff --git a/src/rules/types.ts b/src/rules/types.ts index 087f5b8..ded8d6d 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -71,13 +71,33 @@ export interface NotCondition { export type Condition = TimeBetweenCondition | DeviceStateCondition | AllCondition | AnyCondition | NotCondition; -export interface Action { +export interface CommandAction { + type?: 'command'; command: string; device?: string; args?: Record | null; on_error?: 'continue' | 'stop'; } +export interface NotifyAction { + type: 'notify'; + channel: 'webhook' | 'openclaw' | 'file'; + to: string; + template?: string; + on_failure?: 'log' | 'retry' | 'ignore'; + on_error?: 'continue' | 'stop'; +} + +export type Action = CommandAction | NotifyAction; + +export function isCommandAction(a: Action): a is CommandAction { + return (a as NotifyAction).type !== 'notify'; +} + +export function isNotifyAction(a: Action): a is NotifyAction { + return (a as NotifyAction).type === 'notify'; +} + export interface Throttle { max_per: string; /** Deduplicate identical events arriving within this window after the last fire. */ diff --git a/src/utils/audit.ts b/src/utils/audit.ts index 251c9f8..aebf9a8 100644 --- a/src/utils/audit.ts +++ b/src/utils/audit.ts @@ -23,7 +23,9 @@ export type AuditEntryKind = | 'rule-fire' | 'rule-fire-dry' | 'rule-throttled' - | 'rule-webhook-rejected'; + | 'rule-webhook-rejected' + | 'rule-notify' + | 'llm-suggest'; export interface AuditRuleContext { /** Rule.name from policy.yaml. */ @@ -59,6 +61,16 @@ export interface AuditEntry { planId?: string; /** Present for rule-engine kinds; absent for direct CLI command entries. */ rule?: AuditRuleContext; + /** Present on rule-notify entries. */ + notifyChannel?: string; + /** Round-trip delivery latency in milliseconds, present on rule-notify entries. */ + notifyLatencyMs?: number; + /** LLM backend used for suggestion (e.g. "openai", "anthropic"). */ + llmBackend?: string; + /** Model name returned by the LLM provider. */ + llmModel?: string; + /** Round-trip LLM latency in milliseconds. */ + llmLatencyMs?: number; } function resolveAuditPath(): string | null { diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index cb7e5db..3aa9cda 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -735,4 +735,52 @@ describe('doctor command', () => { expect(note.status).toBe('ok'); expect(String(note.detail.message)).toMatch(/no known breaking-change notice/i); }); + + it('notify-connectivity check appears in --list output', async () => { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--list']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const names = payload.data.checks.map((c: { name: string }) => c.name); + expect(names).toContain('notify-connectivity'); + }); + + it('notify-connectivity check is ok with webhookCount:0 when policy has no notify actions', async () => { + const policyDir = path.join(tmp, '.config', 'openclaw', 'switchbot'); + const policyPath = path.join(policyDir, 'policy.yaml'); + fs.mkdirSync(policyDir, { recursive: true }); + fs.writeFileSync(policyPath, [ + 'version: "0.2"', + 'automation:', + ' enabled: false', + ' rules: []', + ].join('\n') + '\n'); + process.env.SWITCHBOT_POLICY_PATH = policyPath; + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + try { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'notify-connectivity']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const check = payload.data.checks.find((c: { name: string }) => c.name === 'notify-connectivity'); + expect(check).toBeDefined(); + expect(check.status).toBe('ok'); + expect(check.detail.webhookCount).toBe(0); + } finally { + delete process.env.SWITCHBOT_POLICY_PATH; + } + }); + + it('notify-connectivity check is ok with present:false when no policy file exists', async () => { + process.env.SWITCHBOT_POLICY_PATH = path.join(tmp, 'nonexistent-policy.yaml'); + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + try { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'notify-connectivity']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const check = payload.data.checks.find((c: { name: string }) => c.name === 'notify-connectivity'); + expect(check).toBeDefined(); + expect(check.status).toBe('ok'); + expect(check.detail.present).toBe(false); + } finally { + delete process.env.SWITCHBOT_POLICY_PATH; + } + }); }); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 33faf6c..630a611 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -158,7 +158,7 @@ describe('mcp server', () => { delete process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE; }); - it('exposes the twenty-one tools with titles and input schemas', async () => { + it('exposes the twenty-two tools with titles and input schemas', async () => { const { client } = await pair(); const { tools } = await client.listTools(); @@ -182,6 +182,7 @@ describe('mcp server', () => { 'policy_new', 'policy_validate', 'query_device_history', + 'rule_notifications', 'rules_suggest', 'run_scene', 'search_catalog', @@ -864,6 +865,55 @@ describe('mcp server', () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('rule_notifications returns only rule-notify entries', async () => { + const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'sbmcp-rn-')); + const auditPath = path.join(tmp2, 'audit.log'); + const lines = [ + JSON.stringify({ auditVersion: 2, t: '2026-04-24T01:00:00.000Z', kind: 'rule-notify', deviceId: '', command: 'notify:webhook', parameter: 'https://example.com', commandType: 'command', dryRun: false, result: 'ok', notifyChannel: 'webhook', rule: { name: 'rule-a', triggerSource: 'cron', fireId: 'f1' } }), + JSON.stringify({ auditVersion: 2, t: '2026-04-24T01:01:00.000Z', kind: 'rule-fire', deviceId: 'BOT1', command: 'turnOn', parameter: 'default', commandType: 'command', dryRun: false, result: 'ok' }), + JSON.stringify({ auditVersion: 2, t: '2026-04-24T01:02:00.000Z', kind: 'rule-notify', deviceId: '', command: 'notify:file', parameter: '/tmp/out', commandType: 'command', dryRun: false, result: 'error', error: 'ENOENT', notifyChannel: 'file', rule: { name: 'rule-b', triggerSource: 'mqtt', fireId: 'f2' } }), + ]; + fs.writeFileSync(auditPath, lines.join('\n') + '\n', 'utf-8'); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'rule_notifications', + arguments: { file: auditPath }, + }); + + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: Record }).structuredContent!; + const entries = sc.entries as Array<{ kind: string }>; + expect(sc.total).toBe(2); + expect(entries.every(e => e.kind === 'rule-notify')).toBe(true); + + fs.rmSync(tmp2, { recursive: true, force: true }); + }); + + it('rule_notifications filters by rule name', async () => { + const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'sbmcp-rn2-')); + const auditPath = path.join(tmp2, 'audit.log'); + const lines = [ + JSON.stringify({ auditVersion: 2, t: '2026-04-24T01:00:00.000Z', kind: 'rule-notify', deviceId: '', command: 'notify:file', parameter: '/tmp/out', commandType: 'command', dryRun: false, result: 'ok', notifyChannel: 'file', rule: { name: 'rule-a', triggerSource: 'cron', fireId: 'f1' } }), + JSON.stringify({ auditVersion: 2, t: '2026-04-24T01:01:00.000Z', kind: 'rule-notify', deviceId: '', command: 'notify:file', parameter: '/tmp/out', commandType: 'command', dryRun: false, result: 'ok', notifyChannel: 'file', rule: { name: 'rule-b', triggerSource: 'mqtt', fireId: 'f2' } }), + ]; + fs.writeFileSync(auditPath, lines.join('\n') + '\n', 'utf-8'); + + const { client } = await pair(); + const res = await client.callTool({ + name: 'rule_notifications', + arguments: { file: auditPath, rule: 'rule-a' }, + }); + + expect(res.isError).toBeFalsy(); + const sc = (res as { structuredContent?: Record }).structuredContent!; + expect(sc.total).toBe(1); + const entries = sc.entries as Array<{ rule?: { name: string } }>; + expect(entries[0].rule?.name).toBe('rule-a'); + + fs.rmSync(tmp2, { recursive: true, force: true }); + }); }); // ---- policy_validate / policy_new / policy_migrate / policy_diff --------- diff --git a/tests/commands/rules.test.ts b/tests/commands/rules.test.ts index e612e5c..9017e1c 100644 --- a/tests/commands/rules.test.ts +++ b/tests/commands/rules.test.ts @@ -896,4 +896,14 @@ describe('rules suggest', () => { fs.rmSync(outDir, { recursive: true, force: true }); } }); + + it('rejects --llm with an unknown backend value', async () => { + const { stderr, exitCode } = await runCli([ + 'rules', 'suggest', + '--intent', 'turn on light when motion detected', + '--llm', 'garbage', + ]); + expect(exitCode).toBe(2); + expect(stderr.join('\n')).toMatch(/--llm must be one of: auto, openai, anthropic/); + }); }); diff --git a/tests/llm/complexity.test.ts b/tests/llm/complexity.test.ts new file mode 100644 index 0000000..f4a7be0 --- /dev/null +++ b/tests/llm/complexity.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { scoreIntentComplexity } from '../../src/llm/complexity.js'; + +describe('scoreIntentComplexity', () => { + it('scores simple single-action intents low (< 4)', () => { + expect(scoreIntentComplexity('turn off lights at 10pm')).toBeLessThan(4); + expect(scoreIntentComplexity('open curtains every morning')).toBeLessThan(4); + expect(scoreIntentComplexity('lock the door at midnight')).toBeLessThan(4); + }); + + it('scores multi-condition intents at 4 or higher', () => { + expect(scoreIntentComplexity('if temperature > 28 and humidity > 70 turn on AC')).toBeGreaterThanOrEqual(4); + }); + + it('scores time-window intents at 4 or higher', () => { + expect(scoreIntentComplexity('on weekdays between 9am and 6pm keep lights on')).toBeGreaterThanOrEqual(4); + }); + + it('scores complex multi-clause intents at 7 or higher', () => { + const complex = 'on weekdays if someone opens the door and room temp is below 20 turn on AC but skip if AC is already on'; + expect(scoreIntentComplexity(complex)).toBeGreaterThanOrEqual(7); + }); + + it('returns a number in range 0-10', () => { + const score = scoreIntentComplexity('any intent string here'); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(10); + }); + + it('cross-sensor intent scores high', () => { + expect(scoreIntentComplexity('if temperature is high and humidity is also high run the fan')).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/tests/llm/provider.test.ts b/tests/llm/provider.test.ts new file mode 100644 index 0000000..0145a68 --- /dev/null +++ b/tests/llm/provider.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { createLLMProvider, LLM_AUTO_THRESHOLD } from '../../src/llm/index.js'; + +describe('createLLMProvider', () => { + it('throws when backend=openai and no API key set', () => { + const saved = process.env.OPENAI_API_KEY; + const saved2 = process.env.LLM_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.LLM_API_KEY; + try { + expect(() => createLLMProvider('openai')).toThrow(/OPENAI_API_KEY/); + } finally { + if (saved !== undefined) process.env.OPENAI_API_KEY = saved; + if (saved2 !== undefined) process.env.LLM_API_KEY = saved2; + } + }); + + it('throws when backend=anthropic and no API key set', () => { + const saved = process.env.ANTHROPIC_API_KEY; + const saved2 = process.env.LLM_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.LLM_API_KEY; + try { + expect(() => createLLMProvider('anthropic')).toThrow(/ANTHROPIC_API_KEY/); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + if (saved2 !== undefined) process.env.LLM_API_KEY = saved2; + } + }); + + it('creates an OpenAI provider when OPENAI_API_KEY is set', () => { + const saved = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = 'test-key'; + try { + const p = createLLMProvider('openai'); + expect(p.name).toBe('openai'); + expect(p.model).toBeDefined(); + } finally { + if (saved !== undefined) process.env.OPENAI_API_KEY = saved; + else delete process.env.OPENAI_API_KEY; + } + }); + + it('creates an Anthropic provider when ANTHROPIC_API_KEY is set', () => { + const saved = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'test-key'; + try { + const p = createLLMProvider('anthropic'); + expect(p.name).toBe('anthropic'); + expect(p.model).toBeDefined(); + } finally { + if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved; + else delete process.env.ANTHROPIC_API_KEY; + } + }); + + it('LLM_AUTO_THRESHOLD is 4', () => { + expect(LLM_AUTO_THRESHOLD).toBe(4); + }); +}); diff --git a/tests/llm/rule-prompt.test.ts b/tests/llm/rule-prompt.test.ts new file mode 100644 index 0000000..0a8d4cd --- /dev/null +++ b/tests/llm/rule-prompt.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { buildRuleSuggestSystemPrompt } from '../../src/llm/rule-prompt.js'; + +function extractSchemaSnippet(prompt: string): Record { + // The schema snippet is a JSON object embedded between the + // "conforming to this JSON Schema:\n" header and the "Rules:" line. + const start = prompt.indexOf('JSON Schema:\n'); + expect(start).toBeGreaterThan(-1); + const after = prompt.slice(start + 'JSON Schema:\n'.length); + const rulesIdx = after.indexOf('\n\nRules:'); + expect(rulesIdx).toBeGreaterThan(-1); + const json = after.slice(0, rulesIdx); + return JSON.parse(json) as Record; +} + +describe('buildRuleSuggestSystemPrompt — schema snippet completeness', () => { + const prompt = buildRuleSuggestSystemPrompt([], {}); + + it('includes the trigger oneOf wrapper and all three trigger variants', () => { + const snippet = extractSchemaSnippet(prompt); + expect(snippet).toHaveProperty('rule'); + expect(snippet).toHaveProperty('trigger'); + expect(snippet).toHaveProperty('triggerMqtt'); + expect(snippet).toHaveProperty('triggerCron'); + expect(snippet).toHaveProperty('triggerWebhook'); + }); + + it('rule.when $ref points to a def that exists in the snippet (no dangling reference)', () => { + const snippet = extractSchemaSnippet(prompt); + const rule = snippet.rule as { properties?: { when?: { $ref?: string } } }; + const ref = rule.properties?.when?.$ref; + expect(ref).toBe('#/$defs/trigger'); + // The $ref targets `#/$defs/trigger`; the snippet keys should include it. + expect(snippet).toHaveProperty('trigger'); + }); + + it('triggerWebhook def carries the path constraint for the model to obey', () => { + const snippet = extractSchemaSnippet(prompt); + const tw = snippet.triggerWebhook as { + properties?: { path?: { pattern?: string } }; + required?: string[]; + }; + expect(tw.required).toContain('source'); + expect(tw.required).toContain('path'); + expect(tw.properties?.path?.pattern).toBe('^/[a-z0-9/_-]+$'); + }); + + it('rules section instructs the model on webhook trigger structure', () => { + expect(prompt).toMatch(/source:\s*webhook/); + expect(prompt).toMatch(/path:/); + }); +}); diff --git a/tests/llm/suggest.test.ts b/tests/llm/suggest.test.ts new file mode 100644 index 0000000..ba5e2c3 --- /dev/null +++ b/tests/llm/suggest.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const METER_ID = '28372F4C9C4A'; +const AC_ID = '11AABB223344'; +const LIGHT_ID = '55EEFF667788'; + +const VALID_RULE_YAML = `name: ac-on-when-hot +when: + source: mqtt + event: meter.temperature_changed + device: ${METER_ID} +then: + - command: devices command ${AC_ID} turnOn +dry_run: true`; + +vi.mock('../../src/llm/providers/openai.js', () => ({ + OpenAIProvider: vi.fn(), +})); + +vi.mock('../../src/llm/providers/anthropic.js', () => ({ + AnthropicProvider: vi.fn(), +})); + +import { suggestRule } from '../../src/rules/suggest.js'; + +describe('suggestRule — LLM backend', () => { + beforeEach(async () => { + process.env.OPENAI_API_KEY = 'test-key'; + process.env.ANTHROPIC_API_KEY = 'test-key'; + + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + const { AnthropicProvider } = await import('../../src/llm/providers/anthropic.js'); + (OpenAIProvider as ReturnType).mockImplementation(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.resolve(VALID_RULE_YAML), + })); + (AnthropicProvider as ReturnType).mockImplementation(() => ({ + name: 'anthropic', + model: 'claude-haiku-4-5-20251001', + generateYaml: () => Promise.resolve(VALID_RULE_YAML), + })); + }); + + afterEach(() => { + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + }); + + it('returns LLM-generated rule when llm=openai', async () => { + const result = await suggestRule({ + intent: 'when temperature above 28 and humidity over 70 turn on AC', + devices: [{ id: METER_ID, name: 'Meter', type: 'MeterPro' }, { id: AC_ID, name: 'AC' }], + llm: 'openai', + }); + expect(result.rule.name).toBe('ac-on-when-hot'); + expect(result.warnings).toHaveLength(0); + }); + + it('auto mode routes complex intent to LLM', async () => { + // Force openai backend by unsetting anthropic key + const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + let result; + try { + result = await suggestRule({ + intent: 'on weekdays if someone opens the door and room temp is below 20 turn on AC but skip if already on', + devices: [{ id: AC_ID }], + llm: 'auto', + }); + } finally { + if (savedAnthropicKey !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; + } + expect(result!.rule.name).toBe('ac-on-when-hot'); + }); + + it('auto mode uses heuristic for simple intents', async () => { + const result = await suggestRule({ + intent: 'turn off lights at 10pm', + devices: [{ id: 'LIGHT_001' }], + llm: 'auto', + }); + // heuristic path — no LLM called, rule uses the intent as name + expect(result.rule.when.source).toBe('cron'); + expect(result.rule.name).toBe('turn off lights at 10pm'); + expect(result.warnings.some(w => w.includes('intent complexity below LLM threshold'))).toBe(true); + }); + + it('auto mode falls back to heuristic when LLM throws', async () => { + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.reject(new Error('connection refused')), + })); + + // Unset ANTHROPIC_API_KEY so detectLlmBackend picks openai (where we have the reject mock) + const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + + let result; + try { + result = await suggestRule({ + intent: 'on weekdays between 9am and 6pm if someone opens the door and temp is below 20 turn on AC but skip if already on', + devices: [{ id: 'AC_001' }], + llm: 'auto', + }); + } finally { + if (savedAnthropicKey !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; + } + + expect(result!.warnings.some(w => w.includes('fell back to heuristic'))).toBe(true); + }); + + it('forces dry_run:true when LLM proposes dry_run:false', async () => { + const RULE_NO_DRY_RUN = `name: forced-dry-run +when: + source: mqtt + event: meter.temperature_changed +then: + - command: devices command ${AC_ID} turnOn +dry_run: false`; + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.resolve(RULE_NO_DRY_RUN), + })); + + const result = await suggestRule({ + intent: 'force dry run', + devices: [{ id: AC_ID }], + llm: 'openai', + }); + + expect(result.rule.dry_run).toBe(true); + expect(result.warnings.some(w => w.includes('forced to dry_run:true'))).toBe(true); + }); + + it('throws when LLM ignores --trigger constraint', async () => { + // Default mock returns mqtt rule; ask for cron + await expect( + suggestRule({ + intent: 'turn on at 8am', + devices: [{ id: LIGHT_ID }], + trigger: 'cron', + schedule: '0 8 * * *', + llm: 'openai', + }), + ).rejects.toThrow(/ignored --trigger cron/); + }); + + it('overrides LLM cron schedule with caller --schedule and emits warning', async () => { + const RULE_WRONG_SCHEDULE = `name: wrong-schedule +when: + source: cron + schedule: '0 8 * * *' +then: + - command: devices command ${LIGHT_ID} turnOff +dry_run: true`; + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.resolve(RULE_WRONG_SCHEDULE), + })); + + const result = await suggestRule({ + intent: 'turn off at 10pm', + devices: [{ id: LIGHT_ID }], + trigger: 'cron', + schedule: '0 22 * * *', + llm: 'openai', + }); + + expect(result.rule.when.source).toBe('cron'); + expect((result.rule.when as { schedule: string }).schedule).toBe('0 22 * * *'); + expect(result.warnings.some(w => w.includes('--schedule "0 22 * * *"') && w.includes('overriding'))).toBe(true); + }); + + it('overrides LLM mqtt event with caller --event and emits warning', async () => { + const RULE_WRONG_EVENT = `name: wrong-event +when: + source: mqtt + event: device.shadow +then: + - command: devices command ${LIGHT_ID} turnOn +dry_run: true`; + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.resolve(RULE_WRONG_EVENT), + })); + + const result = await suggestRule({ + intent: 'on motion turn on light', + devices: [{ id: LIGHT_ID }], + trigger: 'mqtt', + event: 'motion.detected', + llm: 'openai', + }); + + expect((result.rule.when as { event: string }).event).toBe('motion.detected'); + expect(result.warnings.some(w => w.includes('--event "motion.detected"'))).toBe(true); + }); + + it('rejects LLM rule that violates the policy schema', async () => { + const RULE_INVALID_SCHEMA = `name: missing-action-fields +when: + source: mqtt + event: motion.detected +then: + - { not_a_command: true } +dry_run: true`; + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.resolve(RULE_INVALID_SCHEMA), + })); + + await expect( + suggestRule({ + intent: 'bad rule', + devices: [{ id: 'AC_001' }], + llm: 'openai', + }), + ).rejects.toThrow(/policy schema|failed lint/i); + }); + + it('records audit with result=error and the error message when LLM provider rejects', async () => { + const auditMod = await import('../../src/utils/audit.js'); + const auditSpy = vi.spyOn(auditMod, 'writeAudit').mockImplementation(() => {}); + + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + (OpenAIProvider as ReturnType).mockImplementationOnce(() => ({ + name: 'openai', + model: 'gpt-4o-mini', + generateYaml: () => Promise.reject(new Error('boom from provider')), + })); + + try { + await expect( + suggestRule({ + intent: 'audit error path', + devices: [{ id: 'AC_001' }], + llm: 'openai', + }), + ).rejects.toThrow(/boom from provider/); + + const errorEntries = auditSpy.mock.calls + .map(c => c[0] as Record) + .filter(e => e.kind === 'llm-suggest'); + expect(errorEntries).toHaveLength(1); + expect(errorEntries[0].result).toBe('error'); + expect(errorEntries[0].error).toContain('boom from provider'); + } finally { + auditSpy.mockRestore(); + } + }); +}); diff --git a/tests/policy/validate.test.ts b/tests/policy/validate.test.ts index 67977cb..89d505a 100644 --- a/tests/policy/validate.test.ts +++ b/tests/policy/validate.test.ts @@ -810,3 +810,155 @@ describe('policy validator (v0.2)', () => { }, ); }); + +describe('policy validator — notify action (v0.2 schema extension)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-policy-notify-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('accepts a rule with a valid notify action (webhook channel)', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "alert on motion"', + ' when:', + ' source: cron', + ' schedule: "0 8 * * *"', + ' then:', + ' - type: notify', + ' channel: webhook', + ' to: "https://example.com/hook"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts a rule with a valid notify action (file channel)', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "log motion"', + ' when:', + ' source: mqtt', + ' event: motion.detected', + ' then:', + ' - type: notify', + ' channel: file', + ' to: "/tmp/sb-events.jsonl"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts a rule mixing command and notify actions', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "turn on then notify"', + ' when:', + ' source: cron', + ' schedule: "0 8 * * *"', + ' then:', + ' - command: "devices command 01-202407090924-26354212 turnOn"', + ' - type: notify', + ' channel: webhook', + ' to: "https://example.com/hook"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects a notify action with an unknown channel', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "bad channel"', + ' when:', + ' source: cron', + ' schedule: "0 8 * * *"', + ' then:', + ' - type: notify', + ' channel: fax', + ' to: "https://example.com"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + }); + + it('rejects a notify action with missing required "to" field', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "missing to"', + ' when:', + ' source: cron', + ' schedule: "0 8 * * *"', + ' then:', + ' - type: notify', + ' channel: webhook', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + }); + + it('existing command actions still validate without type field', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' enabled: true', + ' rules:', + ' - name: "legacy command action"', + ' when:', + ' source: cron', + ' schedule: "0 8 * * *"', + ' then:', + ' - command: "devices command 01-202407090924-26354212 turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/tests/rules/engine.test.ts b/tests/rules/engine.test.ts index 939df08..e0ba40d 100644 --- a/tests/rules/engine.test.ts +++ b/tests/rules/engine.test.ts @@ -970,3 +970,152 @@ describe('RulesEngine.reload', () => { }); }); }); + +// ─── M3: notify action support ─────────────────────────────────────────────── + +describe('lintRules — notify actions', () => { + it('accepts a rule with a valid file notify action', () => { + const r = lintRules(automation([ + { name: 'n1', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'file', to: '/tmp/out.jsonl' }] }, + ])); + expect(r.valid).toBe(true); + expect(r.rules[0].status).toBe('ok'); + }); + + it('accepts a rule with a valid webhook notify action', () => { + const r = lintRules(automation([ + { name: 'n2', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'webhook', to: 'https://example.com/hook' }] }, + ])); + expect(r.valid).toBe(true); + }); + + it('errors on notify action missing to field (code: notify-missing-to)', () => { + const r = lintRules(automation([ + { name: 'n3', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'webhook' } as unknown as import('../../src/rules/types.js').Action] }, + ])); + expect(r.valid).toBe(false); + expect(r.rules[0].issues.find(i => i.code === 'notify-missing-to')).toBeDefined(); + }); + + it('errors on notify webhook action with invalid URL (code: notify-invalid-url)', () => { + const r = lintRules(automation([ + { name: 'n4', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'webhook', to: 'not-a-url' }] }, + ])); + expect(r.valid).toBe(false); + expect(r.rules[0].issues.find(i => i.code === 'notify-invalid-url')).toBeDefined(); + }); + + it('errors on notify webhook action with non-http(s) URL (code: notify-unsupported-protocol)', () => { + const r = lintRules(automation([ + { name: 'n5', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'webhook', to: 'ftp://example.com/path' }] }, + ])); + expect(r.valid).toBe(false); + const issue = r.rules[0].issues.find(i => i.code === 'notify-unsupported-protocol'); + expect(issue).toBeDefined(); + expect(issue?.message).toContain('ftp:'); + }); + + it('accepts an http:// notify webhook URL', () => { + const r = lintRules(automation([ + { name: 'n6', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'webhook', to: 'http://example.com/hook' }] }, + ])); + expect(r.valid).toBe(true); + }); + + it('errors on notify file action with relative path (code: notify-relative-path)', () => { + const r = lintRules(automation([ + { name: 'n7', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'file', to: 'logs/x.jsonl' }] }, + ])); + expect(r.valid).toBe(false); + const issue = r.rules[0].issues.find(i => i.code === 'notify-relative-path'); + expect(issue).toBeDefined(); + expect(issue?.message).toContain('logs/x.jsonl'); + expect(issue?.message).toMatch(/must be absolute/); + }); + + it('errors on notify file action with leading-dot relative path', () => { + const r = lintRules(automation([ + { name: 'n8', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'file', to: './out.jsonl' }] }, + ])); + expect(r.valid).toBe(false); + expect(r.rules[0].issues.find(i => i.code === 'notify-relative-path')).toBeDefined(); + }); + + it('errors on notify file action with tilde-prefixed path (~ is not expanded)', () => { + const r = lintRules(automation([ + { name: 'n9', when: { source: 'mqtt', event: 'motion.detected' }, then: [{ type: 'notify', channel: 'file', to: '~/notify.jsonl' }] }, + ])); + expect(r.valid).toBe(false); + expect(r.rules[0].issues.find(i => i.code === 'notify-relative-path')).toBeDefined(); + }); +}); + +describe('RulesEngine — notify action dispatch', () => { + const originalArgv = process.argv; + let tmp: string; + let auditFile: string; + let mqtt: FakeMqttClient; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbengine-notify-')); + auditFile = path.join(tmp, 'audit.log'); + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', auditFile]; + mqtt = new FakeMqttClient(); + }); + afterEach(() => { + process.argv = originalArgv; + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('dispatches a file notify action and writes JSONL to target', async () => { + const targetFile = path.join(tmp, 'events.jsonl'); + const fires: EngineFireEntry[] = []; + const engine = new RulesEngine({ + automation: automation([{ + name: 'notify-file-rule', + when: { source: 'mqtt', event: 'motion.detected' }, + then: [{ type: 'notify', channel: 'file', to: targetFile }], + }]), + aliases: {}, + mqttClient: mqtt as unknown as SwitchBotMqttClient, + mqttCredential: fakeCredential, + skipApiCall: true, + onFire: (e) => fires.push(e), + }); + await engine.start(); + + mqtt.emitMessage({ context: { deviceMac: 'AABBCCDDEEFF', detectionState: 'DETECTED' } }); + await engine.drainForTest(); + + expect(fires.at(-1)?.status).toBe('fired'); + expect(fs.existsSync(targetFile)).toBe(true); + const line = JSON.parse(fs.readFileSync(targetFile, 'utf-8').trim()); + expect(line.rule).toBe('notify-file-rule'); + + await engine.stop(); + }); + + it('writes rule-notify audit entry when notify action fires', async () => { + const targetFile = path.join(tmp, 'events.jsonl'); + const engine = new RulesEngine({ + automation: automation([{ + name: 'notify-audit-rule', + when: { source: 'mqtt', event: 'motion.detected' }, + then: [{ type: 'notify', channel: 'file', to: targetFile }], + }]), + aliases: {}, + mqttClient: mqtt as unknown as SwitchBotMqttClient, + mqttCredential: fakeCredential, + skipApiCall: true, + }); + await engine.start(); + + mqtt.emitMessage({ context: { deviceMac: 'AABBCCDDEEFF', detectionState: 'DETECTED' } }); + await engine.drainForTest(); + + const entries = readAudit(auditFile); + expect(entries.find(e => e.kind === 'rule-notify')).toBeDefined(); + + await engine.stop(); + }); +}); diff --git a/tests/rules/notify.test.ts b/tests/rules/notify.test.ts new file mode 100644 index 0000000..4726a2d --- /dev/null +++ b/tests/rules/notify.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { executeNotifyAction, renderNotifyTemplate } from '../../src/rules/notify.js'; +import type { NotifyAction } from '../../src/rules/types.js'; +import { readAudit } from '../../src/utils/audit.js'; +import type { Rule } from '../../src/rules/types.js'; + +const baseRule: Rule = { + name: 'motion-alert', + when: { source: 'mqtt', event: 'motion.detected' }, + then: [], +}; + +function fileAction(to: string, extra: Partial = {}): NotifyAction { + return { type: 'notify', channel: 'file', to, ...extra }; +} + +function webhookAction(to: string, extra: Partial = {}): NotifyAction { + return { type: 'notify', channel: 'webhook', to, ...extra }; +} + +describe('renderNotifyTemplate', () => { + it('substitutes {{ rule.name }}', () => { + const result = renderNotifyTemplate('Rule {{ rule.name }} fired', { 'rule.name': 'ac-on-when-hot' }); + expect(result).toBe('Rule ac-on-when-hot fired'); + }); + + it('substitutes multiple variables in one string', () => { + const result = renderNotifyTemplate('{{ rule.name }} on {{ device.id }}', { + 'rule.name': 'my-rule', + 'device.id': 'DEV_001', + }); + expect(result).toBe('my-rule on DEV_001'); + }); + + it('leaves unknown placeholders unchanged', () => { + const result = renderNotifyTemplate('{{ unknown.var }} stays', {}); + expect(result).toBe('{{ unknown.var }} stays'); + }); + + it('handles template with no placeholders', () => { + const result = renderNotifyTemplate('plain text', { 'rule.name': 'x' }); + expect(result).toBe('plain text'); + }); + + it('substitutes nested dot-path keys (event.context.deviceMac)', () => { + const result = renderNotifyTemplate('{{ event.context.deviceMac }}@{{ event.list.0 }}', { + 'event.context.deviceMac': 'AA:BB:CC', + 'event.list.0': 'first', + }); + expect(result).toBe('AA:BB:CC@first'); + }); +}); + +describe('executeNotifyAction — file channel', () => { + let tmpDir: string; + let auditLog: string; + const originalArgv = process.argv; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-notify-m2-')); + auditLog = path.join(tmpDir, 'audit.log'); + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', auditLog]; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + process.argv = originalArgv; + }); + + it('appends a JSON line to the target file', async () => { + const target = path.join(tmpDir, 'events.jsonl'); + const result = await executeNotifyAction(fileAction(target), { + rule: baseRule, + fireId: 'fire-001', + eventPayload: { temperature: 29 }, + deviceId: 'METER_001', + }); + + expect(result.ok).toBe(true); + expect(result.channel).toBe('file'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + + const lines = fs.readFileSync(target, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.rule).toBe('motion-alert'); + expect(parsed.fireId).toBe('fire-001'); + }); + + it('creates parent directory if it does not exist', async () => { + const target = path.join(tmpDir, 'subdir', 'events.jsonl'); + const result = await executeNotifyAction(fileAction(target), { + rule: baseRule, + fireId: 'fire-002', + eventPayload: {}, + deviceId: 'DEV_001', + }); + + expect(result.ok).toBe(true); + expect(fs.existsSync(target)).toBe(true); + }); + + it('appends multiple lines on repeated calls', async () => { + const target = path.join(tmpDir, 'events.jsonl'); + await executeNotifyAction(fileAction(target), { rule: baseRule, fireId: 'f1', eventPayload: {}, deviceId: 'd' }); + await executeNotifyAction(fileAction(target), { rule: baseRule, fireId: 'f2', eventPayload: {}, deviceId: 'd' }); + + const lines = fs.readFileSync(target, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + }); + + it('renders custom template when provided', async () => { + const target = path.join(tmpDir, 'events.jsonl'); + await executeNotifyAction( + fileAction(target, { template: '{{ rule.name }}:{{ device.id }}' }), + { rule: baseRule, fireId: 'f3', eventPayload: {}, deviceId: 'DEV_ABC' }, + ); + + const line = fs.readFileSync(target, 'utf-8').trim(); + expect(line).toBe('motion-alert:DEV_ABC'); + }); + + it('renders nested event.* paths and array indexes from payload', async () => { + const target = path.join(tmpDir, 'events.jsonl'); + await executeNotifyAction( + fileAction(target, { template: '{{ event.context.deviceMac }}-{{ event.list.0 }}-{{ event.context.detectionState }}' }), + { + rule: baseRule, + fireId: 'f-nested', + eventPayload: { + context: { deviceMac: 'AA:BB:CC:DD:EE:FF', detectionState: 'DETECTED' }, + list: ['alpha', 'beta'], + }, + deviceId: 'DEV_NEST', + }, + ); + + const line = fs.readFileSync(target, 'utf-8').trim(); + expect(line).toBe('AA:BB:CC:DD:EE:FF-alpha-DETECTED'); + }); + + it('writes a rule-notify audit entry on success', async () => { + const target = path.join(tmpDir, 'events.jsonl'); + await executeNotifyAction(fileAction(target), { + rule: baseRule, + fireId: 'fire-audit', + eventPayload: {}, + deviceId: 'DEV_001', + }); + + const entries = readAudit(auditLog); + const entry = entries.find(e => e.kind === 'rule-notify'); + expect(entry).toBeDefined(); + expect(entry!.result).toBe('ok'); + expect(entry!.notifyChannel).toBe('file'); + expect(entry!.notifyLatencyMs).toBeGreaterThanOrEqual(0); + expect(entry!.rule?.name).toBe('motion-alert'); + expect(entry!.rule?.fireId).toBe('fire-audit'); + }); + + it('returns ok=false and writes error audit entry when path is unwritable', async () => { + // Create a file where the parent directory should be — forces mkdirSync/appendFileSync to fail + const fakeParent = path.join(tmpDir, 'not-a-dir'); + fs.writeFileSync(fakeParent, 'this is a file, not a dir'); + const unwritable = path.join(fakeParent, 'events.jsonl'); + + const result = await executeNotifyAction(fileAction(unwritable), { + rule: baseRule, + fireId: 'fire-err', + eventPayload: {}, + deviceId: 'DEV_001', + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + + const entries = readAudit(auditLog); + const entry = entries.find(e => e.kind === 'rule-notify'); + expect(entry).toBeDefined(); + expect(entry!.result).toBe('error'); + }); + + it('rejects a relative path at runtime with a clear error', async () => { + const result = await executeNotifyAction(fileAction('logs/relative.jsonl'), { + rule: baseRule, + fireId: 'fire-rel', + eventPayload: {}, + deviceId: 'DEV_001', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/must be absolute/); + + const entries = readAudit(auditLog); + const entry = entries.find(e => e.kind === 'rule-notify' && e.rule?.fireId === 'fire-rel'); + expect(entry).toBeDefined(); + expect(entry!.result).toBe('error'); + }); +}); + +describe('executeNotifyAction — webhook channel', () => { + let tmpDir: string; + const originalArgv = process.argv; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-notify-wh-')); + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', path.join(tmpDir, 'audit.log')]; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + process.argv = originalArgv; + }); + + it('returns ok=false with connection refused error', async () => { + const result = await executeNotifyAction( + webhookAction('http://127.0.0.1:19991/hook'), + { rule: baseRule, fireId: 'wh-1', eventPayload: {}, deviceId: 'DEV_001' }, + ); + + expect(result.ok).toBe(false); + expect(result.channel).toBe('webhook'); + expect(result.error).toBeDefined(); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('returns ok=false with error when URL is invalid', async () => { + const result = await executeNotifyAction( + webhookAction('not-a-url'), + { rule: baseRule, fireId: 'wh-2', eventPayload: {}, deviceId: 'DEV_001' }, + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/tests/rules/suggest.test.ts b/tests/rules/suggest.test.ts index f08dff2..d54d12c 100644 --- a/tests/rules/suggest.test.ts +++ b/tests/rules/suggest.test.ts @@ -3,72 +3,72 @@ import { suggestRule } from '../../src/rules/suggest.js'; describe('suggestRule', () => { describe('trigger inference', () => { - it('infers mqtt motion.detected from "motion" in intent', () => { - const { rule, warnings } = suggestRule({ intent: 'when motion detected, turn on light' }); + it('infers mqtt motion.detected from "motion" in intent', async () => { + const { rule, warnings } = await suggestRule({ intent: 'when motion detected, turn on light' }); expect(rule.when.source).toBe('mqtt'); if (rule.when.source === 'mqtt') expect(rule.when.event).toBe('motion.detected'); expect(warnings).toHaveLength(0); }); - it('infers mqtt contact.opened from "door" in intent', () => { - const { rule } = suggestRule({ intent: 'when door opens, turn on porch light' }); + it('infers mqtt contact.opened from "door" in intent', async () => { + const { rule } = await suggestRule({ intent: 'when door opens, turn on porch light' }); expect(rule.when.source).toBe('mqtt'); if (rule.when.source === 'mqtt') expect(rule.when.event).toBe('contact.opened'); }); - it('infers mqtt button.pressed from "button" in intent', () => { - const { rule } = suggestRule({ intent: 'when button pressed, turn on lamp' }); + it('infers mqtt button.pressed from "button" in intent', async () => { + const { rule } = await suggestRule({ intent: 'when button pressed, turn on lamp' }); expect(rule.when.source).toBe('mqtt'); if (rule.when.source === 'mqtt') expect(rule.when.event).toBe('button.pressed'); }); - it('infers cron from "every morning"', () => { - const { rule } = suggestRule({ intent: 'every morning turn on coffee maker' }); + it('infers cron from "every morning"', async () => { + const { rule } = await suggestRule({ intent: 'every morning turn on coffee maker' }); expect(rule.when.source).toBe('cron'); }); - it('infers webhook from "webhook" keyword', () => { - const { rule } = suggestRule({ intent: 'on webhook call, toggle switch' }); + it('infers webhook from "webhook" keyword', async () => { + const { rule } = await suggestRule({ intent: 'on webhook call, toggle switch' }); expect(rule.when.source).toBe('webhook'); }); - it('defaults to mqtt with warning when intent is unrecognized', () => { - const { rule, warnings } = suggestRule({ intent: 'do something weird' }); + it('defaults to mqtt with warning when intent is unrecognized', async () => { + const { rule, warnings } = await suggestRule({ intent: 'do something weird' }); expect(rule.when.source).toBe('mqtt'); expect(warnings.length).toBeGreaterThan(0); expect(warnings[0]).toContain('defaulted to mqtt/device.shadow'); }); - it('respects explicit --trigger override over inference', () => { - const { rule } = suggestRule({ intent: 'motion detected', trigger: 'cron' }); + it('respects explicit --trigger override over inference', async () => { + const { rule } = await suggestRule({ intent: 'motion detected', trigger: 'cron' }); expect(rule.when.source).toBe('cron'); }); }); describe('schedule inference (cron trigger)', () => { - it('parses "8am" → "0 8 * * *"', () => { - const { rule } = suggestRule({ intent: 'every day at 8am', trigger: 'cron' }); + it('parses "8am" → "0 8 * * *"', async () => { + const { rule } = await suggestRule({ intent: 'every day at 8am', trigger: 'cron' }); if (rule.when.source === 'cron') expect(rule.when.schedule).toBe('0 8 * * *'); }); - it('parses "10pm" → "0 22 * * *"', () => { - const { rule } = suggestRule({ intent: 'turn off at 10pm', trigger: 'cron' }); + it('parses "10pm" → "0 22 * * *"', async () => { + const { rule } = await suggestRule({ intent: 'turn off at 10pm', trigger: 'cron' }); if (rule.when.source === 'cron') expect(rule.when.schedule).toBe('0 22 * * *'); }); - it('parses "every hour" → "0 * * * *"', () => { - const { rule } = suggestRule({ intent: 'every hour check lights', trigger: 'cron' }); + it('parses "every hour" → "0 * * * *"', async () => { + const { rule } = await suggestRule({ intent: 'every hour check lights', trigger: 'cron' }); if (rule.when.source === 'cron') expect(rule.when.schedule).toBe('0 * * * *'); }); - it('defaults to "0 8 * * *" with warning for unrecognized schedule intent', () => { - const { rule, warnings } = suggestRule({ intent: 'on a schedule', trigger: 'cron' }); + it('defaults to "0 8 * * *" with warning for unrecognized schedule intent', async () => { + const { rule, warnings } = await suggestRule({ intent: 'on a schedule', trigger: 'cron' }); if (rule.when.source === 'cron') expect(rule.when.schedule).toBe('0 8 * * *'); expect(warnings.some((w) => w.includes('defaulted'))).toBe(true); }); - it('uses --schedule override when provided', () => { - const { rule } = suggestRule({ + it('uses --schedule override when provided', async () => { + const { rule } = await suggestRule({ intent: 'run every night', trigger: 'cron', schedule: '0 23 * * *', @@ -76,8 +76,8 @@ describe('suggestRule', () => { if (rule.when.source === 'cron') expect(rule.when.schedule).toBe('0 23 * * *'); }); - it('applies days filter when provided', () => { - const { rule } = suggestRule({ + it('applies days filter when provided', async () => { + const { rule } = await suggestRule({ intent: 'weekdays at 9am', trigger: 'cron', days: ['mon', 'tue', 'wed', 'thu', 'fri'], @@ -98,36 +98,36 @@ describe('suggestRule', () => { ['open the curtains', 'open'], ['close the blinds', 'close'], ['pause the device', 'pause'], - ])('"%s" → command "%s"', (intent, expected) => { - const { rule } = suggestRule({ intent }); + ])('"%s" → command "%s"', async (intent, expected) => { + const { rule } = await suggestRule({ intent }); expect(rule.then[0].command).toContain(expected); }); - it('defaults to turnOn with warning for unrecognized command intent', () => { - const { rule, warnings } = suggestRule({ intent: 'do a thing with device', trigger: 'mqtt', event: 'motion.detected' }); + it('defaults to turnOn with warning for unrecognized command intent', async () => { + const { rule, warnings } = await suggestRule({ intent: 'do a thing with device', trigger: 'mqtt', event: 'motion.detected' }); expect(rule.then[0].command).toContain('turnOn'); expect(warnings.some((w) => w.includes('turnOn'))).toBe(true); }); }); describe('defaults and structure', () => { - it('always sets dry_run: true', () => { - const { rule } = suggestRule({ intent: 'turn on light' }); + it('always sets dry_run: true', async () => { + const { rule } = await suggestRule({ intent: 'turn on light' }); expect(rule.dry_run).toBe(true); }); - it('sets throttle for mqtt triggers', () => { - const { rule } = suggestRule({ intent: 'motion detected', trigger: 'mqtt', event: 'motion.detected' }); + it('sets throttle for mqtt triggers', async () => { + const { rule } = await suggestRule({ intent: 'motion detected', trigger: 'mqtt', event: 'motion.detected' }); expect(rule.throttle?.max_per).toBe('10m'); }); - it('does not set throttle for cron triggers', () => { - const { rule } = suggestRule({ intent: 'every morning', trigger: 'cron' }); + it('does not set throttle for cron triggers', async () => { + const { rule } = await suggestRule({ intent: 'every morning', trigger: 'cron' }); expect(rule.throttle).toBeUndefined(); }); - it('uses first device as sensor (mqtt) and remaining as action targets', () => { - const { rule } = suggestRule({ + it('uses first device as sensor (mqtt) and remaining as action targets', async () => { + const { rule } = await suggestRule({ intent: 'motion turns on lamp', trigger: 'mqtt', event: 'motion.detected', @@ -142,8 +142,8 @@ describe('suggestRule', () => { expect(rule.then[0].command).toBe('devices command lamp-1 turnOn'); }); - it('emits action device IDs inline in the command instead of a separate device field', () => { - const { ruleYaml } = suggestRule({ + it('emits action device IDs inline in the command instead of a separate device field', async () => { + const { ruleYaml } = await suggestRule({ intent: 'motion turns on lamp', trigger: 'mqtt', event: 'motion.detected', @@ -157,8 +157,8 @@ describe('suggestRule', () => { expect(ruleYaml).not.toContain('device: lamp-1'); }); - it('uses all devices as action targets for cron trigger', () => { - const { rule } = suggestRule({ + it('uses all devices as action targets for cron trigger', async () => { + const { rule } = await suggestRule({ intent: 'turn off at night', trigger: 'cron', devices: [{ id: 'l1', name: 'light 1' }, { id: 'l2', name: 'light 2' }], @@ -166,8 +166,8 @@ describe('suggestRule', () => { expect(rule.then).toHaveLength(2); }); - it('ruleYaml is a valid YAML string containing key fields', () => { - const { ruleYaml } = suggestRule({ intent: 'turn on light', trigger: 'cron', schedule: '0 8 * * *' }); + it('ruleYaml is a valid YAML string containing key fields', async () => { + const { ruleYaml } = await suggestRule({ intent: 'turn on light', trigger: 'cron', schedule: '0 8 * * *' }); expect(typeof ruleYaml).toBe('string'); expect(ruleYaml).toContain('dry_run: true'); expect(ruleYaml).toContain('source: cron'); diff --git a/tests/rules/types.test.ts b/tests/rules/types.test.ts new file mode 100644 index 0000000..f9289a8 --- /dev/null +++ b/tests/rules/types.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isCommandAction, isNotifyAction } from '../../src/rules/types.js'; +import type { Action } from '../../src/rules/types.js'; + +describe('isCommandAction / isNotifyAction type guards', () => { + it('identifies a command action (no type field)', () => { + const action: Action = { command: 'devices command dev-1 turnOn' }; + expect(isCommandAction(action)).toBe(true); + expect(isNotifyAction(action)).toBe(false); + }); + + it('identifies a command action (explicit type: command)', () => { + const action: Action = { type: 'command', command: 'devices command dev-1 turnOn' }; + expect(isCommandAction(action)).toBe(true); + expect(isNotifyAction(action)).toBe(false); + }); + + it('identifies a notify action', () => { + const action: Action = { type: 'notify', channel: 'webhook', to: 'https://example.com' }; + expect(isNotifyAction(action)).toBe(true); + expect(isCommandAction(action)).toBe(false); + }); + + it('identifies a notify action with file channel', () => { + const action: Action = { type: 'notify', channel: 'file', to: '/tmp/events.jsonl' }; + expect(isNotifyAction(action)).toBe(true); + }); +});