@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,158 @@
1
+ /**
2
+ * Flow metadata storage with file locking for concurrent access
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import lockfile from "lockfile";
8
+ import { promisify } from "node:util";
9
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
10
+ import type { FlowMetadata } from "../types.js";
11
+ import { FlowMetadataSchema } from "../validation.js";
12
+
13
+ const lock = promisify(lockfile.lock);
14
+ const unlock = promisify(lockfile.unlock);
15
+
16
+ /**
17
+ * Get the flows directory path
18
+ */
19
+ function getFlowsDir(api: ClawdbotPluginApi): string {
20
+ const stateDir = api.runtime.state.resolveStateDir();
21
+ return path.join(stateDir, "flows");
22
+ }
23
+
24
+ /**
25
+ * Get the flow directory path
26
+ */
27
+ function getFlowDir(api: ClawdbotPluginApi, flowName: string): string {
28
+ return path.join(getFlowsDir(api), flowName);
29
+ }
30
+
31
+ /**
32
+ * Get the flow metadata file path
33
+ */
34
+ function getFlowMetadataPath(
35
+ api: ClawdbotPluginApi,
36
+ flowName: string
37
+ ): string {
38
+ return path.join(getFlowDir(api, flowName), "metadata.json");
39
+ }
40
+
41
+ /**
42
+ * Load a flow by name
43
+ */
44
+ export async function loadFlow(
45
+ api: ClawdbotPluginApi,
46
+ flowName: string
47
+ ): Promise<FlowMetadata | null> {
48
+ const metadataPath = getFlowMetadataPath(api, flowName);
49
+ const lockPath = `${metadataPath}.lock`;
50
+
51
+ try {
52
+ await lock(lockPath, { wait: 5000, retries: 3 });
53
+
54
+ try {
55
+ const content = await fs.readFile(metadataPath, "utf-8");
56
+ const data = JSON.parse(content);
57
+ return FlowMetadataSchema.parse(data);
58
+ } finally {
59
+ await unlock(lockPath);
60
+ }
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
63
+ return null;
64
+ }
65
+ api.logger.error(`Failed to load flow ${flowName}:`, error);
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Save a flow (atomic write with locking)
72
+ */
73
+ export async function saveFlow(
74
+ api: ClawdbotPluginApi,
75
+ flow: FlowMetadata
76
+ ): Promise<void> {
77
+ const flowDir = getFlowDir(api, flow.name);
78
+ const metadataPath = getFlowMetadataPath(api, flow.name);
79
+ const lockPath = `${metadataPath}.lock`;
80
+ const tempPath = `${metadataPath}.tmp`;
81
+
82
+ // Validate flow metadata
83
+ FlowMetadataSchema.parse(flow);
84
+
85
+ try {
86
+ // Ensure flow directory exists
87
+ await fs.mkdir(flowDir, { recursive: true });
88
+
89
+ await lock(lockPath, { wait: 5000, retries: 3 });
90
+
91
+ try {
92
+ // Write to temp file
93
+ await fs.writeFile(tempPath, JSON.stringify(flow, null, 2), "utf-8");
94
+
95
+ // Atomic rename
96
+ await fs.rename(tempPath, metadataPath);
97
+ } finally {
98
+ await unlock(lockPath);
99
+
100
+ // Cleanup temp file if it still exists
101
+ try {
102
+ await fs.unlink(tempPath);
103
+ } catch {
104
+ // Ignore errors
105
+ }
106
+ }
107
+ } catch (error) {
108
+ api.logger.error(`Failed to save flow ${flow.name}:`, error);
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * List all flows
115
+ */
116
+ export async function listFlows(
117
+ api: ClawdbotPluginApi
118
+ ): Promise<FlowMetadata[]> {
119
+ const flowsDir = getFlowsDir(api);
120
+
121
+ try {
122
+ await fs.mkdir(flowsDir, { recursive: true });
123
+ const entries = await fs.readdir(flowsDir, { withFileTypes: true });
124
+
125
+ const flows: FlowMetadata[] = [];
126
+
127
+ for (const entry of entries) {
128
+ if (entry.isDirectory()) {
129
+ const flow = await loadFlow(api, entry.name);
130
+ if (flow) {
131
+ flows.push(flow);
132
+ }
133
+ }
134
+ }
135
+
136
+ return flows;
137
+ } catch (error) {
138
+ api.logger.error("Failed to list flows:", error);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Delete a flow
145
+ */
146
+ export async function deleteFlow(
147
+ api: ClawdbotPluginApi,
148
+ flowName: string
149
+ ): Promise<void> {
150
+ const flowDir = getFlowDir(api, flowName);
151
+
152
+ try {
153
+ await fs.rm(flowDir, { recursive: true, force: true });
154
+ } catch (error) {
155
+ api.logger.error(`Failed to delete flow ${flowName}:`, error);
156
+ throw error;
157
+ }
158
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * JSONL append-only history log for completed flows
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
8
+ import type { FlowSession } from "../types.js";
9
+
10
+ /**
11
+ * Get history file path for a flow
12
+ */
13
+ function getHistoryPath(api: ClawdbotPluginApi, flowName: string): string {
14
+ const stateDir = api.runtime.state.resolveStateDir();
15
+ return path.join(stateDir, "flows", flowName, "history.jsonl");
16
+ }
17
+
18
+ /**
19
+ * Save completed flow session to history (fire-and-forget)
20
+ */
21
+ export async function saveFlowHistory(
22
+ api: ClawdbotPluginApi,
23
+ session: FlowSession
24
+ ): Promise<void> {
25
+ const historyPath = getHistoryPath(api, session.flowName);
26
+
27
+ try {
28
+ // Ensure directory exists
29
+ await fs.mkdir(path.dirname(historyPath), { recursive: true });
30
+
31
+ // Create history entry
32
+ const entry = {
33
+ ...session,
34
+ completedAt: Date.now(),
35
+ };
36
+
37
+ // Append to JSONL file
38
+ const line = JSON.stringify(entry) + "\n";
39
+ await fs.appendFile(historyPath, line, "utf-8");
40
+ } catch (error) {
41
+ // Log error but don't throw (fire-and-forget)
42
+ api.logger.error(
43
+ `Failed to save history for flow ${session.flowName}:`,
44
+ error
45
+ );
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read history for a flow (for analytics/debugging)
51
+ * @public - For future analytics/debugging features
52
+ */
53
+ export async function readFlowHistory(
54
+ api: ClawdbotPluginApi,
55
+ flowName: string
56
+ ): Promise<Array<FlowSession & { completedAt: number }>> {
57
+ const historyPath = getHistoryPath(api, flowName);
58
+
59
+ try {
60
+ const content = await fs.readFile(historyPath, "utf-8");
61
+ const lines = content.trim().split("\n");
62
+
63
+ return lines
64
+ .filter((line) => line.length > 0)
65
+ .map((line) => JSON.parse(line));
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
68
+ return [];
69
+ }
70
+ api.logger.error(
71
+ `Failed to read history for flow ${flowName}:`,
72
+ error
73
+ );
74
+ throw error;
75
+ }
76
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * In-memory session storage with automatic timeout cleanup
3
+ */
4
+
5
+ import type { FlowSession } from "../types.js";
6
+
7
+ // Session timeout: 30 minutes
8
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
9
+
10
+ // Cleanup interval: 5 minutes
11
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
12
+
13
+ // In-memory session store
14
+ const sessions = new Map<string, FlowSession>();
15
+
16
+ // Cleanup timer
17
+ let cleanupTimer: NodeJS.Timeout | null = null;
18
+
19
+ /**
20
+ * Generate session key
21
+ */
22
+ export function getSessionKey(senderId: string, flowName: string): string {
23
+ return `${senderId}-${flowName}`;
24
+ }
25
+
26
+ /**
27
+ * Create a new session
28
+ */
29
+ export function createSession(params: {
30
+ flowName: string;
31
+ currentStepId: string;
32
+ senderId: string;
33
+ channel: string;
34
+ }): FlowSession {
35
+ const now = Date.now();
36
+ const session: FlowSession = {
37
+ ...params,
38
+ variables: {},
39
+ startedAt: now,
40
+ lastActivityAt: now,
41
+ };
42
+
43
+ const key = getSessionKey(params.senderId, params.flowName);
44
+ sessions.set(key, session);
45
+
46
+ // Start cleanup timer if not already running
47
+ if (!cleanupTimer) {
48
+ cleanupTimer = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
49
+ }
50
+
51
+ return session;
52
+ }
53
+
54
+ /**
55
+ * Get session by key (returns null if expired)
56
+ */
57
+ export function getSession(key: string): FlowSession | null {
58
+ const session = sessions.get(key);
59
+
60
+ if (!session) {
61
+ return null;
62
+ }
63
+
64
+ // Check if expired
65
+ const now = Date.now();
66
+ if (now - session.lastActivityAt > SESSION_TIMEOUT_MS) {
67
+ sessions.delete(key);
68
+ return null;
69
+ }
70
+
71
+ return session;
72
+ }
73
+
74
+ /**
75
+ * Update session (merges partial updates)
76
+ */
77
+ export function updateSession(
78
+ key: string,
79
+ patch: Partial<FlowSession>
80
+ ): FlowSession | null {
81
+ const session = getSession(key);
82
+
83
+ if (!session) {
84
+ return null;
85
+ }
86
+
87
+ const updated: FlowSession = {
88
+ ...session,
89
+ ...patch,
90
+ lastActivityAt: Date.now(),
91
+ // Merge variables instead of replacing
92
+ variables: patch.variables
93
+ ? { ...session.variables, ...patch.variables }
94
+ : session.variables,
95
+ };
96
+
97
+ sessions.set(key, updated);
98
+ return updated;
99
+ }
100
+
101
+ /**
102
+ * Delete session
103
+ */
104
+ export function deleteSession(key: string): void {
105
+ sessions.delete(key);
106
+
107
+ // Stop cleanup timer if no sessions remain
108
+ if (sessions.size === 0 && cleanupTimer) {
109
+ clearInterval(cleanupTimer);
110
+ cleanupTimer = null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Cleanup expired sessions
116
+ */
117
+ function cleanupExpiredSessions(): void {
118
+ const now = Date.now();
119
+ const keysToDelete: string[] = [];
120
+
121
+ for (const [key, session] of sessions.entries()) {
122
+ if (now - session.lastActivityAt > SESSION_TIMEOUT_MS) {
123
+ keysToDelete.push(key);
124
+ }
125
+ }
126
+
127
+ for (const key of keysToDelete) {
128
+ sessions.delete(key);
129
+ }
130
+
131
+ // Stop timer if no sessions remain
132
+ if (sessions.size === 0 && cleanupTimer) {
133
+ clearInterval(cleanupTimer);
134
+ cleanupTimer = null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Get all active sessions (for debugging)
140
+ * @public - For debugging and monitoring
141
+ */
142
+ export function getAllSessions(): FlowSession[] {
143
+ return Array.from(sessions.values());
144
+ }
145
+
146
+ /**
147
+ * Clear all sessions (for testing)
148
+ */
149
+ export function clearAllSessions(): void {
150
+ sessions.clear();
151
+ if (cleanupTimer) {
152
+ clearInterval(cleanupTimer);
153
+ cleanupTimer = null;
154
+ }
155
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Core type definitions for skill-flow plugin
3
+ */
4
+
5
+ export type ValidationType = "number" | "email" | "phone";
6
+
7
+ export interface Button {
8
+ text: string;
9
+ value: string | number;
10
+ next?: string;
11
+ }
12
+
13
+ export interface FlowStep {
14
+ id: string;
15
+ message: string;
16
+ buttons?: Array<Button | string | number>;
17
+ next?: string;
18
+ capture?: string;
19
+ validate?: ValidationType;
20
+ condition?: {
21
+ variable: string;
22
+ equals?: string | number;
23
+ greaterThan?: number;
24
+ lessThan?: number;
25
+ contains?: string;
26
+ next: string;
27
+ };
28
+ }
29
+
30
+ export interface FlowMetadata {
31
+ name: string;
32
+ description: string;
33
+ version: string;
34
+ author?: string;
35
+ steps: FlowStep[];
36
+ triggers?: {
37
+ manual?: boolean;
38
+ cron?: string;
39
+ event?: string;
40
+ };
41
+ }
42
+
43
+ export interface FlowSession {
44
+ flowName: string;
45
+ currentStepId: string;
46
+ senderId: string;
47
+ channel: string;
48
+ variables: Record<string, string | number>;
49
+ startedAt: number;
50
+ lastActivityAt: number;
51
+ }
52
+
53
+ export interface TransitionResult {
54
+ nextStepId?: string;
55
+ variables: Record<string, string | number>;
56
+ complete: boolean;
57
+ error?: string;
58
+ message?: string;
59
+ }
60
+
61
+ export interface ReplyPayload {
62
+ text: string;
63
+ buttons?: Array<{
64
+ text: string;
65
+ callback_data: string;
66
+ }[]>;
67
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Zod validation schemas for flow definitions
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { Button } from "./types.js";
7
+
8
+ // Validation type enum
9
+ const ValidationTypeSchema = z.enum(["number", "email", "phone"]);
10
+
11
+ // Button schema - can be string, number, or full object
12
+ const ButtonValueSchema = z.union([
13
+ z.string(),
14
+ z.number(),
15
+ z.object({
16
+ text: z.string(),
17
+ value: z.union([z.string(), z.number()]),
18
+ next: z.string().optional(),
19
+ }),
20
+ ]);
21
+
22
+ // Conditional branching schema
23
+ const ConditionSchema = z.object({
24
+ variable: z.string(),
25
+ equals: z.union([z.string(), z.number()]).optional(),
26
+ greaterThan: z.number().optional(),
27
+ lessThan: z.number().optional(),
28
+ contains: z.string().optional(),
29
+ next: z.string(),
30
+ });
31
+
32
+ // Flow step schema
33
+ const FlowStepSchema = z.object({
34
+ id: z.string(),
35
+ message: z.string(),
36
+ buttons: z.array(ButtonValueSchema).optional(),
37
+ next: z.string().optional(),
38
+ capture: z.string().optional(),
39
+ validate: ValidationTypeSchema.optional(),
40
+ condition: ConditionSchema.optional(),
41
+ });
42
+
43
+ // Trigger schema
44
+ const TriggerSchema = z
45
+ .object({
46
+ manual: z.boolean().optional(),
47
+ cron: z.string().optional(),
48
+ event: z.string().optional(),
49
+ })
50
+ .optional();
51
+
52
+ // Complete flow metadata schema
53
+ export const FlowMetadataSchema = z.object({
54
+ name: z.string(),
55
+ description: z.string(),
56
+ version: z.string(),
57
+ author: z.string().optional(),
58
+ steps: z.array(FlowStepSchema).min(1),
59
+ triggers: TriggerSchema,
60
+ });
61
+
62
+ /**
63
+ * Normalize button input to Button object
64
+ */
65
+ export function normalizeButton(
66
+ btn: string | number | Button,
67
+ _index: number
68
+ ): Button {
69
+ if (typeof btn === "string") {
70
+ return {
71
+ text: btn,
72
+ value: btn,
73
+ };
74
+ }
75
+ if (typeof btn === "number") {
76
+ return {
77
+ text: String(btn),
78
+ value: btn,
79
+ };
80
+ }
81
+ return btn;
82
+ }
83
+
84
+ /**
85
+ * Validate input based on validation type
86
+ */
87
+ export function validateInput(
88
+ input: string,
89
+ validationType: "number" | "email" | "phone"
90
+ ): { valid: boolean; error?: string } {
91
+ switch (validationType) {
92
+ case "number": {
93
+ const num = Number(input);
94
+ if (isNaN(num)) {
95
+ return { valid: false, error: "Please enter a valid number" };
96
+ }
97
+ return { valid: true };
98
+ }
99
+ case "email": {
100
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
101
+ if (!emailRegex.test(input)) {
102
+ return { valid: false, error: "Please enter a valid email address" };
103
+ }
104
+ return { valid: true };
105
+ }
106
+ case "phone": {
107
+ const phoneRegex = /^\+?[\d\s()-]{10,}$/;
108
+ if (!phoneRegex.test(input)) {
109
+ return {
110
+ valid: false,
111
+ error: "Please enter a valid phone number",
112
+ };
113
+ }
114
+ return { valid: true };
115
+ }
116
+ default:
117
+ return { valid: true };
118
+ }
119
+ }
package/types.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Type declarations for external modules without types
3
+ */
4
+
5
+ declare module "lockfile" {
6
+ export function lock(
7
+ path: string,
8
+ opts: { wait?: number; retries?: number },
9
+ callback: (err: Error | null) => void
10
+ ): void;
11
+
12
+ export function unlock(path: string, callback: (err: Error | null) => void): void;
13
+ }
14
+
15
+ declare module "clawdbot/plugin-sdk" {
16
+ export interface ClawdbotPluginApi {
17
+ logger: {
18
+ info(...args: any[]): void;
19
+ error(...args: any[]): void;
20
+ warn(...args: any[]): void;
21
+ debug(...args: any[]): void;
22
+ };
23
+ runtime: {
24
+ state: {
25
+ resolveStateDir(): string;
26
+ };
27
+ };
28
+ registerCommand(config: {
29
+ name: string;
30
+ description: string;
31
+ acceptsArgs: boolean;
32
+ requireAuth: boolean;
33
+ handler: (args: any) => Promise<any>;
34
+ }): void;
35
+ }
36
+ }