@joshualelon/clawdbot-skill-flow 0.1.0 → 0.2.1

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 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.
@@ -1,8 +1,50 @@
1
1
  {
2
- "id": "skill-flow",
2
+ "id": "clawdbot-skill-flow",
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.1.0",
3
+ "version": "0.2.1",
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
- // Get active session
52
- const sessionKey = getSessionKey(args.senderId, flowName);
53
- const session = getSession(sessionKey);
57
+ // Load flow first
58
+ const flow = await loadFlow(api, flowName);
54
59
 
55
- if (!session) {
60
+ if (!flow) {
56
61
  return {
57
- text: `Session expired or not found.\n\nUse /flow-start ${flowName} to restart the flow.`,
62
+ text: `Flow "${flowName}" not found.`,
58
63
  };
59
64
  }
60
65
 
61
- // Load flow
62
- const flow = await loadFlow(api, flowName);
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: `Flow "${flowName}" not found.`,
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
- await saveFlowHistory(api, finalSession);
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
+ }
@@ -3,18 +3,24 @@
3
3
  */
4
4
 
5
5
  import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
6
- import type { FlowMetadata, FlowSession, ReplyPayload } from "../types.js";
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
- return renderStep(api, flow, firstStep, session, session.channel);
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
- const result = executeTransition(api, flow, session, stepId, value);
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(api, flow, nextStep, updatedSession, session.channel);
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
+ }
@@ -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
- buttons: keyboard,
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
- _api: ClawdbotPluginApi,
111
+ export async function renderStep(
112
+ api: ClawdbotPluginApi,
107
113
  flow: FlowMetadata,
108
114
  step: FlowStep,
109
115
  session: FlowSession,
110
- channel: string
111
- ): ReplyPayload {
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, step, session.variables);
136
+ return renderTelegram(flow.name, finalStep, session.variables);
115
137
  }
116
138
 
117
139
  // Fallback for all other channels
118
- return renderFallback(step, session.variables);
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
- _api: ClawdbotPluginApi,
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
- ): TransitionResult {
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
- if (step.validate === "number") {
128
- updatedVariables[step.capture] = Number(value);
129
- } else {
130
- updatedVariables[step.capture] = valueStr;
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
- const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
8
+ // Session timeout: 30 minutes (default, configurable)
9
+ let SESSION_TIMEOUT_MS = 30 * 60 * 1000;
9
10
 
10
- // Cleanup interval: 5 minutes
11
- const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
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
- buttons?: Array<{
64
- text: string;
65
- callback_data: string;
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
  /**
package/types.d.ts CHANGED
@@ -14,6 +14,7 @@ declare module "lockfile" {
14
14
 
15
15
  declare module "clawdbot/plugin-sdk" {
16
16
  export interface ClawdbotPluginApi {
17
+ pluginConfig?: Record<string, unknown>;
17
18
  logger: {
18
19
  info(...args: any[]): void;
19
20
  error(...args: any[]): void;