botinabox 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.5.1] — 2026-04-04
10
+
11
+ ### Fixed
12
+
13
+ - **cron-parser ESM import** — cron-parser v4 is CommonJS-only; fixed named import to default import (`import cronParser from "cron-parser"`).
14
+
15
+ ## [0.5.0] — 2026-04-04
16
+
17
+ ### Added
18
+
19
+ - **Connector interface** — Generic `Connector<T>` abstraction for external service integrations (Gmail, Calendar, Trello, Jira, Salesforce, etc.). Pull-based `sync()` returns typed records; optional `push()` writes back. Connectors produce data — consumers decide where to store it. New `connectors` config key in `BotConfig`.
20
+ - **Google connectors** — `GoogleGmailConnector` and `GoogleCalendarConnector` implementing `Connector<EmailRecord>` and `Connector<CalendarEventRecord>`. Incremental sync (Gmail historyId, Calendar syncToken), full sync with pagination, email sending via `push()`. OAuth2 helpers with callback-based token persistence. Exported from `botinabox/google`. `googleapis` as optional peer dependency.
21
+ - **Scheduler** — Database-backed `Scheduler` class with `schedules` core table. Supports recurring (cron expressions via `cron-parser`) and one-time schedules. Hook-based actions: when a schedule fires, it emits the configured action as a hook event. Methods: `register()`, `update()`, `unregister()`, `list()`, `tick()`.
22
+ - **`schedules` core table** — id, name, type (recurring/one_time), cron, run_at, timezone, enabled, action, action_config, last_fired_at, next_fire_at.
23
+
24
+ ### Deprecated
25
+
26
+ - **HeartbeatScheduler** — Replaced by `Scheduler`. HeartbeatScheduler uses in-memory `setInterval` which loses state on restart. Kept for backward compatibility but marked `@deprecated`.
27
+
28
+ ### Dependencies
29
+
30
+ - Added `cron-parser` ^4.9.0.
31
+ - Added `googleapis` >=140.0.0 as optional peer dependency.
32
+
9
33
  ## [0.3.0] — 2026-04-03
10
34
 
11
35
  ### Added
