@joshualelon/clawdbot-skill-flow 0.3.1 → 0.4.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 +54 -14
- package/package.json +17 -4
- package/src/hooks/README.md +437 -0
- package/src/hooks/common.ts +225 -0
- package/src/hooks/dynamic-buttons.ts +269 -0
- package/src/hooks/google-sheets.ts +326 -0
- package/src/hooks/index.ts +45 -0
- package/src/hooks/scheduling.ts +259 -0
- package/src/hooks/types.ts +76 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Sheets integration utilities for logging flow data
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { google, sheets_v4 } from "googleapis";
|
|
6
|
+
import type { FlowHooks, FlowSession } from "../types.js";
|
|
7
|
+
import type { SheetsLogOptions, GoogleServiceAccountCredentials, HeaderMode } from "./types.js";
|
|
8
|
+
import { withRetry } from "./common.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a Google Sheets API client with authentication
|
|
12
|
+
*/
|
|
13
|
+
async function createSheetsClient(
|
|
14
|
+
credentials?: GoogleServiceAccountCredentials
|
|
15
|
+
): Promise<sheets_v4.Sheets> {
|
|
16
|
+
// If credentials provided, use service account auth
|
|
17
|
+
if (credentials) {
|
|
18
|
+
const auth = new google.auth.GoogleAuth({
|
|
19
|
+
credentials: {
|
|
20
|
+
client_email: credentials.clientEmail,
|
|
21
|
+
private_key: credentials.privateKey,
|
|
22
|
+
},
|
|
23
|
+
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// GoogleAuth is compatible with the auth parameter expected by google.sheets()
|
|
27
|
+
// The typing is complex due to multiple auth types, but runtime behavior is correct
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
return google.sheets({ version: "v4", auth: auth as any });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Otherwise, use application default credentials (from environment)
|
|
33
|
+
const auth = new google.auth.GoogleAuth({
|
|
34
|
+
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
return google.sheets({ version: "v4", auth: auth as any });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a hook that logs captured variables to Google Sheets.
|
|
43
|
+
* Returns an onCapture hook function.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* export default {
|
|
48
|
+
* onCapture: createSheetsLogger({
|
|
49
|
+
* spreadsheetId: '1ABC...xyz',
|
|
50
|
+
* worksheetName: 'Workouts',
|
|
51
|
+
* columns: ['set1', 'set2', 'set3'],
|
|
52
|
+
* includeMetadata: true
|
|
53
|
+
* })
|
|
54
|
+
* };
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function createSheetsLogger(
|
|
58
|
+
options: SheetsLogOptions
|
|
59
|
+
): NonNullable<FlowHooks["onCapture"]> {
|
|
60
|
+
const {
|
|
61
|
+
spreadsheetId,
|
|
62
|
+
worksheetName = "Sheet1",
|
|
63
|
+
columns,
|
|
64
|
+
includeMetadata = true,
|
|
65
|
+
credentials,
|
|
66
|
+
headerMode = 'append',
|
|
67
|
+
} = options;
|
|
68
|
+
|
|
69
|
+
return async (variable: string, value: string | number, session: FlowSession) => {
|
|
70
|
+
try {
|
|
71
|
+
// Prepare row data
|
|
72
|
+
const row: Record<string, unknown> = {};
|
|
73
|
+
|
|
74
|
+
// Add metadata if requested
|
|
75
|
+
if (includeMetadata) {
|
|
76
|
+
row.timestamp = new Date().toISOString();
|
|
77
|
+
row.userId = session.senderId;
|
|
78
|
+
row.flowName = session.flowName;
|
|
79
|
+
row.channel = session.channel;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Add all session variables (or filtered columns)
|
|
83
|
+
if (columns && columns.length > 0) {
|
|
84
|
+
// Only include specified columns
|
|
85
|
+
for (const col of columns) {
|
|
86
|
+
row[col] = session.variables[col] ?? "";
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// Include all variables
|
|
90
|
+
Object.assign(row, session.variables);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Also include the just-captured variable
|
|
94
|
+
row[variable] = value;
|
|
95
|
+
|
|
96
|
+
// Append to sheet with retry
|
|
97
|
+
await withRetry(
|
|
98
|
+
() => appendToSheet(spreadsheetId, worksheetName, [row], credentials, headerMode),
|
|
99
|
+
{ maxAttempts: 3, delayMs: 1000, backoff: true }
|
|
100
|
+
);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Enhanced error message with context
|
|
103
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
104
|
+
console.error(
|
|
105
|
+
`Failed to log to Google Sheets (spreadsheetId: ${spreadsheetId}, worksheet: ${worksheetName}): ${errorMessage}. ` +
|
|
106
|
+
`Check credentials, permissions, and API quotas. Flow continues without logging.`
|
|
107
|
+
);
|
|
108
|
+
// Don't throw - logging failures shouldn't break the flow
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Low-level utility to append rows to a Google Sheet.
|
|
115
|
+
* Creates the worksheet if it doesn't exist, and adds headers on first write.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* await appendToSheet('1ABC...xyz', 'Workouts', [
|
|
120
|
+
* { date: '2026-01-25', reps: 20, weight: 45 },
|
|
121
|
+
* { date: '2026-01-26', reps: 22, weight: 45 }
|
|
122
|
+
* ], undefined, 'append');
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export async function appendToSheet(
|
|
126
|
+
spreadsheetId: string,
|
|
127
|
+
worksheetName: string,
|
|
128
|
+
rows: Array<Record<string, unknown>>,
|
|
129
|
+
credentials?: GoogleServiceAccountCredentials,
|
|
130
|
+
headerMode: HeaderMode = 'append'
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
if (rows.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sheets = await createSheetsClient(credentials);
|
|
137
|
+
|
|
138
|
+
// Ensure worksheet exists
|
|
139
|
+
await ensureWorksheetExists(sheets, spreadsheetId, worksheetName);
|
|
140
|
+
|
|
141
|
+
// Get existing data to check if we need to write headers
|
|
142
|
+
const existingData = await sheets.spreadsheets.values.get({
|
|
143
|
+
spreadsheetId,
|
|
144
|
+
range: `${worksheetName}!A1:ZZ1`, // Check first row for headers
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const existingHeaders = existingData.data.values?.[0] || [];
|
|
148
|
+
const rowKeys = Object.keys(rows[0]!);
|
|
149
|
+
|
|
150
|
+
// Handle headers based on mode
|
|
151
|
+
if (existingHeaders.length === 0) {
|
|
152
|
+
// Empty sheet - always write headers
|
|
153
|
+
await sheets.spreadsheets.values.update({
|
|
154
|
+
spreadsheetId,
|
|
155
|
+
range: `${worksheetName}!A1`,
|
|
156
|
+
valueInputOption: "RAW",
|
|
157
|
+
requestBody: {
|
|
158
|
+
values: [rowKeys],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} else if (!arraysEqual(existingHeaders, rowKeys)) {
|
|
162
|
+
// Headers don't match - apply mode
|
|
163
|
+
switch (headerMode) {
|
|
164
|
+
case 'strict':
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Header mismatch in sheet "${worksheetName}". ` +
|
|
167
|
+
`Expected: ${existingHeaders.join(', ')}. ` +
|
|
168
|
+
`Got: ${rowKeys.join(', ')}. ` +
|
|
169
|
+
`Set headerMode to 'append' or 'overwrite' to handle this.`
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
case 'append': {
|
|
173
|
+
// Find new columns not in existing headers
|
|
174
|
+
const newColumns = rowKeys.filter(key => !existingHeaders.includes(key));
|
|
175
|
+
if (newColumns.length > 0) {
|
|
176
|
+
// Append new columns to the right
|
|
177
|
+
const updatedHeaders = [...existingHeaders, ...newColumns];
|
|
178
|
+
await sheets.spreadsheets.values.update({
|
|
179
|
+
spreadsheetId,
|
|
180
|
+
range: `${worksheetName}!A1`,
|
|
181
|
+
valueInputOption: "RAW",
|
|
182
|
+
requestBody: {
|
|
183
|
+
values: [updatedHeaders],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'overwrite':
|
|
191
|
+
// Replace headers completely
|
|
192
|
+
await sheets.spreadsheets.values.update({
|
|
193
|
+
spreadsheetId,
|
|
194
|
+
range: `${worksheetName}!A1`,
|
|
195
|
+
valueInputOption: "RAW",
|
|
196
|
+
requestBody: {
|
|
197
|
+
values: [rowKeys],
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Convert rows to 2D array for Sheets API
|
|
205
|
+
const values = rows.map((row) => rowKeys.map((key) => row[key] ?? ""));
|
|
206
|
+
|
|
207
|
+
// Append data
|
|
208
|
+
await sheets.spreadsheets.values.append({
|
|
209
|
+
spreadsheetId,
|
|
210
|
+
range: `${worksheetName}!A:A`, // Append to column A (auto-detects next row)
|
|
211
|
+
valueInputOption: "RAW",
|
|
212
|
+
requestBody: {
|
|
213
|
+
values,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Query historical data from a Google Sheet.
|
|
220
|
+
* Useful for calculating statistics or generating dynamic buttons.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const history = await querySheetHistory('1ABC...xyz', 'Workouts', {
|
|
225
|
+
* flowName: 'pushups',
|
|
226
|
+
* userId: 'user123',
|
|
227
|
+
* dateRange: [new Date('2026-01-01'), new Date()]
|
|
228
|
+
* });
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export async function querySheetHistory(
|
|
232
|
+
spreadsheetId: string,
|
|
233
|
+
worksheetName: string,
|
|
234
|
+
filters?: {
|
|
235
|
+
flowName?: string;
|
|
236
|
+
userId?: string;
|
|
237
|
+
dateRange?: [Date, Date];
|
|
238
|
+
},
|
|
239
|
+
credentials?: GoogleServiceAccountCredentials
|
|
240
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
241
|
+
const sheets = await createSheetsClient(credentials);
|
|
242
|
+
|
|
243
|
+
// Get all data from worksheet
|
|
244
|
+
const response = await sheets.spreadsheets.values.get({
|
|
245
|
+
spreadsheetId,
|
|
246
|
+
range: `${worksheetName}!A:ZZ`,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const values = response.data.values || [];
|
|
250
|
+
if (values.length === 0) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// First row is headers
|
|
255
|
+
const headers = values[0] as string[];
|
|
256
|
+
const rows = values.slice(1);
|
|
257
|
+
|
|
258
|
+
// Convert to array of objects
|
|
259
|
+
let data = rows.map((row: unknown[]) => {
|
|
260
|
+
const obj: Record<string, unknown> = {};
|
|
261
|
+
headers.forEach((header, index) => {
|
|
262
|
+
obj[header] = row[index] ?? "";
|
|
263
|
+
});
|
|
264
|
+
return obj;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Apply filters
|
|
268
|
+
if (filters) {
|
|
269
|
+
if (filters.flowName) {
|
|
270
|
+
data = data.filter((row: Record<string, unknown>) => row.flowName === filters.flowName);
|
|
271
|
+
}
|
|
272
|
+
if (filters.userId) {
|
|
273
|
+
data = data.filter((row: Record<string, unknown>) => row.userId === filters.userId);
|
|
274
|
+
}
|
|
275
|
+
if (filters.dateRange) {
|
|
276
|
+
const [start, end] = filters.dateRange;
|
|
277
|
+
data = data.filter((row: Record<string, unknown>) => {
|
|
278
|
+
const timestamp = new Date(row.timestamp as string);
|
|
279
|
+
return timestamp >= start && timestamp <= end;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Ensure a worksheet exists, create it if not
|
|
289
|
+
*/
|
|
290
|
+
async function ensureWorksheetExists(
|
|
291
|
+
sheets: sheets_v4.Sheets,
|
|
292
|
+
spreadsheetId: string,
|
|
293
|
+
worksheetName: string
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
// Get spreadsheet metadata
|
|
296
|
+
const spreadsheet = await sheets.spreadsheets.get({ spreadsheetId });
|
|
297
|
+
const sheetExists = spreadsheet.data.sheets?.some(
|
|
298
|
+
(sheet: sheets_v4.Schema$Sheet) => sheet.properties?.title === worksheetName
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (!sheetExists) {
|
|
302
|
+
// Create new worksheet
|
|
303
|
+
await sheets.spreadsheets.batchUpdate({
|
|
304
|
+
spreadsheetId,
|
|
305
|
+
requestBody: {
|
|
306
|
+
requests: [
|
|
307
|
+
{
|
|
308
|
+
addSheet: {
|
|
309
|
+
properties: {
|
|
310
|
+
title: worksheetName,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if two arrays are equal (for header comparison)
|
|
322
|
+
*/
|
|
323
|
+
function arraysEqual<T>(a: T[], b: T[]): boolean {
|
|
324
|
+
if (a.length !== b.length) return false;
|
|
325
|
+
return a.every((val, index) => val === b[index]);
|
|
326
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks Utility Library
|
|
3
|
+
*
|
|
4
|
+
* Optional utilities for common workflow patterns.
|
|
5
|
+
* Import only what you need:
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // Import specific utilities
|
|
10
|
+
* import { createSheetsLogger } from '@joshualelon/clawdbot-skill-flow/hooks/google-sheets';
|
|
11
|
+
* import { createDynamicButtons } from '@joshualelon/clawdbot-skill-flow/hooks/dynamic-buttons';
|
|
12
|
+
*
|
|
13
|
+
* // Or import from main hooks export
|
|
14
|
+
* import { composeHooks, withRetry } from '@joshualelon/clawdbot-skill-flow/hooks';
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Re-export types
|
|
19
|
+
export type * from "./types.js";
|
|
20
|
+
|
|
21
|
+
// Re-export common utilities
|
|
22
|
+
export {
|
|
23
|
+
composeHooks,
|
|
24
|
+
withRetry,
|
|
25
|
+
validateEmail,
|
|
26
|
+
validateNumber,
|
|
27
|
+
validatePhone,
|
|
28
|
+
whenCondition,
|
|
29
|
+
debounceHook,
|
|
30
|
+
throttleHook,
|
|
31
|
+
} from "./common.js";
|
|
32
|
+
|
|
33
|
+
// Re-export Google Sheets utilities
|
|
34
|
+
export { createSheetsLogger, appendToSheet, querySheetHistory } from "./google-sheets.js";
|
|
35
|
+
|
|
36
|
+
// Re-export dynamic buttons utilities
|
|
37
|
+
export { createDynamicButtons, getRecentAverage, generateButtonRange } from "./dynamic-buttons.js";
|
|
38
|
+
|
|
39
|
+
// Re-export scheduling utilities
|
|
40
|
+
export {
|
|
41
|
+
createScheduler,
|
|
42
|
+
scheduleNextSession,
|
|
43
|
+
checkCalendarConflicts,
|
|
44
|
+
findNextAvailableSlot,
|
|
45
|
+
} from "./scheduling.js";
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduling utilities for recurring workflows
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { google, calendar_v3 } from "googleapis";
|
|
6
|
+
import type { FlowHooks, FlowSession } from "../types.js";
|
|
7
|
+
import type { ScheduleConfig, GoogleServiceAccountCredentials } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a hook that schedules the next workflow session after completion.
|
|
11
|
+
* Returns an onFlowComplete hook function.
|
|
12
|
+
*
|
|
13
|
+
* Note: Scheduling uses local server time. Ensure your server is in the correct timezone.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* export default {
|
|
18
|
+
* onFlowComplete: createScheduler({
|
|
19
|
+
* days: ['mon', 'wed', 'fri'],
|
|
20
|
+
* time: '08:00',
|
|
21
|
+
* calendarCheck: true,
|
|
22
|
+
* calendarId: 'primary'
|
|
23
|
+
* })
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function createScheduler(
|
|
28
|
+
config: ScheduleConfig
|
|
29
|
+
): NonNullable<FlowHooks["onFlowComplete"]> {
|
|
30
|
+
const {
|
|
31
|
+
days = ["mon", "wed", "fri"],
|
|
32
|
+
time = "08:00",
|
|
33
|
+
calendarId = "primary",
|
|
34
|
+
credentials,
|
|
35
|
+
calendarCheck = false,
|
|
36
|
+
rescheduleOnConflict = false,
|
|
37
|
+
} = config;
|
|
38
|
+
|
|
39
|
+
return async (session: FlowSession): Promise<void> => {
|
|
40
|
+
try {
|
|
41
|
+
// Calculate next scheduled date
|
|
42
|
+
const nextDate = findNextScheduledDate(days, time);
|
|
43
|
+
|
|
44
|
+
if (!nextDate) {
|
|
45
|
+
console.warn("Unable to calculate next scheduled date");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check for calendar conflicts if enabled
|
|
50
|
+
if (calendarCheck) {
|
|
51
|
+
const hasConflict = await checkCalendarConflicts(nextDate, 60, { calendarId, credentials });
|
|
52
|
+
|
|
53
|
+
if (hasConflict && rescheduleOnConflict) {
|
|
54
|
+
// Find alternative slot
|
|
55
|
+
const alternativeDate = await findNextAvailableSlot(
|
|
56
|
+
[nextDate],
|
|
57
|
+
60, // 60 minutes duration
|
|
58
|
+
{ calendarId, credentials }
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (alternativeDate) {
|
|
62
|
+
await scheduleNextSession(
|
|
63
|
+
session.flowName,
|
|
64
|
+
session.senderId,
|
|
65
|
+
alternativeDate,
|
|
66
|
+
{ calendarId, credentials }
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
} else if (hasConflict) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`Calendar conflict detected for ${nextDate.toISOString()}, not scheduling`
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Schedule next session
|
|
79
|
+
await scheduleNextSession(session.flowName, session.senderId, nextDate, { calendarId, credentials });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Enhanced error message with context
|
|
82
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
83
|
+
console.error(
|
|
84
|
+
`Failed to schedule next session for flow "${session.flowName}": ${errorMessage}. ` +
|
|
85
|
+
`Check calendar credentials and permissions.`
|
|
86
|
+
);
|
|
87
|
+
// Don't throw - scheduling failures shouldn't break the flow
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Schedule the next workflow session for a user by creating a Google Calendar event.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* await scheduleNextSession('pushups', 'user123', new Date('2026-01-27T08:00:00Z'), {
|
|
98
|
+
* calendarId: 'primary',
|
|
99
|
+
* credentials: { clientEmail: '...', privateKey: '...' }
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export async function scheduleNextSession(
|
|
104
|
+
flowName: string,
|
|
105
|
+
userId: string,
|
|
106
|
+
nextDate: Date,
|
|
107
|
+
options?: { calendarId?: string; credentials?: GoogleServiceAccountCredentials }
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const calendar = await createCalendarClient(options?.credentials);
|
|
110
|
+
const calendarId = options?.calendarId || 'primary';
|
|
111
|
+
|
|
112
|
+
// Calculate end time (default 30 min duration)
|
|
113
|
+
const endDate = new Date(nextDate);
|
|
114
|
+
endDate.setMinutes(endDate.getMinutes() + 30);
|
|
115
|
+
|
|
116
|
+
await calendar.events.insert({
|
|
117
|
+
calendarId,
|
|
118
|
+
requestBody: {
|
|
119
|
+
summary: `${flowName} Workflow`,
|
|
120
|
+
start: { dateTime: nextDate.toISOString() },
|
|
121
|
+
end: { dateTime: endDate.toISOString() },
|
|
122
|
+
description: `Scheduled session for ${flowName} flow (user: ${userId})`
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if there are calendar conflicts at the given time by querying Google Calendar.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* const hasConflict = await checkCalendarConflicts(
|
|
133
|
+
* new Date('2026-01-27T08:00:00Z'),
|
|
134
|
+
* 60,
|
|
135
|
+
* { calendarId: 'primary', credentials: { ... } }
|
|
136
|
+
* );
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export async function checkCalendarConflicts(
|
|
140
|
+
dateTime: Date,
|
|
141
|
+
duration = 60,
|
|
142
|
+
options?: { calendarId?: string; credentials?: GoogleServiceAccountCredentials }
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
const calendar = await createCalendarClient(options?.credentials);
|
|
145
|
+
const calendarId = options?.calendarId || 'primary';
|
|
146
|
+
|
|
147
|
+
const endTime = new Date(dateTime);
|
|
148
|
+
endTime.setMinutes(endTime.getMinutes() + duration);
|
|
149
|
+
|
|
150
|
+
const response = await calendar.events.list({
|
|
151
|
+
calendarId,
|
|
152
|
+
timeMin: dateTime.toISOString(),
|
|
153
|
+
timeMax: endTime.toISOString(),
|
|
154
|
+
singleEvents: true
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return (response.data.items?.length || 0) > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find the next available time slot from a list of preferred dates by checking Google Calendar.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* const nextSlot = await findNextAvailableSlot(
|
|
166
|
+
* [new Date('2026-01-27T08:00:00Z')],
|
|
167
|
+
* 60,
|
|
168
|
+
* { calendarId: 'primary', credentials: { ... } }
|
|
169
|
+
* );
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export async function findNextAvailableSlot(
|
|
173
|
+
preferredDates: Date[],
|
|
174
|
+
duration: number,
|
|
175
|
+
options?: { calendarId?: string; credentials?: GoogleServiceAccountCredentials }
|
|
176
|
+
): Promise<Date | null> {
|
|
177
|
+
for (const date of preferredDates) {
|
|
178
|
+
const hasConflict = await checkCalendarConflicts(date, duration, options);
|
|
179
|
+
if (!hasConflict) {
|
|
180
|
+
return date;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null; // No free slots found
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Find the next scheduled date based on day-of-week and time preferences.
|
|
188
|
+
* Uses local server time.
|
|
189
|
+
*/
|
|
190
|
+
function findNextScheduledDate(
|
|
191
|
+
days: string[],
|
|
192
|
+
time: string
|
|
193
|
+
): Date | null {
|
|
194
|
+
const now = new Date();
|
|
195
|
+
|
|
196
|
+
// Parse target time (HH:MM format)
|
|
197
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
198
|
+
if (hours === undefined || minutes === undefined || Number.isNaN(hours) || Number.isNaN(minutes)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Map day names to numbers (0=Sunday, 1=Monday, etc.)
|
|
203
|
+
const dayMap: Record<string, number> = {
|
|
204
|
+
sun: 0,
|
|
205
|
+
mon: 1,
|
|
206
|
+
tue: 2,
|
|
207
|
+
wed: 3,
|
|
208
|
+
thu: 4,
|
|
209
|
+
fri: 5,
|
|
210
|
+
sat: 6,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const targetDays = days.map((d) => dayMap[d.toLowerCase()]).filter((d): d is number => d !== undefined);
|
|
214
|
+
|
|
215
|
+
if (targetDays.length === 0) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Find next occurrence
|
|
220
|
+
for (let daysAhead = 1; daysAhead <= 14; daysAhead++) {
|
|
221
|
+
const candidate = new Date(now);
|
|
222
|
+
candidate.setDate(candidate.getDate() + daysAhead);
|
|
223
|
+
candidate.setHours(hours, minutes, 0, 0);
|
|
224
|
+
|
|
225
|
+
const dayOfWeek = candidate.getDay();
|
|
226
|
+
|
|
227
|
+
if (targetDays.includes(dayOfWeek)) {
|
|
228
|
+
return candidate;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a Google Calendar API client with authentication
|
|
237
|
+
*/
|
|
238
|
+
async function createCalendarClient(
|
|
239
|
+
credentials?: GoogleServiceAccountCredentials
|
|
240
|
+
): Promise<calendar_v3.Calendar> {
|
|
241
|
+
if (credentials) {
|
|
242
|
+
const auth = new google.auth.GoogleAuth({
|
|
243
|
+
credentials: {
|
|
244
|
+
client_email: credentials.clientEmail,
|
|
245
|
+
private_key: credentials.privateKey,
|
|
246
|
+
},
|
|
247
|
+
scopes: ['https://www.googleapis.com/auth/calendar'],
|
|
248
|
+
});
|
|
249
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
250
|
+
return google.calendar({ version: 'v3', auth: auth as any });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Use application default credentials
|
|
254
|
+
const auth = new google.auth.GoogleAuth({
|
|
255
|
+
scopes: ['https://www.googleapis.com/auth/calendar'],
|
|
256
|
+
});
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
|
+
return google.calendar({ version: 'v3', auth: auth as any });
|
|
259
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for hooks utility library
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FlowSession, FlowHooks } from "../types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Google Sheets configuration
|
|
9
|
+
*/
|
|
10
|
+
export interface SheetsConfig {
|
|
11
|
+
spreadsheetId: string;
|
|
12
|
+
worksheetName?: string;
|
|
13
|
+
credentials?: GoogleServiceAccountCredentials;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GoogleServiceAccountCredentials {
|
|
17
|
+
clientEmail: string;
|
|
18
|
+
privateKey: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HeaderMode = 'strict' | 'append' | 'overwrite';
|
|
22
|
+
|
|
23
|
+
export interface SheetsLogOptions {
|
|
24
|
+
spreadsheetId: string;
|
|
25
|
+
worksheetName?: string;
|
|
26
|
+
columns?: string[]; // Variable names to log
|
|
27
|
+
includeMetadata?: boolean; // Add timestamp, userId, flowName
|
|
28
|
+
credentials?: GoogleServiceAccountCredentials;
|
|
29
|
+
headerMode?: HeaderMode; // How to handle header mismatches (default: 'append')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Dynamic buttons configuration
|
|
34
|
+
*/
|
|
35
|
+
export interface DynamicButtonsConfig {
|
|
36
|
+
spreadsheetId?: string; // Source for history (Google Sheets)
|
|
37
|
+
historyFile?: string; // Or use local .jsonl
|
|
38
|
+
variable: string; // Which variable to generate buttons for
|
|
39
|
+
strategy: "centered" | "progressive" | "range";
|
|
40
|
+
buttonCount?: number; // How many buttons (default: 5)
|
|
41
|
+
step?: number; // Increment between buttons
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ButtonStrategy = "centered" | "progressive" | "range";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scheduling configuration
|
|
48
|
+
*/
|
|
49
|
+
export interface ScheduleConfig {
|
|
50
|
+
days?: string[]; // ['mon', 'wed', 'fri']
|
|
51
|
+
time?: string; // '08:00' (uses local server time)
|
|
52
|
+
calendarId?: string; // Google Calendar ID (default: 'primary')
|
|
53
|
+
credentials?: GoogleServiceAccountCredentials; // For calendar API access
|
|
54
|
+
calendarCheck?: boolean; // Check for conflicts
|
|
55
|
+
rescheduleOnConflict?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hook composer type - accepts multiple hooks and merges them
|
|
60
|
+
*/
|
|
61
|
+
export type HookFunction<T extends keyof FlowHooks> = NonNullable<FlowHooks[T]>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Condition function for conditional hooks
|
|
65
|
+
*/
|
|
66
|
+
export type ConditionFunction = (session: FlowSession) => boolean | Promise<boolean>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Retry configuration
|
|
70
|
+
*/
|
|
71
|
+
export interface RetryOptions {
|
|
72
|
+
maxAttempts?: number;
|
|
73
|
+
delayMs?: number;
|
|
74
|
+
backoff?: boolean; // Use exponential backoff
|
|
75
|
+
maxDelayMs?: number; // Maximum delay cap (default: 30000ms)
|
|
76
|
+
}
|