@joshualelon/clawdbot-skill-flow 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -18
- package/clawdbot.plugin.json +43 -1
- package/index.ts +12 -1
- package/package.json +3 -4
- package/src/commands/flow-step.ts +41 -12
- package/src/config.ts +45 -0
- package/src/engine/executor.ts +50 -9
- package/src/engine/hooks-loader.ts +125 -0
- package/src/engine/renderer.ts +29 -7
- package/src/engine/transitions.ts +21 -8
- package/src/examples/pushups-hooks.example.js +127 -0
- package/src/examples/sheets-storage.example.js +126 -0
- package/src/security/path-validation.ts +53 -0
- package/src/state/history-store.ts +35 -2
- package/src/state/session-store.ts +22 -4
- package/src/types.ts +77 -4
- package/src/validation.ts +10 -0
- package/types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -15,6 +15,15 @@ Build deterministic, button-driven conversation flows without AI inference overh
|
|
|
15
15
|
- **History Tracking** - JSONL append-only log for completed flows
|
|
16
16
|
- **Cron Integration** - Schedule flows to run automatically via Clawdbot's cron system
|
|
17
17
|
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- **Clawdbot**: v2026.1.25 or later (requires Telegram `sendPayload` support)
|
|
21
|
+
- PR: https://github.com/clawdbot/clawdbot/pull/1917
|
|
22
|
+
- **Important**: Telegram inline keyboard buttons will not work with older Clawdbot versions
|
|
23
|
+
- Text-based fallback will work on all versions
|
|
24
|
+
- **Node.js**: 22+ (same as Clawdbot)
|
|
25
|
+
- **Channels**: Currently optimized for Telegram (other channels use text-based menus)
|
|
26
|
+
|
|
18
27
|
## Quick Start
|
|
19
28
|
|
|
20
29
|
### 1. Install Plugin
|
|
@@ -196,6 +205,123 @@ Override default `next` on a per-button basis:
|
|
|
196
205
|
}
|
|
197
206
|
```
|
|
198
207
|
|
|
208
|
+
### Hooks System
|
|
209
|
+
|
|
210
|
+
Customize flow behavior at key points without forking the plugin:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"name": "pushups",
|
|
215
|
+
"description": "4-set pushup workout",
|
|
216
|
+
"hooks": "./hooks.js",
|
|
217
|
+
"steps": [...]
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Available Hooks:**
|
|
222
|
+
- `onStepRender(step, session)` - Modify step before rendering (e.g., dynamic buttons)
|
|
223
|
+
- `onCapture(variable, value, session)` - Called after variable capture (e.g., log to Google Sheets)
|
|
224
|
+
- `onFlowComplete(session)` - Called when flow completes (e.g., schedule next session)
|
|
225
|
+
- `onFlowAbandoned(session, reason)` - Called on timeout/cancellation (e.g., track completion rates)
|
|
226
|
+
|
|
227
|
+
**Example hooks file:**
|
|
228
|
+
|
|
229
|
+
```javascript
|
|
230
|
+
export default {
|
|
231
|
+
async onStepRender(step, session) {
|
|
232
|
+
// Generate dynamic buttons based on past performance
|
|
233
|
+
if (step.id === 'set1') {
|
|
234
|
+
const average = await calculateAverage(session.senderId);
|
|
235
|
+
return {
|
|
236
|
+
...step,
|
|
237
|
+
buttons: [average - 5, average, average + 5, average + 10],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return step;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async onCapture(variable, value, session) {
|
|
244
|
+
// Log to Google Sheets in real-time
|
|
245
|
+
await sheets.append({
|
|
246
|
+
spreadsheetId: 'YOUR_SHEET_ID',
|
|
247
|
+
values: [[new Date(), session.senderId, variable, value]],
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async onFlowComplete(session) {
|
|
252
|
+
// Schedule next workout
|
|
253
|
+
const nextDate = calculateNextWorkout();
|
|
254
|
+
await cron.create({
|
|
255
|
+
schedule: nextDate,
|
|
256
|
+
message: '/flow-start pushups',
|
|
257
|
+
userId: session.senderId,
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
See `src/examples/pushups-hooks.example.js` for a complete reference.
|
|
264
|
+
|
|
265
|
+
### Custom Storage Backends
|
|
266
|
+
|
|
267
|
+
Replace or supplement the built-in JSONL storage:
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"name": "pushups",
|
|
272
|
+
"storage": {
|
|
273
|
+
"backend": "./storage.js",
|
|
274
|
+
"builtin": false
|
|
275
|
+
},
|
|
276
|
+
"steps": [...]
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**StorageBackend Interface:**
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
export default {
|
|
284
|
+
async saveSession(session) {
|
|
285
|
+
// Write to Google Sheets, database, etc.
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
async loadHistory(flowName, options) {
|
|
289
|
+
// Return historical sessions for analytics
|
|
290
|
+
return [];
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Set `"builtin": false` to disable JSONL storage and only use the custom backend. Omit it (or set to `true`) to use both.
|
|
296
|
+
|
|
297
|
+
See `src/examples/sheets-storage.example.js` for a complete reference.
|
|
298
|
+
|
|
299
|
+
## Security
|
|
300
|
+
|
|
301
|
+
### Hooks & Storage Backend Safety
|
|
302
|
+
|
|
303
|
+
The plugin validates that all dynamically loaded files (hooks, storage backends) remain within the `~/.clawdbot/flows/` directory. This prevents directory traversal attacks.
|
|
304
|
+
|
|
305
|
+
**Valid hook paths:**
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"name": "myflow",
|
|
309
|
+
"hooks": "./hooks.js", // ✅ Relative to flow directory
|
|
310
|
+
"hooks": "hooks/custom.js" // ✅ Subdirectory within flow
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Invalid hook paths (will be rejected):**
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"hooks": "/etc/passwd", // ❌ Absolute path outside flows
|
|
318
|
+
"hooks": "../../../etc/passwd", // ❌ Directory traversal
|
|
319
|
+
"hooks": "~/malicious.js" // ❌ Tilde expansion outside flows
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The plugin uses path validation similar to Clawdbot's core security patterns to ensure hooks and storage backends can only access files within their designated flow directory.
|
|
324
|
+
|
|
199
325
|
## How It Works
|
|
200
326
|
|
|
201
327
|
### Telegram Button Callbacks
|
|
@@ -231,24 +357,6 @@ This enables deterministic, instant responses for structured workflows.
|
|
|
231
357
|
├── metadata.json
|
|
232
358
|
└── history.jsonl
|
|
233
359
|
```
|
|
234
|
-
|
|
235
|
-
## Roadmap
|
|
236
|
-
|
|
237
|
-
### v0.2.0
|
|
238
|
-
- LLM-driven steps (e.g., "Ask user for feedback, then summarize")
|
|
239
|
-
- External service integrations (Google Sheets, webhooks)
|
|
240
|
-
- Flow analytics dashboard (completion rates, drop-off points)
|
|
241
|
-
|
|
242
|
-
### v0.3.0
|
|
243
|
-
- Visual flow builder (web UI)
|
|
244
|
-
- Loops and repeats
|
|
245
|
-
- Sub-flows (call another flow as a step)
|
|
246
|
-
|
|
247
|
-
### v1.0.0
|
|
248
|
-
- Production-ready comprehensive tests
|
|
249
|
-
- Performance optimizations
|
|
250
|
-
- Migration guides
|
|
251
|
-
|
|
252
360
|
## Contributing
|
|
253
361
|
|
|
254
362
|
Issues and PRs welcome! This plugin follows Clawdbot's coding conventions.
|
package/clawdbot.plugin.json
CHANGED
|
@@ -3,6 +3,48 @@
|
|
|
3
3
|
"configSchema": {
|
|
4
4
|
"type": "object",
|
|
5
5
|
"additionalProperties": false,
|
|
6
|
-
"properties": {
|
|
6
|
+
"properties": {
|
|
7
|
+
"sessionTimeoutMinutes": {
|
|
8
|
+
"type": "number",
|
|
9
|
+
"minimum": 1,
|
|
10
|
+
"maximum": 1440,
|
|
11
|
+
"default": 30
|
|
12
|
+
},
|
|
13
|
+
"sessionCleanupIntervalMinutes": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"minimum": 1,
|
|
16
|
+
"maximum": 60,
|
|
17
|
+
"default": 5
|
|
18
|
+
},
|
|
19
|
+
"enableBuiltinHistory": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"default": true
|
|
22
|
+
},
|
|
23
|
+
"maxFlowsPerUser": {
|
|
24
|
+
"type": "number",
|
|
25
|
+
"minimum": 1,
|
|
26
|
+
"maximum": 1000
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"uiHints": {
|
|
31
|
+
"sessionTimeoutMinutes": {
|
|
32
|
+
"label": "Session Timeout (minutes)",
|
|
33
|
+
"help": "How long before inactive flow sessions expire (1-1440 minutes)"
|
|
34
|
+
},
|
|
35
|
+
"sessionCleanupIntervalMinutes": {
|
|
36
|
+
"label": "Cleanup Interval (minutes)",
|
|
37
|
+
"help": "How often to check for expired sessions (1-60 minutes)",
|
|
38
|
+
"advanced": true
|
|
39
|
+
},
|
|
40
|
+
"enableBuiltinHistory": {
|
|
41
|
+
"label": "Enable History Logging",
|
|
42
|
+
"help": "Save completed flows to .jsonl files (can be disabled if using custom storage backend)"
|
|
43
|
+
},
|
|
44
|
+
"maxFlowsPerUser": {
|
|
45
|
+
"label": "Max Flows Per User",
|
|
46
|
+
"help": "Limit concurrent flows per user (leave empty for unlimited)",
|
|
47
|
+
"advanced": true
|
|
48
|
+
}
|
|
7
49
|
}
|
|
8
50
|
}
|
package/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
6
|
+
import { parseSkillFlowConfig } from "./src/config.js";
|
|
7
|
+
import { initSessionStore } from "./src/state/session-store.js";
|
|
6
8
|
import { createFlowStartCommand } from "./src/commands/flow-start.js";
|
|
7
9
|
import { createFlowStepCommand } from "./src/commands/flow-step.js";
|
|
8
10
|
import { createFlowCreateCommand } from "./src/commands/flow-create.js";
|
|
@@ -17,6 +19,12 @@ const plugin = {
|
|
|
17
19
|
version: "0.1.0",
|
|
18
20
|
|
|
19
21
|
register(api: ClawdbotPluginApi) {
|
|
22
|
+
// Parse and validate config
|
|
23
|
+
const config = parseSkillFlowConfig(api.pluginConfig);
|
|
24
|
+
|
|
25
|
+
// Initialize session store with config
|
|
26
|
+
initSessionStore(config);
|
|
27
|
+
|
|
20
28
|
// Register commands
|
|
21
29
|
api.registerCommand({
|
|
22
30
|
name: "flow-start",
|
|
@@ -58,7 +66,10 @@ const plugin = {
|
|
|
58
66
|
handler: createFlowDeleteCommand(api),
|
|
59
67
|
});
|
|
60
68
|
|
|
61
|
-
api.logger.info("Skill Flow plugin registered successfully"
|
|
69
|
+
api.logger.info("Skill Flow plugin registered successfully", {
|
|
70
|
+
sessionTimeoutMinutes: config.sessionTimeoutMinutes,
|
|
71
|
+
enableBuiltinHistory: config.enableBuiltinHistory,
|
|
72
|
+
});
|
|
62
73
|
},
|
|
63
74
|
};
|
|
64
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joshualelon/clawdbot-skill-flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-step workflow orchestration plugin for Clawdbot",
|
|
6
6
|
"keywords": [
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "https://github.com/joshualelon/clawdbot-skill-flow.git"
|
|
19
|
+
"url": "git+https://github.com/joshualelon/clawdbot-skill-flow.git"
|
|
20
20
|
},
|
|
21
21
|
"homepage": "https://github.com/joshualelon/clawdbot-skill-flow#readme",
|
|
22
22
|
"bugs": {
|
|
@@ -69,8 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
"lint-staged": {
|
|
71
71
|
"*.ts": [
|
|
72
|
-
"oxlint --fix"
|
|
73
|
-
"tsc --noEmit"
|
|
72
|
+
"oxlint --fix"
|
|
74
73
|
]
|
|
75
74
|
}
|
|
76
75
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
6
|
+
import { parseSkillFlowConfig } from "../config.js";
|
|
6
7
|
import { loadFlow } from "../state/flow-store.js";
|
|
7
8
|
import {
|
|
8
9
|
getSession,
|
|
@@ -12,6 +13,11 @@ import {
|
|
|
12
13
|
} from "../state/session-store.js";
|
|
13
14
|
import { saveFlowHistory } from "../state/history-store.js";
|
|
14
15
|
import { processStep } from "../engine/executor.js";
|
|
16
|
+
import {
|
|
17
|
+
loadHooks,
|
|
18
|
+
resolveFlowPath,
|
|
19
|
+
safeExecuteHook,
|
|
20
|
+
} from "../engine/hooks-loader.js";
|
|
15
21
|
|
|
16
22
|
export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
17
23
|
return async (args: {
|
|
@@ -48,23 +54,45 @@ export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
|
48
54
|
const stepId = stepData.substring(0, colonIndex);
|
|
49
55
|
const valueStr = stepData.substring(colonIndex + 1);
|
|
50
56
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
const session = getSession(sessionKey);
|
|
57
|
+
// Load flow first
|
|
58
|
+
const flow = await loadFlow(api, flowName);
|
|
54
59
|
|
|
55
|
-
if (!
|
|
60
|
+
if (!flow) {
|
|
56
61
|
return {
|
|
57
|
-
text: `
|
|
62
|
+
text: `Flow "${flowName}" not found.`,
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
//
|
|
62
|
-
const
|
|
66
|
+
// Get active session
|
|
67
|
+
const sessionKey = getSessionKey(args.senderId, flowName);
|
|
68
|
+
const session = getSession(sessionKey);
|
|
69
|
+
|
|
70
|
+
if (!session) {
|
|
71
|
+
// Load hooks and call onFlowAbandoned
|
|
72
|
+
if (flow.hooks) {
|
|
73
|
+
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
74
|
+
const hooks = await loadHooks(api, hooksPath);
|
|
75
|
+
if (hooks?.onFlowAbandoned) {
|
|
76
|
+
await safeExecuteHook(
|
|
77
|
+
api,
|
|
78
|
+
"onFlowAbandoned",
|
|
79
|
+
hooks.onFlowAbandoned,
|
|
80
|
+
{
|
|
81
|
+
flowName,
|
|
82
|
+
currentStepId: stepId,
|
|
83
|
+
senderId: args.senderId,
|
|
84
|
+
channel: args.channel,
|
|
85
|
+
variables: {},
|
|
86
|
+
startedAt: 0,
|
|
87
|
+
lastActivityAt: 0,
|
|
88
|
+
},
|
|
89
|
+
"timeout"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
|
|
64
|
-
if (!flow) {
|
|
65
|
-
deleteSession(sessionKey);
|
|
66
94
|
return {
|
|
67
|
-
text: `
|
|
95
|
+
text: `Session expired or not found.\n\nUse /flow-start ${flowName} to restart the flow.`,
|
|
68
96
|
};
|
|
69
97
|
}
|
|
70
98
|
|
|
@@ -72,7 +100,7 @@ export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
|
72
100
|
const value = /^\d+$/.test(valueStr) ? Number(valueStr) : valueStr;
|
|
73
101
|
|
|
74
102
|
// Process step transition
|
|
75
|
-
const result = processStep(api, flow, session, stepId, value);
|
|
103
|
+
const result = await processStep(api, flow, session, stepId, value);
|
|
76
104
|
|
|
77
105
|
// Update session or cleanup
|
|
78
106
|
if (result.complete) {
|
|
@@ -81,7 +109,8 @@ export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
|
81
109
|
...session,
|
|
82
110
|
variables: result.updatedVariables,
|
|
83
111
|
};
|
|
84
|
-
|
|
112
|
+
const config = parseSkillFlowConfig(api.pluginConfig);
|
|
113
|
+
await saveFlowHistory(api, finalSession, flow, config);
|
|
85
114
|
|
|
86
115
|
// Cleanup session
|
|
87
116
|
deleteSession(sessionKey);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const SkillFlowConfigSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
sessionTimeoutMinutes: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(1440)
|
|
10
|
+
.default(30)
|
|
11
|
+
.describe("Session timeout in minutes (default: 30)"),
|
|
12
|
+
|
|
13
|
+
sessionCleanupIntervalMinutes: z
|
|
14
|
+
.number()
|
|
15
|
+
.int()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(60)
|
|
18
|
+
.default(5)
|
|
19
|
+
.describe("How often to clean up expired sessions (default: 5)"),
|
|
20
|
+
|
|
21
|
+
enableBuiltinHistory: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.default(true)
|
|
24
|
+
.describe("Save completed flows to JSONL history files (default: true)"),
|
|
25
|
+
|
|
26
|
+
maxFlowsPerUser: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(1000)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Max concurrent flows per user (optional limit)"),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
|
|
36
|
+
export type SkillFlowConfig = z.infer<typeof SkillFlowConfigSchema>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse and validate plugin config with defaults
|
|
40
|
+
*/
|
|
41
|
+
export function parseSkillFlowConfig(
|
|
42
|
+
raw: unknown
|
|
43
|
+
): SkillFlowConfig {
|
|
44
|
+
return SkillFlowConfigSchema.parse(raw ?? {});
|
|
45
|
+
}
|
package/src/engine/executor.ts
CHANGED
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
FlowMetadata,
|
|
8
|
+
FlowSession,
|
|
9
|
+
ReplyPayload,
|
|
10
|
+
FlowHooks,
|
|
11
|
+
} from "../types.js";
|
|
7
12
|
import { renderStep } from "./renderer.js";
|
|
8
13
|
import { executeTransition } from "./transitions.js";
|
|
14
|
+
import { loadHooks, resolveFlowPath, safeExecuteHook } from "./hooks-loader.js";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Start a flow from the beginning
|
|
12
18
|
*/
|
|
13
|
-
export function startFlow(
|
|
19
|
+
export async function startFlow(
|
|
14
20
|
api: ClawdbotPluginApi,
|
|
15
21
|
flow: FlowMetadata,
|
|
16
22
|
session: FlowSession
|
|
17
|
-
): ReplyPayload {
|
|
23
|
+
): Promise<ReplyPayload> {
|
|
18
24
|
const firstStep = flow.steps[0];
|
|
19
25
|
|
|
20
26
|
if (!firstStep) {
|
|
@@ -23,24 +29,45 @@ export function startFlow(
|
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
// Load hooks if configured
|
|
33
|
+
let hooks: FlowHooks | null = null;
|
|
34
|
+
if (flow.hooks) {
|
|
35
|
+
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
36
|
+
hooks = await loadHooks(api, hooksPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return renderStep(api, flow, firstStep, session, session.channel, hooks);
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
/**
|
|
30
43
|
* Process a step transition and return next step or completion message
|
|
31
44
|
*/
|
|
32
|
-
export function processStep(
|
|
45
|
+
export async function processStep(
|
|
33
46
|
api: ClawdbotPluginApi,
|
|
34
47
|
flow: FlowMetadata,
|
|
35
48
|
session: FlowSession,
|
|
36
49
|
stepId: string,
|
|
37
50
|
value: string | number
|
|
38
|
-
): {
|
|
51
|
+
): Promise<{
|
|
39
52
|
reply: ReplyPayload;
|
|
40
53
|
complete: boolean;
|
|
41
54
|
updatedVariables: Record<string, string | number>;
|
|
42
|
-
} {
|
|
43
|
-
|
|
55
|
+
}> {
|
|
56
|
+
// Load hooks if configured
|
|
57
|
+
let hooks: FlowHooks | null = null;
|
|
58
|
+
if (flow.hooks) {
|
|
59
|
+
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
60
|
+
hooks = await loadHooks(api, hooksPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await executeTransition(
|
|
64
|
+
api,
|
|
65
|
+
flow,
|
|
66
|
+
session,
|
|
67
|
+
stepId,
|
|
68
|
+
value,
|
|
69
|
+
hooks
|
|
70
|
+
);
|
|
44
71
|
|
|
45
72
|
// Handle errors
|
|
46
73
|
if (result.error) {
|
|
@@ -53,6 +80,13 @@ export function processStep(
|
|
|
53
80
|
|
|
54
81
|
// Handle completion
|
|
55
82
|
if (result.complete) {
|
|
83
|
+
const updatedSession = { ...session, variables: result.variables };
|
|
84
|
+
|
|
85
|
+
// Call onFlowComplete hook
|
|
86
|
+
if (hooks?.onFlowComplete) {
|
|
87
|
+
await safeExecuteHook(api, "onFlowComplete", hooks.onFlowComplete, updatedSession);
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
const completionMessage = generateCompletionMessage(flow, result.variables);
|
|
57
91
|
return {
|
|
58
92
|
reply: { text: completionMessage },
|
|
@@ -80,7 +114,14 @@ export function processStep(
|
|
|
80
114
|
}
|
|
81
115
|
|
|
82
116
|
const updatedSession = { ...session, variables: result.variables };
|
|
83
|
-
const reply = renderStep(
|
|
117
|
+
const reply = await renderStep(
|
|
118
|
+
api,
|
|
119
|
+
flow,
|
|
120
|
+
nextStep,
|
|
121
|
+
updatedSession,
|
|
122
|
+
session.channel,
|
|
123
|
+
hooks
|
|
124
|
+
);
|
|
84
125
|
|
|
85
126
|
return {
|
|
86
127
|
reply,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks loader - Dynamically loads and executes flow hooks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FlowHooks, StorageBackend } from "../types.js";
|
|
6
|
+
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
resolvePathSafely,
|
|
11
|
+
validatePathWithinBase,
|
|
12
|
+
} from "../security/path-validation.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load hooks from a file path
|
|
16
|
+
* @param api - Plugin API for logging
|
|
17
|
+
* @param hooksPath - Absolute path to hooks file
|
|
18
|
+
* @returns FlowHooks object or null if loading fails
|
|
19
|
+
*/
|
|
20
|
+
export async function loadHooks(
|
|
21
|
+
api: ClawdbotPluginApi,
|
|
22
|
+
hooksPath: string
|
|
23
|
+
): Promise<FlowHooks | null> {
|
|
24
|
+
try {
|
|
25
|
+
// Validate path is within flows directory
|
|
26
|
+
const flowsDir = path.join(api.runtime.state.resolveStateDir(), "flows");
|
|
27
|
+
validatePathWithinBase(hooksPath, flowsDir, "hooks file");
|
|
28
|
+
|
|
29
|
+
// Check if file exists
|
|
30
|
+
await fs.access(hooksPath);
|
|
31
|
+
|
|
32
|
+
// Dynamically import the hooks module
|
|
33
|
+
const hooksModule = await import(hooksPath);
|
|
34
|
+
|
|
35
|
+
// Support both default export and named export
|
|
36
|
+
const hooks: FlowHooks = hooksModule.default ?? hooksModule;
|
|
37
|
+
|
|
38
|
+
api.logger.debug(`Loaded hooks from ${hooksPath}`);
|
|
39
|
+
return hooks;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
api.logger.warn(`Failed to load hooks from ${hooksPath}:`, error);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load storage backend from a file path
|
|
48
|
+
* @param api - Plugin API for logging
|
|
49
|
+
* @param backendPath - Absolute path to storage backend file
|
|
50
|
+
* @returns StorageBackend or null if loading fails
|
|
51
|
+
*/
|
|
52
|
+
export async function loadStorageBackend(
|
|
53
|
+
api: ClawdbotPluginApi,
|
|
54
|
+
backendPath: string
|
|
55
|
+
): Promise<StorageBackend | null> {
|
|
56
|
+
try {
|
|
57
|
+
// Validate path is within flows directory
|
|
58
|
+
const flowsDir = path.join(api.runtime.state.resolveStateDir(), "flows");
|
|
59
|
+
validatePathWithinBase(backendPath, flowsDir, "storage backend");
|
|
60
|
+
|
|
61
|
+
// Check if file exists
|
|
62
|
+
await fs.access(backendPath);
|
|
63
|
+
|
|
64
|
+
// Dynamically import the backend module
|
|
65
|
+
const backendModule = await import(backendPath);
|
|
66
|
+
|
|
67
|
+
// Support both default export and named export
|
|
68
|
+
const backend: StorageBackend = backendModule.default ?? backendModule;
|
|
69
|
+
|
|
70
|
+
api.logger.debug(`Loaded storage backend from ${backendPath}`);
|
|
71
|
+
return backend;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
api.logger.warn(`Failed to load storage backend from ${backendPath}:`, error);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve relative path to absolute (relative to flow directory)
|
|
80
|
+
* @param api - Plugin API
|
|
81
|
+
* @param flowName - Name of the flow
|
|
82
|
+
* @param relativePath - Relative path from flow config
|
|
83
|
+
* @returns Absolute path
|
|
84
|
+
*/
|
|
85
|
+
export function resolveFlowPath(
|
|
86
|
+
api: ClawdbotPluginApi,
|
|
87
|
+
flowName: string,
|
|
88
|
+
relativePath: string
|
|
89
|
+
): string {
|
|
90
|
+
const flowDir = path.join(
|
|
91
|
+
api.runtime.state.resolveStateDir(),
|
|
92
|
+
"flows",
|
|
93
|
+
flowName
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Validate that relativePath doesn't escape flowDir
|
|
97
|
+
return resolvePathSafely(flowDir, relativePath, "flow path");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Safe hook executor - wraps hook calls with error handling
|
|
102
|
+
* @param api - Plugin API for logging
|
|
103
|
+
* @param hookName - Name of the hook being called
|
|
104
|
+
* @param hookFn - The hook function to execute
|
|
105
|
+
* @param args - Arguments to pass to the hook
|
|
106
|
+
* @returns Result of hook or undefined if hook fails
|
|
107
|
+
*/
|
|
108
|
+
export async function safeExecuteHook<T, Args extends unknown[]>(
|
|
109
|
+
api: ClawdbotPluginApi,
|
|
110
|
+
hookName: string,
|
|
111
|
+
hookFn: ((...args: Args) => T | Promise<T>) | undefined,
|
|
112
|
+
...args: Args
|
|
113
|
+
): Promise<T | undefined> {
|
|
114
|
+
if (!hookFn) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const result = await hookFn(...args);
|
|
120
|
+
return result;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
api.logger.error(`Hook ${hookName} failed:`, error);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/engine/renderer.ts
CHANGED
|
@@ -8,8 +8,10 @@ import type {
|
|
|
8
8
|
FlowStep,
|
|
9
9
|
FlowSession,
|
|
10
10
|
ReplyPayload,
|
|
11
|
+
FlowHooks,
|
|
11
12
|
} from "../types.js";
|
|
12
13
|
import { normalizeButton } from "../validation.js";
|
|
14
|
+
import { safeExecuteHook } from "./hooks-loader.js";
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Interpolate variables in message text
|
|
@@ -69,7 +71,11 @@ function renderTelegram(
|
|
|
69
71
|
|
|
70
72
|
return {
|
|
71
73
|
text: message,
|
|
72
|
-
|
|
74
|
+
channelData: {
|
|
75
|
+
telegram: {
|
|
76
|
+
buttons: keyboard,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
73
79
|
};
|
|
74
80
|
}
|
|
75
81
|
|
|
@@ -102,18 +108,34 @@ function renderFallback(
|
|
|
102
108
|
/**
|
|
103
109
|
* Render a flow step
|
|
104
110
|
*/
|
|
105
|
-
export function renderStep(
|
|
106
|
-
|
|
111
|
+
export async function renderStep(
|
|
112
|
+
api: ClawdbotPluginApi,
|
|
107
113
|
flow: FlowMetadata,
|
|
108
114
|
step: FlowStep,
|
|
109
115
|
session: FlowSession,
|
|
110
|
-
channel: string
|
|
111
|
-
|
|
116
|
+
channel: string,
|
|
117
|
+
hooks?: FlowHooks | null
|
|
118
|
+
): Promise<ReplyPayload> {
|
|
119
|
+
// Call onStepRender hook if available
|
|
120
|
+
let finalStep = step;
|
|
121
|
+
if (hooks?.onStepRender) {
|
|
122
|
+
const modifiedStep = await safeExecuteHook(
|
|
123
|
+
api,
|
|
124
|
+
"onStepRender",
|
|
125
|
+
hooks.onStepRender,
|
|
126
|
+
step,
|
|
127
|
+
session
|
|
128
|
+
);
|
|
129
|
+
if (modifiedStep) {
|
|
130
|
+
finalStep = modifiedStep;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
112
134
|
// Channel-specific rendering
|
|
113
135
|
if (channel === "telegram") {
|
|
114
|
-
return renderTelegram(flow.name,
|
|
136
|
+
return renderTelegram(flow.name, finalStep, session.variables);
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
// Fallback for all other channels
|
|
118
|
-
return renderFallback(
|
|
140
|
+
return renderFallback(finalStep, session.variables);
|
|
119
141
|
}
|
|
@@ -8,8 +8,10 @@ import type {
|
|
|
8
8
|
FlowStep,
|
|
9
9
|
FlowSession,
|
|
10
10
|
TransitionResult,
|
|
11
|
+
FlowHooks,
|
|
11
12
|
} from "../types.js";
|
|
12
13
|
import { normalizeButton, validateInput } from "../validation.js";
|
|
14
|
+
import { safeExecuteHook } from "./hooks-loader.js";
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Evaluate condition against session variables
|
|
@@ -86,13 +88,14 @@ function findNextStep(
|
|
|
86
88
|
/**
|
|
87
89
|
* Execute step transition
|
|
88
90
|
*/
|
|
89
|
-
export function executeTransition(
|
|
90
|
-
|
|
91
|
+
export async function executeTransition(
|
|
92
|
+
api: ClawdbotPluginApi,
|
|
91
93
|
flow: FlowMetadata,
|
|
92
94
|
session: FlowSession,
|
|
93
95
|
stepId: string,
|
|
94
|
-
value: string | number
|
|
95
|
-
|
|
96
|
+
value: string | number,
|
|
97
|
+
hooks?: FlowHooks | null
|
|
98
|
+
): Promise<TransitionResult> {
|
|
96
99
|
// Find current step
|
|
97
100
|
const step = flow.steps.find((s) => s.id === stepId);
|
|
98
101
|
|
|
@@ -124,10 +127,20 @@ export function executeTransition(
|
|
|
124
127
|
const updatedVariables = { ...session.variables };
|
|
125
128
|
if (step.capture) {
|
|
126
129
|
// Convert to number if validation type is number
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
const capturedValue =
|
|
131
|
+
step.validate === "number" ? Number(value) : valueStr;
|
|
132
|
+
updatedVariables[step.capture] = capturedValue;
|
|
133
|
+
|
|
134
|
+
// Call onCapture hook
|
|
135
|
+
if (hooks?.onCapture) {
|
|
136
|
+
await safeExecuteHook(
|
|
137
|
+
api,
|
|
138
|
+
"onCapture",
|
|
139
|
+
hooks.onCapture,
|
|
140
|
+
step.capture,
|
|
141
|
+
capturedValue,
|
|
142
|
+
{ ...session, variables: updatedVariables }
|
|
143
|
+
);
|
|
131
144
|
}
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example hooks file for pushups flow
|
|
3
|
+
*
|
|
4
|
+
* This file demonstrates how to use hooks to customize flow behavior.
|
|
5
|
+
* To use this with your pushups flow:
|
|
6
|
+
* 1. Copy this file to ~/.clawdbot/flows/pushups/hooks.js
|
|
7
|
+
* 2. Update pushups.json to reference it: "hooks": "./hooks.js"
|
|
8
|
+
*
|
|
9
|
+
* NOTE: This is just an example showing the API. The actual integrations
|
|
10
|
+
* (Google Sheets, calendar, etc.) would require additional dependencies
|
|
11
|
+
* and authentication setup.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
/**
|
|
16
|
+
* Dynamic button generation based on past performance
|
|
17
|
+
* Example: Center buttons around user's average reps
|
|
18
|
+
*/
|
|
19
|
+
async onStepRender(step, _session) {
|
|
20
|
+
// Only modify rep-count steps
|
|
21
|
+
if (!step.id.startsWith('set')) {
|
|
22
|
+
return step;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// In a real implementation, you would:
|
|
26
|
+
// 1. Query past sessions (via custom storage backend or history.jsonl)
|
|
27
|
+
// 2. Calculate average reps for this step
|
|
28
|
+
// 3. Generate buttons centered around that average
|
|
29
|
+
|
|
30
|
+
// Example: Generate 5 buttons centered around 25 reps
|
|
31
|
+
const average = 25; // Would be calculated from history
|
|
32
|
+
const buttons = [
|
|
33
|
+
average - 10,
|
|
34
|
+
average - 5,
|
|
35
|
+
average,
|
|
36
|
+
average + 5,
|
|
37
|
+
average + 10,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
...step,
|
|
42
|
+
buttons,
|
|
43
|
+
message: `${step.message}\n\n💡 Your average: ${average} reps`,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Log each captured variable to external service
|
|
49
|
+
* Example: Append rep counts to Google Sheets
|
|
50
|
+
*/
|
|
51
|
+
async onCapture(variable, value, _session) {
|
|
52
|
+
console.log(`[Hooks] Captured ${variable} = ${value}`);
|
|
53
|
+
|
|
54
|
+
// In a real implementation, you would:
|
|
55
|
+
// 1. Authenticate with Google Sheets API
|
|
56
|
+
// 2. Append [timestamp, userId, variable, value] to a spreadsheet
|
|
57
|
+
// 3. Handle errors gracefully
|
|
58
|
+
|
|
59
|
+
// Example pseudo-code:
|
|
60
|
+
// await sheets.append({
|
|
61
|
+
// spreadsheetId: 'YOUR_SHEET_ID',
|
|
62
|
+
// range: 'A:D',
|
|
63
|
+
// values: [[new Date().toISOString(), session.senderId, variable, value]]
|
|
64
|
+
// });
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Follow-up actions when flow completes
|
|
69
|
+
* Example: Schedule next workout, send summary
|
|
70
|
+
*/
|
|
71
|
+
async onFlowComplete(session) {
|
|
72
|
+
console.log(`[Hooks] Flow completed for ${session.senderId}`);
|
|
73
|
+
console.log('Variables:', session.variables);
|
|
74
|
+
|
|
75
|
+
// Calculate total reps
|
|
76
|
+
const total =
|
|
77
|
+
(session.variables.set1 || 0) +
|
|
78
|
+
(session.variables.set2 || 0) +
|
|
79
|
+
(session.variables.set3 || 0) +
|
|
80
|
+
(session.variables.set4 || 0);
|
|
81
|
+
|
|
82
|
+
console.log(`Total reps: ${total}`);
|
|
83
|
+
|
|
84
|
+
// In a real implementation, you would:
|
|
85
|
+
// 1. Check calendar for next available slot
|
|
86
|
+
// 2. Create a cron job for that time
|
|
87
|
+
// 3. Create a calendar event
|
|
88
|
+
// 4. Send a congratulatory message
|
|
89
|
+
|
|
90
|
+
// Example pseudo-code:
|
|
91
|
+
// const nextWorkout = await calendar.findNextSlot();
|
|
92
|
+
// await cron.create({
|
|
93
|
+
// schedule: nextWorkout,
|
|
94
|
+
// command: '/flow-start pushups',
|
|
95
|
+
// userId: session.senderId
|
|
96
|
+
// });
|
|
97
|
+
// await calendar.createEvent({
|
|
98
|
+
// title: 'Pushups Workout',
|
|
99
|
+
// start: nextWorkout,
|
|
100
|
+
// duration: 30
|
|
101
|
+
// });
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Track abandonment for analytics
|
|
106
|
+
* Example: Log to database for completion rate tracking
|
|
107
|
+
*/
|
|
108
|
+
async onFlowAbandoned(session, reason) {
|
|
109
|
+
console.log(`[Hooks] Flow abandoned: ${reason}`);
|
|
110
|
+
console.log('Session:', session);
|
|
111
|
+
|
|
112
|
+
// In a real implementation, you would:
|
|
113
|
+
// 1. Log to analytics database
|
|
114
|
+
// 2. Track completion rates
|
|
115
|
+
// 3. Send reminder if appropriate
|
|
116
|
+
|
|
117
|
+
// Example pseudo-code:
|
|
118
|
+
// await db.logAbandonment({
|
|
119
|
+
// userId: session.senderId,
|
|
120
|
+
// flowName: session.flowName,
|
|
121
|
+
// reason,
|
|
122
|
+
// timestamp: Date.now(),
|
|
123
|
+
// lastStep: session.currentStepId,
|
|
124
|
+
// variables: session.variables
|
|
125
|
+
// });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example custom storage backend for Google Sheets
|
|
3
|
+
*
|
|
4
|
+
* This file demonstrates how to implement a custom storage backend
|
|
5
|
+
* that writes completed sessions to Google Sheets instead of (or in addition to)
|
|
6
|
+
* the built-in JSONL files.
|
|
7
|
+
*
|
|
8
|
+
* To use this with your flow:
|
|
9
|
+
* 1. Copy this file to ~/.clawdbot/flows/<flowname>/storage.js
|
|
10
|
+
* 2. Update flow.json to reference it:
|
|
11
|
+
* "storage": {
|
|
12
|
+
* "backend": "./storage.js",
|
|
13
|
+
* "builtin": false // Set to false to only use custom backend
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* NOTE: This is just an example showing the API. A real implementation
|
|
17
|
+
* would require:
|
|
18
|
+
* - npm install googleapis
|
|
19
|
+
* - Google Cloud project with Sheets API enabled
|
|
20
|
+
* - Service account credentials or OAuth tokens
|
|
21
|
+
* - Spreadsheet ID and appropriate permissions
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* StorageBackend interface implementation
|
|
26
|
+
*/
|
|
27
|
+
export default {
|
|
28
|
+
/**
|
|
29
|
+
* Save a completed session to Google Sheets
|
|
30
|
+
* @param {FlowSession} session - The completed flow session
|
|
31
|
+
*/
|
|
32
|
+
async saveSession(session) {
|
|
33
|
+
console.log('[Storage] Saving session to Google Sheets');
|
|
34
|
+
console.log('Session:', session);
|
|
35
|
+
|
|
36
|
+
// In a real implementation:
|
|
37
|
+
//
|
|
38
|
+
// 1. Authenticate with Google Sheets API
|
|
39
|
+
// const auth = await google.auth.getClient({
|
|
40
|
+
// keyFile: 'path/to/credentials.json',
|
|
41
|
+
// scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
42
|
+
// });
|
|
43
|
+
// const sheets = google.sheets({ version: 'v4', auth });
|
|
44
|
+
//
|
|
45
|
+
// 2. Format session data as row
|
|
46
|
+
// const row = [
|
|
47
|
+
// new Date().toISOString(), // Timestamp
|
|
48
|
+
// session.senderId, // User ID
|
|
49
|
+
// session.flowName, // Flow name
|
|
50
|
+
// JSON.stringify(session.variables), // Variables (JSON)
|
|
51
|
+
// session.variables.set1 || '', // Individual columns per variable
|
|
52
|
+
// session.variables.set2 || '',
|
|
53
|
+
// session.variables.set3 || '',
|
|
54
|
+
// session.variables.set4 || '',
|
|
55
|
+
// ];
|
|
56
|
+
//
|
|
57
|
+
// 3. Append to spreadsheet
|
|
58
|
+
// await sheets.spreadsheets.values.append({
|
|
59
|
+
// spreadsheetId: 'YOUR_SPREADSHEET_ID',
|
|
60
|
+
// range: 'Sheet1!A:H', // Adjust range based on your columns
|
|
61
|
+
// valueInputOption: 'RAW',
|
|
62
|
+
// requestBody: {
|
|
63
|
+
// values: [row]
|
|
64
|
+
// }
|
|
65
|
+
// });
|
|
66
|
+
//
|
|
67
|
+
// 4. Handle errors
|
|
68
|
+
// try {
|
|
69
|
+
// await sheets.spreadsheets.values.append(...);
|
|
70
|
+
// } catch (error) {
|
|
71
|
+
// console.error('Failed to write to Google Sheets:', error);
|
|
72
|
+
// throw error; // Re-throw so plugin knows it failed
|
|
73
|
+
// }
|
|
74
|
+
|
|
75
|
+
// Placeholder for example
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load historical sessions from Google Sheets
|
|
81
|
+
* @param {string} flowName - Name of the flow
|
|
82
|
+
* @param {object} options - Query options
|
|
83
|
+
* @param {number} options.limit - Maximum number of sessions to return
|
|
84
|
+
* @param {string} options.senderId - Filter by user ID
|
|
85
|
+
* @returns {Promise<FlowSession[]>} Array of historical sessions
|
|
86
|
+
*/
|
|
87
|
+
async loadHistory(flowName, options = {}) {
|
|
88
|
+
console.log('[Storage] Loading history from Google Sheets');
|
|
89
|
+
console.log('Flow:', flowName, 'Options:', options);
|
|
90
|
+
|
|
91
|
+
// In a real implementation:
|
|
92
|
+
//
|
|
93
|
+
// 1. Authenticate (same as above)
|
|
94
|
+
//
|
|
95
|
+
// 2. Read spreadsheet data
|
|
96
|
+
// const response = await sheets.spreadsheets.values.get({
|
|
97
|
+
// spreadsheetId: 'YOUR_SPREADSHEET_ID',
|
|
98
|
+
// range: 'Sheet1!A:H'
|
|
99
|
+
// });
|
|
100
|
+
// const rows = response.data.values || [];
|
|
101
|
+
//
|
|
102
|
+
// 3. Parse rows into FlowSession objects
|
|
103
|
+
// const sessions = rows
|
|
104
|
+
// .filter(row => row[2] === flowName) // Filter by flow name
|
|
105
|
+
// .filter(row => !options.senderId || row[1] === options.senderId)
|
|
106
|
+
// .map(row => ({
|
|
107
|
+
// flowName: row[2],
|
|
108
|
+
// senderId: row[1],
|
|
109
|
+
// currentStepId: '', // Not stored in this example
|
|
110
|
+
// channel: '', // Not stored in this example
|
|
111
|
+
// variables: JSON.parse(row[3]),
|
|
112
|
+
// startedAt: 0, // Could parse from row[0]
|
|
113
|
+
// lastActivityAt: 0, // Could parse from row[0]
|
|
114
|
+
// }));
|
|
115
|
+
//
|
|
116
|
+
// 4. Apply limit
|
|
117
|
+
// if (options.limit) {
|
|
118
|
+
// return sessions.slice(-options.limit); // Most recent N
|
|
119
|
+
// }
|
|
120
|
+
//
|
|
121
|
+
// return sessions;
|
|
122
|
+
|
|
123
|
+
// Placeholder for example
|
|
124
|
+
return Promise.resolve([]);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate that a resolved path stays within a base directory.
|
|
5
|
+
* Prevents directory traversal attacks like ../../../etc/passwd
|
|
6
|
+
* @throws Error if path escapes base directory
|
|
7
|
+
*/
|
|
8
|
+
export function validatePathWithinBase(
|
|
9
|
+
resolvedPath: string,
|
|
10
|
+
baseDir: string,
|
|
11
|
+
description: string = "path"
|
|
12
|
+
): void {
|
|
13
|
+
const normalized = path.normalize(path.resolve(resolvedPath));
|
|
14
|
+
const normalizedBase = path.normalize(path.resolve(baseDir));
|
|
15
|
+
|
|
16
|
+
// Must be inside baseDir or be exactly baseDir
|
|
17
|
+
const insideOrEqual =
|
|
18
|
+
normalized === normalizedBase ||
|
|
19
|
+
normalized.startsWith(normalizedBase + path.sep);
|
|
20
|
+
|
|
21
|
+
if (!insideOrEqual) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Path traversal detected: ${description} "${resolvedPath}" escapes base directory "${baseDir}"`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Safely resolve a relative path within a base directory
|
|
30
|
+
* @returns Absolute path if valid; throws if attempts to escape base
|
|
31
|
+
*/
|
|
32
|
+
export function resolvePathSafely(
|
|
33
|
+
basePath: string,
|
|
34
|
+
relativePath: string,
|
|
35
|
+
description: string = "path"
|
|
36
|
+
): string {
|
|
37
|
+
const resolved = path.resolve(basePath, relativePath);
|
|
38
|
+
validatePathWithinBase(resolved, basePath, description);
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sanitize a filename for cross-platform compatibility
|
|
44
|
+
* Removes unsafe characters: < > : " / \ | ? * and control chars
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeFilename(name: string): string {
|
|
47
|
+
// Remove unsafe chars and control chars (U+0000-U+001F)
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
|
|
50
|
+
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_");
|
|
51
|
+
// Collapse multiple underscores, trim leading/trailing, limit length
|
|
52
|
+
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
|
|
53
|
+
}
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
8
|
-
import type { FlowSession } from "../types.js";
|
|
8
|
+
import type { FlowSession, FlowMetadata } from "../types.js";
|
|
9
|
+
import type { SkillFlowConfig } from "../config.js";
|
|
10
|
+
import {
|
|
11
|
+
loadStorageBackend,
|
|
12
|
+
resolveFlowPath,
|
|
13
|
+
} from "../engine/hooks-loader.js";
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Get history file path for a flow
|
|
@@ -20,8 +25,36 @@ function getHistoryPath(api: ClawdbotPluginApi, flowName: string): string {
|
|
|
20
25
|
*/
|
|
21
26
|
export async function saveFlowHistory(
|
|
22
27
|
api: ClawdbotPluginApi,
|
|
23
|
-
session: FlowSession
|
|
28
|
+
session: FlowSession,
|
|
29
|
+
flow?: FlowMetadata,
|
|
30
|
+
config?: SkillFlowConfig
|
|
24
31
|
): Promise<void> {
|
|
32
|
+
// Use custom storage backend if configured
|
|
33
|
+
if (flow?.storage?.backend) {
|
|
34
|
+
try {
|
|
35
|
+
const backendPath = resolveFlowPath(
|
|
36
|
+
api,
|
|
37
|
+
session.flowName,
|
|
38
|
+
flow.storage.backend
|
|
39
|
+
);
|
|
40
|
+
const backend = await loadStorageBackend(api, backendPath);
|
|
41
|
+
if (backend) {
|
|
42
|
+
await backend.saveSession(session);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
api.logger.error(
|
|
46
|
+
`Custom storage backend failed for flow ${session.flowName}:`,
|
|
47
|
+
error
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Write to built-in JSONL storage unless disabled by config or flow settings
|
|
53
|
+
const useBuiltin = config?.enableBuiltinHistory ?? flow?.storage?.builtin ?? true;
|
|
54
|
+
if (!useBuiltin) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
const historyPath = getHistoryPath(api, session.flowName);
|
|
26
59
|
|
|
27
60
|
try {
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { FlowSession } from "../types.js";
|
|
6
|
+
import type { SkillFlowConfig } from "../config.js";
|
|
6
7
|
|
|
7
|
-
// Session timeout: 30 minutes
|
|
8
|
-
|
|
8
|
+
// Session timeout: 30 minutes (default, configurable)
|
|
9
|
+
let SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
9
10
|
|
|
10
|
-
// Cleanup interval: 5 minutes
|
|
11
|
-
|
|
11
|
+
// Cleanup interval: 5 minutes (default, configurable)
|
|
12
|
+
let CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
// In-memory session store
|
|
14
15
|
const sessions = new Map<string, FlowSession>();
|
|
@@ -16,6 +17,23 @@ const sessions = new Map<string, FlowSession>();
|
|
|
16
17
|
// Cleanup timer
|
|
17
18
|
let cleanupTimer: NodeJS.Timeout | null = null;
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Initialize session store with configuration
|
|
22
|
+
*/
|
|
23
|
+
export function initSessionStore(config: SkillFlowConfig): void {
|
|
24
|
+
SESSION_TIMEOUT_MS = config.sessionTimeoutMinutes * 60 * 1000;
|
|
25
|
+
CLEANUP_INTERVAL_MS = config.sessionCleanupIntervalMinutes * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
// Restart cleanup timer with new interval
|
|
28
|
+
if (cleanupTimer) {
|
|
29
|
+
clearInterval(cleanupTimer);
|
|
30
|
+
cleanupTimer = null;
|
|
31
|
+
}
|
|
32
|
+
if (sessions.size > 0) {
|
|
33
|
+
cleanupTimer = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/**
|
|
20
38
|
* Generate session key
|
|
21
39
|
*/
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface FlowMetadata {
|
|
|
38
38
|
cron?: string;
|
|
39
39
|
event?: string;
|
|
40
40
|
};
|
|
41
|
+
hooks?: string; // Path to hooks file (relative to flow directory)
|
|
42
|
+
storage?: {
|
|
43
|
+
backend?: string; // Path to custom storage backend
|
|
44
|
+
builtin?: boolean; // Also write to JSONL (default: true)
|
|
45
|
+
};
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface FlowSession {
|
|
@@ -60,8 +65,76 @@ export interface TransitionResult {
|
|
|
60
65
|
|
|
61
66
|
export interface ReplyPayload {
|
|
62
67
|
text: string;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
channelData?: {
|
|
69
|
+
telegram?: {
|
|
70
|
+
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hooks interface for customizing flow behavior at key points.
|
|
77
|
+
* All hooks are optional and async-compatible.
|
|
78
|
+
*/
|
|
79
|
+
export interface FlowHooks {
|
|
80
|
+
/**
|
|
81
|
+
* Called before rendering a step. Return modified step (e.g., dynamic buttons).
|
|
82
|
+
* @param step - The step about to be rendered
|
|
83
|
+
* @param session - Current session state (variables, history)
|
|
84
|
+
* @returns Modified step or original
|
|
85
|
+
*/
|
|
86
|
+
onStepRender?: (
|
|
87
|
+
step: FlowStep,
|
|
88
|
+
session: FlowSession
|
|
89
|
+
) => FlowStep | Promise<FlowStep>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called after a variable is captured. Use for external logging.
|
|
93
|
+
* @param variable - Variable name
|
|
94
|
+
* @param value - Captured value
|
|
95
|
+
* @param session - Current session state
|
|
96
|
+
*/
|
|
97
|
+
onCapture?: (
|
|
98
|
+
variable: string,
|
|
99
|
+
value: string | number,
|
|
100
|
+
session: FlowSession
|
|
101
|
+
) => void | Promise<void>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Called when flow completes (reaches terminal step). Use for follow-up actions.
|
|
105
|
+
* @param session - Final session state with all captured variables
|
|
106
|
+
*/
|
|
107
|
+
onFlowComplete?: (session: FlowSession) => void | Promise<void>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Called when flow is abandoned (timeout, user cancels).
|
|
111
|
+
* @param session - Session state at time of abandonment
|
|
112
|
+
* @param reason - "timeout" | "cancelled" | "error"
|
|
113
|
+
*/
|
|
114
|
+
onFlowAbandoned?: (
|
|
115
|
+
session: FlowSession,
|
|
116
|
+
reason: "timeout" | "cancelled" | "error"
|
|
117
|
+
) => void | Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pluggable storage backend interface for custom persistence.
|
|
122
|
+
*/
|
|
123
|
+
export interface StorageBackend {
|
|
124
|
+
/**
|
|
125
|
+
* Save a completed session to storage.
|
|
126
|
+
* @param session - The completed flow session
|
|
127
|
+
*/
|
|
128
|
+
saveSession(session: FlowSession): Promise<void>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load historical sessions for a flow.
|
|
132
|
+
* @param flowName - Name of the flow
|
|
133
|
+
* @param options - Query options (limit, filter by sender)
|
|
134
|
+
* @returns Array of historical sessions
|
|
135
|
+
*/
|
|
136
|
+
loadHistory(
|
|
137
|
+
flowName: string,
|
|
138
|
+
options?: { limit?: number; senderId?: string }
|
|
139
|
+
): Promise<FlowSession[]>;
|
|
67
140
|
}
|
package/src/validation.ts
CHANGED
|
@@ -49,6 +49,14 @@ const TriggerSchema = z
|
|
|
49
49
|
})
|
|
50
50
|
.optional();
|
|
51
51
|
|
|
52
|
+
// Storage backend schema
|
|
53
|
+
const StorageSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
backend: z.string().optional(),
|
|
56
|
+
builtin: z.boolean().optional(),
|
|
57
|
+
})
|
|
58
|
+
.optional();
|
|
59
|
+
|
|
52
60
|
// Complete flow metadata schema
|
|
53
61
|
export const FlowMetadataSchema = z.object({
|
|
54
62
|
name: z.string(),
|
|
@@ -57,6 +65,8 @@ export const FlowMetadataSchema = z.object({
|
|
|
57
65
|
author: z.string().optional(),
|
|
58
66
|
steps: z.array(FlowStepSchema).min(1),
|
|
59
67
|
triggers: TriggerSchema,
|
|
68
|
+
hooks: z.string().optional(),
|
|
69
|
+
storage: StorageSchema,
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
/**
|