@@ -0,0 +1,63 @@
1
+ /** Connector types — generic external service integrations. */
2
+ interface ConnectorMeta {
3
+ displayName: string;
4
+ /** Provider identifier, e.g. "google", "trello", "jira", "salesforce" */
5
+ provider: string;
6
+ /** Data type this connector handles, e.g. "email", "calendar", "board", "crm" */
7
+ dataType: string;
8
+ }
9
+ interface SyncOptions {
10
+ /** Only sync records after this ISO 8601 timestamp */
11
+ since?: string;
12
+ /** Provider-specific incremental sync token */
13
+ cursor?: string;
14
+ /** Maximum number of records to fetch */
15
+ limit?: number;
16
+ /** Provider-specific query filters */
17
+ filters?: Record<string, unknown>;
18
+ }
19
+ interface SyncResult<T = Record<string, unknown>> {
20
+ /** Typed records produced by the connector — consumer decides where to store */
21
+ records: T[];
22
+ /** Next incremental sync token (persist for future calls) */
23
+ cursor?: string;
24
+ /** Whether more records are available (pagination) */
25
+ hasMore: boolean;
26
+ /** Errors encountered during sync (non-fatal per-record failures) */
27
+ errors: Array<{
28
+ id?: string;
29
+ error: string;
30
+ }>;
31
+ }
32
+ interface PushResult {
33
+ success: boolean;
34
+ externalId?: string;
35
+ error?: string;
36
+ }
37
+ type ConnectorConfig = Record<string, unknown>;
38
+ /**
39
+ * Generic connector interface for external service integrations.
40
+ *
41
+ * Connectors pull and optionally push data to/from external services
42
+ * (Gmail, Calendar, Trello, Jira, Salesforce, etc.). They produce
43
+ * typed records — the consuming application decides where to store them.
44
+ *
45
+ * @typeParam T - The record type this connector produces/consumes.
46
+ */
47
+ interface Connector<T = Record<string, unknown>> {
48
+ readonly id: string;
49
+ readonly meta: ConnectorMeta;
50
+ connect(config: ConnectorConfig): Promise<void>;
51
+ disconnect(): Promise<void>;
52
+ healthCheck(): Promise<{
53
+ ok: boolean;
54
+ account?: string;
55
+ error?: string;
56
+ }>;
57
+ /** Pull records from external source */
58
+ sync(options?: SyncOptions): Promise<SyncResult<T>>;
59
+ /** Push a record to external source (optional) */
60
+ push?(payload: T): Promise<PushResult>;
61
+ }
62
+
63
+ export type { ConnectorConfig as C, PushResult as P, SyncOptions as S, Connector as a, ConnectorMeta as b, SyncResult as c };
@@ -0,0 +1,190 @@
1
+ import { C as ConnectorConfig, a as Connector, b as ConnectorMeta, S as SyncOptions, c as SyncResult, P as PushResult } from '../../connector-DDahQw-2.js';
2
+
3
+ /** Google connector types. */
4
+
5
+ interface GoogleOAuthConfig {
6
+ clientId: string;
7
+ clientSecret: string;
8
+ redirectUri: string;
9
+ }
10
+ interface GoogleTokens {
11
+ access_token: string;
12
+ refresh_token?: string;
13
+ expiry_date?: number;
14
+ token_type: string;
15
+ }
16
+ interface GoogleConnectorConfig extends ConnectorConfig {
17
+ /** Google account email */
18
+ account: string;
19
+ oauth: GoogleOAuthConfig;
20
+ scopes: string[];
21
+ }
22
+ interface EmailAddress {
23
+ name?: string;
24
+ email: string;
25
+ }
26
+ interface EmailRecord {
27
+ gmailId: string;
28
+ threadId: string;
29
+ account: string;
30
+ subject: string;
31
+ from: EmailAddress;
32
+ to: EmailAddress[];
33
+ cc: EmailAddress[];
34
+ bcc: EmailAddress[];
35
+ /** ISO 8601 timestamp */
36
+ date: string;
37
+ snippet: string;
38
+ body?: string;
39
+ labels: string[];
40
+ isRead: boolean;
41
+ }
42
+ interface CalendarAttendee {
43
+ email: string;
44
+ displayName?: string;
45
+ responseStatus?: string;
46
+ }
47
+ interface CalendarEventRecord {
48
+ googleEventId: string;
49
+ calendarId: string;
50
+ account: string;
51
+ title: string;
52
+ description?: string;
53
+ location?: string;
54
+ /** ISO 8601 timestamp */
55
+ startAt: string;
56
+ /** ISO 8601 timestamp */
57
+ endAt: string;
58
+ allDay: boolean;
59
+ timezone?: string;
60
+ status: string;
61
+ organizerEmail: string;
62
+ attendees: CalendarAttendee[];
63
+ recurrence?: string[];
64
+ htmlLink?: string;
65
+ }
66
+
67
+ /**
68
+ * Google OAuth2 helpers.
69
+ *
70
+ * Uses dynamic `import('googleapis')` so the package is only required
71
+ * at runtime by consumers who actually use the Google connectors.
72
+ */
73
+
74
+ type OAuth2Client = any;
75
+ /**
76
+ * Create a Google OAuth2 client from app credentials.
77
+ */
78
+ declare function createOAuth2Client(config: GoogleOAuthConfig): Promise<OAuth2Client>;
79
+ /**
80
+ * Generate the consent screen URL for the given scopes.
81
+ */
82
+ declare function getAuthUrl(client: OAuth2Client, scopes: string[]): string;
83
+ /**
84
+ * Exchange an authorization code for tokens.
85
+ */
86
+ declare function exchangeCode(client: OAuth2Client, code: string): Promise<GoogleTokens>;
87
+ /**
88
+ * Load persisted tokens via a generic getter callback.
89
+ *
90
+ * @param getter Reads a string value by key (e.g. SecretStore.get)
91
+ * @param accountKey Unique key prefix for this account
92
+ */
93
+ declare function loadTokens(getter: (key: string) => Promise<string | null>, accountKey: string): Promise<GoogleTokens | null>;
94
+ /**
95
+ * Persist tokens via a generic setter callback.
96
+ *
97
+ * @param setter Writes a string value by key (e.g. SecretStore.set)
98
+ * @param accountKey Unique key prefix for this account
99
+ * @param tokens Tokens to persist
100
+ */
101
+ declare function saveTokens(setter: (key: string, value: string) => Promise<void>, accountKey: string, tokens: GoogleTokens): Promise<void>;
102
+ /**
103
+ * Refresh the access token if it has expired (or is about to within 60 s).
104
+ *
105
+ * If the token was refreshed and a `saver` callback is provided, the new
106
+ * tokens are persisted automatically.
107
+ */
108
+ declare function refreshIfNeeded(client: OAuth2Client, tokens: GoogleTokens, saver?: (tokens: GoogleTokens) => Promise<void>): Promise<GoogleTokens>;
109
+
110
+ /**
111
+ * Gmail connector — pulls email metadata and optionally sends mail.
112
+ *
113
+ * Produces `EmailRecord` objects. Does NOT write to any database table;
114
+ * the consuming application decides how to store records.
115
+ */
116
+
117
+ interface GmailConnectorOpts {
118
+ /** Load persisted tokens for a given account key. */
119
+ tokenLoader: (key: string) => Promise<string | null>;
120
+ /** Persist tokens for a given account key. */
121
+ tokenSaver: (key: string, value: string) => Promise<void>;
122
+ }
123
+ declare class GoogleGmailConnector implements Connector<EmailRecord> {
124
+ readonly id = "google-gmail";
125
+ readonly meta: ConnectorMeta;
126
+ private tokenLoader;
127
+ private tokenSaver;
128
+ private client;
129
+ private config;
130
+ private tokens;
131
+ private gmail;
132
+ constructor(opts: GmailConnectorOpts);
133
+ connect(config: GoogleConnectorConfig): Promise<void>;
134
+ disconnect(): Promise<void>;
135
+ healthCheck(): Promise<{
136
+ ok: boolean;
137
+ account?: string;
138
+ error?: string;
139
+ }>;
140
+ sync(options?: SyncOptions): Promise<SyncResult<EmailRecord>>;
141
+ /** Incremental sync using Gmail history API. */
142
+ private syncIncremental;
143
+ /** Full sync — list messages and fetch each one. */
144
+ private syncFull;
145
+ push(payload: EmailRecord): Promise<PushResult>;
146
+ private ensureConnected;
147
+ /** Fetch a single message by ID and parse into an EmailRecord. */
148
+ private fetchMessage;
149
+ }
150
+
151
+ /**
152
+ * Google Calendar connector — pulls calendar events.
153
+ *
154
+ * Produces `CalendarEventRecord` objects. Does NOT write to any database
155
+ * table; the consuming application decides how to store records.
156
+ */
157
+
158
+ interface CalendarConnectorOpts {
159
+ /** Load persisted tokens for a given account key. */
160
+ tokenLoader: (key: string) => Promise<string | null>;
161
+ /** Persist tokens for a given account key. */
162
+ tokenSaver: (key: string, value: string) => Promise<void>;
163
+ }
164
+ declare class GoogleCalendarConnector implements Connector<CalendarEventRecord> {
165
+ readonly id = "google-calendar";
166
+ readonly meta: ConnectorMeta;
167
+ private tokenLoader;
168
+ private tokenSaver;
169
+ private client;
170
+ private config;
171
+ private tokens;
172
+ private calendar;
173
+ constructor(opts: CalendarConnectorOpts);
174
+ connect(config: GoogleConnectorConfig): Promise<void>;
175
+ disconnect(): Promise<void>;
176
+ healthCheck(): Promise<{
177
+ ok: boolean;
178
+ account?: string;
179
+ error?: string;
180
+ }>;
181
+ sync(options?: SyncOptions): Promise<SyncResult<CalendarEventRecord>>;
182
+ /** Incremental sync using Calendar syncToken. */
183
+ private syncIncremental;
184
+ /** Full sync using timeMin. */
185
+ private syncFull;
186
+ private ensureConnected;
187
+ private mapEvent;
188
+ }
189
+
190
+ export { type CalendarAttendee, type CalendarConnectorOpts, type CalendarEventRecord, type EmailAddress, type EmailRecord, type GmailConnectorOpts, GoogleCalendarConnector, type GoogleConnectorConfig, GoogleGmailConnector, type GoogleOAuthConfig, type GoogleTokens, createOAuth2Client, exchangeCode, getAuthUrl, loadTokens, refreshIfNeeded, saveTokens };
@@ -0,0 +1,487 @@
1
+ // src/connectors/google/oauth.ts
2
+ var _google;
3
+ async function getGoogle() {
4
+ if (!_google) {
5
+ try {
6
+ const mod = await import("googleapis");
7
+ _google = mod.google;
8
+ } catch {
9
+ throw new Error(
10
+ "googleapis is required for Google connectors. Install it: npm install googleapis"
11
+ );
12
+ }
13
+ }
14
+ return _google;
15
+ }
16
+ async function createOAuth2Client(config) {
17
+ const google = await getGoogle();
18
+ return new google.auth.OAuth2(
19
+ config.clientId,
20
+ config.clientSecret,
21
+ config.redirectUri
22
+ );
23
+ }
24
+ function getAuthUrl(client, scopes) {
25
+ return client.generateAuthUrl({
26
+ access_type: "offline",
27
+ prompt: "consent",
28
+ scope: scopes
29
+ });
30
+ }
31
+ async function exchangeCode(client, code) {
32
+ const { tokens } = await client.getToken(code);
33
+ return tokens;
34
+ }
35
+ async function loadTokens(getter, accountKey) {
36
+ const raw = await getter(`google_tokens:${accountKey}`);
37
+ if (!raw) return null;
38
+ try {
39
+ return JSON.parse(raw);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ async function saveTokens(setter, accountKey, tokens) {
45
+ await setter(`google_tokens:${accountKey}`, JSON.stringify(tokens));
46
+ }
47
+ async function refreshIfNeeded(client, tokens, saver) {
48
+ const buffer = 6e4;
49
+ const isExpired = tokens.expiry_date != null && Date.now() >= tokens.expiry_date - buffer;
50
+ if (!isExpired) return tokens;
51
+ client.setCredentials(tokens);
52
+ const { credentials } = await client.refreshAccessToken();
53
+ const refreshed = {
54
+ access_token: credentials.access_token,
55
+ refresh_token: credentials.refresh_token ?? tokens.refresh_token,
56
+ expiry_date: credentials.expiry_date ?? void 0,
57
+ token_type: credentials.token_type ?? "Bearer"
58
+ };
59
+ if (saver) {
60
+ await saver(refreshed);
61
+ }
62
+ return refreshed;
63
+ }
64
+
65
+ // src/connectors/google/gmail-connector.ts
66
+ var GoogleGmailConnector = class {
67
+ id = "google-gmail";
68
+ meta = {
69
+ displayName: "Google Gmail",
70
+ provider: "google",
71
+ dataType: "email"
72
+ };
73
+ tokenLoader;
74
+ tokenSaver;
75
+ client = null;
76
+ config = null;
77
+ tokens = null;
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ gmail = null;
80
+ constructor(opts) {
81
+ this.tokenLoader = opts.tokenLoader;
82
+ this.tokenSaver = opts.tokenSaver;
83
+ }
84
+ // ── Lifecycle ──────────────────────────────────────────────────
85
+ async connect(config) {
86
+ this.config = config;
87
+ this.client = await createOAuth2Client(config.oauth);
88
+ this.tokens = await loadTokens(this.tokenLoader, config.account);
89
+ if (!this.tokens) {
90
+ throw new Error(
91
+ `No stored tokens for account ${config.account}. Complete the OAuth flow first.`
92
+ );
93
+ }
94
+ this.tokens = await refreshIfNeeded(
95
+ this.client,
96
+ this.tokens,
97
+ async (t) => saveTokens(this.tokenSaver, config.account, t)
98
+ );
99
+ this.client.setCredentials(this.tokens);
100
+ const { google } = await import("googleapis");
101
+ this.gmail = google.gmail({ version: "v1", auth: this.client });
102
+ }
103
+ async disconnect() {
104
+ this.client = null;
105
+ this.gmail = null;
106
+ this.tokens = null;
107
+ this.config = null;
108
+ }
109
+ async healthCheck() {
110
+ try {
111
+ this.ensureConnected();
112
+ const res = await this.gmail.users.getProfile({ userId: "me" });
113
+ return { ok: true, account: res.data.emailAddress };
114
+ } catch (err) {
115
+ return { ok: false, error: errorMessage(err) };
116
+ }
117
+ }
118
+ // ── Sync ───────────────────────────────────────────────────────
119
+ async sync(options) {
120
+ this.ensureConnected();
121
+ if (options?.cursor) {
122
+ return this.syncIncremental(options.cursor, options.limit);
123
+ }
124
+ return this.syncFull(options);
125
+ }
126
+ /** Incremental sync using Gmail history API. */
127
+ async syncIncremental(startHistoryId, limit) {
128
+ const records = [];
129
+ const errors = [];
130
+ const seenIds = /* @__PURE__ */ new Set();
131
+ let pageToken;
132
+ let latestHistoryId = startHistoryId;
133
+ do {
134
+ const res = await this.gmail.users.history.list({
135
+ userId: "me",
136
+ startHistoryId,
137
+ historyTypes: ["messageAdded"],
138
+ ...pageToken ? { pageToken } : {}
139
+ });
140
+ latestHistoryId = res.data.historyId ?? latestHistoryId;
141
+ const histories = res.data.history ?? [];
142
+ for (const h of histories) {
143
+ for (const added of h.messagesAdded ?? []) {
144
+ const msgId = added.message?.id;
145
+ if (!msgId || seenIds.has(msgId)) continue;
146
+ seenIds.add(msgId);
147
+ try {
148
+ const record = await this.fetchMessage(msgId);
149
+ records.push(record);
150
+ } catch (err) {
151
+ errors.push({ id: msgId, error: errorMessage(err) });
152
+ }
153
+ if (limit && records.length >= limit) {
154
+ return { records, cursor: latestHistoryId, hasMore: true, errors };
155
+ }
156
+ }
157
+ }
158
+ pageToken = res.data.nextPageToken ?? void 0;
159
+ } while (pageToken);
160
+ return { records, cursor: latestHistoryId, hasMore: false, errors };
161
+ }
162
+ /** Full sync — list messages and fetch each one. */
163
+ async syncFull(options) {
164
+ const records = [];
165
+ const errors = [];
166
+ const maxResults = options?.limit ?? 100;
167
+ let query = "";
168
+ if (options?.since) {
169
+ const epoch = Math.floor(new Date(options.since).getTime() / 1e3);
170
+ query = `after:${epoch}`;
171
+ }
172
+ if (options?.filters?.q) {
173
+ query = query ? `${query} ${options.filters.q}` : String(options.filters.q);
174
+ }
175
+ let pageToken;
176
+ let collected = 0;
177
+ do {
178
+ const res = await this.gmail.users.messages.list({
179
+ userId: "me",
180
+ maxResults: Math.min(maxResults - collected, 100),
181
+ ...query ? { q: query } : {},
182
+ ...pageToken ? { pageToken } : {}
183
+ });
184
+ const messages = res.data.messages ?? [];
185
+ for (const msg of messages) {
186
+ try {
187
+ const record = await this.fetchMessage(msg.id);
188
+ records.push(record);
189
+ } catch (err) {
190
+ errors.push({ id: msg.id, error: errorMessage(err) });
191
+ }
192
+ collected++;
193
+ if (collected >= maxResults) break;
194
+ }
195
+ pageToken = res.data.nextPageToken ?? void 0;
196
+ } while (pageToken && collected < maxResults);
197
+ const profile = await this.gmail.users.getProfile({ userId: "me" });
198
+ const cursor = profile.data.historyId ?? void 0;
199
+ return {
200
+ records,
201
+ cursor,
202
+ hasMore: !!pageToken,
203
+ errors
204
+ };
205
+ }
206
+ // ── Push (send email) ─────────────────────────────────────────
207
+ async push(payload) {
208
+ this.ensureConnected();
209
+ try {
210
+ const toHeader = payload.to.map(formatAddress).join(", ");
211
+ const ccHeader = payload.cc.length ? `Cc: ${payload.cc.map(formatAddress).join(", ")}\r
212
+ ` : "";
213
+ const bccHeader = payload.bcc.length ? `Bcc: ${payload.bcc.map(formatAddress).join(", ")}\r
214
+ ` : "";
215
+ const mime = [
216
+ `To: ${toHeader}\r
217
+ `,
218
+ ccHeader,
219
+ bccHeader,
220
+ `Subject: ${payload.subject}\r
221
+ `,
222
+ `Content-Type: text/plain; charset="UTF-8"\r
223
+ `,
224
+ `\r
225
+ `,
226
+ payload.body ?? ""
227
+ ].join("");
228
+ const encoded = Buffer.from(mime).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
229
+ const res = await this.gmail.users.messages.send({
230
+ userId: "me",
231
+ requestBody: { raw: encoded }
232
+ });
233
+ return { success: true, externalId: res.data.id };
234
+ } catch (err) {
235
+ return { success: false, error: errorMessage(err) };
236
+ }
237
+ }
238
+ // ── Internals ─────────────────────────────────────────────────
239
+ ensureConnected() {
240
+ if (!this.gmail || !this.config) {
241
+ throw new Error("GoogleGmailConnector is not connected. Call connect() first.");
242
+ }
243
+ }
244
+ /** Fetch a single message by ID and parse into an EmailRecord. */
245
+ async fetchMessage(messageId) {
246
+ const res = await this.gmail.users.messages.get({
247
+ userId: "me",
248
+ id: messageId,
249
+ format: "metadata",
250
+ metadataHeaders: ["From", "To", "Cc", "Bcc", "Subject", "Date"]
251
+ });
252
+ const msg = res.data;
253
+ const headers = msg.payload?.headers ?? [];
254
+ const getHeader = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
255
+ return {
256
+ gmailId: msg.id,
257
+ threadId: msg.threadId,
258
+ account: this.config.account,
259
+ subject: getHeader("Subject"),
260
+ from: parseAddress(getHeader("From")),
261
+ to: parseAddressList(getHeader("To")),
262
+ cc: parseAddressList(getHeader("Cc")),
263
+ bcc: parseAddressList(getHeader("Bcc")),
264
+ date: new Date(getHeader("Date")).toISOString(),
265
+ snippet: msg.snippet ?? "",
266
+ labels: msg.labelIds ?? [],
267
+ isRead: !(msg.labelIds ?? []).includes("UNREAD")
268
+ };
269
+ }
270
+ };
271
+ function parseAddress(raw) {
272
+ const match = raw.match(/^(.+?)\s*<([^>]+)>$/);
273
+ if (match) {
274
+ return { name: match[1].replace(/^["']|["']$/g, "").trim(), email: match[2] };
275
+ }
276
+ return { email: raw.trim() };
277
+ }
278
+ function parseAddressList(raw) {
279
+ if (!raw.trim()) return [];
280
+ const results = [];
281
+ const parts = raw.split(/,(?=(?:[^<]*<[^>]*>)*[^>]*$)/);
282
+ for (const part of parts) {
283
+ const trimmed = part.trim();
284
+ if (trimmed) results.push(parseAddress(trimmed));
285
+ }
286
+ return results;
287
+ }
288
+ function formatAddress(addr) {
289
+ return addr.name ? `${addr.name} <${addr.email}>` : addr.email;
290
+ }
291
+ function errorMessage(err) {
292
+ return err instanceof Error ? err.message : String(err);
293
+ }
294
+
295
+ // src/connectors/google/calendar-connector.ts
296
+ var GoogleCalendarConnector = class {
297
+ id = "google-calendar";
298
+ meta = {
299
+ displayName: "Google Calendar",
300
+ provider: "google",
301
+ dataType: "calendar"
302
+ };
303
+ tokenLoader;
304
+ tokenSaver;
305
+ client = null;
306
+ config = null;
307
+ tokens = null;
308
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
309
+ calendar = null;
310
+ constructor(opts) {
311
+ this.tokenLoader = opts.tokenLoader;
312
+ this.tokenSaver = opts.tokenSaver;
313
+ }
314
+ // ── Lifecycle ──────────────────────────────────────────────────
315
+ async connect(config) {
316
+ this.config = config;
317
+ this.client = await createOAuth2Client(config.oauth);
318
+ this.tokens = await loadTokens(this.tokenLoader, config.account);
319
+ if (!this.tokens) {
320
+ throw new Error(
321
+ `No stored tokens for account ${config.account}. Complete the OAuth flow first.`
322
+ );
323
+ }
324
+ this.tokens = await refreshIfNeeded(
325
+ this.client,
326
+ this.tokens,
327
+ async (t) => saveTokens(this.tokenSaver, config.account, t)
328
+ );
329
+ this.client.setCredentials(this.tokens);
330
+ const { google } = await import("googleapis");
331
+ this.calendar = google.calendar({ version: "v3", auth: this.client });
332
+ }
333
+ async disconnect() {
334
+ this.client = null;
335
+ this.calendar = null;
336
+ this.tokens = null;
337
+ this.config = null;
338
+ }
339
+ async healthCheck() {
340
+ try {
341
+ this.ensureConnected();
342
+ const res = await this.calendar.calendarList.list({ maxResults: 1 });
343
+ const primary = (res.data.items ?? []).find(
344
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
345
+ (c) => c.primary
346
+ );
347
+ return { ok: true, account: primary?.id ?? this.config.account };
348
+ } catch (err) {
349
+ return { ok: false, error: errorMessage2(err) };
350
+ }
351
+ }
352
+ // ── Sync ───────────────────────────────────────────────────────
353
+ async sync(options) {
354
+ this.ensureConnected();
355
+ if (options?.cursor) {
356
+ return this.syncIncremental(options.cursor, options);
357
+ }
358
+ return this.syncFull(options);
359
+ }
360
+ /** Incremental sync using Calendar syncToken. */
361
+ async syncIncremental(syncToken, options) {
362
+ const calendarId = options?.filters?.calendarId ?? "primary";
363
+ const records = [];
364
+ const errors = [];
365
+ let pageToken;
366
+ let nextSyncToken;
367
+ try {
368
+ do {
369
+ const res = await this.calendar.events.list({
370
+ calendarId,
371
+ syncToken,
372
+ ...pageToken ? { pageToken } : {},
373
+ maxResults: options?.limit ? Math.min(options.limit - records.length, 250) : 250
374
+ });
375
+ for (const event of res.data.items ?? []) {
376
+ try {
377
+ records.push(this.mapEvent(event, calendarId));
378
+ } catch (err) {
379
+ errors.push({ id: event.id, error: errorMessage2(err) });
380
+ }
381
+ if (options?.limit && records.length >= options.limit) break;
382
+ }
383
+ pageToken = res.data.nextPageToken ?? void 0;
384
+ nextSyncToken = res.data.nextSyncToken ?? void 0;
385
+ } while (pageToken && (!options?.limit || records.length < options.limit));
386
+ } catch (err) {
387
+ if (err?.code === 410) {
388
+ return this.syncFull(options);
389
+ }
390
+ throw err;
391
+ }
392
+ return {
393
+ records,
394
+ cursor: nextSyncToken,
395
+ hasMore: !!pageToken,
396
+ errors
397
+ };
398
+ }
399
+ /** Full sync using timeMin. */
400
+ async syncFull(options) {
401
+ const calendarId = options?.filters?.calendarId ?? "primary";
402
+ const records = [];
403
+ const errors = [];
404
+ const maxResults = options?.limit ?? 250;
405
+ const timeMin = options?.since ? new Date(options.since).toISOString() : new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3).toISOString();
406
+ let pageToken;
407
+ let nextSyncToken;
408
+ do {
409
+ const res = await this.calendar.events.list({
410
+ calendarId,
411
+ timeMin,
412
+ singleEvents: true,
413
+ orderBy: "startTime",
414
+ maxResults: Math.min(maxResults - records.length, 250),
415
+ ...pageToken ? { pageToken } : {}
416
+ });
417
+ for (const event of res.data.items ?? []) {
418
+ try {
419
+ records.push(this.mapEvent(event, calendarId));
420
+ } catch (err) {
421
+ errors.push({ id: event.id, error: errorMessage2(err) });
422
+ }
423
+ if (records.length >= maxResults) break;
424
+ }
425
+ pageToken = res.data.nextPageToken ?? void 0;
426
+ nextSyncToken = res.data.nextSyncToken ?? void 0;
427
+ } while (pageToken && records.length < maxResults);
428
+ return {
429
+ records,
430
+ cursor: nextSyncToken,
431
+ hasMore: !!pageToken,
432
+ errors
433
+ };
434
+ }
435
+ // ── Internals ─────────────────────────────────────────────────
436
+ ensureConnected() {
437
+ if (!this.calendar || !this.config) {
438
+ throw new Error("GoogleCalendarConnector is not connected. Call connect() first.");
439
+ }
440
+ }
441
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
442
+ mapEvent(event, calendarId) {
443
+ const start = event.start ?? {};
444
+ const end = event.end ?? {};
445
+ const allDay = !!start.date;
446
+ const startAt = allDay ? new Date(start.date).toISOString() : new Date(start.dateTime).toISOString();
447
+ const endAt = allDay ? new Date(end.date).toISOString() : new Date(end.dateTime).toISOString();
448
+ const attendees = (event.attendees ?? []).map(
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
+ (a) => ({
451
+ email: a.email,
452
+ displayName: a.displayName,
453
+ responseStatus: a.responseStatus
454
+ })
455
+ );
456
+ return {
457
+ googleEventId: event.id,
458
+ calendarId,
459
+ account: this.config.account,
460
+ title: event.summary ?? "(No title)",
461
+ description: event.description ?? void 0,
462
+ location: event.location ?? void 0,
463
+ startAt,
464
+ endAt,
465
+ allDay,
466
+ timezone: start.timeZone ?? void 0,
467
+ status: event.status ?? "confirmed",
468
+ organizerEmail: event.organizer?.email ?? "",
469
+ attendees,
470
+ recurrence: event.recurrence ?? void 0,
471
+ htmlLink: event.htmlLink ?? void 0
472
+ };
473
+ }
474
+ };
475
+ function errorMessage2(err) {
476
+ return err instanceof Error ? err.message : String(err);
477
+ }
478
+ export {
479
+ GoogleCalendarConnector,
480
+ GoogleGmailConnector,
481
+ createOAuth2Client,
482
+ exchangeCode,
483
+ getAuthUrl,
484
+ loadTokens,
485
+ refreshIfNeeded,
486
+ saveTokens
487
+ };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import { C as ChannelAdapter, H as HealthStatus, I as InboundMessage } from './c
2
2
  export { A as Attachment, a as ChannelCapabilities, b as ChannelConfig, c as ChannelMeta, d as ChatType, F as FormattingMode, O as OutboundPayload, S as SendResult } from './channel-06G0vbIn.js';
3
3
  import { T as TokenUsage, L as LLMProvider, M as ModelInfo, R as ResolvedModel, C as ChatMessage } from './provider-qqJYv9nv.js';
4
4
  export { a as ChatParams, b as ChatResult, c as ContentBlock, d as ToolDefinition, e as ToolUse } from './provider-qqJYv9nv.js';
5
+ import { C as ConnectorConfig } from './connector-DDahQw-2.js';
6
+ export { a as Connector, b as ConnectorMeta, P as PushResult, S as SyncOptions, c as SyncResult } from './connector-DDahQw-2.js';
5
7
  import * as better_sqlite3 from 'better-sqlite3';
6
8
 
7
9
  /** Execution adapter types — Story 1.5 / 3.4 / 3.5 */
@@ -191,6 +193,11 @@ interface BotConfig {
191
193
  enabled: boolean;
192
194
  accounts?: Record<string, unknown>;
193
195
  } & Record<string, unknown>>;
196
+ connectors?: Record<string, {
197
+ enabled: boolean;
198
+ provider: string;
199
+ accounts?: Record<string, ConnectorConfig>;
200
+ } & Record<string, unknown>>;
194
201
  agents: AgentConfig[];
195
202
  providers: Record<string, {
196
203
  enabled: boolean;
@@ -1313,6 +1320,11 @@ declare class NdjsonLogger {
1313
1320
  close(): void;
1314
1321
  }
1315
1322
 
1323
+ /**
1324
+ * @deprecated Use {@link Scheduler} from `botinabox` instead.
1325
+ * HeartbeatScheduler uses in-memory setInterval which loses state on restart.
1326
+ * The Scheduler class uses database-backed schedules with cron expressions.
1327
+ */
1316
1328
  declare class HeartbeatScheduler {
1317
1329
  private wakeupQueue;
1318
1330
  private hooks;
@@ -1325,6 +1337,76 @@ declare class HeartbeatScheduler {
1325
1337
  stop(): void;
1326
1338
  }
1327
1339
 
1340
+ /**
1341
+ * Scheduler — database-backed job scheduling with cron expressions.
1342
+ *
1343
+ * Supports one-time and recurring schedules. When a schedule fires,
1344
+ * it emits the schedule's `action` as a hook event with the
1345
+ * `action_config` payload. Consumers subscribe to handle the action.
1346
+ *
1347
+ * Replaces HeartbeatScheduler for recurring use cases.
1348
+ */
1349
+
1350
+ interface ScheduleDef {
1351
+ name: string;
1352
+ description?: string;
1353
+ /** Cron expression for recurring schedules */
1354
+ cron?: string;
1355
+ /** ISO 8601 datetime for one-time schedules */
1356
+ runAt?: string;
1357
+ /** Hook event name to emit when fired */
1358
+ action: string;
1359
+ /** JSON-serializable payload passed to the hook */
1360
+ actionConfig?: Record<string, unknown>;
1361
+ timezone?: string;
1362
+ }
1363
+ interface Schedule {
1364
+ id: string;
1365
+ name: string;
1366
+ description: string | null;
1367
+ type: "one_time" | "recurring";
1368
+ cron: string | null;
1369
+ run_at: string | null;
1370
+ timezone: string;
1371
+ enabled: number;
1372
+ action: string;
1373
+ action_config: string;
1374
+ last_fired_at: string | null;
1375
+ next_fire_at: string | null;
1376
+ created_at: string;
1377
+ updated_at: string;
1378
+ deleted_at: string | null;
1379
+ }
1380
+ declare class Scheduler {
1381
+ private db;
1382
+ private hooks;
1383
+ private timer;
1384
+ constructor(db: DataStore, hooks: HookBus);
1385
+ /**
1386
+ * Start the scheduler. Computes initial next_fire_at for schedules
1387
+ * that don't have one, then polls for due schedules.
1388
+ */
1389
+ start(pollIntervalMs?: number): Promise<void>;
1390
+ stop(): void;
1391
+ /** Check for and fire due schedules. */
1392
+ tick(): Promise<void>;
1393
+ /** Register a new schedule. */
1394
+ register(def: ScheduleDef): Promise<string>;
1395
+ /** Update an existing schedule. */
1396
+ update(id: string, changes: Partial<Pick<ScheduleDef, "name" | "cron" | "runAt" | "action" | "actionConfig" | "timezone" | "description">> & {
1397
+ enabled?: boolean;
1398
+ }): Promise<void>;
1399
+ /** Soft-delete a schedule. */
1400
+ unregister(id: string): Promise<void>;
1401
+ /** List schedules, optionally filtered. */
1402
+ list(filter?: {
1403
+ enabled?: boolean;
1404
+ action?: string;
1405
+ }): Promise<Schedule[]>;
1406
+ /** Compute next_fire_at for any enabled schedule missing it. */
1407
+ private initializeNextFireTimes;
1408
+ }
1409
+
1328
1410
  /**
1329
1411
  * Creates a config revision record with before/after snapshots.
1330
1412
  * Note: uses config_revisions table with config_yaml storing JSON of {agentId, before, after}.
@@ -1485,4 +1567,4 @@ declare function isLoginRequired(stdout: string): boolean;
1485
1567
  /** Rewrite local image paths to prevent CLI auto-embedding as vision content. */
1486
1568
  declare function deactivateLocalImagePaths(prompt: string): string;
1487
1569
 
1488
- export { AGENT_STATUSES, type AgentConfig, type AgentDefinition, type AgentFilter, type AgentRecord, AgentRegistry, type AgentStatus, ApiExecutionAdapter, AuditEmitter, type AuditEvent, BackupManager, type BotConfig, type BudgetCheck, type BudgetConfig, BudgetController, CORE_MIGRATIONS, ChannelAdapter, ChannelRegistry, ChannelRegistryError, ChatMessage, ChatSessionManager, CliExecutionAdapter, type ColumnValidator, ColumnValidatorImpl, type ConfigLoadError, type ConfigLoadResult, DEFAULTS, DEFAULT_CONFIG, type DataConfig, DataStore, DataStoreError, type DomainEntityContextOptions, type DomainSchemaOptions, EVENTS, type EntityColumnDef, type EntityConfig, type EntityContextDef, type EntityFileSpec, type EntitySource, type ExecutionAdapter, type Filter, HealthStatus, HeartbeatScheduler, HookBus, type HookHandler, type HookOptions, type HookRegistration, InboundMessage, LLMProvider, MAX_CHAIN_DEPTH, MessagePipeline, type ModelConfig, ModelInfo, ModelRouter, NdjsonLogger, NotificationQueue, type PackageMigration, type PackageUpdate, type ParsedStream, type PkLookup, ProviderRegistry, type QueryOptions, RUN_STATUSES, type RelationDef, type RenderConfig, ResolvedModel, type RetryPolicy, type Row, type RunContext, RunManager, type RunResult, type RunStatus, type SanitizerOptions, type SchemaError, type SecretInput, type SecretMeta, SecretStore, type SecurityConfig, type SeedItem, SessionKey, SessionManager, type SqliteAdapter, type StepRef, TASK_STATUSES, type TableDefinition, type TableInfoRow, type TaskDefinition, TaskQueue, type TaskRecord, type TaskStatus, TokenUsage, type Unsubscribe, UpdateChecker, type UpdateConfig, UpdateManager, type UpdateManifest, type UsageSummary, type User, type UserInput, UserRegistry, WakeupQueue, type WorkflowConfigEntry, type WorkflowDefinition$1 as WorkflowDefinition, WorkflowEngine, type WorkflowRunRecord, type WorkflowRunStatus, type WorkflowStep$1 as WorkflowStep, type WorkflowStepConfig, type WorkflowTrigger, _resetConfig, areDependenciesMet, buildAgentBindings, buildChainOrigin, buildProcessEnv, checkAllowlist, checkChainDepth, checkMentionGate, chunkText, classifyUpdate, compareVersions, createConfigRevision, deactivateLocalImagePaths, defineCoreEntityContexts, defineCoreTables, defineDomainEntityContexts, defineDomainTables, detectCycle, discoverChannels, discoverProviders, formatText, getConfig, initConfig, interpolate, interpolateEnv, isLoginRequired, isMaxTurns, loadConfig, parseClaudeStream, parseVersion, runPackageMigrations, sanitize, topologicalSort, validateConfig };
1570
+ export { AGENT_STATUSES, type AgentConfig, type AgentDefinition, type AgentFilter, type AgentRecord, AgentRegistry, type AgentStatus, ApiExecutionAdapter, AuditEmitter, type AuditEvent, BackupManager, type BotConfig, type BudgetCheck, type BudgetConfig, BudgetController, CORE_MIGRATIONS, ChannelAdapter, ChannelRegistry, ChannelRegistryError, ChatMessage, ChatSessionManager, CliExecutionAdapter, type ColumnValidator, ColumnValidatorImpl, type ConfigLoadError, type ConfigLoadResult, ConnectorConfig, DEFAULTS, DEFAULT_CONFIG, type DataConfig, DataStore, DataStoreError, type DomainEntityContextOptions, type DomainSchemaOptions, EVENTS, type EntityColumnDef, type EntityConfig, type EntityContextDef, type EntityFileSpec, type EntitySource, type ExecutionAdapter, type Filter, HealthStatus, HeartbeatScheduler, HookBus, type HookHandler, type HookOptions, type HookRegistration, InboundMessage, LLMProvider, MAX_CHAIN_DEPTH, MessagePipeline, type ModelConfig, ModelInfo, ModelRouter, NdjsonLogger, NotificationQueue, type PackageMigration, type PackageUpdate, type ParsedStream, type PkLookup, ProviderRegistry, type QueryOptions, RUN_STATUSES, type RelationDef, type RenderConfig, ResolvedModel, type RetryPolicy, type Row, type RunContext, RunManager, type RunResult, type RunStatus, type SanitizerOptions, type Schedule, type ScheduleDef, Scheduler, type SchemaError, type SecretInput, type SecretMeta, SecretStore, type SecurityConfig, type SeedItem, SessionKey, SessionManager, type SqliteAdapter, type StepRef, TASK_STATUSES, type TableDefinition, type TableInfoRow, type TaskDefinition, TaskQueue, type TaskRecord, type TaskStatus, TokenUsage, type Unsubscribe, UpdateChecker, type UpdateConfig, UpdateManager, type UpdateManifest, type UsageSummary, type User, type UserInput, UserRegistry, WakeupQueue, type WorkflowConfigEntry, type WorkflowDefinition$1 as WorkflowDefinition, WorkflowEngine, type WorkflowRunRecord, type WorkflowRunStatus, type WorkflowStep$1 as WorkflowStep, type WorkflowStepConfig, type WorkflowTrigger, _resetConfig, areDependenciesMet, buildAgentBindings, buildChainOrigin, buildProcessEnv, checkAllowlist, checkChainDepth, checkMentionGate, chunkText, classifyUpdate, compareVersions, createConfigRevision, deactivateLocalImagePaths, defineCoreEntityContexts, defineCoreTables, defineDomainEntityContexts, defineDomainTables, detectCycle, discoverChannels, discoverProviders, formatText, getConfig, initConfig, interpolate, interpolateEnv, isLoginRequired, isMaxTurns, loadConfig, parseClaudeStream, parseVersion, runPackageMigrations, sanitize, topologicalSort, validateConfig };
package/dist/index.js CHANGED
@@ -1580,6 +1580,29 @@ function defineCoreTables(db) {
1580
1580
  "FOREIGN KEY (user_id) REFERENCES users(id)"
1581
1581
  ]
1582
1582
  });
1583
+ db.define("schedules", {
1584
+ columns: {
1585
+ id: "TEXT PRIMARY KEY",
1586
+ name: "TEXT NOT NULL",
1587
+ description: "TEXT",
1588
+ type: "TEXT NOT NULL DEFAULT 'recurring'",
1589
+ cron: "TEXT",
1590
+ run_at: "TEXT",
1591
+ timezone: "TEXT DEFAULT 'UTC'",
1592
+ enabled: "INTEGER NOT NULL DEFAULT 1",
1593
+ action: "TEXT NOT NULL",
1594
+ action_config: "TEXT DEFAULT '{}'",
1595
+ last_fired_at: "TEXT",
1596
+ next_fire_at: "TEXT",
1597
+ created_at: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
1598
+ updated_at: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
1599
+ deleted_at: "TEXT"
1600
+ },
1601
+ tableConstraints: [
1602
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_name ON schedules(name) WHERE deleted_at IS NULL",
1603
+ "CREATE INDEX IF NOT EXISTS idx_schedules_next ON schedules(enabled, next_fire_at) WHERE deleted_at IS NULL"
1604
+ ]
1605
+ });
1583
1606
  db.define("secrets", {
1584
1607
  columns: {
1585
1608
  id: "TEXT PRIMARY KEY",
@@ -1618,6 +1641,34 @@ var CORE_MIGRATIONS = [
1618
1641
  {
1619
1642
  version: "003_runs_cost_index",
1620
1643
  sql: `CREATE INDEX IF NOT EXISTS idx_runs_cost ON runs(agent_id, completed_at) WHERE cost_cents > 0;`
1644
+ },
1645
+ {
1646
+ version: "004_schedules_table",
1647
+ sql: `CREATE TABLE IF NOT EXISTS schedules (
1648
+ id TEXT PRIMARY KEY,
1649
+ name TEXT NOT NULL,
1650
+ description TEXT,
1651
+ type TEXT NOT NULL DEFAULT 'recurring',
1652
+ cron TEXT,
1653
+ run_at TEXT,
1654
+ timezone TEXT DEFAULT 'UTC',
1655
+ enabled INTEGER NOT NULL DEFAULT 1,
1656
+ action TEXT NOT NULL,
1657
+ action_config TEXT DEFAULT '{}',
1658
+ last_fired_at TEXT,
1659
+ next_fire_at TEXT,
1660
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
1661
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
1662
+ deleted_at TEXT
1663
+ )`
1664
+ },
1665
+ {
1666
+ version: "005_schedules_name_index",
1667
+ sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_name ON schedules(name) WHERE deleted_at IS NULL`
1668
+ },
1669
+ {
1670
+ version: "006_schedules_next_index",
1671
+ sql: `CREATE INDEX IF NOT EXISTS idx_schedules_next ON schedules(enabled, next_fire_at) WHERE deleted_at IS NULL`
1621
1672
  }
1622
1673
  ];
1623
1674
 
@@ -3387,6 +3438,194 @@ var HeartbeatScheduler = class {
3387
3438
  }
3388
3439
  };
3389
3440
 
3441
+ // src/core/orchestrator/scheduler.ts
3442
+ import cronParser from "cron-parser";
3443
+ import { v4 as uuid } from "uuid";
3444
+ function computeNextFire(cron, timezone, after) {
3445
+ const interval = cronParser.parseExpression(cron, {
3446
+ currentDate: after ?? /* @__PURE__ */ new Date(),
3447
+ tz: timezone
3448
+ });
3449
+ return interval.next().toISOString();
3450
+ }
3451
+ var Scheduler = class {
3452
+ constructor(db, hooks) {
3453
+ this.db = db;
3454
+ this.hooks = hooks;
3455
+ }
3456
+ db;
3457
+ hooks;
3458
+ timer = null;
3459
+ /**
3460
+ * Start the scheduler. Computes initial next_fire_at for schedules
3461
+ * that don't have one, then polls for due schedules.
3462
+ */
3463
+ async start(pollIntervalMs = 3e4) {
3464
+ await this.initializeNextFireTimes();
3465
+ this.timer = setInterval(() => {
3466
+ void this.tick();
3467
+ }, pollIntervalMs);
3468
+ void this.tick();
3469
+ }
3470
+ stop() {
3471
+ if (this.timer) {
3472
+ clearInterval(this.timer);
3473
+ this.timer = null;
3474
+ }
3475
+ }
3476
+ /** Check for and fire due schedules. */
3477
+ async tick() {
3478
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3479
+ const schedules = (await this.db.query("schedules", {
3480
+ where: { enabled: 1 }
3481
+ })).filter(
3482
+ (s) => s["deleted_at"] == null && s["next_fire_at"] != null && s["next_fire_at"] <= now
3483
+ );
3484
+ for (const schedule of schedules) {
3485
+ try {
3486
+ const config = JSON.parse(schedule.action_config || "{}");
3487
+ await this.hooks.emit(schedule.action, {
3488
+ schedule_id: schedule.id,
3489
+ schedule_name: schedule.name,
3490
+ ...config
3491
+ });
3492
+ await this.hooks.emit("schedule.fired", {
3493
+ schedule_id: schedule.id,
3494
+ schedule_name: schedule.name,
3495
+ action: schedule.action,
3496
+ fired_at: now
3497
+ });
3498
+ if (schedule.type === "recurring" && schedule.cron) {
3499
+ const nextFire = computeNextFire(
3500
+ schedule.cron,
3501
+ schedule.timezone,
3502
+ /* @__PURE__ */ new Date()
3503
+ );
3504
+ await this.db.update(
3505
+ "schedules",
3506
+ { id: schedule.id },
3507
+ {
3508
+ last_fired_at: now,
3509
+ next_fire_at: nextFire,
3510
+ updated_at: now
3511
+ }
3512
+ );
3513
+ } else {
3514
+ await this.db.update(
3515
+ "schedules",
3516
+ { id: schedule.id },
3517
+ {
3518
+ last_fired_at: now,
3519
+ next_fire_at: null,
3520
+ enabled: 0,
3521
+ updated_at: now
3522
+ }
3523
+ );
3524
+ }
3525
+ } catch (err) {
3526
+ console.error(
3527
+ `[Scheduler] Error firing schedule "${schedule.name}":`,
3528
+ err
3529
+ );
3530
+ await this.hooks.emit("schedule.error", {
3531
+ schedule_id: schedule.id,
3532
+ schedule_name: schedule.name,
3533
+ error: String(err)
3534
+ });
3535
+ }
3536
+ }
3537
+ }
3538
+ /** Register a new schedule. */
3539
+ async register(def) {
3540
+ const id = uuid();
3541
+ const type = def.cron ? "recurring" : "one_time";
3542
+ const timezone = def.timezone ?? "UTC";
3543
+ let nextFire = null;
3544
+ if (def.cron) {
3545
+ nextFire = computeNextFire(def.cron, timezone);
3546
+ } else if (def.runAt) {
3547
+ nextFire = new Date(def.runAt).toISOString();
3548
+ }
3549
+ await this.db.insert("schedules", {
3550
+ id,
3551
+ name: def.name,
3552
+ description: def.description ?? null,
3553
+ type,
3554
+ cron: def.cron ?? null,
3555
+ run_at: def.runAt ?? null,
3556
+ timezone,
3557
+ enabled: 1,
3558
+ action: def.action,
3559
+ action_config: JSON.stringify(def.actionConfig ?? {}),
3560
+ next_fire_at: nextFire
3561
+ });
3562
+ return id;
3563
+ }
3564
+ /** Update an existing schedule. */
3565
+ async update(id, changes) {
3566
+ const row = {
3567
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3568
+ };
3569
+ if (changes.name !== void 0) row["name"] = changes.name;
3570
+ if (changes.description !== void 0) row["description"] = changes.description;
3571
+ if (changes.action !== void 0) row["action"] = changes.action;
3572
+ if (changes.actionConfig !== void 0)
3573
+ row["action_config"] = JSON.stringify(changes.actionConfig);
3574
+ if (changes.enabled !== void 0) row["enabled"] = changes.enabled ? 1 : 0;
3575
+ if (changes.cron !== void 0) {
3576
+ row["cron"] = changes.cron;
3577
+ row["type"] = "recurring";
3578
+ row["run_at"] = null;
3579
+ row["next_fire_at"] = computeNextFire(
3580
+ changes.cron,
3581
+ changes.timezone ?? "UTC"
3582
+ );
3583
+ } else if (changes.runAt !== void 0) {
3584
+ row["run_at"] = changes.runAt;
3585
+ row["type"] = "one_time";
3586
+ row["cron"] = null;
3587
+ row["next_fire_at"] = new Date(changes.runAt).toISOString();
3588
+ }
3589
+ if (changes.timezone !== void 0) row["timezone"] = changes.timezone;
3590
+ await this.db.update("schedules", { id }, row);
3591
+ }
3592
+ /** Soft-delete a schedule. */
3593
+ async unregister(id) {
3594
+ await this.db.update("schedules", { id }, {
3595
+ enabled: 0,
3596
+ deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
3597
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3598
+ });
3599
+ }
3600
+ /** List schedules, optionally filtered. */
3601
+ async list(filter) {
3602
+ const where = {};
3603
+ if (filter?.enabled !== void 0) where["enabled"] = filter.enabled ? 1 : 0;
3604
+ if (filter?.action !== void 0) where["action"] = filter.action;
3605
+ return (await this.db.query("schedules", { where })).filter((s) => s["deleted_at"] == null);
3606
+ }
3607
+ /** Compute next_fire_at for any enabled schedule missing it. */
3608
+ async initializeNextFireTimes() {
3609
+ const schedules = (await this.db.query("schedules", { where: { enabled: 1 } })).filter(
3610
+ (s) => s["deleted_at"] == null && s["next_fire_at"] == null
3611
+ );
3612
+ for (const s of schedules) {
3613
+ let nextFire = null;
3614
+ if (s.type === "recurring" && s.cron) {
3615
+ nextFire = computeNextFire(s.cron, s.timezone);
3616
+ } else if (s.type === "one_time" && s.run_at) {
3617
+ nextFire = new Date(s.run_at).toISOString();
3618
+ }
3619
+ if (nextFire) {
3620
+ await this.db.update("schedules", { id: s.id }, {
3621
+ next_fire_at: nextFire,
3622
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3623
+ });
3624
+ }
3625
+ }
3626
+ }
3627
+ };
3628
+
3390
3629
  // src/core/orchestrator/adapters/api-adapter.ts
3391
3630
  import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
3392
3631
 
@@ -3963,6 +4202,7 @@ export {
3963
4202
  ProviderRegistry,
3964
4203
  RUN_STATUSES,
3965
4204
  RunManager,
4205
+ Scheduler,
3966
4206
  SecretStore,
3967
4207
  SessionKey,
3968
4208
  SessionManager,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botinabox",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Bot in a Box — framework for building multi-agent bots",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,6 +33,10 @@
33
33
  "./webhook": {
34
34
  "import": "./dist/channels/webhook/index.js",
35
35
  "types": "./dist/channels/webhook/index.d.ts"
36
+ },
37
+ "./google": {
38
+ "import": "./dist/connectors/google/index.js",
39
+ "types": "./dist/connectors/google/index.d.ts"
36
40
  }
37
41
  },
38
42
  "bin": {
@@ -46,12 +50,14 @@
46
50
  "dependencies": {
47
51
  "@types/uuid": "^10.0.0",
48
52
  "ajv": "^8.17.1",
53
+ "cron-parser": "^4.9.0",
49
54
  "latticesql": "^0.18.0",
50
55
  "uuid": "^13.0.0",
51
56
  "yaml": "^2.7.0"
52
57
  },
53
58
  "peerDependencies": {
54
59
  "@anthropic-ai/sdk": "^0.52.0",
60
+ "googleapis": ">=140.0.0",
55
61
  "openai": "^4.104.0"
56
62
  },
57
63
  "peerDependenciesMeta": {
@@ -60,12 +66,16 @@
60
66
  },
61
67
  "openai": {
62
68
  "optional": true
69
+ },
70
+ "googleapis": {
71
+ "optional": true
63
72
  }
64
73
  },
65
74
  "devDependencies": {
66
75
  "@anthropic-ai/sdk": "^0.52.0",
67
76
  "@types/better-sqlite3": "^7.6.12",
68
77
  "@types/node": "^22.10.0",
78
+ "googleapis": "^171.4.0",
69
79
  "openai": "^4.104.0",
70
80
  "tsup": "^8.3.5",
71
81
  "typescript": "^5.7.2",