@joshualelon/clawdbot-skill-flow 0.1.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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * /flow-step command - Handle flow step transitions (called via Telegram callbacks)
3
+ */
4
+
5
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
6
+ import { loadFlow } from "../state/flow-store.js";
7
+ import {
8
+ getSession,
9
+ getSessionKey,
10
+ updateSession,
11
+ deleteSession,
12
+ } from "../state/session-store.js";
13
+ import { saveFlowHistory } from "../state/history-store.js";
14
+ import { processStep } from "../engine/executor.js";
15
+
16
+ export function createFlowStepCommand(api: ClawdbotPluginApi) {
17
+ return async (args: {
18
+ args?: string;
19
+ senderId: string;
20
+ channel: string;
21
+ }) => {
22
+ const input = args.args?.trim();
23
+
24
+ if (!input) {
25
+ return {
26
+ text: "Error: Missing step parameters",
27
+ };
28
+ }
29
+
30
+ // Parse callback: "flowName stepId:value"
31
+ const parts = input.split(" ");
32
+ if (parts.length < 2) {
33
+ return {
34
+ text: "Error: Invalid step parameters",
35
+ };
36
+ }
37
+
38
+ const flowName = parts[0]!;
39
+ const stepData = parts.slice(1).join(" "); // Handle spaces in value
40
+ const colonIndex = stepData.indexOf(":");
41
+
42
+ if (colonIndex === -1) {
43
+ return {
44
+ text: "Error: Invalid step format (expected stepId:value)",
45
+ };
46
+ }
47
+
48
+ const stepId = stepData.substring(0, colonIndex);
49
+ const valueStr = stepData.substring(colonIndex + 1);
50
+
51
+ // Get active session
52
+ const sessionKey = getSessionKey(args.senderId, flowName);
53
+ const session = getSession(sessionKey);
54
+
55
+ if (!session) {
56
+ return {
57
+ text: `Session expired or not found.\n\nUse /flow-start ${flowName} to restart the flow.`,
58
+ };
59
+ }
60
+
61
+ // Load flow
62
+ const flow = await loadFlow(api, flowName);
63
+
64
+ if (!flow) {
65
+ deleteSession(sessionKey);
66
+ return {
67
+ text: `Flow "${flowName}" not found.`,
68
+ };
69
+ }
70
+
71
+ // Detect if value should be numeric
72
+ const value = /^\d+$/.test(valueStr) ? Number(valueStr) : valueStr;
73
+
74
+ // Process step transition
75
+ const result = processStep(api, flow, session, stepId, value);
76
+
77
+ // Update session or cleanup
78
+ if (result.complete) {
79
+ // Save to history
80
+ const finalSession = {
81
+ ...session,
82
+ variables: result.updatedVariables,
83
+ };
84
+ await saveFlowHistory(api, finalSession);
85
+
86
+ // Cleanup session
87
+ deleteSession(sessionKey);
88
+
89
+ api.logger.info(
90
+ `Completed flow "${flowName}" for user ${args.senderId}`
91
+ );
92
+ } else {
93
+ // Update session with new variables and current step
94
+ updateSession(sessionKey, {
95
+ variables: result.updatedVariables,
96
+ });
97
+ }
98
+
99
+ return result.reply;
100
+ };
101
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Main flow execution orchestrator
3
+ */
4
+
5
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
6
+ import type { FlowMetadata, FlowSession, ReplyPayload } from "../types.js";
7
+ import { renderStep } from "./renderer.js";
8
+ import { executeTransition } from "./transitions.js";
9
+
10
+ /**
11
+ * Start a flow from the beginning
12
+ */
13
+ export function startFlow(
14
+ api: ClawdbotPluginApi,
15
+ flow: FlowMetadata,
16
+ session: FlowSession
17
+ ): ReplyPayload {
18
+ const firstStep = flow.steps[0];
19
+
20
+ if (!firstStep) {
21
+ return {
22
+ text: `Error: Flow "${flow.name}" has no steps`,
23
+ };
24
+ }
25
+
26
+ return renderStep(api, flow, firstStep, session, session.channel);
27
+ }
28
+
29
+ /**
30
+ * Process a step transition and return next step or completion message
31
+ */
32
+ export function processStep(
33
+ api: ClawdbotPluginApi,
34
+ flow: FlowMetadata,
35
+ session: FlowSession,
36
+ stepId: string,
37
+ value: string | number
38
+ ): {
39
+ reply: ReplyPayload;
40
+ complete: boolean;
41
+ updatedVariables: Record<string, string | number>;
42
+ } {
43
+ const result = executeTransition(api, flow, session, stepId, value);
44
+
45
+ // Handle errors
46
+ if (result.error) {
47
+ return {
48
+ reply: { text: result.message || result.error },
49
+ complete: false,
50
+ updatedVariables: result.variables,
51
+ };
52
+ }
53
+
54
+ // Handle completion
55
+ if (result.complete) {
56
+ const completionMessage = generateCompletionMessage(flow, result.variables);
57
+ return {
58
+ reply: { text: completionMessage },
59
+ complete: true,
60
+ updatedVariables: result.variables,
61
+ };
62
+ }
63
+
64
+ // Render next step
65
+ if (!result.nextStepId) {
66
+ return {
67
+ reply: { text: "Error: No next step found" },
68
+ complete: false,
69
+ updatedVariables: result.variables,
70
+ };
71
+ }
72
+
73
+ const nextStep = flow.steps.find((s) => s.id === result.nextStepId);
74
+ if (!nextStep) {
75
+ return {
76
+ reply: { text: `Error: Step ${result.nextStepId} not found` },
77
+ complete: false,
78
+ updatedVariables: result.variables,
79
+ };
80
+ }
81
+
82
+ const updatedSession = { ...session, variables: result.variables };
83
+ const reply = renderStep(api, flow, nextStep, updatedSession, session.channel);
84
+
85
+ return {
86
+ reply,
87
+ complete: false,
88
+ updatedVariables: result.variables,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Generate completion message
94
+ */
95
+ function generateCompletionMessage(
96
+ flow: FlowMetadata,
97
+ variables: Record<string, string | number>
98
+ ): string {
99
+ let message = `āœ… Flow "${flow.name}" completed!\n\n`;
100
+
101
+ if (Object.keys(variables).length > 0) {
102
+ message += "Summary:\n";
103
+ for (const [key, value] of Object.entries(variables)) {
104
+ message += `• ${key}: ${value}\n`;
105
+ }
106
+ }
107
+
108
+ return message;
109
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Flow step rendering for different channel types
3
+ */
4
+
5
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
6
+ import type {
7
+ FlowMetadata,
8
+ FlowStep,
9
+ FlowSession,
10
+ ReplyPayload,
11
+ } from "../types.js";
12
+ import { normalizeButton } from "../validation.js";
13
+
14
+ /**
15
+ * Interpolate variables in message text
16
+ */
17
+ function interpolateVariables(
18
+ text: string,
19
+ variables: Record<string, string | number>
20
+ ): string {
21
+ return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
22
+ const value = variables[varName];
23
+ return value !== undefined ? String(value) : match;
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Render step for Telegram (with inline keyboard)
29
+ */
30
+ function renderTelegram(
31
+ flowName: string,
32
+ step: FlowStep,
33
+ variables: Record<string, string | number>
34
+ ): ReplyPayload {
35
+ const message = interpolateVariables(step.message, variables);
36
+
37
+ if (!step.buttons || step.buttons.length === 0) {
38
+ return { text: message };
39
+ }
40
+
41
+ const buttons = step.buttons.map((btn, idx) =>
42
+ normalizeButton(btn, idx)
43
+ );
44
+
45
+ // Detect if all buttons are numeric (for grid layout)
46
+ const allNumeric = buttons.every((btn) => typeof btn.value === "number");
47
+
48
+ let keyboard: Array<{ text: string; callback_data: string }[]>;
49
+
50
+ if (allNumeric && buttons.length > 2) {
51
+ // 2-column grid for numeric buttons
52
+ keyboard = [];
53
+ for (let i = 0; i < buttons.length; i += 2) {
54
+ const row = buttons.slice(i, i + 2).map((btn) => ({
55
+ text: btn.text,
56
+ callback_data: `/flow-step ${flowName} ${step.id}:${btn.value}`,
57
+ }));
58
+ keyboard.push(row);
59
+ }
60
+ } else {
61
+ // Single column for text buttons
62
+ keyboard = buttons.map((btn) => [
63
+ {
64
+ text: btn.text,
65
+ callback_data: `/flow-step ${flowName} ${step.id}:${btn.value}`,
66
+ },
67
+ ]);
68
+ }
69
+
70
+ return {
71
+ text: message,
72
+ buttons: keyboard,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Render step for fallback channels (text-based)
78
+ */
79
+ function renderFallback(
80
+ step: FlowStep,
81
+ variables: Record<string, string | number>
82
+ ): ReplyPayload {
83
+ const message = interpolateVariables(step.message, variables);
84
+
85
+ if (!step.buttons || step.buttons.length === 0) {
86
+ return { text: message };
87
+ }
88
+
89
+ const buttons = step.buttons.map((btn, idx) =>
90
+ normalizeButton(btn, idx)
91
+ );
92
+
93
+ const buttonList = buttons
94
+ .map((btn, idx) => `${idx + 1}. ${btn.text}`)
95
+ .join("\n");
96
+
97
+ return {
98
+ text: `${message}\n\n${buttonList}\n\nReply with the number of your choice.`,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Render a flow step
104
+ */
105
+ export function renderStep(
106
+ _api: ClawdbotPluginApi,
107
+ flow: FlowMetadata,
108
+ step: FlowStep,
109
+ session: FlowSession,
110
+ channel: string
111
+ ): ReplyPayload {
112
+ // Channel-specific rendering
113
+ if (channel === "telegram") {
114
+ return renderTelegram(flow.name, step, session.variables);
115
+ }
116
+
117
+ // Fallback for all other channels
118
+ return renderFallback(step, session.variables);
119
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Flow step transition logic with validation and conditional branching
3
+ */
4
+
5
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
6
+ import type {
7
+ FlowMetadata,
8
+ FlowStep,
9
+ FlowSession,
10
+ TransitionResult,
11
+ } from "../types.js";
12
+ import { normalizeButton, validateInput } from "../validation.js";
13
+
14
+ /**
15
+ * Evaluate condition against session variables
16
+ */
17
+ function evaluateCondition(
18
+ condition: FlowStep["condition"],
19
+ variables: Record<string, string | number>
20
+ ): boolean {
21
+ if (!condition) {
22
+ return false;
23
+ }
24
+
25
+ const value = variables[condition.variable];
26
+
27
+ if (value === undefined) {
28
+ return false;
29
+ }
30
+
31
+ // Check equals
32
+ if (condition.equals !== undefined) {
33
+ return value === condition.equals;
34
+ }
35
+
36
+ // Check greater than
37
+ if (condition.greaterThan !== undefined) {
38
+ const numValue =
39
+ typeof value === "number" ? value : Number(value);
40
+ return !isNaN(numValue) && numValue > condition.greaterThan;
41
+ }
42
+
43
+ // Check less than
44
+ if (condition.lessThan !== undefined) {
45
+ const numValue =
46
+ typeof value === "number" ? value : Number(value);
47
+ return !isNaN(numValue) && numValue < condition.lessThan;
48
+ }
49
+
50
+ // Check contains (for strings)
51
+ if (condition.contains !== undefined) {
52
+ return String(value).includes(condition.contains);
53
+ }
54
+
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Find next step ID based on transition rules
60
+ */
61
+ function findNextStep(
62
+ step: FlowStep,
63
+ value: string | number,
64
+ variables: Record<string, string | number>
65
+ ): string | undefined {
66
+ // 1. Check button-specific next
67
+ if (step.buttons && step.buttons.length > 0) {
68
+ const buttons = step.buttons.map((btn, idx) =>
69
+ normalizeButton(btn, idx)
70
+ );
71
+ const matchingButton = buttons.find((btn) => btn.value === value);
72
+ if (matchingButton?.next) {
73
+ return matchingButton.next;
74
+ }
75
+ }
76
+
77
+ // 2. Check conditional branching
78
+ if (step.condition && evaluateCondition(step.condition, variables)) {
79
+ return step.condition.next;
80
+ }
81
+
82
+ // 3. Use default next
83
+ return step.next;
84
+ }
85
+
86
+ /**
87
+ * Execute step transition
88
+ */
89
+ export function executeTransition(
90
+ _api: ClawdbotPluginApi,
91
+ flow: FlowMetadata,
92
+ session: FlowSession,
93
+ stepId: string,
94
+ value: string | number
95
+ ): TransitionResult {
96
+ // Find current step
97
+ const step = flow.steps.find((s) => s.id === stepId);
98
+
99
+ if (!step) {
100
+ return {
101
+ variables: session.variables,
102
+ complete: false,
103
+ error: `Step ${stepId} not found`,
104
+ };
105
+ }
106
+
107
+ // Convert value to string for validation
108
+ const valueStr = String(value);
109
+
110
+ // Validate input if required
111
+ if (step.validate) {
112
+ const validation = validateInput(valueStr, step.validate);
113
+ if (!validation.valid) {
114
+ return {
115
+ variables: session.variables,
116
+ complete: false,
117
+ error: validation.error,
118
+ message: validation.error,
119
+ };
120
+ }
121
+ }
122
+
123
+ // Capture variable if specified
124
+ const updatedVariables = { ...session.variables };
125
+ if (step.capture) {
126
+ // 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;
131
+ }
132
+ }
133
+
134
+ // Find next step
135
+ const nextStepId = findNextStep(step, value, updatedVariables);
136
+
137
+ // If no next step, flow is complete
138
+ if (!nextStepId) {
139
+ return {
140
+ variables: updatedVariables,
141
+ complete: true,
142
+ };
143
+ }
144
+
145
+ // Verify next step exists
146
+ const nextStep = flow.steps.find((s) => s.id === nextStepId);
147
+ if (!nextStep) {
148
+ return {
149
+ variables: updatedVariables,
150
+ complete: false,
151
+ error: `Next step ${nextStepId} not found`,
152
+ };
153
+ }
154
+
155
+ return {
156
+ nextStepId,
157
+ variables: updatedVariables,
158
+ complete: false,
159
+ };
160
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "onboarding",
3
+ "description": "User onboarding wizard with email validation",
4
+ "version": "1.0.0",
5
+ "author": "Clawdbot Team",
6
+ "triggers": {
7
+ "manual": true
8
+ },
9
+ "steps": [
10
+ {
11
+ "id": "welcome",
12
+ "message": "šŸ‘‹ Welcome! Let's get you set up.\n\nWhat's your name?",
13
+ "capture": "name",
14
+ "next": "preferences"
15
+ },
16
+ {
17
+ "id": "preferences",
18
+ "message": "Nice to meet you, {{name}}!\n\nWhat are you interested in?",
19
+ "buttons": [
20
+ "Productivity",
21
+ "Fitness",
22
+ "Learning",
23
+ "Entertainment"
24
+ ],
25
+ "capture": "interests",
26
+ "next": "email"
27
+ },
28
+ {
29
+ "id": "email",
30
+ "message": "What's your email address?",
31
+ "capture": "email",
32
+ "validate": "email",
33
+ "next": "confirm"
34
+ },
35
+ {
36
+ "id": "confirm",
37
+ "message": "Perfect! Here's what we have:\n\n• Name: {{name}}\n• Interests: {{interests}}\n• Email: {{email}}\n\nReady to get started?",
38
+ "buttons": ["Yes, let's go!", "Start over"]
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pushups",
3
+ "description": "4-set pushup workout tracker",
4
+ "version": "1.0.0",
5
+ "author": "Clawdbot Team",
6
+ "triggers": {
7
+ "manual": true,
8
+ "cron": "45 13 * * *"
9
+ },
10
+ "steps": [
11
+ {
12
+ "id": "reminder",
13
+ "message": "šŸ‹ļø Time for your daily pushup workout!\n\nReady to start?",
14
+ "buttons": ["Yes, let's go!", "Skip today"],
15
+ "next": "set1"
16
+ },
17
+ {
18
+ "id": "set1",
19
+ "message": "Set 1: How many pushups did you do?",
20
+ "buttons": [20, 25, 30, 35, 40],
21
+ "capture": "set1",
22
+ "validate": "number",
23
+ "next": "set2"
24
+ },
25
+ {
26
+ "id": "set2",
27
+ "message": "Great! Set 2: How many pushups?",
28
+ "buttons": [20, 25, 30, 35, 40],
29
+ "capture": "set2",
30
+ "validate": "number",
31
+ "next": "set3"
32
+ },
33
+ {
34
+ "id": "set3",
35
+ "message": "Awesome! Set 3: How many?",
36
+ "buttons": [20, 25, 30, 35, 40],
37
+ "capture": "set3",
38
+ "validate": "number",
39
+ "next": "set4"
40
+ },
41
+ {
42
+ "id": "set4",
43
+ "message": "Final set! How many pushups?",
44
+ "buttons": [20, 25, 30, 35, 40],
45
+ "capture": "set4",
46
+ "validate": "number"
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "survey",
3
+ "description": "Customer satisfaction survey with conditional branching",
4
+ "version": "1.0.0",
5
+ "author": "Clawdbot Team",
6
+ "triggers": {
7
+ "manual": true
8
+ },
9
+ "steps": [
10
+ {
11
+ "id": "satisfaction",
12
+ "message": "How satisfied are you with our service?",
13
+ "buttons": ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"],
14
+ "capture": "satisfaction",
15
+ "next": "nps"
16
+ },
17
+ {
18
+ "id": "nps",
19
+ "message": "On a scale of 0-10, how likely are you to recommend us?",
20
+ "buttons": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
21
+ "capture": "nps",
22
+ "validate": "number",
23
+ "condition": {
24
+ "variable": "nps",
25
+ "greaterThan": 7,
26
+ "next": "positive-feedback"
27
+ },
28
+ "next": "negative-feedback"
29
+ },
30
+ {
31
+ "id": "positive-feedback",
32
+ "message": "Great to hear! šŸŽ‰ What did you love most about our service?",
33
+ "capture": "feedback"
34
+ },
35
+ {
36
+ "id": "negative-feedback",
37
+ "message": "We're sorry to hear that. What can we improve?",
38
+ "capture": "feedback"
39
+ }
40
+ ]
41
+ }